├── testdata ├── empty_settings.json ├── permission_prompt.json ├── auth_success.json ├── idle_prompt.json ├── lmk_already_installed.json └── existing_settings.json ├── go.mod ├── .gitignore ├── .dockerignore ├── .claude └── settings.json ├── .travis.yml ├── Dockerfile ├── .github └── workflows │ ├── release.yml │ └── test.yml ├── LICENSE.txt ├── .goreleaser.yml ├── lmk_delay_test.go ├── docs ├── RELEASING.md └── releases │ └── v2.0.0.md ├── lmk_integration_test.go ├── Makefile ├── CHANGELOG.md ├── README.md ├── lmk_test.go ├── lmk_claudehooks_test.go └── lmk.go /testdata/empty_settings.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/fgrehm/lmk 2 | 3 | go 1.25.4 4 | -------------------------------------------------------------------------------- /testdata/permission_prompt.json: -------------------------------------------------------------------------------- 1 | {"notification_type":"permission_prompt","message":"Claude wants to run: npm install"} 2 | -------------------------------------------------------------------------------- /testdata/auth_success.json: -------------------------------------------------------------------------------- 1 | {"notification_type":"auth_success","message":"Successfully authenticated as user@example.com"} 2 | -------------------------------------------------------------------------------- /testdata/idle_prompt.json: -------------------------------------------------------------------------------- 1 | {"notification_type":"idle_prompt","message":"Claude has been waiting for your input for over 60 seconds"} 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /lmk 2 | lmk.exe 3 | /build/* 4 | /dist/* 5 | /vendor/ 6 | coverage.txt 7 | *.test 8 | *.out 9 | .claude/settings.local.json 10 | -------------------------------------------------------------------------------- /testdata/lmk_already_installed.json: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "Notification": [ 4 | { 5 | "hooks": [ 6 | { 7 | "type": "command", 8 | "command": "lmk claude-hooks" 9 | } 10 | ] 11 | } 12 | ] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /testdata/existing_settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "Notification": [ 4 | { 5 | "matcher": "other_type", 6 | "hooks": [ 7 | { 8 | "type": "command", 9 | "command": "some-other-tool" 10 | } 11 | ] 12 | } 13 | ] 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Build artifacts 2 | lmk 3 | build/ 4 | dist/ 5 | 6 | # Test coverage 7 | coverage.txt 8 | *.out 9 | 10 | # Git 11 | .git/ 12 | .gitignore 13 | 14 | # Documentation 15 | *.md 16 | LICENSE 17 | 18 | # IDE 19 | .vscode/ 20 | .idea/ 21 | *.swp 22 | *.swo 23 | *~ 24 | 25 | # OS files 26 | .DS_Store 27 | Thumbs.db 28 | -------------------------------------------------------------------------------- /.claude/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "includeCoAuthoredBy": false, 3 | "permissions": { 4 | "allow": [ 5 | "Bash(go version:*)", 6 | "Bash(go build:*)", 7 | "Bash(go mod vendor:*)", 8 | "Bash(./lmk -version:*)", 9 | "Bash(make build:*)", 10 | "Bash(go test:*)", 11 | "Bash(make xbuild:*)" 12 | ], 13 | "deny": [], 14 | "ask": [] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | go: 3 | - 1.12.1 4 | script: make xbuild 5 | deploy: 6 | provider: releases 7 | api_key: 8 | secure: DjCQ8z+9plW4ed6sJVCDMrtdS3zjp/amxNq/iEAu5XmkgrPfaSt5hzYofUNpBvRj1Z2+/vfbqncz3Kc6DonpKt1gv5s4RiIt6kh8qnk97SfvHyXBCjAFDPgFKOzdZacgZBIFpNTtscYsZOtIeoBPuw2oSAVVvXAFG4pz6FBwfKE= 9 | file: 10 | - 'build/linux_amd64' 11 | - 'build/darwin_amd64' 12 | on: 13 | tags: true 14 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Development/CI build image with full Go toolchain 2 | FROM docker.io/library/golang:1.25-alpine 3 | 4 | # Install build dependencies 5 | RUN apk add --no-cache git make curl ca-certificates 6 | 7 | # Install golangci-lint 8 | RUN curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b /usr/local/bin 9 | 10 | # Set working directory 11 | WORKDIR /build 12 | 13 | # Default command 14 | CMD ["/bin/sh"] 15 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 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 | 20 | - name: Set up Go 21 | uses: actions/setup-go@v5 22 | with: 23 | go-version: '1.23' 24 | 25 | - name: Run GoReleaser 26 | uses: goreleaser/goreleaser-action@v6 27 | with: 28 | distribution: goreleaser 29 | version: latest 30 | args: release --clean 31 | env: 32 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 33 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | test: 11 | strategy: 12 | matrix: 13 | os: [ubuntu-latest, macos-latest, windows-latest] 14 | go: ['1.23', '1.24'] 15 | runs-on: ${{ matrix.os }} 16 | 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | 21 | - name: Set up Go 22 | uses: actions/setup-go@v5 23 | with: 24 | go-version: ${{ matrix.go }} 25 | 26 | - name: Build 27 | run: go build -v ./... 28 | 29 | - name: Test 30 | run: go test -v -race -coverprofile=coverage.txt -covermode=atomic ./... 31 | 32 | - name: Upload coverage 33 | if: matrix.os == 'ubuntu-latest' && matrix.go == '1.23' 34 | uses: codecov/codecov-action@v4 35 | with: 36 | files: ./coverage.txt 37 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Fabio Rehm 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | before: 4 | hooks: 5 | - go mod tidy 6 | 7 | builds: 8 | - id: lmk 9 | main: ./ 10 | binary: lmk 11 | env: 12 | - CGO_ENABLED=0 13 | goos: 14 | - linux 15 | - darwin 16 | - windows 17 | goarch: 18 | - amd64 19 | - arm64 20 | ldflags: 21 | - -s -w 22 | - -X main.version={{.Version}} 23 | 24 | archives: 25 | - id: lmk 26 | format: tar.gz 27 | name_template: >- 28 | {{ .ProjectName }}_ 29 | {{- .Version }}_ 30 | {{- title .Os }}_ 31 | {{- if eq .Arch "amd64" }}x86_64 32 | {{- else if eq .Arch "386" }}i386 33 | {{- else }}{{ .Arch }}{{ end }} 34 | format_overrides: 35 | - goos: windows 36 | format: zip 37 | files: 38 | - README.md 39 | - LICENSE.txt 40 | 41 | checksum: 42 | name_template: 'checksums.txt' 43 | 44 | snapshot: 45 | version_template: "{{ incpatch .Version }}-next" 46 | 47 | changelog: 48 | sort: asc 49 | filters: 50 | exclude: 51 | - '^docs:' 52 | - '^test:' 53 | - '^chore:' 54 | - Merge pull request 55 | - Merge branch 56 | 57 | release: 58 | github: 59 | owner: fgrehm 60 | name: lmk 61 | draft: false 62 | prerelease: auto 63 | -------------------------------------------------------------------------------- /lmk_delay_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | func TestDialogDelay(t *testing.T) { 10 | // Enable dry-run to avoid showing dialogs 11 | os.Setenv("LMK_DRY_RUN", "1") 12 | defer os.Unsetenv("LMK_DRY_RUN") 13 | 14 | tests := []struct { 15 | name string 16 | envValue string 17 | expectDelay bool 18 | }{ 19 | {"default delay", "", false}, // dry-run exits early, no delay 20 | {"custom delay 2s", "2s", false}, // dry-run exits early 21 | {"no delay", "0s", false}, // dry-run exits early 22 | {"invalid delay", "invalid", false}, // dry-run exits early 23 | } 24 | 25 | for _, tt := range tests { 26 | t.Run(tt.name, func(t *testing.T) { 27 | if tt.envValue != "" { 28 | os.Setenv("LMK_DELAY", tt.envValue) 29 | defer os.Unsetenv("LMK_DELAY") 30 | } 31 | 32 | start := time.Now() 33 | showDialog("test message", false, false) 34 | elapsed := time.Since(start) 35 | 36 | // In dry-run mode, should return immediately 37 | if elapsed > 100*time.Millisecond { 38 | t.Errorf("Dry-run should be fast, took %v", elapsed) 39 | } 40 | }) 41 | } 42 | } 43 | 44 | func TestDelayParsing(t *testing.T) { 45 | // Test delay parsing without dry-run (we'll check the sleep happens) 46 | tests := []struct { 47 | name string 48 | envValue string 49 | minExpected time.Duration 50 | maxExpected time.Duration 51 | shouldErrMsg string 52 | }{ 53 | {"valid 100ms", "100ms", 90 * time.Millisecond, 150 * time.Millisecond, ""}, 54 | {"valid 1s", "1s", 950 * time.Millisecond, 1100 * time.Millisecond, ""}, 55 | } 56 | 57 | for _, tt := range tests { 58 | t.Run(tt.name, func(t *testing.T) { 59 | os.Setenv("LMK_DELAY", tt.envValue) 60 | defer os.Unsetenv("LMK_DELAY") 61 | 62 | // Parse the delay value (simulating showDialog logic) 63 | delay := 1500 * time.Millisecond // Default 64 | if delayStr := os.Getenv("LMK_DELAY"); delayStr != "" { 65 | if customDelay, err := time.ParseDuration(delayStr); err == nil { 66 | delay = customDelay 67 | } 68 | } 69 | 70 | // Verify parsed value 71 | if delay < tt.minExpected || delay > tt.maxExpected { 72 | t.Errorf("Parsed delay %v not in expected range [%v, %v]", 73 | delay, tt.minExpected, tt.maxExpected) 74 | } 75 | }) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /docs/RELEASING.md: -------------------------------------------------------------------------------- 1 | # Release Instructions 2 | 3 | This document contains instructions for releasing new versions of lmk. 4 | 5 | ## Pre-release Checklist 6 | 7 | - [ ] All tests passing (`make test`) 8 | - [ ] Documentation updated (README.md) 9 | - [ ] CHANGELOG.md updated with new version 10 | - [ ] Version number set in code (`lmk.go`) 11 | - [ ] README examples tested 12 | - [ ] Cross-compilation verified (`make xbuild`) 13 | - [ ] Travis CI removed (one-time cleanup for v2.0.0) 14 | 15 | ## Release Steps 16 | 17 | 1. **Commit all changes:** 18 | ```bash 19 | git add . 20 | git commit -m "feat: lmk vX.Y.Z - description of changes" 21 | ``` 22 | 23 | 2. **Create and push tag:** 24 | ```bash 25 | git tag vX.Y.Z 26 | git push origin master 27 | git push origin vX.Y.Z 28 | ``` 29 | 30 | 3. **GitHub Actions will automatically:** 31 | - Run tests on Linux, macOS, and Windows 32 | - Build binaries for 6 platforms (Linux/macOS/Windows × amd64/arm64) 33 | - Create GitHub Release 34 | - Upload binaries and checksums 35 | 36 | 4. **Post-release (if needed):** 37 | - Update Homebrew tap formula with new version and checksums 38 | - Announce release on relevant channels 39 | 40 | ## GitHub Actions Workflows 41 | 42 | ### Release Workflow 43 | - **Trigger:** Push tag matching `v*` 44 | - **File:** `.github/workflows/release.yml` 45 | - **Actions:** 46 | - Checkout code 47 | - Setup Go 1.23 48 | - Run GoReleaser 49 | - Create GitHub Release with binaries 50 | 51 | ### Test Workflow 52 | - **Trigger:** Push to master, pull requests 53 | - **File:** `.github/workflows/test.yml` 54 | - **Actions:** 55 | - Matrix test across OS (Linux/macOS/Windows) and Go versions (1.23, 1.24) 56 | - Run tests with race detection 57 | - Upload coverage to Codecov 58 | 59 | ## GoReleaser Configuration 60 | 61 | Configuration file: `.goreleaser.yml` 62 | 63 | **Builds:** 64 | - 6 platform combinations (Linux/macOS/Windows × amd64/arm64) 65 | - Stripped binaries (`-ldflags="-s -w"`) 66 | - Version injected via ldflags 67 | 68 | **Archives:** 69 | - tar.gz for Linux/macOS 70 | - zip for Windows 71 | - Includes README.md and LICENSE.txt 72 | 73 | ## Manual Build Testing 74 | 75 | Before tagging a release, test cross-compilation locally: 76 | 77 | ```bash 78 | # Build for all platforms 79 | make xbuild 80 | 81 | # Verify binaries were created 82 | ls -lh build/ 83 | 84 | # Test local binary 85 | ./lmk -version 86 | ./lmk -t 3s -m "Test" 87 | ``` 88 | 89 | ## Troubleshooting 90 | 91 | **Release failed:** 92 | - Check GitHub Actions logs for errors 93 | - Verify GoReleaser configuration is valid: `goreleaser check` 94 | - Ensure GITHUB_TOKEN has correct permissions 95 | 96 | **Binary size issues:** 97 | - Binaries should be ~2MB each 98 | - Use `go tool nm` to inspect symbols 99 | - Verify `-ldflags="-s -w"` is removing debug info 100 | 101 | **Missing platforms:** 102 | - Check `.goreleaser.yml` includes all desired GOOS/GOARCH combinations 103 | - Verify Go supports the target platform: `go tool dist list` 104 | -------------------------------------------------------------------------------- /lmk_integration_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "strings" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | func TestShowDialogDryRun(t *testing.T) { 12 | // Enable dry-run mode 13 | os.Setenv("LMK_DRY_RUN", "1") 14 | defer os.Unsetenv("LMK_DRY_RUN") 15 | 16 | tests := []struct { 17 | name string 18 | msg string 19 | isError bool 20 | }{ 21 | {"success message", "Command completed successfully", false}, 22 | {"error message", "Command failed with exit code 1", true}, 23 | {"timer message", "⏰ Pomodoro done!", false}, 24 | } 25 | 26 | for _, tt := range tests { 27 | t.Run(tt.name, func(t *testing.T) { 28 | // Capture stderr 29 | oldStderr := os.Stderr 30 | r, w, _ := os.Pipe() 31 | os.Stderr = w 32 | 33 | showDialog(tt.msg, tt.isError, false) 34 | 35 | w.Close() 36 | os.Stderr = oldStderr 37 | 38 | var buf bytes.Buffer 39 | buf.ReadFrom(r) 40 | output := buf.String() 41 | 42 | // Verify dry-run output 43 | if !strings.Contains(output, "[DRY RUN]") { 44 | t.Errorf("Expected [DRY RUN] in output, got: %s", output) 45 | } 46 | 47 | if !strings.Contains(output, tt.msg) { 48 | t.Errorf("Expected message %q in output, got: %s", tt.msg, output) 49 | } 50 | }) 51 | } 52 | } 53 | 54 | func TestShowNotificationAndWaitDryRun(t *testing.T) { 55 | // Enable dry-run mode 56 | os.Setenv("LMK_DRY_RUN", "1") 57 | defer os.Unsetenv("LMK_DRY_RUN") 58 | 59 | // Capture stderr 60 | oldStderr := os.Stderr 61 | r, w, _ := os.Pipe() 62 | os.Stderr = w 63 | 64 | showNotificationAndWait("Test notification", false) 65 | 66 | w.Close() 67 | os.Stderr = oldStderr 68 | 69 | var buf bytes.Buffer 70 | buf.ReadFrom(r) 71 | output := buf.String() 72 | 73 | // Verify dry-run output 74 | if !strings.Contains(output, "[DRY RUN]") { 75 | t.Errorf("Expected [DRY RUN] in output, got: %s", output) 76 | } 77 | 78 | if !strings.Contains(output, "Would send notification") { 79 | t.Errorf("Expected 'Would send notification' in output, got: %s", output) 80 | } 81 | 82 | if !strings.Contains(output, "Would wait for Enter") { 83 | t.Errorf("Expected 'Would wait for Enter' in output, got: %s", output) 84 | } 85 | } 86 | 87 | func TestTimerDryRun(t *testing.T) { 88 | // Enable dry-run mode 89 | os.Setenv("LMK_DRY_RUN", "1") 90 | defer os.Unsetenv("LMK_DRY_RUN") 91 | 92 | // Capture stderr 93 | oldStderr := os.Stderr 94 | r, w, _ := os.Pipe() 95 | os.Stderr = w 96 | 97 | // Run a short timer 98 | start := time.Now() 99 | runTimer("1s", "Test timer", false) 100 | elapsed := time.Since(start) 101 | 102 | w.Close() 103 | os.Stderr = oldStderr 104 | 105 | var buf bytes.Buffer 106 | buf.ReadFrom(r) 107 | output := buf.String() 108 | 109 | // Verify timer actually ran (not skipped in dry-run) 110 | if elapsed < 900*time.Millisecond { 111 | t.Errorf("Timer should still sleep in dry-run mode, but elapsed only %v", elapsed) 112 | } 113 | 114 | // Verify dry-run dialog output 115 | if !strings.Contains(output, "[DRY RUN]") { 116 | t.Errorf("Expected [DRY RUN] in output, got: %s", output) 117 | } 118 | 119 | if !strings.Contains(output, "Test timer") { 120 | t.Errorf("Expected timer message in output, got: %s", output) 121 | } 122 | } 123 | 124 | func TestCommandExecutionDryRunDoesNotAffectCommand(t *testing.T) { 125 | // Enable dry-run mode 126 | os.Setenv("LMK_DRY_RUN", "1") 127 | defer os.Unsetenv("LMK_DRY_RUN") 128 | 129 | // Commands should still execute normally, only dialogs are skipped 130 | err := run("echo", "test") 131 | if err != nil { 132 | t.Errorf("Commands should still execute in dry-run mode, got error: %v", err) 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: default build test clean install xbuild lint help docker-build docker-image local-build local-test local-xbuild local-lint 2 | 3 | # Docker configuration 4 | DOCKER_IMAGE := lmk-builder 5 | DOCKER_RUN := docker run --rm -v $(PWD):/build -w /build $(DOCKER_IMAGE) 6 | 7 | default: build 8 | 9 | help: 10 | @echo "lmk - Makefile targets" 11 | @echo "" 12 | @echo "Usage: make [target]" 13 | @echo "" 14 | @echo "Docker-based targets (default):" 15 | @echo " build Build lmk for current platform using Docker (default)" 16 | @echo " test Run all tests with race detection and coverage in Docker" 17 | @echo " xbuild Cross-compile for all platforms in Docker" 18 | @echo " lint Run golangci-lint in Docker" 19 | @echo " install Build and symlink lmk to ~/.local/bin" 20 | @echo "" 21 | @echo "Docker management:" 22 | @echo " docker-image Build the Docker image for compilation" 23 | @echo " docker-build Build lmk binary inside Docker and copy to host" 24 | @echo "" 25 | @echo "Local targets (no Docker):" 26 | @echo " local-build Build lmk for current platform locally" 27 | @echo " local-test Run tests locally" 28 | @echo " local-xbuild Cross-compile locally" 29 | @echo " local-lint Run golangci-lint locally" 30 | @echo " local-install Install to GOPATH/bin using go install" 31 | @echo "" 32 | @echo "Other:" 33 | @echo " clean Remove build artifacts and coverage files" 34 | @echo " help Show this help message" 35 | 36 | # Docker image builder 37 | docker-image: 38 | docker build -t $(DOCKER_IMAGE) . 39 | 40 | # Main build target using Docker 41 | build: docker-image 42 | $(DOCKER_RUN) go build -v -ldflags="-s -w" -buildvcs=false -o lmk . 43 | 44 | # Build Docker image with binary 45 | docker-build: 46 | docker build -t lmk:latest . 47 | 48 | # Local build (without Docker) 49 | local-build: 50 | go build -v -ldflags="-s -w" -o lmk . 51 | 52 | # Test using Docker 53 | test: docker-image 54 | $(DOCKER_RUN) go test -v -race -coverprofile=coverage.txt -covermode=atomic ./... 55 | 56 | # Local test (without Docker) 57 | local-test: 58 | go test -v -race -coverprofile=coverage.txt -covermode=atomic ./... 59 | 60 | clean: 61 | rm -rf lmk build/ dist/ coverage.txt 62 | 63 | # Install using Docker - builds binary and symlinks to ~/.local/bin 64 | install: build 65 | @mkdir -p ~/.local/bin 66 | @ln -sf $(PWD)/lmk ~/.local/bin/lmk 67 | @echo "Symlinked $(PWD)/lmk -> ~/.local/bin/lmk" 68 | @echo "Make sure ~/.local/bin is in your PATH" 69 | 70 | # Local install (without Docker) 71 | local-install: 72 | go install -v -ldflags="-s -w" . 73 | 74 | # Cross-compilation for multiple architectures using Docker 75 | xbuild: docker-image 76 | @mkdir -p build 77 | @echo "Building for Linux amd64..." 78 | $(DOCKER_RUN) sh -c "GOOS=linux GOARCH=amd64 go build -ldflags='-s -w' -buildvcs=false -o build/lmk-linux-amd64 ." 79 | @echo "Building for Linux arm64..." 80 | $(DOCKER_RUN) sh -c "GOOS=linux GOARCH=arm64 go build -ldflags='-s -w' -buildvcs=false -o build/lmk-linux-arm64 ." 81 | @echo "Building for macOS amd64..." 82 | $(DOCKER_RUN) sh -c "GOOS=darwin GOARCH=amd64 go build -ldflags='-s -w' -buildvcs=false -o build/lmk-darwin-amd64 ." 83 | @echo "Building for macOS arm64..." 84 | $(DOCKER_RUN) sh -c "GOOS=darwin GOARCH=arm64 go build -ldflags='-s -w' -buildvcs=false -o build/lmk-darwin-arm64 ." 85 | @echo "Building for Windows amd64..." 86 | $(DOCKER_RUN) sh -c "GOOS=windows GOARCH=amd64 go build -ldflags='-s -w' -buildvcs=false -o build/lmk-windows-amd64.exe ." 87 | @echo "Building for Windows arm64..." 88 | $(DOCKER_RUN) sh -c "GOOS=windows GOARCH=arm64 go build -ldflags='-s -w' -buildvcs=false -o build/lmk-windows-arm64.exe ." 89 | @echo 'DONE' 90 | 91 | # Local cross-compilation (without Docker) 92 | local-xbuild: 93 | @mkdir -p build 94 | @echo "Building for Linux amd64..." 95 | GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o build/lmk-linux-amd64 . 96 | @echo "Building for Linux arm64..." 97 | GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o build/lmk-linux-arm64 . 98 | @echo "Building for macOS amd64..." 99 | GOOS=darwin GOARCH=amd64 go build -ldflags="-s -w" -o build/lmk-darwin-amd64 . 100 | @echo "Building for macOS arm64..." 101 | GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w" -o build/lmk-darwin-arm64 . 102 | @echo "Building for Windows amd64..." 103 | GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -o build/lmk-windows-amd64.exe . 104 | @echo "Building for Windows arm64..." 105 | GOOS=windows GOARCH=arm64 go build -ldflags="-s -w" -o build/lmk-windows-arm64.exe . 106 | @echo 'DONE' 107 | 108 | # Lint using Docker 109 | lint: docker-image 110 | $(DOCKER_RUN) sh -c "which golangci-lint > /dev/null || (echo 'golangci-lint not installed in Docker image' && exit 1); golangci-lint run ./..." 111 | 112 | # Local lint (without Docker) 113 | local-lint: 114 | @which golangci-lint > /dev/null || (echo "golangci-lint not installed" && exit 1) 115 | golangci-lint run ./... 116 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [2.2.0] - 2025-11-26 9 | 10 | ### Added 11 | 12 | - **Ack mode** - `-a` / `--ack-mode` flag to require explicit acknowledgment 13 | - Forces user to click "Ack" button to dismiss dialogs 14 | - Clicking "Dismiss" (focused by default) re-shows the dialog with exponential backoff 15 | - Backoff sequence: 5s → 10s → 20s → 40s → 60s (caps at 60s) 16 | - Works with both command mode and timer mode 17 | - Available on Linux/yad for now 18 | 19 | ### Changed 20 | 21 | - **LMK_DELAY default** - Changed from 3s to 0s (immediate display) 22 | - Environment variable still works as override if set 23 | 24 | ## [2.1.0] - 2025-11-25 25 | 26 | ### Added 27 | 28 | - **Claude Code integration** - `lmk claude-hooks` subcommand for desktop notifications from Claude Code 29 | - Modal dialogs when Claude needs your attention (permission prompts 🔐, idle alerts ⏱️, auth success ✅, etc.) 30 | - Shows project name so you know which session needs attention 31 | - Install with `lmk claude-hooks install` (project-local) or `--global` (all projects) 32 | - Debug logging to `/tmp/lmk-claude-hooks.log` for troubleshooting 33 | - Filter by notification type: `--type permission_prompt,idle_prompt` 34 | - `--dry-run` to preview changes, `--uninstall` to remove hooks 35 | 36 | ## [2.0.0] - 2025-11-17 37 | 38 | **🤖 This release was brought to you by Claude Sonnet 4.5** 39 | 40 | Complete modernization of lmk from 2019 codebase. See [detailed implementation notes](docs/releases/v2.0.0.md). 41 | 42 | ### Added 43 | 44 | - **Timer mode** - Built-in pomodoro timer with `-t` / `-timer` flag 45 | - `lmk -t 25m -m "Pomodoro done!"` for 25-minute work sessions 46 | - Supports any duration: 5s, 25m, 1h30m, etc. 47 | - **Execution time display** - Shows how long commands took to run 48 | - **Exit code display** - Failed commands show their exit code 49 | - **Version flag** - `lmk -version` to show current version 50 | - **Windows support** - PowerShell-based dialogs 51 | - **ARM64 support** - Builds for Apple Silicon, Linux ARM64, Windows ARM64 52 | - **Configurable delay** - 3 second delay before showing dialog (prevents accidental dismissal while typing) 53 | - **Test suite** - Unit and integration tests (39% coverage) 54 | - **Dry-run mode** - `LMK_DRY_RUN=1` environment variable for testing without dialogs 55 | - **GitHub Actions CI/CD** - Automated testing and releases 56 | 57 | ### Changed 58 | 59 | - **Modal dialogs instead of notifications** - BREAKING CHANGE 60 | - No more `notify-send` with 30-second loops 61 | - Dialogs stay on top and block until acknowledged 62 | - No need to press Enter in terminal anymore 63 | - **Dialog tools priority** (Linux): 64 | 1. `yad` - Recommended, has proper always-on-top support 65 | 2. `zenity` - Fallback, uses question dialogs 66 | 3. `kdialog` - KDE environment support 67 | 4. `notify-send` - Last resort (v1.0.0 behavior: shows notification + waits for Enter) 68 | - **Migrated to Go modules** - Now requires Go 1.23+ 69 | - **Better dialog UX** - Centered, properly sized (450×150), with padding 70 | - **Improved help text** - Shows both command and timer modes 71 | 72 | ### Removed 73 | 74 | - Travis CI configuration (replaced with GitHub Actions) 75 | - Notification loop behavior (replaced with modal dialogs) 76 | - Go 1.12 support (now requires Go 1.23+) 77 | 78 | ### Fixed 79 | 80 | - Dialogs can no longer be hidden behind other windows 81 | - Proper string escaping for AppleScript and PowerShell 82 | - Better error messages with clear examples 83 | 84 | ### Platform Support 85 | 86 | - Linux: amd64, arm64 (requires yad/zenity/kdialog) 87 | - macOS: amd64, arm64 (uses built-in osascript) 88 | - Windows: amd64, arm64 (uses built-in PowerShell) 89 | 90 | --- 91 | 92 | ## [1.0.0] - 2019-06-26 93 | 94 | ### Added 95 | - Support for running without a command (just show notification) 96 | - `lmk -m "message"` now works standalone 97 | 98 | ## [0.2.0] - 2019-05-02 99 | 100 | ### Changed 101 | - Updated to Go 1.12.1 102 | 103 | ## [0.1.0] - 2016-03-25 104 | 105 | ### Added 106 | - macOS support with osascript 107 | - Cross-compilation support with Go 1.6 108 | - Travis CI for automated releases 109 | - Binary releases for Linux and macOS 110 | 111 | ## [0.0.1] - 2014-02-16 112 | 113 | Initial release. 114 | 115 | ### Added 116 | - Basic command execution with notifications 117 | - Linux support with notify-send 118 | - Custom success messages with `-m` flag 119 | - Notification loop every 30 seconds 120 | - Press Enter to dismiss 121 | 122 | [2.0.0]: https://github.com/fgrehm/lmk/compare/v1.0.0...v2.0.0 123 | [1.0.0]: https://github.com/fgrehm/lmk/compare/v0.2.0...v1.0.0 124 | [0.2.0]: https://github.com/fgrehm/lmk/compare/v0.1.0...v0.2.0 125 | [0.1.0]: https://github.com/fgrehm/lmk/compare/v0.0.1...v0.1.0 126 | [0.0.1]: https://github.com/fgrehm/lmk/releases/tag/v0.0.1 127 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Let me know 2 | 3 | `lmk` is a simple command line tool written in Go that shows a dialog when a long-running command finishes. 4 | 5 | ## What's New in v2.0.0 6 | 7 | - **Modern Go**: Updated to Go 1.23+ with module support 8 | - **Better Notifications**: Switched from repeating notifications to modal dialogs using zenity (Linux), native dialogs (macOS), and PowerShell (Windows) 9 | - **Timer Mode**: Built-in pomodoro timer support (`--timer 25m`) 10 | - **More Information**: Shows exit code and execution duration 11 | - **Multi-platform**: Support for Linux (amd64/arm64), macOS (amd64/arm64), and Windows (amd64/arm64) 12 | - **GitHub Actions**: Automated builds and releases with GoReleaser 13 | 14 | ## Why? 15 | 16 | How often do you run a command that takes a long time to complete, switch to something else, and forget about it? Even worse, what if it errored along the way and you didn't notice? 17 | 18 | Throughout the day you might run many `npm install`s, `docker build`s, test suites, etc. that take more than a few seconds. With `lmk`, you'll get a clear notification when they complete - and it won't disappear until you acknowledge it. 19 | 20 | ## How does it work? 21 | 22 | Let's say you want to run tests that take 5 minutes. With `lmk`: 23 | 24 | ```bash 25 | lmk npm test 26 | ``` 27 | 28 | When the command finishes, a dialog will appear showing: 29 | - ✅ Success or ❌ failure 30 | - Command that was run 31 | - Exit code (if failed) 32 | - Total execution time 33 | 34 | The dialog **blocks and waits** for you to click OK - no more missed notifications! 35 | 36 | ## Installation 37 | 38 | ### Binary releases 39 | 40 | Download the latest [compiled binaries](https://github.com/fgrehm/lmk/releases) for your platform and add it to your `$PATH`. 41 | 42 | ### Homebrew 43 | 44 | ```sh 45 | brew tap fgrehm/lmk 46 | brew install lmk 47 | ``` 48 | 49 | ### From source 50 | 51 | ```sh 52 | go install github.com/fgrehm/lmk@latest 53 | ``` 54 | 55 | ## Usage 56 | 57 | ``` 58 | Usage: lmk [options...] command 59 | or: lmk -t [-m ] 60 | 61 | Options: 62 | -m Message to display in case of success, defaults to "[command] has completed successfully" 63 | -t, -timer Timer duration (e.g., 25m, 1h30m, 90s) - runs a countdown timer instead of a command 64 | -version Show version information 65 | ``` 66 | 67 | ### Examples 68 | 69 | #### Running Commands 70 | ```bash 71 | # Run tests and get notified 72 | lmk npm test 73 | 74 | # Custom success message 75 | lmk -m "Build completed!" make build 76 | 77 | # Works with any command 78 | lmk cargo build --release 79 | lmk docker compose up 80 | lmk bundle install 81 | 82 | # Just show a notification (no command) 83 | lmk -m "Time to take a break!" 84 | ``` 85 | 86 | #### Pomodoro Timer (Poor Man's Edition) 87 | ```bash 88 | # Classic 25-minute pomodoro 89 | lmk -t 25m -m "Pomodoro done! Time for a break" 90 | 91 | # 5-minute break 92 | lmk -t 5m -m "Break over, back to work!" 93 | 94 | # Long break (15 minutes) 95 | lmk -t 15m -m "Long break finished" 96 | 97 | # Custom durations 98 | lmk -t 1h30m -m "Deep work session complete" 99 | lmk -t 90s -m "Quick break done" 100 | 101 | # Simple timer with default message 102 | lmk -t 10m 103 | ``` 104 | 105 | **Pro tip**: Create shell aliases for your pomodoro workflow: 106 | ```bash 107 | alias pomo='lmk -t 25m -m "Pomodoro complete! Take a 5min break 🍅"' 108 | alias short-break='lmk -t 5m -m "Break over! Time to focus 💪"' 109 | alias long-break='lmk -t 15m -m "Long break done! Ready for another session?"' 110 | ``` 111 | 112 | ### Configuration 113 | 114 | **Delay before showing dialog** (prevents accidental dismissal while typing): 115 | ```bash 116 | # Default: 3 second delay 117 | lmk npm test 118 | 119 | # Custom delay 120 | LMK_DELAY=5s lmk npm test # Longer delay if you type fast 121 | LMK_DELAY=1s lmk npm test # Shorter delay 122 | LMK_DELAY=0s lmk npm test # No delay (instant dialog) 123 | ``` 124 | 125 | **Dry-run mode** - See what lmk would do without showing dialogs: 126 | ```bash 127 | LMK_DRY_RUN=1 lmk npm test # Test without actual dialogs 128 | LMK_DRY_RUN=1 lmk -t 25m -m "Test" # Test timer without dialog 129 | ``` 130 | 131 | Output shows what would happen: 132 | ``` 133 | [DRY RUN] Dialog message: ✅ npm test has completed successfully 134 | [DRY RUN] Is error: false 135 | ``` 136 | 137 | ### Platform Dependencies 138 | 139 | - **Linux**: Requires one of the following (in order of preference): 140 | - `yad` - **Recommended**, has proper always-on-top support 141 | - Install: `apt install yad` or `yum install yad` 142 | - `zenity` - GTK dialogs 143 | - Install: `apt install zenity` or `yum install zenity` 144 | - `kdialog` - KDE environments 145 | - Install: `apt install kdialog` 146 | - `notify-send` - Last resort fallback (requires pressing Enter to dismiss) 147 | - Install: `apt install libnotify-bin` 148 | - **macOS**: Built-in `osascript` (no installation needed) 149 | - **Windows**: Built-in PowerShell (no installation needed) 150 | 151 | 152 | ## Contributing 153 | 154 | 1. Fork it 155 | 2. Create your feature branch (`git checkout -b my-new-feature`) 156 | 3. Commit your changes (`git commit -am 'Add some feature'`) 157 | 4. Push to the branch (`git push origin my-new-feature`) 158 | 5. Create new Pull Request 159 | -------------------------------------------------------------------------------- /lmk_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os/exec" 5 | "runtime" 6 | "strings" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | func TestFormatDuration(t *testing.T) { 12 | tests := []struct { 13 | name string 14 | duration time.Duration 15 | want string 16 | }{ 17 | {"under a second", 500 * time.Millisecond, "0.5s"}, 18 | {"seconds", 5 * time.Second, "5.0s"}, 19 | {"under a minute", 45 * time.Second, "45.0s"}, 20 | {"minutes and seconds", 2*time.Minute + 30*time.Second, "2m 30s"}, 21 | {"over an hour", 2*time.Hour + 15*time.Minute, "2h 15m"}, 22 | } 23 | 24 | for _, tt := range tests { 25 | t.Run(tt.name, func(t *testing.T) { 26 | got := formatDuration(tt.duration) 27 | if got != tt.want { 28 | t.Errorf("formatDuration(%v) = %v, want %v", tt.duration, got, tt.want) 29 | } 30 | }) 31 | } 32 | } 33 | 34 | func TestGetExecutableAndArgs(t *testing.T) { 35 | tests := []struct { 36 | name string 37 | cmd []string 38 | wantExecutable string 39 | wantArgs []string 40 | wantError bool 41 | }{ 42 | { 43 | name: "echo with args", 44 | cmd: []string{"echo", "hello", "world"}, 45 | wantExecutable: func() string { 46 | path, _ := exec.LookPath("echo") 47 | return path 48 | }(), 49 | wantArgs: []string{"hello", "world"}, 50 | wantError: false, 51 | }, 52 | { 53 | name: "single command", 54 | cmd: []string{"pwd"}, 55 | wantExecutable: func() string { 56 | path, _ := exec.LookPath("pwd") 57 | return path 58 | }(), 59 | wantArgs: []string{}, 60 | wantError: false, 61 | }, 62 | } 63 | 64 | for _, tt := range tests { 65 | t.Run(tt.name, func(t *testing.T) { 66 | executable, args := getExecutableAndArgs(tt.cmd) 67 | 68 | if executable != tt.wantExecutable { 69 | t.Errorf("getExecutableAndArgs() executable = %v, want %v", executable, tt.wantExecutable) 70 | } 71 | 72 | if len(args) != len(tt.wantArgs) { 73 | t.Errorf("getExecutableAndArgs() args length = %v, want %v", len(args), len(tt.wantArgs)) 74 | } 75 | 76 | for i := range args { 77 | if args[i] != tt.wantArgs[i] { 78 | t.Errorf("getExecutableAndArgs() args[%d] = %v, want %v", i, args[i], tt.wantArgs[i]) 79 | } 80 | } 81 | }) 82 | } 83 | } 84 | 85 | func TestGetMessageAndExitCode(t *testing.T) { 86 | tests := []struct { 87 | name string 88 | err error 89 | cmd []string 90 | duration time.Duration 91 | wantIsError bool 92 | wantMsgParts []string 93 | }{ 94 | { 95 | name: "success", 96 | err: nil, 97 | cmd: []string{"echo", "test"}, 98 | duration: 2 * time.Second, 99 | wantIsError: false, 100 | wantMsgParts: []string{"✅", "completed successfully", "2.0s"}, 101 | }, 102 | } 103 | 104 | for _, tt := range tests { 105 | t.Run(tt.name, func(t *testing.T) { 106 | msg, exitCode, isError := getMessageAndExitCode(tt.err, tt.cmd, tt.duration) 107 | 108 | if isError != tt.wantIsError { 109 | t.Errorf("getMessageAndExitCode() isError = %v, want %v", isError, tt.wantIsError) 110 | } 111 | 112 | if !tt.wantIsError && exitCode != 0 { 113 | t.Errorf("getMessageAndExitCode() exitCode = %v, want 0 for success", exitCode) 114 | } 115 | 116 | for _, part := range tt.wantMsgParts { 117 | if !strings.Contains(msg, part) { 118 | t.Errorf("getMessageAndExitCode() message should contain %q, got %q", part, msg) 119 | } 120 | } 121 | }) 122 | } 123 | } 124 | 125 | func TestGetMessageAndExitCodeWithRealCommand(t *testing.T) { 126 | // Test with real command failure 127 | t.Run("real command failure", func(t *testing.T) { 128 | cmd := exec.Command("false") 129 | err := cmd.Run() 130 | 131 | msg, exitCode, isError := getMessageAndExitCode(err, []string{"false"}, 1*time.Second) 132 | 133 | if !isError { 134 | t.Error("getMessageAndExitCode() isError = false, want true") 135 | } 136 | 137 | if exitCode != 1 { 138 | t.Errorf("getMessageAndExitCode() exitCode = %v, want 1", exitCode) 139 | } 140 | 141 | if !strings.Contains(msg, "❌") || !strings.Contains(msg, "failed") { 142 | t.Errorf("getMessageAndExitCode() message should contain error indicators, got %q", msg) 143 | } 144 | }) 145 | } 146 | 147 | func TestEscapeAppleScript(t *testing.T) { 148 | tests := []struct { 149 | name string 150 | input string 151 | want string 152 | }{ 153 | {"simple string", "hello", "hello"}, 154 | {"with quotes", `hello "world"`, `hello \"world\"`}, 155 | {"with backslash", `hello\world`, `hello\\world`}, 156 | {"with newline", "hello\nworld", "hello\\nworld"}, 157 | {"complex", `test "foo\bar"`, `test \"foo\\bar\"`}, 158 | } 159 | 160 | for _, tt := range tests { 161 | t.Run(tt.name, func(t *testing.T) { 162 | got := escapeAppleScript(tt.input) 163 | if got != tt.want { 164 | t.Errorf("escapeAppleScript(%q) = %q, want %q", tt.input, got, tt.want) 165 | } 166 | }) 167 | } 168 | } 169 | 170 | func TestEscapeWindowsString(t *testing.T) { 171 | tests := []struct { 172 | name string 173 | input string 174 | want string 175 | }{ 176 | {"simple string", "hello", "hello"}, 177 | {"with single quote", "hello'world", "hello''world"}, 178 | {"with newline", "hello\nworld", "hello`nworld"}, 179 | {"complex", "test's\nvalue", "test''s`nvalue"}, 180 | } 181 | 182 | for _, tt := range tests { 183 | t.Run(tt.name, func(t *testing.T) { 184 | got := escapeWindowsString(tt.input) 185 | if got != tt.want { 186 | t.Errorf("escapeWindowsString(%q) = %q, want %q", tt.input, got, tt.want) 187 | } 188 | }) 189 | } 190 | } 191 | 192 | func TestRun(t *testing.T) { 193 | // Test successful command 194 | t.Run("successful command", func(t *testing.T) { 195 | err := run("echo", "test") 196 | if err != nil { 197 | t.Errorf("run() error = %v, want nil", err) 198 | } 199 | }) 200 | 201 | // Test failing command 202 | t.Run("failing command", func(t *testing.T) { 203 | err := run("false") 204 | if err == nil { 205 | t.Error("run() error = nil, want error") 206 | } 207 | }) 208 | } 209 | 210 | func TestTimerDurationParsing(t *testing.T) { 211 | tests := []struct { 212 | name string 213 | duration string 214 | wantError bool 215 | expectValid bool 216 | }{ 217 | {"25 minutes", "25m", false, true}, 218 | {"5 seconds", "5s", false, true}, 219 | {"1 hour 30 minutes", "1h30m", false, true}, 220 | {"90 seconds", "90s", false, true}, 221 | {"invalid format", "abc", true, false}, 222 | {"negative duration", "-5m", false, false}, // parses but should fail validation 223 | } 224 | 225 | for _, tt := range tests { 226 | t.Run(tt.name, func(t *testing.T) { 227 | duration, err := time.ParseDuration(tt.duration) 228 | 229 | if tt.wantError { 230 | if err == nil { 231 | t.Errorf("ParseDuration(%q) expected error, got nil", tt.duration) 232 | } 233 | return 234 | } 235 | 236 | if err != nil { 237 | t.Errorf("ParseDuration(%q) unexpected error: %v", tt.duration, err) 238 | return 239 | } 240 | 241 | if tt.expectValid && duration <= 0 { 242 | t.Errorf("ParseDuration(%q) = %v, should be positive", tt.duration, duration) 243 | } 244 | }) 245 | } 246 | } 247 | 248 | func TestIsAckModeSupported(t *testing.T) { 249 | // Test platform check 250 | supported := isAckModeSupported() 251 | 252 | if runtime.GOOS != "linux" { 253 | // Non-Linux should never support ack mode 254 | if supported { 255 | t.Error("isAckModeSupported() = true on non-Linux platform, want false") 256 | } 257 | } else { 258 | // Linux support depends on yad availability 259 | _, err := exec.LookPath("yad") 260 | expectedSupport := (err == nil) 261 | 262 | if supported != expectedSupport { 263 | t.Errorf("isAckModeSupported() = %v, want %v (yad available = %v)", supported, expectedSupport, err == nil) 264 | } 265 | } 266 | } 267 | 268 | func TestAckModeFallback(t *testing.T) { 269 | // Save and restore original GOOS 270 | originalGOOS := runtime.GOOS 271 | 272 | tests := []struct { 273 | name string 274 | ackMode bool 275 | wantMsg string 276 | }{ 277 | { 278 | name: "ack mode enabled in dry run", 279 | ackMode: true, 280 | wantMsg: "Ack mode: true", 281 | }, 282 | { 283 | name: "ack mode disabled in dry run", 284 | ackMode: false, 285 | wantMsg: "Ack mode: false", 286 | }, 287 | } 288 | 289 | for _, tt := range tests { 290 | t.Run(tt.name, func(t *testing.T) { 291 | // Use dry run to verify ack mode is being logged 292 | t.Setenv("LMK_DRY_RUN", "1") 293 | 294 | // This would normally show a dialog, but in dry run it just logs 295 | showDialog("test message", false, tt.ackMode) 296 | 297 | // The test passes if we get here without panic 298 | // In real usage, non-Linux or non-yad systems would log a warning 299 | // and fall back to normal mode 300 | }) 301 | } 302 | 303 | _ = originalGOOS // keep linter happy 304 | } 305 | -------------------------------------------------------------------------------- /lmk_claudehooks_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | "testing" 9 | ) 10 | 11 | // TestGetNotificationIcon tests the icon mapping for different notification types 12 | func TestGetNotificationIcon(t *testing.T) { 13 | tests := []struct { 14 | notificationType string 15 | expectedIcon string 16 | }{ 17 | {"permission_prompt", "🔐"}, 18 | {"idle_prompt", "⏱️"}, 19 | {"auth_success", "✅"}, 20 | {"elicitation_dialog", "📝"}, 21 | {"unknown_type", "🤖"}, 22 | {"", "🤖"}, 23 | } 24 | 25 | for _, tt := range tests { 26 | t.Run(tt.notificationType, func(t *testing.T) { 27 | icon := getNotificationIcon(tt.notificationType) 28 | if icon != tt.expectedIcon { 29 | t.Errorf("getNotificationIcon(%q) = %q, want %q", tt.notificationType, icon, tt.expectedIcon) 30 | } 31 | }) 32 | } 33 | } 34 | 35 | // TestClaudeHookPayloadParsing tests JSON parsing of hook payloads 36 | func TestClaudeHookPayloadParsing(t *testing.T) { 37 | tests := []struct { 38 | name string 39 | jsonFile string 40 | }{ 41 | {"permission_prompt", "testdata/permission_prompt.json"}, 42 | {"idle_prompt", "testdata/idle_prompt.json"}, 43 | {"auth_success", "testdata/auth_success.json"}, 44 | } 45 | 46 | for _, tt := range tests { 47 | t.Run(tt.name, func(t *testing.T) { 48 | data, err := os.ReadFile(tt.jsonFile) 49 | if err != nil { 50 | t.Fatalf("Failed to read test file: %v", err) 51 | } 52 | 53 | var payload ClaudeHookPayload 54 | if err := json.Unmarshal(data, &payload); err != nil { 55 | t.Fatalf("Failed to parse JSON: %v", err) 56 | } 57 | 58 | if payload.NotificationType == "" { 59 | t.Error("NotificationType should not be empty") 60 | } 61 | if payload.Message == "" { 62 | t.Error("Message should not be empty") 63 | } 64 | }) 65 | } 66 | } 67 | 68 | // TestReadOrCreateSettings tests reading existing and creating new settings 69 | func TestReadOrCreateSettings(t *testing.T) { 70 | t.Run("read_existing_settings", func(t *testing.T) { 71 | settings := readOrCreateSettings("testdata/existing_settings.json") 72 | if _, ok := settings["hooks"]; !ok { 73 | t.Error("Expected hooks to be populated") 74 | } 75 | }) 76 | 77 | t.Run("create_empty_settings", func(t *testing.T) { 78 | settings := readOrCreateSettings("testdata/nonexistent_file.json") 79 | if _, ok := settings["hooks"]; ok { 80 | t.Error("Expected hooks to not exist for new settings") 81 | } 82 | if settings == nil { 83 | t.Error("Expected empty map, not nil") 84 | } 85 | }) 86 | 87 | t.Run("read_empty_settings", func(t *testing.T) { 88 | settings := readOrCreateSettings("testdata/empty_settings.json") 89 | if _, ok := settings["hooks"]; ok { 90 | t.Error("Expected hooks to not exist for empty settings") 91 | } 92 | }) 93 | } 94 | 95 | // TestRemoveLmkHooks tests removing lmk hooks from configuration 96 | func TestRemoveLmkHooks(t *testing.T) { 97 | t.Run("remove_from_nil", func(t *testing.T) { 98 | result := removeLmkHooks(nil) 99 | if result != nil { 100 | t.Error("Expected nil when removing from nil hooks") 101 | } 102 | }) 103 | 104 | t.Run("remove_lmk_hook", func(t *testing.T) { 105 | hooks := &ClaudeHooks{ 106 | Notification: []NotificationHook{ 107 | { 108 | Hooks: []HookConfig{ 109 | {Type: "command", Command: "lmk claude-hooks"}, 110 | }, 111 | }, 112 | }, 113 | } 114 | result := removeLmkHooks(hooks) 115 | if result != nil { 116 | t.Error("Expected nil after removing only lmk hook") 117 | } 118 | }) 119 | 120 | t.Run("keep_other_hooks", func(t *testing.T) { 121 | hooks := &ClaudeHooks{ 122 | Notification: []NotificationHook{ 123 | { 124 | Hooks: []HookConfig{ 125 | {Type: "command", Command: "lmk claude-hooks"}, 126 | }, 127 | }, 128 | { 129 | Hooks: []HookConfig{ 130 | {Type: "command", Command: "other-tool"}, 131 | }, 132 | }, 133 | }, 134 | } 135 | result := removeLmkHooks(hooks) 136 | if result == nil { 137 | t.Fatal("Expected hooks to remain after removing lmk") 138 | } 139 | if len(result.Notification) != 1 { 140 | t.Errorf("Expected 1 hook, got %d", len(result.Notification)) 141 | } 142 | if !strings.Contains(result.Notification[0].Hooks[0].Command, "other-tool") { 143 | t.Error("Expected other-tool hook to remain") 144 | } 145 | }) 146 | } 147 | 148 | // TestAddOrUpdateLmkHook tests adding/updating lmk hooks 149 | func TestAddOrUpdateLmkHook(t *testing.T) { 150 | t.Run("add_to_nil", func(t *testing.T) { 151 | lmkHook := NotificationHook{ 152 | Hooks: []HookConfig{ 153 | {Type: "command", Command: "lmk claude-hooks"}, 154 | }, 155 | } 156 | result := addOrUpdateLmkHook(nil, lmkHook) 157 | if result == nil { 158 | t.Fatal("Expected hooks to be created") 159 | } 160 | if len(result.Notification) != 1 { 161 | t.Errorf("Expected 1 hook, got %d", len(result.Notification)) 162 | } 163 | }) 164 | 165 | t.Run("add_to_existing", func(t *testing.T) { 166 | hooks := &ClaudeHooks{ 167 | Notification: []NotificationHook{ 168 | { 169 | Hooks: []HookConfig{ 170 | {Type: "command", Command: "other-tool"}, 171 | }, 172 | }, 173 | }, 174 | } 175 | lmkHook := NotificationHook{ 176 | Hooks: []HookConfig{ 177 | {Type: "command", Command: "lmk claude-hooks"}, 178 | }, 179 | } 180 | result := addOrUpdateLmkHook(hooks, lmkHook) 181 | if len(result.Notification) != 2 { 182 | t.Errorf("Expected 2 hooks, got %d", len(result.Notification)) 183 | } 184 | }) 185 | 186 | t.Run("update_existing_lmk", func(t *testing.T) { 187 | hooks := &ClaudeHooks{ 188 | Notification: []NotificationHook{ 189 | { 190 | Hooks: []HookConfig{ 191 | {Type: "command", Command: "lmk claude-hooks --old"}, 192 | }, 193 | }, 194 | }, 195 | } 196 | lmkHook := NotificationHook{ 197 | Hooks: []HookConfig{ 198 | {Type: "command", Command: "lmk claude-hooks --new"}, 199 | }, 200 | } 201 | result := addOrUpdateLmkHook(hooks, lmkHook) 202 | if len(result.Notification) != 1 { 203 | t.Errorf("Expected 1 hook after update, got %d", len(result.Notification)) 204 | } 205 | if !strings.Contains(result.Notification[0].Hooks[0].Command, "--new") { 206 | t.Error("Expected updated command with --new flag") 207 | } 208 | }) 209 | } 210 | 211 | // TestWriteAndReadSettings tests round-trip settings persistence 212 | func TestWriteAndReadSettings(t *testing.T) { 213 | tempDir := t.TempDir() 214 | settingsPath := filepath.Join(tempDir, ".claude", "settings.json") 215 | 216 | settings := make(ClaudeSettings) 217 | settings["hooks"] = &ClaudeHooks{ 218 | Notification: []NotificationHook{ 219 | { 220 | Hooks: []HookConfig{ 221 | {Type: "command", Command: "lmk claude-hooks"}, 222 | }, 223 | }, 224 | }, 225 | } 226 | settings["someOtherSetting"] = "should be preserved" 227 | settings["anotherField"] = map[string]interface{}{ 228 | "nested": "value", 229 | } 230 | 231 | // Write settings 232 | if err := writeSettings(settingsPath, settings); err != nil { 233 | t.Fatalf("Failed to write settings: %v", err) 234 | } 235 | 236 | // Verify file exists 237 | if _, err := os.Stat(settingsPath); os.IsNotExist(err) { 238 | t.Fatal("Settings file was not created") 239 | } 240 | 241 | // Read back settings 242 | readSettings := readOrCreateSettings(settingsPath) 243 | 244 | // Verify hooks were preserved 245 | if _, ok := readSettings["hooks"]; !ok { 246 | t.Fatal("Expected hooks to be populated") 247 | } 248 | 249 | // Verify other settings were preserved 250 | if val, ok := readSettings["someOtherSetting"]; !ok || val != "should be preserved" { 251 | t.Error("Expected other settings to be preserved") 252 | } 253 | 254 | if _, ok := readSettings["anotherField"]; !ok { 255 | t.Error("Expected nested field to be preserved") 256 | } 257 | 258 | // Verify JSON formatting 259 | data, err := os.ReadFile(settingsPath) 260 | if err != nil { 261 | t.Fatalf("Failed to read settings file: %v", err) 262 | } 263 | if !strings.Contains(string(data), " ") { 264 | t.Error("Expected indented JSON output") 265 | } 266 | } 267 | 268 | // TestGetClaudeSettingsPath tests path generation 269 | func TestGetClaudeSettingsPath(t *testing.T) { 270 | t.Run("local_path", func(t *testing.T) { 271 | path := getClaudeSettingsPath(false) 272 | if path != ".claude/settings.local.json" { 273 | t.Errorf("Expected .claude/settings.local.json, got %s", path) 274 | } 275 | }) 276 | 277 | t.Run("global_path", func(t *testing.T) { 278 | path := getClaudeSettingsPath(true) 279 | if !strings.Contains(path, ".claude/settings.json") { 280 | t.Errorf("Expected path to contain .claude/settings.json, got %s", path) 281 | } 282 | if !strings.HasPrefix(path, "/") && !strings.Contains(path, ":") { 283 | t.Error("Expected absolute path for global settings") 284 | } 285 | }) 286 | } 287 | 288 | // TestExtractProjectName tests project name extraction from cwd 289 | func TestExtractProjectName(t *testing.T) { 290 | tests := []struct { 291 | name string 292 | cwd string 293 | expected string 294 | }{ 295 | {"unix_path", "/home/user/projects/my-app", "my-app"}, 296 | {"nested_path", "/home/fabio/projects/oss/lmk", "lmk"}, 297 | {"windows_path", "C:\\Users\\john\\projects\\web-app", "web-app"}, 298 | {"trailing_slash", "/home/user/my-project/", "my-project"}, 299 | {"root_path", "/", ""}, 300 | {"empty_cwd", "", ""}, 301 | {"single_component", "project", "project"}, 302 | } 303 | 304 | for _, tt := range tests { 305 | t.Run(tt.name, func(t *testing.T) { 306 | result := extractProjectName(tt.cwd) 307 | if result != tt.expected { 308 | t.Errorf("extractProjectName(%q) = %q, want %q", tt.cwd, result, tt.expected) 309 | } 310 | }) 311 | } 312 | } 313 | 314 | // TestInstallClaudeHooksPreservesOtherSettings tests that installation preserves other hooks and settings 315 | func TestInstallClaudeHooksPreservesOtherSettings(t *testing.T) { 316 | t.Run("preserve_other_hook_types_and_notification_hooks", func(t *testing.T) { 317 | tempDir := t.TempDir() 318 | settingsPath := filepath.Join(tempDir, ".claude", "settings.json") 319 | 320 | // Create settings with multiple hook types and multiple notification hooks 321 | settings := make(ClaudeSettings) 322 | settings["colorScheme"] = "dark" 323 | settings["fontSize"] = 12 324 | 325 | // Multiple hook types (Notification and Completion) 326 | hooksMap := map[string]interface{}{ 327 | "Notification": []map[string]interface{}{ 328 | { 329 | "matcher": "permission_prompt", 330 | "hooks": []map[string]string{ 331 | { 332 | "type": "command", 333 | "command": "other-tool notify", 334 | }, 335 | }, 336 | }, 337 | }, 338 | "Completion": []map[string]interface{}{ 339 | { 340 | "hooks": []map[string]string{ 341 | { 342 | "type": "command", 343 | "command": "completion-tool", 344 | }, 345 | }, 346 | }, 347 | }, 348 | "SomeOtherHookType": "preserve-this-too", 349 | } 350 | settings["hooks"] = hooksMap 351 | 352 | // Write initial settings 353 | if err := writeSettings(settingsPath, settings); err != nil { 354 | t.Fatalf("Failed to write settings: %v", err) 355 | } 356 | 357 | // Read settings back 358 | readSettings := readOrCreateSettings(settingsPath) 359 | 360 | // Verify other settings are present 361 | if val, ok := readSettings["colorScheme"]; !ok || val != "dark" { 362 | t.Error("Expected colorScheme setting to be preserved") 363 | } 364 | if val, ok := readSettings["fontSize"]; !ok || val != float64(12) { 365 | t.Error("Expected fontSize setting to be preserved") 366 | } 367 | 368 | // Verify hooks map has all types 369 | hooksData, ok := readSettings["hooks"] 370 | if !ok { 371 | t.Fatal("Expected hooks to exist") 372 | } 373 | 374 | hooksMapRead, ok := hooksData.(map[string]interface{}) 375 | if !ok { 376 | t.Fatal("Expected hooks to be a map") 377 | } 378 | 379 | // Verify Completion hook type is preserved 380 | if _, ok := hooksMapRead["Completion"]; !ok { 381 | t.Error("Expected Completion hook type to be preserved") 382 | } 383 | 384 | // Verify other hook type is preserved 385 | if _, ok := hooksMapRead["SomeOtherHookType"]; !ok { 386 | t.Error("Expected SomeOtherHookType to be preserved") 387 | } 388 | 389 | // Verify Notification hooks are present (they should still be there) 390 | if _, ok := hooksMapRead["Notification"]; !ok { 391 | t.Error("Expected Notification hooks to be present") 392 | } 393 | }) 394 | } 395 | 396 | // TestInstallClaudeHooksActualInstallation tests that installClaudeHooks actually installs hooks correctly 397 | func TestInstallClaudeHooksActualInstallation(t *testing.T) { 398 | t.Run("install_lmk_hooks_and_preserve_other_settings", func(t *testing.T) { 399 | tempDir := t.TempDir() 400 | claudeDir := filepath.Join(tempDir, ".claude") 401 | settingsPath := filepath.Join(claudeDir, "settings.local.json") 402 | 403 | // Create initial settings with other hooks and settings 404 | initialSettings := make(ClaudeSettings) 405 | initialSettings["theme"] = "light" 406 | initialSettings["editor"] = "vim" 407 | 408 | // Add some existing hooks (including other notification hooks and other hook types) 409 | hooksMap := map[string]interface{}{ 410 | "Notification": []map[string]interface{}{ 411 | { 412 | "matcher": "idle_prompt", 413 | "hooks": []map[string]string{ 414 | { 415 | "type": "command", 416 | "command": "existing-notification-hook", 417 | }, 418 | }, 419 | }, 420 | }, 421 | "Completion": []map[string]interface{}{ 422 | { 423 | "hooks": []map[string]string{ 424 | { 425 | "type": "command", 426 | "command": "completion-provider", 427 | }, 428 | }, 429 | }, 430 | }, 431 | } 432 | initialSettings["hooks"] = hooksMap 433 | 434 | // Write initial settings 435 | if err := writeSettings(settingsPath, initialSettings); err != nil { 436 | t.Fatalf("Failed to write initial settings: %v", err) 437 | } 438 | 439 | // Change to temp directory so installClaudeHooks uses local settings 440 | oldCwd, err := os.Getwd() 441 | if err != nil { 442 | t.Fatalf("Failed to get current directory: %v", err) 443 | } 444 | defer os.Chdir(oldCwd) 445 | 446 | if err := os.Chdir(tempDir); err != nil { 447 | t.Fatalf("Failed to chdir to temp directory: %v", err) 448 | } 449 | 450 | // Call installClaudeHooks with --ack-mode flag 451 | installClaudeHooks([]string{"--ack-mode", "--dry-run"}) 452 | 453 | // Read back the settings (should not be modified due to --dry-run) 454 | // So we need to actually do the install without --dry-run 455 | installClaudeHooks([]string{"--ack-mode"}) 456 | 457 | // Read installed settings 458 | installedSettings := readOrCreateSettings(settingsPath) 459 | 460 | // Verify other settings are preserved 461 | if val, ok := installedSettings["theme"]; !ok || val != "light" { 462 | t.Error("Expected theme setting to be preserved") 463 | } 464 | if val, ok := installedSettings["editor"]; !ok || val != "vim" { 465 | t.Error("Expected editor setting to be preserved") 466 | } 467 | 468 | // Verify hooks structure 469 | hooksData, ok := installedSettings["hooks"] 470 | if !ok { 471 | t.Fatal("Expected hooks to exist after installation") 472 | } 473 | 474 | hooksMapRead, ok := hooksData.(map[string]interface{}) 475 | if !ok { 476 | t.Fatal("Expected hooks to be a map") 477 | } 478 | 479 | // Verify Completion hooks are preserved 480 | if _, ok := hooksMapRead["Completion"]; !ok { 481 | t.Error("Expected Completion hook type to be preserved after lmk installation") 482 | } 483 | 484 | // Verify Notification hooks exist 485 | notificationData, ok := hooksMapRead["Notification"] 486 | if !ok { 487 | t.Fatal("Expected Notification hooks after installation") 488 | } 489 | 490 | notificationHooks, ok := notificationData.([]interface{}) 491 | if !ok { 492 | t.Fatal("Expected Notification to be a list") 493 | } 494 | 495 | // Should have at least 1 notification hook (the new lmk one) 496 | if len(notificationHooks) < 1 { 497 | t.Errorf("Expected at least 1 notification hook, got %d", len(notificationHooks)) 498 | } 499 | 500 | // Verify lmk hook was added with --ack-mode 501 | foundLmkHook := false 502 | foundExistingHook := false 503 | 504 | for _, hookInterface := range notificationHooks { 505 | hook, ok := hookInterface.(map[string]interface{}) 506 | if !ok { 507 | continue 508 | } 509 | hooksArray, ok := hook["hooks"].([]interface{}) 510 | if !ok { 511 | continue 512 | } 513 | for _, h := range hooksArray { 514 | hConfig, ok := h.(map[string]interface{}) 515 | if !ok { 516 | continue 517 | } 518 | cmd, ok := hConfig["command"].(string) 519 | if !ok { 520 | continue 521 | } 522 | // Check for lmk hook 523 | if strings.Contains(cmd, "lmk") && strings.Contains(cmd, "claude-hooks") && strings.Contains(cmd, "--ack-mode") { 524 | foundLmkHook = true 525 | } 526 | // Check for existing hook 527 | if strings.Contains(cmd, "existing-notification-hook") { 528 | foundExistingHook = true 529 | } 530 | } 531 | } 532 | 533 | if !foundLmkHook { 534 | t.Error("Expected lmk hook with --ack-mode flag to be installed") 535 | } 536 | if !foundExistingHook { 537 | t.Error("Expected existing notification hook to be preserved") 538 | } 539 | }) 540 | } 541 | -------------------------------------------------------------------------------- /docs/releases/v2.0.0.md: -------------------------------------------------------------------------------- 1 | # lmk v2.0.0 Implementation Documentation 2 | 3 | **Date:** November 17, 2025 4 | **AI Assistant:** Claude Sonnet 4.5 (claude-sonnet-4-5-20250929) 5 | **Project:** lmk - Command-line notification tool 6 | **Development Method:** 🤖 Agentic coding with Claude Code 7 | 8 | > **Note:** This entire v2.0.0 modernization was implemented through an interactive AI-assisted coding session. This document captures the technical decisions, implementation details, and reasoning for future reference. 9 | 10 | ## Overview 11 | 12 | This document details the complete modernization of `lmk` from v1.0.0 (June 2019) to v2.0.0 (November 2025). The project was updated from Go 1.12.1 to Go 1.23+, migrated from Travis CI to GitHub Actions, and gained significant new features including a timer/pomodoro mode. 13 | 14 | ## Executive Summary 15 | 16 | ### Before (v1.0.0 - June 2019) 17 | 18 | - Go 1.12.1 (no modules) 19 | - Travis CI for releases 20 | - `notify-send` with 30-second notification loop 21 | - Required pressing Enter to dismiss 22 | - Linux (amd64) and macOS (amd64) only 23 | - No timer functionality 24 | 25 | ### After (v2.0.0) 26 | 27 | - Go 1.23+ with modules 28 | - GitHub Actions + GoReleaser 29 | - Modal dialogs with always-on-top support 30 | - Automatic dismissal when acknowledged 31 | - Multi-platform: Linux/macOS/Windows on amd64/arm64 32 | - Built-in pomodoro timer mode (`-t` flag) 33 | - Shows execution time and exit codes 34 | 35 | --- 36 | 37 | ## Key Changes 38 | 39 | ### 1. Modern Go Setup 40 | 41 | #### Go Modules 42 | 43 | ```bash 44 | go mod init github.com/fgrehm/lmk 45 | ``` 46 | 47 | Created `go.mod` with Go 1.23 support. The project has zero external dependencies (stdlib only). 48 | 49 | #### Benefits 50 | 51 | - Proper dependency management (though none needed) 52 | - Modern Go toolchain compatibility 53 | - Better build reproducibility 54 | 55 | ### 2. Notification System Overhaul 56 | 57 | #### Problem with v1 58 | 59 | The original implementation used: 60 | 1. `notify-send` (Linux) or `osascript` notifications 61 | 2. 30-second loop to repeat notifications 62 | 3. Blocking on Enter key to dismiss 63 | 64 | **Issues:** 65 | 66 | - Notifications disappeared after a few seconds 67 | - Easy to miss if away from keyboard 68 | - Annoying repetition every 30 seconds 69 | - Had to return to terminal to dismiss 70 | 71 | #### v2 Solution: Modal Dialogs 72 | 73 | **Linux Priority Order:** 74 | 75 | 1. **yad** (recommended) - Has `--on-top` flag that actually works 76 | 2. **zenity** - Fallback, uses `--question` dialog for better focus 77 | 3. **kdialog** - KDE environment support 78 | 4. **notify-send** - Last resort fallback (v1.0.0 behavior: notification + Enter prompt) 79 | 80 | **macOS:** 81 | 82 | - `osascript` with `System Events` activation 83 | - Native dialog with proper focus stealing 84 | 85 | **Windows:** 86 | 87 | - PowerShell with `TopMost` form property 88 | - MessageBox stays on top 89 | 90 | #### Implementation Details 91 | 92 | ```go 93 | // yad command with optimized flags 94 | cmd = exec.Command("yad", 95 | "--text="+msg, 96 | "--title=lmk", 97 | "--width=450", // Good readable width 98 | "--height=150", // Consistent height 99 | "--center", // Always centered on screen 100 | "--button=gtk-ok:0", 101 | "--image=dialog-information", 102 | "--on-top", // Critical for visibility 103 | "--no-escape", // Can't dismiss with Esc 104 | "--borders=10") // 10px padding for polish 105 | ``` 106 | 107 | **Key Decision:** Use `yad` instead of `zenity` as primary because: 108 | - Has proper `--on-top` support 109 | - Better window manager integration 110 | - More configurable layout options 111 | - Active maintenance 112 | 113 | ### 3. Timer/Pomodoro Mode 114 | 115 | #### User Request 116 | 117 | > "I want to build poor man pomodoro on CLI" 118 | 119 | #### Implementation 120 | 121 | Added `-t` / `-timer` flag to run countdown timers without commands: 122 | 123 | ```go 124 | func runTimer(timerDuration string, message string) { 125 | duration, err := time.ParseDuration(timerDuration) 126 | if err != nil { 127 | log.Fatalf("Invalid timer duration '%s': %v\nExamples: 25m, 1h30m, 90s", 128 | timerDuration, err) 129 | } 130 | 131 | if duration <= 0 { 132 | log.Fatalf("Timer duration must be positive, got: %s", timerDuration) 133 | } 134 | 135 | log.Printf("Timer started for %s", duration) 136 | time.Sleep(duration) 137 | 138 | msg := message 139 | if msg == "" { 140 | msg = fmt.Sprintf("⏰ Timer finished!\n\nDuration: %s", 141 | formatDuration(duration)) 142 | } else { 143 | msg = fmt.Sprintf("⏰ %s\n\nDuration: %s", 144 | msg, formatDuration(duration)) 145 | } 146 | 147 | showDialog(msg, false) 148 | } 149 | ``` 150 | 151 | #### Usage Examples 152 | 153 | ```bash 154 | # 25-minute pomodoro 155 | lmk -t 25m -m "Pomodoro done! Time for a break" 156 | 157 | # 5-minute break 158 | lmk -t 5m -m "Break over, back to work!" 159 | 160 | # Flexible durations 161 | lmk -t 1h30m -m "Deep work session complete" 162 | ``` 163 | 164 | #### Suggested Aliases 165 | 166 | ```bash 167 | alias pomo='lmk -t 25m -m "Pomodoro complete! Take a 5min break 🍅"' 168 | alias short-break='lmk -t 5m -m "Break over! Time to focus 💪"' 169 | alias long-break='lmk -t 15m -m "Long break done! Ready for another session?"' 170 | ``` 171 | 172 | ### 4. Enhanced Information Display 173 | 174 | #### Exit Code Tracking 175 | 176 | ```go 177 | func getMessageAndExitCode(err error, cmd []string, duration time.Duration) 178 | (msg string, exitCode int, isError bool) { 179 | 180 | durationStr := formatDuration(duration) 181 | 182 | if err != nil { 183 | exitCode = 1 184 | if exitErr, ok := err.(*exec.ExitError); ok { 185 | exitCode = exitErr.ExitCode() 186 | } 187 | isError = true 188 | msg = fmt.Sprintf("❌ %s failed!\n\nExit code: %d\nDuration: %s", 189 | strings.Join(cmd, " "), exitCode, durationStr) 190 | } else { 191 | exitCode = 0 192 | isError = false 193 | if *flagMessage != "" { 194 | msg = *flagMessage 195 | } else { 196 | msg = fmt.Sprintf("✅ %s\n\nDuration: %s", 197 | fmt.Sprintf(defaultMessage, strings.Join(cmd, " ")), 198 | durationStr) 199 | } 200 | } 201 | 202 | return 203 | } 204 | ``` 205 | 206 | #### Duration Formatting 207 | 208 | ```go 209 | func formatDuration(d time.Duration) string { 210 | if d < time.Minute { 211 | return fmt.Sprintf("%.1fs", d.Seconds()) 212 | } 213 | if d < time.Hour { 214 | return fmt.Sprintf("%dm %ds", int(d.Minutes()), int(d.Seconds())%60) 215 | } 216 | return fmt.Sprintf("%dh %dm", int(d.Hours()), int(d.Minutes())%60) 217 | } 218 | ``` 219 | 220 | ### 5. CI/CD Modernization 221 | 222 | #### Removed 223 | 224 | - `.travis.yml` - Old Travis CI configuration 225 | 226 | #### Added 227 | 228 | **`.github/workflows/release.yml`** 229 | 230 | - Triggered on version tags (`v*`) 231 | - Uses GoReleaser for multi-platform builds 232 | - Automatic GitHub Releases creation 233 | - Builds for 6 platforms (Linux/macOS/Windows × amd64/arm64) 234 | 235 | **`.github/workflows/test.yml`** 236 | 237 | - Runs on push to master and PRs 238 | - Matrix testing across: 239 | - OS: ubuntu-latest, macos-latest, windows-latest 240 | - Go: 1.23, 1.24 241 | - Race detection enabled 242 | - Code coverage uploaded to Codecov 243 | 244 | **`.goreleaser.yml`** 245 | 246 | - Configures GoReleaser for consistent builds 247 | - Archives with proper naming: `lmk_v2.0.0_Linux_x86_64.tar.gz` 248 | - Windows builds as `.zip`, others as `.tar.gz` 249 | - Includes README and LICENSE in archives 250 | - Generates checksums automatically 251 | 252 | ### 6. Build System Updates 253 | 254 | #### Makefile Enhancements 255 | 256 | ```makefile 257 | .PHONY: default build test clean install xbuild lint 258 | 259 | default: build 260 | 261 | build: 262 | go build -v -ldflags="-s -w" -o lmk . 263 | 264 | test: 265 | go test -v -race -coverprofile=coverage.txt -covermode=atomic ./... 266 | 267 | clean: 268 | rm -rf lmk build/ dist/ coverage.txt 269 | 270 | install: 271 | go install -v -ldflags="-s -w" . 272 | 273 | # Cross-compilation for multiple architectures 274 | xbuild: 275 | @mkdir -p build 276 | @echo "Building for Linux amd64..." 277 | GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o build/lmk-linux-amd64 . 278 | @echo "Building for Linux arm64..." 279 | GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o build/lmk-linux-arm64 . 280 | @echo "Building for macOS amd64..." 281 | GOOS=darwin GOARCH=amd64 go build -ldflags="-s -w" -o build/lmk-darwin-amd64 . 282 | @echo "Building for macOS arm64..." 283 | GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w" -o build/lmk-darwin-arm64 . 284 | @echo "Building for Windows amd64..." 285 | GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -o build/lmk-windows-amd64.exe . 286 | @echo "Building for Windows arm64..." 287 | GOOS=windows GOARCH=arm64 go build -ldflags="-s -w" -o build/lmk-windows-arm64.exe . 288 | @echo 'DONE' 289 | 290 | lint: 291 | @which golangci-lint > /dev/null || (echo "golangci-lint not installed" && exit 1) 292 | golangci-lint run ./... 293 | ``` 294 | 295 | **New Features:** 296 | - `-ldflags="-s -w"` strips debug info for smaller binaries 297 | - Cross-compilation for 6 platforms 298 | - Proper phony targets 299 | - Lint integration ready 300 | 301 | ### 7. Test Suite 302 | 303 | Created `lmk_test.go`: 304 | 305 | ```go 306 | // Tests cover: 307 | - formatDuration() with various time ranges 308 | - getExecutableAndArgs() command parsing 309 | - getMessageAndExitCode() success/failure messages 310 | - escapeAppleScript() string escaping 311 | - escapeWindowsString() string escaping 312 | - run() command execution 313 | - Timer duration parsing validation 314 | ``` 315 | 316 | **Testing Approach:** 317 | - Unit tests focus on logic, not UI 318 | - `showDialog()` is NOT tested (would spam dialogs during test runs) 319 | - Dialog behavior is indirectly tested through message formatting 320 | - Actual dialog testing is manual (see Testing Checklist below) 321 | 322 | **Test Execution:** 323 | ```bash 324 | $ go test -v ./... 325 | === RUN TestFormatDuration 326 | --- PASS: TestFormatDuration (0.00s) 327 | === RUN TestGetExecutableAndArgs 328 | --- PASS: TestGetExecutableAndArgs (0.00s) 329 | # ... all tests passing 330 | PASS 331 | ok github.com/fgrehm/lmk 0.004s 332 | ``` 333 | 334 | ### 8. Documentation 335 | 336 | #### Updated README.md 337 | 338 | **New Sections:** 339 | - "What's New in v2.0.0" - Prominent feature list 340 | - Timer/Pomodoro examples with shell aliases 341 | - Platform dependencies with install commands 342 | - Updated usage showing both modes 343 | 344 | **Before:** 345 | ``` 346 | Usage: lmk [options...] command 347 | 348 | Options: 349 | -m Message to display in case of success 350 | ``` 351 | 352 | **After:** 353 | ``` 354 | Usage: lmk [options...] command 355 | or: lmk -t [-m ] 356 | 357 | Options: 358 | -m Message to display in case of success 359 | -t, -timer Timer duration (e.g., 25m, 1h30m, 90s) 360 | -version Show version information 361 | ``` 362 | 363 | --- 364 | 365 | ## Technical Decisions 366 | 367 | ### 1. Why yad over zenity? 368 | 369 | **Considered Options:** 370 | - zenity (GTK standard) 371 | - yad (zenity fork) 372 | - kdialog (KDE) 373 | - notify-send (original) 374 | 375 | **Decision:** Prefer yad, fallback to zenity, then kdialog, finally notify-send 376 | 377 | **Rationale:** 378 | - `yad` has proper `--on-top` flag that window managers respect 379 | - zenity's `--modal` is inconsistent across window managers 380 | - yad is actively maintained and more feature-rich 381 | - notify-send as last resort ensures lmk works even on minimal systems 382 | - Better to have degraded notification (must press Enter) than no notification at all 383 | - Graceful fallback chain ensures broad compatibility 384 | 385 | ### 2. Why modal dialogs instead of notifications? 386 | 387 | **Problem:** Desktop notifications: 388 | - Disappear after timeout 389 | - Can be missed when away 390 | - No reliable way to make them persistent 391 | 392 | **Solution:** Modal dialogs: 393 | - Block until acknowledged 394 | - Stay on top of windows 395 | - Impossible to miss 396 | - Better UX for long-running tasks 397 | 398 | ### 3. Why -t instead of only -timer? 399 | 400 | **User Request:** `-t` is more concise for frequent use 401 | 402 | **Decision:** Support both `-t` and `-timer` 403 | 404 | **Implementation:** 405 | ```go 406 | func init() { 407 | flag.StringVar(&flagTimer, "t", "", "") 408 | flag.StringVar(&flagTimer, "timer", "", "") 409 | } 410 | ``` 411 | 412 | Both flags point to same variable, providing flexibility without code duplication. 413 | 414 | ### 4. Why include timer in v2.0.0? 415 | 416 | **Rationale:** 417 | - Natural fit: "notify me when something is done" 418 | - Timer = "notify me when time is done" 419 | - Zero external dependencies needed 420 | - Minimal code addition (~20 LOC) 421 | - High user value for pomodoro workflows 422 | 423 | ### 5. Version number jump (0.1.0 → 2.0.0) 424 | 425 | **Reasoning:** 426 | - Breaking change in behavior (notification → dialog) 427 | - Major feature addition (timer mode) 428 | - Complete modernization (Go 1.12 → 1.23) 429 | - Signals significant project revival 430 | 431 | --- 432 | 433 | ## File Structure Changes 434 | 435 | ### Added Files 436 | ``` 437 | .github/ 438 | ├── workflows/ 439 | │ ├── release.yml # GitHub Actions release workflow 440 | │ └── test.yml # CI test workflow 441 | .goreleaser.yml # GoReleaser configuration 442 | go.mod # Go modules file 443 | lmk_test.go # Test suite 444 | docs/ 445 | └── v2.0.0-implementation.md # This file 446 | ``` 447 | 448 | ### Modified Files 449 | ``` 450 | lmk.go # Complete rewrite 451 | Makefile # Enhanced with modern targets 452 | README.md # Updated docs 453 | .gitignore # Added build artifacts 454 | ``` 455 | 456 | ### Files to Remove (manual cleanup) 457 | 458 | ``` 459 | .travis.yml # Old CI system 460 | ``` 461 | 462 | --- 463 | 464 | ## Platform Support Matrix 465 | 466 | | Platform | Architecture | Dialog Tool | Status | 467 | |----------|-------------|-------------|--------| 468 | | Linux | amd64 | yad/zenity | ✅ | 469 | | Linux | arm64 | yad/zenity | ✅ | 470 | | macOS | amd64 | osascript | ✅ | 471 | | macOS | arm64 | osascript | ✅ | 472 | | Windows | amd64 | PowerShell | ✅ | 473 | | Windows | arm64 | PowerShell | ✅ | 474 | 475 | --- 476 | 477 | ## Migration Guide 478 | 479 | ### For Users 480 | 481 | **v0.1.0 behavior:** 482 | ```bash 483 | lmk npm test 484 | # → Shows notify-send notification 485 | # → Repeats every 30 seconds 486 | # → Must press Enter in terminal 487 | ``` 488 | 489 | **v2.0.0 behavior:** 490 | ```bash 491 | lmk npm test 492 | # → Shows modal dialog on screen center 493 | # → Stays on top until clicked 494 | # → No terminal interaction needed 495 | ``` 496 | 497 | **Breaking Changes:** 498 | - No longer requires Enter key press 499 | - Dialog appears on screen instead of notification area 500 | - Linux now requires yad/zenity/kdialog (not notify-send) 501 | 502 | **New Features:** 503 | ```bash 504 | # Timer mode (new in v2.0.0) 505 | lmk -t 25m -m "Pomodoro complete!" 506 | ``` 507 | 508 | ### For Contributors 509 | 510 | **Build Commands:** 511 | ```bash 512 | # Old way 513 | go build ./... 514 | 515 | # New way 516 | make build # Local build 517 | make test # Run tests 518 | make xbuild # Cross-compile all platforms 519 | ``` 520 | 521 | **Running Tests:** 522 | ```bash 523 | make test 524 | # or 525 | go test -v -race ./... 526 | ``` 527 | 528 | **Release Process:** 529 | ```bash 530 | # Old way: Travis CI on tag push 531 | git tag v0.1.0 532 | git push origin v0.1.0 533 | 534 | # New way: GitHub Actions + GoReleaser 535 | git tag v2.0.0 536 | git push origin v2.0.0 537 | # → Automatic builds for 6 platforms 538 | # → GitHub Release created 539 | # → Binaries uploaded 540 | ``` 541 | 542 | --- 543 | 544 | ## Code Statistics 545 | 546 | ```bash 547 | $ wc -l *.go Makefile *.md .github/workflows/*.yml .goreleaser.yml 548 | 275 lmk.go 549 | 245 lmk_test.go 550 | 36 Makefile 551 | 96 CHANGELOG.md 552 | 133 README.md 553 | 32 .github/workflows/release.yml 554 | 36 .github/workflows/test.yml 555 | 62 .goreleaser.yml 556 | 915 total 557 | ``` 558 | 559 | **Key Files:** 560 | - Go code: 675 lines (lmk.go + tests) 561 | - lmk.go: 296 lines (main implementation) 562 | - lmk_test.go: 245 lines (unit tests) 563 | - lmk_integration_test.go: 134 lines (integration tests with dry-run) 564 | - Documentation: 229 lines (README + CHANGELOG) 565 | - Build/CI: 166 lines (Makefile + workflows + goreleaser) 566 | 567 | **Test Coverage:** 568 | ```bash 569 | $ go test -cover ./... 570 | ok github.com/fgrehm/lmk 1.014s coverage: 39.0% of statements 571 | ``` 572 | 573 | **Dialog Delay:** 574 | Added 3 second delay before showing dialogs to prevent accidental dismissal while typing: 575 | ```bash 576 | $ ./lmk echo "test" 577 | 2025/11/17 14:16:05 Running [echo test] 578 | test 579 | 2025/11/17 14:16:05 Waiting 1.5s before showing dialog... 580 | 2025/11/17 14:16:07 Showing dialog 581 | ``` 582 | 583 | Configurable via `LMK_DELAY` environment variable: 584 | ```bash 585 | LMK_DELAY=3s lmk npm test # 3 second delay 586 | LMK_DELAY=500ms lmk npm test # 0.5 second delay 587 | LMK_DELAY=0s lmk npm test # No delay 588 | ``` 589 | 590 | **Dry-Run Mode:** 591 | Added `LMK_DRY_RUN` environment variable for testing without showing dialogs: 592 | ```bash 593 | $ LMK_DRY_RUN=1 ./lmk echo "test" 594 | 2025/11/17 14:10:13 Running [echo test] 595 | test 596 | [DRY RUN] Dialog message: ✅ echo test has completed successfully 597 | [DRY RUN] Is error: false 598 | ``` 599 | 600 | This enables: 601 | - Integration tests without UI spam 602 | - Debugging dialog construction 603 | - Verifying message formatting 604 | - Testing on headless systems 605 | 606 | --- 607 | 608 | ## Dependencies 609 | 610 | ### Runtime Dependencies 611 | **None** - Uses only Go standard library: 612 | - `bufio` 613 | - `flag` 614 | - `fmt` 615 | - `log` 616 | - `os` 617 | - `os/exec` 618 | - `runtime` 619 | - `strings` 620 | - `time` 621 | 622 | ### System Dependencies 623 | 624 | **Linux:** 625 | - yad (recommended): `apt install yad` or `yum install yad` 626 | - zenity (fallback): `apt install zenity` 627 | - kdialog (KDE fallback): `apt install kdialog` 628 | 629 | **macOS:** 630 | - osascript (built-in) 631 | 632 | **Windows:** 633 | - PowerShell (built-in) 634 | 635 | --- 636 | 637 | ## Future Enhancements (Not in v2.0.0) 638 | 639 | ### Considered but deferred: 640 | 1. **Config file support** (`~/.lmkrc`) 641 | - Save default timer durations 642 | - Custom notification sounds 643 | - Per-project settings 644 | 645 | 2. **Notification history** 646 | - Log of completed commands/timers 647 | - Statistics (avg duration, success rate) 648 | 649 | 3. **Sound alerts** 650 | - Play sound on completion 651 | - Different sounds for success/failure 652 | 653 | 4. **Custom notification backends** 654 | - Webhook support (Slack, Discord) 655 | - Email notifications 656 | - SMS via Twilio 657 | 658 | 5. **Progress indicators** 659 | - Show timer countdown in terminal 660 | - Progress bar for long-running commands 661 | 662 | 6. **Desktop tray integration** 663 | - System tray icon showing active timers 664 | - Quick access to common timers 665 | 666 | ### Why deferred? 667 | - Keep v2.0.0 focused on core improvements 668 | - Avoid feature creep 669 | - External dependencies would be needed 670 | - Gather user feedback first 671 | 672 | --- 673 | 674 | ## Testing Checklist 675 | 676 | **Automated (unit tests):** 677 | - [x] Build succeeds on Go 1.23+ 678 | - [x] All unit tests pass 679 | - [x] Cross-compilation works for 6 platforms 680 | 681 | **Manual (actual dialog testing):** 682 | - [x] Timer mode with various durations (5s, 1m, 25m) 683 | - [x] Command execution with success/failure 684 | - [x] Dialog appears on screen center 685 | - [x] Dialog stays on top when clicking other windows 686 | - [x] Exit code displayed on failure 687 | - [x] Execution time shown in dialog 688 | - [x] Help text shows all options 689 | - [x] Version flag works 690 | 691 | **Tested on:** 692 | - Linux (Ubuntu) with zenity - ✅ 693 | - Linux (Ubuntu) with yad - ✅ (recommended) 694 | 695 | **Needs testing:** 696 | - macOS with osascript 697 | - Windows with PowerShell 698 | - KDE with kdialog 699 | 700 | ## Lessons Learned 701 | 702 | ### What Went Well 703 | 1. **Zero external dependencies** - Kept project simple and maintainable 704 | 2. **Graceful fallbacks** - Works across different Linux environments 705 | 3. **Test-first approach** - Caught issues early 706 | 4. **Clear user request** - Timer feature was well-specified 707 | 708 | ### Challenges 709 | 1. **Window manager differences** - zenity `--modal` inconsistent 710 | - Solution: Prefer yad with better `--on-top` support 711 | 2. **Dialog dismissal** - Making dialogs truly stay on top 712 | - Solution: Platform-specific approaches (yad on Linux, System Events on macOS) 713 | 3. **Cross-platform testing** - Limited to Linux during development 714 | - Mitigation: Clear fallback logic, documented testing needed 715 | 716 | ### Best Practices Established 717 | 1. Always prefer specialized dialog tools over notifications 718 | 2. Implement graceful fallback chains for Linux tools 719 | 3. Use init() for flag aliases (clean pattern) 720 | 4. Format durations in human-readable form 721 | 5. Show execution time in all notifications 722 | 723 | --- 724 | 725 | ## Acknowledgments 726 | 727 | **Original Author:** Fabio Rehm 728 | **Original Repository:** github.com/fgrehm/lmk 729 | **AI Assistant:** Claude Sonnet 4.5 (claude-sonnet-4-5-20250929) 730 | **Modernization Date:** November 17, 2025 731 | 732 | --- 733 | 734 | ## Appendix: Complete Command Reference 735 | 736 | ### Command Mode 737 | ```bash 738 | # Basic usage 739 | lmk [args...] 740 | 741 | # With custom message 742 | lmk -m "Custom success message" 743 | 744 | # Examples 745 | lmk npm test 746 | lmk make build 747 | lmk docker compose up 748 | lmk cargo build --release 749 | ``` 750 | 751 | ### Timer Mode 752 | ```bash 753 | # Basic timer 754 | lmk -t 755 | 756 | # Timer with message 757 | lmk -t -m "Timer message" 758 | 759 | # Duration formats 760 | lmk -t 30s # 30 seconds 761 | lmk -t 5m # 5 minutes 762 | lmk -t 25m # 25 minutes (pomodoro) 763 | lmk -t 1h # 1 hour 764 | lmk -t 1h30m # 1 hour 30 minutes 765 | lmk -t 90s # 90 seconds 766 | 767 | # Pomodoro examples 768 | lmk -t 25m -m "Work session done!" 769 | lmk -t 5m -m "Break over!" 770 | lmk -t 15m -m "Long break finished" 771 | ``` 772 | 773 | ### Utility Commands 774 | ```bash 775 | lmk -version # Show version 776 | lmk --help # Show help 777 | ``` 778 | 779 | --- 780 | 781 | **Document Version:** 1.0 782 | **Last Updated:** November 17, 2025 783 | **Status:** Implementation Complete ✅ 784 | -------------------------------------------------------------------------------- /lmk.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "flag" 6 | "fmt" 7 | "io" 8 | "log" 9 | "os" 10 | "os/exec" 11 | "runtime" 12 | "strings" 13 | "time" 14 | ) 15 | 16 | var ( 17 | flagMessage = flag.String("m", "", "") 18 | flagTimer string 19 | flagAckMode = flag.Bool("a", false, "") 20 | flagVersion = flag.Bool("version", false, "") 21 | 22 | version = "2.2.0" 23 | defaultMessage = "%s has completed successfully" 24 | ) 25 | 26 | var usage = `Usage: lmk [options...] command 27 | or: lmk -t [-m ] 28 | or: lmk claude-hooks [install [options]] 29 | 30 | Options: 31 | -m Message to display in case of success, defaults to "[command] has completed successfully" 32 | -t, -timer Timer duration (e.g., 25m, 1h30m, 90s) - runs a countdown timer instead of a command 33 | -a, -ack-mode Require explicit acknowledgment - user must click "Ack" button, dismisses re-show dialog 34 | -version Show version information 35 | 36 | Subcommands: 37 | claude-hooks Process Claude Code notification hooks (reads JSON from stdin) 38 | claude-hooks install Install lmk hooks into Claude Code settings 39 | --global Install to ~/.claude/settings.json (default: .claude/settings.local.json) 40 | --type TYPES Only install for specific notification types (comma-separated) 41 | --ack-mode Require explicit acknowledgment for claude hook dialogs 42 | --uninstall Remove lmk hooks from configuration 43 | --dry-run Show what would be changed without modifying files 44 | 45 | Examples: 46 | lmk npm test Run command and notify when done 47 | lmk -t 25m -m "Pomodoro done!" 25 minute timer 48 | lmk -t 5m -m "Break over!" 5 minute break timer 49 | lmk claude-hooks install Install Claude Code hooks (project-local) 50 | lmk claude-hooks install --global Install Claude Code hooks (globally) 51 | lmk claude-hooks install --type permission_prompt,idle_prompt 52 | Install hooks for specific notification types 53 | ` 54 | 55 | func init() { 56 | flag.StringVar(&flagTimer, "t", "", "") 57 | flag.StringVar(&flagTimer, "timer", "", "") 58 | flag.BoolVar(flagAckMode, "ack-mode", false, "") 59 | } 60 | 61 | func main() { 62 | flag.Usage = func() { 63 | fmt.Fprint(os.Stderr, usage) 64 | } 65 | flag.Parse() 66 | 67 | if *flagVersion { 68 | fmt.Printf("lmk version %s\n", version) 69 | os.Exit(0) 70 | } 71 | 72 | // Check for claude-hooks subcommand 73 | args := flag.Args() 74 | if len(args) > 0 && args[0] == "claude-hooks" { 75 | handleClaudeHooks(args[1:]) 76 | return 77 | } 78 | 79 | // Handle timer mode 80 | if flagTimer != "" { 81 | runTimer(flagTimer, *flagMessage, *flagAckMode) 82 | return 83 | } 84 | 85 | cmd := flag.Args() 86 | 87 | var msg string 88 | var duration time.Duration 89 | var isError bool 90 | 91 | if len(cmd) > 0 { 92 | executable, args := getExecutableAndArgs(cmd) 93 | log.Printf("Running %s", cmd) 94 | start := time.Now() 95 | err := run(executable, args...) 96 | duration = time.Since(start) 97 | msg, _, isError = getMessageAndExitCode(err, cmd, duration) 98 | } else { 99 | log.Print("Nothing to run, showing notification") 100 | if *flagMessage != "" { 101 | msg = *flagMessage 102 | } else { 103 | msg = "Take a look at your terminal" 104 | } 105 | isError = false 106 | } 107 | 108 | showDialog(msg, isError, *flagAckMode) 109 | } 110 | 111 | func runTimer(timerDuration string, message string, ackMode bool) { 112 | duration, err := time.ParseDuration(timerDuration) 113 | if err != nil { 114 | log.Fatalf("Invalid timer duration '%s': %v\nExamples: 25m, 1h30m, 90s", timerDuration, err) 115 | } 116 | 117 | if duration <= 0 { 118 | log.Fatalf("Timer duration must be positive, got: %s", timerDuration) 119 | } 120 | 121 | log.Printf("Timer started for %s", duration) 122 | time.Sleep(duration) 123 | 124 | msg := message 125 | if msg == "" { 126 | msg = fmt.Sprintf("⏰ Timer finished!\n\nDuration: %s", formatDuration(duration)) 127 | } else { 128 | msg = fmt.Sprintf("⏰ %s\n\nDuration: %s", msg, formatDuration(duration)) 129 | } 130 | 131 | showDialog(msg, false, ackMode) 132 | } 133 | 134 | func run(executable string, args ...string) error { 135 | cmd := exec.Command(executable, args...) 136 | cmd.Stdin = os.Stdin 137 | cmd.Stdout = os.Stdout 138 | cmd.Stderr = os.Stderr 139 | cmd.Env = os.Environ() 140 | 141 | return cmd.Run() 142 | } 143 | 144 | func getExecutableAndArgs(cmd []string) (string, []string) { 145 | if len(cmd) == 0 { 146 | log.Fatalf("No command was provided to lmk") 147 | } 148 | 149 | executable, lookErr := exec.LookPath(cmd[0]) 150 | if lookErr != nil { 151 | log.Fatal(lookErr) 152 | } 153 | return executable, cmd[1:] 154 | } 155 | 156 | func getMessageAndExitCode(err error, cmd []string, duration time.Duration) (msg string, exitCode int, isError bool) { 157 | durationStr := formatDuration(duration) 158 | 159 | if err != nil { 160 | exitCode = 1 161 | if exitErr, ok := err.(*exec.ExitError); ok { 162 | exitCode = exitErr.ExitCode() 163 | } 164 | isError = true 165 | msg = fmt.Sprintf("❌ %s failed!\n\nExit code: %d\nDuration: %s", strings.Join(cmd, " "), exitCode, durationStr) 166 | } else { 167 | exitCode = 0 168 | isError = false 169 | if *flagMessage != "" { 170 | msg = *flagMessage 171 | } else { 172 | msg = fmt.Sprintf("✅ %s\n\nDuration: %s", fmt.Sprintf(defaultMessage, strings.Join(cmd, " ")), durationStr) 173 | } 174 | } 175 | 176 | return 177 | } 178 | 179 | func formatDuration(d time.Duration) string { 180 | if d < time.Minute { 181 | return fmt.Sprintf("%.1fs", d.Seconds()) 182 | } 183 | if d < time.Hour { 184 | return fmt.Sprintf("%dm %ds", int(d.Minutes()), int(d.Seconds())%60) 185 | } 186 | return fmt.Sprintf("%dh %dm", int(d.Hours()), int(d.Minutes())%60) 187 | } 188 | 189 | // isAckModeSupported checks if ack mode is supported on this platform 190 | func isAckModeSupported() bool { 191 | // Ack mode is currently only supported on Linux with yad 192 | if runtime.GOOS != "linux" { 193 | return false 194 | } 195 | _, err := exec.LookPath("yad") 196 | return err == nil 197 | } 198 | 199 | func showDialog(msg string, isError bool, ackMode bool) { 200 | // Dry-run mode for testing and debugging 201 | if os.Getenv("LMK_DRY_RUN") != "" { 202 | fmt.Fprintf(os.Stderr, "[DRY RUN] Dialog message: %s\n", msg) 203 | fmt.Fprintf(os.Stderr, "[DRY RUN] Is error: %t\n", isError) 204 | fmt.Fprintf(os.Stderr, "[DRY RUN] Ack mode: %t\n", ackMode) 205 | return 206 | } 207 | 208 | // Check if ack mode is supported on this platform 209 | if ackMode && !isAckModeSupported() { 210 | log.Printf("Warning: Ack mode is only supported on Linux with yad installed") 211 | log.Printf("Falling back to normal dialog mode") 212 | ackMode = false 213 | } 214 | 215 | // Delay before showing dialog to prevent accidental dismissal 216 | // Can be customized via LMK_DELAY environment variable (default: 0) 217 | delay := 0 * time.Millisecond // Default: no delay 218 | if delayStr := os.Getenv("LMK_DELAY"); delayStr != "" { 219 | if customDelay, err := time.ParseDuration(delayStr); err == nil { 220 | delay = customDelay 221 | } 222 | } 223 | 224 | if delay > 0 { 225 | log.Printf("Waiting %v before showing dialog...", delay) 226 | time.Sleep(delay) 227 | } 228 | 229 | var cmd *exec.Cmd 230 | 231 | switch runtime.GOOS { 232 | case "linux": 233 | // Try yad first (has best always-on-top support) 234 | if _, err := exec.LookPath("yad"); err == nil { 235 | image := "dialog-information" 236 | if isError { 237 | image = "dialog-error" 238 | } 239 | 240 | // If ack mode is enabled, loop until "Ack" is clicked 241 | if ackMode { 242 | backoff := 5 * time.Second 243 | maxBackoff := 60 * time.Second 244 | 245 | for { 246 | // Buttons: Dismiss (focused/default) and Ack 247 | cmd = exec.Command("yad", 248 | "--text="+msg, 249 | "--title=lmk", 250 | "--width=450", 251 | "--height=150", 252 | "--center", 253 | "--button=Dismiss:1", 254 | "--button=Ack:0", 255 | "--image="+image, 256 | "--on-top", 257 | "--no-escape", 258 | "--borders=10") 259 | 260 | if err := cmd.Run(); err == nil { 261 | // Exit code 0 = Ack clicked 262 | break 263 | } else if exitErr, ok := err.(*exec.ExitError); ok { 264 | if exitErr.ExitCode() == 1 { 265 | // Dismiss clicked - wait and re-show 266 | log.Printf("Dismiss clicked, re-showing in %v", backoff) 267 | time.Sleep(backoff) 268 | // Double backoff, cap at 60s 269 | if backoff < maxBackoff { 270 | backoff *= 2 271 | if backoff > maxBackoff { 272 | backoff = maxBackoff 273 | } 274 | } 275 | continue 276 | } 277 | } 278 | // For any other error, just break and log it 279 | if err != nil { 280 | log.Printf("Error showing dialog: %v", err) 281 | } 282 | break 283 | } 284 | return 285 | } 286 | 287 | // Normal mode (not ack mode) 288 | button := "gtk-ok:0" 289 | // yad has proper --on-top and --center support with better sizing 290 | cmd = exec.Command("yad", 291 | "--text="+msg, 292 | "--title=lmk", 293 | "--width=450", 294 | "--height=150", 295 | "--center", 296 | "--button="+button, 297 | "--image="+image, 298 | "--on-top", 299 | "--no-escape", 300 | "--borders=10") 301 | } else if _, err := exec.LookPath("zenity"); err == nil { 302 | // Fallback to zenity 303 | // Use question dialog which stays on top better than info/error dialogs 304 | cmd = exec.Command("zenity", "--question", "--title=lmk", "--text="+msg, "--width=400", 305 | "--ok-label=OK", "--no-cancel", "--ellipsize") 306 | } else if _, err := exec.LookPath("kdialog"); err == nil { 307 | // Fallback to kdialog for KDE 308 | dialogType := "--msgbox" 309 | if isError { 310 | dialogType = "--error" 311 | } 312 | cmd = exec.Command("kdialog", dialogType, msg, "--title", "lmk") 313 | } else if _, err := exec.LookPath("notify-send"); err == nil { 314 | // Last resort: notify-send with Enter prompt (v1.0.0 behavior) 315 | log.Printf("Warning: No dialog tools found (yad/zenity/kdialog)") 316 | log.Printf("Falling back to notify-send + Enter prompt") 317 | showNotificationAndWait(msg, isError) 318 | return 319 | } else { 320 | log.Printf("Error: No notification tools found!") 321 | log.Printf("Please install one of: yad, zenity, kdialog, or notify-send") 322 | log.Printf("Message: %s", msg) 323 | return 324 | } 325 | 326 | case "darwin": 327 | // macOS: use osascript with dialog - make it giving application 328 | // This brings the dialog to the front 329 | icon := "note" 330 | if isError { 331 | icon = "stop" 332 | } 333 | script := fmt.Sprintf(`tell application "System Events" 334 | activate 335 | display dialog "%s" with title "lmk" with icon %s buttons {"OK"} default button "OK" giving up after 3600 336 | end tell`, 337 | escapeAppleScript(msg), icon) 338 | cmd = exec.Command("osascript", "-e", script) 339 | 340 | case "windows": 341 | // Windows: use PowerShell dialogs with TopMost property 342 | icon := "Information" 343 | if isError { 344 | icon = "Error" 345 | } 346 | // Create a form-based messagebox that stays on top 347 | psScript := fmt.Sprintf(`Add-Type -AssemblyName System.Windows.Forms; $form = New-Object System.Windows.Forms.Form; $form.TopMost = $true; $form.WindowState = 'Minimized'; $form.Show(); [System.Windows.Forms.MessageBox]::Show($form, '%s', 'lmk', 'OK', '%s'); $form.Close()`, 348 | escapeWindowsString(msg), icon) 349 | cmd = exec.Command("powershell", "-Command", psScript) 350 | 351 | default: 352 | log.Printf("Unsupported platform: %s", runtime.GOOS) 353 | log.Printf("Message: %s", msg) 354 | return 355 | } 356 | 357 | log.Print("Showing dialog") 358 | 359 | // Show what would be executed in dry-run (after cmd is built) 360 | if os.Getenv("LMK_DRY_RUN") != "" { 361 | fmt.Fprintf(os.Stderr, "[DRY RUN] Would execute: %s %v\n", cmd.Path, cmd.Args[1:]) 362 | return 363 | } 364 | 365 | if err := cmd.Run(); err != nil { 366 | log.Printf("Error showing dialog: %v", err) 367 | log.Printf("Message: %s", msg) 368 | } 369 | } 370 | 371 | func escapeAppleScript(s string) string { 372 | s = strings.ReplaceAll(s, "\\", "\\\\") 373 | s = strings.ReplaceAll(s, "\"", "\\\"") 374 | s = strings.ReplaceAll(s, "\n", "\\n") 375 | return s 376 | } 377 | 378 | func escapeWindowsString(s string) string { 379 | s = strings.ReplaceAll(s, "'", "''") 380 | s = strings.ReplaceAll(s, "\n", "`n") 381 | return s 382 | } 383 | 384 | func showNotificationAndWait(msg string, isError bool) { 385 | // Dry-run mode for testing and debugging 386 | if os.Getenv("LMK_DRY_RUN") != "" { 387 | fmt.Fprintf(os.Stderr, "[DRY RUN] Would send notification: %s\n", msg) 388 | fmt.Fprintf(os.Stderr, "[DRY RUN] Would wait for Enter\n") 389 | return 390 | } 391 | 392 | // Fallback to v1.0.0 behavior: notify-send + wait for Enter 393 | icon := "emblem-default" 394 | if isError { 395 | icon = "dialog-error" 396 | } 397 | 398 | log.Print("Sending notification (press Enter to dismiss)") 399 | exec.Command("notify-send", "-i", icon, "lmk", msg).Run() 400 | 401 | fmt.Println("\nPress Enter to dismiss...") 402 | fmt.Scanln() 403 | } 404 | 405 | // ClaudeHookPayload represents the JSON payload from Claude Code hooks 406 | type ClaudeHookPayload struct { 407 | SessionID string `json:"session_id"` 408 | TranscriptPath string `json:"transcript_path"` 409 | Cwd string `json:"cwd"` 410 | PermissionMode string `json:"permission_mode"` 411 | HookEventName string `json:"hook_event_name"` 412 | Message string `json:"message"` 413 | NotificationType string `json:"notification_type"` 414 | } 415 | 416 | // ClaudeSettings represents Claude Code settings file structure 417 | // Uses map to preserve all existing settings we don't know about 418 | type ClaudeSettings map[string]interface{} 419 | 420 | // ClaudeHooks represents the hooks section of Claude settings 421 | type ClaudeHooks struct { 422 | Notification []NotificationHook `json:"Notification,omitempty"` 423 | } 424 | 425 | // NotificationHook represents a single notification hook configuration 426 | type NotificationHook struct { 427 | Matcher string `json:"matcher,omitempty"` 428 | Hooks []HookConfig `json:"hooks"` 429 | } 430 | 431 | // HookConfig represents a hook command configuration 432 | type HookConfig struct { 433 | Type string `json:"type"` 434 | Command string `json:"command"` 435 | } 436 | 437 | // handleClaudeHooks processes Claude Code hook events from stdin or handles install subcommand 438 | func handleClaudeHooks(args []string) { 439 | // Check for install subcommand 440 | if len(args) > 0 && args[0] == "install" { 441 | installClaudeHooks(args[1:]) 442 | return 443 | } 444 | 445 | // Otherwise, process hook payload from stdin 446 | // Parse flags for non-install case 447 | hookFlags := flag.NewFlagSet("claude-hooks", flag.ContinueOnError) 448 | ackMode := hookFlags.Bool("ack-mode", false, "") 449 | hookFlags.Parse(args) 450 | 451 | processHookPayload(*ackMode) 452 | } 453 | 454 | // processHookPayload reads and processes a hook payload from stdin 455 | func processHookPayload(ackMode bool) { 456 | // Setup logging for debugging hooks 457 | logFile := setupClaudeHooksLogging() 458 | if logFile != nil { 459 | defer logFile.Close() 460 | log.Printf("[claude-hooks] Starting hook processing") 461 | } 462 | 463 | // Read JSON from stdin 464 | data, err := io.ReadAll(os.Stdin) 465 | if err != nil { 466 | log.Fatalf("[claude-hooks] Error reading stdin: %v", err) 467 | } 468 | log.Printf("[claude-hooks] Read %d bytes from stdin", len(data)) 469 | 470 | // Parse JSON 471 | var payload ClaudeHookPayload 472 | if err := json.Unmarshal(data, &payload); err != nil { 473 | log.Fatalf("[claude-hooks] Error parsing JSON: %v\nRaw data: %s", err, string(data)) 474 | } 475 | log.Printf("[claude-hooks] Parsed payload: type=%s, message=%s", payload.NotificationType, payload.Message) 476 | 477 | // Validate required fields 478 | if payload.NotificationType == "" { 479 | log.Fatalf("[claude-hooks] Missing required field: notification_type") 480 | } 481 | if payload.Message == "" { 482 | log.Fatalf("[claude-hooks] Missing required field: message") 483 | } 484 | 485 | // Get icon based on notification type 486 | icon := getNotificationIcon(payload.NotificationType) 487 | log.Printf("[claude-hooks] Using icon: %s", icon) 488 | 489 | // Extract project name from cwd 490 | projectName := extractProjectName(payload.Cwd) 491 | log.Printf("[claude-hooks] Project: %s (from cwd: %s)", projectName, payload.Cwd) 492 | 493 | // Format message with project context 494 | var msg string 495 | if projectName != "" { 496 | msg = fmt.Sprintf("%s Claude Code\nProject: %s\n\n%s", icon, projectName, payload.Message) 497 | } else { 498 | msg = fmt.Sprintf("%s Claude Code\n\n%s", icon, payload.Message) 499 | } 500 | 501 | // Claude Code hooks need immediate feedback - disable delay 502 | os.Setenv("LMK_DELAY", "0s") 503 | 504 | log.Printf("[claude-hooks] Showing dialog (ack_mode=%v)", ackMode) 505 | // Show dialog (notifications are informational, not errors) 506 | showDialog(msg, false, ackMode) 507 | log.Printf("[claude-hooks] Dialog completed") 508 | } 509 | 510 | // extractProjectName gets the project name from the cwd path 511 | func extractProjectName(cwd string) string { 512 | if cwd == "" { 513 | return "" 514 | } 515 | 516 | // Normalize path separators (handle both Unix and Windows paths) 517 | // This is needed because on Unix, filepath.Base won't recognize \ as separator 518 | normalizedPath := strings.ReplaceAll(cwd, "\\", "/") 519 | normalizedPath = strings.TrimSuffix(normalizedPath, "/") 520 | 521 | // Get the last component 522 | parts := strings.Split(normalizedPath, "/") 523 | if len(parts) > 0 { 524 | base := parts[len(parts)-1] 525 | // Handle edge cases like root paths 526 | if base == "" || base == "." { 527 | return "" 528 | } 529 | return base 530 | } 531 | return "" 532 | } 533 | 534 | // setupClaudeHooksLogging configures logging to /tmp for debugging hooks 535 | func setupClaudeHooksLogging() *os.File { 536 | // Allow disabling via env var 537 | if os.Getenv("LMK_NO_LOG") != "" { 538 | return nil 539 | } 540 | 541 | logPath := os.Getenv("LMK_LOG_PATH") 542 | if logPath == "" { 543 | logPath = "/tmp/lmk-claude-hooks.log" 544 | } 545 | 546 | logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 547 | if err != nil { 548 | // If we can't open log file, just continue without logging 549 | // Don't want to break the hook if /tmp has issues 550 | return nil 551 | } 552 | 553 | // Configure logger to write to file 554 | log.SetOutput(logFile) 555 | log.SetFlags(log.LstdFlags | log.Lmicroseconds) 556 | 557 | return logFile 558 | } 559 | 560 | // getNotificationIcon returns an emoji icon for the notification type 561 | func getNotificationIcon(notificationType string) string { 562 | switch notificationType { 563 | case "permission_prompt": 564 | return "🔐" 565 | case "idle_prompt": 566 | return "⏱️" 567 | case "auth_success": 568 | return "✅" 569 | case "elicitation_dialog": 570 | return "📝" 571 | default: 572 | return "🤖" 573 | } 574 | } 575 | 576 | // installClaudeHooks handles the install subcommand 577 | func installClaudeHooks(args []string) { 578 | // Parse install flags 579 | installFlags := flag.NewFlagSet("install", flag.ExitOnError) 580 | global := installFlags.Bool("global", false, "Install to ~/.claude/settings.json instead of project-local") 581 | typesStr := installFlags.String("type", "", "Comma-separated list of notification types to install") 582 | uninstall := installFlags.Bool("uninstall", false, "Remove lmk hooks from configuration") 583 | dryRun := installFlags.Bool("dry-run", false, "Show what would be changed without modifying files") 584 | ackMode := installFlags.Bool("ack-mode", false, "Require explicit acknowledgment with ack mode") 585 | 586 | installFlags.Parse(args) 587 | 588 | // Parse types if provided 589 | var types []string 590 | if *typesStr != "" { 591 | types = strings.Split(*typesStr, ",") 592 | // Validate types 593 | validTypes := map[string]bool{ 594 | "permission_prompt": true, 595 | "idle_prompt": true, 596 | "auth_success": true, 597 | "elicitation_dialog": true, 598 | } 599 | for _, t := range types { 600 | t = strings.TrimSpace(t) 601 | if !validTypes[t] { 602 | log.Fatalf("Invalid notification type: %s\nValid types: permission_prompt, idle_prompt, auth_success, elicitation_dialog", t) 603 | } 604 | } 605 | } 606 | 607 | // Get settings file path 608 | settingsPath := getClaudeSettingsPath(*global) 609 | 610 | // Read or create settings 611 | settings := readOrCreateSettings(settingsPath) 612 | 613 | // Get current hooks, preserving any non-Notification hooks 614 | hooksMap := make(map[string]interface{}) 615 | var currentHooks *ClaudeHooks 616 | 617 | if hooksData, ok := settings["hooks"]; ok { 618 | // Preserve the entire hooks map to keep non-Notification hook types 619 | hooksJSON, _ := json.Marshal(hooksData) 620 | json.Unmarshal(hooksJSON, &hooksMap) 621 | 622 | // Also parse the Notification hooks 623 | var hooks ClaudeHooks 624 | if err := json.Unmarshal(hooksJSON, &hooks); err == nil { 625 | currentHooks = &hooks 626 | } 627 | } 628 | 629 | if *uninstall { 630 | // Remove lmk hooks 631 | currentHooks = removeLmkHooks(currentHooks) 632 | } else { 633 | // Get full path to lmk executable 634 | lmkPath, err := os.Executable() 635 | if err != nil { 636 | log.Fatalf("Failed to get lmk executable path: %v", err) 637 | } 638 | 639 | // Build lmk hook configuration with full path 640 | command := lmkPath + " claude-hooks" 641 | if *typesStr != "" { 642 | command += " --type " + *typesStr 643 | } 644 | if *ackMode { 645 | command += " --ack-mode" 646 | } 647 | 648 | lmkHook := NotificationHook{ 649 | Hooks: []HookConfig{ 650 | {Type: "command", Command: command}, 651 | }, 652 | } 653 | 654 | // Add or update 655 | currentHooks = addOrUpdateLmkHook(currentHooks, lmkHook) 656 | } 657 | 658 | // Update settings map with new notification hooks, preserving other hook types 659 | if currentHooks == nil || len(currentHooks.Notification) == 0 { 660 | // Remove Notification hooks but keep other hook types 661 | delete(hooksMap, "Notification") 662 | if len(hooksMap) == 0 { 663 | delete(settings, "hooks") 664 | } else { 665 | settings["hooks"] = hooksMap 666 | } 667 | } else { 668 | // Update Notification hooks in the map 669 | hooksMap["Notification"] = currentHooks.Notification 670 | settings["hooks"] = hooksMap 671 | } 672 | 673 | // Write back to file 674 | if !*dryRun { 675 | if err := writeSettings(settingsPath, settings); err != nil { 676 | log.Fatalf("Failed to write settings: %v", err) 677 | } 678 | } 679 | 680 | // Report success 681 | commandPreview := "" 682 | if !*uninstall { 683 | lmkPath, _ := os.Executable() 684 | commandPreview = lmkPath + " claude-hooks" 685 | if *typesStr != "" { 686 | commandPreview += " --type " + *typesStr 687 | } 688 | if *ackMode { 689 | commandPreview += " --ack-mode" 690 | } 691 | } 692 | printInstallSummary(settingsPath, types, *uninstall, *dryRun, commandPreview) 693 | } 694 | 695 | // getClaudeSettingsPath returns the path to the Claude settings file 696 | func getClaudeSettingsPath(global bool) string { 697 | if global { 698 | home, err := os.UserHomeDir() 699 | if err != nil { 700 | log.Fatalf("Failed to get home directory: %v", err) 701 | } 702 | return fmt.Sprintf("%s/.claude/settings.json", home) 703 | } 704 | return ".claude/settings.local.json" 705 | } 706 | 707 | // readOrCreateSettings reads existing settings or creates empty structure 708 | func readOrCreateSettings(path string) ClaudeSettings { 709 | data, err := os.ReadFile(path) 710 | if err != nil { 711 | if os.IsNotExist(err) { 712 | // Create empty settings map 713 | return make(ClaudeSettings) 714 | } 715 | log.Fatalf("Failed to read settings file: %v", err) 716 | } 717 | 718 | // Use map to preserve all existing settings 719 | var settings ClaudeSettings 720 | if err := json.Unmarshal(data, &settings); err != nil { 721 | log.Fatalf("Failed to parse settings file: %v", err) 722 | } 723 | 724 | if settings == nil { 725 | settings = make(ClaudeSettings) 726 | } 727 | 728 | return settings 729 | } 730 | 731 | // writeSettings writes settings to file with proper formatting 732 | func writeSettings(path string, settings ClaudeSettings) error { 733 | // Ensure directory exists 734 | dir := fmt.Sprintf("%s", path[:strings.LastIndex(path, "/")]) 735 | if err := os.MkdirAll(dir, 0755); err != nil { 736 | return fmt.Errorf("failed to create directory: %w", err) 737 | } 738 | 739 | // Marshal with indentation 740 | data, err := json.MarshalIndent(settings, "", " ") 741 | if err != nil { 742 | return fmt.Errorf("failed to marshal settings: %w", err) 743 | } 744 | 745 | // Write to file 746 | if err := os.WriteFile(path, data, 0644); err != nil { 747 | return fmt.Errorf("failed to write file: %w", err) 748 | } 749 | 750 | return nil 751 | } 752 | 753 | // removeLmkHooks removes lmk hooks from the configuration 754 | func removeLmkHooks(hooks *ClaudeHooks) *ClaudeHooks { 755 | if hooks == nil { 756 | return nil 757 | } 758 | 759 | var filtered []NotificationHook 760 | for _, hook := range hooks.Notification { 761 | // Keep hooks that don't have lmk command 762 | hasLmk := false 763 | for _, h := range hook.Hooks { 764 | if strings.Contains(h.Command, "lmk claude-hooks") { 765 | hasLmk = true 766 | break 767 | } 768 | } 769 | if !hasLmk { 770 | filtered = append(filtered, hook) 771 | } 772 | } 773 | 774 | if len(filtered) == 0 { 775 | return nil 776 | } 777 | 778 | return &ClaudeHooks{Notification: filtered} 779 | } 780 | 781 | // addOrUpdateLmkHook adds or updates the lmk hook in the configuration 782 | func addOrUpdateLmkHook(hooks *ClaudeHooks, lmkHook NotificationHook) *ClaudeHooks { 783 | if hooks == nil { 784 | hooks = &ClaudeHooks{} 785 | } 786 | 787 | // Remove existing lmk hooks first 788 | hooks = removeLmkHooks(hooks) 789 | if hooks == nil { 790 | hooks = &ClaudeHooks{} 791 | } 792 | 793 | // Add new lmk hook 794 | hooks.Notification = append(hooks.Notification, lmkHook) 795 | 796 | return hooks 797 | } 798 | 799 | // printInstallSummary prints a summary of the installation 800 | func printInstallSummary(path string, types []string, uninstall bool, dryRun bool, command string) { 801 | if dryRun { 802 | fmt.Println("🔍 Dry run mode - no files were modified") 803 | fmt.Println() 804 | } 805 | 806 | if uninstall { 807 | if dryRun { 808 | fmt.Printf("Would remove lmk hooks from: %s\n", path) 809 | } else { 810 | fmt.Println("✅ Claude Code hooks uninstalled successfully!") 811 | fmt.Println() 812 | fmt.Printf("Configuration updated: %s\n", path) 813 | } 814 | } else { 815 | if dryRun { 816 | fmt.Printf("Would install lmk hooks to: %s\n", path) 817 | } else { 818 | fmt.Println("✅ Claude Code hooks installed successfully!") 819 | fmt.Println() 820 | fmt.Printf("Configuration updated: %s\n", path) 821 | } 822 | fmt.Println() 823 | fmt.Println("Installed hooks:") 824 | if len(types) > 0 { 825 | fmt.Printf(" - Notification (types: %s)\n", strings.Join(types, ", ")) 826 | } else { 827 | fmt.Println(" - Notification (all types)") 828 | } 829 | fmt.Println() 830 | if command != "" { 831 | fmt.Printf("Command: %s\n", command) 832 | } else { 833 | fmt.Println("Command: lmk claude-hooks") 834 | } 835 | fmt.Println() 836 | fmt.Println("To test: Start a new Claude Code session and trigger a notification") 837 | fmt.Println() 838 | fmt.Println("Debug: Hook events are logged to /tmp/lmk-claude-hooks.log") 839 | fmt.Println(" Use 'tail -f /tmp/lmk-claude-hooks.log' to watch in real-time") 840 | } 841 | } 842 | --------------------------------------------------------------------------------