diff --git a/old/.github/workflows/site.yml b/.github/workflows/site.yml similarity index 83% rename from old/.github/workflows/site.yml rename to .github/workflows/site.yml index 37d1503..f7100db 100644 --- a/old/.github/workflows/site.yml +++ b/.github/workflows/site.yml @@ -1,5 +1,5 @@ # -# Date: 2026-06-09 +# Date: 2026-06-15 # Author: Spicer Matthews (spicer@cloudmanic.com) # Copyright: 2026 Cloudmanic Labs, LLC. All rights reserved. # @@ -10,14 +10,13 @@ # binary (no Node toolchain), then build Hugo with --minify. Output is the # www/public directory, uploaded as the Pages artifact. # -# First-time setup (once, in the repo on GitHub): -# Settings → Pages → Build and deployment → Source: "GitHub Actions". -# The site then publishes at https://cloudmanic.github.io/herdr-plus/. +# This workflow is the *only* one that ships the website — the release pipeline +# (release.yml) ignores www/** so a docs-only change never cuts a binary release. # -# Custom domain later (herdrplus.com): in hugo.toml set -# baseURL = "https://herdrplus.com/" -# add a file www/static/CNAME containing "herdrplus.com", point the domain's -# DNS at GitHub Pages, and set the custom domain under Settings → Pages. +# One-time setup (already done for this repo): Settings -> Pages -> Source: +# "GitHub Actions". The site publishes to the custom domain herdrplus.com, set by +# www/static/CNAME (Hugo copies it to the site root) plus the domain's DNS and the +# custom-domain field under Settings -> Pages. # name: Site diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 692febc..069a99c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,9 +5,7 @@ # # Test pipeline for herdr-plus. Runs the build, vet, and `go test ./...` with the # race detector on every push and pull request, on Linux + macOS, so we catch -# platform-specific issues before they ship. Only the root module is exercised; -# the archived reference code in old/ is a separate nested module and is ignored -# by `./...`. +# platform-specific issues before they ship. name: Test diff --git a/Makefile b/Makefile index a2bb011..448efa0 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ BINARY := herdr-plus -.PHONY: help build test test-short vet tidy plugin-link clean +.PHONY: help build test test-short vet tidy plugin-link clean site site-dev site-clean site-deps # help is the default target so `make` with no args prints what's available. help: @@ -20,6 +20,10 @@ help: @echo " make tidy Run 'go mod tidy'." @echo " make plugin-link Build, then link this checkout as a herdr plugin (dev)." @echo " make clean Remove ./bin and coverage artifacts." + @echo "" + @echo " make site Build the www/ Hugo site into www/public (mirrors CI)." + @echo " make site-dev Run the site locally with live reload at http://localhost:1313/." + @echo " make site-clean Remove the site's build output." # build produces a single binary at ./bin/$(BINARY) — the same path the plugin # manifest's [[build]] step and entry points use. @@ -52,3 +56,36 @@ plugin-link: build # clean removes build artifacts and coverage output. clean: rm -rf bin coverage.out coverage.html + +# ---------------------------------------------------------------- website --- +# The marketing + docs site lives in www/ as a Hugo site styled with Tailwind +# v4 (standalone binary — no Node). These targets mirror the GitHub Actions +# deploy in .github/workflows/site.yml. + +# site-deps fails early with a friendly message if hugo/tailwindcss are missing. +site-deps: + @command -v hugo >/dev/null 2>&1 || { echo "✗ hugo not found — install with: brew install hugo"; exit 1; } + @command -v tailwindcss >/dev/null 2>&1 || { echo "✗ tailwindcss not found — install with: brew install tailwindcss"; exit 1; } + +# site builds the production static site into www/public. +site: site-deps + @echo "→ Compiling Tailwind CSS…" + @cd www && tailwindcss -i assets/css/app.css -o static/css/app.css --minify + @echo "→ Building Hugo site…" + @cd www && hugo --minify --gc + @echo "" + @echo "✓ Built www/public — preview the whole thing with: make site-dev" + +# site-dev runs Tailwind in --watch alongside Hugo's live-reload dev server. +site-dev: site-deps + @echo "→ Tailwind --watch + Hugo dev server on http://localhost:1313/ …" + @cd www && ( \ + tailwindcss -i assets/css/app.css -o static/css/app.css --watch & \ + TW=$$!; \ + trap "kill $$TW 2>/dev/null" EXIT INT TERM; \ + hugo server --disableFastRender \ + ) + +# site-clean removes generated site output. +site-clean: + rm -rf www/public www/resources www/static/css/app.css diff --git a/README.md b/README.md index c2bdadf..57abfc6 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,6 @@ herdr-plus is an add-on for [herdr](https://herdr.dev), built as a first-class - **[Quick Actions](#quick-actions)** — a fuzzy launcher for one-off actions/scripts, run in the directory you launched from. -> This is a clean, plugin-first rebuild. The previous standalone-binary -> implementation lives under [`old/`](old/) as reference and is not built. - ## Install herdr-plus is a herdr plugin (requires **herdr ≥ 0.7.0**). Installing it registers @@ -203,5 +200,5 @@ make test # go test -race ./... make vet # go vet ./... ``` -The repo root is the active Go module; `old/` is a separate nested module and is -ignored by `go ... ./...`. +The marketing + docs site lives in `www/` (Hugo + Tailwind). Build it with +`make site`, or run it locally with live reload via `make site-dev`. diff --git a/old/.github/workflows/release.yml b/old/.github/workflows/release.yml deleted file mode 100644 index 0711849..0000000 --- a/old/.github/workflows/release.yml +++ /dev/null @@ -1,134 +0,0 @@ -# -# Date: 2026-06-09 -# Author: Spicer Matthews (spicer@cloudmanic.com) -# Copyright: 2026 Cloudmanic Labs, LLC. All rights reserved. -# -# Release pipeline for herdr-plus. Triggers on every push to main: -# -# 1. Determines the next version. If the pushed commit hand-edited -# internal/version/version.go (you bumped major/minor manually), the -# workflow uses that version as-is. Otherwise it auto-bumps the patch -# number, commits the change back to main with [skip ci], and pushes. -# 2. Tags v and pushes the tag. -# 3. Runs goreleaser to cross-compile binaries (linux/darwin/windows × -# amd64/arm64), package them, attach them to a GitHub Release, and push -# an updated Homebrew formula into Formula/ in this same repo (using the -# default GITHUB_TOKEN — no separate tap repo or PAT required). - -name: Release - -on: - push: - branches: [main] - # Website-only changes ship via .github/workflows/site.yml and must NOT - # cut a new binary release. paths-ignore skips this workflow only when - # *every* changed file matches, so a mixed code+site commit still releases. - paths-ignore: - - "www/**" - - ".github/workflows/site.yml" - # Manual escape hatch. If a merge commit accidentally carries a skip-CI - # marker (or anything else suppresses the auto-trigger), we can rerun the - # release flow against the current main without an empty no-op commit. - workflow_dispatch: - -# Pushing the version bump and the brew formula back to main needs write access -# to the repo's contents. -permissions: - contents: write - -# Serialise releases — no two version bumps racing each other. -concurrency: - group: release - cancel-in-progress: false - -jobs: - release: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - # Full history so the workflow can inspect HEAD~1 for the "did this - # commit hand-edit version.go?" check, and so goreleaser sees prior - # tags for changelog generation. - fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: stable - - # Set the git identity once for the whole job. Both the version-bump - # commit and the annotated release tag need it, and the bump step is - # skipped on a hand-edited (non-auto-bumped) release — so configuring it - # here keeps tagging working on every path. - - name: Configure git identity - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - # Decide whether to auto-bump or accept a hand-edited version. - - name: Determine version - id: version - run: | - set -euo pipefail - - CURRENT=$(sed -nE 's/.*const Version = "([0-9]+\.[0-9]+\.[0-9]+)".*/\1/p' internal/version/version.go) - if [[ -z "$CURRENT" ]]; then - echo "Could not parse version from internal/version/version.go" >&2 - exit 1 - fi - - # If the pushed commit changed version.go, treat that as a manual - # major/minor bump and use the value as-is. Otherwise auto-bump patch. - if git diff --name-only HEAD~1..HEAD 2>/dev/null | grep -qx 'internal/version/version.go'; then - echo "Version was manually edited to $CURRENT — using as-is." - echo "version=$CURRENT" >> "$GITHUB_OUTPUT" - echo "bumped=false" >> "$GITHUB_OUTPUT" - else - MAJOR=$(echo "$CURRENT" | cut -d. -f1) - MINOR=$(echo "$CURRENT" | cut -d. -f2) - PATCH=$(echo "$CURRENT" | cut -d. -f3) - NEW="${MAJOR}.${MINOR}.$((PATCH + 1))" - echo "Auto-bumping $CURRENT → $NEW" - echo "version=$NEW" >> "$GITHUB_OUTPUT" - echo "bumped=true" >> "$GITHUB_OUTPUT" - fi - - # Commit the bumped version.go back to main. The [skip ci] marker tells - # GitHub Actions not to re-run this workflow (which would loop forever). - - name: Commit version bump - if: steps.version.outputs.bumped == 'true' - run: | - set -euo pipefail - NEW="${{ steps.version.outputs.version }}" - - sed -i -E "s/(const Version = )\"[0-9]+\.[0-9]+\.[0-9]+\"/\1\"$NEW\"/" internal/version/version.go - - git add internal/version/version.go - git commit -m "Release v$NEW [skip ci]" - git push origin HEAD:main - - # Always create + push the tag (even when version was hand-edited — that's - # the whole point of accepting it as-is). - - name: Tag release - run: | - set -euo pipefail - NEW="${{ steps.version.outputs.version }}" - if git rev-parse -q --verify "refs/tags/v$NEW" >/dev/null; then - echo "Tag v$NEW already exists, skipping." - exit 0 - fi - git tag -a "v$NEW" -m "Release v$NEW" - git push origin "v$NEW" - - - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v6 - with: - version: latest - args: release --clean - env: - # Same-repo brew formula push uses the default workflow token — no - # separate tap repo or PAT needed. - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/old/.github/workflows/test.yml b/old/.github/workflows/test.yml deleted file mode 100644 index 3bfb079..0000000 --- a/old/.github/workflows/test.yml +++ /dev/null @@ -1,70 +0,0 @@ -# -# Date: 2026-06-09 -# Author: Spicer Matthews (spicer@cloudmanic.com) -# Copyright: 2026 Cloudmanic Labs, LLC. All rights reserved. -# -# Test pipeline for herdr-plus. Runs `go test ./...` with the race detector on -# every push and every pull request, on Linux + macOS so we catch -# platform-specific flakes before they ship. The release workflow (release.yml) -# triggers separately on push to main; a red test job is meant to block merges -# via the PR's required-checks setting (configure that on GitHub once the first -# run is green). - -name: Test - -on: - push: - branches: [main] - pull_request: - branches: [main] - -# Cancel in-flight runs for the same ref when a new push lands. -concurrency: - group: test-${{ github.ref }} - cancel-in-progress: true - -jobs: - test: - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, macos-latest] - runs-on: ${{ matrix.os }} - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: stable - cache: true - - - name: Verify go.mod is tidy - run: | - go mod tidy - if ! git diff --quiet go.mod go.sum; then - echo "go.mod / go.sum are not tidy. Run 'make tidy' and commit." >&2 - git --no-pager diff go.mod go.sum - exit 1 - fi - - - name: Build - run: go build ./... - - - name: Vet - run: go vet ./... - - - name: Test - run: go test -race -coverprofile=coverage.out ./... - - - name: Coverage summary - run: go tool cover -func=coverage.out | tail -n 30 - - - name: Upload coverage report - if: matrix.os == 'ubuntu-latest' - uses: actions/upload-artifact@v4 - with: - name: coverage - path: coverage.out - retention-days: 14 diff --git a/old/.gitignore b/old/.gitignore deleted file mode 100644 index 0126282..0000000 --- a/old/.gitignore +++ /dev/null @@ -1,14 +0,0 @@ -# Compiled binary (same name as the module; anchored to repo root) -/herdr-plus - -# Build output (Makefile ./bin, GoReleaser ./dist) -/bin/ -/dist/ - -# Go build & test artifacts -*.test -*.out -coverage.html - -# Editor / OS cruft -.DS_Store diff --git a/old/.goreleaser.yml b/old/.goreleaser.yml deleted file mode 100644 index 3c2bd03..0000000 --- a/old/.goreleaser.yml +++ /dev/null @@ -1,89 +0,0 @@ -# -# Date: 2026-06-09 -# Author: Spicer Matthews (spicer@cloudmanic.com) -# Copyright: 2026 Cloudmanic Labs, LLC. All rights reserved. -# -# GoReleaser config for herdr-plus. Driven by .github/workflows/release.yml on -# every push to main: that workflow bumps internal/version/version.go and tags -# v, then invokes `goreleaser release --clean`. GoReleaser then -# cross-compiles the binary, archives it, attaches it to a GitHub Release, and -# pushes an updated Homebrew formula into Formula/ in this same repo. - -version: 2 - -project_name: herdr-plus - -before: - hooks: - - go mod tidy - -builds: - - id: herdr-plus - binary: herdr-plus - main: . - env: - - CGO_ENABLED=0 - goos: [linux, darwin, windows] - goarch: [amd64, arm64] - # Skip windows/arm64 — less common, and we'd rather not ship binaries we - # can't easily test. Add it back if there's demand. - ignore: - - goos: windows - goarch: arm64 - ldflags: - - -s -w - -archives: - - id: default - name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" - formats: [tar.gz] - format_overrides: - - goos: windows - formats: [zip] - files: - - LICENSE* - - README* - -checksum: - name_template: "checksums.txt" - -changelog: - sort: asc - use: github - filters: - exclude: - - "^docs:" - - "^chore:" - - "^test:" - - "Merge pull request" - - "Merge branch" - -# Homebrew formula committed straight back into this repo under Formula/. -# Users install via: -# -# brew tap cloudmanic/herdr-plus https://github.com/cloudmanic/herdr-plus -# brew install cloudmanic/herdr-plus/herdr-plus -# -# Because the formula lives in the same repo as the source, the default -# GITHUB_TOKEN the workflow already holds is enough to push it — no PAT, no -# separate tap repo. The [skip ci] marker on the formula-update commit stops the -# new push from triggering a second release run. -brews: - - name: herdr-plus - repository: - owner: cloudmanic - name: herdr-plus - branch: main - token: "{{ .Env.GITHUB_TOKEN }}" - directory: Formula - commit_msg_template: "Update {{ .ProjectName }} brew formula to {{ .Tag }} [skip ci]" - homepage: "https://github.com/cloudmanic/herdr-plus" - description: "herdr-plus — an add-on platform for the herdr terminal multiplexer." - license: "MIT" - commit_author: - name: github-actions[bot] - email: 41898282+github-actions[bot]@users.noreply.github.com - install: | - bin.install "herdr-plus" - test: | - assert_path_exists bin/"herdr-plus" diff --git a/old/Formula/herdr-plus.rb b/old/Formula/herdr-plus.rb deleted file mode 100644 index 9e5f7a8..0000000 --- a/old/Formula/herdr-plus.rb +++ /dev/null @@ -1,50 +0,0 @@ -# typed: false -# frozen_string_literal: true - -# This file was generated by GoReleaser. DO NOT EDIT. -class HerdrPlus < Formula - desc "herdr-plus — an add-on platform for the herdr terminal multiplexer." - homepage "https://github.com/cloudmanic/herdr-plus" - version "0.0.8" - license "MIT" - - on_macos do - if Hardware::CPU.intel? - url "https://github.com/cloudmanic/herdr-plus/releases/download/v0.0.8/herdr-plus_0.0.8_darwin_amd64.tar.gz" - sha256 "6ac9aa31f02f1d1393d92c09d89bb11cd4dc5ac20591726e0851817704b39b2a" - - define_method(:install) do - bin.install "herdr-plus" - end - end - if Hardware::CPU.arm? - url "https://github.com/cloudmanic/herdr-plus/releases/download/v0.0.8/herdr-plus_0.0.8_darwin_arm64.tar.gz" - sha256 "a77974a98e04ae842cd04071c6a5f7a5ec9b2198869c16f853988cad38dae4e7" - - define_method(:install) do - bin.install "herdr-plus" - end - end - end - - on_linux do - if Hardware::CPU.intel? && Hardware::CPU.is_64_bit? - url "https://github.com/cloudmanic/herdr-plus/releases/download/v0.0.8/herdr-plus_0.0.8_linux_amd64.tar.gz" - sha256 "b1095c5a711586b2678b234a81607c98fb9a490e97bdd2f1b5bd19e57fd15225" - define_method(:install) do - bin.install "herdr-plus" - end - end - if Hardware::CPU.arm? && Hardware::CPU.is_64_bit? - url "https://github.com/cloudmanic/herdr-plus/releases/download/v0.0.8/herdr-plus_0.0.8_linux_arm64.tar.gz" - sha256 "551492981fa4af844041a6570a616b84644d2d4a5076a276a759fee9c397408d" - define_method(:install) do - bin.install "herdr-plus" - end - end - end - - test do - assert_path_exists bin/"herdr-plus" - end -end diff --git a/old/LICENSE b/old/LICENSE deleted file mode 100644 index f127363..0000000 --- a/old/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 Cloudmanic Labs, LLC - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/old/Makefile b/old/Makefile deleted file mode 100644 index 10293c5..0000000 --- a/old/Makefile +++ /dev/null @@ -1,111 +0,0 @@ -# -# Date: 2026-06-09 -# Author: Spicer Matthews (spicer@cloudmanic.com) -# Copyright: 2026 Cloudmanic Labs, LLC. All rights reserved. -# - -BINARY := herdr-plus - -.PHONY: run build install-bin install-keybind build-linux test test-short coverage tidy clean help site site-dev site-clean - -# help is the default target so `make` with no args prints what's available. -help: - @echo "herdr-plus — an add-on platform for the herdr terminal multiplexer" - @echo "" - @echo "Targets:" - @echo " make run Run the launcher (inside a herdr pane)." - @echo " make build Build the binary into ./bin/$(BINARY)." - @echo " make install-bin Install ./bin/$(BINARY) into /usr/local/bin." - @echo " make install-keybind Build, then bind the herdr keybinding (prefix+down)." - @echo " make build-linux Cross-compile a static linux/amd64 binary." - @echo " make test Run the full test suite with -race." - @echo " make test-short Skip slow tests (-short) — quick iteration loop." - @echo " make coverage Generate coverage.out + an HTML report at coverage.html." - @echo " make tidy Run 'go mod tidy'." - @echo " make clean Remove ./bin and coverage artifacts." - @echo "" - @echo " make site Build the www/ Hugo site into www/public (mirrors CI)." - @echo " make site-dev Run the site locally with live reload at http://localhost:1313/." - @echo " make site-clean Remove the site's build output." - -# run starts the launcher via 'go run'. Must be run inside a herdr pane. -run: - go run . - -# build produces a single binary at ./bin/$(BINARY). -build: - mkdir -p bin - go build -o bin/$(BINARY) . - -# install-bin copies the binary into /usr/local/bin so you can launch it as -# `herdr-plus`. -install-bin: build - install -m 0755 bin/$(BINARY) /usr/local/bin/$(BINARY) - -# install-keybind builds the binary and registers the herdr keybinding, using -# the freshly built binary's absolute path. -install-keybind: build - ./bin/$(BINARY) install - -# build-linux cross-compiles a fully static linux/amd64 binary. -build-linux: - mkdir -p bin - GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags='-s -w' -o bin/$(BINARY)-linux-amd64 . - -# test runs the full suite with the race detector — the same command CI runs -# (.github/workflows/test.yml). Keep them in lockstep. -test: - go test -race ./... - -# test-short is the quick local iteration loop: skip anything tagged slow. -test-short: - go test -short ./... - -# coverage produces a coverage profile and a rendered HTML report. -coverage: - go test -coverprofile=coverage.out ./... - go tool cover -html=coverage.out -o coverage.html - @echo "Coverage report: coverage.html" - @go tool cover -func=coverage.out | tail -n 1 - -# tidy keeps go.mod / go.sum in sync with what's actually imported. -tidy: - go mod tidy - -# clean removes build artifacts and coverage output. -clean: - rm -rf bin coverage.out coverage.html - -# ---------------------------------------------------------------- website --- -# The marketing + docs site lives in www/ as a Hugo site styled with Tailwind -# v4 (standalone binary — no Node). These targets mirror the GitHub Actions -# deploy in .github/workflows/site.yml. - -# site-deps fails early with a friendly message if hugo/tailwindcss are missing. -.PHONY: site-deps -site-deps: - @command -v hugo >/dev/null 2>&1 || { echo "✗ hugo not found — install with: brew install hugo"; exit 1; } - @command -v tailwindcss >/dev/null 2>&1 || { echo "✗ tailwindcss not found — install with: brew install tailwindcss"; exit 1; } - -# site builds the production static site into www/public. -site: site-deps - @echo "→ Compiling Tailwind CSS…" - @cd www && tailwindcss -i assets/css/app.css -o static/css/app.css --minify - @echo "→ Building Hugo site…" - @cd www && hugo --minify --gc - @echo "" - @echo "✓ Built www/public — preview the whole thing with: make site-dev" - -# site-dev runs Tailwind in --watch alongside Hugo's live-reload dev server. -site-dev: site-deps - @echo "→ Tailwind --watch + Hugo dev server on http://localhost:1313/herdr-plus/ …" - @cd www && ( \ - tailwindcss -i assets/css/app.css -o static/css/app.css --watch & \ - TW=$$!; \ - trap "kill $$TW 2>/dev/null" EXIT INT TERM; \ - hugo server --disableFastRender \ - ) - -# site-clean removes generated site output. -site-clean: - rm -rf www/public www/resources www/static/css/app.css diff --git a/old/README.md b/old/README.md deleted file mode 100644 index c4196d5..0000000 --- a/old/README.md +++ /dev/null @@ -1,315 +0,0 @@ -# herdr-plus - -herdr-plus is an add-on platform for [herdr](https://herdr.dev) — a place to build -extensions and plugins on top of herdr's terminal panes. The same binary can run -in different **modes**; each mode decides what to do when it talks to herdr. - -We're in explore mode: the list of modes will grow over time. - -## Modes - -Pick a mode with `--mode=`. With no flag, the default mode runs. - -| Mode | Slug | Key | What it does | -|------|------|-----|--------------| -| Control | `control` (default) | `prefix+up` | herdr-plus's home base — a full-screen workspace for driving herdr. First feature: **Projects**. | -| Quick Actions | `quick-actions` | `prefix+down` | A fuzzy launcher: pick an action and run it in a split. | - -```bash -herdr-plus # default mode (control) -herdr-plus --mode=quick-actions # the fuzzy launcher -herdr-plus version # print the version and exit -``` - -## Control mode & Projects - -Pressing `prefix+up` opens a brand-new, full-screen herdr workspace titled -**Herdr Plus** with a `projects` tab, and runs the projects browser there. This is -control mode — over time it will gain more features; today it has Projects. - -A **project** is a declarative herdr workspace template: a name, a description, a -working directory, and an ordered list of tabs (each with an optional startup -command). Fuzzy-find a project, press `enter`, and herdr-plus spins up a whole -workspace — every tab created and every command running — then closes the ephemeral -"Herdr Plus" workspace. It replaces hand-written workspace shell scripts with simple -config files. - -Projects live in `~/.config/herdr-plus/projects/` (honoring `$XDG_CONFIG_HOME`), one -TOML file per project (the file name doesn't matter): - -```toml -name = "Options Cafe" -description = "The main options.cafe monorepo" -working_dir = "~/Development/options-cafe/options.cafe" # ~ and $VARS expand - -[[tabs]] -name = "claude" -command = "claude --dangerously-skip-permissions --chrome" - -[[tabs]] -name = "lazygit" -command = "lazygit" - -[[tabs]] -name = "terminal" # no command — just an empty shell -``` - -Tabs open in file order; the first tab reuses the workspace's root tab and the rest -are created behind it. A tab with no `command` is just an empty shell. With no -project files yet, control mode shows an onboarding screen explaining all of this. - -### Grouping projects - -A project may set an optional `group` — a label that clusters related projects in -the browser. It is handy when one client has several projects: - -```toml -name = "Acme — Web" -group = "Acme Co." -working_dir = "~/Clients/acme/web" - -[[tabs]] -name = "editor" -command = "spiceedit" -``` - -Projects that share a `group` are shown together under that heading, in -case-insensitive alphabetical order by group name. Any project without a `group` -falls under a catch-all **Ungrouped** heading at the bottom. Grouping only kicks -in when at least one project sets a `group` — if none do, the browser stays a -plain, heading-less list exactly as before. Filtering is unchanged: start typing -and the headings drop away to a single ranked list. - -### Split panes within a tab - -A tab can hold up to **4 panes**. Instead of a single `command`, give the tab -`[[tabs.panes]]` entries. Each pane after the first sets `split` to `"down"` -(stacked, top/bottom) or `"right"` (side by side) — the direction it splits off the -previous pane. An omitted `split` defaults to `"down"`. - -```toml -[[tabs]] -name = "server" - -[[tabs.panes]] -command = "php artisan serve" - -[[tabs.panes]] -command = "npm run dev" -split = "down" -``` - -A tab uses *either* `command` *or* `[[tabs.panes]]`, not both. In the projects list, -split tabs are shown with a `×N` pane count (e.g. `server ×2`). - -## Installing - -**Homebrew** (the repo is its own tap): - -```bash -brew tap cloudmanic/herdr-plus https://github.com/cloudmanic/herdr-plus -brew install cloudmanic/herdr-plus/herdr-plus -``` - -**Install script** (Linux/macOS, no Homebrew): - -```bash -curl -fsSL https://raw.githubusercontent.com/cloudmanic/herdr-plus/main/install.sh | sh -``` - -**From source:** - -```bash -make build && make install-bin -``` - -Every merge to `main` auto-bumps the patch version and cuts a new GitHub Release -with cross-compiled binaries; `brew upgrade` / re-running the install script -pulls the latest. - -The bare command opens a focused split beneath your current pane and runs the -picker there. Choose an action, and the pane closes itself when the action runs. - -## Install the keybinding - -```bash -herdr-plus install # binds BOTH modes: prefix+up -> control, prefix+down -> quick-actions -herdr-plus install --mode=quick-actions # bind just quick-actions (prefix+down) -herdr-plus install --key=prefix+a # override the key for a single mode -``` - -A bare `herdr-plus install` wires up every mode on its own default key (control → -`prefix+up`, quick-actions → `prefix+down`) in one shot. Pass `--mode` to install -just one, and `--key` to override that mode's key. - -`install` adds a `[[keys.command]]` entry to herdr's `config.toml` that runs the -**absolute path** of the binary you invoked, then reloads the running herdr -server. It is idempotent (it won't duplicate an existing herdr-plus binding) and -refuses to overwrite a key already bound to something else. After installing, -press your herdr prefix (default `ctrl+b`) followed by the bound key. - -## Configuration - -All config lives under `~/.config/herdr-plus/` (honoring `$XDG_CONFIG_HOME`): - -``` -~/.config/herdr-plus/ - projects/ # one *.toml per project (control mode) - options-cafe.toml - bevio.toml - ... - quick-actions/ # one *.toml per action (quick-actions mode) - github.toml - google.toml - ... -``` - -For **quick-actions**, each `*.toml` defines one action; the directory is seeded -with editable examples the first time you run the mode. For **projects** (see -[Control mode & Projects](#control-mode--projects)), each `*.toml` defines one -project and the directory starts empty — control mode's onboarding screen explains -how to add your first one. In both cases: add a file to add an entry, delete a file -to remove it. - -### Per-project quick actions - -A repo can ship its own quick actions. Add a `.herdr-plus/` directory at the repo -root that mirrors the global layout, and drop one `*.toml` per action into its -`quick-actions/` subdirectory — same format as your global actions: - -``` -your-repo/ - .herdr-plus/ - quick-actions/ - make-build.toml - make-test.toml -``` - -When you launch the quick-actions picker from inside that repo, its project -actions appear **grouped under a `Project` heading**, above your `Global` ones, so -it is always clear which is which. (Start typing to filter and the two groups -merge into a single ranked list.) Launch from a repo with no `.herdr-plus` -directory and the picker looks exactly as before — a single, ungrouped list. The -directory is read-only and never auto-created: it shows up only when a repo -actually provides it. This repo ships one as a live example (`make build` / -`make test`). - -## Actions - -An action has a `name`, a `description`, a `command`, and a `type`. The command -is run through `sh -c`, in the working directory you launched from, with the -context exported as `HERDR_PLUS_*` environment variables. - -The `command` is a [Go text/template](https://pkg.go.dev/text/template) rendered -against the run context (see [Variables](#variables)). - -### Type: `command` (default) - -Runs immediately when selected. - -```toml -name = "GitHub" -description = "Open https://github.com" -command = "open https://github.com" -``` - -### Type: `select` - -Shows a second fuzzy list of options. The chosen option's `value` becomes -`{{.Value}}`. If `value` is omitted, the `label` is used. An optional -`description` shows dim text next to the label (the `value` itself is never -shown, so you can encode data into it without cluttering the list). - -```toml -name = "Open Repo on GitHub" -description = "Pick a repo and open it" -type = "select" -command = "open https://github.com/cloudmanic/{{.Value}}" - -[[options]] -label = "Herdr Plus" -value = "herdr-plus" -description = "cloudmanic/herdr-plus" - -[[options]] -label = "Options Cafe" -value = "options-cafe" -description = "cloudmanic/options-cafe" -``` - -To visually group options, add a separator: an option with **no `label`**. Give -it a `heading` to show a dim group title, or leave it blank for a plain spacer. -Separators are not selectable, are skipped when navigating, and disappear while -you filter. - -```toml -[[options]] -heading = "Cascade" # a labeled group header - -[[options]] -label = "Options Cafe" -value = "cascade https://github.com/users/cloudmanic/projects/8" - -[[options]] # a blank spacer (no label, no heading) - -[[options]] -label = "Options Cafe (Rager)" -value = "rager https://github.com/users/cloudmanic/projects/8" -``` - -### Type: `form` - -Shows a text field. What you type becomes `{{.Value}}`. The `[form]` table is -optional. - -```toml -name = "Search Google" -description = "Type a query and open the results" -type = "form" -command = "open 'https://www.google.com/search?q={{.Value | urlquery}}'" - -[form] -prompt = "Search Google for" -placeholder = "e.g. herdr terminal multiplexer" -``` - -### Passing the value - -If your command references `{{.Value}}`, the value is placed exactly there. If it -doesn't, the value is appended as a single shell-quoted final argument — so -`command = "my-script"` becomes `my-script 'the value'`. - -## Variables - -Every action's command template can use these fields. The same values are also -exported to the command's environment with a `HERDR_PLUS_` prefix (e.g. -`{{.WorkDir}}` is also `$HERDR_PLUS_WORKDIR`). - -| Template | Env var | Meaning | -|----------|---------|---------| -| `{{.Value}}` | `HERDR_PLUS_VALUE` | Selected option / entered text (select & form). | -| `{{.WorkDir}}` | `HERDR_PLUS_WORKDIR` | Directory you launched herdr-plus from. | -| `{{.SessionTitle}}` | `HERDR_PLUS_SESSION_TITLE` | herdr workspace label (often the repo name). | -| `{{.SessionId}}` | `HERDR_PLUS_SESSION_ID` | herdr workspace id. | -| `{{.WorkspaceLabel}}` | `HERDR_PLUS_WORKSPACE_LABEL` | Same as SessionTitle. | -| `{{.WorkspaceId}}` | `HERDR_PLUS_WORKSPACE_ID` | Same as SessionId. | -| `{{.TabLabel}}` | `HERDR_PLUS_TAB_LABEL` | herdr tab label. | -| `{{.TabId}}` | `HERDR_PLUS_TAB_ID` | herdr tab id. | -| `{{.PaneId}}` | `HERDR_PLUS_PANE_ID` | Pane you launched from. | -| `{{.TerminalId}}` | `HERDR_PLUS_TERMINAL_ID` | herdr terminal id. | -| `{{.Agent}}` | `HERDR_PLUS_AGENT` | Agent running in the pane, if any. | -| `{{.AgentSessionId}}` | `HERDR_PLUS_AGENT_SESSION_ID` | That agent's session id. | -| `{{.Home}}` | — | Your home directory. | - -## Building - -```bash -go build -o herdr-plus . -go test ./... -``` - -## Adding a mode - -1. Add a `Mode` value and register it in `modes` in `mode.go`. -2. (Optional) Add bundled example actions under `examples//`. -3. Teach the launcher/picker how the mode behaves where it differs. diff --git a/old/action.go b/old/action.go deleted file mode 100644 index 2fcc68e..0000000 --- a/old/action.go +++ /dev/null @@ -1,192 +0,0 @@ -// -// Date: 2026-06-09 -// Author: Spicer Matthews (spicer@cloudmanic.com) -// Copyright: 2026 Cloudmanic Labs, LLC. All rights reserved. -// - -package main - -import ( - "bytes" - "fmt" - "os" - "os/exec" - "strings" - "text/template" -) - -// Action types. An action's Type decides what happens between selecting it in -// the picker and running its command. -const ( - // TypeCommand runs the command immediately with no further input. - TypeCommand = "command" - // TypeSelect shows a list of Options; the chosen option's Value becomes the - // action's Value (available to the command as {{.Value}}). - TypeSelect = "select" - // TypeForm shows a single text field; the entered string becomes the action's - // Value. - TypeForm = "form" -) - -// Option is one choice in a "select" action. Label is what the user sees in the -// list; Value is what gets handed to the command. When Value is empty the Label -// is used as the value too. Description, when set, is shown as dim text next to -// the label — useful when the label alone isn't self-explanatory, or to keep a -// long value out of the list. -// -// An option with no Label is a non-selectable separator used to visually group -// the choices: with a Heading it renders as a dim group title (preceded by a -// blank line); without one it renders as a plain blank spacer. -type Option struct { - Label string `toml:"label"` - Value string `toml:"value"` - Description string `toml:"description"` - Heading string `toml:"heading"` -} - -// isSeparator reports whether the option is a non-selectable spacer/heading -// rather than a real choice. An option needs a label to be selectable. -func (o Option) isSeparator() bool { - return o.Label == "" -} - -// resolvedValue returns the value to hand to the command for this option. -func (o Option) resolvedValue() string { - if o.Value != "" { - return o.Value - } - return o.Label -} - -// actionOrigin records where an action was loaded from. The picker uses it to -// group project-local actions separately from the user's global actions so it is -// always clear which is which. -type actionOrigin int - -const ( - // originGlobal is an action from the user's global config dir - // (~/.config/herdr-plus//). It is the zero value, so any Action built - // without an explicit origin is treated as global. - originGlobal actionOrigin = iota - // originProject is an action from a repo's own .herdr-plus// directory, - // available only when herdr-plus is launched from inside that repo. - originProject -) - -// FormConfig customizes the text field shown for a "form" action. -type FormConfig struct { - // Prompt is the label rendered above the input field. - Prompt string `toml:"prompt"` - // Placeholder is the greyed-out hint shown in the empty field. - Placeholder string `toml:"placeholder"` -} - -// Action is one entry in the quick-action picker, loaded from a TOML file in the -// mode's config directory. Name and Description are shown in the list; Command -// is the shell command run when the action completes. Command is a Go -// text/template rendered against a RunContext, so it can reference {{.Value}}, -// {{.WorkDir}}, {{.SessionTitle}}, and the other context fields. -type Action struct { - Name string `toml:"name"` - Description string `toml:"description"` - Type string `toml:"type"` - Command string `toml:"command"` - Options []Option `toml:"options"` - Form FormConfig `toml:"form"` - - // source is the file the action was loaded from, used only for error - // messages. It is not part of the on-disk format. - source string - - // origin marks whether the action came from the global config or a project's - // .herdr-plus directory. Set at load time; not part of the on-disk format. - origin actionOrigin -} - -// effectiveType returns the action's type, defaulting to TypeCommand when the -// file omits it so the simplest actions need only a name and a command. -func (a Action) effectiveType() string { - if a.Type == "" { - return TypeCommand - } - return a.Type -} - -// validate checks that an action is internally consistent before we ever try to -// run it, turning config mistakes into clear errors at load time. -func (a Action) validate() error { - if a.Name == "" { - return fmt.Errorf("action %s: name is required", a.source) - } - if strings.TrimSpace(a.Command) == "" { - return fmt.Errorf("action %q (%s): command is required", a.Name, a.source) - } - switch a.effectiveType() { - case TypeCommand, TypeForm: - // nothing extra to check - case TypeSelect: - selectable := 0 - for _, o := range a.Options { - if !o.isSeparator() { - selectable++ - } - } - if selectable == 0 { - return fmt.Errorf("action %q (%s): select actions need at least one selectable option (one with a label)", a.Name, a.source) - } - default: - return fmt.Errorf("action %q (%s): unknown type %q (want command, select, or form)", a.Name, a.source, a.Type) - } - return nil -} - -// render turns the action's command template into the final shell command. The -// chosen Value (option value or form input) is placed in ctx.Value. When the -// template does not explicitly reference .Value but a value is present, the -// value is appended as a single shell-quoted argument — so a command can either -// position the value precisely with {{.Value}} or just receive it as its last -// argument. -func (a Action) render(ctx RunContext) (string, error) { - tmpl, err := template.New(a.Name).Parse(a.Command) - if err != nil { - return "", fmt.Errorf("parse command for %q: %w", a.Name, err) - } - - var buf bytes.Buffer - if err := tmpl.Execute(&buf, ctx); err != nil { - return "", fmt.Errorf("render command for %q: %w", a.Name, err) - } - cmdline := buf.String() - - if ctx.Value != "" && !strings.Contains(a.Command, ".Value") { - cmdline += " " + shellQuote(ctx.Value) - } - return cmdline, nil -} - -// run renders and executes the action's command through the shell, in the -// invoking pane's working directory and with the context exported as -// HERDR_PLUS_* environment variables. Running through "sh -c" lets commands use -// pipes, arguments, and full scripts. -func (a Action) run(ctx RunContext) error { - cmdline, err := a.render(ctx) - if err != nil { - return err - } - - cmd := exec.Command("sh", "-c", cmdline) - if ctx.WorkDir != "" { - cmd.Dir = ctx.WorkDir - } - cmd.Env = append(os.Environ(), ctx.envPairs()...) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() -} - -// shellQuote wraps a string in single quotes so the shell treats it as one -// literal argument, escaping any embedded single quotes the usual POSIX way -// ('\” closes the quote, adds an escaped quote, and reopens it). -func shellQuote(s string) string { - return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" -} diff --git a/old/action_test.go b/old/action_test.go deleted file mode 100644 index 1429518..0000000 --- a/old/action_test.go +++ /dev/null @@ -1,139 +0,0 @@ -// -// Date: 2026-06-09 -// Author: Spicer Matthews (spicer@cloudmanic.com) -// Copyright: 2026 Cloudmanic Labs, LLC. All rights reserved. -// - -package main - -import ( - "strings" - "testing" -) - -// TestActionRender exercises command template rendering: explicit {{.Value}} -// placement, context variables, the urlquery helper, and the auto-append of a -// value when the template does not reference it. -func TestActionRender(t *testing.T) { - cases := []struct { - name string - action Action - ctx RunContext - want string - wantSub []string // substrings that must appear (when an exact match is brittle) - }{ - { - name: "plain command unchanged", - action: Action{Name: "GitHub", Command: "open https://github.com"}, - ctx: RunContext{}, - want: "open https://github.com", - }, - { - name: "value substituted into template", - action: Action{Name: "Repo", Type: TypeSelect, Command: "open https://github.com/cloudmanic/{{.Value}}"}, - ctx: RunContext{Value: "herdr-plus"}, - want: "open https://github.com/cloudmanic/herdr-plus", - }, - { - name: "value appended when template omits it", - action: Action{Name: "Say", Type: TypeForm, Command: "say"}, - ctx: RunContext{Value: "hi there"}, - want: "say 'hi there'", - }, - { - name: "value with single quote is shell-safe when appended", - action: Action{Name: "Say", Type: TypeForm, Command: "say"}, - ctx: RunContext{Value: "it's me"}, - want: `say 'it'\''s me'`, - }, - { - name: "workdir variable", - action: Action{Name: "Reveal", Command: "open {{.WorkDir}}"}, - ctx: RunContext{WorkDir: "/tmp/project"}, - want: "open /tmp/project", - }, - { - name: "session title method", - action: Action{Name: "Echo", Command: "echo {{.SessionTitle}}"}, - ctx: RunContext{WorkspaceLabel: "herdr-plus"}, - want: "echo herdr-plus", - }, - { - name: "urlquery escapes spaces", - action: Action{Name: "Search", Type: TypeForm, Command: "open 'https://g.co/s?q={{.Value | urlquery}}'"}, - ctx: RunContext{Value: "hello world"}, - wantSub: []string{"hello", "world"}, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - got, err := tc.action.render(tc.ctx) - if err != nil { - t.Fatalf("render: %v", err) - } - if tc.want != "" && got != tc.want { - t.Fatalf("render = %q, want %q", got, tc.want) - } - for _, sub := range tc.wantSub { - if !strings.Contains(got, sub) { - t.Fatalf("render = %q, want substring %q", got, sub) - } - } - if tc.name == "urlquery escapes spaces" && strings.Contains(got, "hello world") { - t.Fatalf("render = %q, space was not escaped", got) - } - }) - } -} - -// TestActionValidate confirms validation rejects incomplete or inconsistent -// action definitions and accepts well-formed ones. -func TestActionValidate(t *testing.T) { - cases := []struct { - name string - action Action - wantErr bool - }{ - {"valid command", Action{Name: "A", Command: "open x"}, false}, - {"valid select", Action{Name: "A", Type: TypeSelect, Command: "open {{.Value}}", Options: []Option{{Label: "x"}}}, false}, - {"valid form", Action{Name: "A", Type: TypeForm, Command: "open {{.Value}}"}, false}, - {"missing name", Action{Command: "open x"}, true}, - {"missing command", Action{Name: "A"}, true}, - {"select without options", Action{Name: "A", Type: TypeSelect, Command: "x"}, true}, - {"unknown type", Action{Name: "A", Type: "wat", Command: "x"}, true}, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - err := tc.action.validate() - if tc.wantErr && err == nil { - t.Fatalf("expected error, got nil") - } - if !tc.wantErr && err != nil { - t.Fatalf("unexpected error: %v", err) - } - }) - } -} - -// TestOptionResolvedValue checks that an option falls back to its label when no -// explicit value is given. -func TestOptionResolvedValue(t *testing.T) { - if got := (Option{Label: "Herdr Plus", Value: "herdr-plus"}).resolvedValue(); got != "herdr-plus" { - t.Fatalf("resolvedValue = %q, want %q", got, "herdr-plus") - } - if got := (Option{Label: "herdr-plus"}).resolvedValue(); got != "herdr-plus" { - t.Fatalf("resolvedValue = %q, want label fallback %q", got, "herdr-plus") - } -} - -// TestShellQuote verifies single-quote escaping produces a single shell token. -func TestShellQuote(t *testing.T) { - if got := shellQuote("plain"); got != "'plain'" { - t.Fatalf("shellQuote(plain) = %q", got) - } - if got := shellQuote("it's"); got != `'it'\''s'` { - t.Fatalf("shellQuote(it's) = %q", got) - } -} diff --git a/old/config.go b/old/config.go deleted file mode 100644 index 0bfdb8a..0000000 --- a/old/config.go +++ /dev/null @@ -1,196 +0,0 @@ -// -// Date: 2026-06-09 -// Author: Spicer Matthews (spicer@cloudmanic.com) -// Copyright: 2026 Cloudmanic Labs, LLC. All rights reserved. -// - -package main - -import ( - "embed" - "fmt" - "os" - "path/filepath" - "sort" - "strings" - - "github.com/BurntSushi/toml" -) - -// embeddedExamples holds the starter action files baked into the binary. They -// are copied into a mode's config directory the first time herdr-plus runs that -// mode, giving the user a working set of actions to learn from and edit. Keeping -// them embedded means the repo's examples/ tree is the single source of truth. -// -//go:embed examples -var embeddedExamples embed.FS - -// configBaseDir returns the root configuration directory, ~/.config/herdr-plus. -// It honors $XDG_CONFIG_HOME when set (the cross-platform convention) and -// otherwise falls back to ~/.config so the location is the same on macOS and -// Linux. -func configBaseDir() (string, error) { - if x := os.Getenv("XDG_CONFIG_HOME"); x != "" { - return filepath.Join(x, "herdr-plus"), nil - } - home, err := os.UserHomeDir() - if err != nil { - return "", err - } - return filepath.Join(home, ".config", "herdr-plus"), nil -} - -// modeConfigDir returns the per-mode subdirectory that holds a mode's action -// files, e.g. ~/.config/herdr-plus/quick-actions. -func modeConfigDir(mode Mode) (string, error) { - base, err := configBaseDir() - if err != nil { - return "", err - } - return filepath.Join(base, mode.Slug), nil -} - -// projectConfigDirName is the directory a repo adds at its root to ship its own -// herdr-plus config. It mirrors the global config layout: actions for a mode live -// in /.herdr-plus//. -const projectConfigDirName = ".herdr-plus" - -// projectConfigDir returns the project-local config directory for a mode within -// workDir, e.g. /.herdr-plus/quick-actions. It returns "" when workDir -// is unknown. Unlike the global dir, it is never created or seeded: project -// config is opt-in and read only when the repo actually provides it. -func projectConfigDir(mode Mode, workDir string) string { - if workDir == "" { - return "" - } - return filepath.Join(workDir, projectConfigDirName, mode.Slug) -} - -// ensureModeConfig makes sure a mode's config directory exists and returns its -// path. The very first time (when the directory does not yet exist) it seeds the -// directory with the embedded example actions. Once the directory exists it is -// left untouched, so deleting an example never causes it to reappear. -func ensureModeConfig(mode Mode) (string, error) { - dir, err := modeConfigDir(mode) - if err != nil { - return "", err - } - - if _, err := os.Stat(dir); err == nil { - return dir, nil - } else if !os.IsNotExist(err) { - return "", err - } - - if err := os.MkdirAll(dir, 0o755); err != nil { - return "", err - } - if err := seedExamples(mode, dir); err != nil { - return "", err - } - return dir, nil -} - -// seedExamples copies the embedded example actions for a mode into destDir. A -// mode without bundled examples seeds nothing, which is fine. -func seedExamples(mode Mode, destDir string) error { - srcDir := "examples/" + mode.Slug - entries, err := embeddedExamples.ReadDir(srcDir) - if err != nil { - // No bundled examples for this mode; nothing to seed. - return nil - } - for _, e := range entries { - if e.IsDir() { - continue - } - data, err := embeddedExamples.ReadFile(srcDir + "/" + e.Name()) - if err != nil { - return err - } - if err := os.WriteFile(filepath.Join(destDir, e.Name()), data, 0o644); err != nil { - return err - } - } - return nil -} - -// loadActions reads, parses, and validates every *.toml action in the mode's -// global config directory, returning them sorted by name and tagged as global. A -// malformed or invalid file fails the whole load with a message naming the -// offending files, so config mistakes surface loudly instead of an action -// silently going missing. -func loadActions(mode Mode) ([]Action, error) { - dir, err := ensureModeConfig(mode) - if err != nil { - return nil, err - } - return loadActionsFromDir(dir, originGlobal) -} - -// loadActionsFromDir reads, parses, and validates every *.toml action in dir, -// tagging each with origin and returning them sorted by name. A directory that -// does not exist yields no actions and no error, so an absent project config dir -// simply contributes nothing. A malformed or invalid file fails the whole load -// with a message naming the offending files and their directory. -func loadActionsFromDir(dir string, origin actionOrigin) ([]Action, error) { - if dir == "" { - return nil, nil - } - - entries, err := os.ReadDir(dir) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, err - } - - var actions []Action - var problems []string - for _, e := range entries { - if e.IsDir() || !strings.HasSuffix(e.Name(), ".toml") { - continue - } - path := filepath.Join(dir, e.Name()) - - var a Action - if _, err := toml.DecodeFile(path, &a); err != nil { - problems = append(problems, fmt.Sprintf(" %s: %v", e.Name(), err)) - continue - } - a.source = e.Name() - a.origin = origin - if err := a.validate(); err != nil { - problems = append(problems, " "+err.Error()) - continue - } - actions = append(actions, a) - } - - if len(problems) > 0 { - return nil, fmt.Errorf("invalid action files in %s:\n%s", dir, strings.Join(problems, "\n")) - } - - sort.Slice(actions, func(i, j int) bool { return actions[i].Name < actions[j].Name }) - return actions, nil -} - -// loadPickerActions loads the actions to show in the picker: the mode's global -// actions plus any project-local actions found in workDir's .herdr-plus// -// directory. Project actions come first and are tagged originProject, globals -// originGlobal, so the picker can group them. A repo without a .herdr-plus dir -// just yields the global set, exactly as before this feature existed. -func loadPickerActions(mode Mode, workDir string) ([]Action, error) { - global, err := loadActions(mode) - if err != nil { - return nil, err - } - - project, err := loadActionsFromDir(projectConfigDir(mode, workDir), originProject) - if err != nil { - return nil, err - } - - return append(project, global...), nil -} diff --git a/old/config_test.go b/old/config_test.go deleted file mode 100644 index 997d913..0000000 --- a/old/config_test.go +++ /dev/null @@ -1,201 +0,0 @@ -// -// Date: 2026-06-09 -// Author: Spicer Matthews (spicer@cloudmanic.com) -// Copyright: 2026 Cloudmanic Labs, LLC. All rights reserved. -// - -package main - -import ( - "os" - "path/filepath" - "testing" -) - -// TestLoadActionsSeedsAndParses points the config dir at a temp directory and -// confirms loadActions seeds the embedded examples on first run and that every -// bundled example parses and validates. This doubles as a guard that the shipped -// example TOML files stay correct. -func TestLoadActionsSeedsAndParses(t *testing.T) { - tmp := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", tmp) - - actions, err := loadActions(ModeQuickActions) - if err != nil { - t.Fatalf("loadActions: %v", err) - } - if len(actions) == 0 { - t.Fatal("expected seeded example actions, got none") - } - - // The directory should now exist with the seeded files. - dir := filepath.Join(tmp, "herdr-plus", ModeQuickActions.Slug) - if _, err := os.Stat(dir); err != nil { - t.Fatalf("mode config dir not created: %v", err) - } - - // At least one example of each type should be present and valid. - types := map[string]bool{} - for _, a := range actions { - if err := a.validate(); err != nil { - t.Fatalf("seeded action %q failed validation: %v", a.Name, err) - } - types[a.effectiveType()] = true - } - for _, want := range []string{TypeCommand, TypeSelect, TypeForm} { - if !types[want] { - t.Fatalf("seeded examples missing a %q action; have types %v", want, types) - } - } -} - -// TestLoadActionsRejectsBadFile confirms a malformed action file fails the load -// with an error rather than silently disappearing. -func TestLoadActionsRejectsBadFile(t *testing.T) { - tmp := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", tmp) - - dir := filepath.Join(tmp, "herdr-plus", ModeQuickActions.Slug) - if err := os.MkdirAll(dir, 0o755); err != nil { - t.Fatalf("mkdir: %v", err) - } - // A select action with no options is invalid. - bad := "name = \"Broken\"\ntype = \"select\"\ncommand = \"echo {{.Value}}\"\n" - if err := os.WriteFile(filepath.Join(dir, "broken.toml"), []byte(bad), 0o644); err != nil { - t.Fatalf("write: %v", err) - } - - if _, err := loadActions(ModeQuickActions); err == nil { - t.Fatal("expected error for invalid action file, got nil") - } -} - -// TestSeedingIsNotRepeatedOnExistingDir confirms an existing (even empty) mode -// directory is left untouched, so a user who deletes an example does not see it -// return. -func TestSeedingIsNotRepeatedOnExistingDir(t *testing.T) { - tmp := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", tmp) - - dir := filepath.Join(tmp, "herdr-plus", ModeQuickActions.Slug) - if err := os.MkdirAll(dir, 0o755); err != nil { - t.Fatalf("mkdir: %v", err) - } - - if _, err := ensureModeConfig(ModeQuickActions); err != nil { - t.Fatalf("ensureModeConfig: %v", err) - } - - entries, err := os.ReadDir(dir) - if err != nil { - t.Fatalf("readdir: %v", err) - } - if len(entries) != 0 { - t.Fatalf("expected existing dir left empty, found %d files", len(entries)) - } -} - -// TestProjectConfigDir confirms the project dir mirrors the global per-mode -// layout (/.herdr-plus/) and that an unknown working directory yields -// no path. -func TestProjectConfigDir(t *testing.T) { - got := projectConfigDir(ModeQuickActions, "/repo") - want := filepath.Join("/repo", ".herdr-plus", ModeQuickActions.Slug) - if got != want { - t.Fatalf("projectConfigDir = %q, want %q", got, want) - } - if got := projectConfigDir(ModeQuickActions, ""); got != "" { - t.Fatalf("projectConfigDir(\"\") = %q, want empty", got) - } -} - -// TestLoadActionsFromDirMissingIsEmpty confirms a non-existent directory yields no -// actions and no error, so an absent project config is simply ignored rather than -// failing the picker. -func TestLoadActionsFromDirMissingIsEmpty(t *testing.T) { - actions, err := loadActionsFromDir(filepath.Join(t.TempDir(), "nope"), originProject) - if err != nil { - t.Fatalf("loadActionsFromDir on missing dir: %v", err) - } - if actions != nil { - t.Fatalf("expected nil actions for missing dir, got %v", actions) - } -} - -// TestLoadPickerActionsCombinesGlobalAndProject confirms loadPickerActions returns -// the seeded global actions tagged global, that an absent .herdr-plus contributes -// nothing, and that a project action added under .herdr-plus/quick-actions then -// appears tagged project. -func TestLoadPickerActionsCombinesGlobalAndProject(t *testing.T) { - tmp := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", tmp) - - work := t.TempDir() - - // No .herdr-plus yet: only the seeded globals come back, all tagged global. - globalOnly, err := loadPickerActions(ModeQuickActions, work) - if err != nil { - t.Fatalf("loadPickerActions (no project): %v", err) - } - if len(globalOnly) == 0 { - t.Fatal("expected seeded global actions, got none") - } - for _, a := range globalOnly { - if a.origin != originGlobal { - t.Fatalf("action %q has origin %v, want global", a.Name, a.origin) - } - } - - // Add a project action and confirm it now appears, tagged project. - projDir := filepath.Join(work, projectConfigDirName, ModeQuickActions.Slug) - if err := os.MkdirAll(projDir, 0o755); err != nil { - t.Fatalf("mkdir project dir: %v", err) - } - actionTOML := "name = \"make build\"\ncommand = \"make build\"\n" - if err := os.WriteFile(filepath.Join(projDir, "make-build.toml"), []byte(actionTOML), 0o644); err != nil { - t.Fatalf("write project action: %v", err) - } - - combined, err := loadPickerActions(ModeQuickActions, work) - if err != nil { - t.Fatalf("loadPickerActions (with project): %v", err) - } - if len(combined) != len(globalOnly)+1 { - t.Fatalf("got %d actions, want %d", len(combined), len(globalOnly)+1) - } - - var found *Action - for i := range combined { - if combined[i].Name == "make build" { - found = &combined[i] - } - } - if found == nil { - t.Fatal("project action \"make build\" missing from combined set") - } - if found.origin != originProject { - t.Fatalf("project action origin = %v, want project", found.origin) - } -} - -// TestLoadPickerActionsRejectsBadProjectFile confirms a malformed project action -// fails the load just like a malformed global one, instead of silently dropping. -func TestLoadPickerActionsRejectsBadProjectFile(t *testing.T) { - tmp := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", tmp) - - work := t.TempDir() - projDir := filepath.Join(work, projectConfigDirName, ModeQuickActions.Slug) - if err := os.MkdirAll(projDir, 0o755); err != nil { - t.Fatalf("mkdir: %v", err) - } - // A select action with no options is invalid. - bad := "name = \"Broken\"\ntype = \"select\"\ncommand = \"echo {{.Value}}\"\n" - if err := os.WriteFile(filepath.Join(projDir, "broken.toml"), []byte(bad), 0o644); err != nil { - t.Fatalf("write: %v", err) - } - - if _, err := loadPickerActions(ModeQuickActions, work); err == nil { - t.Fatal("expected error for invalid project action file, got nil") - } -} diff --git a/old/context.go b/old/context.go deleted file mode 100644 index 38483d9..0000000 --- a/old/context.go +++ /dev/null @@ -1,145 +0,0 @@ -// -// Date: 2026-06-09 -// Author: Spicer Matthews (spicer@cloudmanic.com) -// Copyright: 2026 Cloudmanic Labs, LLC. All rights reserved. -// - -package main - -import ( - "encoding/base64" - "encoding/json" - "os" -) - -// RunContext is the bag of variables herdr-plus exposes to an action's command. -// It is gathered by the launcher — which runs in the pane the user invoked us -// from, so it sees the real working directory — then serialized and handed to -// the picker. The picker substitutes these values into the chosen command (as -// Go template fields) and also exports them to the command's environment. -type RunContext struct { - // Value is the dynamic input for the action: the chosen option's value for a - // "select" action, or the entered string for a "form" action. It is empty for - // a plain "command" action. - Value string `json:"value"` - - // WorkDir is the working directory the user invoked herdr-plus from. Actions - // run with this as their working directory and can read it as {{.WorkDir}}. - WorkDir string `json:"work_dir"` - - // herdr session/pane metadata, straight from the socket API. - PaneId string `json:"pane_id"` - TabId string `json:"tab_id"` - TabLabel string `json:"tab_label"` - WorkspaceId string `json:"workspace_id"` - WorkspaceLabel string `json:"workspace_label"` - TerminalId string `json:"terminal_id"` - Agent string `json:"agent"` - AgentSessionId string `json:"agent_session_id"` -} - -// SessionTitle is a friendly alias for the workspace label — herdr's closest -// notion of "what am I working on". Templates can use {{.SessionTitle}}. -func (c RunContext) SessionTitle() string { return c.WorkspaceLabel } - -// SessionId is a friendly alias for the workspace id, available as {{.SessionId}}. -func (c RunContext) SessionId() string { return c.WorkspaceId } - -// Home returns the current user's home directory, available as {{.Home}}. -func (c RunContext) Home() string { - h, _ := os.UserHomeDir() - return h -} - -// envPairs renders the context as KEY=VALUE strings so any spawned command can -// read the same variables from its environment (handy for scripts that would -// rather not bother with templating). Every field is prefixed HERDR_PLUS_ to -// avoid colliding with herdr's own HERDR_ variables. -func (c RunContext) envPairs() []string { - return []string{ - "HERDR_PLUS_VALUE=" + c.Value, - "HERDR_PLUS_WORKDIR=" + c.WorkDir, - "HERDR_PLUS_PANE_ID=" + c.PaneId, - "HERDR_PLUS_TAB_ID=" + c.TabId, - "HERDR_PLUS_TAB_LABEL=" + c.TabLabel, - "HERDR_PLUS_WORKSPACE_ID=" + c.WorkspaceId, - "HERDR_PLUS_WORKSPACE_LABEL=" + c.WorkspaceLabel, - "HERDR_PLUS_SESSION_TITLE=" + c.WorkspaceLabel, - "HERDR_PLUS_SESSION_ID=" + c.WorkspaceId, - "HERDR_PLUS_TERMINAL_ID=" + c.TerminalId, - "HERDR_PLUS_AGENT=" + c.Agent, - "HERDR_PLUS_AGENT_SESSION_ID=" + c.AgentSessionId, - } -} - -// encode serializes the context to a base64 JSON blob so the launcher can pass -// it to the picker as a single, shell-safe command-line argument. -func (c RunContext) encode() (string, error) { - b, err := json.Marshal(c) - if err != nil { - return "", err - } - return base64.StdEncoding.EncodeToString(b), nil -} - -// decodeRunContext is the inverse of encode. An empty string yields a zero -// context rather than an error so the picker can still run with no metadata. -func decodeRunContext(s string) (RunContext, error) { - var c RunContext - if s == "" { - return c, nil - } - b, err := base64.StdEncoding.DecodeString(s) - if err != nil { - return c, err - } - err = json.Unmarshal(b, &c) - return c, err -} - -// gatherContext collects everything herdr-plus knows about the invoking pane: -// the working directory plus the pane, tab, and workspace metadata herdr reports -// over the socket. Any field herdr cannot supply is left empty — a partial -// context is far better than refusing to launch. -func gatherContext(client *herdrClient, paneID string) RunContext { - ctx := RunContext{PaneId: paneID} - - pane, err := client.paneGet(paneID) - if err != nil { - // Socket unavailable; the best we can do is our own working directory. - if wd, e := os.Getwd(); e == nil { - ctx.WorkDir = wd - } - return ctx - } - - ctx.TabId = pane.TabID - ctx.WorkspaceId = pane.WorkspaceID - ctx.TerminalId = pane.TerminalID - ctx.Agent = pane.Agent - ctx.AgentSessionId = pane.AgentSession.Value - - // The pane's cwd is authoritative for the working directory: it is correct - // whether we were launched from the pane's own shell (where HERDR_PANE_ID is - // set) or from a keybinding (which runs server-side, where our own process - // cwd would be wrong). Prefer the foreground process cwd, then the shell cwd, - // and only fall back to our own cwd if herdr reports neither. - ctx.WorkDir = pane.ForegroundCwd - if ctx.WorkDir == "" { - ctx.WorkDir = pane.Cwd - } - if ctx.WorkDir == "" { - if wd, e := os.Getwd(); e == nil { - ctx.WorkDir = wd - } - } - - // Tab and workspace labels are best-effort; ignore their errors. - if tab, err := client.tabGet(pane.TabID); err == nil { - ctx.TabLabel = tab.Label - } - if ws, err := client.workspaceGet(pane.WorkspaceID); err == nil { - ctx.WorkspaceLabel = ws.Label - } - return ctx -} diff --git a/old/context_test.go b/old/context_test.go deleted file mode 100644 index 1bf589a..0000000 --- a/old/context_test.go +++ /dev/null @@ -1,97 +0,0 @@ -// -// Date: 2026-06-09 -// Author: Spicer Matthews (spicer@cloudmanic.com) -// Copyright: 2026 Cloudmanic Labs, LLC. All rights reserved. -// - -package main - -import ( - "strings" - "testing" -) - -// TestRunContextRoundTrip checks that a context survives encode/decode intact, -// since the launcher and picker are separate processes that communicate through -// this serialized blob. -func TestRunContextRoundTrip(t *testing.T) { - want := RunContext{ - Value: "some value", - WorkDir: "/Users/spicer/Development/cloudmanic/herdr-plus", - PaneId: "p_172", - TabId: "w1:1", - TabLabel: "claude", - WorkspaceId: "w1", - WorkspaceLabel: "herdr-plus", - TerminalId: "term_1", - Agent: "claude", - AgentSessionId: "abc-123", - } - - encoded, err := want.encode() - if err != nil { - t.Fatalf("encode: %v", err) - } - - got, err := decodeRunContext(encoded) - if err != nil { - t.Fatalf("decode: %v", err) - } - if got != want { - t.Fatalf("round trip mismatch:\n got %+v\nwant %+v", got, want) - } -} - -// TestDecodeEmptyContext confirms an empty string decodes to a zero context -// rather than an error, so the picker can still run with no metadata. -func TestDecodeEmptyContext(t *testing.T) { - got, err := decodeRunContext("") - if err != nil { - t.Fatalf("decode empty: %v", err) - } - if (got != RunContext{}) { - t.Fatalf("decode empty = %+v, want zero context", got) - } -} - -// TestContextAliases verifies the friendly accessors map to the workspace -// fields. -func TestContextAliases(t *testing.T) { - c := RunContext{WorkspaceLabel: "herdr-plus", WorkspaceId: "w1"} - if c.SessionTitle() != "herdr-plus" { - t.Fatalf("SessionTitle = %q", c.SessionTitle()) - } - if c.SessionId() != "w1" { - t.Fatalf("SessionId = %q", c.SessionId()) - } -} - -// TestEnvPairs confirms the context is exported with the HERDR_PLUS_ prefix and -// includes the session-title alias. -func TestEnvPairs(t *testing.T) { - c := RunContext{Value: "v", WorkDir: "/wd", WorkspaceLabel: "herdr-plus"} - pairs := c.envPairs() - - want := map[string]string{ - "HERDR_PLUS_VALUE": "v", - "HERDR_PLUS_WORKDIR": "/wd", - "HERDR_PLUS_SESSION_TITLE": "herdr-plus", - } - for key, val := range want { - found := false - for _, p := range pairs { - if p == key+"="+val { - found = true - break - } - } - if !found { - t.Fatalf("env pairs missing %s=%s in %v", key, val, pairs) - } - } - for _, p := range pairs { - if !strings.HasPrefix(p, "HERDR_PLUS_") { - t.Fatalf("env pair %q is not HERDR_PLUS_ prefixed", p) - } - } -} diff --git a/old/control.go b/old/control.go deleted file mode 100644 index 28375f9..0000000 --- a/old/control.go +++ /dev/null @@ -1,205 +0,0 @@ -// -// Date: 2026-06-09 -// Author: Spicer Matthews (spicer@cloudmanic.com) -// Copyright: 2026 Cloudmanic Labs, LLC. All rights reserved. -// - -package main - -import ( - "flag" - "fmt" - "os" - "strings" - - tea "github.com/charmbracelet/bubbletea" -) - -// controlWorkspaceLabel is the title herdr-plus gives the workspace it opens for -// control mode. It is the home base from which you drive herdr. -const controlWorkspaceLabel = "Herdr Plus" - -// controlProjectsTabLabel is the name of the tab inside the control workspace -// where the projects browser runs. -const controlProjectsTabLabel = "projects" - -// launchControl is control mode's launcher. Unlike quick-actions (which splits -// the current pane), it opens a brand-new, full-screen workspace titled -// "Herdr Plus", renames its root tab to "projects", and starts the control UI -// inside that pane. It returns immediately so the pane the user pressed the -// keybinding from keeps its prompt. -func launchControl(client *herdrClient) { - home, err := os.UserHomeDir() - if err != nil { - errExit(err) - } - - // Resolve our own absolute path so the new pane's shell can launch the - // control UI even when the binary is not on PATH. - exe, err := os.Executable() - if err != nil { - errExit(err) - } - - // Open the focused control workspace rooted at the home directory. - ws, tab, pane, err := client.workspaceCreate(home, controlWorkspaceLabel, true) - if err != nil { - errExit("could not create control workspace:", err) - } - - // Rename the root tab to "projects". Best effort: if it fails the tab simply - // keeps its default numbered name. - _ = client.tabRename(tab, controlProjectsTabLabel) - - // Start the control UI in the new pane, handing it the control workspace id so - // it can tear the whole workspace down when the user picks a project or quits. - // runCommand waits for the new shell's prompt and submits with a real Enter - // key (not a trailing newline — see sendInput), so the UI starts reliably. - launch := fmt.Sprintf("%s control %s", shellQuote(exe), ws) - if err := client.runCommand(pane, launch); err != nil { - errExit("failed to start control UI:", err) - } -} - -// runControlCmd parses the internal `control` invocation. Its single positional -// argument is the id of the control workspace to close on exit. -func runControlCmd(args []string) { - fs := flag.NewFlagSet("control", flag.ExitOnError) - _ = fs.Parse(args) - - controlWS := "" - if rest := fs.Args(); len(rest) > 0 { - controlWS = rest[0] - } - - runControl(controlWS) -} - -// runControl loads the projects, renders the full-screen browser, and acts on -// the result: opening the chosen project's workspace, or — on cancel — tearing -// down the ephemeral control workspace. controlWS is the workspace this UI runs -// in, which is closed once we are done with it. -func runControl(controlWS string) { - projects, err := loadProjects() - if err != nil { - // Leave the pane open so the user can read the config error. - errExit(err) - } - - dir, _ := projectsConfigDir() - - // WithMouseCellMotion enables click/release/wheel events so a project can be - // opened with the mouse. herdr forwards these to us once we ask for them; - // until then it keeps the mouse for its own pane focus/selection. - p := tea.NewProgram(newControlModel(projects, dir), tea.WithAltScreen(), tea.WithMouseCellMotion()) - result, err := p.Run() - if err != nil { - fmt.Fprintln(os.Stderr, "herdr-plus:", err) - } - - m, ok := result.(controlModel) - if !ok || m.chosen == nil { - // Cancelled (or the program never produced a model) — remove the - // ephemeral control workspace and return focus to where the user was. - closeControlWorkspace(controlWS) - return - } - - client, err := newHerdrClient() - if err != nil { - errExit(err) - } - if err := openProject(client, *m.chosen, controlWS); err != nil { - // Leave the control workspace open so the error stays on screen. - errExit("could not open project:", err) - } -} - -// openProject turns a project into a live herdr workspace: it creates a focused -// workspace rooted at the project's working directory, lays out one tab per -// entry (the first reusing the workspace's root tab, the rest created behind -// it), runs each tab's startup command, and finally closes the control -// workspace we were launched from. -func openProject(client *herdrClient, p Project, controlWS string) error { - dir := p.expandedWorkingDir() - if fi, err := os.Stat(dir); err != nil || !fi.IsDir() { - return fmt.Errorf("working directory does not exist: %s", dir) - } - - ws, rootTab, rootPane, err := client.workspaceCreate(dir, p.Name, true) - if err != nil { - return fmt.Errorf("create workspace: %w", err) - } - - // pendingRun pairs a pane with the command it should run once all panes exist. - type pendingRun struct { - pane string - command string - } - var runs []pendingRun - - // Create every tab in the project's order. tab[0] reuses the workspace's root - // tab (renamed); each later tab is created without focus so the first tab - // stays in front while the rest spin up. Within a tab, the first pane is the - // tab's root and each later pane is split off the previous one. - for i, t := range p.Tabs { - tabRoot := rootPane - if i == 0 { - if err := client.tabRename(rootTab, t.Name); err != nil { - return fmt.Errorf("rename root tab: %w", err) - } - } else { - _, tabRoot, err = client.tabCreate(ws, t.Name, false) - if err != nil { - return fmt.Errorf("create tab %q: %w", t.Name, err) - } - } - - prev := tabRoot - for j, pane := range t.effectivePanes() { - paneID := tabRoot - if j > 0 { - paneID, err = client.paneSplit(prev, pane.Split, false) - if err != nil { - return fmt.Errorf("split pane %d in tab %q: %w", j+1, t.Name, err) - } - } - if strings.TrimSpace(pane.Command) != "" { - runs = append(runs, pendingRun{pane: paneID, command: pane.Command}) - } - prev = paneID - } - } - - // Run each tab's startup command. runCommand paces itself to each freshly - // spawned shell — waiting for its prompt, typing the command, then submitting - // with a real Enter key — so the apps (claude, lazygit, …) actually start - // instead of sitting unsubmitted at the prompt for the user to press Enter. - for _, r := range runs { - if err := client.runCommand(r.pane, r.command); err != nil { - return fmt.Errorf("run command in pane %s: %w", r.pane, err) - } - } - - // Tear down the ephemeral control workspace. Focus is already on the new - // project workspace, so this only removes the launcher. This also closes the - // pane this process is running in, so it is the last thing we do. - if controlWS != "" { - _ = client.workspaceClose(controlWS) - } - return nil -} - -// closeControlWorkspace asks herdr to close the control workspace. Failures are -// ignored: from a pane that is about to go away, there is nothing useful to do -// if the socket is unreachable. -func closeControlWorkspace(workspaceID string) { - if workspaceID == "" { - return - } - client, err := newHerdrClient() - if err != nil { - return - } - _ = client.workspaceClose(workspaceID) -} diff --git a/old/controlmodel.go b/old/controlmodel.go deleted file mode 100644 index 33b3382..0000000 --- a/old/controlmodel.go +++ /dev/null @@ -1,460 +0,0 @@ -// -// Date: 2026-06-09 -// Author: Spicer Matthews (spicer@cloudmanic.com) -// Copyright: 2026 Cloudmanic Labs, LLC. All rights reserved. -// - -package main - -import ( - "sort" - "strconv" - "strings" - - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" -) - -// docsURL is where the empty-state points for "more documentation". Full docs -// will live elsewhere later; for now the repo is the home of everything. -const docsURL = "https://github.com/cloudmanic/herdr-plus" - -// controlHeaderLines is how many screen lines precede the embedded fuzzyList in -// the projects browser: the full-width title bar and the blank line under it. A -// mouse click's screen row minus this offset is the list-local line for clickRow. -const controlHeaderLines = 2 - -// Control-mode styles. These build on the shared palette declared in picker.go -// (titleStyle, nameStyle, descStyle, footerStyle, …); here we add the few extra -// pieces the full-screen projects browser needs. -var ( - // headerBarStyle is the full-width purple title bar across the top. - headerBarStyle = titleStyle - - // detailBoxStyle frames the bottom bar that previews the highlighted project. - detailBoxStyle = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("#A78BFA")). - Padding(0, 1) - - // dirIconStyle / pathStyle render the "📁 " line of the detail bar. - dirIconStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#A78BFA")) - pathStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E5E7EB")) - tabNameStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#C4B5FD")) - dotStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#4B5563")) - - // Empty-state styles: a centered onboarding card. - cardStyle = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("#A78BFA")). - Padding(1, 3) - cardTitleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#A78BFA")).Bold(true) - bodyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E5E7EB")) - pathHintStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#F2A900")).Bold(true) - codeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#9CA3AF")) - linkStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#67E8F9")).Underline(true) -) - -// controlModel is the full-screen projects browser shown in the "Herdr Plus" -// control workspace. It is a thin shell around the shared fuzzyList: arrow or -// type to find a project, enter to open it, esc to back out. When there are no -// projects it renders an onboarding card instead of the list. -type controlModel struct { - projects []Project - list fuzzyList - projectsDir string - - width int - height int - - // chosen is the project to open, read back after the program exits; nil when - // the user cancelled. - chosen *Project - quitting bool -} - -// ungroupedHeading labels the catch-all bucket for projects that declare no -// group. It is only ever shown when at least one other project does declare one; -// when no project sets a group the browser has no headings at all. -const ungroupedHeading = "Ungrouped" - -// newControlModel builds the initial TUI state over the loaded projects. -// projectsDir is shown in the empty-state so the user knows where to add files. -// Projects are arranged into group headings (see orderProjectsByGroup) and the -// resulting display order is stored on the model so each list row's ref indexes -// straight back into it. -func newControlModel(projects []Project, projectsDir string) controlModel { - ordered, items := orderProjectsByGroup(projects) - return controlModel{ - projects: ordered, - list: newFuzzyList("Type to filter projects…", items), - projectsDir: projectsDir, - } -} - -// orderProjectsByGroup arranges projects for the browser and returns them in -// display order alongside the matching list rows. Grouping engages only when at -// least one project declares a group: named groups come first in -// case-insensitive alphabetical order, each introduced by a non-selectable -// heading row, followed by any group-less projects under an "Ungrouped" heading. -// Within every group the input order (name-sorted by loadProjects) is preserved, -// so a client's projects stay alphabetized under their heading. Each selectable -// row's ref indexes into the returned slice, so the caller stores that slice and -// looks a project up by ref directly. When no project declares a group, the input -// is returned unchanged with a plain, heading-less list — the browser then looks -// exactly as it did before this feature. Filtering is unaffected: the fuzzyList -// drops heading rows while a query is active, collapsing back to one ranked list. -func orderProjectsByGroup(projects []Project) ([]Project, []listItem) { - // Does anything opt into grouping? If not, emit a plain list whose refs index - // straight into the unchanged input. - grouped := false - for _, p := range projects { - if strings.TrimSpace(p.Group) != "" { - grouped = true - break - } - } - if !grouped { - items := make([]listItem, len(projects)) - for i, p := range projects { - items[i] = listItem{name: p.Name, desc: p.Description, selectable: true, ref: i} - } - return projects, items - } - - // Partition into named groups (first-seen order recorded for sorting) plus the - // group-less remainder, preserving each project's incoming name order. - byGroup := map[string][]Project{} - var groupNames []string - var ungrouped []Project - for _, p := range projects { - g := strings.TrimSpace(p.Group) - if g == "" { - ungrouped = append(ungrouped, p) - continue - } - if _, seen := byGroup[g]; !seen { - groupNames = append(groupNames, g) - } - byGroup[g] = append(byGroup[g], p) - } - - // Sort group headings case-insensitively, falling back to the raw label so two - // groups differing only in case still order deterministically. - sort.SliceStable(groupNames, func(i, j int) bool { - li, lj := strings.ToLower(groupNames[i]), strings.ToLower(groupNames[j]) - if li == lj { - return groupNames[i] < groupNames[j] - } - return li < lj - }) - - ordered := make([]Project, 0, len(projects)) - items := make([]listItem, 0, len(projects)+len(groupNames)+1) - - // Emit each named group's heading followed by its projects; ref tracks the - // running index into ordered so every row points back at its project. - for _, name := range groupNames { - items = append(items, listItem{name: name}) - for _, p := range byGroup[name] { - items = append(items, listItem{name: p.Name, desc: p.Description, selectable: true, ref: len(ordered)}) - ordered = append(ordered, p) - } - } - - // Group-less projects trail under the catch-all heading. - if len(ungrouped) > 0 { - items = append(items, listItem{name: ungroupedHeading}) - for _, p := range ungrouped { - items = append(items, listItem{name: p.Name, desc: p.Description, selectable: true, ref: len(ordered)}) - ordered = append(ordered, p) - } - } - - return ordered, items -} - -// Init implements tea.Model and starts the cursor blinking. -func (m controlModel) Init() tea.Cmd { - return textinput.Blink -} - -// Update routes key presses; everything else (window sizes, the blink tick) is -// forwarded to the query box so the cursor keeps blinking and text keeps flowing. -func (m controlModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - return m, nil - - case tea.KeyMsg: - // With no projects, the screen is just an onboarding card: any exit key - // closes it. - if len(m.projects) == 0 { - switch msg.String() { - case "ctrl+c", "esc", "q", "enter": - m.quitting = true - return m, tea.Quit - } - return m, nil - } - - switch msg.String() { - case "ctrl+c", "esc": - m.quitting = true - return m, tea.Quit - case "up", "ctrl+p": - m.list.moveUp() - return m, nil - case "down", "ctrl+n": - m.list.moveDown() - return m, nil - case "enter": - return m.activateProject() - } - - cmd := m.list.editQuery(msg) - return m, cmd - - case tea.MouseMsg: - // The onboarding card (no projects) has nothing to click. - if len(m.projects) == 0 { - return m, nil - } - switch msg.Button { - case tea.MouseButtonWheelUp: - m.list.moveUp() - case tea.MouseButtonWheelDown: - m.list.moveDown() - case tea.MouseButtonLeft: - // Move the highlight to the clicked row, opening it on release — the - // natural completion of a click. - if m.list.clickRow(msg.Y-controlHeaderLines) && msg.Action == tea.MouseActionRelease { - return m.activateProject() - } - } - return m, nil - } - - // Non-key messages (e.g. the blink tick) keep the input alive. - cmd := m.list.editQuery(msg) - return m, cmd -} - -// activateProject records the highlighted project as the chosen one and signals -// a quit so its workspace gets built. Shared by the enter key and a left-click; -// activating with nothing selectable is a no-op. -func (m controlModel) activateProject() (tea.Model, tea.Cmd) { - idx := m.list.selectedIndex() - if idx < 0 { - return m, nil - } - p := m.projects[idx] - m.chosen = &p - return m, tea.Quit -} - -// View renders the screen for the current state: the onboarding card when there -// are no projects, otherwise the header / list / detail-bar / footer layout. -func (m controlModel) View() string { - if m.quitting { - return "" - } - - // Fall back to a sane size until the first WindowSizeMsg arrives. - w, h := m.width, m.height - if w <= 0 { - w = 80 - } - if h <= 0 { - h = 24 - } - - if len(m.projects) == 0 { - return m.emptyView(w, h) - } - return m.browserView(w, h) -} - -// browserView lays out the populated projects browser: a full-width title bar up -// top, the fuzzy list below it, and the highlighted project's detail bar pinned -// just above the footer at the bottom of the screen. -func (m controlModel) browserView(w, h int) string { - header := headerBarStyle.Width(w).Render(ModeControl.Title) - - body := m.list.view("no matching projects") - - detail := m.detailBar(w) - footer := footerStyle.Render(" ↑/↓ move · type to filter · click/enter open · esc quit") - - top := header + "\n\n" + body - bottom := detail + "\n" + footer - - // Pin the detail bar + footer to the bottom by padding the space between. - gap := h - lipgloss.Height(top) - lipgloss.Height(bottom) - if gap < 1 { - gap = 1 - } - return top + strings.Repeat("\n", gap) + bottom -} - -// detailBar renders the bordered preview of the currently highlighted project: -// its working directory and the ordered list of tab names. It updates live as -// the cursor moves. -func (m controlModel) detailBar(w int) string { - // lipgloss Width counts content+padding; the 1-col border is added outside, - // so Width(w-2) makes the box span (almost) the full screen width. - box := detailBoxStyle.Width(w - 2) - - idx := m.list.selectedIndex() - if idx < 0 { - return box.Render(descStyle.Render("no matching project")) - } - p := m.projects[idx] - - // inner is the usable text width inside the box; keep a couple columns of - // slack under the true content width so a long line never soft-wraps and - // breaks the fixed two-line box. - inner := w - 7 - if inner < 10 { - inner = 10 - } - - dirLine := dirIconStyle.Render("📁 ") + pathStyle.Render(truncate(p.expandedWorkingDir(), inner-3)) - - labels := p.tabLabels() - styled := make([]string, len(labels)) - for i, n := range labels { - styled[i] = tabNameStyle.Render(n) - } - tabsLine := strings.Join(styled, dotStyle.Render(" · ")) - tabsLine = truncateStyled(tabsLine, labels, inner) - - return box.Render(dirLine + "\n" + tabsLine) -} - -// emptyView renders the onboarding card shown the first time, before any project -// files exist: what projects are, where to put them, a copy-paste example, and a -// docs link. It is centered in the full screen. -func (m controlModel) emptyView(w, h int) string { - var b strings.Builder - - b.WriteString(cardTitleStyle.Render("Welcome to Herdr Plus · Projects")) - b.WriteString("\n\n") - b.WriteString(bodyStyle.Render("A project is a saved herdr workspace: a working directory and an")) - b.WriteString("\n") - b.WriteString(bodyStyle.Render("ordered set of tabs, each with a command to run on startup. Pick one")) - b.WriteString("\n") - b.WriteString(bodyStyle.Render("here and herdr-plus spins up the whole workspace for you.")) - b.WriteString("\n\n") - b.WriteString(bodyStyle.Render("Create your first project — drop a .toml file in:")) - b.WriteString("\n") - b.WriteString(pathHintStyle.Render(" " + m.projectsDir)) - b.WriteString("\n\n") - b.WriteString(descStyle.Render("Example (" + exampleFileName + "):")) - b.WriteString("\n") - b.WriteString(codeStyle.Render(indent(exampleProjectSnippet(), " "))) - b.WriteString("\n\n") - b.WriteString(descStyle.Render("Docs: ")) - b.WriteString(linkStyle.Render(docsURL)) - - card := cardStyle.Render(b.String()) - - footer := footerStyle.Render("esc to close") - content := lipgloss.JoinVertical(lipgloss.Center, card, "", footer) - - return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, content) -} - -// exampleFileName / exampleProjectTOML expose the bundled sample project so the -// empty-state and the repo share one source of truth (it is embedded by the -// //go:embed directive in config.go). -const exampleFileName = "options-cafe.toml" - -func exampleProjectTOML() string { - b, err := embeddedExamples.ReadFile("examples/projects/example.toml") - if err != nil { - return "" - } - return strings.TrimRight(string(b), "\n") -} - -// exampleProjectSnippet is the example trimmed for the empty-state card: full-line -// comments are dropped and runs of blank lines collapsed, so the card stays -// compact enough for shorter terminals while still derived from the one embedded -// source of truth. Inline comments (after a value) are kept — they teach. -func exampleProjectSnippet() string { - var out []string - blank := false - for _, line := range strings.Split(exampleProjectTOML(), "\n") { - trimmed := strings.TrimSpace(line) - if strings.HasPrefix(trimmed, "#") { - continue - } - if trimmed == "" { - if blank || len(out) == 0 { - continue // collapse consecutive blanks and skip a leading blank - } - blank = true - out = append(out, "") - continue - } - blank = false - out = append(out, line) - } - return strings.TrimRight(strings.Join(out, "\n"), "\n") -} - -// indent prefixes every line of s with prefix, used to inset the example block. -func indent(s, prefix string) string { - lines := strings.Split(s, "\n") - for i, l := range lines { - lines[i] = prefix + l - } - return strings.Join(lines, "\n") -} - -// truncate shortens a plain (unstyled) string to max display columns, ending it -// with an ellipsis when it had to cut. Used for the working-dir path. -func truncate(s string, max int) string { - if max <= 0 { - return "" - } - if lipgloss.Width(s) <= max { - return s - } - r := []rune(s) - for len(r) > 0 && lipgloss.Width(string(r))+1 > max { - r = r[:len(r)-1] - } - return string(r) + "…" -} - -// truncateStyled keeps the styled tab-names line from overflowing the detail box. -// Styling makes display width hard to measure directly, so it falls back to the -// plain names: if they fit, the styled line is returned untouched; if not, it -// re-renders only as many names as fit, plus a "+N" tail. -func truncateStyled(styled string, names []string, max int) string { - plain := strings.Join(names, " · ") - if lipgloss.Width(plain) <= max { - return styled - } - - var shown []string - width := 0 - for _, n := range names { - add := lipgloss.Width(n) - if len(shown) > 0 { - add += 3 // " · " - } - if width+add > max-6 { // leave room for the "+N more" tail - break - } - width += add - shown = append(shown, tabNameStyle.Render(n)) - } - tail := dotStyle.Render(" +" + strconv.Itoa(len(names)-len(shown)) + " more") - return strings.Join(shown, dotStyle.Render(" · ")) + tail -} diff --git a/old/controlmodel_test.go b/old/controlmodel_test.go deleted file mode 100644 index 45eef3c..0000000 --- a/old/controlmodel_test.go +++ /dev/null @@ -1,268 +0,0 @@ -// -// Date: 2026-06-09 -// Author: Spicer Matthews (spicer@cloudmanic.com) -// Copyright: 2026 Cloudmanic Labs, LLC. All rights reserved. -// - -package main - -import ( - "strings" - "testing" - - tea "github.com/charmbracelet/bubbletea" -) - -// sampleProjects returns two pre-sorted projects for the model tests. -func sampleProjects() []Project { - return []Project{ - {Name: "Alpha", Description: "first one", WorkingDir: "/srv/alpha", Tabs: []ProjectTab{{Name: "run", Command: "make serve"}}}, - {Name: "Bravo", Description: "second one", WorkingDir: "/srv/bravo", Tabs: []ProjectTab{{Name: "edit", Command: "vim"}, {Name: "shell"}}}, - } -} - -// step feeds one message to the model and returns the updated controlModel. -func step(t *testing.T, m controlModel, msg tea.Msg) controlModel { - t.Helper() - updated, _ := m.Update(msg) - cm, ok := updated.(controlModel) - if !ok { - t.Fatalf("Update returned %T, want controlModel", updated) - } - return cm -} - -// TestNewControlModelItems confirms each project becomes a selectable list row -// carrying its name, description, and original index. -func TestNewControlModelItems(t *testing.T) { - m := newControlModel(sampleProjects(), "/cfg/projects") - if len(m.list.items) != 2 { - t.Fatalf("got %d list items, want 2", len(m.list.items)) - } - if !m.list.items[0].selectable || m.list.items[0].name != "Alpha" || m.list.items[0].desc != "first one" || m.list.items[0].ref != 0 { - t.Fatalf("item0 = %+v", m.list.items[0]) - } - if m.list.items[1].ref != 1 { - t.Fatalf("item1 ref = %d, want 1", m.list.items[1].ref) - } -} - -// groupedProjects returns four name-sorted projects spanning two named groups -// and one group-less project, the way loadProjects would hand them over. -func groupedProjects() []Project { - return []Project{ - {Name: "Acme API", Group: "Acme", Tabs: []ProjectTab{{Name: "run", Command: "make"}}}, - {Name: "Acme Web", Group: "Acme", Tabs: []ProjectTab{{Name: "run", Command: "make"}}}, - {Name: "Loose", Tabs: []ProjectTab{{Name: "run", Command: "make"}}}, - {Name: "Zeta", Group: "beta", Tabs: []ProjectTab{{Name: "run", Command: "make"}}}, - } -} - -// TestOrderProjectsByGroupUngrouped confirms that with no project declaring a -// group, the rows are a plain, heading-less list whose refs index straight into -// the unchanged input — i.e. the pre-feature behavior is preserved exactly. -func TestOrderProjectsByGroupUngrouped(t *testing.T) { - in := sampleProjects() // neither sample sets a group - ordered, items := orderProjectsByGroup(in) - - if len(ordered) != len(in) || ordered[0].Name != "Alpha" || ordered[1].Name != "Bravo" { - t.Fatalf("ungrouped order changed: %+v", ordered) - } - if len(items) != 2 { - t.Fatalf("got %d rows, want 2 (no headings)", len(items)) - } - for i, it := range items { - if !it.selectable || it.ref != i { - t.Fatalf("row %d = %+v, want a selectable row with ref %d", i, it, i) - } - } -} - -// TestOrderProjectsByGroupHeadings confirms that once any project sets a group, -// the rows become heading-bracketed: named groups in case-insensitive -// alphabetical order ("Acme" before "beta"), then an "Ungrouped" heading, with -// each selectable row's ref pointing at the right project in the returned order. -func TestOrderProjectsByGroupHeadings(t *testing.T) { - ordered, items := orderProjectsByGroup(groupedProjects()) - - // The display order interleaves groups: Acme's two, then beta's one, then the - // group-less one last. - wantOrder := []string{"Acme API", "Acme Web", "Zeta", "Loose"} - if len(ordered) != len(wantOrder) { - t.Fatalf("got %d ordered projects, want %d", len(ordered), len(wantOrder)) - } - for i, name := range wantOrder { - if ordered[i].Name != name { - t.Fatalf("ordered[%d] = %q, want %q", i, ordered[i].Name, name) - } - } - - // The row sequence: heading, its projects, … then the Ungrouped heading. - var headings []string - for _, it := range items { - if !it.selectable { - headings = append(headings, it.name) - continue - } - // Every selectable row's ref must resolve to the project of the same name. - if ordered[it.ref].Name != it.name { - t.Fatalf("row %q ref %d resolves to %q", it.name, it.ref, ordered[it.ref].Name) - } - } - wantHeadings := []string{"Acme", "beta", ungroupedHeading} - if strings.Join(headings, ",") != strings.Join(wantHeadings, ",") { - t.Fatalf("headings = %v, want %v", headings, wantHeadings) - } -} - -// TestControlModelGroupedSelect confirms the grouped browser is fully navigable: -// the cursor starts on the first project under the first heading (skipping the -// heading row), and entering opens the project its ref points at. -func TestControlModelGroupedSelect(t *testing.T) { - m := newControlModel(groupedProjects(), "/cfg/projects") - m = step(t, m, tea.WindowSizeMsg{Width: 100, Height: 30}) - - // Cursor parks on the first selectable row, not the "Acme" heading. - if got := m.list.selectedIndex(); got != 0 || m.projects[got].Name != "Acme API" { - t.Fatalf("initial selection ref = %d, want 0 (Acme API)", got) - } - - // Down once lands on Acme Web; once more skips the "beta" heading to Zeta. - m = step(t, m, tea.KeyMsg{Type: tea.KeyDown}) - m = step(t, m, tea.KeyMsg{Type: tea.KeyDown}) - m = step(t, m, tea.KeyMsg{Type: tea.KeyEnter}) - - if m.chosen == nil || m.chosen.Name != "Zeta" { - t.Fatalf("after two downs + enter, chosen = %v, want Zeta", m.chosen) - } -} - -// TestControlModelGroupedFilterDropsHeadings confirms search is unchanged: a -// query collapses the grouped view to a single ranked list with no headings, and -// enter opens the lone match. -func TestControlModelGroupedFilterDropsHeadings(t *testing.T) { - m := newControlModel(groupedProjects(), "/cfg/projects") - m = step(t, m, tea.WindowSizeMsg{Width: 100, Height: 30}) - m = step(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("loose")}) - - for _, it := range m.list.filtered { - if !it.item.selectable { - t.Fatalf("a heading row survived filtering: %+v", it.item) - } - } - m = step(t, m, tea.KeyMsg{Type: tea.KeyEnter}) - if m.chosen == nil || m.chosen.Name != "Loose" { - t.Fatalf("filtering to 'loose' then enter chose %v, want Loose", m.chosen) - } -} - -// TestControlModelEnterSelects confirms pressing enter records the highlighted -// project (the first, since the cursor starts at the top) and signals a quit. -func TestControlModelEnterSelects(t *testing.T) { - m := newControlModel(sampleProjects(), "/cfg/projects") - m = step(t, m, tea.WindowSizeMsg{Width: 100, Height: 30}) - m = step(t, m, tea.KeyMsg{Type: tea.KeyDown}) // move to Bravo - m = step(t, m, tea.KeyMsg{Type: tea.KeyEnter}) - - if m.chosen == nil { - t.Fatal("expected a chosen project, got nil") - } - if m.chosen.Name != "Bravo" { - t.Fatalf("chosen = %q, want Bravo", m.chosen.Name) - } -} - -// TestControlModelEscCancels confirms esc records no selection. -func TestControlModelEscCancels(t *testing.T) { - m := newControlModel(sampleProjects(), "/cfg/projects") - m = step(t, m, tea.KeyMsg{Type: tea.KeyEsc}) - - if m.chosen != nil { - t.Fatalf("esc should not choose a project, got %q", m.chosen.Name) - } - if !m.quitting { - t.Fatal("esc should set quitting") - } -} - -// TestControlModelFilterThenSelect confirms typing narrows the list and enter -// then opens the single remaining match. -func TestControlModelFilterThenSelect(t *testing.T) { - m := newControlModel(sampleProjects(), "/cfg/projects") - m = step(t, m, tea.WindowSizeMsg{Width: 100, Height: 30}) - m = step(t, m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("brav")}) - m = step(t, m, tea.KeyMsg{Type: tea.KeyEnter}) - - if m.chosen == nil || m.chosen.Name != "Bravo" { - t.Fatalf("after filtering to 'brav', chosen = %v, want Bravo", m.chosen) - } -} - -// TestControlModelMouseClickOpens confirms a left-button release over a project -// row opens it — the click counterpart to enter. With the title bar and query -// line above, the two projects sit at screen rows 4 (Alpha) and 5 (Bravo). -func TestControlModelMouseClickOpens(t *testing.T) { - m := newControlModel(sampleProjects(), "/cfg/projects") - m = step(t, m, tea.WindowSizeMsg{Width: 100, Height: 30}) - m = step(t, m, tea.MouseMsg{ - Action: tea.MouseActionRelease, - Button: tea.MouseButtonLeft, - Y: 5, - }) - - if m.chosen == nil || m.chosen.Name != "Bravo" { - t.Fatalf("clicking the Bravo row chose %v, want Bravo", m.chosen) - } -} - -// TestControlModelMouseWheelMoves confirms the scroll wheel walks the highlight -// without opening anything. -func TestControlModelMouseWheelMoves(t *testing.T) { - m := newControlModel(sampleProjects(), "/cfg/projects") - m = step(t, m, tea.WindowSizeMsg{Width: 100, Height: 30}) - m = step(t, m, tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonWheelDown}) - - if m.chosen != nil { - t.Fatal("the wheel should not open a project") - } - if got := m.list.selectedIndex(); got != 1 { - t.Fatalf("after wheel down selected ref = %d, want 1 (Bravo)", got) - } -} - -// TestControlModelBrowserView confirms the populated view shows the header, the -// project names, and the highlighted project's working directory in the detail -// bar. -func TestControlModelBrowserView(t *testing.T) { - m := newControlModel(sampleProjects(), "/cfg/projects") - m = step(t, m, tea.WindowSizeMsg{Width: 100, Height: 30}) - view := m.View() - - for _, want := range []string{"Alpha", "Bravo", "/srv/alpha"} { - if !strings.Contains(view, want) { - t.Fatalf("browser view missing %q:\n%s", want, view) - } - } -} - -// TestControlModelEmptyState confirms that with no projects the onboarding card -// renders (with the config path and docs link) and any exit key closes it. -func TestControlModelEmptyState(t *testing.T) { - m := newControlModel(nil, "/cfg/projects") - m = step(t, m, tea.WindowSizeMsg{Width: 100, Height: 40}) - view := m.View() - - for _, want := range []string{"Welcome to Herdr Plus", "/cfg/projects", docsURL} { - if !strings.Contains(view, want) { - t.Fatalf("empty-state view missing %q:\n%s", want, view) - } - } - - m = step(t, m, tea.KeyMsg{Type: tea.KeyEnter}) - if !m.quitting { - t.Fatal("empty-state should quit on a key press") - } - if m.chosen != nil { - t.Fatal("empty-state must never choose a project") - } -} diff --git a/old/examples/projects/example.toml b/old/examples/projects/example.toml deleted file mode 100644 index 54a2594..0000000 --- a/old/examples/projects/example.toml +++ /dev/null @@ -1,40 +0,0 @@ -# A project is a herdr workspace template. Save one *.toml file per project in -# ~/.config/herdr-plus/projects/ — the file name does not matter. -# -# Picking a project in control mode (prefix+up) opens a new herdr workspace -# rooted at `working_dir`, labeled `name`, with one tab per [[tabs]] entry (in -# order). Each tab runs its `command` on startup; omit `command` for an empty -# terminal. - -name = "Options Cafe" -description = "The main options.cafe monorepo" -group = "Cloudmanic" # optional: cluster a client's projects under a heading -working_dir = "~/Development/options-cafe/options.cafe" # ~ and $VARS expand - -[[tabs]] -name = "claude" -command = "claude --dangerously-skip-permissions --chrome" - -[[tabs]] -name = "lazygit" -command = "lazygit" - -[[tabs]] -name = "editor" -command = "spiceedit" - -# A tab can be split into up to 4 panes. Instead of a single `command`, give it -# [[tabs.panes]]. Each pane after the first sets `split = "down"` (stacked) or -# "right" (side by side) — how it splits off the previous pane (default "down"). -[[tabs]] -name = "server" - -[[tabs.panes]] -command = "php artisan serve" - -[[tabs.panes]] -command = "npm run dev" -split = "down" - -[[tabs]] -name = "terminal" # no command — just an empty shell diff --git a/old/examples/quick-actions/github.toml b/old/examples/quick-actions/github.toml deleted file mode 100644 index 5f37851..0000000 --- a/old/examples/quick-actions/github.toml +++ /dev/null @@ -1,7 +0,0 @@ -# A plain command action: selecting it runs `command` immediately. -# This is the simplest possible action — just a name, a description, and a -# command. `type` defaults to "command" when omitted. - -name = "GitHub" -description = "Open https://github.com" -command = "open https://github.com" diff --git a/old/examples/quick-actions/google-search.toml b/old/examples/quick-actions/google-search.toml deleted file mode 100644 index 2fbfb49..0000000 --- a/old/examples/quick-actions/google-search.toml +++ /dev/null @@ -1,14 +0,0 @@ -# A "form" action: choosing it shows a text field, and whatever you type becomes -# {{.Value}} in the command. The built-in `urlquery` template function escapes -# the text so it is safe inside a URL. -# -# The optional [form] table customizes the field's prompt and placeholder. - -name = "Search Google" -description = "Type a query and open the results" -type = "form" -command = "open 'https://www.google.com/search?q={{.Value | urlquery}}'" - -[form] -prompt = "Search Google for" -placeholder = "e.g. herdr terminal multiplexer" diff --git a/old/examples/quick-actions/google.toml b/old/examples/quick-actions/google.toml deleted file mode 100644 index 5e13928..0000000 --- a/old/examples/quick-actions/google.toml +++ /dev/null @@ -1,6 +0,0 @@ -# Another plain command action. - -name = "Google" -description = "Open https://google.com" -type = "command" -command = "open https://google.com" diff --git a/old/examples/quick-actions/open-repo.toml b/old/examples/quick-actions/open-repo.toml deleted file mode 100644 index 85180d5..0000000 --- a/old/examples/quick-actions/open-repo.toml +++ /dev/null @@ -1,26 +0,0 @@ -# A "select" action: choosing it shows a second fuzzy list of options, and the -# option you pick becomes {{.Value}} in the command. -# -# Each option has a `label` (shown in the list) and a `value` (substituted into -# the command). If `value` is omitted, the label is used as the value too. An -# optional `description` shows dim text next to the label. - -name = "Open Repo on GitHub" -description = "Pick one of our repos and open it" -type = "select" -command = "open https://github.com/cloudmanic/{{.Value}}" - -[[options]] -label = "Herdr Plus" -value = "herdr-plus" -description = "cloudmanic/herdr-plus" - -[[options]] -label = "Options Cafe" -value = "options-cafe" -description = "cloudmanic/options-cafe" - -[[options]] -label = "Skyclerk" -value = "skyclerk" -description = "cloudmanic/skyclerk" diff --git a/old/examples/quick-actions/open-working-dir.toml b/old/examples/quick-actions/open-working-dir.toml deleted file mode 100644 index 0dba9e9..0000000 --- a/old/examples/quick-actions/open-working-dir.toml +++ /dev/null @@ -1,22 +0,0 @@ -# A command action that uses a context variable. The command is a Go -# text/template rendered against the run context, so {{.WorkDir}} expands to the -# directory you launched herdr-plus from. Other variables you can use: -# -# {{.WorkDir}} the working directory you launched from -# {{.SessionTitle}} the herdr workspace label (e.g. the repo name) -# {{.SessionId}} the herdr workspace id -# {{.TabLabel}} the herdr tab label -# {{.PaneId}} the pane herdr-plus was launched from -# {{.WorkspaceLabel}} {{.WorkspaceId}} {{.TabId}} {{.TerminalId}} -# {{.Agent}} the agent running in the pane, if any -# {{.AgentSessionId}} that agent's session id -# {{.Home}} your home directory -# {{.Value}} the selected option / entered text (select & form only) -# -# The same values are also exported to the command's environment as -# HERDR_PLUS_WORKDIR, HERDR_PLUS_SESSION_TITLE, and so on. - -name = "Reveal Working Dir" -description = "Open the launch directory in Finder" -type = "command" -command = "open {{.WorkDir}}" diff --git a/old/fuzzylist.go b/old/fuzzylist.go deleted file mode 100644 index 5a06077..0000000 --- a/old/fuzzylist.go +++ /dev/null @@ -1,298 +0,0 @@ -// -// Date: 2026-06-09 -// Author: Spicer Matthews (spicer@cloudmanic.com) -// Copyright: 2026 Cloudmanic Labs, LLC. All rights reserved. -// - -package main - -import ( - "fmt" - "strings" - - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - "github.com/sahilm/fuzzy" -) - -// listItem is one row in a fuzzyList. A selectable row shows a name (matched and -// highlighted) plus an optional dimmer description, and carries ref — the -// caller's identifier for the row (e.g. an index into its own slice). A row with -// selectable=false is a non-selectable separator: with a name it renders as a -// dim group heading, without one as a blank spacer. Either way it is skipped -// during navigation and hidden while filtering. -type listItem struct { - name string - desc string - selectable bool - ref int -} - -// scoredItem is a listItem that survived the current query filter, carrying the -// name-character positions that matched, for highlighting. -type scoredItem struct { - item listItem - matched []int -} - -// fuzzyList is a reusable fuzzy-filtered, keyboard-navigable list with a query -// box. Both the action picker and the "select" option screen are fuzzyLists, so -// the matching, navigation, separators, and rendering live here once. -type fuzzyList struct { - input textinput.Model - items []listItem - filtered []scoredItem - cursor int -} - -// newFuzzyList builds a list over items with a focused, empty query box. -func newFuzzyList(placeholder string, items []listItem) fuzzyList { - ti := textinput.New() - ti.Placeholder = placeholder - ti.Prompt = "" - ti.Focus() - - l := fuzzyList{input: ti, items: items} - l.filter() - return l -} - -// filter recomputes the visible rows from the current query. An empty query -// shows every item — separators included — in its natural order. A non-empty -// query fuzzy-matches only the selectable items (separators are dropped while -// searching) against name and description together, while highlighting only the -// matches that land inside the name. -func (l *fuzzyList) filter() { - q := strings.TrimSpace(l.input.Value()) - l.filtered = l.filtered[:0] - - if q == "" { - for _, it := range l.items { - l.filtered = append(l.filtered, scoredItem{item: it}) - } - l.clampCursor() - return - } - - var sel []listItem - for _, it := range l.items { - if it.selectable { - sel = append(sel, it) - } - } - haystacks := make([]string, len(sel)) - nameLens := make([]int, len(sel)) - for i, it := range sel { - haystacks[i] = it.name + " " + it.desc - nameLens[i] = len(it.name) - } - for _, mt := range fuzzy.Find(q, haystacks) { - var inName []int - for _, idx := range mt.MatchedIndexes { - if idx < nameLens[mt.Index] { - inName = append(inName, idx) - } - } - l.filtered = append(l.filtered, scoredItem{item: sel[mt.Index], matched: inName}) - } - - l.clampCursor() -} - -// clampCursor keeps the cursor in range and parked on a selectable row, moving -// to the nearest selectable row (searching down, then up) if it landed on a -// separator. -func (l *fuzzyList) clampCursor() { - if len(l.filtered) == 0 { - l.cursor = 0 - return - } - if l.cursor >= len(l.filtered) { - l.cursor = len(l.filtered) - 1 - } - if l.cursor < 0 { - l.cursor = 0 - } - if l.filtered[l.cursor].item.selectable { - return - } - for i := l.cursor; i < len(l.filtered); i++ { - if l.filtered[i].item.selectable { - l.cursor = i - return - } - } - for i := l.cursor; i >= 0; i-- { - if l.filtered[i].item.selectable { - l.cursor = i - return - } - } -} - -// moveUp and moveDown move the highlight to the previous/next selectable row, -// skipping any separators in between. -func (l *fuzzyList) moveUp() { - for i := l.cursor - 1; i >= 0; i-- { - if l.filtered[i].item.selectable { - l.cursor = i - return - } - } -} - -func (l *fuzzyList) moveDown() { - for i := l.cursor + 1; i < len(l.filtered); i++ { - if l.filtered[i].item.selectable { - l.cursor = i - return - } - } -} - -// selectedIndex returns the ref of the highlighted selectable row, or -1 when -// nothing is selectable (empty list, or all matches filtered away). -func (l *fuzzyList) selectedIndex() int { - if len(l.filtered) == 0 { - return -1 - } - it := l.filtered[l.cursor].item - if !it.selectable { - return -1 - } - return it.ref -} - -// listPromptLines is how many lines view() renders before the first result row: -// the query/prompt line and the blank spacer beneath it. rowIndexAt and view() -// share it, so the two must always agree about the list's layout. -const listPromptLines = 2 - -// rowIndexAt maps a view-local screen line (line 0 is the query/prompt line) to -// the index into filtered of the selectable row drawn there, or -1 when the line -// is the prompt, a blank spacer, a separator/heading, or past the end of the -// list. It mirrors view()'s exact line accounting — the prompt line, the blank -// spacer, then one line per selectable row and a blank (plus an optional heading -// line) per separator — so this and view() must change together. -func (l *fuzzyList) rowIndexAt(y int) int { - line := listPromptLines - for i, s := range l.filtered { - if !s.item.selectable { - line++ // the separator's leading blank line - if s.item.name != "" { - line++ // its heading line - } - continue - } - if line == y { - return i - } - line++ - } - return -1 -} - -// clickRow moves the highlight to the selectable row at view-local line y, -// reporting whether y landed on one. It is the mouse counterpart to moveUp / -// moveDown: the caller subtracts its own header height from the click's screen -// row before calling, so y is measured from the top of view()'s own output. -func (l *fuzzyList) clickRow(y int) bool { - idx := l.rowIndexAt(y) - if idx < 0 { - return false - } - l.cursor = idx - return true -} - -// editQuery feeds a message to the query box and re-filters. Non-key messages -// (such as the cursor blink tick) pass through harmlessly. -func (l *fuzzyList) editQuery(msg tea.Msg) tea.Cmd { - var cmd tea.Cmd - l.input, cmd = l.input.Update(msg) - l.filter() - return cmd -} - -// view renders the query line, the match count (selectable rows only), and the -// result rows. Separators render as a blank line plus an optional dim heading; -// emptyMsg is shown when no selectable row matches the query. -func (l fuzzyList) view(emptyMsg string) string { - var b strings.Builder - - matched, total := 0, 0 - for _, it := range l.items { - if it.selectable { - total++ - } - } - for _, s := range l.filtered { - if s.item.selectable { - matched++ - } - } - - b.WriteString(promptStyle.Render("❯ ")) - b.WriteString(l.input.View()) - b.WriteString(" ") - b.WriteString(countStyle.Render(fmt.Sprintf("%d/%d", matched, total))) - b.WriteString("\n\n") - - if matched == 0 { - b.WriteString(descStyle.Render(" " + emptyMsg)) - b.WriteString("\n") - } - for i, s := range l.filtered { - it := s.item - if !it.selectable { - // A blank line separates groups; a heading (if any) labels the group. - b.WriteString("\n") - if it.name != "" { - b.WriteString(headingStyle.Render(it.name)) - b.WriteString("\n") - } - continue - } - selected := i == l.cursor - if selected { - b.WriteString(barStyle.Render("▌ ")) - } else { - b.WriteString(" ") - } - b.WriteString(highlightName(it.name, s.matched, selected)) - if it.desc != "" { - b.WriteString(" ") - b.WriteString(descStyle.Render(it.desc)) - } - b.WriteString("\n") - } - return b.String() -} - -// highlightName renders a row's name with the fuzzy-matched characters -// emphasized. matched holds byte indexes into the name string (names are -// effectively ASCII for matching, so byte and rune indexes coincide). -func highlightName(name string, matched []int, selected bool) string { - base := nameStyle - if selected { - base = nameSelStyle - } - if len(matched) == 0 { - return base.Render(name) - } - - set := make(map[int]bool, len(matched)) - for _, idx := range matched { - set[idx] = true - } - - var b strings.Builder - for i, r := range name { - if set[i] { - b.WriteString(matchStyle.Render(string(r))) - } else { - b.WriteString(base.Render(string(r))) - } - } - return b.String() -} diff --git a/old/fuzzylist_test.go b/old/fuzzylist_test.go deleted file mode 100644 index 5906d34..0000000 --- a/old/fuzzylist_test.go +++ /dev/null @@ -1,161 +0,0 @@ -// -// Date: 2026-06-09 -// Author: Spicer Matthews (spicer@cloudmanic.com) -// Copyright: 2026 Cloudmanic Labs, LLC. All rights reserved. -// - -package main - -import ( - "strings" - "testing" -) - -// TestFuzzyListSkipsSeparators confirms the cursor starts on a selectable row -// and that navigation steps over separator rows. -func TestFuzzyListSkipsSeparators(t *testing.T) { - items := []listItem{ - {name: "alpha", selectable: true, ref: 0}, - {name: "Group", selectable: false}, // heading separator - {name: "bravo", selectable: true, ref: 2}, - {name: "charlie", selectable: true, ref: 3}, - } - l := newFuzzyList("", items) - - if got := l.selectedIndex(); got != 0 { - t.Fatalf("initial selected = %d, want 0", got) - } - l.moveDown() - if got := l.selectedIndex(); got != 2 { - t.Fatalf("after moveDown selected = %d, want 2 (skip separator)", got) - } - l.moveDown() - if got := l.selectedIndex(); got != 3 { - t.Fatalf("after 2nd moveDown selected = %d, want 3", got) - } - l.moveUp() - if got := l.selectedIndex(); got != 2 { - t.Fatalf("after moveUp selected = %d, want 2", got) - } -} - -// TestFuzzyListStartsOnSelectableAfterLeadingSeparator confirms a list that -// begins with a heading parks the cursor on the first real option. -func TestFuzzyListStartsOnSelectableAfterLeadingSeparator(t *testing.T) { - items := []listItem{ - {name: "Heading", selectable: false}, - {name: "first", selectable: true, ref: 1}, - } - l := newFuzzyList("", items) - if got := l.selectedIndex(); got != 1 { - t.Fatalf("selected = %d, want 1 (skip leading heading)", got) - } -} - -// TestFuzzyListHidesSeparatorsWhileFiltering confirms separators drop out of the -// results once a query is typed, leaving only matching selectable rows. -func TestFuzzyListHidesSeparatorsWhileFiltering(t *testing.T) { - items := []listItem{ - {name: "alpha", selectable: true, ref: 0}, - {name: "Group", selectable: false}, - {name: "bravo", selectable: true, ref: 2}, - } - l := newFuzzyList("", items) - l.input.SetValue("alpha") - l.filter() - - for _, s := range l.filtered { - if !s.item.selectable { - t.Fatalf("a separator leaked into filtered results while searching") - } - } - if got := l.selectedIndex(); got != 0 { - t.Fatalf("selected = %d, want 0", got) - } -} - -// TestFuzzyListViewCountExcludesSeparators confirms the match count reflects -// only selectable rows, not separators. -func TestFuzzyListViewCountExcludesSeparators(t *testing.T) { - items := []listItem{ - {name: "alpha", selectable: true, ref: 0}, - {name: "Group", selectable: false}, - {name: "bravo", selectable: true, ref: 2}, - } - l := newFuzzyList("", items) - if got := l.view("none"); !strings.Contains(got, "2/2") { - t.Fatalf("view count should be 2/2 (separators excluded); got:\n%s", got) - } -} - -// TestFuzzyListRowIndexAtMatchesView confirms rowIndexAt's line accounting agrees -// with what view() actually renders — including the blank line and heading a -// separator consumes — by finding each row's real screen line in the rendered -// output and asking rowIndexAt to map it back to the right row. This guards the -// "rowIndexAt and view() must change together" invariant. -func TestFuzzyListRowIndexAtMatchesView(t *testing.T) { - items := []listItem{ - {name: "alpha", selectable: true, ref: 0}, - {name: "Group", selectable: false}, // blank + heading between the rows - {name: "bravo", selectable: true, ref: 2}, - {name: "charlie", selectable: true, ref: 3}, - } - l := newFuzzyList("", items) - lines := strings.Split(l.view("none"), "\n") - - for _, tc := range []struct { - name string - ref int - }{{"alpha", 0}, {"bravo", 2}, {"charlie", 3}} { - y := -1 - for i, line := range lines { - if strings.Contains(line, tc.name) { - y = i - break - } - } - if y < 0 { - t.Fatalf("row %q not found in rendered view:\n%s", tc.name, l.view("none")) - } - idx := l.rowIndexAt(y) - if idx < 0 { - t.Fatalf("rowIndexAt(%d) for %q = -1, want a selectable row", y, tc.name) - } - if got := l.filtered[idx].item.ref; got != tc.ref { - t.Fatalf("rowIndexAt(%d) -> ref %d, want %d (%q)", y, got, tc.ref, tc.name) - } - } - - // The blank spacer under the prompt and a line past the end are not rows. - if got := l.rowIndexAt(1); got != -1 { - t.Fatalf("rowIndexAt(1) = %d, want -1 (blank spacer under the prompt)", got) - } - if got := l.rowIndexAt(len(lines) + 5); got != -1 { - t.Fatalf("rowIndexAt past the end = %d, want -1", got) - } -} - -// TestFuzzyListClickRow confirms clicking a row's line moves the highlight there -// and reports success, while clicking a non-row line is a no-op that leaves the -// cursor put. -func TestFuzzyListClickRow(t *testing.T) { - items := []listItem{ - {name: "alpha", selectable: true, ref: 0}, // view line 2 - {name: "bravo", selectable: true, ref: 1}, // view line 3 - } - l := newFuzzyList("", items) - - if !l.clickRow(3) { - t.Fatal("clickRow(3) should land on bravo") - } - if got := l.selectedIndex(); got != 1 { - t.Fatalf("after clickRow(3) selected ref = %d, want 1 (bravo)", got) - } - - if l.clickRow(1) { // the blank spacer line - t.Fatal("clickRow on a blank line should report no hit") - } - if got := l.selectedIndex(); got != 1 { - t.Fatalf("a missed click moved the cursor: ref = %d, want 1", got) - } -} diff --git a/old/go.mod b/old/go.mod deleted file mode 100644 index cf3286a..0000000 --- a/old/go.mod +++ /dev/null @@ -1,35 +0,0 @@ -module github.com/cloudmanic/herdr-plus/old - -go 1.26.2 - -require ( - github.com/BurntSushi/toml v1.6.0 - github.com/charmbracelet/bubbles v1.0.0 - github.com/charmbracelet/bubbletea v1.3.10 - github.com/charmbracelet/lipgloss v1.1.0 - github.com/sahilm/fuzzy v0.1.2 -) - -require ( - github.com/atotto/clipboard v0.1.4 // indirect - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/colorprofile v0.4.1 // indirect - github.com/charmbracelet/x/ansi v0.11.6 // indirect - github.com/charmbracelet/x/cellbuf v0.0.15 // indirect - github.com/charmbracelet/x/term v0.2.2 // indirect - github.com/clipperhouse/displaywidth v0.9.0 // indirect - github.com/clipperhouse/stringish v0.1.1 // indirect - github.com/clipperhouse/uax29/v2 v2.5.0 // indirect - github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/lucasb-eyer/go-colorful v1.3.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.19 // indirect - github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect - github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/termenv v0.16.0 // indirect - github.com/rivo/uniseg v0.4.7 // indirect - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/text v0.3.8 // indirect -) diff --git a/old/go.sum b/old/go.sum deleted file mode 100644 index f2d2119..0000000 --- a/old/go.sum +++ /dev/null @@ -1,58 +0,0 @@ -github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= -github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= -github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= -github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= -github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= -github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= -github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= -github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= -github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= -github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= -github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= -github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= -github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= -github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= -github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= -github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= -github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= -github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= -github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= -github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= -github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= -github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= -github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= -github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= -github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= -github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= -github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= -github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/sahilm/fuzzy v0.1.2 h1:kdSkz23lx1meNjEl+SLJULeSbjTI4Dn14K/YxdGrIww= -github.com/sahilm/fuzzy v0.1.2/go.mod h1:au6//VbVSqu6DFrkL2CfjlJ5iURpNCPeE+1GwY3XsT8= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= diff --git a/old/herdr.go b/old/herdr.go deleted file mode 100644 index 5983177..0000000 --- a/old/herdr.go +++ /dev/null @@ -1,387 +0,0 @@ -// -// Date: 2026-06-09 -// Author: Spicer Matthews (spicer@cloudmanic.com) -// Copyright: 2026 Cloudmanic Labs, LLC. All rights reserved. -// - -package main - -import ( - "bufio" - "encoding/json" - "errors" - "fmt" - "net" - "os" - "strings" - "time" -) - -// herdrClient talks to the running herdr instance over its unix domain socket. -// The protocol is newline-delimited JSON: one request object per line, one -// response object per line. Each call opens a short-lived connection, writes a -// single request, and reads a single response. -type herdrClient struct { - socketPath string -} - -// newHerdrClient builds a client from the HERDR_SOCKET_PATH environment -// variable. It returns an error when the process is not running inside herdr. -func newHerdrClient() (*herdrClient, error) { - path := os.Getenv("HERDR_SOCKET_PATH") - if path == "" { - return nil, errors.New("HERDR_SOCKET_PATH is not set; are you running inside herdr?") - } - return &herdrClient{socketPath: path}, nil -} - -// request is one JSON-RPC-style message sent to herdr. -type request struct { - ID string `json:"id"` - Method string `json:"method"` - Params map[string]any `json:"params"` -} - -// herdrError carries the code and human message herdr returns on failure. -type herdrError struct { - Code string `json:"code"` - Message string `json:"message"` -} - -// response is one JSON line returned by herdr. Exactly one of Result or Error -// is populated. -type response struct { - ID string `json:"id"` - Result json.RawMessage `json:"result"` - Error *herdrError `json:"error"` -} - -// call sends a single request over a fresh connection and decodes the result -// into out (which may be nil when the caller does not care about the payload). -func (c *herdrClient) call(method string, params map[string]any, out any) error { - conn, err := net.Dial("unix", c.socketPath) - if err != nil { - return fmt.Errorf("connect herdr socket: %w", err) - } - defer conn.Close() - - // json.Encoder.Encode appends a trailing newline, which is exactly the - // framing herdr expects for each request. - if err := json.NewEncoder(conn).Encode(request{ID: "qa", Method: method, Params: params}); err != nil { - return fmt.Errorf("write request: %w", err) - } - - var resp response - if err := json.NewDecoder(bufio.NewReader(conn)).Decode(&resp); err != nil { - return fmt.Errorf("read response: %w", err) - } - if resp.Error != nil { - return fmt.Errorf("herdr error %s: %s", resp.Error.Code, resp.Error.Message) - } - if out != nil { - if err := json.Unmarshal(resp.Result, out); err != nil { - return fmt.Errorf("decode result: %w", err) - } - } - return nil -} - -// paneSplit splits the target pane in the given direction ("down" for a new pane -// beneath it, "right" for one beside it), creating a new pane, and returns the -// new pane's id. When focus is true the new pane becomes the focused pane (the -// socket API does not focus new panes by default). -func (c *herdrClient) paneSplit(targetPaneID, direction string, focus bool) (string, error) { - var out struct { - Pane struct { - PaneID string `json:"pane_id"` - } `json:"pane"` - } - err := c.call("pane.split", map[string]any{ - "target_pane_id": targetPaneID, - "direction": direction, - "focus": focus, - }, &out) - if err != nil { - return "", err - } - return out.Pane.PaneID, nil -} - -// sendInput types text into a pane and then presses the given keys, as if at the -// keyboard. The keys are herdr key names (e.g. "Enter") delivered as real key -// events. To RUN a shell command, pass the command as text and "Enter" as the -// sole key — do not embed a trailing newline in text. herdr's send_input treats -// text as a paste: once the shell's line editor (zsh ZLE) is active it inserts an -// embedded "\n" literally instead of executing the line, so the command would -// just sit at the prompt until the user pressed Enter by hand. A real Enter key -// always submits, which is also how herdr's own `pane run` works. Pass no keys -// for plain typing with no submission. -func (c *herdrClient) sendInput(paneID, text string, keys ...string) error { - params := map[string]any{ - "pane_id": paneID, - "text": text, - } - if len(keys) > 0 { - params["keys"] = keys - } - return c.call("pane.send_input", params, nil) -} - -// paneRead returns the text currently shown in a pane. source selects which slice -// of the terminal to read ("visible" for the on-screen rows, "recent" for the -// recent scrollback); lines caps how many trailing lines come back. It exists so -// tests can confirm a command actually ran — its output appeared — rather than -// merely being typed at the prompt. -func (c *herdrClient) paneRead(paneID, source string, lines int) (string, error) { - var out struct { - Read struct { - Text string `json:"text"` - } `json:"read"` - } - err := c.call("pane.read", map[string]any{ - "pane_id": paneID, - "source": source, - "lines": lines, - }, &out) - if err != nil { - return "", err - } - return out.Read.Text, nil -} - -// runCommand types command into a freshly created pane and submits it, pacing -// itself to the shell's startup so the command actually runs instead of sitting -// unsubmitted at the prompt. There are two startup races it has to dodge: -// -// - Typing too early: a pane created moments ago may not have an interactive -// shell yet; keystrokes sent into that gap are dropped. So we first wait for -// the shell to draw its prompt. -// - Submitting too early: even once typing lands, pressing Enter before the -// shell's line editor has the text races startup and the line is lost. So we -// wait until the command visibly echoes before pressing Enter. -// -// Submission is a real Enter key, never a trailing "\n" in the text — herdr pastes -// text, and an embedded newline is inserted literally once zsh's line editor is -// active rather than running the line (see sendInput). Every wait is best effort: -// on timeout we proceed anyway, so a slow or unusual shell degrades to the old -// blind behavior rather than hanging. -func (c *herdrClient) runCommand(paneID, command string) error { - // 1. Wait for the shell to be ready to receive input (its prompt is drawn). - c.waitForPaneReady(paneID, 5*time.Second) - - // 2. Type the command (no trailing newline). - if err := c.sendInput(paneID, command); err != nil { - return err - } - - // 3. Wait until the command echoes back, proving the line editor accepted it. - c.waitForPaneText(paneID, commandEchoProbe(command), 5*time.Second) - - // 4. Submit with a real Enter key. - return c.sendInput(paneID, "", "Enter") -} - -// commandEchoProbe returns a short, stable fragment of a command to look for when -// confirming it was typed at the prompt: the first line, capped to a few -// characters so it stays on a single terminal row. A long command wraps across -// rows, so matching the whole string against the rendered screen would fail; a -// short leading fragment does not wrap and is specific enough on an otherwise -// empty fresh pane. -func commandEchoProbe(command string) string { - probe := command - if i := strings.IndexByte(probe, '\n'); i >= 0 { - probe = probe[:i] - } - if len(probe) > 12 { - probe = probe[:12] - } - return strings.TrimSpace(probe) -} - -// waitForPaneReady blocks until the pane shows any non-blank content — its shell -// prompt — or the timeout elapses. A fresh pane is blank until its shell starts -// and prints a prompt, so non-blank content is a good "ready for input" signal. -// Best effort: a timeout just means we stop waiting and proceed. -func (c *herdrClient) waitForPaneReady(paneID string, timeout time.Duration) { - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - if text, err := c.paneRead(paneID, "visible", 5); err == nil && strings.TrimSpace(text) != "" { - return - } - time.Sleep(50 * time.Millisecond) - } -} - -// waitForPaneText blocks until the pane's visible text contains probe or the -// timeout elapses. An empty probe returns immediately. Best effort, like -// waitForPaneReady: a timeout returns without error and the caller proceeds. -func (c *herdrClient) waitForPaneText(paneID, probe string, timeout time.Duration) { - if probe == "" { - return - } - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - if text, err := c.paneRead(paneID, "visible", 20); err == nil && strings.Contains(text, probe) { - return - } - time.Sleep(50 * time.Millisecond) - } -} - -// closePane terminates a pane and frees its terminal. Closing the focused pane -// returns focus to an adjacent pane. -func (c *herdrClient) closePane(paneID string) error { - return c.call("pane.close", map[string]any{ - "pane_id": paneID, - }, nil) -} - -// paneInfo is the subset of herdr's pane metadata we expose to actions. -type paneInfo struct { - PaneID string `json:"pane_id"` - TabID string `json:"tab_id"` - WorkspaceID string `json:"workspace_id"` - TerminalID string `json:"terminal_id"` - Cwd string `json:"cwd"` - ForegroundCwd string `json:"foreground_cwd"` - Agent string `json:"agent"` - AgentSession struct { - Value string `json:"value"` - } `json:"agent_session"` -} - -// focusedPaneID returns the id of the currently focused pane. It is used when -// herdr-plus is launched outside a pane's own shell — for example from a -// keybinding, which runs server-side and does not set HERDR_PANE_ID. -func (c *herdrClient) focusedPaneID() (string, error) { - var out struct { - Panes []struct { - PaneID string `json:"pane_id"` - Focused bool `json:"focused"` - } `json:"panes"` - } - if err := c.call("pane.list", map[string]any{}, &out); err != nil { - return "", err - } - for _, p := range out.Panes { - if p.Focused { - return p.PaneID, nil - } - } - return "", errors.New("no focused pane") -} - -// paneGet fetches metadata for a single pane, including its working directory -// and the tab/workspace it belongs to. -func (c *herdrClient) paneGet(paneID string) (paneInfo, error) { - var out struct { - Pane paneInfo `json:"pane"` - } - err := c.call("pane.get", map[string]any{"pane_id": paneID}, &out) - return out.Pane, err -} - -// tabInfo is the subset of herdr's tab metadata we expose to actions. -type tabInfo struct { - TabID string `json:"tab_id"` - Label string `json:"label"` -} - -// tabGet fetches metadata for a single tab, notably its human label. -func (c *herdrClient) tabGet(tabID string) (tabInfo, error) { - var out struct { - Tab tabInfo `json:"tab"` - } - err := c.call("tab.get", map[string]any{"tab_id": tabID}, &out) - return out.Tab, err -} - -// workspaceInfo is the subset of herdr's workspace metadata we expose to -// actions. -type workspaceInfo struct { - WorkspaceID string `json:"workspace_id"` - Label string `json:"label"` -} - -// workspaceGet fetches metadata for a single workspace, notably its label — -// which herdr derives from the repo or folder name and is our best stand-in for -// a "session title". -func (c *herdrClient) workspaceGet(workspaceID string) (workspaceInfo, error) { - var out struct { - Workspace workspaceInfo `json:"workspace"` - } - err := c.call("workspace.get", map[string]any{"workspace_id": workspaceID}, &out) - return out.Workspace, err -} - -// workspaceCreate makes a brand-new workspace rooted at cwd with the given -// label, and returns the ids of the new workspace, its single root tab, and -// that tab's root pane. When focus is true the workspace becomes the active one -// (the user is switched to it); pass false to create it in the background. -// Control mode uses this both to open its own "Herdr Plus" workspace and to -// build a project's workspace. -func (c *herdrClient) workspaceCreate(cwd, label string, focus bool) (workspaceID, tabID, paneID string, err error) { - var out struct { - Workspace struct { - WorkspaceID string `json:"workspace_id"` - } `json:"workspace"` - Tab struct { - TabID string `json:"tab_id"` - } `json:"tab"` - RootPane struct { - PaneID string `json:"pane_id"` - } `json:"root_pane"` - } - err = c.call("workspace.create", map[string]any{ - "cwd": cwd, - "label": label, - "focus": focus, - }, &out) - if err != nil { - return "", "", "", err - } - return out.Workspace.WorkspaceID, out.Tab.TabID, out.RootPane.PaneID, nil -} - -// tabCreate adds a tab to an existing workspace and returns the new tab's id and -// its root pane's id. focus controls whether the new tab is brought to the front -// — we create a project's later tabs with focus=false so the first tab stays -// active while the rest spin up behind it. -func (c *herdrClient) tabCreate(workspaceID, label string, focus bool) (tabID, paneID string, err error) { - var out struct { - Tab struct { - TabID string `json:"tab_id"` - } `json:"tab"` - RootPane struct { - PaneID string `json:"pane_id"` - } `json:"root_pane"` - } - err = c.call("tab.create", map[string]any{ - "workspace_id": workspaceID, - "label": label, - "focus": focus, - }, &out) - if err != nil { - return "", "", err - } - return out.Tab.TabID, out.RootPane.PaneID, nil -} - -// tabRename changes a tab's human label. A freshly created workspace's root tab -// is named "1"; we rename it to the project's first tab name (or "projects" for -// the control workspace itself). -func (c *herdrClient) tabRename(tabID, label string) error { - return c.call("tab.rename", map[string]any{ - "tab_id": tabID, - "label": label, - }, nil) -} - -// workspaceClose tears down a whole workspace and all of its tabs and panes. -// Control mode calls this on its own ephemeral "Herdr Plus" workspace once a -// project has been opened (or the picker is cancelled). -func (c *herdrClient) workspaceClose(workspaceID string) error { - return c.call("workspace.close", map[string]any{ - "workspace_id": workspaceID, - }, nil) -} diff --git a/old/herdr_test.go b/old/herdr_test.go deleted file mode 100644 index c0c8db3..0000000 --- a/old/herdr_test.go +++ /dev/null @@ -1,82 +0,0 @@ -// -// Date: 2026-06-09 -// Author: Spicer Matthews (spicer@cloudmanic.com) -// Copyright: 2026 Cloudmanic Labs, LLC. All rights reserved. -// - -package main - -import ( - "os" - "strings" - "testing" - "time" -) - -// TestLiveSocketWorkspaceLifecycle exercises the real workspace/tab socket -// methods (the exact calls openProject makes) against a running herdr instance. -// It is skipped unless HERDR_PLUS_LIVE=1 and a socket is present, so ordinary -// `go test` and CI never touch herdr. Everything is created in the background -// (focus=false) and torn down at the end, so it never disturbs the user's view. -func TestLiveSocketWorkspaceLifecycle(t *testing.T) { - if os.Getenv("HERDR_PLUS_LIVE") != "1" || os.Getenv("HERDR_SOCKET_PATH") == "" { - t.Skip("live herdr socket test; set HERDR_PLUS_LIVE=1 inside herdr to run") - } - - client, err := newHerdrClient() - if err != nil { - t.Fatalf("newHerdrClient: %v", err) - } - - home, _ := os.UserHomeDir() - - ws, rootTab, rootPane, err := client.workspaceCreate(home, "herdr-plus-verify", false) - if err != nil { - t.Fatalf("workspaceCreate: %v", err) - } - // Always clean up, even if a later step fails. - defer func() { - if err := client.workspaceClose(ws); err != nil { - t.Errorf("workspaceClose: %v", err) - } - }() - - if ws == "" || rootTab == "" || rootPane == "" { - t.Fatalf("workspaceCreate returned empty ids: ws=%q tab=%q pane=%q", ws, rootTab, rootPane) - } - - if err := client.tabRename(rootTab, "first"); err != nil { - t.Fatalf("tabRename: %v", err) - } - - _, pane2, err := client.tabCreate(ws, "second", false) - if err != nil { - t.Fatalf("tabCreate: %v", err) - } - if pane2 == "" { - t.Fatal("tabCreate returned empty pane id") - } - - // Run a command in the new pane the exact way openProject does — through - // runCommand — and confirm it actually executed by reading back its output. - // This is the regression guard for the bug where the command was only typed at - // the prompt (a trailing "\n" never submitted), so the app never started until - // the user pressed Enter. runCommand owns the startup-race handling (wait for - // prompt, type, wait for echo, real Enter), so the test needs no manual delays. - const marker = "herdr_plus_run_marker_8842" - if err := client.runCommand(pane2, "echo "+marker); err != nil { - t.Fatalf("runCommand: %v", err) - } - - // Give the shell a moment to run the command, then read the pane. The marker - // must appear twice: once as the typed command line and once as echo's output. - // A single occurrence means it was typed but never submitted — the bug. - time.Sleep(750 * time.Millisecond) - out, err := client.paneRead(pane2, "visible", 20) - if err != nil { - t.Fatalf("paneRead: %v", err) - } - if got := strings.Count(out, marker); got < 2 { - t.Fatalf("command did not run: marker %q appeared %d time(s), want >= 2 (echoed command + its output). pane:\n%s", marker, got, out) - } -} diff --git a/old/install.go b/old/install.go deleted file mode 100644 index 6052e89..0000000 --- a/old/install.go +++ /dev/null @@ -1,242 +0,0 @@ -// -// Date: 2026-06-09 -// Author: Spicer Matthews (spicer@cloudmanic.com) -// Copyright: 2026 Cloudmanic Labs, LLC. All rights reserved. -// - -package main - -import ( - "flag" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - - "github.com/BurntSushi/toml" -) - -// keybinding is the slice of a herdr [[keys.command]] entry we care about for -// install: which key it binds and what command it runs. -type keybinding struct { - Key string - Command string -} - -// runInstallCmd wires herdr-plus into herdr's config.toml as one or more -// keybindings so a single keypress launches it. The bound command uses the -// absolute path of the running binary, so it works no matter where herdr-plus -// lives or what the current directory is. Re-running is safe: it detects an -// existing herdr-plus binding and refuses to clobber a key already taken by -// something else. -// -// With neither --mode nor --key, it installs EVERY mode on its own default key -// (control → prefix+up, quick-actions → prefix+down) — the common case where a -// bare `herdr-plus install` should wire up the whole add-on in one shot. An -// explicit --mode (optionally with --key) installs just that single mode. -func runInstallCmd(args []string) { - fs := flag.NewFlagSet("install", flag.ExitOnError) - key := fs.String("key", "", "herdr keybinding to bind (default: the mode's own key, e.g. prefix+up for control)") - modeSlug := fs.String("mode", "", "which herdr-plus mode the keybinding launches (default: install every mode on its own key)") - _ = fs.Parse(args) - - // The absolute path of this very binary — what every keybinding will run. - self, err := selfBinaryPath() - if err != nil { - errExit(err) - } - - cfgPath, err := herdrConfigPath() - if err != nil { - errExit(err) - } - - // Bare install: bind each mode to its own default key in one pass, then - // reload once. A conflict on one mode is reported but does not stop the - // others, so installing both is as forgiving as possible. - if *modeSlug == "" && *key == "" { - wroteAny := false - for _, m := range orderedModes { - if wrote, _ := installMode(m, m.DefaultKey, self, cfgPath); wrote { - wroteAny = true - } - } - if wroteAny { - reloadHerdrConfig("") - } - return - } - - // Single-mode install: resolve the requested mode and bind it on its own - // default key unless --key overrides. - mode, err := lookupMode(*modeSlug) - if err != nil { - errExit(err) - } - k := *key - if k == "" { - k = mode.DefaultKey - } - - wrote, conflict := installMode(mode, k, self, cfgPath) - if conflict { - // installMode already explained the clash on stderr; exit non-zero so - // scripts notice the explicit install did not take. - os.Exit(1) - } - if wrote { - reloadHerdrConfig(k) - } -} - -// installMode binds a single herdr-plus mode to key in herdr's config.toml. It -// is idempotent per mode (each mode's command carries its own --mode, so two -// modes never match each other) and never clobbers a key already taken by -// something else. It returns wrote=true when it actually appended a new binding -// — telling the caller the config needs a reload — and conflict=true when key -// was already occupied by an unrelated command. It reads the file fresh on each -// call, so looping over modes correctly sees bindings written earlier in the -// same run. -func installMode(mode Mode, key, self, cfgPath string) (wrote bool, conflict bool) { - command := shellQuote(self) + " --mode=" + mode.Slug - description := "herdr-plus: " + mode.Slug - - // Read existing bindings (a missing file just means "none yet"). - existing, _ := readKeybindings(cfgPath) - - // Idempotent per mode: if THIS mode is already bound, report where and stop. - if b, ok := existingBinding(existing, command); ok { - if b.Key == key { - fmt.Printf("herdr-plus: %s already installed — press %s to launch it.\n", mode.Slug, b.Key) - } else { - fmt.Printf("herdr-plus: %s already installed at %s. Remove that binding in %s to rebind to %s.\n", mode.Slug, b.Key, cfgPath, key) - } - return false, false - } - - // Don't clobber a key already used by anything else (including the other mode). - if b, ok := conflictBinding(existing, key, command); ok { - fmt.Fprintf(os.Stderr, "herdr-plus: %s not installed: key %q is already bound to: %s\n Choose a different key with --key (e.g. --key=prefix+a).\n", mode.Slug, key, b.Command) - return false, true - } - - if err := appendToFile(cfgPath, keybindBlock(key, command, description)); err != nil { - errExit("could not write to", cfgPath+":", err) - } - fmt.Printf("herdr-plus: bound %s -> %s\n in %s\n", key, command, cfgPath) - return true, false -} - -// reloadHerdrConfig asks the running herdr server to reload its config so any -// freshly added binding is live without a restart. It is best effort: a failure -// just prints how to reload manually. keyHint, when non-empty, names the single -// key to press in the success message; empty means several were bound, so the -// message stays generic. -func reloadHerdrConfig(keyHint string) { - if out, err := exec.Command("herdr", "server", "reload-config").CombinedOutput(); err != nil { - fmt.Printf("herdr-plus: saved, but reload failed (%v). Run `herdr server reload-config` or restart herdr.\n", strings.TrimSpace(string(out))) - return - } - if keyHint != "" { - fmt.Printf("herdr-plus: reloaded herdr config. Press your prefix, then %s, to launch.\n", strings.TrimPrefix(keyHint, "prefix+")) - } else { - fmt.Println("herdr-plus: reloaded herdr config. Press your prefix, then a bound key (e.g. up or down), to launch.") - } -} - -// selfBinaryPath returns the absolute, symlink-resolved path of the running -// herdr-plus binary. -func selfBinaryPath() (string, error) { - exe, err := os.Executable() - if err != nil { - return "", err - } - if resolved, err := filepath.EvalSymlinks(exe); err == nil { - exe = resolved - } - return exe, nil -} - -// herdrConfigPath returns the path to herdr's config.toml, honoring -// $XDG_CONFIG_HOME and otherwise using ~/.config/herdr/config.toml. -func herdrConfigPath() (string, error) { - if x := os.Getenv("XDG_CONFIG_HOME"); x != "" { - return filepath.Join(x, "herdr", "config.toml"), nil - } - home, err := os.UserHomeDir() - if err != nil { - return "", err - } - return filepath.Join(home, ".config", "herdr", "config.toml"), nil -} - -// readKeybindings parses the [[keys.command]] entries from herdr's config.toml. -func readKeybindings(path string) ([]keybinding, error) { - var cfg struct { - Keys struct { - Command []struct { - Key string `toml:"key"` - Command string `toml:"command"` - } `toml:"command"` - } `toml:"keys"` - } - if _, err := toml.DecodeFile(path, &cfg); err != nil { - return nil, err - } - out := make([]keybinding, 0, len(cfg.Keys.Command)) - for _, c := range cfg.Keys.Command { - out = append(out, keybinding{Key: c.Key, Command: c.Command}) - } - return out, nil -} - -// existingBinding finds a binding that already runs this exact herdr-plus -// command — same binary and same --mode. Because each mode's command carries its -// own --mode flag, two herdr-plus modes never match each other here, so -// installing one mode is idempotent without disturbing the other. -func existingBinding(bindings []keybinding, command string) (keybinding, bool) { - for _, b := range bindings { - if b.Command == command { - return b, true - } - } - return keybinding{}, false -} - -// conflictBinding finds a binding that occupies key with a different command — -// anything other than this mode's own binding (including herdr-plus's other -// mode). That is a real conflict we must not clobber. -func conflictBinding(bindings []keybinding, key, command string) (keybinding, bool) { - for _, b := range bindings { - if b.Key == key && b.Command != command { - return b, true - } - } - return keybinding{}, false -} - -// keybindBlock renders a herdr [[keys.command]] block for appending. The %q -// verb produces double-quoted strings whose escaping is compatible with TOML -// basic strings. -func keybindBlock(key, command, description string) string { - return fmt.Sprintf( - "\n# herdr-plus — added by `herdr-plus install`\n[[keys.command]]\nkey = %q\ntype = \"shell\"\ncommand = %q\ndescription = %q\n", - key, command, description, - ) -} - -// appendToFile appends text to a file, creating it (and its directory) if -// needed. When the path is a symlink, the write follows it to the target. -func appendToFile(path, text string) error { - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return err - } - f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) - if err != nil { - return err - } - defer f.Close() - _, err = f.WriteString(text) - return err -} diff --git a/old/install.sh b/old/install.sh deleted file mode 100755 index 9ec4c9d..0000000 --- a/old/install.sh +++ /dev/null @@ -1,210 +0,0 @@ -#!/bin/sh -# -# Date: 2026-06-09 -# Author: Spicer Matthews (spicer@cloudmanic.com) -# Copyright: 2026 Cloudmanic Labs, LLC. All rights reserved. -# -# One-liner installer / upgrader for herdr-plus. Detects the host OS and -# architecture, downloads the matching archive from the latest GitHub Release, -# extracts the static `herdr-plus` binary, and drops it into ~/.local/bin -# (preferred) or /usr/local/bin. Re-running performs an upgrade. -# -# Usage: -# -# curl -fsSL https://raw.githubusercontent.com/cloudmanic/herdr-plus/main/install.sh | sh -# -# Override the install location: -# -# curl -fsSL .../install.sh | INSTALL_DIR=/opt/bin sh -# -# Override the version (default is the latest GitHub Release): -# -# curl -fsSL .../install.sh | VERSION=v0.0.1 sh -# -# Designed to run under POSIX `sh` so it works on Alpine / BusyBox / minimal -# SSH targets in addition to bash on a normal Linux box. - -set -eu - -REPO="cloudmanic/herdr-plus" -BINARY="herdr-plus" - -# Pretty output when stderr is a terminal, plain otherwise. -if [ -t 2 ]; then - BOLD="$(printf '\033[1m')" - DIM="$(printf '\033[2m')" - GREEN="$(printf '\033[32m')" - RED="$(printf '\033[31m')" - YELLOW="$(printf '\033[33m')" - RESET="$(printf '\033[0m')" -else - BOLD="" - DIM="" - GREEN="" - RED="" - YELLOW="" - RESET="" -fi - -# info prints a step-prefixed line to stderr so it doesn't pollute stdout. -info() { - printf '%s==>%s %s\n' "$GREEN" "$RESET" "$1" >&2 -} - -warn() { - printf '%s==>%s %s\n' "$YELLOW" "$RESET" "$1" >&2 -} - -# fatal prints an error and exits non-zero. -fatal() { - printf '%serror:%s %s\n' "$RED" "$RESET" "$1" >&2 - exit 1 -} - -# detect_os normalises uname -s into the lowercase token GoReleaser uses in -# archive names (linux, darwin). -detect_os() { - uname_s="$(uname -s 2>/dev/null || echo unknown)" - case "$uname_s" in - Linux) echo "linux" ;; - Darwin) echo "darwin" ;; - *) fatal "unsupported OS: $uname_s (only Linux and macOS are released)" ;; - esac -} - -# detect_arch maps uname -m onto GoReleaser's arch token (amd64 or arm64). -detect_arch() { - uname_m="$(uname -m 2>/dev/null || echo unknown)" - case "$uname_m" in - x86_64|amd64) echo "amd64" ;; - aarch64|arm64) echo "arm64" ;; - *) fatal "unsupported architecture: $uname_m (only amd64 and arm64 are released)" ;; - esac -} - -# require_cmd ensures cmd is on PATH. -require_cmd() { - command -v "$1" >/dev/null 2>&1 || fatal "$1 is required but not found on PATH" -} - -# fetch downloads url to outfile using curl or wget (whichever is present). -fetch() { - url="$1" - outfile="$2" - if command -v curl >/dev/null 2>&1; then - curl --fail --show-error --silent --location --output "$outfile" "$url" - elif command -v wget >/dev/null 2>&1; then - wget --quiet --output-document "$outfile" "$url" - else - fatal "need curl or wget to download release archives" - fi -} - -# resolve_version picks the version to install. If the user passed VERSION, -# trust it as-is. Otherwise follow GitHub's /releases/latest redirect. -resolve_version() { - if [ -n "${VERSION:-}" ]; then - echo "$VERSION" - return - fi - url="https://github.com/${REPO}/releases/latest" - if command -v curl >/dev/null 2>&1; then - header="$(curl --silent --location --head "$url" 2>/dev/null \ - | grep -i '^location:' | tail -n 1)" - else - header="$(wget --max-redirect=0 --server-response --output-document=/dev/null "$url" 2>&1 \ - | grep -i 'Location:' | tail -n 1)" - fi - tag="${header##*/}" - tag="$(printf '%s' "$tag" | tr -d '\r\n')" - if [ -z "$tag" ]; then - fatal "could not resolve latest version from $url" - fi - echo "$tag" -} - -# pick_install_dir chooses where to drop the binary, honoring INSTALL_DIR. -# Default: ~/.local/bin (no sudo), falling back to /usr/local/bin. -pick_install_dir() { - if [ -n "${INSTALL_DIR:-}" ]; then - echo "$INSTALL_DIR" - return - fi - if [ -d "$HOME/.local/bin" ] || mkdir -p "$HOME/.local/bin" 2>/dev/null; then - echo "$HOME/.local/bin" - return - fi - echo "/usr/local/bin" -} - -# install_binary moves the extracted binary into the chosen directory, using -# sudo if the directory isn't writable. -install_binary() { - src="$1" - dest_dir="$2" - dest="$dest_dir/$BINARY" - - if [ -w "$dest_dir" ] || ([ ! -e "$dest_dir" ] && mkdir -p "$dest_dir" 2>/dev/null); then - mv "$src" "$dest" - chmod +x "$dest" - return - fi - if command -v sudo >/dev/null 2>&1; then - warn "writing to $dest_dir requires sudo" - sudo mkdir -p "$dest_dir" - sudo mv "$src" "$dest" - sudo chmod +x "$dest" - return - fi - fatal "cannot write to $dest_dir and sudo is not available" -} - -# warn_if_not_in_path nudges the user to fix their PATH if we installed -# somewhere they can't run from. -warn_if_not_in_path() { - dir="$1" - case ":$PATH:" in - *":$dir:"*) return ;; - esac - warn "$dir is not on your \$PATH — add this to your shell rc:" - printf '\n %sexport PATH="%s:\$PATH"%s\n\n' "$BOLD" "$dir" "$RESET" >&2 -} - -main() { - require_cmd tar - - os="$(detect_os)" - arch="$(detect_arch)" - version="$(resolve_version)" - # Strip a leading 'v' so the version slot in the archive name matches - # GoReleaser's {{ .Version }} (which is bare). Tags ARE prefixed v. - bare_version="${version#v}" - - archive="${BINARY}_${bare_version}_${os}_${arch}.tar.gz" - url="https://github.com/${REPO}/releases/download/${version}/${archive}" - - info "Installing ${BINARY} ${version} (${os}/${arch})" - info " source: ${url}" - - tmp="$(mktemp -d)" - trap 'rm -rf "$tmp"' EXIT INT TERM - - fetch "$url" "$tmp/$archive" \ - || fatal "download failed (was the release published with this archive name?)" - - tar -xzf "$tmp/$archive" -C "$tmp" \ - || fatal "extraction failed (archive may be corrupt)" - - if [ ! -f "$tmp/$BINARY" ]; then - fatal "archive did not contain a $BINARY binary" - fi - - dest_dir="$(pick_install_dir)" - info "Installing to ${dest_dir}/${BINARY}" - install_binary "$tmp/$BINARY" "$dest_dir" - - info "Done. ${BOLD}${dest_dir}/${BINARY}${RESET}${DIM} (${version})${RESET}" - warn_if_not_in_path "$dest_dir" -} - -main "$@" diff --git a/old/install_test.go b/old/install_test.go deleted file mode 100644 index 0980d3e..0000000 --- a/old/install_test.go +++ /dev/null @@ -1,141 +0,0 @@ -// -// Date: 2026-06-09 -// Author: Spicer Matthews (spicer@cloudmanic.com) -// Copyright: 2026 Cloudmanic Labs, LLC. All rights reserved. -// - -package main - -import ( - "os" - "path/filepath" - "testing" - - "github.com/BurntSushi/toml" -) - -// TestKeybindBlockIsValidToml confirms the generated block parses as TOML and -// carries the expected key, type, command, and description. -func TestKeybindBlockIsValidToml(t *testing.T) { - block := keybindBlock("prefix+down", "'/abs/herdr-plus' --mode=quick-actions", "herdr-plus: quick-actions") - - var cfg struct { - Keys struct { - Command []struct { - Key string `toml:"key"` - Type string `toml:"type"` - Command string `toml:"command"` - Description string `toml:"description"` - } `toml:"command"` - } `toml:"keys"` - } - if _, err := toml.Decode(block, &cfg); err != nil { - t.Fatalf("block is not valid TOML: %v\n%s", err, block) - } - if len(cfg.Keys.Command) != 1 { - t.Fatalf("got %d command entries, want 1", len(cfg.Keys.Command)) - } - c := cfg.Keys.Command[0] - if c.Key != "prefix+down" || c.Type != "shell" { - t.Fatalf("key/type = %q/%q", c.Key, c.Type) - } - if c.Command != "'/abs/herdr-plus' --mode=quick-actions" { - t.Fatalf("command = %q", c.Command) - } - if c.Description != "herdr-plus: quick-actions" { - t.Fatalf("description = %q", c.Description) - } -} - -// TestReadKeybindings confirms existing bindings are parsed out of a config. -func TestReadKeybindings(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "config.toml") - content := ` -[[keys.command]] -key = "prefix+u" -type = "shell" -command = "$HOME/bin/setup.sh" - -[[keys.command]] -key = "prefix+down" -type = "shell" -command = "/abs/herdr-plus --mode=quick-actions" -` - if err := os.WriteFile(path, []byte(content), 0o644); err != nil { - t.Fatalf("write: %v", err) - } - - got, err := readKeybindings(path) - if err != nil { - t.Fatalf("readKeybindings: %v", err) - } - if len(got) != 2 { - t.Fatalf("got %d bindings, want 2", len(got)) - } - if got[1].Key != "prefix+down" || got[1].Command != "/abs/herdr-plus --mode=quick-actions" { - t.Fatalf("binding[1] = %+v", got[1]) - } -} - -// TestExistingAndConflictBinding checks the per-mode idempotency and conflict -// detection used by install: a binding matches only when both the binary and the -// --mode match, so the two modes can be installed side by side. -func TestExistingAndConflictBinding(t *testing.T) { - quickCmd := "/abs/herdr-plus --mode=quick-actions" - controlCmd := "/abs/herdr-plus --mode=control" - bindings := []keybinding{ - {Key: "prefix+u", Command: "$HOME/bin/setup.sh"}, - {Key: "prefix+down", Command: quickCmd}, - } - - // The quick-actions binding is found by its exact command. - if b, ok := existingBinding(bindings, quickCmd); !ok || b.Key != "prefix+down" { - t.Fatalf("existingBinding(quick-actions) = %+v, %v; want prefix+down", b, ok) - } - - // Control mode is NOT installed yet: same binary, different --mode. - if b, ok := existingBinding(bindings, controlCmd); ok { - t.Fatalf("existingBinding(control) = %+v; want not found (different mode)", b) - } - - // Installing control on prefix+down would clobber quick-actions -> conflict. - if b, ok := conflictBinding(bindings, "prefix+down", controlCmd); !ok || b.Command != quickCmd { - t.Fatalf("conflictBinding(prefix+down, control) = %+v, %v; want the quick-actions conflict", b, ok) - } - - // prefix+down holds quick-actions' own command, so it is not a self-conflict. - if _, ok := conflictBinding(bindings, "prefix+down", quickCmd); ok { - t.Fatal("conflictBinding(prefix+down, quick-actions) reported a conflict for its own binding") - } - - // A free key (prefix+up) has no conflict for control. - if _, ok := conflictBinding(bindings, "prefix+up", controlCmd); ok { - t.Fatal("conflictBinding(prefix+up, control) reported a conflict for a free key") - } -} - -// TestAppendToFileFollowsSymlink confirms appending through a symlink writes to -// the link target (herdr's config.toml is a dotfiles symlink). -func TestAppendToFileFollowsSymlink(t *testing.T) { - dir := t.TempDir() - target := filepath.Join(dir, "real.toml") - link := filepath.Join(dir, "config.toml") - if err := os.WriteFile(target, []byte("onboarding = false\n"), 0o644); err != nil { - t.Fatalf("write target: %v", err) - } - if err := os.Symlink(target, link); err != nil { - t.Fatalf("symlink: %v", err) - } - - if err := appendToFile(link, "\nADDED\n"); err != nil { - t.Fatalf("appendToFile: %v", err) - } - data, err := os.ReadFile(target) - if err != nil { - t.Fatalf("read target: %v", err) - } - if !filepath.IsAbs(target) || string(data) != "onboarding = false\n\nADDED\n" { - t.Fatalf("target content = %q", string(data)) - } -} diff --git a/old/internal/version/version.go b/old/internal/version/version.go deleted file mode 100644 index e428d68..0000000 --- a/old/internal/version/version.go +++ /dev/null @@ -1,15 +0,0 @@ -// -// Date: 2026-06-09 -// Author: Spicer Matthews (spicer@cloudmanic.com) -// Copyright: 2026 Cloudmanic Labs, LLC. All rights reserved. -// - -// Package version exposes herdr-plus's release version. Keep this file tiny — -// it is the single source of truth that release CI bumps on every merge to -// main, so the one-line diff stays trivial to review. -package version - -// Version is the herdr-plus release version, printed by `herdr-plus version`. -// Release automation bumps the patch number on every merge to main; edit the -// major or minor by hand to cut a larger release. -const Version = "0.0.8" diff --git a/old/internal/version/version_test.go b/old/internal/version/version_test.go deleted file mode 100644 index d914270..0000000 --- a/old/internal/version/version_test.go +++ /dev/null @@ -1,56 +0,0 @@ -// -// Date: 2026-06-09 -// Author: Spicer Matthews (spicer@cloudmanic.com) -// Copyright: 2026 Cloudmanic Labs, LLC. All rights reserved. -// - -// Tests for the tiny version package. The Version constant is the single source -// of truth release CI bumps, so we fail loudly if its shape ever drifts away -// from semver and breaks the auto-bump regex in .github/workflows/release.yml. - -package version - -import ( - "strconv" - "strings" - "testing" -) - -// TestVersionNotEmpty makes sure the constant has a value at all. -func TestVersionNotEmpty(t *testing.T) { - if Version == "" { - t.Fatal("Version is empty") - } -} - -// TestVersionIsSemver verifies the constant is in major.minor.patch form so the -// release pipeline's auto-bump logic stays correct — anything else would break -// the regex in release.yml. -func TestVersionIsSemver(t *testing.T) { - parts := strings.Split(Version, ".") - if len(parts) != 3 { - t.Fatalf("Version %q is not in x.y.z form (got %d parts)", Version, len(parts)) - } - for i, p := range parts { - n, err := strconv.Atoi(p) - if err != nil { - t.Fatalf("Version %q part %d (%q) is not an integer: %v", Version, i, p, err) - } - if n < 0 { - t.Fatalf("Version %q part %d (%q) is negative", Version, i, p) - } - } -} - -// TestVersionPreOneZero pins the major version at 0 while we are still pre-1.0. -// Bumping past 0 is a deliberate event, so update this test on purpose rather -// than letting the constant drift. -func TestVersionPreOneZero(t *testing.T) { - major, err := strconv.Atoi(strings.Split(Version, ".")[0]) - if err != nil { - t.Fatalf("Version %q has non-numeric major: %v", Version, err) - } - if major != 0 { - t.Fatalf("Version %q major is %d, expected 0 (pre-1.0). Update this test deliberately when shipping 1.0.", Version, major) - } -} diff --git a/old/main.go b/old/main.go deleted file mode 100644 index d7a8888..0000000 --- a/old/main.go +++ /dev/null @@ -1,158 +0,0 @@ -// -// Date: 2026-06-09 -// Author: Spicer Matthews (spicer@cloudmanic.com) -// Copyright: 2026 Cloudmanic Labs, LLC. All rights reserved. -// - -package main - -import ( - "flag" - "fmt" - "os" - - "github.com/cloudmanic/herdr-plus/old/internal/version" -) - -// main dispatches between the two modes of the binary. The bare binary -// (optionally with --mode) is the launcher: it opens a bottom split and starts -// the picker inside it. The hidden "picker" subcommand renders the fuzzy-finder -// TUI and, on exit, closes its own pane. The launcher re-invokes itself in -// picker mode, so end users only ever run the bare binary. -func main() { - if len(os.Args) > 1 { - switch os.Args[1] { - case "control": - runControlCmd(os.Args[2:]) - return - case "picker": - runPickerCmd(os.Args[2:]) - return - case "install": - runInstallCmd(os.Args[2:]) - return - case "version", "--version", "-v", "-V": - fmt.Println("herdr-plus", version.Version) - return - } - } - runLauncherCmd(os.Args[1:]) -} - -// runLauncherCmd parses the launcher's flags (currently just --mode) and starts -// the launcher for the resolved mode. -func runLauncherCmd(args []string) { - fs := flag.NewFlagSet("herdr-plus", flag.ExitOnError) - modeSlug := fs.String("mode", "", "which herdr-plus mode to run (default: quick-actions)") - _ = fs.Parse(args) - - mode, err := lookupMode(*modeSlug) - if err != nil { - errExit(err) - } - runLauncher(mode) -} - -// runPickerCmd parses the internal picker invocation: --mode and --ctx flags -// plus a trailing pane id (the picker's own pane, which it closes on exit). -func runPickerCmd(args []string) { - fs := flag.NewFlagSet("picker", flag.ExitOnError) - modeSlug := fs.String("mode", "", "which herdr-plus mode to run") - ctxArg := fs.String("ctx", "", "base64-encoded run context from the launcher") - _ = fs.Parse(args) - - mode, err := lookupMode(*modeSlug) - if err != nil { - errExit(err) - } - ctx, err := decodeRunContext(*ctxArg) - if err != nil { - errExit("could not decode run context:", err) - } - - // The trailing argument is the picker's own pane id so it can close itself; - // fall back to HERDR_PANE_ID, which is this pane. - selfPane := "" - if rest := fs.Args(); len(rest) > 0 { - selfPane = rest[0] - } - if selfPane == "" { - selfPane = os.Getenv("HERDR_PANE_ID") - } - - runPicker(mode, ctx, selfPane) -} - -// runLauncher dispatches to the right launch behavior for the mode. Modes differ -// in how they present themselves: quick-actions opens a small split beneath the -// current pane, while control mode opens a brand-new full-screen workspace. -func runLauncher(mode Mode) { - client, err := newHerdrClient() - if err != nil { - errExit(err) - } - - switch mode.Slug { - case ModeControl.Slug: - launchControl(client) - default: - launchPicker(client, mode) - } -} - -// launchPicker splits the current herdr pane downward, focuses the new pane, and -// launches this same binary in picker mode inside it. Before splitting it -// gathers the run context (working directory and herdr session metadata) from -// the pane it was invoked in — the only pane that knows the user's real working -// directory — and hands it to the picker so the chosen action sees it. It then -// returns immediately so the original shell prompt comes back. -func launchPicker(client *herdrClient, mode Mode) { - // HERDR_PANE_ID identifies the pane we are launched from; it is the pane we - // split to create the picker beneath it. It is set in a pane's own shell but - // not for a keybinding (which runs server-side), so fall back to the focused - // pane in that case. - paneID := os.Getenv("HERDR_PANE_ID") - if paneID == "" { - var err error - paneID, err = client.focusedPaneID() - if err != nil || paneID == "" { - errExit("could not determine the focused pane; are you running inside herdr?") - } - } - - // Resolve our own absolute path so the new pane's shell can launch the - // picker even when the binary is not on PATH. - exe, err := os.Executable() - if err != nil { - errExit(err) - } - - // Gather context from the invoking pane and encode it for the picker. - ctx := gatherContext(client, paneID) - encoded, err := ctx.encode() - if err != nil { - errExit(err) - } - - // Create the picker pane beneath the current one and focus it so keystrokes - // flow to the picker. - newPane, err := client.paneSplit(paneID, "down", true) - if err != nil { - errExit("split failed:", err) - } - - // Start the picker in the new pane, handing it the mode, the encoded context, - // and the new pane's id so it can close itself when done. runCommand waits for - // the new shell's prompt and submits with a real Enter key (not a trailing - // newline — see sendInput), so the picker starts reliably. - launch := fmt.Sprintf("%s picker --mode=%s --ctx=%s %s", shellQuote(exe), mode.Slug, encoded, newPane) - if err := client.runCommand(newPane, launch); err != nil { - errExit("failed to start picker:", err) - } -} - -// errExit prints a "herdr-plus:"-prefixed message to stderr and exits non-zero. -func errExit(args ...any) { - fmt.Fprintln(os.Stderr, append([]any{"herdr-plus:"}, args...)...) - os.Exit(1) -} diff --git a/old/mode.go b/old/mode.go deleted file mode 100644 index ec197e7..0000000 --- a/old/mode.go +++ /dev/null @@ -1,69 +0,0 @@ -// -// Date: 2026-06-09 -// Author: Spicer Matthews (spicer@cloudmanic.com) -// Copyright: 2026 Cloudmanic Labs, LLC. All rights reserved. -// - -package main - -import "fmt" - -// Mode identifies which herdr-plus behavior to run. herdr-plus is an add-on -// platform for herdr: the same binary can be invoked many times in different -// herdr panes, and a --mode flag tells each invocation what to do when it talks -// to herdr. We expect this list to grow. -type Mode struct { - // Slug is the stable identifier used on the command line (--mode=) and - // as the per-mode configuration subdirectory name under ~/.config/herdr-plus. - Slug string - // Title is the human-facing heading shown at the top of the mode's UI. - Title string - // DefaultKey is the herdr keybinding `herdr-plus install` binds for this mode - // when the user does not pass --key. Each mode claims its own key so the two - // modes can coexist (quick-actions on prefix+down, control on prefix+up). - DefaultKey string -} - -// ModeControl is herdr-plus's home base: a full-screen workspace ("Herdr Plus") -// from which you drive herdr. Its first feature is Projects — declarative -// workspace templates you fuzzy-pick to spin up a fully laid-out workspace. It -// is the default mode, so the bare binary lands here. -var ModeControl = Mode{Slug: "control", Title: "Herdr Plus · Control · Projects", DefaultKey: "prefix+up"} - -// ModeQuickActions is the fuzzy launcher: pick an action from your config and -// run it in a split beneath the pane you launched from. This was herdr-plus's -// original behavior. -var ModeQuickActions = Mode{Slug: "quick-actions", Title: "⚡ Quick Actions", DefaultKey: "prefix+down"} - -// defaultMode is the mode used when --mode is omitted. Control mode is the -// front door of herdr-plus, so the bare binary opens it. -var defaultMode = ModeControl - -// orderedModes lists every mode herdr-plus knows about, in install order. A bare -// `herdr-plus install` walks this slice so each mode lands on its conventional -// key (control → prefix+up, quick-actions → prefix+down). Add new modes here. -var orderedModes = []Mode{ModeControl, ModeQuickActions} - -// modes is every mode keyed by slug, derived from orderedModes so the lookup -// table and the install order can never drift apart. -var modes = func() map[string]Mode { - m := make(map[string]Mode, len(orderedModes)) - for _, mode := range orderedModes { - m[mode.Slug] = mode - } - return m -}() - -// lookupMode resolves a --mode slug to its Mode. An empty slug selects the -// default mode; an unrecognized slug is an error so typos fail loudly instead of -// silently doing the wrong thing. -func lookupMode(slug string) (Mode, error) { - if slug == "" { - return defaultMode, nil - } - m, ok := modes[slug] - if !ok { - return Mode{}, fmt.Errorf("unknown mode %q", slug) - } - return m, nil -} diff --git a/old/mode_test.go b/old/mode_test.go deleted file mode 100644 index d719c43..0000000 --- a/old/mode_test.go +++ /dev/null @@ -1,41 +0,0 @@ -// -// Date: 2026-06-09 -// Author: Spicer Matthews (spicer@cloudmanic.com) -// Copyright: 2026 Cloudmanic Labs, LLC. All rights reserved. -// - -package main - -import "testing" - -// TestLookupModeDefaultIsControl confirms the bare binary (empty --mode) resolves -// to control mode, while explicit slugs resolve to their modes and typos error. -func TestLookupModeDefaultIsControl(t *testing.T) { - if defaultMode.Slug != ModeControl.Slug { - t.Fatalf("default mode = %q, want control", defaultMode.Slug) - } - - m, err := lookupMode("") - if err != nil || m.Slug != ModeControl.Slug { - t.Fatalf("lookupMode(\"\") = %q, %v; want control", m.Slug, err) - } - - if m, err := lookupMode("quick-actions"); err != nil || m.Slug != ModeQuickActions.Slug { - t.Fatalf("lookupMode(quick-actions) = %q, %v", m.Slug, err) - } - - if _, err := lookupMode("nope"); err == nil { - t.Fatal("lookupMode(nope) should error on an unknown slug") - } -} - -// TestModeDefaultKeys pins each mode to its conventional keybinding so the two -// modes can be installed side by side without colliding. -func TestModeDefaultKeys(t *testing.T) { - if ModeControl.DefaultKey != "prefix+up" { - t.Fatalf("control default key = %q, want prefix+up", ModeControl.DefaultKey) - } - if ModeQuickActions.DefaultKey != "prefix+down" { - t.Fatalf("quick-actions default key = %q, want prefix+down", ModeQuickActions.DefaultKey) - } -} diff --git a/old/picker.go b/old/picker.go deleted file mode 100644 index d6936a7..0000000 --- a/old/picker.go +++ /dev/null @@ -1,451 +0,0 @@ -// -// Date: 2026-06-09 -// Author: Spicer Matthews (spicer@cloudmanic.com) -// Copyright: 2026 Cloudmanic Labs, LLC. All rights reserved. -// - -package main - -import ( - "fmt" - "os" - "strings" - - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" -) - -// Palette. A small, cohesive set of colors for a clean dark-terminal look. -// These styles are shared with the fuzzyList component in the same package. -var ( - titleStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#11111B")). - Background(lipgloss.Color("#A78BFA")). - Padding(0, 1) - - promptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#A78BFA")).Bold(true) - countStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#6B7280")) - - nameStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#E5E7EB")) - nameSelStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true) - descStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#6B7280")) - matchStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#F2A900")).Bold(true) - barStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#A78BFA")).Bold(true) - footerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#4B5563")) - headingStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#8B5CF6")).Bold(true) -) - -// pickerHeaderLines is how many screen lines precede the embedded fuzzyList in -// every picker stage: the title bar and the blank line under it. A mouse click's -// screen row minus this offset is the list-local line passed to clickRow. -const pickerHeaderLines = 2 - -// stage is which screen the picker is currently showing. -type stage int - -const ( - // stageActions is the top-level list of actions. - stageActions stage = iota - // stageSelect is the option list shown for a "select" action. - stageSelect - // stageForm is the text field shown for a "form" action. - stageForm -) - -// pickerModel holds the state of the fuzzy-finder TUI. It is a small state -// machine: pick an action, then for select/form actions gather the extra input -// before the program quits and the chosen action runs. -type pickerModel struct { - mode Mode - ctx RunContext - - stage stage - - actions []Action - actionList fuzzyList - - // current is the action awaiting extra input (set in the select/form stages). - current *Action - optionList fuzzyList - formInput textinput.Model - - width int - height int - - // Results, read back after the program exits. - chosen *Action // the action to run, nil if the user cancelled - value string // resolved option value or form input for the chosen action - quitting bool -} - -// newPickerModel builds the initial TUI state showing every action. Actions are -// partitioned by origin so project-local actions sort and render ahead of the -// global ones; m.actions is stored in that same project-then-global order so each -// list row's ref indexes straight back into it. -func newPickerModel(mode Mode, ctx RunContext, actions []Action) pickerModel { - var project, global []Action - for _, a := range actions { - if a.origin == originProject { - project = append(project, a) - } else { - global = append(global, a) - } - } - - ordered := make([]Action, 0, len(actions)) - ordered = append(ordered, project...) - ordered = append(ordered, global...) - - return pickerModel{ - mode: mode, - ctx: ctx, - stage: stageActions, - actions: ordered, - actionList: newFuzzyList("Type to filter…", actionListItems(project, global)), - } -} - -// actionListItems turns the project and global actions into picker rows. When -// both groups are present it brackets each with a non-selectable "Project" / -// "Global" heading so the origin of every action is clear; when only one group -// exists it emits a plain, ungrouped list so a repo without project actions looks -// exactly as it did before. Each selectable row's ref is its index into the -// concatenated project++global slice, matching the order newPickerModel stores in -// m.actions. -func actionListItems(project, global []Action) []listItem { - grouped := len(project) > 0 && len(global) > 0 - items := make([]listItem, 0, len(project)+len(global)+2) - - if grouped { - items = append(items, listItem{name: "Project"}) - } - for i, a := range project { - items = append(items, listItem{name: a.Name, desc: a.Description, selectable: true, ref: i}) - } - - if grouped { - items = append(items, listItem{name: "Global"}) - } - for j, a := range global { - items = append(items, listItem{name: a.Name, desc: a.Description, selectable: true, ref: len(project) + j}) - } - - return items -} - -// Init implements tea.Model and starts the cursor blinking. -func (m pickerModel) Init() tea.Cmd { - return textinput.Blink -} - -// Update routes key presses to the handler for the current stage and forwards -// everything else (window sizes, the blink tick) to the active input. -func (m pickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - return m, nil - - case tea.MouseMsg: - return m.updateMouse(msg) - - case tea.KeyMsg: - switch m.stage { - case stageActions: - return m.updateActions(msg) - case stageSelect: - return m.updateSelect(msg) - case stageForm: - return m.updateForm(msg) - } - } - - return m.forwardToInput(msg) -} - -// updateMouse turns mouse input into list navigation: the wheel moves the -// highlight, and the left button selects the row under the pointer — running it -// on release, the natural completion of a click. The text-only form stage has no -// list, so it ignores the mouse. This works only because herdr forwards mouse -// events to the focused pane's program once that program enables mouse reporting -// (which runPicker now does via tea.WithMouseCellMotion); otherwise herdr would -// keep the clicks for its own pane focus/selection. -func (m pickerModel) updateMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { - switch m.stage { - case stageActions: - switch msg.Button { - case tea.MouseButtonWheelUp: - m.actionList.moveUp() - case tea.MouseButtonWheelDown: - m.actionList.moveDown() - case tea.MouseButtonLeft: - if m.actionList.clickRow(msg.Y-pickerHeaderLines) && msg.Action == tea.MouseActionRelease { - return m.activateAction() - } - } - case stageSelect: - switch msg.Button { - case tea.MouseButtonWheelUp: - m.optionList.moveUp() - case tea.MouseButtonWheelDown: - m.optionList.moveDown() - case tea.MouseButtonLeft: - if m.optionList.clickRow(msg.Y-pickerHeaderLines) && msg.Action == tea.MouseActionRelease { - return m.activateOption() - } - } - } - return m, nil -} - -// updateActions handles keys while choosing an action. Selecting a plain command -// quits so it can run; selecting a select/form action advances to the matching -// input stage. -func (m pickerModel) updateActions(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "ctrl+c", "esc": - m.quitting = true - return m, tea.Quit - case "up", "ctrl+p": - m.actionList.moveUp() - return m, nil - case "down", "ctrl+n": - m.actionList.moveDown() - return m, nil - case "enter": - return m.activateAction() - } - - cmd := m.actionList.editQuery(msg) - return m, cmd -} - -// activateAction runs the highlighted action: a plain command quits so it can -// run, while a select/form action advances to its input stage. It is shared by -// the enter key and a left-click; activating with nothing selectable is a no-op. -func (m pickerModel) activateAction() (tea.Model, tea.Cmd) { - idx := m.actionList.selectedIndex() - if idx < 0 { - return m, nil - } - a := m.actions[idx] - switch a.effectiveType() { - case TypeSelect: - m.current = &a - m.optionList = newFuzzyList("Pick an option…", optionItems(a.Options)) - m.stage = stageSelect - return m, textinput.Blink - case TypeForm: - m.current = &a - m.formInput = newFormInput(a.Form) - m.stage = stageForm - return m, textinput.Blink - default: // TypeCommand - m.chosen = &a - return m, tea.Quit - } -} - -// updateSelect handles keys while choosing an option for a select action. esc -// returns to the action list; enter records the chosen value and quits. -func (m pickerModel) updateSelect(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "ctrl+c": - m.quitting = true - return m, tea.Quit - case "esc": - m.current = nil - m.stage = stageActions - return m, textinput.Blink - case "up", "ctrl+p": - m.optionList.moveUp() - return m, nil - case "down", "ctrl+n": - m.optionList.moveDown() - return m, nil - case "enter": - return m.activateOption() - } - - cmd := m.optionList.editQuery(msg) - return m, cmd -} - -// activateOption records the highlighted option's value as the chosen action's -// value and quits so the action runs. Shared by the enter key and a left-click; -// activating with nothing selectable is a no-op. -func (m pickerModel) activateOption() (tea.Model, tea.Cmd) { - idx := m.optionList.selectedIndex() - if idx < 0 { - return m, nil - } - m.value = m.current.Options[idx].resolvedValue() - m.chosen = m.current - return m, tea.Quit -} - -// updateForm handles keys while entering text for a form action. esc returns to -// the action list; enter records the entered string and quits. -func (m pickerModel) updateForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "ctrl+c": - m.quitting = true - return m, tea.Quit - case "esc": - m.current = nil - m.stage = stageActions - return m, textinput.Blink - case "enter": - m.value = strings.TrimSpace(m.formInput.Value()) - m.chosen = m.current - return m, tea.Quit - } - - var cmd tea.Cmd - m.formInput, cmd = m.formInput.Update(msg) - return m, cmd -} - -// forwardToInput passes a non-key message to whichever input is active so the -// cursor keeps blinking and text keeps flowing in every stage. -func (m pickerModel) forwardToInput(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - switch m.stage { - case stageActions: - cmd = m.actionList.editQuery(msg) - case stageSelect: - cmd = m.optionList.editQuery(msg) - case stageForm: - m.formInput, cmd = m.formInput.Update(msg) - } - return m, cmd -} - -// View renders the screen for the current stage. -func (m pickerModel) View() string { - if m.quitting { - return "" - } - - var b strings.Builder - - switch m.stage { - case stageSelect: - b.WriteString(titleStyle.Render(m.current.Name)) - b.WriteString("\n\n") - b.WriteString(m.optionList.view("no matching options")) - b.WriteString("\n") - b.WriteString(footerStyle.Render("↑/↓ move • click/enter select • esc back")) - - case stageForm: - b.WriteString(titleStyle.Render(m.current.Name)) - b.WriteString("\n\n") - prompt := m.current.Form.Prompt - if prompt == "" { - prompt = "Enter a value" - } - b.WriteString(descStyle.Render(prompt)) - b.WriteString("\n") - b.WriteString(promptStyle.Render("❯ ")) - b.WriteString(m.formInput.View()) - b.WriteString("\n\n") - b.WriteString(footerStyle.Render("enter submit • esc back")) - - default: // stageActions - b.WriteString(titleStyle.Render(m.mode.Title)) - b.WriteString("\n\n") - b.WriteString(m.actionList.view("no matching actions")) - b.WriteString("\n") - b.WriteString(footerStyle.Render("↑/↓ move • click/enter run • esc cancel")) - } - - return b.String() -} - -// optionItems turns a select action's options into list rows. A selectable row -// shows its label plus the option's optional description (the value is never -// shown, so encoding data into the value — e.g. "host url" — does not clutter -// the list). A separator option becomes a non-selectable heading/spacer row. -// ref carries each row's index into the original options slice so the picker can -// map the selected row back to its option. -func optionItems(options []Option) []listItem { - items := make([]listItem, 0, len(options)) - for i, o := range options { - if o.isSeparator() { - items = append(items, listItem{name: o.Heading}) - continue - } - items = append(items, listItem{name: o.Label, desc: o.Description, selectable: true, ref: i}) - } - return items -} - -// newFormInput builds the text field for a form action, applying its optional -// placeholder. -func newFormInput(form FormConfig) textinput.Model { - ti := textinput.New() - ti.Prompt = "" - ti.Placeholder = form.Placeholder - if ti.Placeholder == "" { - ti.Placeholder = "Type a value…" - } - ti.Focus() - return ti -} - -// runPicker loads the mode's actions, renders the fuzzy-finder TUI, runs the -// chosen action with the gathered context, and then closes its own herdr pane so -// focus returns to the pane it was launched from. selfPane is the pane id to -// close on exit. -func runPicker(mode Mode, ctx RunContext, selfPane string) { - actions, err := loadPickerActions(mode, ctx.WorkDir) - if err != nil { - // Leave the pane open so the user can read the config error. - errExit(err) - } - - if len(actions) == 0 { - dir, _ := modeConfigDir(mode) - fmt.Fprintf(os.Stderr, "herdr-plus: no actions found in %s\n", dir) - closeSelf(selfPane) - return - } - - // WithMouseCellMotion enables click/release/wheel events so a row can be run - // with the mouse. herdr forwards these to us once we ask for them; until then - // it keeps the mouse for its own pane focus/selection. - p := tea.NewProgram(newPickerModel(mode, ctx, actions), tea.WithAltScreen(), tea.WithMouseCellMotion()) - result, err := p.Run() - if err != nil { - fmt.Fprintln(os.Stderr, "herdr-plus:", err) - } - - // Run the chosen action before tearing down the pane. The context carries the - // working directory and session metadata; we fill in the resolved Value. - if m, ok := result.(pickerModel); ok && m.chosen != nil { - runCtx := ctx - runCtx.Value = m.value - if err := m.chosen.run(runCtx); err != nil { - fmt.Fprintln(os.Stderr, "herdr-plus: action failed:", err) - } - } - - closeSelf(selfPane) -} - -// closeSelf asks herdr to close the picker's pane. Failures are ignored: if we -// cannot reach the socket there is nothing useful to do from a pane that is -// about to go away anyway. -func closeSelf(paneID string) { - if paneID == "" { - return - } - client, err := newHerdrClient() - if err != nil { - return - } - _ = client.closePane(paneID) -} diff --git a/old/picker_test.go b/old/picker_test.go deleted file mode 100644 index b77b987..0000000 --- a/old/picker_test.go +++ /dev/null @@ -1,136 +0,0 @@ -// -// Date: 2026-06-09 -// Author: Spicer Matthews (spicer@cloudmanic.com) -// Copyright: 2026 Cloudmanic Labs, LLC. All rights reserved. -// - -package main - -import ( - "testing" - - tea "github.com/charmbracelet/bubbletea" -) - -// TestOptionItems confirms select options render their label plus optional -// description (never the raw value), that separators become non-selectable -// heading/spacer rows, and that selectable rows keep their original options -// index in ref even when separators are interspersed. -func TestOptionItems(t *testing.T) { - opts := []Option{ - {Label: "With desc", Value: "v1", Description: "a description"}, // 0 - {Label: "No desc", Value: "some long encoded value"}, // 1 - {Heading: "Group B"}, // 2: heading separator - {Label: "After heading", Value: "v3"}, // 3 - {}, // 4: blank spacer - } - items := optionItems(opts) - - if len(items) != 5 { - t.Fatalf("got %d items, want 5", len(items)) - } - if !items[0].selectable || items[0].name != "With desc" || items[0].desc != "a description" || items[0].ref != 0 { - t.Fatalf("item0 = %+v", items[0]) - } - if !items[1].selectable || items[1].name != "No desc" || items[1].desc != "" || items[1].ref != 1 { - t.Fatalf("item1 = %+v, value must not appear as desc", items[1]) - } - if items[2].selectable || items[2].name != "Group B" { - t.Fatalf("item2 = %+v, want non-selectable heading", items[2]) - } - if !items[3].selectable || items[3].name != "After heading" || items[3].ref != 3 { - t.Fatalf("item3 = %+v, ref must be original options index 3", items[3]) - } - if items[4].selectable || items[4].name != "" { - t.Fatalf("item4 = %+v, want blank spacer", items[4]) - } -} - -// TestActionListItems confirms project and global actions are grouped under -// "Project"/"Global" headings when both are present — with each selectable row's -// ref pointing into the concatenated project++global order — and that a -// global-only set renders as a plain, ungrouped list (no headings), preserving -// the pre-feature look for repos without a .herdr-plus directory. -func TestActionListItems(t *testing.T) { - project := []Action{ - {Name: "make build", Description: "Build", origin: originProject}, - {Name: "make test", Description: "Test", origin: originProject}, - } - global := []Action{ - {Name: "GitHub", Description: "Open GitHub", origin: originGlobal}, - } - - items := actionListItems(project, global) - // Project heading, 2 project rows, Global heading, 1 global row. - if len(items) != 5 { - t.Fatalf("got %d items, want 5: %+v", len(items), items) - } - if items[0].selectable || items[0].name != "Project" { - t.Fatalf("item0 = %+v, want non-selectable Project heading", items[0]) - } - if !items[1].selectable || items[1].name != "make build" || items[1].ref != 0 { - t.Fatalf("item1 = %+v, want make build with ref 0", items[1]) - } - if !items[2].selectable || items[2].name != "make test" || items[2].ref != 1 { - t.Fatalf("item2 = %+v, want make test with ref 1", items[2]) - } - if items[3].selectable || items[3].name != "Global" { - t.Fatalf("item3 = %+v, want non-selectable Global heading", items[3]) - } - if !items[4].selectable || items[4].name != "GitHub" || items[4].ref != 2 { - t.Fatalf("item4 = %+v, want GitHub with ref 2 (index into project++global)", items[4]) - } - - // Global-only: no headings, refs start at 0. - only := actionListItems(nil, global) - if len(only) != 1 { - t.Fatalf("got %d items for global-only, want 1 (no headings): %+v", len(only), only) - } - if !only[0].selectable || only[0].name != "GitHub" || only[0].ref != 0 { - t.Fatalf("global-only item = %+v, want GitHub ref 0 with no heading", only[0]) - } -} - -// TestPickerMouseClickRunsAction confirms a left-button release over a command -// row selects that action and quits so it runs — the click counterpart to enter. -// With a global-only list (no headings) the rows sit just below the title bar and -// query line: "build" at screen row 4, "test" at row 5. -func TestPickerMouseClickRunsAction(t *testing.T) { - actions := []Action{ - {Name: "build", Command: "make build", origin: originGlobal}, - {Name: "test", Command: "make test", origin: originGlobal}, - } - m := newPickerModel(ModeQuickActions, RunContext{}, actions) - - updated, _ := m.Update(tea.MouseMsg{ - Action: tea.MouseActionRelease, - Button: tea.MouseButtonLeft, - Y: 5, - }) - pm, ok := updated.(pickerModel) - if !ok { - t.Fatalf("Update returned %T, want pickerModel", updated) - } - if pm.chosen == nil || pm.chosen.Name != "test" { - t.Fatalf("clicking the 'test' row chose %v, want test", pm.chosen) - } -} - -// TestPickerMouseWheelMoves confirms the scroll wheel walks the highlight without -// running anything. -func TestPickerMouseWheelMoves(t *testing.T) { - actions := []Action{ - {Name: "build", Command: "make build", origin: originGlobal}, - {Name: "test", Command: "make test", origin: originGlobal}, - } - m := newPickerModel(ModeQuickActions, RunContext{}, actions) - - updated, _ := m.Update(tea.MouseMsg{Action: tea.MouseActionPress, Button: tea.MouseButtonWheelDown}) - pm := updated.(pickerModel) - if pm.chosen != nil { - t.Fatal("the wheel should not run an action") - } - if got := pm.actionList.selectedIndex(); got != 1 { - t.Fatalf("after wheel down selected ref = %d, want 1 (test)", got) - } -} diff --git a/old/project.go b/old/project.go deleted file mode 100644 index c0044b3..0000000 --- a/old/project.go +++ /dev/null @@ -1,238 +0,0 @@ -// -// Date: 2026-06-09 -// Author: Spicer Matthews (spicer@cloudmanic.com) -// Copyright: 2026 Cloudmanic Labs, LLC. All rights reserved. -// - -package main - -import ( - "fmt" - "os" - "path/filepath" - "sort" - "strings" - - "github.com/BurntSushi/toml" -) - -// Split directions herdr's pane.split understands. "down" stacks the new pane -// below the previous one (top/bottom); "right" puts it beside (side by side). -const ( - SplitDown = "down" - SplitRight = "right" -) - -// maxPanesPerTab caps how many panes a single tab may declare. A tab is split -// into at most this many panes. -const maxPanesPerTab = 4 - -// ProjectPane is one pane within a tab. Command, when set, runs in the pane on -// startup. Split is how the pane is created relative to the previous pane in the -// tab — "down" or "right"; it is ignored for the first pane (the tab's root) and -// defaults to "down" when omitted. -type ProjectPane struct { - Command string `toml:"command"` - Split string `toml:"split"` -} - -// ProjectTab is one tab in a project's workspace, in the order it should be -// created. Name is the tab's label. A tab is authored in one of two forms: the -// single-pane shorthand sets Command directly; a split tab instead lists -// [[tabs.panes]] (up to maxPanesPerTab of them). A tab with neither is an empty -// terminal. -type ProjectTab struct { - Name string `toml:"name"` - Command string `toml:"command"` - Panes []ProjectPane `toml:"panes"` -} - -// effectivePanes returns the tab's panes in creation order, normalizing the two -// authoring forms into one list. The first pane is the tab's root (its split is -// cleared); each later pane carries the direction it splits off the previous -// one, defaulting to "down". -func (t ProjectTab) effectivePanes() []ProjectPane { - if len(t.Panes) == 0 { - return []ProjectPane{{Command: t.Command}} - } - panes := make([]ProjectPane, len(t.Panes)) - for i, p := range t.Panes { - panes[i] = p - if i == 0 { - panes[i].Split = "" - continue - } - if panes[i].Split == "" { - panes[i].Split = SplitDown - } - } - return panes -} - -// Project is a declarative herdr workspace template, loaded from one TOML file in -// ~/.config/herdr-plus/projects. Picking a project in control mode opens a new -// herdr workspace rooted at WorkingDir, labeled Name, with one tab per entry in -// Tabs (in order) running each tab's startup command. Projects replace the -// hand-written herdr-workspaces shell scripts. -type Project struct { - Name string `toml:"name"` - Description string `toml:"description"` - - // Group is an optional label that clusters projects in the control-mode - // browser. Projects sharing a Group are shown together under a heading — for - // example, every project belonging to one client. It is purely a browsing aid - // and has no effect on the workspace that opens. Leaving it empty drops the - // project into the catch-all "Ungrouped" heading when any other project sets a - // Group; when no project sets one, the browser stays a plain, heading-less - // list exactly as before. - Group string `toml:"group"` - - WorkingDir string `toml:"working_dir"` - Tabs []ProjectTab `toml:"tabs"` - - // source is the file the project was loaded from, used only for error - // messages. It is not part of the on-disk format. - source string -} - -// projectsConfigDir returns the directory that holds project files, -// ~/.config/herdr-plus/projects. It hangs directly off the herdr-plus config -// root (not under a mode slug) because projects are a first-class concept that -// could one day be driven by more than one mode. -func projectsConfigDir() (string, error) { - base, err := configBaseDir() - if err != nil { - return "", err - } - return filepath.Join(base, "projects"), nil -} - -// ensureProjectsDir makes sure the projects directory exists and returns its -// path. Unlike a mode's action directory, it is never seeded with examples: an -// empty directory is meaningful — it triggers control mode's onboarding -// empty-state — so we only create the (empty) folder for the user to drop files -// into. -func ensureProjectsDir() (string, error) { - dir, err := projectsConfigDir() - if err != nil { - return "", err - } - if err := os.MkdirAll(dir, 0o755); err != nil { - return "", err - } - return dir, nil -} - -// loadProjects reads, parses, and validates every *.toml project in the projects -// directory, returning them sorted by name. A malformed or invalid file fails -// the whole load with a message naming the offending files, so config mistakes -// surface loudly instead of a project silently going missing. An empty directory -// returns an empty slice (not an error) so the caller can show the empty-state. -func loadProjects() ([]Project, error) { - dir, err := ensureProjectsDir() - if err != nil { - return nil, err - } - - entries, err := os.ReadDir(dir) - if err != nil { - return nil, err - } - - var projects []Project - var problems []string - for _, e := range entries { - if e.IsDir() || !strings.HasSuffix(e.Name(), ".toml") { - continue - } - path := filepath.Join(dir, e.Name()) - - var p Project - if _, err := toml.DecodeFile(path, &p); err != nil { - problems = append(problems, fmt.Sprintf(" %s: %v", e.Name(), err)) - continue - } - p.source = e.Name() - if err := p.validate(); err != nil { - problems = append(problems, " "+err.Error()) - continue - } - projects = append(projects, p) - } - - if len(problems) > 0 { - return nil, fmt.Errorf("invalid project files in %s:\n%s", dir, strings.Join(problems, "\n")) - } - - sort.Slice(projects, func(i, j int) bool { return projects[i].Name < projects[j].Name }) - return projects, nil -} - -// validate checks that a project is internally consistent before we ever try to -// open it, turning config mistakes into clear errors at load time. The working -// directory is intentionally not checked for existence here — that is a per-open -// concern (the dir might exist on one machine but not another), reported when -// the project is actually opened. -func (p Project) validate() error { - if strings.TrimSpace(p.Name) == "" { - return fmt.Errorf("project %s: name is required", p.source) - } - if len(p.Tabs) == 0 { - return fmt.Errorf("project %q (%s): needs at least one [[tabs]] entry", p.Name, p.source) - } - for i, t := range p.Tabs { - if strings.TrimSpace(t.Name) == "" { - return fmt.Errorf("project %q (%s): tab %d is missing a name", p.Name, p.source, i+1) - } - if len(t.Panes) > 0 && strings.TrimSpace(t.Command) != "" { - return fmt.Errorf("project %q (%s): tab %q sets both command and [[tabs.panes]]; use one or the other", p.Name, p.source, t.Name) - } - if len(t.Panes) > maxPanesPerTab { - return fmt.Errorf("project %q (%s): tab %q has %d panes; at most %d are allowed", p.Name, p.source, t.Name, len(t.Panes), maxPanesPerTab) - } - for j, pane := range t.Panes { - if j == 0 { - continue // the first pane is the tab's root; its split is ignored - } - switch pane.Split { - case "", SplitDown, SplitRight: - // ok — an empty split defaults to "down" - default: - return fmt.Errorf("project %q (%s): tab %q pane %d has split %q; must be %q or %q", p.Name, p.source, t.Name, j+1, pane.Split, SplitDown, SplitRight) - } - } - } - return nil -} - -// expandedWorkingDir resolves the project's working directory to an absolute -// path, expanding a leading ~ to the home directory and any $VARS in the path. -// An empty working_dir defaults to the user's home directory, so a minimal -// project still opens somewhere sensible. -func (p Project) expandedWorkingDir() string { - dir := strings.TrimSpace(p.WorkingDir) - home, _ := os.UserHomeDir() - - if dir == "" || dir == "~" { - return home - } - if strings.HasPrefix(dir, "~/") { - dir = filepath.Join(home, dir[2:]) - } - return os.ExpandEnv(dir) -} - -// tabLabels returns the tab names in order for the control TUI's detail bar, -// annotating split tabs with a "×N" pane count so the layout is visible at a -// glance (e.g. "server ×2"). -func (p Project) tabLabels() []string { - labels := make([]string, len(p.Tabs)) - for i, t := range p.Tabs { - if n := len(t.effectivePanes()); n > 1 { - labels[i] = fmt.Sprintf("%s ×%d", t.Name, n) - } else { - labels[i] = t.Name - } - } - return labels -} diff --git a/old/project_test.go b/old/project_test.go deleted file mode 100644 index 0f5f5a0..0000000 --- a/old/project_test.go +++ /dev/null @@ -1,254 +0,0 @@ -// -// Date: 2026-06-09 -// Author: Spicer Matthews (spicer@cloudmanic.com) -// Copyright: 2026 Cloudmanic Labs, LLC. All rights reserved. -// - -package main - -import ( - "os" - "path/filepath" - "testing" -) - -// projectsDirIn returns the projects directory under a temp XDG config root and -// makes sure it exists, mirroring how the real config layout is rooted. -func projectsDirIn(t *testing.T, tmp string) string { - t.Helper() - dir := filepath.Join(tmp, "herdr-plus", "projects") - if err := os.MkdirAll(dir, 0o755); err != nil { - t.Fatalf("mkdir projects dir: %v", err) - } - return dir -} - -// TestLoadProjectsParsesAndSorts confirms valid project files are parsed, sorted -// by name, and have their tabs preserved in file order. -func TestLoadProjectsParsesAndSorts(t *testing.T) { - tmp := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", tmp) - dir := projectsDirIn(t, tmp) - - bravo := `name = "Bravo" -description = "second alphabetically" -working_dir = "~/code/bravo" - -[[tabs]] -name = "edit" -command = "vim" - -[[tabs]] -name = "shell" -` - alpha := `name = "Alpha" -working_dir = "/srv/alpha" - -[[tabs]] -name = "run" -command = "make serve" -` - if err := os.WriteFile(filepath.Join(dir, "bravo.toml"), []byte(bravo), 0o644); err != nil { - t.Fatalf("write bravo: %v", err) - } - if err := os.WriteFile(filepath.Join(dir, "alpha.toml"), []byte(alpha), 0o644); err != nil { - t.Fatalf("write alpha: %v", err) - } - - projects, err := loadProjects() - if err != nil { - t.Fatalf("loadProjects: %v", err) - } - if len(projects) != 2 { - t.Fatalf("got %d projects, want 2", len(projects)) - } - if projects[0].Name != "Alpha" || projects[1].Name != "Bravo" { - t.Fatalf("projects not sorted by name: %q, %q", projects[0].Name, projects[1].Name) - } - - b := projects[1] - if len(b.Tabs) != 2 || b.Tabs[0].Name != "edit" || b.Tabs[0].Command != "vim" { - t.Fatalf("bravo tabs wrong: %+v", b.Tabs) - } - if b.Tabs[1].Command != "" { - t.Fatalf("bravo second tab should have no command, got %q", b.Tabs[1].Command) - } -} - -// TestLoadProjectsParsesSplitPanes confirms a tab authored with [[tabs.panes]] -// loads with its panes, commands, and split directions intact. -func TestLoadProjectsParsesSplitPanes(t *testing.T) { - tmp := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", tmp) - dir := projectsDirIn(t, tmp) - - content := `name = "Rental Notice" -working_dir = "/srv/rental" - -[[tabs]] -name = "claude" -command = "claude" - -[[tabs]] -name = "server" - -[[tabs.panes]] -command = "php artisan serve" - -[[tabs.panes]] -command = "npm run dev" -split = "down" -` - if err := os.WriteFile(filepath.Join(dir, "rental.toml"), []byte(content), 0o644); err != nil { - t.Fatalf("write: %v", err) - } - - projects, err := loadProjects() - if err != nil { - t.Fatalf("loadProjects: %v", err) - } - if len(projects) != 1 { - t.Fatalf("got %d projects, want 1", len(projects)) - } - - server := projects[0].Tabs[1] - if len(server.Panes) != 2 { - t.Fatalf("server panes = %d, want 2", len(server.Panes)) - } - if server.Panes[0].Command != "php artisan serve" || server.Panes[1].Command != "npm run dev" { - t.Fatalf("pane commands wrong: %+v", server.Panes) - } - if server.Panes[1].Split != "down" { - t.Fatalf("pane 2 split = %q, want down", server.Panes[1].Split) - } -} - -// TestLoadProjectsEmptyDirIsNotAnError confirms an empty projects directory -// yields no projects (and no error), so the caller can show the empty-state. -func TestLoadProjectsEmptyDirIsNotAnError(t *testing.T) { - tmp := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", tmp) - projectsDirIn(t, tmp) - - projects, err := loadProjects() - if err != nil { - t.Fatalf("loadProjects on empty dir: %v", err) - } - if len(projects) != 0 { - t.Fatalf("expected no projects, got %d", len(projects)) - } -} - -// TestLoadProjectsRejectsInvalidFile confirms a structurally-invalid project -// (here: no tabs) fails the load loudly rather than silently disappearing. -func TestLoadProjectsRejectsInvalidFile(t *testing.T) { - tmp := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", tmp) - dir := projectsDirIn(t, tmp) - - noTabs := "name = \"No Tabs\"\nworking_dir = \"/tmp\"\n" - if err := os.WriteFile(filepath.Join(dir, "notabs.toml"), []byte(noTabs), 0o644); err != nil { - t.Fatalf("write: %v", err) - } - - if _, err := loadProjects(); err == nil { - t.Fatal("expected error for project with no tabs, got nil") - } -} - -// TestProjectValidate exercises each validation rule directly. -func TestProjectValidate(t *testing.T) { - twoPanes := []ProjectPane{{Command: "a"}, {Command: "b", Split: "down"}} - fivePanes := []ProjectPane{{}, {}, {}, {}, {}} - cases := []struct { - name string - project Project - wantErr bool - }{ - {"ok", Project{Name: "A", Tabs: []ProjectTab{{Name: "t"}}}, false}, - {"missing name", Project{Tabs: []ProjectTab{{Name: "t"}}}, true}, - {"no tabs", Project{Name: "A"}, true}, - {"tab missing name", Project{Name: "A", Tabs: []ProjectTab{{Command: "ls"}}}, true}, - {"ok multi-pane", Project{Name: "A", Tabs: []ProjectTab{{Name: "t", Panes: twoPanes}}}, false}, - {"command and panes", Project{Name: "A", Tabs: []ProjectTab{{Name: "t", Command: "ls", Panes: twoPanes}}}, true}, - {"too many panes", Project{Name: "A", Tabs: []ProjectTab{{Name: "t", Panes: fivePanes}}}, true}, - {"bad split", Project{Name: "A", Tabs: []ProjectTab{{Name: "t", Panes: []ProjectPane{{}, {Split: "sideways"}}}}}, true}, - {"first pane split ignored", Project{Name: "A", Tabs: []ProjectTab{{Name: "t", Panes: []ProjectPane{{Split: "sideways"}}}}}, false}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - err := c.project.validate() - if (err != nil) != c.wantErr { - t.Fatalf("validate() err = %v, wantErr = %v", err, c.wantErr) - } - }) - } -} - -// TestEffectivePanes confirms the two authoring forms normalize correctly: a -// single-pane tab yields one pane, and a multi-pane tab clears the first pane's -// split while defaulting later panes to "down". -func TestEffectivePanes(t *testing.T) { - single := ProjectTab{Name: "claude", Command: "claude"}.effectivePanes() - if len(single) != 1 || single[0].Command != "claude" || single[0].Split != "" { - t.Fatalf("single-pane = %+v", single) - } - - multi := ProjectTab{Name: "server", Panes: []ProjectPane{ - {Command: "php artisan serve", Split: "right"}, // split on root is ignored - {Command: "npm run dev"}, // omitted split defaults to down - {Command: "tail -f log", Split: "right"}, - }}.effectivePanes() - if len(multi) != 3 { - t.Fatalf("got %d panes, want 3", len(multi)) - } - if multi[0].Split != "" { - t.Fatalf("root pane split = %q, want empty", multi[0].Split) - } - if multi[1].Split != SplitDown { - t.Fatalf("pane 2 split = %q, want down (default)", multi[1].Split) - } - if multi[2].Split != SplitRight { - t.Fatalf("pane 3 split = %q, want right", multi[2].Split) - } -} - -// TestTabLabels confirms split tabs are annotated with a "×N" pane count while -// single-pane tabs show just their name. -func TestTabLabels(t *testing.T) { - p := Project{Tabs: []ProjectTab{ - {Name: "claude", Command: "claude"}, - {Name: "server", Panes: []ProjectPane{{Command: "a"}, {Command: "b"}}}, - }} - got := p.tabLabels() - if got[0] != "claude" { - t.Fatalf("label[0] = %q, want claude", got[0]) - } - if got[1] != "server ×2" { - t.Fatalf("label[1] = %q, want \"server ×2\"", got[1]) - } -} - -// TestExpandedWorkingDir confirms ~, $VARS, absolute paths, and an empty value -// all resolve sensibly relative to the home directory. -func TestExpandedWorkingDir(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - - cases := []struct { - in string - want string - }{ - {"", home}, - {"~", home}, - {"~/code/x", filepath.Join(home, "code", "x")}, - {"$HOME/code/y", filepath.Join(home, "code", "y")}, - {"/srv/abs", "/srv/abs"}, - } - for _, c := range cases { - got := Project{WorkingDir: c.in}.expandedWorkingDir() - if got != c.want { - t.Fatalf("expandedWorkingDir(%q) = %q, want %q", c.in, got, c.want) - } - } -} diff --git a/old/www/content/docs/_index.md b/old/www/content/docs/_index.md deleted file mode 100644 index e49ff58..0000000 --- a/old/www/content/docs/_index.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -title: "Documentation" -description: "herdr-plus is a free, open-source extension platform for herdr — add Quick Actions and Projects to your terminal multiplexer." ---- - -herdr-plus is an add-on platform for [herdr](https://herdr.dev) — a place to build -extensions and plugins on top of herdr's terminal panes. It is free and open -source, built by [Cloudmanic Labs](https://github.com/cloudmanic/herdr-plus). - -## The mental model - -herdr-plus ships as a single binary. The same binary can run in different -**modes**, and each mode decides what to do when it talks to herdr. You bind a -mode to a herdr keybinding, press your prefix plus that key, and the mode springs -to life inside herdr. - -We're in explore mode: the list of modes will grow over time. Today there are -two. - -## Modes - -Pick a mode with `--mode=`. With no flag, the default mode (`control`) -runs. - -| Mode | Slug | Default key | What it does | -|------|------|-------------|--------------| -| Control | `control` (default) | `prefix+up` | herdr-plus's home base — a full-screen workspace for driving herdr. First feature: **Projects**. | -| Quick Actions | `quick-actions` | `prefix+down` | A fuzzy launcher: pick an action and run it in a split pane. | - -Each mode has its own default key, so the two can be installed side by side. - -## Where to start - -- **[Quick Start](quick-start/)** — the fastest path from zero to a working - keybinding. -- **[Installation](installation/)** — every install method (Homebrew, install - script, from source) and how upgrades work. -- **[Control Mode & Projects](projects/)** — declarative workspace templates that - spin up a whole herdr workspace of tabs and panes. -- **[Quick Actions](quick-actions/)** — the fuzzy launcher and per-project - actions. - -If you just want the reference, jump to -[Keybindings](keybindings/), [Modes](modes/), the -[Actions Reference](actions/), [Template Variables](variables/), -[Configuration](configuration/), the [Examples & Cookbook](examples/), or -[Troubleshooting](troubleshooting/). diff --git a/old/www/content/docs/configuration.md b/old/www/content/docs/configuration.md deleted file mode 100644 index b333795..0000000 --- a/old/www/content/docs/configuration.md +++ /dev/null @@ -1,91 +0,0 @@ ---- -title: "Configuration" -description: "The herdr-plus config directory layout: projects/, quick-actions/, the file-per-entry model, per-repo overrides, and XDG_CONFIG_HOME." -weight: 90 ---- - -All herdr-plus configuration lives under `~/.config/herdr-plus/`, honoring -`$XDG_CONFIG_HOME`. There's no central config file — everything is a file per -entry. - -## Directory layout - -```text -~/.config/herdr-plus/ - projects/ # one *.toml per project (control mode) - options-cafe.toml - bevio.toml - ... - quick-actions/ # one *.toml per action (quick-actions mode) - github.toml - google.toml - ... -``` - -- **`projects/`** holds your [project templates](../projects/) for control mode. - Each `*.toml` defines one project. This directory **starts empty** — control - mode's onboarding screen explains how to add your first one. -- **`quick-actions/`** holds your [quick actions](../quick-actions/). Each - `*.toml` defines one action. This directory is **seeded with editable - examples** the first time you run the mode. - -The per-mode subdirectory name is the mode's slug (`quick-actions`), so future -modes get their own folder. Projects hang directly off the config root (not under -a mode slug) because they're a first-class concept. - -## The file-per-entry model - -In both directories the rule is the same: **add a file to add an entry, delete a -file to remove it.** File names don't matter — only the contents. Entries are -sorted by their `name` in the UI. - -> **Important:** A malformed or invalid file fails the whole load for that -> directory, with an error naming the offending file. This is deliberate: a typo -> surfaces loudly instead of an entry silently going missing. - -### Seeding behavior - -- `quick-actions/` is seeded with bundled examples **only** when the directory - doesn't yet exist. Once it exists, herdr-plus leaves it alone — so deleting an - example won't make it reappear. -- `projects/` is never seeded. An empty directory is meaningful: it triggers - control mode's onboarding empty-state. herdr-plus only ever creates the empty - folder for you to drop files into. - -## Per-project (per-repo) overrides - -A repo can ship its own quick actions. Add a `.herdr-plus/` directory at the repo -root that mirrors the global layout, with one `*.toml` per action in its -`quick-actions/` subdirectory: - -```text -your-repo/ - .herdr-plus/ - quick-actions/ - make-build.toml - make-test.toml -``` - -When you launch the quick-actions picker from inside that repo, these actions -appear grouped under a `Project` heading above your `Global` ones. The directory -is **read-only and never auto-created** — it's read only when a repo actually -provides it. See [Quick Actions](../quick-actions/) for the full behavior. - -## `XDG_CONFIG_HOME` behavior - -herdr-plus follows the XDG convention: - -- If `XDG_CONFIG_HOME` is set, config lives in - `$XDG_CONFIG_HOME/herdr-plus/`. -- Otherwise it falls back to `~/.config/herdr-plus/`, so the location is the same - on macOS and Linux. - -> **Note:** herdr's own config (the `config.toml` that `herdr-plus install` -> writes to) follows the same rule under `herdr/` rather than `herdr-plus/`. See -> [Keybindings](../keybindings/). - -## See also - -- [Control Mode & Projects](../projects/) — the project file format. -- [Actions Reference](../actions/) — the action file format. -- [Examples & Cookbook](../examples/) — ready-to-copy files. diff --git a/old/www/content/docs/installation.md b/old/www/content/docs/installation.md deleted file mode 100644 index 825b262..0000000 --- a/old/www/content/docs/installation.md +++ /dev/null @@ -1,125 +0,0 @@ ---- -title: "Installation" -description: "Install herdr-plus via Homebrew, the install.sh one-liner, or from source — with supported platforms, install locations, and upgrades." -weight: 20 ---- - -herdr-plus is a single static binary. There are three ways to install it: -Homebrew, the install script, or from source. All of them are free and open -source. - -> **Note:** herdr-plus is an add-on for [herdr](https://herdr.dev). Install and -> set up herdr first — herdr-plus does its work by talking to a running herdr -> server. - -## Homebrew - -The herdr-plus repository is its own Homebrew tap. Tap it, then install: - -```bash -brew tap cloudmanic/herdr-plus https://github.com/cloudmanic/herdr-plus -brew install cloudmanic/herdr-plus/herdr-plus -``` - -To upgrade later: - -```bash -brew upgrade cloudmanic/herdr-plus/herdr-plus -``` - -## Install script - -A POSIX `sh` installer detects your OS and architecture, downloads the matching -archive from the latest GitHub Release, extracts the static binary, and drops it -into place. It works under plain `sh`, so it's fine on Alpine/BusyBox and minimal -SSH targets, not just bash. - -```bash -curl -fsSL https://raw.githubusercontent.com/cloudmanic/herdr-plus/main/install.sh | sh -``` - -Re-running the script performs an upgrade. - -### Environment overrides - -The script honors two environment variables: - -| Variable | Default | What it does | -|----------|---------|--------------| -| `INSTALL_DIR` | `~/.local/bin` (else `/usr/local/bin`) | Where the binary is installed. | -| `VERSION` | the latest GitHub Release | Pin a specific release tag to install. | - -Examples: - -```bash -# Install into a custom directory. -curl -fsSL https://raw.githubusercontent.com/cloudmanic/herdr-plus/main/install.sh | INSTALL_DIR=/opt/bin sh - -# Pin a specific version (tags are prefixed with "v"). -curl -fsSL https://raw.githubusercontent.com/cloudmanic/herdr-plus/main/install.sh | VERSION=v0.0.1 sh -``` - -## From source - -Clone the [repository](https://github.com/cloudmanic/herdr-plus) and build with -the Makefile: - -```bash -make build # build the herdr-plus binary -make install-bin # build, then install the binary onto your PATH -``` - -Or use the Go toolchain directly: - -```bash -go build -o herdr-plus . -go test ./... -``` - -## Supported platforms - -Releases are cross-compiled for: - -- **Operating systems:** Linux and macOS. -- **Architectures:** `amd64` (x86_64) and `arm64` (aarch64). - -The install script maps `uname` output onto those tokens and refuses to run on -anything else, telling you exactly what it detected. - -## Where the binary lands - -The install script chooses, in order: - -1. The directory in `INSTALL_DIR`, if you set it. -2. `~/.local/bin` — preferred, because it needs no `sudo`. -3. `/usr/local/bin` — the fallback, which may prompt for `sudo`. - -> **Tip:** If the chosen directory isn't on your `$PATH`, the script prints the -> exact `export PATH=...` line to add to your shell rc. Without that, your shell -> won't find the `herdr-plus` command. - -## Checking the version - -Confirm what's installed at any time: - -```bash -herdr-plus version -``` - -(`--version`, `-v`, and `-V` all work too.) It prints `herdr-plus` followed by -the release version. - -## How upgrades work - -Every merge to `main` auto-bumps the patch version and cuts a new GitHub Release -with cross-compiled binaries. To pull the latest: - -- **Homebrew:** `brew upgrade cloudmanic/herdr-plus/herdr-plus` -- **Install script:** re-run the `curl ... | sh` one-liner. -- **From source:** `git pull` and `make install-bin` again. - -## Next steps - -Once herdr-plus is on your PATH, bind it to a key: -[Keybindings](../keybindings/), then jump into the -[Quick Start](../quick-start/). diff --git a/old/www/content/docs/keybindings.md b/old/www/content/docs/keybindings.md deleted file mode 100644 index 1c70078..0000000 --- a/old/www/content/docs/keybindings.md +++ /dev/null @@ -1,108 +0,0 @@ ---- -title: "Keybindings" -description: "How herdr-plus install binds a herdr key to a mode: per-mode defaults, overriding the key, idempotency, and how to trigger an action." -weight: 30 ---- - -You launch herdr-plus by pressing a herdr keybinding. The `herdr-plus install` -command wires those bindings into herdr for you. - -## What `herdr-plus install` does - -A bare `herdr-plus install` installs **every mode at once**, each on its own -default key (control → `prefix+up`, quick-actions → `prefix+down`). For each one -it adds a `[[keys.command]]` entry to herdr's `config.toml`, then reloads the -running herdr server so the bindings are live immediately. Pass `--mode` to -install just a single mode. One entry looks like this: - -```toml -# herdr-plus — added by `herdr-plus install` -[[keys.command]] -key = "prefix+up" -type = "shell" -command = "'/absolute/path/to/herdr-plus' --mode=control" -description = "herdr-plus: control" -``` - -A few important details: - -- **It binds the absolute path of the binary you invoked.** That means the - keybinding works no matter where herdr-plus lives or what your current - directory is. If the binary was reached through a symlink, the symlink is - resolved to its real target first. -- **It writes to herdr's config.** That's `$XDG_CONFIG_HOME/herdr/config.toml` - if `XDG_CONFIG_HOME` is set, otherwise `~/.config/herdr/config.toml`. The file - (and its directory) are created if they don't exist. -- **It reloads herdr.** After writing, it runs `herdr server reload-config` so - you don't have to restart herdr. If the reload fails it tells you to run that - command yourself or restart herdr. - -### It's idempotent - -Running `install` again for the same mode won't duplicate the binding. herdr-plus -detects an existing binding that runs the exact same command (same binary, same -`--mode`) and simply reports where it lives instead of adding another. Because -each mode's command carries its own `--mode` flag, the two modes never collide -with each other — installing `control` doesn't trip over an existing -`quick-actions` binding. - -### It refuses to clobber other bindings - -If the key you're asking for is already bound to something *else*, `install` -stops and tells you what's there, suggesting you pick a different key with -`--key`. It never overwrites a binding it didn't create. - -## Per-mode default keys - -Each mode claims its own conventional key, so the two can be installed side by -side without conflicting: - -| Mode | Slug | Default key | -|------|------|-------------| -| Control | `control` | `prefix+up` | -| Quick Actions | `quick-actions` | `prefix+down` | - -When you run `install` for a single mode without `--key`, it uses that mode's -default. - -## Installing both, side by side - -A bare `install` does this in one shot — it walks every mode and binds each to -its default key: - -```bash -herdr-plus install # prefix+up -> control AND prefix+down -> quick-actions -``` - -Now `prefix+up` opens Control mode and `prefix+down` opens Quick Actions. (You -can still install them one at a time with `--mode` if you only want one.) - -## Overriding the key - -Pass `--key` to bind any herdr key you like, for any mode: - -```bash -herdr-plus install --key=prefix+a # control on prefix+a -herdr-plus install --mode=quick-actions --key=prefix+space -``` - -If the chosen key is taken, `install` will refuse and ask you to pick another. - -## The herdr prefix, and triggering an action - -herdr keybindings are *prefixed*: you press your herdr prefix (default -`ctrl+b`), release it, then press the bound key. So to launch a mode bound to -`prefix+up`: - -> Press `ctrl+b`, then press `up`. - -The `prefix+` part of the key name is herdr's placeholder for "whatever your -prefix is" — it isn't the literal text `prefix`. After a successful install, -herdr-plus prints the exact reminder, e.g. *"Press your prefix, then up, to -launch."* - -## See also - -- [Modes](../modes/) — the mode concept and the `--mode` flag. -- [Installation](../installation/) — getting the binary onto your PATH. -- [Troubleshooting](../troubleshooting/) — what to check when a key does nothing. diff --git a/old/www/content/docs/modes.md b/old/www/content/docs/modes.md deleted file mode 100644 index d7be0b7..0000000 --- a/old/www/content/docs/modes.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -title: "Modes" -description: "One herdr-plus binary, multiple modes. How --mode works, the available modes, and how to invoke each." -weight: 40 ---- - -herdr-plus is an add-on platform for [herdr](https://herdr.dev): the same binary -can be invoked many times in different herdr panes, and a `--mode` flag tells -each invocation what to do when it talks to herdr. We expect this list of modes -to grow. - -## How modes work - -One binary, multiple behaviors. You pick a mode with `--mode=`. With no -flag, the **default mode (`control`)** runs — it's the front door of herdr-plus, -so the bare binary lands there. - -An unrecognized slug is an error (so typos fail loudly instead of silently doing -the wrong thing). For example, `herdr-plus --mode=quikactions` exits with an -`unknown mode` message. - -## Available modes - -| Mode | Slug | Default key | What it does | -|------|------|-------------|--------------| -| Control | `control` (default) | `prefix+up` | herdr-plus's home base — a full-screen "Herdr Plus" workspace for driving herdr. First feature: **Projects**. | -| Quick Actions | `quick-actions` | `prefix+down` | A fuzzy launcher: pick an action and run it in a split pane. | - -Each mode has its own default key, so the two can be installed side by side. The -slug is also the name of the mode's per-mode config subdirectory under -`~/.config/herdr-plus/` (see [Configuration](../configuration/)). - -## Invoking herdr-plus - -```bash -herdr-plus # default mode (control) -herdr-plus --mode=quick-actions # the fuzzy launcher -herdr-plus version # print the version and exit -``` - -In day-to-day use you don't run these by hand — you bind them to a herdr key with -`herdr-plus install` and press the key instead. See [Keybindings](../keybindings/). - -## How adding a mode works - -herdr-plus is open source and designed to grow. Adding a mode is a small change -in the [repository](https://github.com/cloudmanic/herdr-plus): register a new -`Mode` value (giving it a slug, title, and default key) in `mode.go`, optionally -add bundled example actions under `examples//`, and teach the launcher how -the mode should present itself. If you'd like to build one, the repo is the place -to start. - -## Go deeper - -- [Control Mode & Projects](../projects/) — the full guide to control mode. -- [Quick Actions](../quick-actions/) — the full guide to the launcher. diff --git a/old/www/content/docs/quick-start.md b/old/www/content/docs/quick-start.md deleted file mode 100644 index b7c2b1d..0000000 --- a/old/www/content/docs/quick-start.md +++ /dev/null @@ -1,70 +0,0 @@ ---- -title: "Quick Start" -description: "Go from zero to a working herdr-plus keybinding in four steps: install herdr, install herdr-plus, bind a key, press it." -weight: 10 ---- - -This is the fastest path from zero to a working herdr-plus keybinding. Four -steps, a couple of minutes. - -## 1. Make sure herdr is installed and running - -herdr-plus is an add-on for [herdr](https://herdr.dev). You need a working herdr -install first — follow the [herdr install guide](https://herdr.dev) — and you -need to be running inside a herdr session, because herdr-plus talks to the -running herdr server over a local socket. - -> **Note:** herdr-plus only does something useful from inside herdr. If you run -> it outside herdr it can't find a pane to work with. - -## 2. Install herdr-plus - -Pick whichever you prefer. **Homebrew** (the repo is its own tap): - -```bash -brew tap cloudmanic/herdr-plus https://github.com/cloudmanic/herdr-plus -brew install cloudmanic/herdr-plus/herdr-plus -``` - -**Install script** (Linux/macOS, no Homebrew): - -```bash -curl -fsSL https://raw.githubusercontent.com/cloudmanic/herdr-plus/main/install.sh | sh -``` - -See [Installation](../installation/) for from-source builds, install-location -overrides, and how upgrades work. - -## 3. Install the keybindings - -`herdr-plus install` wires herdr-plus into herdr's `config.toml` as keybindings -and reloads the running herdr server, so the bindings are live immediately. A -bare install binds **every mode at once**, each on its own default key: - -```bash -herdr-plus install # binds prefix+up -> control AND prefix+down -> quick-actions -herdr-plus install --mode=quick-actions # bind just quick-actions (prefix+down) -``` - -Each mode claims its own default key, so the two coexist. Override a single -mode's key with `--key=prefix+a`. See [Keybindings](../keybindings/) for the full -story. - -## 4. Press your prefix, then the key - -In herdr, press your prefix (default `ctrl+b`) followed by the bound key: - -- `prefix` then `up` opens **Control mode** — a full-screen "Herdr Plus" - workspace with the Projects browser. -- `prefix` then `down` opens the **Quick Actions** launcher — a fuzzy finder in a - split beneath your current pane. - -That's it. The first time you open Quick Actions, herdr-plus seeds your config -with editable example actions so you have something to try right away. - -## Next steps - -- [Control Mode & Projects](../projects/) — build workspace templates. -- [Quick Actions](../quick-actions/) — the launcher and per-project actions. -- [Actions Reference](../actions/) — write your own actions. -- [Configuration](../configuration/) — where everything lives on disk. diff --git a/old/www/content/docs/troubleshooting.md b/old/www/content/docs/troubleshooting.md deleted file mode 100644 index 35948d8..0000000 --- a/old/www/content/docs/troubleshooting.md +++ /dev/null @@ -1,127 +0,0 @@ ---- -title: "Troubleshooting & FAQ" -description: "Concrete fixes for the common herdr-plus issues: dead keybindings, command not found, key conflicts, config not loading, and template errors." -weight: 110 ---- - -Concrete answers to the things that go wrong. Each item lists what to check, in -order. - -## Nothing happens when I press the key - -Work through these: - -1. **Did you run `herdr-plus install`?** The key only does something after you - bind it. Run `herdr-plus install` (control) and/or - `herdr-plus install --mode=quick-actions`. -2. **Are you inside herdr?** herdr-plus talks to a running herdr server over a - local socket. If you're not in a herdr session, there's no pane to act on. -3. **Did the binding reload?** `install` runs `herdr server reload-config` for - you, but if that step failed it told you so. Run `herdr server reload-config` - yourself, or restart herdr. -4. **Are you pressing the prefix correctly?** herdr keybindings are prefixed: - press your prefix (default `ctrl+b`), release, then press the bound key (e.g. - `up`). See [Keybindings](../keybindings/). - -## "command not found: herdr-plus" - -The binary isn't on your `$PATH`. - -- The install script installs to `~/.local/bin` (preferred) or `/usr/local/bin`. - If that directory isn't on your `$PATH`, the script printed the exact - `export PATH=...` line to add to your shell rc — add it and restart your shell. -- You can also pass `INSTALL_DIR=...` to the script to install somewhere already - on your PATH. See [Installation](../installation/). - -> **Note:** The *keybinding* itself uses the binary's **absolute path**, so a -> bound key works even when `herdr-plus` isn't on your PATH. The "command not -> found" error only bites when you type `herdr-plus` at a prompt. - -## "key is already bound to: ..." - -`install` refuses to overwrite a key that's bound to something else — it never -clobbers a binding it didn't create. Pick a different key: - -```bash -herdr-plus install --key=prefix+a -``` - -If you really want that key, remove the conflicting `[[keys.command]]` entry from -herdr's `config.toml` first, then re-run `install`. - -> **Tip:** Re-running `install` for a mode that's already bound is safe — it -> won't duplicate the binding, it just reports where the existing one lives. - -## My config / action / project isn't picked up - -1. **Right directory?** Quick actions go in - `~/.config/herdr-plus/quick-actions/`; projects go in - `~/.config/herdr-plus/projects/`. Per-project quick actions go in a repo's - `.herdr-plus/quick-actions/`. -2. **`XDG_CONFIG_HOME` set?** If it is, config lives under - `$XDG_CONFIG_HOME/herdr-plus/`, not `~/.config/herdr-plus/`. See - [Configuration](../configuration/). -3. **Is the file `*.toml`?** Only files ending in `.toml` are loaded. -4. **Is the TOML valid?** A malformed or invalid file fails the **whole** load - for that directory, with an error naming the offending file. herdr-plus leaves - the pane open so you can read the error. Fix the named file. -5. **Required fields present?** Actions need a `name` and a non-empty `command`; - `select` actions need at least one option with a label. Projects need a `name` - and at least one `[[tabs]]` entry, and each tab needs a `name`. - -## My per-project actions don't show up - -1. **`.herdr-plus/quick-actions/` at the repo root?** The directory must mirror - the global layout and sit at the root of the repo. -2. **Launched from inside the repo?** Project actions appear only when the pane's - working directory is inside that repo — that's the launch directory - herdr-plus uses. -3. **The directory is never auto-created.** herdr-plus won't make it for you; the - repo has to provide it. - -See [Quick Actions](../quick-actions/) for the full behavior. - -## A project's working directory error - -Opening a project fails with "working directory does not exist" when its -`working_dir` doesn't resolve to a real directory on this machine. The path is -checked at open time, not load time, so the same file can be valid elsewhere. -Fix the `working_dir` (remember `~` and `$VARS` expand). See -[Control Mode & Projects](../projects/). - -## Template errors in a command - -The `command` is a Go `text/template`. A bad field name or malformed `{{...}}` -produces a parse or render error when the action runs (printed to stderr). - -- Check your field names against [Template Variables](../variables/) — they're - case-sensitive (`{{.WorkDir}}`, not `{{.workdir}}`). -- Make sure braces are balanced: `{{.Value}}`, not `{{.Value}` or `{.Value}}`. - -## My action's output flashed by before I could read it - -The quick-actions pane closes itself once the command finishes. To hold it open, -end the command with a wait that reads the terminal directly (its stdin is -`/dev/null`): - -```text -read _ {{ .Site.Params.description }} - -herdr-plus is an open-source add-on platform for herdr ({{ .Site.Params.herdrUrl }}), a terminal multiplexer and agent runtime. The same small binary runs in different *modes*; each mode adds a capability on top of herdr. Today it ships two: **Control** (the home base, whose first feature is **Projects** — declarative workspace templates) and **Quick Actions** (a fuzzy launcher that runs an action in a split pane). Configuration is plain TOML files under ~/.config/herdr-plus/. It is free and MIT-licensed, by {{ .Site.Params.company }}. - -## Documentation -{{ range $docs.RegularPages.ByWeight -}} -- [{{ .Title }}]({{ .Permalink }}): {{ .Description }} -{{ end }} -## Full text for agents -- [llms-full.txt]({{ "llms-full.txt" | absURL }}): The complete documentation inlined as a single plain-text file. - -## Project links -- [Documentation home]({{ $docs.Permalink }}): Human-readable docs. -- [GitHub repository]({{ .Site.Params.githubUrl }}): Source code, issues, releases. -- [Releases]({{ .Site.Params.releasesUrl }}): Versioned downloads and changelog. -- [herdr]({{ .Site.Params.herdrUrl }}): The terminal herdr-plus extends. -- [Cloudmanic Labs]({{ .Site.Params.cloudmanicUrl }}): The maintainer. - -## Install -- Homebrew: `{{ .Site.Params.brewTapCmd }}` then `{{ .Site.Params.brewInstallCmd }}` -- Script: `{{ .Site.Params.scriptInstallCmd }}` -- Bind keys: `herdr-plus install` (Control → prefix+up) and `herdr-plus install --mode=quick-actions` (Quick Actions → prefix+down) diff --git a/old/www/layouts/partials/install-box.html b/old/www/layouts/partials/install-box.html deleted file mode 100644 index 1de672a..0000000 --- a/old/www/layouts/partials/install-box.html +++ /dev/null @@ -1,21 +0,0 @@ -{{- /* install-box.html — segmented Homebrew / install-script picker with - copyable commands. Call: {{ partial "install-box.html" (dict "id" "hero" "ctx" .) }} - .id must be unique per page so multiple boxes don't share tab state. */ -}} -{{- $id := .id -}} -{{- $p := .ctx.Site.Params -}} -
-
- - -
- -
- {{ partial "codebox.html" (dict "cmd" $p.brewTapCmd) }} - {{ partial "codebox.html" (dict "cmd" $p.brewInstallCmd) }} -
- - -
diff --git a/old/www/.gitignore b/www/.gitignore similarity index 100% rename from old/www/.gitignore rename to www/.gitignore diff --git a/old/www/assets/css/app.css b/www/assets/css/app.css similarity index 100% rename from old/www/assets/css/app.css rename to www/assets/css/app.css diff --git a/www/content/docs/_index.md b/www/content/docs/_index.md new file mode 100644 index 0000000..1e1e136 --- /dev/null +++ b/www/content/docs/_index.md @@ -0,0 +1,41 @@ +--- +title: "Documentation" +description: "herdr-plus is a free, open-source herdr plugin that adds Quick Actions and Projects to your terminal multiplexer." +--- + +herdr-plus is an add-on for [herdr](https://herdr.dev), built as a first-class +[herdr plugin](https://herdr.dev/docs/plugins/). It is free and open source, built +by [Cloudmanic Labs](https://github.com/cloudmanic/herdr-plus). + +## The mental model + +You install herdr-plus once with `herdr plugin install`. herdr clones the repo, +builds it, and registers the plugin's **actions** with herdr. From then on you run +those actions — from herdr's action menu, or from a key you bind to them — and they +spring to life inside herdr. + +Two actions ship today: + +| Action id | What it does | +|-----------|--------------| +| `cloudmanic.herdr-plus.projects` | Opens a full-screen fuzzy browser of your **Projects** — declarative templates that spin up a whole herdr workspace. | +| `cloudmanic.herdr-plus.quick-actions` | Opens the **Quick Actions** launcher — a fuzzy finder that runs a one-off action in the directory you launched from. | + +Everything herdr-plus does is driven by plain TOML files you own, kept in herdr's +managed plugin config directory. We expect the list of features to grow. + +## Where to start + +- **[Quick Start](quick-start/)** — the fastest path from zero to a working + keybinding. +- **[Installation](installation/)** — installing the plugin, the optional + standalone binary, and how upgrades work. +- **[Projects](projects/)** — declarative workspace templates that spin up a whole + herdr workspace of tabs and panes. +- **[Quick Actions](quick-actions/)** — the fuzzy launcher and per-project actions. + +If you just want the reference, jump to +[Keybindings](keybindings/), the +[Actions Reference](actions/), [Template Variables](variables/), +[Configuration](configuration/), the [Examples & Cookbook](examples/), or +[Troubleshooting](troubleshooting/). diff --git a/old/www/content/docs/actions.md b/www/content/docs/actions.md similarity index 96% rename from old/www/content/docs/actions.md rename to www/content/docs/actions.md index 131c071..b621be6 100644 --- a/old/www/content/docs/actions.md +++ b/www/content/docs/actions.md @@ -4,8 +4,9 @@ description: "The complete action file format: the command, command/select/form weight: 70 --- -An action is one entry in the quick-actions picker, loaded from a TOML file in -the mode's config directory. This page documents the complete file format. +An action is one entry in the Quick Actions picker, loaded from a TOML file in +herdr-plus's `quick-actions/` config directory (see +[Configuration](../configuration/)). This page documents the complete file format. ## The basics diff --git a/www/content/docs/configuration.md b/www/content/docs/configuration.md new file mode 100644 index 0000000..f7dd66d --- /dev/null +++ b/www/content/docs/configuration.md @@ -0,0 +1,88 @@ +--- +title: "Configuration" +description: "The herdr-plus config directory: herdr's managed plugin dir, the projects/ and quick-actions/ subdirs, the file-per-entry model, and per-repo overrides." +weight: 90 +--- + +herdr-plus keeps its configuration in herdr's **managed plugin directory**. There's +no central config file — everything is a file per entry. + +## Finding the config directory + +Ask herdr where it is: + +```bash +herdr plugin config-dir cloudmanic.herdr-plus +# → ~/.config/herdr/plugins/config/cloudmanic.herdr-plus +``` + +herdr provisions this directory for the plugin and **keeps it across uninstall and +upgrade**, so your projects and quick actions survive a reinstall. + +> **Running outside herdr:** if you run the `herdr-plus` binary directly (not +> through herdr), there's no managed directory to use, so it falls back to +> `~/.config/herdr-plus/`, honoring `$XDG_CONFIG_HOME`. Inside herdr — the normal +> case — the managed directory above always wins. + +## Directory layout + +```text +/ + projects/ # one *.toml per project + options-cafe.toml + bevio.toml + ... + quick-actions/ # one *.toml per action + github.toml + google.toml + ... +``` + +- **`projects/`** holds your [project templates](../projects/). Each `*.toml` + defines one project. This directory **starts empty** — the Projects browser's + onboarding screen explains how to add your first one. +- **`quick-actions/`** holds your [quick actions](../quick-actions/). Each `*.toml` + defines one action. This directory is **seeded with editable examples** the first + time you open the launcher. + +## The file-per-entry model + +In both directories the rule is the same: **add a file to add an entry, delete a +file to remove it.** File names don't matter — only the contents. Entries are +sorted by their `name` in the UI. + +> **Important:** A malformed or invalid file fails the whole load for that +> directory, with an error naming the offending file. This is deliberate: a typo +> surfaces loudly instead of an entry silently going missing. + +### Seeding behavior + +- `quick-actions/` is seeded with bundled examples **only** when the directory + doesn't yet exist. Once it exists, herdr-plus leaves it alone — so deleting an + example won't make it reappear. +- `projects/` is never seeded. An empty directory is meaningful: it triggers the + Projects onboarding empty-state. + +## Per-project (per-repo) overrides + +A repo can ship its own quick actions. Add a `.herdr-plus/` directory at the repo +root with one `*.toml` per action in its `quick-actions/` subdirectory: + +```text +your-repo/ + .herdr-plus/ + quick-actions/ + make-build.toml + make-test.toml +``` + +When you launch the Quick Actions picker from inside that repo, these actions +appear grouped under a `Project` heading above your `Global` ones. The directory is +**read-only and never auto-created** — it's read only when a repo actually provides +it. See [Quick Actions](../quick-actions/) for the full behavior. + +## See also + +- [Projects](../projects/) — the project file format. +- [Actions Reference](../actions/) — the action file format. +- [Examples & Cookbook](../examples/) — ready-to-copy files. diff --git a/old/www/content/docs/examples.md b/www/content/docs/examples.md similarity index 95% rename from old/www/content/docs/examples.md rename to www/content/docs/examples.md index 0859048..f7f2415 100644 --- a/old/www/content/docs/examples.md +++ b/www/content/docs/examples.md @@ -4,9 +4,10 @@ description: "A copy-pasteable gallery of complete quick actions and project tem weight: 100 --- -A gallery of complete, copy-pasteable examples. Drop a quick action into -`~/.config/herdr-plus/quick-actions/`, or a project into -`~/.config/herdr-plus/projects/` — one file per entry, file name up to you. +A gallery of complete, copy-pasteable examples. Drop a quick action into the +`quick-actions/` subdirectory of [herdr-plus's config dir](../configuration/), or a +project into `projects/` — one file per entry, file name up to you. Find the +directory with `herdr plugin config-dir cloudmanic.herdr-plus`. ## Quick actions @@ -256,5 +257,5 @@ command = "nvim ." ## See also - [Actions Reference](../actions/) — the full action format. -- [Control Mode & Projects](../projects/) — the full project schema. +- [Projects](../projects/) — the full project schema. - [Template Variables](../variables/) — context you can use in commands. diff --git a/www/content/docs/installation.md b/www/content/docs/installation.md new file mode 100644 index 0000000..5b9d68c --- /dev/null +++ b/www/content/docs/installation.md @@ -0,0 +1,109 @@ +--- +title: "Installation" +description: "Install herdr-plus as a herdr plugin with herdr plugin install — plus the optional standalone binary, supported platforms, and how upgrades work." +weight: 20 +--- + +herdr-plus is a [herdr plugin](https://herdr.dev/docs/plugins/). Installing it is +one command — herdr does the rest. + +> **Note:** herdr-plus is an add-on for [herdr](https://herdr.dev) and requires +> **herdr ≥ 0.7.0**. Install and set up herdr first — herdr-plus does its work by +> talking to a running herdr server. + +## Install the plugin + +```bash +herdr plugin install cloudmanic/herdr-plus +``` + +herdr clones the repository, runs the manifest's build step, and registers the +plugin's actions. The build step **prefers a local Go toolchain** (an exact build +of the cloned source) and **falls back to downloading the latest prebuilt release +binary**, so it works **with or without Go**. + +After it finishes, manage the plugin with: + +```bash +herdr plugin list # confirm it's registered +herdr plugin action list --plugin cloudmanic.herdr-plus # see its actions +herdr plugin uninstall cloudmanic.herdr-plus # remove it +``` + +> Uninstalling removes the plugin's clone and registration but **preserves your +> config directory**, so your projects and quick actions survive a reinstall. + +## How upgrades work + +Re-running the install command **is** the upgrade — herdr re-clones, rebuilds, and +re-registers in place: + +```bash +herdr plugin install cloudmanic/herdr-plus +``` + +Every merge to `main` cuts a new release, so a re-install always pulls the latest. +You can pin a specific ref with `--ref ` if you need a particular version. + +## Local development + +To hack on herdr-plus, build the binary and link your checkout in place instead of +installing from GitHub: + +```bash +make build +herdr plugin link /path/to/herdr-plus # or: make plugin-link +``` + +herdr then runs the freshly built `./bin/herdr-plus` for the plugin's actions. +Undo with `herdr plugin unlink cloudmanic.herdr-plus`. + +## The optional standalone binary + +You don't need the `herdr-plus` binary on your `PATH` — the plugin install handles +everything. But if you'd like it there (for example to run `herdr-plus version`), +prebuilt binaries are published on every release. + +**Homebrew** — the repository is its own tap: + +```bash +brew tap cloudmanic/herdr-plus https://github.com/cloudmanic/herdr-plus +brew install cloudmanic/herdr-plus/herdr-plus +``` + +**Install script** (Linux/macOS, no Homebrew). It detects your OS/arch, downloads +the matching archive from the latest GitHub Release, and drops the static binary +into place: + +```bash +curl -fsSL https://raw.githubusercontent.com/cloudmanic/herdr-plus/main/install.sh | sh +``` + +> The standalone binary on its own does **not** register the plugin with herdr — +> use `herdr plugin install` (above) for that. + +### Install-script overrides + +| Variable | Default | What it does | +|----------|---------|--------------| +| `INSTALL_DIR` | `~/.local/bin` (else `/usr/local/bin`) | Where the binary is installed. | +| `VERSION` | the latest GitHub Release | Pin a specific release tag to install. | + +## Supported platforms + +Releases are cross-compiled for **Linux** and **macOS** on `amd64` (x86_64) and +`arm64` (aarch64). herdr-plus is not tested on Windows. + +## Checking the version + +```bash +herdr-plus version +``` + +(`--version`, `-v`, and `-V` all work too.) + +## Next steps + +With the plugin installed, run an action from herdr's action menu, or +[bind it to a key](../keybindings/), then jump into the +[Quick Start](../quick-start/). diff --git a/www/content/docs/keybindings.md b/www/content/docs/keybindings.md new file mode 100644 index 0000000..a6e9311 --- /dev/null +++ b/www/content/docs/keybindings.md @@ -0,0 +1,64 @@ +--- +title: "Keybindings" +description: "Bind herdr keys to herdr-plus's plugin actions with [[keys.command]] entries (type = plugin_action), or just run them from herdr's action menu." +weight: 30 +--- + +herdr-plus registers two herdr **actions**: + +| Action id | Opens | +|-----------|-------| +| `cloudmanic.herdr-plus.projects` | The Projects browser | +| `cloudmanic.herdr-plus.quick-actions` | The Quick Actions launcher | + +You can run either one **from herdr's action menu** without binding anything. If +you'd rather trigger them with a keystroke, bind each to a key. + +## Binding a key + +Keybindings are a one-time edit to **your** herdr `config.toml` — herdr-plus never +touches it. Add a `[[keys.command]]` entry with `type = "plugin_action"` whose +`command` is the action id: + +```toml +[[keys.command]] +key = "prefix+up" +type = "plugin_action" +command = "cloudmanic.herdr-plus.projects" +description = "herdr-plus: projects" + +[[keys.command]] +key = "prefix+down" +type = "plugin_action" +command = "cloudmanic.herdr-plus.quick-actions" +description = "herdr-plus: quick actions" +``` + +The keys above (`prefix+up` / `prefix+down`) are just a convention — bind whatever +you like. + +### Where the config lives + +herdr reads `$XDG_CONFIG_HOME/herdr/config.toml` if `XDG_CONFIG_HOME` is set, +otherwise `~/.config/herdr/config.toml`. After editing it, reload so the bindings +go live: + +```bash +herdr server reload-config # or just restart herdr +``` + +## The herdr prefix + +herdr keybindings are *prefixed*: you press your herdr prefix (default `ctrl+b`), +release it, then press the bound key. So to launch the action bound to `prefix+up`: + +> Press `ctrl+b`, then press `up`. + +The `prefix+` part of the key name is herdr's placeholder for "whatever your prefix +is" — it isn't the literal text `prefix`. + +## See also + +- [Installation](../installation/) — installing the plugin. +- [Quick Start](../quick-start/) — the four-step path from zero. +- [Troubleshooting](../troubleshooting/) — what to check when a key does nothing. diff --git a/old/www/content/docs/projects.md b/www/content/docs/projects.md similarity index 79% rename from old/www/content/docs/projects.md rename to www/content/docs/projects.md index 3825173..aa82539 100644 --- a/old/www/content/docs/projects.md +++ b/www/content/docs/projects.md @@ -1,45 +1,47 @@ --- -title: "Control Mode & Projects" -description: "Control mode opens a full-screen Herdr Plus workspace with the Projects browser — declarative templates that spin up a whole herdr workspace." +title: "Projects" +description: "Projects are declarative herdr workspace templates — pick one from a full-screen fuzzy browser and herdr-plus spins up a whole workspace of tabs and panes." weight: 50 --- -Control mode is herdr-plus's home base. Today its one feature is **Projects** — -declarative herdr workspace templates that spin up a fully laid-out workspace -with a single keypress. +**Projects** are declarative herdr workspace templates that spin up a fully +laid-out workspace with a single keypress. -## What Control mode does +## What it does -Pressing `prefix+up` opens a brand-new, full-screen herdr workspace titled -**Herdr Plus** with a tab named `projects`, and runs the projects browser there. -This is control mode — over time it will gain more features; today it has -Projects. +Trigger the `cloudmanic.herdr-plus.projects` action — from herdr's action menu, or +a [bound key](../keybindings/) — and herdr-plus opens a **full-screen fuzzy +browser** of your projects. -Fuzzy-find a project, press `enter` (or click it), and herdr-plus spins up a -whole workspace — every tab created and every command running — then closes the -ephemeral "Herdr Plus" workspace so you land directly in your new project. -Cancel with `esc` and the ephemeral workspace is torn down, returning you to -where you were. +Fuzzy-find a project, press `enter` (or click it), and herdr-plus spins up a whole +workspace — every tab created, every split laid out, and every startup command +running — then drops you straight into it. Cancel with `esc` to close the browser +and return to where you were. -> **Note:** With no project files yet, control mode shows an onboarding screen +> **Note:** With no project files yet, the browser shows an onboarding screen > explaining how to add your first project. ## What a project is A **project** is a declarative herdr workspace template: a name, a description, a working directory, and an ordered list of tabs (each with an optional startup -command, or a set of split panes). It replaces hand-written workspace shell -scripts with a simple config file. +command, or a set of split panes). It replaces hand-written workspace shell scripts +with a simple config file. ## Where projects live -Projects live in `~/.config/herdr-plus/projects/` (honoring `$XDG_CONFIG_HOME`), -**one TOML file per project**. The file name doesn't matter — only its contents. +Projects live in the `projects/` subdirectory of +[herdr-plus's config directory](../configuration/), **one TOML file per project**. +The file name doesn't matter — only its contents. Find the directory with: + +```bash +herdr plugin config-dir cloudmanic.herdr-plus +``` The directory **starts empty**: unlike quick-actions, it is never seeded with examples, because an empty directory is meaningful (it triggers the onboarding -screen). To add a project, drop a `.toml` file in. To remove a project, delete -its file. +screen). To add a project, drop a `.toml` file in. To remove a project, delete its +file. ## The project schema @@ -165,7 +167,7 @@ command = "spiceedit" How the browser lays out: - **Headings appear only when used.** If no project sets a `group`, the browser - is a plain, flat list exactly as it was before — nothing changes. + is a plain, flat list — nothing changes. - **Named groups come first**, in case-insensitive alphabetical order by group name. A client's projects keep their usual name order under the heading. - **Group-less projects** fall under a catch-all **Ungrouped** heading at the @@ -177,8 +179,8 @@ How the browser lays out: The model is one file per project: -- **Add a project** — drop a new `.toml` file into - `~/.config/herdr-plus/projects/`. +- **Add a project** — drop a new `.toml` file into the `projects/` subdirectory of + [herdr-plus's config dir](../configuration/). - **Remove a project** — delete its file. Projects are sorted by name in the browser, and clustered under group headings @@ -189,7 +191,6 @@ missing. ## See also -- [Configuration](../configuration/) — the full directory layout and XDG - behavior. +- [Configuration](../configuration/) — the full directory layout. - [Examples & Cookbook](../examples/) — ready-to-copy project files. -- [Quick Actions](../quick-actions/) — the other mode. +- [Quick Actions](../quick-actions/) — the other feature. diff --git a/old/www/content/docs/quick-actions.md b/www/content/docs/quick-actions.md similarity index 54% rename from old/www/content/docs/quick-actions.md rename to www/content/docs/quick-actions.md index 754d587..4e1f493 100644 --- a/old/www/content/docs/quick-actions.md +++ b/www/content/docs/quick-actions.md @@ -1,39 +1,39 @@ --- -title: "Quick Actions Mode" -description: "The fuzzy launcher: pick an action and run it in a split. Covers global actions and per-project actions shipped in a repo's .herdr-plus directory." +title: "Quick Actions" +description: "The fuzzy launcher: pick an action and run it in the directory you launched from. Covers global actions and per-project actions shipped in a repo's .herdr-plus directory." weight: 60 --- -Quick Actions is a fuzzy launcher. Press `prefix+down`, type a few characters to -filter, pick an action, and it runs in a split pane. +**Quick Actions** is a fuzzy launcher. Trigger it, type a few characters to filter, +pick an action, and it runs — in the directory you launched from. ## What it does -Pressing `prefix+down` opens a focused split *beneath* your current pane and runs -the picker there. The picker is a fuzzy finder over your actions: +Trigger the `cloudmanic.herdr-plus.quick-actions` action — from herdr's action +menu, or a [bound key](../keybindings/) — and herdr-plus opens a focused launcher +over your workspace. The launcher is a fuzzy finder over your actions: - `↑`/`↓` (or `ctrl+p`/`ctrl+n`, or the mouse wheel) move the highlight. - Type to filter the list. - `enter` or a left-click runs the highlighted action. - `esc` (or `ctrl+c`) cancels. -When you choose an action, the picker runs it in that split, then **closes its -own pane** so focus returns to where you launched from. (The launch directory is -the working directory of the pane you launched from, so actions run in the right -place.) +When you choose an action, herdr-plus runs it and then **closes the launcher**, +handing focus back to where you were. The command runs in the **working directory +of the pane you launched from**, so actions act on the right place. -> **Tip:** A fast command's output would flash by before the pane closes. To hold -> the pane open so you can read it, end the command with a wait — see the -> [Actions Reference](../actions/) and [Examples](../examples/) for the -> keep-open patterns (`read **Tip:** A fast command's output would flash by before the launcher closes. To +> hold it open so you can read it, end the command with a wait — see the +> [Actions Reference](../actions/) and [Examples](../examples/) for the keep-open +> patterns (`read - {{ partial "codebox.html" (dict "cmd" $p.scriptInstallCmd) }} -

Open source · Free forever · macOS & Linux · no Electron

+ {{ partial "codebox.html" (dict "cmd" $p.pluginInstallCmd) }} +

A first-class herdr plugin · Open source · Free forever · macOS & Linux

@@ -56,11 +56,11 @@

@@ -115,8 +116,8 @@

It’s a plugin layer for your terminal. {{ partial "icon.html" (dict "name" "sparkle" "class" "h-5 w-5") }}
-

More modes soon

-

A plugin system that keeps growing.

+

More to come soon

+

A herdr plugin, designed to grow.

@@ -169,14 +170,15 @@

It’s a plugin layer for your terminal.{{ partial "icon.html" (dict "name" "bolt" "class" "h-4 w-4") }} Quick Actions

A fuzzy launcher in any tab.

- Hit prefix and a focused split opens beneath your pane. - Fuzzy-find an action, press enter, and it runs — then the split closes itself. Your hands never leave the keyboard. + Hit prefix and a focused launcher opens over your workspace. + Fuzzy-find an action, press enter, and it runs in the directory you launched from — then the launcher closes itself + and hands focus back. Your hands never leave the keyboard.

  • {{ partial "icon.html" (dict "name" "check" "class" "h-4 w-4") }}Three action types — run a command, pick from a select list, or type into a form.
  • {{ partial "icon.html" (dict "name" "check" "class" "h-4 w-4") }}Per-project actions — drop a .herdr-plus/ folder in a repo and its actions appear, grouped, only there.
  • {{ partial "icon.html" (dict "name" "check" "class" "h-4 w-4") }}Context-aware — commands are Go templates with {{ "{{.WorkDir}}" }}, {{ "{{.Value}}" }}, and more, also exported as HERDR_PLUS_* env vars.
  • -
  • {{ partial "icon.html" (dict "name" "check" "class" "h-4 w-4") }}Just files — one TOML per action in ~/.config/herdr-plus/. Add a file, get an action.
  • +
  • {{ partial "icon.html" (dict "name" "check" "class" "h-4 w-4") }}Just files — one TOML per action in herdr-plus's managed config dir. Add a file, get an action.
Quick Actions docs {{ partial "icon.html" (dict "name" "arrow-right" "class" "ml-1 h-4 w-4") }}
@@ -199,7 +201,7 @@

Whole workspaces from one file.

  • {{ partial "icon.html" (dict "name" "check" "class" "h-4 w-4") }}Split panes — up to four panes per tab, stacked down or side-by-side right.
  • {{ partial "icon.html" (dict "name" "check" "class" "h-4 w-4") }}Paths that expand~ and $VARS resolve in working_dir.
  • {{ partial "icon.html" (dict "name" "check" "class" "h-4 w-4") }}Group by client — tag projects with a group and the browser clusters them under headings; the rest fall under Ungrouped.
  • -
  • {{ partial "icon.html" (dict "name" "check" "class" "h-4 w-4") }}One file per project — kept in ~/.config/herdr-plus/projects/. Versionable, shareable, yours.
  • +
  • {{ partial "icon.html" (dict "name" "check" "class" "h-4 w-4") }}One file per project — kept in the projects/ folder of herdr-plus's config dir. Versionable, shareable, yours.
  • Projects docs {{ partial "icon.html" (dict "name" "arrow-right" "class" "ml-1 h-4 w-4") }} @@ -230,10 +232,10 @@

    Small, fast, and entirely yours.

    {{ $features := slice (dict "icon" "terminal" "title" "Plain TOML, no UI" "body" "Every action and project is a file you can read, edit, version, and share. No databases, no dashboards.") (dict "icon" "bolt" "title" "Keyboard-native" "body" "Bound to your herdr prefix. Fuzzy-find, run, done — the launcher closes itself when the action fires.") - (dict "icon" "puzzle" "title" "A modes system" "body" "One binary, many modes. Control and Quick Actions today; the plugin surface is designed to grow.") + (dict "icon" "puzzle" "title" "A real herdr plugin" "body" "Installed with herdr plugin install — it registers its own herdr actions and panes, and it's built to grow.") (dict "icon" "layers" "title" "Per-repo extensions" "body" "Ship a .herdr-plus/ folder with a repo and its actions show up — grouped and scoped — only inside it.") - (dict "icon" "check" "title" "Idempotent install" "body" "herdr-plus install wires the keybinding and reloads herdr. It won’t duplicate or clobber an existing binding.") - (dict "icon" "sparkle" "title" "Free & open source" "body" "MIT-licensed, no telemetry, no lock-in. Auto-updates via Homebrew or the install script on every release.") + (dict "icon" "check" "title" "One-command install" "body" "herdr plugin install clones, builds, and registers it — with or without Go. Re-run the same command to upgrade in place.") + (dict "icon" "sparkle" "title" "Free & open source" "body" "MIT-licensed, no telemetry, no lock-in. Re-run herdr plugin install to pull the latest on any release.") }} {{ range $features }}
    @@ -260,19 +262,19 @@

    Up and running in under a minute.

    -
    1

    Install the binary

    -

    Homebrew or a one-line script — your pick.

    +
    1

    Install the plugin

    +

    One command — herdr clones, builds, and registers it.

    {{ partial "install-box.html" (dict "id" "install-section" "ctx" .) }}
    -
    2

    Bind the keys

    -

    Wire the keybindings into herdr (it reloads automatically).

    -
    - {{ partial "codebox.html" (dict "cmd" "herdr-plus install") }} - {{ partial "codebox.html" (dict "cmd" "herdr-plus install --mode=quick-actions") }} +
    2

    Bind keys (optional)

    +

    Run either action from herdr's action menu anytime — or bind a key. Add two [[keys.command]] entries (type = "plugin_action") to your herdr config.toml:

    +
    +
    …projectsprefix
    +
    …quick-actionsprefix
    -

    Control → prefix · Quick Actions → prefix

    + How to bind keys {{ partial "icon.html" (dict "name" "arrow-right" "class" "ml-1 h-4 w-4") }}
    diff --git a/www/layouts/index.llms.txt b/www/layouts/index.llms.txt new file mode 100644 index 0000000..689c162 --- /dev/null +++ b/www/layouts/index.llms.txt @@ -0,0 +1,27 @@ +{{- /* index.llms.txt — concise, link-rich index for LLMs (llms.txt convention). */ -}} +{{- $docs := .Site.GetPage "/docs" -}} +# herdr plus + +> {{ .Site.Params.description }} + +herdr-plus is an open-source add-on for herdr ({{ .Site.Params.herdrUrl }}), a terminal multiplexer and agent runtime — built as a first-class herdr plugin ({{ .Site.Params.herdrPluginsUrl }}). Install it with `{{ .Site.Params.pluginInstallCmd }}`; herdr clones the repo, builds it, and registers the plugin's actions. Two ship today: **Projects** (action `cloudmanic.herdr-plus.projects`) — declarative templates that spin up a whole herdr workspace of tabs and panes — and **Quick Actions** (action `cloudmanic.herdr-plus.quick-actions`) — a fuzzy launcher that runs a one-off action in the directory you launched from. Configuration is plain TOML files in herdr's managed plugin config directory. It is free and MIT-licensed, by {{ .Site.Params.company }}. + +## Documentation +{{ range $docs.RegularPages.ByWeight -}} +- [{{ .Title }}]({{ .Permalink }}): {{ .Description }} +{{ end }} +## Full text for agents +- [llms-full.txt]({{ "llms-full.txt" | absURL }}): The complete documentation inlined as a single plain-text file. + +## Project links +- [Documentation home]({{ $docs.Permalink }}): Human-readable docs. +- [GitHub repository]({{ .Site.Params.githubUrl }}): Source code, issues, releases. +- [Releases]({{ .Site.Params.releasesUrl }}): Versioned downloads and changelog. +- [herdr]({{ .Site.Params.herdrUrl }}): The terminal herdr-plus extends. +- [Cloudmanic Labs]({{ .Site.Params.cloudmanicUrl }}): The maintainer. + +## Install +- Plugin (this is the install): `{{ .Site.Params.pluginInstallCmd }}` — herdr clones, builds, and registers it; re-run the same command to upgrade in place. Requires herdr >= 0.7.0. +- Config dir (holds projects/ and quick-actions/): `{{ .Site.Params.configDirCmd }}` +- Bind keys (optional — or run the actions from herdr's action menu): add `[[keys.command]]` entries to herdr's config.toml with `type = "plugin_action"` and `command = "cloudmanic.herdr-plus.projects"` / `"cloudmanic.herdr-plus.quick-actions"`, then run `herdr server reload-config`. +- Optional standalone binary (only to get `herdr-plus` on your PATH; does NOT register the plugin): `{{ .Site.Params.brewTapCmd }}` then `{{ .Site.Params.brewInstallCmd }}`, or `{{ .Site.Params.scriptInstallCmd }}`. diff --git a/old/www/layouts/index.llmsfull.txt b/www/layouts/index.llmsfull.txt similarity index 58% rename from old/www/layouts/index.llmsfull.txt rename to www/layouts/index.llmsfull.txt index 9ddbd98..ae19cb6 100644 --- a/old/www/layouts/index.llmsfull.txt +++ b/www/layouts/index.llmsfull.txt @@ -25,16 +25,30 @@ Source: {{ .Permalink }} ================================================================================ # Install reference -Homebrew (this repository is its own tap): +Install the plugin (this is the whole install — herdr clones the repo, builds it +with or without Go, and registers its actions; re-run to upgrade in place). +Requires herdr >= 0.7.0: + {{ .Site.Params.pluginInstallCmd }} + +Find the config directory (projects/ and quick-actions/ live here): + {{ .Site.Params.configDirCmd }} + +Bind keys (optional — or run the actions from herdr's action menu). Add to herdr's +config.toml, then run `herdr server reload-config`: + [[keys.command]] + key = "prefix+up" + type = "plugin_action" + command = "cloudmanic.herdr-plus.projects" + + [[keys.command]] + key = "prefix+down" + type = "plugin_action" + command = "cloudmanic.herdr-plus.quick-actions" + +Optional standalone binary (only to get `herdr-plus` on your PATH; does NOT +register the plugin with herdr): {{ .Site.Params.brewTapCmd }} {{ .Site.Params.brewInstallCmd }} - -Install script (Linux/macOS, no Homebrew): {{ .Site.Params.scriptInstallCmd }} -Bind the herdr keybindings: - herdr-plus install # Control mode → prefix+up - herdr-plus install --mode=quick-actions # Quick Actions → prefix+down - herdr-plus install --key=prefix+a # override the key for any mode - License: MIT · Maintainer: {{ .Site.Params.company }} ({{ .Site.Params.cloudmanicUrl }}) diff --git a/old/www/layouts/partials/brand.html b/www/layouts/partials/brand.html similarity index 100% rename from old/www/layouts/partials/brand.html rename to www/layouts/partials/brand.html diff --git a/old/www/layouts/partials/codebox.html b/www/layouts/partials/codebox.html similarity index 100% rename from old/www/layouts/partials/codebox.html rename to www/layouts/partials/codebox.html diff --git a/old/www/layouts/partials/docs-sidebar.html b/www/layouts/partials/docs-sidebar.html similarity index 100% rename from old/www/layouts/partials/docs-sidebar.html rename to www/layouts/partials/docs-sidebar.html diff --git a/old/www/layouts/partials/footer.html b/www/layouts/partials/footer.html similarity index 100% rename from old/www/layouts/partials/footer.html rename to www/layouts/partials/footer.html diff --git a/old/www/layouts/partials/head.html b/www/layouts/partials/head.html similarity index 100% rename from old/www/layouts/partials/head.html rename to www/layouts/partials/head.html diff --git a/old/www/layouts/partials/icon.html b/www/layouts/partials/icon.html similarity index 100% rename from old/www/layouts/partials/icon.html rename to www/layouts/partials/icon.html diff --git a/www/layouts/partials/install-box.html b/www/layouts/partials/install-box.html new file mode 100644 index 0000000..44199f3 --- /dev/null +++ b/www/layouts/partials/install-box.html @@ -0,0 +1,31 @@ +{{- /* install-box.html — the herdr-plus install. The plugin install is the + primary (and only required) step; the standalone binary is an optional + extra tucked behind a disclosure. Call: + {{ partial "install-box.html" (dict "id" "hero" "ctx" .) }} + .id must be unique per page so multiple boxes don't collide. */ -}} +{{- $id := .id -}} +{{- $p := .ctx.Site.Params -}} +
    + {{ partial "codebox.html" (dict "cmd" $p.pluginInstallCmd) }} +

    + herdr clones the repo, runs the build, and registers the actions — with or without Go. + Re-run the same command any time to upgrade. +

    + +
    + + Just want the herdr-plus binary on your PATH? + +
    +

    Homebrew — the repo is its own tap:

    + {{ partial "codebox.html" (dict "cmd" $p.brewTapCmd) }} + {{ partial "codebox.html" (dict "cmd" $p.brewInstallCmd) }} +

    …or the install script (Linux & macOS, no Homebrew):

    + {{ partial "codebox.html" (dict "cmd" $p.scriptInstallCmd) }} +

    + This installs only the binary (handy for herdr-plus version). + It does not register the plugin with herdr — use the command above for that. +

    +
    +
    +
    diff --git a/old/www/layouts/partials/nav.html b/www/layouts/partials/nav.html similarity index 100% rename from old/www/layouts/partials/nav.html rename to www/layouts/partials/nav.html diff --git a/old/www/layouts/partials/schema.html b/www/layouts/partials/schema.html similarity index 100% rename from old/www/layouts/partials/schema.html rename to www/layouts/partials/schema.html diff --git a/old/www/layouts/robots.txt b/www/layouts/robots.txt similarity index 100% rename from old/www/layouts/robots.txt rename to www/layouts/robots.txt diff --git a/old/www/static/CNAME b/www/static/CNAME similarity index 100% rename from old/www/static/CNAME rename to www/static/CNAME diff --git a/old/www/static/images/apple-touch-icon.png b/www/static/images/apple-touch-icon.png similarity index 100% rename from old/www/static/images/apple-touch-icon.png rename to www/static/images/apple-touch-icon.png diff --git a/old/www/static/images/favicon-32.png b/www/static/images/favicon-32.png similarity index 100% rename from old/www/static/images/favicon-32.png rename to www/static/images/favicon-32.png diff --git a/old/www/static/images/favicon.ico b/www/static/images/favicon.ico similarity index 100% rename from old/www/static/images/favicon.ico rename to www/static/images/favicon.ico diff --git a/old/www/static/images/icon-16.png b/www/static/images/icon-16.png similarity index 100% rename from old/www/static/images/icon-16.png rename to www/static/images/icon-16.png diff --git a/old/www/static/images/icon-180.png b/www/static/images/icon-180.png similarity index 100% rename from old/www/static/images/icon-180.png rename to www/static/images/icon-180.png diff --git a/old/www/static/images/icon-256.png b/www/static/images/icon-256.png similarity index 100% rename from old/www/static/images/icon-256.png rename to www/static/images/icon-256.png diff --git a/old/www/static/images/icon-32.png b/www/static/images/icon-32.png similarity index 100% rename from old/www/static/images/icon-32.png rename to www/static/images/icon-32.png diff --git a/old/www/static/images/icon-512.png b/www/static/images/icon-512.png similarity index 100% rename from old/www/static/images/icon-512.png rename to www/static/images/icon-512.png diff --git a/old/www/static/images/logo.svg b/www/static/images/logo.svg similarity index 100% rename from old/www/static/images/logo.svg rename to www/static/images/logo.svg diff --git a/old/www/static/images/og.png b/www/static/images/og.png similarity index 100% rename from old/www/static/images/og.png rename to www/static/images/og.png diff --git a/old/www/static/images/projects.webp b/www/static/images/projects.webp similarity index 100% rename from old/www/static/images/projects.webp rename to www/static/images/projects.webp diff --git a/old/www/static/images/quick-actions.webp b/www/static/images/quick-actions.webp similarity index 100% rename from old/www/static/images/quick-actions.webp rename to www/static/images/quick-actions.webp