
Herdr
- 16k installs
- 24.4k repo stars
- Updated August 5, 2026
- ogulcancelik/herdr
herdr is an agent skill for Control herdr from inside it. Manage workspaces and tabs, split panes, spawn agents, read output, and wait for state cha
About
The herdr skill control herdr from inside it. Manage workspaces and tabs, split panes, spawn agents, read output, and wait for state changes - all via CLI commands that talk to the running herdr instance over a local unix socket. Use when running inside herdr (HERDR_ENV=1).. herdr - agent skill before using this skill, check that HERDR_ENV=1 . if it is not set to 1 , say you are not running inside a herdr-managed pane and stop. do not inspect or control the focused herdr pane from outside herdr. you are running inside herdr, a terminal-native agent multiplexer. herdr gives you workspaces, tabs, and panes - each pane is a real terminal with its own shell, agent, server, or log stream - and you can control all of it from the cli. this means you can: - see what other panes and agents are doing - create tabs for separate subcontexts inside one workspace - split panes and run commands in them - start servers, watch logs, and run tests
- Control herdr from inside it. Manage workspaces and tabs, split panes, spawn agents, read output, and wait for state cha
- see what other panes and agents are doing
- create tabs for separate subcontexts inside one workspace
- split panes and run commands in them
- start servers, watch logs, and run tests in sibling panes
Herdr by the numbers
- 16,034 all-time installs (skills.sh)
- +1,975 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #50 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
herdr capabilities & compatibility
- Capabilities
- control herdr from inside it. manage workspaces · see what other panes and agents are doing · create tabs for separate subcontexts inside one
npx skills add https://github.com/ogulcancelik/herdr --skill herdrAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 16k |
|---|---|
| repo stars | ★ 24.4k |
| Last updated | August 5, 2026 |
| Repository | ogulcancelik/herdr ↗ |
How do I run herdr tasks with correct setup and documented commands?
Control herdr from inside it. Manage workspaces and tabs, split panes, spawn agents, read output, and wait for state changes - all via CLI commands that talk to the running herdr instance over a
Who is it for?
Developers automating herdr via agent-guided SKILL.md workflows.
Skip if: Skip when unrelated tooling already covers the task without this skill's documented flow.
When should I use this skill?
Control herdr from inside it. Manage workspaces and tabs, split panes, spawn agents, read output, and wait for state changes - all via CLI commands that talk to the running herdr instance over a loc
What you get
Repeatable herdr workflows with grounded commands and expected outputs.
By the numbers
- 1 install reported on skills.sh
- Catalog rank 4734 on skills.sh
Files
Herdr Pre-release Audit
Use this skill only inside the herdr repository.
Read references/pre-release-audit.md and follow its workflow. Treat it as the source of truth for:
- choosing the release base ref
- inspecting first-parent history and merged PRs
- auditing
docs/next/CHANGELOG.md - auditing
docs/next/README.mdand staged website docs - checking issue reference lines
- deciding when to run
just release-docs-check - producing the final release-readiness report
Do not edit files during the audit unless the user explicitly asks to apply fixes. When applying fixes, keep changes scoped to the files named in the reference workflow.
Audit release readiness for this repo.
Optional starting ref override: $1 Extra user intent/context: ${@:2}
Process:
1. Determine the base ref.
- If
$1is non-empty and looks like a ref/tag, use it. - Otherwise use the latest release tag, preferring the repo's semver tag style:
git describe --tags --abbrev=02. Inspect the range from base ref to HEAD.
- Use first-parent history for release context:
git log --first-parent --reverse --format='%H%x09%s' <base>..HEAD- Also inspect full commits and commit bodies when needed:
git log --reverse --format='%H%x09%s%n%b' <base>..HEAD3. Detect merged PRs if any.
- Look for first-parent subjects that indicate PR merges, including squash merges like
title (#123). - If GitHub CLI is available and the PR number is known, use it to fetch PR title/body for context.
- Treat a merged PR as the primary release unit.
- Do not also list individual commits that belong to that PR.
4. Handle direct commits separately.
- Any commit in the range not represented by a merged PR should be considered on its own.
5. Infer what matters.
- For each PR or direct commit, inspect changed files and diff stats.
- Read the most relevant files in full when needed to understand user-facing impact.
- Ignore pure housekeeping unless it has release value:
- version bumps
- release/tag commits
- changelog-only commits
- formatting-only changes
- comment-only/doc-only changes unless they materially affect users
6. Audit docs/next/CHANGELOG.md and issue references.
- Treat root
CHANGELOG.mdas the latest released changelog. - Treat
docs/next/CHANGELOG.mdas the next-release changelog. - Compare meaningful user-facing changes in the commit range against
docs/next/CHANGELOG.md. - Flag missing entries for new features, bug fixes, removals, breaking changes, defaults, compatibility changes, user-visible command/config/API behavior, and security-relevant changes.
- Do not require changelog entries solely for internal client/server protocol version bumps. Mention protocol only when the release intentionally changes user-facing compatibility guidance beyond the normal restart requirement.
- Inspect commit bodies for issue reference lines in the form
refs #<issue-number>. - Flag normal commits that use GitHub closing keywords like
fixes #<issue-number>,closes #<issue-number>, orresolves #<issue-number>, because they close issues before release when they land onmaster. - For each shipped issue reference, check whether the changelog has a matching user-facing entry that mentions
#<issue-number>when appropriate. - For each merged external human PR, check whether the changelog entry mentions the PR number and thanks the contributor in the existing style, e.g.
(#129, thanks @username). If the PR primarily ships an issue fix, include both the issue and PR numbers when useful, e.g.(#128, #129, thanks @username). Do not add thanks text for maintainer-owned bots or automation accounts such askangal-botordependabot. - Do not require or add GitHub closing keywords like
fixes #<issue-number>,closes #<issue-number>, orresolves #<issue-number>to changelog entries or release notes. - List shipped issue references under
Issue references to close after release:so the release operator can verify what release CI will close after the GitHub Release is published. - Flag stale entries that do not appear to correspond to shipped changes in the range.
- Flag entries that are too implementation-focused or unclear for end users.
- Preserve the existing changelog style and sections:
Added,Changed,Fixed,Removed, andBreaking Changeswhen applicable.
7. Audit next-release public docs.
- Treat root
README.mdandwebsite/src/content/docs/as the latest released public docs. - Treat
docs/next/README.mdas the next-release root README, anddocs/next/website/src/content/docs/as the full next-release mirror of website docs. - Compare meaningful user-facing changes in the range against next-release docs first.
- Flag missing release docs for new or changed features, commands, config keys, protocol behavior, integrations, defaults, and compatibility notes.
- Compare
docs/next/README.mdagainst rootREADME.md, and compare the staged website-doc mirror againstwebsite/src/content/docs/. Flag each difference as intended to ship in this release, stale, or needing user decision. - Also audit example config snippets for release readiness.
8. Verify finalization state.
- Before
just release, approveddocs/next/README.mdmust be copied to rootREADME.md, approved staged website docs must be copied fromdocs/next/website/src/content/docs/towebsite/src/content/docs/, and the deleted root doc files must stay deleted. - If the release will change
Cargo.lockor the package version, checknix/package.nixafter the release version bump and refreshcargoHashbefore tagging. A stale hash fails both theNixworkflow and the release workflow'sflake-checkjob with a fixed-output derivation mismatch. Use thegot:hash printed bynix flake check --print-build-logsor CI, then rerun the Nix check when available. - Run or recommend:
just release-docs-check- This check must include root
README.md, rootCHANGELOG.md, the removed root doc files, and exact 1:1 sync betweendocs/next/website/src/content/docs/andwebsite/src/content/docs/. - Do not run
just releaseunless the working tree is clean and the docs check passes.
9. Apply changes only when asked.
- Do not edit files during the audit unless the user explicitly asks you to apply fixes.
- When asked to apply audit fixes, update
docs/next/CHANGELOG.md,docs/next/README.md, and any required staged website docs underdocs/next/website/src/content/docs/. - When asked to finalize release docs, copy approved next-release README and changelog into root, copy approved staged website docs into
website/src/content/docs/, then runjust release-docs-check.
Output format:
Release readiness: READY | NOT READY
Base: <base ref>
Range: <base ref>..HEAD
Meaningful shipped changes: yes | no
Changelog: OK | MISSING ENTRIES | NEEDS ATTENTION
Missing:
- <only user-facing shipped changes missing from docs/next/CHANGELOG.md>
Docs: OK | MISSING | INACCURATE | NEEDS DECISION
Missing:
- <only required next-release public docs gaps>
Wrong or questionable:
- <docs that disagree with implementation, if any>
Issue refs: OK | NEEDS ATTENTION
Will close after release:
- #<issue>
Accepted/no action:
- <items the user explicitly accepted, such as known closing-keyword commits>
Root docs finalized: YES | NO
<result of just release-docs-check or why it was not run>
Nix cargoHash: OK | NEEDS UPDATE | NOT CHECKED
<result of nix flake check or the hash-refresh status>
Required before release:
1. <short action>Keep the main output glanceable. Put commit inventories, excluded housekeeping, and commands run in an appendix only when they materially help the operator.
If the range has no meaningful user-facing changes, say that plainly instead of forcing entries.
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(git rev-parse --show-toplevel)"
cd "$repo_root"
python3 scripts/conventional_commits.py --message-file "$1"
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(git rev-parse --show-toplevel)"
cd "$repo_root"
echo "pre-commit: running just lint"
just lint
# GitHub usernames, one per line.
# Maintainers and trusted contributors can be listed here.
Edmund-a7
naaive
othavioquiliao
edheltzel
EYH0602
mspiegel31
fbettag
nexxeln
soomtong
moonsphere
leeeanh
ChihGodlee
babymastodon
cloudmanic
kangal-bot
Golden-Pigeon
icedac
LaneBirmingham
chenrui333
wayneleelwc
reobin
wardpeet
juliogc
turgaybulut
SunskyXH
sf-jin-ku
DeevsDeevs
wbxl2000
charliesbot
udirom
imrajyavardhan12
dzevs
igasmi
minatoaquaMK2
dmmulroy
cullendotdev
version: 2
updates:
- package-ecosystem: cargo
directory: /
schedule:
interval: monthly
open-pull-requests-limit: 1
groups:
cargo-dependencies:
patterns:
- "*"
- package-ecosystem: github-actions
directory: /
schedule:
interval: monthly
open-pull-requests-limit: 1
groups:
github-actions:
patterns:
- "*"
title: "[idea] "
body:
- type: textarea
id: idea
attributes:
label: idea / problem
description: what do you want to do, or what feels awkward today?
validations:
required: true
- type: textarea
id: requested-change
attributes:
label: requested change
description: what would you like herdr to support?
validations:
required: true
- type: textarea
id: why
attributes:
label: why you want this
description: how would this help you?
validations:
required: true
title: "[question] "
body:
- type: textarea
id: question
attributes:
label: question
description: what are you trying to understand?
validations:
required: true
- type: textarea
id: context
attributes:
label: context
description: what version, os, terminal, config, or workflow matters here?
validations:
required: false
github: ogulcancelik
name: Bug report
description: Report a reproducible bug in herdr.
body:
- type: markdown
attributes:
value: |
**Before you start:** Read [CONTRIBUTING.md](https://github.com/ogulcancelik/herdr/blob/master/CONTRIBUTING.md).
Issues are only for reproducible bugs and maintainer-created or maintainer-converted work items. Feature requests, ideas, questions, contribution proposals, and direction checks belong in [Discussions](https://github.com/ogulcancelik/herdr/discussions).
New PRs from unapproved contributors are auto-closed by default. Do not open a PR unless a maintainer has approved the work and approved you to contribute.
Keep this short. If it does not fit on one screen, it is too long. Write in your own voice.
- type: checkboxes
id: bug-confirmation
attributes:
label: Is this a reproducible bug?
description: Feature requests, ideas, questions, contribution proposals, and direction checks belong in Discussions and may be closed automatically if opened as issues.
options:
- label: I confirm this is a reproducible bug, not a feature request, idea, question, contribution proposal, or direction check.
required: true
- type: textarea
id: current-behavior
attributes:
label: Current behavior
description: What is happening now?
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: Expected behavior
description: What should happen instead?
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Reproduction
description: What are the shortest steps that show the problem?
validations:
required: true
- type: textarea
id: impact
attributes:
label: Impact
description: How does this affect you?
validations:
required: true
- type: textarea
id: environment
attributes:
label: Environment
description: Herdr version, update channel, operating system, and terminal are required. Shell and relevant config are optional.
value: |
- Herdr version:
- Update channel (stable or preview):
- Operating system:
- Terminal:
- Shell, if relevant:
- Relevant config, if any:
validations:
required: true
blank_issues_enabled: false
contact_links:
- name: Feature requests and ideas
url: https://github.com/ogulcancelik/herdr/discussions
about: Start a discussion for feature requests, ideas, behavior changes, and product direction.
- name: Questions and contribution proposals
url: https://github.com/ogulcancelik/herdr/discussions
about: Use discussions for questions, contribution proposals, and anything that is not a reproducible bug.
name: Approve Contributor
on:
issue_comment:
types: [created, edited]
jobs:
approve:
if: ${{ !github.event.issue.pull_request && github.event.comment.user.login != 'kangal-bot' }}
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
- name: Add contributor to approved list
id: update
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
github-token: ${{ secrets.KANGAL_GITHUB_TOKEN }}
script: |
const fs = require('fs');
const issueAuthor = context.payload.issue.user.login;
const commenter = context.payload.comment.user.login;
const commentBody = context.payload.comment.body || '';
const action = context.payload.action;
const approvedFile = '.github/APPROVED_CONTRIBUTORS';
const approveMatch = commentBody.match(/(^|\s)\/approve(?:\s+@([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?))?(?=$|\s)/i);
const maintainerPermissions = ['admin', 'maintain', 'write'];
if (!approveMatch) {
console.log('Comment does not contain /approve');
core.setOutput('status', 'skipped');
return;
}
const approvedUser = approveMatch[2] || issueAuthor;
core.setOutput('approved_user', approvedUser);
try {
const { data: permissionLevel } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username: commenter,
});
if (!maintainerPermissions.includes(permissionLevel.permission)) {
console.log(`${commenter} does not have maintainer access`);
core.setOutput('status', 'skipped');
return;
}
} catch (error) {
console.log(`${commenter} does not have collaborator access`);
core.setOutput('status', 'skipped');
return;
}
let content = fs.readFileSync(approvedFile, 'utf8');
const approvedList = content
.split('\n')
.map(line => line.trim().toLowerCase())
.filter(line => line && !line.startsWith('#'));
if (approvedList.includes(approvedUser.toLowerCase())) {
console.log(`${approvedUser} is already approved`);
if (action === 'edited') {
core.setOutput('status', 'skipped');
return;
}
core.setOutput('status', 'already');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `@${approvedUser} is already in the approved contributors list.`,
});
return;
}
content = content.trimEnd() + '\n' + approvedUser + '\n';
fs.writeFileSync(approvedFile, content);
console.log(`Added ${approvedUser} to approved contributors`);
core.setOutput('status', 'added');
- name: Commit and push
if: steps.update.outputs.status == 'added'
env:
KANGAL_GITHUB_TOKEN: ${{ secrets.KANGAL_GITHUB_TOKEN }}
run: |
git config user.name "kangal-bot"
git config user.email "285672167+kangal-bot@users.noreply.github.com"
git add .github/APPROVED_CONTRIBUTORS
git diff --staged --quiet || git commit -m "chore: approve contributor ${{ steps.update.outputs.approved_user }}"
git push "https://x-access-token:${KANGAL_GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" HEAD:${{ github.event.repository.default_branch }}
- name: Comment on issue
if: steps.update.outputs.status == 'added'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
github-token: ${{ secrets.KANGAL_GITHUB_TOKEN }}
script: |
const approvedUser = '${{ steps.update.outputs.approved_user }}';
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `@${approvedUser} has been added to the approved contributors list. You can now submit PRs for maintainer-approved work. Thanks for contributing!`,
});
name: Approve Merged Contributor
on:
pull_request_target:
types: [closed]
jobs:
approve:
if: ${{ github.event.pull_request.merged == true }}
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
- name: Add merged contributor to approved list
id: update
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
github-token: ${{ secrets.KANGAL_GITHUB_TOKEN }}
script: |
const fs = require('fs');
const prAuthor = context.payload.pull_request.user.login;
const approvedFile = '.github/APPROVED_CONTRIBUTORS';
if (prAuthor.endsWith('[bot]') || prAuthor === 'dependabot[bot]') {
console.log(`Skipping bot: ${prAuthor}`);
core.setOutput('status', 'skipped');
return;
}
let content = fs.readFileSync(approvedFile, 'utf8');
const approvedList = content
.split('\n')
.map(line => line.trim().toLowerCase())
.filter(line => line && !line.startsWith('#'));
if (approvedList.includes(prAuthor.toLowerCase())) {
console.log(`${prAuthor} is already approved`);
core.setOutput('status', 'already');
return;
}
content = content.trimEnd() + '\n' + prAuthor + '\n';
fs.writeFileSync(approvedFile, content);
console.log(`Added ${prAuthor} to approved contributors`);
core.setOutput('status', 'added');
- name: Commit and push
if: steps.update.outputs.status == 'added'
env:
KANGAL_GITHUB_TOKEN: ${{ secrets.KANGAL_GITHUB_TOKEN }}
run: |
git config user.name "kangal-bot"
git config user.email "285672167+kangal-bot@users.noreply.github.com"
git add .github/APPROVED_CONTRIBUTORS
git diff --staged --quiet || git commit -m "chore: approve merged contributor ${{ github.event.pull_request.user.login }}"
git push "https://x-access-token:${KANGAL_GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" HEAD:${{ github.event.repository.default_branch }}
name: Build artifacts (manual)
on:
workflow_dispatch:
inputs:
build_group:
description: which artifacts to build
required: true
default: linux
type: choice
options:
- linux
- macos
- windows
- all
libghostty_optimize:
description: libghostty-vt optimize mode for this build
required: true
default: ReleaseSafe
type: choice
options:
- ReleaseSafe
- ReleaseFast
- ReleaseSmall
- Debug
libghostty_simd:
description: build libghostty-vt with SIMD-accelerated code paths
required: true
default: 'false'
type: choice
options:
- 'false'
- 'true'
permissions:
contents: read
actions: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
build-linux:
if: ${{ inputs.build_group == 'linux' || inputs.build_group == 'all' }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- target: x86_64-unknown-linux-musl
os: ubuntu-latest
name: herdr-linux-x86_64
- target: aarch64-unknown-linux-musl
os: ubuntu-latest
name: herdr-linux-aarch64
env:
LIBGHOSTTY_VT_OPTIMIZE: ${{ inputs.libghostty_optimize }}
LIBGHOSTTY_VT_SIMD: ${{ inputs.libghostty_simd }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- name: Install Rust
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
targets: ${{ matrix.target }}
- name: Install Zig
uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1
with:
version: 0.15.2
- name: Prefer official Ubuntu mirrors over Azure
run: |
if [ -f /etc/apt/apt-mirrors.txt ]; then
sudo sed -i '/azure.archive.ubuntu.com/d' /etc/apt/apt-mirrors.txt
echo "Using apt mirrors:"
cat /etc/apt/apt-mirrors.txt
fi
- name: Install Linux build tools
run: sudo apt-get update && sudo apt-get install -y cmake ninja-build musl-tools gcc-aarch64-linux-gnu crossbuild-essential-arm64
- name: Set Linux aarch64 linker
if: matrix.target == 'aarch64-unknown-linux-musl'
run: echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=aarch64-linux-gnu-gcc" >> $GITHUB_ENV
- name: Remove Zig caches
run: rm -rf .zig-cache vendor/libghostty-vt/.zig-cache vendor/libghostty-vt/zig-out
- name: Build
run: cargo build --release --locked --target ${{ matrix.target }}
- name: Package artifact
run: |
cp target/${{ matrix.target }}/release/herdr ${{ matrix.name }}
file ${{ matrix.name }} > BUILD_INFO.txt
ldd ${{ matrix.name }} 2>&1 | tee -a BUILD_INFO.txt
grep -q "statically linked" BUILD_INFO.txt
if nm -u ${{ matrix.name }} 2>/dev/null | grep -E '(__cxa|GLIBCXX|CXXABI|_ZSt)'; then
echo "error: Linux artifact has unresolved C++ runtime symbols" >&2
exit 1
fi
{
echo "commit=$GITHUB_SHA"
echo "target=${{ matrix.target }}"
echo "libghostty_vt_optimize=$LIBGHOSTTY_VT_OPTIMIZE"
echo "libghostty_vt_simd=$LIBGHOSTTY_VT_SIMD"
} >> BUILD_INFO.txt
python3 -c "import hashlib, pathlib; artifact = pathlib.Path('${{ matrix.name }}'); print(f'sha256={hashlib.sha256(artifact.read_bytes()).hexdigest()} {artifact.name}')" >> BUILD_INFO.txt
- name: Upload artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ${{ matrix.name }}-${{ inputs.libghostty_optimize }}-simd-${{ inputs.libghostty_simd }}
path: |
${{ matrix.name }}
BUILD_INFO.txt
retention-days: 7
build-macos:
if: ${{ inputs.build_group == 'macos' || inputs.build_group == 'all' }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- target: x86_64-apple-darwin
os: macos-latest
name: herdr-macos-x86_64
- target: aarch64-apple-darwin
os: macos-latest
name: herdr-macos-aarch64
env:
LIBGHOSTTY_VT_OPTIMIZE: ${{ inputs.libghostty_optimize }}
LIBGHOSTTY_VT_SIMD: ${{ inputs.libghostty_simd }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- name: Install Rust
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
targets: ${{ matrix.target }}
- name: Install Zig
uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1
with:
version: 0.15.2
- name: Install macOS build tools
run: brew install cmake ninja
- name: Remove Zig caches
run: rm -rf .zig-cache vendor/libghostty-vt/.zig-cache vendor/libghostty-vt/zig-out
- name: Build
run: cargo build --release --locked --target ${{ matrix.target }}
- name: Package artifact
run: |
cp target/${{ matrix.target }}/release/herdr ${{ matrix.name }}
file ${{ matrix.name }} > BUILD_INFO.txt
{
echo "commit=$GITHUB_SHA"
echo "target=${{ matrix.target }}"
echo "libghostty_vt_optimize=$LIBGHOSTTY_VT_OPTIMIZE"
echo "libghostty_vt_simd=$LIBGHOSTTY_VT_SIMD"
} >> BUILD_INFO.txt
python3 -c "import hashlib, pathlib; artifact = pathlib.Path('${{ matrix.name }}'); print(f'sha256={hashlib.sha256(artifact.read_bytes()).hexdigest()} {artifact.name}')" >> BUILD_INFO.txt
- name: Upload artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ${{ matrix.name }}-${{ inputs.libghostty_optimize }}-simd-${{ inputs.libghostty_simd }}
path: |
${{ matrix.name }}
BUILD_INFO.txt
retention-days: 7
build-windows:
if: ${{ inputs.build_group == 'windows' || inputs.build_group == 'all' }}
runs-on: windows-latest
env:
LIBGHOSTTY_VT_OPTIMIZE: ${{ inputs.libghostty_optimize }}
LIBGHOSTTY_VT_SIMD: ${{ inputs.libghostty_simd }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- name: Install Rust
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
targets: x86_64-pc-windows-msvc
- name: Install Zig
uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1
with:
version: 0.15.2
- name: Remove Zig caches
shell: pwsh
run: |
Remove-Item -Recurse -Force .zig-cache -ErrorAction SilentlyContinue
Remove-Item -Recurse -Force vendor/libghostty-vt/.zig-cache -ErrorAction SilentlyContinue
Remove-Item -Recurse -Force vendor/libghostty-vt/zig-out -ErrorAction SilentlyContinue
- name: Build
run: cargo build --release --locked --target x86_64-pc-windows-msvc
- name: Package artifact
shell: pwsh
run: |
Copy-Item target\x86_64-pc-windows-msvc\release\herdr.exe herdr-windows-x86_64.exe
"commit=$env:GITHUB_SHA" | Out-File -Encoding utf8 BUILD_INFO.txt
"target=x86_64-pc-windows-msvc" | Out-File -Encoding utf8 -Append BUILD_INFO.txt
"libghostty_vt_optimize=$env:LIBGHOSTTY_VT_OPTIMIZE" | Out-File -Encoding utf8 -Append BUILD_INFO.txt
"libghostty_vt_simd=$env:LIBGHOSTTY_VT_SIMD" | Out-File -Encoding utf8 -Append BUILD_INFO.txt
$hash = (Get-FileHash -Algorithm SHA256 herdr-windows-x86_64.exe).Hash.ToLowerInvariant()
"sha256=$hash herdr-windows-x86_64.exe" | Out-File -Encoding utf8 -Append BUILD_INFO.txt
- name: Upload artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: herdr-windows-x86_64-${{ inputs.libghostty_optimize }}-simd-${{ inputs.libghostty_simd }}
path: |
herdr-windows-x86_64.exe
BUILD_INFO.txt
retention-days: 7
name: CI
on:
pull_request:
types: [opened, synchronize, reopened]
push:
branches: [master, windows]
paths-ignore:
- "website/**"
- ".github/APPROVED_CONTRIBUTORS"
permissions:
contents: read
concurrency:
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
conventional-commits:
if: github.event_name != 'push' || github.ref_name == 'master'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 0
persist-credentials: false
- name: Validate commit subjects
if: github.event_name == 'push'
run: python3 scripts/conventional_commits.py --range "${{ github.event.before }}..${{ github.event.after }}"
- name: Validate PR title
if: github.event_name == 'pull_request'
run: python3 scripts/conventional_commits.py "${{ github.event.pull_request.title }}"
check:
name: check (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
kind: unix
nextest_filter: all()
- os: macos-latest
kind: unix
nextest_filter: not binary(live_handoff)
- os: windows-latest
kind: windows
runs-on: ${{ matrix.os }}
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- name: Install Rust
if: matrix.kind == 'unix'
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
- name: Install Rust
if: matrix.kind == 'windows'
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
targets: x86_64-pc-windows-msvc
- name: Install Rust tools
if: matrix.kind == 'unix'
uses: taiki-e/install-action@fd2f5e3d644b484055ebf4268f474c565f148f25 # v2.81.9
with:
tool: just,cargo-nextest
- name: Install Bun
if: matrix.kind == 'unix'
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.3.14
- name: Install Zig
uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1
with:
version: 0.15.2
- name: Install macOS build tools
if: runner.os == 'macOS'
run: brew install cmake ninja
- name: Restore cargo cache
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
cache-bin: false
key: ${{ matrix.os }}
- name: Run checks
if: matrix.kind == 'unix'
run: just ci '${{ matrix.nextest_filter }}'
- name: Run Windows checks
if: matrix.kind == 'windows'
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
function Invoke-Checked {
param([string]$Command, [string[]]$Arguments)
& $Command @Arguments
if ($LASTEXITCODE -ne 0) {
throw "command failed with exit code $LASTEXITCODE`: $Command $($Arguments -join ' ')"
}
}
Invoke-Checked cargo @("fmt", "--check")
Invoke-Checked cargo @("clippy", "--bin", "herdr", "--locked", "--target", "x86_64-pc-windows-msvc", "--", "-D", "warnings")
Invoke-Checked cargo @("test", "--locked", "--target", "x86_64-pc-windows-msvc", "--bin", "herdr", "windows_")
Invoke-Checked cargo @("test", "--locked", "--target", "x86_64-pc-windows-msvc", "--bin", "herdr", "server::client_transport::tests")
Invoke-Checked cargo @("build", "--locked", "--target", "x86_64-pc-windows-msvc")
- name: Smoke ConPTY pane
if: matrix.kind == 'windows'
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$env:HERDR_SESSION = "ci-windows-$env:GITHUB_RUN_ID-$env:GITHUB_RUN_ATTEMPT"
$exe = Join-Path $PWD "target\x86_64-pc-windows-msvc\debug\herdr.exe"
& $exe --version
& $exe --default-config | Out-Null
$server = Start-Process -FilePath $exe -ArgumentList "server" -PassThru -WindowStyle Hidden
try {
$deadline = (Get-Date).AddSeconds(10)
do {
Start-Sleep -Milliseconds 250
$status = & $exe status server 2>&1
if ($LASTEXITCODE -eq 0 -and (($status -join "`n") -match "status: running")) {
break
}
} while ((Get-Date) -lt $deadline)
if ((Get-Date) -ge $deadline) {
throw "server did not become ready"
}
& $exe agent start smoke -- "$env:ComSpec" /K dir
Start-Sleep -Seconds 2
$read = & $exe agent read smoke --source recent --lines 40 --format text
$text = $read -join "`n"
if ($text -notmatch "File\(s\)" -or $text -notmatch "Dir\(s\)") {
throw "pane read did not include cmd.exe directory output: $text"
}
} finally {
& $exe server stop 2>$null
Wait-Process -Id $server.Id -Timeout 10 -ErrorAction SilentlyContinue
}
name: Issue Gate
on:
issues:
types: [opened]
permissions:
issues: write
jobs:
check-template:
if: ${{ !github.event.issue.pull_request }}
runs-on: ubuntu-latest
steps:
- name: Close non-template issues
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
github-token: ${{ secrets.KANGAL_GITHUB_TOKEN }}
script: |
const issue = context.payload.issue;
const author = issue.user.login;
const sender = context.payload.sender?.login ?? author;
const bugConfirmationPattern = /^\s*-\s*\[[xX]\]\s*I confirm this is a reproducible bug, not a feature request, idea, question, contribution proposal, or direction check\.\s*$/m;
const requiredSections = [
'### Is this a reproducible bug?',
'### Current behavior',
'### Expected behavior',
'### Reproduction',
'### Impact',
'### Environment',
];
const requiredEnvironmentFields = [
/^\s*(?:-\s*)?Herdr version:[^\S\r\n]*\S.*$/m,
/^\s*(?:-\s*)?Operating system:[^\S\r\n]*\S.*$/m,
/^\s*(?:-\s*)?Terminal:[^\S\r\n]*\S.*$/m,
];
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function extractSection(body, heading) {
const pattern = new RegExp(`^${escapeRegExp(heading)}\\s*$`, 'm');
const match = pattern.exec(body);
if (!match) {
return null;
}
const sectionStart = match.index + match[0].length;
const rest = body.slice(sectionStart);
const headingIndex = requiredSections.indexOf(heading);
const nextTemplateHeading = requiredSections
.slice(headingIndex + 1)
.map((section) => {
const nextPattern = new RegExp(`^${escapeRegExp(section)}\\s*$`, 'm');
const nextMatch = nextPattern.exec(rest);
return nextMatch ? nextMatch.index : -1;
})
.filter((index) => index !== -1)
.sort((left, right) => left - right)[0];
return (nextTemplateHeading === undefined ? rest : rest.slice(0, nextTemplateHeading)).trim();
}
function hasContent(section) {
return typeof section === 'string' && section.trim().length > 0;
}
async function getPermission(username) {
try {
const { data: permissionLevel } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username,
});
return permissionLevel.permission;
} catch {
return null;
}
}
const maintainerPermissions = ['admin', 'maintain', 'write'];
const authorPermission = await getPermission(author);
if (maintainerPermissions.includes(authorPermission)) {
console.log(`${author} is a collaborator with ${authorPermission} access; leaving issue open`);
return;
}
const senderPermission = sender === author ? authorPermission : await getPermission(sender);
if (maintainerPermissions.includes(senderPermission)) {
console.log(`${sender} opened this issue with ${senderPermission} access; leaving issue open`);
return;
}
const body = issue.body || '';
const hasBugConfirmation = bugConfirmationPattern.test(body);
const hasBugTemplate = requiredSections.every((section) => body.includes(section));
const currentBehavior = extractSection(body, '### Current behavior');
const expectedBehavior = extractSection(body, '### Expected behavior');
const reproduction = extractSection(body, '### Reproduction');
const impact = extractSection(body, '### Impact');
const environment = extractSection(body, '### Environment');
const hasRequiredContent = [
currentBehavior,
expectedBehavior,
reproduction,
impact,
].every(hasContent);
const hasEnvironmentFields = hasContent(environment) && requiredEnvironmentFields.every((pattern) => pattern.test(environment));
if (hasBugConfirmation && hasBugTemplate && hasRequiredContent && hasEnvironmentFields) {
console.log(`#${issue.number} matches the bug report template`);
return;
}
if (hasBugConfirmation && hasBugTemplate && hasRequiredContent && !hasEnvironmentFields) {
const message = [
`hi @${author}, thanks for opening this.`,
'',
'this bug report is missing required environment details. please edit the issue and fill in herdr version, operating system, and terminal.',
'',
'shell and relevant config are optional, but they help when they affect the bug.',
].join('\n');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
body: message,
});
console.log(`#${issue.number} is missing required environment details; leaving issue open for correction`);
return;
}
const message = [
`hi @${author}, thanks for opening this.`,
'',
'this issue does not appear to use the full bug report template. herdr issues are only for reproducible bugs and maintainer-created or maintainer-converted work items.',
'',
'bug reports must include the reproducible-bug confirmation, current behavior, expected behavior, reproduction, impact, and environment fields with herdr version, operating system, and terminal.',
'',
'feature requests, ideas, questions, contribution proposals, and direction checks belong in discussions:',
`https://github.com/${context.repo.owner}/${context.repo.repo}/discussions`,
'',
'please read the contributing guidelines before opening issues or prs:',
`https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${context.payload.repository.default_branch}/CONTRIBUTING.md`,
'',
'closing this so the issue tracker stays limited to actionable bug reports.',
].join('\n');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
body: message,
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
state: 'closed',
state_reason: 'not_planned',
});
name: Close pending-release issues
on:
push:
branches: [master]
permissions:
contents: read
issues: write
env:
NEXT_RELEASE_LABEL: pending-release
jobs:
close:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 0
persist-credentials: false
- name: Close issues referenced by pushed commits
shell: bash
env:
GH_TOKEN: ${{ secrets.KANGAL_GITHUB_TOKEN }}
run: |
set -euo pipefail
echo "Using GitHub token for $(gh api user --jq .login)."
BEFORE="${{ github.event.before }}"
AFTER="${{ github.sha }}"
ZERO_SHA="0000000000000000000000000000000000000000"
if [ "$BEFORE" = "$ZERO_SHA" ]; then
RANGE_ARGS=(-1 "$AFTER")
echo "Scanning latest commit $AFTER for refs #<issue> lines."
else
RANGE_ARGS=("$BEFORE..$AFTER")
echo "Scanning pushed commits in $BEFORE..$AFTER for refs #<issue> lines."
fi
mapfile -t ISSUES < <(
git log --format=%b "${RANGE_ARGS[@]}" \
| perl -ne 'print "$1\n" if /^\s*refs\s+#([0-9]+)\s*$/i' \
| sort -nu
)
if [ "${#ISSUES[@]}" -eq 0 ]; then
echo "No issue refs found."
exit 0
fi
if ! gh api "repos/${GITHUB_REPOSITORY}/labels/${NEXT_RELEASE_LABEL}" >/dev/null 2>&1; then
gh api -X POST "repos/${GITHUB_REPOSITORY}/labels" \
-f name="$NEXT_RELEASE_LABEL" \
-f color="5319E7" \
-f description="Implemented on master and pending a published release." \
>/dev/null \
|| gh api "repos/${GITHUB_REPOSITORY}/labels/${NEXT_RELEASE_LABEL}" >/dev/null
fi
PENDING_COMMENT_MARKER="<!-- herdr:pending-release -->"
PENDING_COMMENT="${PENDING_COMMENT_MARKER}"$'\n'"Implemented on master and queued for the next release. This is not available in a published Herdr release yet."
issue_has_comment_marker() {
local issue="$1"
local marker="$2"
local comments
if ! comments="$(gh api --paginate "repos/${GITHUB_REPOSITORY}/issues/${issue}/comments?per_page=100" --jq '.[].body')"; then
echo "::warning::Could not read comments for issue #$issue."
return 2
fi
grep -F -- "$marker" >/dev/null <<<"$comments"
}
for issue in "${ISSUES[@]}"; do
echo "Checking #$issue"
if ! data="$(gh api "repos/${GITHUB_REPOSITORY}/issues/${issue}")"; then
echo "::warning::Could not read issue #$issue; skipping."
continue
fi
if jq -e 'has("pull_request")' <<<"$data" >/dev/null; then
echo "Skipping #$issue because it is a pull request."
continue
fi
ISSUE_STATE="$(jq -r '.state' <<<"$data")"
HAS_NEXT_RELEASE_LABEL="$(jq -r --arg label "$NEXT_RELEASE_LABEL" 'any(.labels[].name; . == $label)' <<<"$data")"
if [ "$HAS_NEXT_RELEASE_LABEL" != "true" ]; then
if ! gh issue edit "$issue" --repo "$GITHUB_REPOSITORY" --add-label "$NEXT_RELEASE_LABEL"; then
echo "::warning::Could not label issue #$issue."
continue
fi
fi
MARKER_STATUS=0
issue_has_comment_marker "$issue" "$PENDING_COMMENT_MARKER" || MARKER_STATUS="$?"
if [ "$MARKER_STATUS" -eq 2 ]; then
continue
fi
HAS_PENDING_COMMENT="false"
if [ "$MARKER_STATUS" -eq 0 ]; then
HAS_PENDING_COMMENT="true"
fi
if [ "$ISSUE_STATE" = "open" ]; then
if [ "$HAS_PENDING_COMMENT" = "true" ]; then
if ! gh issue close "$issue" --repo "$GITHUB_REPOSITORY" --reason completed; then
echo "::warning::Could not close issue #$issue."
continue
fi
elif ! gh issue close "$issue" --repo "$GITHUB_REPOSITORY" --reason completed --comment "$PENDING_COMMENT"; then
echo "::warning::Could not close issue #$issue."
continue
fi
elif [ "$HAS_PENDING_COMMENT" != "true" ]; then
if ! gh issue comment "$issue" --repo "$GITHUB_REPOSITORY" --body "$PENDING_COMMENT"; then
echo "::warning::Could not comment on closed issue #$issue."
continue
fi
else
echo "Skipping pending-release comment for #$issue because it already exists."
fi
done
name: Nix
on:
pull_request:
types: [opened, synchronize, reopened]
paths:
- ".github/workflows/nix.yml"
- "assets/**"
- "build.rs"
- "Cargo.lock"
- "Cargo.toml"
- "flake.lock"
- "flake.nix"
- "nix/**"
- "vendor/**"
push:
branches: [master]
paths:
- ".github/workflows/nix.yml"
- "assets/**"
- "build.rs"
- "Cargo.lock"
- "Cargo.toml"
- "flake.lock"
- "flake.nix"
- "nix/**"
- "vendor/**"
permissions:
contents: read
concurrency:
group: nix-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
flake-check:
name: flake check
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- name: Install Nix
uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
with:
extra_nix_config: |
experimental-features = nix-command flakes
always-allow-substitutes = true
- name: Run Nix flake check
run: |
nix flake check --print-build-logs
nix flake check --all-systems --no-build --print-build-logs
name: PR Gate
on:
pull_request_target:
types: [opened, reopened]
jobs:
check-contributor:
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
pull-requests: write
steps:
- name: Check if contributor is approved
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
github-token: ${{ secrets.KANGAL_GITHUB_TOKEN }}
script: |
const prAuthor = context.payload.pull_request.user.login;
const reopener = context.payload.sender?.login ?? null;
const action = context.payload.action;
const defaultBranch = context.payload.repository.default_branch;
const maintainerPermissions = ['admin', 'maintain', 'write'];
if (prAuthor.endsWith('[bot]') || prAuthor === 'dependabot[bot]') {
console.log(`Skipping bot: ${prAuthor}`);
return;
}
async function getPermission(username) {
try {
const { data: permissionLevel } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username,
});
return permissionLevel.permission;
} catch {
return null;
}
}
async function getTextFile(path) {
const { data: fileContent } = await github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path,
ref: defaultBranch,
});
if (!('content' in fileContent) || typeof fileContent.content !== 'string') {
throw new Error(`Expected file content for ${path}`);
}
return Buffer.from(fileContent.content, 'base64').toString('utf8');
}
async function closePullRequest(message) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body: message,
});
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request.number,
state: 'closed',
});
}
const authorPermission = await getPermission(prAuthor);
if (maintainerPermissions.includes(authorPermission)) {
console.log(`${prAuthor} is a collaborator with ${authorPermission} access`);
return;
}
if (action === 'reopened') {
const reopenerPermission = reopener ? await getPermission(reopener) : null;
if (maintainerPermissions.includes(reopenerPermission)) {
console.log(`${reopener} reopened this PR with ${reopenerPermission} access; leaving it open`);
return;
}
}
const approvedContent = await getTextFile('.github/APPROVED_CONTRIBUTORS');
const approvedList = approvedContent
.split('\n')
.map(line => line.trim().toLowerCase())
.filter(line => line && !line.startsWith('#'));
const isApprovedContributor = approvedList.includes(prAuthor.toLowerCase());
if (isApprovedContributor) {
console.log(`${prAuthor} is in the approved contributors list`);
return;
}
console.log(`${prAuthor} is not approved, closing PR`);
const message = [
`Hi @${prAuthor}, thanks for your interest in contributing!`,
'',
'New contributors need maintainer approval before opening PRs. This keeps review time focused on accepted work and avoids wasted effort.',
'',
'Herdr is opinionated about how it should look, feel, and work. Feature requests, ideas, questions, contribution proposals, and product-direction checks belong in Discussions, not issues.',
'',
'**Next steps:**',
'1. If this is a feature or behavior change, open a Discussion describing what you want to change and why',
'2. If the work is accepted, a maintainer may convert the discussion into an issue or create a new issue for it',
'3. If this is a reproducible bug, open a bug report using the issue template',
'4. Wait for maintainer approval on an accepted issue before opening a PR',
' Maintainers approve first-time PR paths with `/approve @your-github-username` on the accepted issue.',
'5. Keep it concise and write in your own voice',
'',
'A discussion, issue, branch, or proposed implementation does not reserve the work and does not mean the PR path is approved.',
'',
`This PR will be closed automatically. See https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md for more details.`,
].join('\n');
await closePullRequest(message);
name: Preview
on:
workflow_dispatch:
inputs:
commit:
description: Optional master commit SHA to publish
required: false
type: string
permissions:
contents: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
concurrency:
group: preview-publish
cancel-in-progress: false
jobs:
preflight:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
should_publish: ${{ steps.plan.outputs.should_publish }}
commit: ${{ steps.plan.outputs.commit }}
short_sha: ${{ steps.plan.outputs.short_sha }}
build_id: ${{ steps.plan.outputs.build_id }}
tag: ${{ steps.plan.outputs.tag }}
built_at: ${{ steps.plan.outputs.built_at }}
base_version: ${{ steps.plan.outputs.base_version }}
protocol: ${{ steps.plan.outputs.protocol }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: master
fetch-depth: 0
persist-credentials: false
- name: Select preview commit
id: plan
shell: bash
run: |
set -euo pipefail
git fetch origin master --tags
requested="${{ github.event.inputs.commit || '' }}"
if [ -n "$requested" ]; then
commit="$(git rev-parse "$requested^{commit}")"
if ! git merge-base --is-ancestor "$commit" origin/master; then
echo "error: requested commit $commit is not reachable from origin/master" >&2
exit 1
fi
else
commit="$(python3 scripts/preview.py select-commit --ref origin/master)"
fi
current_preview="$(python3 scripts/preview.py current-commit --manifest website/preview.json || true)"
if [ "$current_preview" = "$commit" ]; then
echo "Preview already points at $commit; skipping."
echo "should_publish=false" >> "$GITHUB_OUTPUT"
exit 0
fi
git checkout --detach "$commit"
short_sha="$(git rev-parse --short=12 HEAD)"
day="$(git show -s --format=%cs HEAD)"
built_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
build_id="$day-$short_sha"
tag="preview-$build_id"
base_version="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)"
protocol="$(python3 -c 'import re; print(re.search(r"pub const PROTOCOL_VERSION: u32 = (\d+);", open("src/protocol/wire.rs").read()).group(1))')"
{
echo "should_publish=true"
echo "commit=$commit"
echo "short_sha=$short_sha"
echo "build_id=$build_id"
echo "tag=$tag"
echo "built_at=$built_at"
echo "base_version=$base_version"
echo "protocol=$protocol"
} >> "$GITHUB_OUTPUT"
- name: Install Rust
if: steps.plan.outputs.should_publish == 'true'
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
- name: Install Rust tools
if: steps.plan.outputs.should_publish == 'true'
uses: taiki-e/install-action@fd2f5e3d644b484055ebf4268f474c565f148f25 # v2.81.9
with:
tool: just,cargo-nextest
- name: Install Bun
if: steps.plan.outputs.should_publish == 'true'
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.3.14
- name: Install Zig
if: steps.plan.outputs.should_publish == 'true'
uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1
with:
version: 0.15.2
- name: Restore cargo cache
if: steps.plan.outputs.should_publish == 'true'
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
cache-bin: false
- name: Run checks
if: steps.plan.outputs.should_publish == 'true'
run: just check
build:
needs: preflight
if: needs.preflight.outputs.should_publish == 'true'
permissions:
contents: read
strategy:
fail-fast: false
matrix:
include:
- target: x86_64-unknown-linux-musl
os: ubuntu-latest
name: herdr-linux-x86_64
- target: aarch64-unknown-linux-musl
os: ubuntu-latest
name: herdr-linux-aarch64
- target: x86_64-apple-darwin
os: macos-latest
name: herdr-macos-x86_64
- target: aarch64-apple-darwin
os: macos-latest
name: herdr-macos-aarch64
- target: x86_64-pc-windows-msvc
os: windows-latest
name: herdr-windows-x86_64.exe
runs-on: ${{ matrix.os }}
env:
LIBGHOSTTY_VT_OPTIMIZE: ReleaseFast
LIBGHOSTTY_VT_SIMD: 'true'
HERDR_BUILD_CHANNEL: preview
HERDR_BUILD_ID: ${{ needs.preflight.outputs.build_id }}
HERDR_BUILD_COMMIT: ${{ needs.preflight.outputs.commit }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: ${{ needs.preflight.outputs.commit }}
persist-credentials: false
- name: Install Rust
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
targets: ${{ matrix.target }}
- name: Install Zig
uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1
with:
version: 0.15.2
- name: Prefer official Ubuntu mirrors over Azure
if: runner.os == 'Linux'
run: |
if [ -f /etc/apt/apt-mirrors.txt ]; then
sudo sed -i '/azure.archive.ubuntu.com/d' /etc/apt/apt-mirrors.txt
cat /etc/apt/apt-mirrors.txt
fi
- name: Install Linux build tools
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y cmake ninja-build musl-tools gcc-aarch64-linux-gnu crossbuild-essential-arm64
- name: Install macOS build tools
if: runner.os == 'macOS'
run: brew install cmake ninja
- name: Set Linux aarch64 linker
if: matrix.target == 'aarch64-unknown-linux-musl'
run: echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=aarch64-linux-gnu-gcc" >> $GITHUB_ENV
- name: Cache Rust artifacts
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
key: preview-${{ matrix.target }}
- name: Remove Zig caches
if: runner.os != 'Windows'
run: rm -rf .zig-cache vendor/libghostty-vt/.zig-cache vendor/libghostty-vt/zig-out
- name: Remove Zig caches
if: runner.os == 'Windows'
shell: pwsh
run: |
Remove-Item -Recurse -Force .zig-cache -ErrorAction SilentlyContinue
Remove-Item -Recurse -Force vendor/libghostty-vt/.zig-cache -ErrorAction SilentlyContinue
Remove-Item -Recurse -Force vendor/libghostty-vt/zig-out -ErrorAction SilentlyContinue
- name: Build
run: cargo build --release --locked --target ${{ matrix.target }}
- name: Package artifact
if: runner.os != 'Windows'
shell: bash
run: |
set -euo pipefail
cp target/${{ matrix.target }}/release/herdr ${{ matrix.name }}
if [ "${{ runner.os }}" = "Linux" ]; then
file ${{ matrix.name }} > BUILD_INFO.txt
ldd ${{ matrix.name }} > LDD_INFO.txt 2>&1 || true
cat LDD_INFO.txt >> BUILD_INFO.txt
grep -Eq "statically linked|not a dynamic executable" LDD_INFO.txt
if nm -u ${{ matrix.name }} 2>/dev/null | grep -E '(__cxa|GLIBCXX|CXXABI|_ZSt)'; then
echo "error: Linux artifact has unresolved C++ runtime symbols" >&2
exit 1
fi
else
file ${{ matrix.name }} > BUILD_INFO.txt
fi
python3 - <<'PY' | tee ${{ matrix.name }}.sha256
import hashlib, pathlib
path = pathlib.Path('${{ matrix.name }}')
print(f"{hashlib.sha256(path.read_bytes()).hexdigest()} {path.name}")
PY
{
echo "commit=${{ needs.preflight.outputs.commit }}"
echo "build_id=${{ needs.preflight.outputs.build_id }}"
echo "target=${{ matrix.target }}"
echo "channel=preview"
} >> BUILD_INFO.txt
- name: Package artifact
if: runner.os == 'Windows'
shell: pwsh
run: |
Copy-Item target\${{ matrix.target }}\release\herdr.exe ${{ matrix.name }}
$hash = (Get-FileHash -Algorithm SHA256 ${{ matrix.name }}).Hash.ToLowerInvariant()
"$hash ${{ matrix.name }}" | Out-File -Encoding ascii ${{ matrix.name }}.sha256
"commit=${{ needs.preflight.outputs.commit }}" | Out-File -Encoding utf8 BUILD_INFO.txt
"build_id=${{ needs.preflight.outputs.build_id }}" | Out-File -Encoding utf8 -Append BUILD_INFO.txt
"target=${{ matrix.target }}" | Out-File -Encoding utf8 -Append BUILD_INFO.txt
"channel=preview" | Out-File -Encoding utf8 -Append BUILD_INFO.txt
- name: Upload artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ${{ matrix.name }}
path: |
${{ matrix.name }}
${{ matrix.name }}.sha256
BUILD_INFO.txt
publish:
needs: [preflight, build]
if: needs.preflight.outputs.should_publish == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
issues: write
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: master
fetch-depth: 0
persist-credentials: false
- name: Download all artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
path: artifacts
- name: Record previous preview commit
id: previous-preview
shell: bash
run: |
set -euo pipefail
previous="$(python3 scripts/preview.py current-commit --manifest website/preview.json || true)"
if [ -z "$previous" ]; then
previous="$(git describe --tags --match 'v[0-9]*' --abbrev=0)"
fi
range_base="$(python3 scripts/preview.py range-base \
--previous "$previous" \
--commit '${{ needs.preflight.outputs.commit }}')"
echo "commit=$previous" >> "$GITHUB_OUTPUT"
echo "range_base=$range_base" >> "$GITHUB_OUTPUT"
- name: Generate notes and checksums
shell: bash
run: |
set -euo pipefail
python3 scripts/preview.py notes \
--previous '${{ steps.previous-preview.outputs.range_base }}' \
--commit '${{ needs.preflight.outputs.commit }}' \
--build-id '${{ needs.preflight.outputs.build_id }}' \
--base-version '${{ needs.preflight.outputs.base_version }}' \
--output PREVIEW_NOTES.md
python3 - <<'PY'
import json, pathlib
result = {}
for path in pathlib.Path('artifacts').glob('herdr-*/*.sha256'):
digest, name = path.read_text().split()[:2]
target = name.removeprefix('herdr-').removesuffix('.exe')
result[target] = digest
pathlib.Path('preview-sha256.json').write_text(json.dumps(result, indent=2) + '\n')
PY
- name: Create preview prerelease
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3
with:
tag_name: ${{ needs.preflight.outputs.tag }}
name: Preview build ${{ needs.preflight.outputs.build_id }}
body_path: PREVIEW_NOTES.md
prerelease: true
make_latest: false
overwrite_files: true
target_commitish: ${{ needs.preflight.outputs.commit }}
files: |
artifacts/herdr-linux-x86_64/herdr-linux-x86_64
artifacts/herdr-linux-aarch64/herdr-linux-aarch64
artifacts/herdr-macos-x86_64/herdr-macos-x86_64
artifacts/herdr-macos-aarch64/herdr-macos-aarch64
artifacts/herdr-windows-x86_64.exe/herdr-windows-x86_64.exe
- name: Update preview manifest
run: |
python3 scripts/preview.py manifest \
--output website/preview.json \
--tag '${{ needs.preflight.outputs.tag }}' \
--build-id '${{ needs.preflight.outputs.build_id }}' \
--commit '${{ needs.preflight.outputs.commit }}' \
--built-at '${{ needs.preflight.outputs.built_at }}' \
--base-version '${{ needs.preflight.outputs.base_version }}' \
--protocol '${{ needs.preflight.outputs.protocol }}' \
--notes PREVIEW_NOTES.md \
--sha-file preview-sha256.json \
--retain 30
- name: Commit preview manifest
env:
KANGAL_GITHUB_TOKEN: ${{ secrets.KANGAL_GITHUB_TOKEN }}
run: |
git config user.name "kangal-bot"
git config user.email "285672167+kangal-bot@users.noreply.github.com"
git add website/preview.json
git diff --cached --quiet || git commit -m "docs: update preview manifest"
git push "https://x-access-token:${KANGAL_GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" HEAD:master
- name: Mark preview-released issues
continue-on-error: true
shell: bash
env:
GH_TOKEN: ${{ secrets.KANGAL_GITHUB_TOKEN }}
PREVIEW_RELEASED_LABEL: preview-released
run: |
set -euo pipefail
echo "Using GitHub token for $(gh api user --jq .login)."
PREVIEW_COMMIT='${{ needs.preflight.outputs.commit }}'
PREVIEW_TAG='${{ needs.preflight.outputs.tag }}'
PREVIEW_BUILD_ID='${{ needs.preflight.outputs.build_id }}'
PREVIOUS_PREVIEW_COMMIT='${{ steps.previous-preview.outputs.commit }}'
PREVIEW_RANGE_BASE='${{ steps.previous-preview.outputs.range_base }}'
echo "Previous preview commit: $PREVIOUS_PREVIEW_COMMIT"
echo "Scanning preview commits in $PREVIEW_RANGE_BASE..$PREVIEW_COMMIT for refs #<issue> mentions."
mapfile -t ISSUES < <(
git log --format='%s%n%b' "$PREVIEW_RANGE_BASE..$PREVIEW_COMMIT" \
| perl -ne 'print "$1\n" if /\brefs\s+#([0-9]+)\b/i' \
| sort -nu
)
if [ "${#ISSUES[@]}" -eq 0 ]; then
echo "No preview issue refs found."
exit 0
fi
if ! gh api "repos/${GITHUB_REPOSITORY}/labels/${PREVIEW_RELEASED_LABEL}" >/dev/null 2>&1; then
gh api -X POST "repos/${GITHUB_REPOSITORY}/labels" \
-f name="$PREVIEW_RELEASED_LABEL" \
-f color="1D76DB" \
-f description="Available on the preview channel, pending a stable release." \
>/dev/null \
|| gh api "repos/${GITHUB_REPOSITORY}/labels/${PREVIEW_RELEASED_LABEL}" >/dev/null
fi
PREVIEW_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${PREVIEW_TAG}"
PREVIEW_COMMENT_MARKER="<!-- herdr:preview-released:${PREVIEW_TAG} -->"
PREVIEW_COMMENT="${PREVIEW_COMMENT_MARKER}"$'\n'"Released on the preview channel in [${PREVIEW_BUILD_ID}](${PREVIEW_URL}). This is available to preview users, but is not in a stable Herdr release yet."
issue_has_comment_marker() {
local issue="$1"
local marker="$2"
local comments
if ! comments="$(gh api --paginate "repos/${GITHUB_REPOSITORY}/issues/${issue}/comments?per_page=100" --jq '.[].body')"; then
echo "::warning::Could not read comments for issue #$issue."
return 2
fi
grep -F -- "$marker" >/dev/null <<<"$comments"
}
for issue in "${ISSUES[@]}"; do
echo "Checking #$issue"
if ! data="$(gh api "repos/${GITHUB_REPOSITORY}/issues/${issue}")"; then
echo "::warning::Could not read issue #$issue; skipping."
continue
fi
if jq -e 'has("pull_request")' <<<"$data" >/dev/null; then
echo "Skipping #$issue because it is a pull request."
continue
fi
HAS_PREVIEW_LABEL="$(jq -r --arg label "$PREVIEW_RELEASED_LABEL" 'any(.labels[].name; . == $label)' <<<"$data")"
if [ "$HAS_PREVIEW_LABEL" != "true" ]; then
if ! gh issue edit "$issue" --repo "$GITHUB_REPOSITORY" --add-label "$PREVIEW_RELEASED_LABEL"; then
echo "::warning::Could not label issue #$issue."
continue
fi
fi
MARKER_STATUS=0
issue_has_comment_marker "$issue" "$PREVIEW_COMMENT_MARKER" || MARKER_STATUS="$?"
if [ "$MARKER_STATUS" -eq 2 ]; then
continue
fi
if [ "$MARKER_STATUS" -eq 0 ]; then
echo "Skipping preview release comment for #$issue because it already exists."
continue
fi
if ! gh issue comment "$issue" --repo "$GITHUB_REPOSITORY" --body "$PREVIEW_COMMENT"; then
echo "::warning::Could not comment on issue #$issue."
continue
fi
done
- name: Prune old preview prereleases
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
gh release list --repo "$GITHUB_REPOSITORY" --limit 100 --json tagName,isPrerelease,createdAt > preview-releases.json
python3 - <<'PY' > old-preview-tags.txt
import json
with open("preview-releases.json", encoding="utf-8") as handle:
data = json.load(handle)
releases = [
release for release in data
if release.get("isPrerelease") and str(release.get("tagName", "")).startswith("preview-")
]
releases.sort(key=lambda release: str(release.get("createdAt", "")), reverse=True)
for release in releases[30:]:
print(release["tagName"])
PY
while IFS= read -r tag; do
[ -n "$tag" ] || continue
gh release delete "$tag" --repo "$GITHUB_REPOSITORY" --yes --cleanup-tag
done < old-preview-tags.txt
name: Release
on:
push:
tags:
- "v*"
permissions:
contents: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
flake-check:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- name: Install Nix
uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6
with:
extra_nix_config: |
experimental-features = nix-command flakes
always-allow-substitutes = true
- name: Run Nix flake check
run: |
nix flake check --print-build-logs
nix flake check --all-systems --no-build --print-build-logs
build:
permissions:
contents: read
strategy:
matrix:
include:
- target: x86_64-unknown-linux-musl
os: ubuntu-latest
name: herdr-linux-x86_64
libghostty_vt_optimize: ReleaseFast
libghostty_vt_simd: 'true'
- target: aarch64-unknown-linux-musl
os: ubuntu-latest
name: herdr-linux-aarch64
libghostty_vt_optimize: ReleaseFast
libghostty_vt_simd: 'true'
- target: x86_64-apple-darwin
os: macos-latest
name: herdr-macos-x86_64
libghostty_vt_optimize: ReleaseFast
libghostty_vt_simd: 'true'
- target: aarch64-apple-darwin
os: macos-latest
name: herdr-macos-aarch64
libghostty_vt_optimize: ReleaseFast
libghostty_vt_simd: 'true'
runs-on: ${{ matrix.os }}
env:
LIBGHOSTTY_VT_OPTIMIZE: ${{ matrix.libghostty_vt_optimize }}
LIBGHOSTTY_VT_SIMD: ${{ matrix.libghostty_vt_simd }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- name: Verify tag matches Cargo.toml version
run: |
CARGO_VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/')
TAG_VERSION="${GITHUB_REF_NAME#v}"
if [ "$CARGO_VERSION" != "$TAG_VERSION" ]; then
echo "error: tag $GITHUB_REF_NAME doesn't match Cargo.toml version $CARGO_VERSION"
exit 1
fi
- name: Install Rust
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
targets: ${{ matrix.target }}
- name: Install Zig
uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1
with:
version: 0.15.2
- name: Prefer official Ubuntu mirrors over Azure
if: runner.os == 'Linux'
run: |
if [ -f /etc/apt/apt-mirrors.txt ]; then
sudo sed -i '/azure.archive.ubuntu.com/d' /etc/apt/apt-mirrors.txt
echo "Using apt mirrors:"
cat /etc/apt/apt-mirrors.txt
fi
- name: Install Linux build tools
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y cmake ninja-build musl-tools gcc-aarch64-linux-gnu crossbuild-essential-arm64
- name: Install macOS build tools
if: runner.os == 'macOS'
run: brew install cmake ninja
- name: Set Linux aarch64 linker
if: matrix.target == 'aarch64-unknown-linux-musl'
run: echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=aarch64-linux-gnu-gcc" >> $GITHUB_ENV
- name: Cache Rust artifacts
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
key: release-${{ matrix.target }}
- name: Remove Zig caches
run: rm -rf .zig-cache vendor/libghostty-vt/.zig-cache vendor/libghostty-vt/zig-out
- name: Build
run: cargo build --release --locked --target ${{ matrix.target }}
- name: Rename binary
run: cp target/${{ matrix.target }}/release/herdr ${{ matrix.name }}
- name: Upload artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ${{ matrix.name }}
path: ${{ matrix.name }}
validate-release-inputs:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- name: Validate product announcement
run: python3 scripts/changelog.py validate-product-announcement
release:
needs: [build, flake-check, validate-release-inputs]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- name: Download all artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
- name: Extract release notes from changelog
run: python3 scripts/changelog.py extract --version "${GITHUB_REF_NAME#v}" --output RELEASE_NOTES.md
- name: Create release
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3
with:
files: |
herdr-linux-x86_64/herdr-linux-x86_64
herdr-linux-aarch64/herdr-linux-aarch64
herdr-macos-x86_64/herdr-macos-x86_64
herdr-macos-aarch64/herdr-macos-aarch64
body_path: RELEASE_NOTES.md
close-released-issues:
needs: release
runs-on: ubuntu-latest
continue-on-error: true
permissions:
contents: read
issues: write
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 0
persist-credentials: false
- name: Close issues referenced by released commits
shell: bash
env:
GH_TOKEN: ${{ secrets.KANGAL_GITHUB_TOKEN }}
NEXT_RELEASE_LABEL: pending-release
LEGACY_NEXT_RELEASE_LABEL: included-in-next-release
PREVIEW_RELEASED_LABEL: preview-released
run: |
set -euo pipefail
echo "Using GitHub token for $(gh api user --jq .login)."
VERSION="${GITHUB_REF_NAME#v}"
CURRENT_COMMIT="$(git rev-list -n 1 "$GITHUB_REF_NAME")"
PREVIOUS_TAG="$(git describe --first-parent --tags --match 'v[0-9]*' --abbrev=0 "${CURRENT_COMMIT}^" 2>/dev/null || true)"
if [ -z "$PREVIOUS_TAG" ]; then
echo "No previous release tag found; skipping issue close."
exit 0
fi
echo "Scanning released commits in $PREVIOUS_TAG..$GITHUB_REF_NAME for refs #<issue> mentions."
mapfile -t ISSUES < <(
git log --format='%s%n%b' "$PREVIOUS_TAG..$CURRENT_COMMIT" \
| perl -ne 'print "$1\n" if /\brefs\s+#([0-9]+)\b/i' \
| sort -nu
)
if [ "${#ISSUES[@]}" -eq 0 ]; then
echo "No released issue refs found."
exit 0
fi
RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/v${VERSION}"
RELEASE_COMMENT_MARKER="<!-- herdr:released:v${VERSION} -->"
RELEASE_COMMENT="${RELEASE_COMMENT_MARKER}"$'\n'"Released in [v${VERSION}](${RELEASE_URL})."
issue_has_comment_marker() {
local issue="$1"
local marker="$2"
local comments
if ! comments="$(gh api --paginate "repos/${GITHUB_REPOSITORY}/issues/${issue}/comments?per_page=100" --jq '.[].body')"; then
echo "::warning::Could not read comments for issue #$issue."
return 2
fi
grep -F -- "$marker" >/dev/null <<<"$comments"
}
for issue in "${ISSUES[@]}"; do
echo "Checking #$issue"
if ! data="$(gh api "repos/${GITHUB_REPOSITORY}/issues/${issue}")"; then
echo "::warning::Could not read issue #$issue; skipping."
continue
fi
if jq -e 'has("pull_request")' <<<"$data" >/dev/null; then
echo "Skipping #$issue because it is a pull request."
continue
fi
HAS_NEXT_RELEASE_LABEL="$(jq -r --arg label "$NEXT_RELEASE_LABEL" 'any(.labels[].name; . == $label)' <<<"$data")"
HAS_LEGACY_NEXT_RELEASE_LABEL="$(jq -r --arg label "$LEGACY_NEXT_RELEASE_LABEL" 'any(.labels[].name; . == $label)' <<<"$data")"
HAS_PREVIEW_RELEASED_LABEL="$(jq -r --arg label "$PREVIEW_RELEASED_LABEL" 'any(.labels[].name; . == $label)' <<<"$data")"
HAS_RELEASE_TRACKING_LABEL="false"
if [ "$HAS_NEXT_RELEASE_LABEL" = "true" ] || [ "$HAS_LEGACY_NEXT_RELEASE_LABEL" = "true" ] || [ "$HAS_PREVIEW_RELEASED_LABEL" = "true" ]; then
HAS_RELEASE_TRACKING_LABEL="true"
fi
MARKER_STATUS=0
issue_has_comment_marker "$issue" "$RELEASE_COMMENT_MARKER" || MARKER_STATUS="$?"
if [ "$MARKER_STATUS" -eq 2 ]; then
continue
fi
HAS_RELEASE_COMMENT="false"
if [ "$MARKER_STATUS" -eq 0 ]; then
HAS_RELEASE_COMMENT="true"
fi
if [ "$(jq -r '.state' <<<"$data")" = "open" ]; then
if [ "$HAS_RELEASE_COMMENT" = "true" ]; then
if ! gh issue close "$issue" --repo "$GITHUB_REPOSITORY" --reason completed; then
echo "::warning::Could not close issue #$issue."
continue
fi
elif ! gh issue close "$issue" --repo "$GITHUB_REPOSITORY" --reason completed --comment "$RELEASE_COMMENT"; then
echo "::warning::Could not close issue #$issue."
continue
fi
elif [ "$HAS_RELEASE_TRACKING_LABEL" = "true" ]; then
if [ "$HAS_RELEASE_COMMENT" != "true" ]; then
if ! gh issue comment "$issue" --repo "$GITHUB_REPOSITORY" --body "$RELEASE_COMMENT"; then
echo "::warning::Could not comment on issue #$issue."
continue
fi
else
echo "Skipping release comment for #$issue because it already exists."
fi
else
echo "Skipping release comment for #$issue because it is closed and has no release tracking label."
fi
if [ "$HAS_NEXT_RELEASE_LABEL" = "true" ]; then
if ! gh issue edit "$issue" --repo "$GITHUB_REPOSITORY" --remove-label "$NEXT_RELEASE_LABEL"; then
echo "::warning::Could not remove $NEXT_RELEASE_LABEL from issue #$issue."
continue
fi
fi
if [ "$HAS_LEGACY_NEXT_RELEASE_LABEL" = "true" ]; then
if ! gh issue edit "$issue" --repo "$GITHUB_REPOSITORY" --remove-label "$LEGACY_NEXT_RELEASE_LABEL"; then
echo "::warning::Could not remove $LEGACY_NEXT_RELEASE_LABEL from issue #$issue."
continue
fi
fi
if [ "$HAS_PREVIEW_RELEASED_LABEL" = "true" ]; then
if ! gh issue edit "$issue" --repo "$GITHUB_REPOSITORY" --remove-label "$PREVIEW_RELEASED_LABEL"; then
echo "::warning::Could not remove $PREVIEW_RELEASED_LABEL from issue #$issue."
continue
fi
fi
done
update-latest-json:
needs: release
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: master
fetch-depth: 0
ssh-key: ${{ secrets.RELEASE_DEPLOY_KEY }}
- name: Update website latest manifest
env:
GH_TOKEN: ${{ github.token }}
run: |
VERSION="${GITHUB_REF_NAME#v}"
ANNOUNCEMENT_PATH="$RUNNER_TEMP/product-announcement.json"
ANNOUNCEMENT_ORIGINAL_PATH="$RUNNER_TEMP/product-announcement-original.json"
git show "${GITHUB_REF_NAME}:docs/next/product-announcement.json" > "$ANNOUNCEMENT_PATH"
cp "$ANNOUNCEMENT_PATH" "$ANNOUNCEMENT_ORIGINAL_PATH"
python3 scripts/changelog.py validate-product-announcement --path "$ANNOUNCEMENT_PATH"
RELEASE_PROTOCOL=$(git show "${GITHUB_REF_NAME}:src/protocol/wire.rs" | python3 -c 'import re, sys; match = re.search(r"pub const PROTOCOL_VERSION: u32 = (\d+);", sys.stdin.read()); sys.exit(1) if match is None else print(match.group(1))')
CURRENT_VERSION=$(python3 -c 'import json; print(json.load(open("website/latest.json")).get("version", ""))')
if [ "$CURRENT_VERSION" = "$VERSION" ]; then
echo "website/latest.json is already at v$VERSION"
exit 0
fi
python3 scripts/changelog.py sync-latest-json --version "$VERSION" --output website/latest.json --announcement "$ANNOUNCEMENT_PATH" --protocol "$RELEASE_PROTOCOL"
if cmp -s "$ANNOUNCEMENT_ORIGINAL_PATH" docs/next/product-announcement.json; then
printf 'null\n' > docs/next/product-announcement.json
else
echo "::warning::docs/next/product-announcement.json changed after $GITHUB_REF_NAME; leaving it unchanged."
fi
- name: Commit website latest manifest
run: |
VERSION="${GITHUB_REF_NAME#v}"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add website/latest.json docs/next/product-announcement.json
git diff --cached --quiet || git commit -m "docs: update website manifest for v$VERSION"
git push origin master
- name: Trigger website deploy
env:
DEPLOY_HOOK: ${{ secrets.CLOUDFLARE_PAGES_DEPLOY_HOOK }}
run: |
if [ -z "$DEPLOY_HOOK" ]; then
echo "CLOUDFLARE_PAGES_DEPLOY_HOOK not set; relying on Cloudflare git auto-deploy"
exit 0
fi
curl -fsS -X POST "$DEPLOY_HOOK" > /dev/null
echo "Triggered Cloudflare Pages production deploy"
/target
/vendor/libghostty-vt/zig-pkg/
__pycache__/
*.pyc
*.swp
*.swo
*~
.DS_Store
/docs/*
!/docs/next/
!/docs/next/**
/.local/
/backlog
/.pi/todos
/.pi/issues
/.pi/cache
/result
/result-*
/.wrangler/
**/.wrangler/
/**
* `@issue` GitHub issues and `@pr` GitHub PRs autocomplete provider.
*
* On `@issue`, `@issue:<token>`, or `@issue <token>` in the input editor, suggests open issues from the current repo.
* On `@pr`, `@pr:<token>`, or `@pr <token>` in the input editor, suggests recent PRs from the current repo.
*
* Accepting a completion inserts the reference (e.g. `issue owner/repo#123` or `pr owner/repo#45`).
* A `before_agent_start` nudge tells the agent how to fetch details with `gh`.
*
* Issues and PRs are served from cache immediately, refreshed in the background,
* and exact numeric misses are fetched on demand.
*/
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import {
fuzzyFilter,
type AutocompleteItem,
type AutocompleteProvider,
type AutocompleteSuggestions,
} from "@earendil-works/pi-tui";
// --- Constants ---
const ISSUE_PREFIX = "@issue";
const PR_PREFIX = "@pr";
/** Match `@issue`, `@issue:`, `@issue:<token>`, or `@issue <token>` at end of text before cursor. */
const ISSUE_TOKEN_RE = /(?:^|\s)(@issue(?:(?::|\s+)\s*([^\s@]*))?)$/;
/** Match `@pr`, `@pr:`, `@pr:<token>`, or `@pr <token>` at end of text before cursor. */
const PR_TOKEN_RE = /(?:^|\s)(@pr(?:(?::|\s+)\s*([^\s@]*))?)$/;
const MAX_SUGGESTIONS = 20;
const GH_TIMEOUT_MS = 5_000;
const CACHE_REFRESH_INTERVAL_MS = 30_000;
// --- Types ---
interface RepoInfo {
owner: string;
repo: string;
/** `owner/repo` string */
fullName: string;
}
interface IssueInfo {
number: number;
title: string;
labels: string;
author: string;
updatedAt: string;
}
interface PRInfo {
number: number;
title: string;
state: string;
author: string;
headRefName: string;
updatedAt: string;
isDraft: boolean;
}
interface CachedData {
repo: RepoInfo | null;
issues: IssueInfo[];
prs: PRInfo[];
fetchedAt?: string;
}
function getCachePath(cwd: string): string {
return join(cwd, ".pi", "cache", "github-refs.json");
}
async function readCache(cwd: string): Promise<CachedData> {
try {
const raw = await readFile(getCachePath(cwd), "utf8");
const data = JSON.parse(raw) as CachedData;
return {
repo: data.repo ?? null,
issues: Array.isArray(data.issues) ? data.issues : [],
prs: Array.isArray(data.prs)
? data.prs.map((pr) => ({ ...pr, state: pr.state ?? "OPEN" }))
: [],
fetchedAt: typeof data.fetchedAt === "string" ? data.fetchedAt : undefined,
};
} catch {
return { repo: null, issues: [], prs: [] };
}
}
async function writeCache(cwd: string, data: CachedData): Promise<void> {
const cachePath = getCachePath(cwd);
await mkdir(join(cwd, ".pi", "cache"), { recursive: true });
await writeFile(cachePath, JSON.stringify(data, null, 2), "utf8");
}
function replaceData(target: CachedData, source: CachedData): void {
target.repo = source.repo;
target.issues = source.issues;
target.prs = source.prs;
target.fetchedAt = source.fetchedAt;
}
function cacheTimestamp(data: CachedData): number {
const parsed = data.fetchedAt ? Date.parse(data.fetchedAt) : 0;
return Number.isFinite(parsed) ? parsed : 0;
}
function replaceDataIfNewer(target: CachedData, source: CachedData): void {
if (cacheTimestamp(source) >= cacheTimestamp(target)) {
replaceData(target, source);
}
}
function upsertIssue(data: CachedData, issue: IssueInfo): void {
data.issues = [issue, ...data.issues.filter((item) => item.number !== issue.number)];
}
function upsertPR(data: CachedData, pr: PRInfo): void {
data.prs = [pr, ...data.prs.filter((item) => item.number !== pr.number)];
}
function shouldRefresh(data: CachedData): boolean {
const fetchedAt = data.fetchedAt ? Date.parse(data.fetchedAt) : 0;
return !Number.isFinite(fetchedAt) || Date.now() - fetchedAt > CACHE_REFRESH_INTERVAL_MS;
}
// --- Shell helpers (use pi.exec) ---
function repoInfoFromRemoteUrl(remoteUrl: string): RepoInfo | null {
const match = remoteUrl.trim().match(
/(?:github\.com[:/])([^/]+)\/([^/\s]+?)(?:\.git)?$/,
);
const owner = match?.[1] ?? "";
const repo = match?.[2] ?? "";
if (!owner || !repo) return null;
return { owner, repo, fullName: `${owner}/${repo}` };
}
async function fetchRepoName(
exec: ExtensionAPI["exec"],
cwd: string,
): Promise<RepoInfo | null> {
const remote = await exec("git", ["remote", "get-url", "origin"], {
cwd,
timeout: 1_000,
});
if (remote.code === 0 && remote.stdout.trim()) {
const repo = repoInfoFromRemoteUrl(remote.stdout);
if (repo) return repo;
}
const result = await exec("gh", ["repo", "view", "--json", "owner,name"], {
cwd,
timeout: GH_TIMEOUT_MS,
});
if (result.code !== 0 || !result.stdout.trim()) return null;
try {
const data = JSON.parse(result.stdout) as {
owner: { login: string };
name: string;
};
const owner = data.owner?.login ?? "";
const repo = data.name ?? "";
if (!owner || !repo) return null;
return { owner, repo, fullName: `${owner}/${repo}` };
} catch {
return null;
}
}
async function fetchIssues(
exec: ExtensionAPI["exec"],
cwd: string,
): Promise<IssueInfo[] | null> {
const result = await exec(
"gh",
["issue", "list", "--json", "number,title,labels,author,updatedAt", "--limit", "50", "--state", "open"],
{ cwd, timeout: GH_TIMEOUT_MS },
);
if (result.code !== 0 || !result.stdout.trim()) return null;
try {
const items = JSON.parse(result.stdout) as Array<{
number: number;
title: string;
labels: Array<{ name: string }>;
author: { login: string };
updatedAt: string;
}>;
return items.map((item) => ({
number: item.number,
title: item.title,
labels: item.labels.map((l) => l.name).join(", "),
author: item.author?.login ?? "",
updatedAt: item.updatedAt,
}));
} catch {
return null;
}
}
async function fetchIssueByNumber(
exec: ExtensionAPI["exec"],
cwd: string,
number: number,
): Promise<IssueInfo | null> {
const result = await exec(
"gh",
[
"issue",
"view",
String(number),
"--json",
"number,title,labels,author,updatedAt",
],
{ cwd, timeout: GH_TIMEOUT_MS },
);
if (result.code !== 0 || !result.stdout.trim()) return null;
try {
const item = JSON.parse(result.stdout) as {
number: number;
title: string;
labels: Array<{ name: string }>;
author: { login: string };
updatedAt: string;
};
return {
number: item.number,
title: item.title,
labels: item.labels.map((l) => l.name).join(", "),
author: item.author?.login ?? "",
updatedAt: item.updatedAt,
};
} catch {
return null;
}
}
async function fetchPRs(
exec: ExtensionAPI["exec"],
cwd: string,
): Promise<PRInfo[] | null> {
const result = await exec(
"gh",
[
"pr",
"list",
"--json",
"number,title,state,author,headRefName,updatedAt,isDraft",
"--limit",
"100",
"--state",
"all",
],
{ cwd, timeout: GH_TIMEOUT_MS },
);
if (result.code !== 0 || !result.stdout.trim()) return null;
try {
const items = JSON.parse(result.stdout || "[]") as Array<{
number: number;
title: string;
state: string;
author: { login: string };
headRefName: string;
updatedAt: string;
isDraft: boolean;
}>;
return items.map((item) => ({
number: item.number,
title: item.title,
state: item.state ?? "",
author: item.author?.login ?? "",
headRefName: item.headRefName,
updatedAt: item.updatedAt,
isDraft: item.isDraft,
}));
} catch {
return null;
}
}
async function fetchPRByNumber(
exec: ExtensionAPI["exec"],
cwd: string,
number: number,
): Promise<PRInfo | null> {
const result = await exec(
"gh",
[
"pr",
"view",
String(number),
"--json",
"number,title,state,author,headRefName,updatedAt,isDraft",
],
{ cwd, timeout: GH_TIMEOUT_MS },
);
if (result.code !== 0 || !result.stdout.trim()) return null;
try {
const item = JSON.parse(result.stdout) as {
number: number;
title: string;
state: string;
author: { login: string };
headRefName: string;
updatedAt: string;
isDraft: boolean;
};
return {
number: item.number,
title: item.title,
state: item.state ?? "",
author: item.author?.login ?? "",
headRefName: item.headRefName,
updatedAt: item.updatedAt,
isDraft: item.isDraft,
};
} catch {
return null;
}
}
/** Fetch all data needed for the session in parallel. */
async function fetchIssueSnapshot(
exec: ExtensionAPI["exec"],
cwd: string,
previous?: CachedData,
): Promise<CachedData> {
const [repo, issues] = await Promise.all([
previous?.repo ? Promise.resolve(previous.repo) : fetchRepoName(exec, cwd),
fetchIssues(exec, cwd),
]);
return {
repo: repo ?? previous?.repo ?? null,
issues: issues ?? previous?.issues ?? [],
prs: previous?.prs ?? [],
fetchedAt: new Date().toISOString(),
};
}
async function fetchPRSnapshot(
exec: ExtensionAPI["exec"],
cwd: string,
previous?: CachedData,
): Promise<CachedData> {
const [repo, prs] = await Promise.all([
previous?.repo ? Promise.resolve(previous.repo) : fetchRepoName(exec, cwd),
fetchPRs(exec, cwd),
]);
return {
repo: repo ?? previous?.repo ?? null,
issues: previous?.issues ?? [],
prs: prs ?? previous?.prs ?? [],
fetchedAt: new Date().toISOString(),
};
}
// --- Autocomplete replacement ---
function replaceAutocompletePrefix(
lines: string[],
cursorLine: number,
cursorCol: number,
prefix: string,
value: string,
) {
const currentLine = lines[cursorLine] ?? "";
const beforePrefix = currentLine.slice(0, cursorCol - prefix.length);
const afterCursor = currentLine.slice(cursorCol);
const newLines = [...lines];
newLines[cursorLine] = `${beforePrefix}${value}${afterCursor}`;
return {
lines: newLines,
cursorLine,
cursorCol: beforePrefix.length + value.length,
};
}
function extractPrefixCandidate(
textBeforeCursor: string,
targetPrefix: string,
): string | undefined {
const match = textBeforeCursor.match(/(^|\s)(@\S*)$/);
const candidate = match?.[2];
if (!candidate || !targetPrefix.startsWith(candidate)) return undefined;
return candidate;
}
function createPrefixCompletionItem(
value: string,
label: string,
description: string,
): AutocompleteItem {
return { value, label, description };
}
function filterIssues(issues: IssueInfo[], token: string): IssueInfo[] {
const query = token.trim();
if (!query) return issues.slice(0, MAX_SUGGESTIONS);
if (/^\d+$/.test(query)) {
const numericMatches = issues.filter((issue) =>
String(issue.number).startsWith(query),
);
if (numericMatches.length > 0) {
return numericMatches.slice(0, MAX_SUGGESTIONS);
}
}
return fuzzyFilter(
issues,
query,
(issue) => `${issue.number} ${issue.title} ${issue.labels} ${issue.author}`,
).slice(0, MAX_SUGGESTIONS);
}
function filterPRs(prs: PRInfo[], token: string): PRInfo[] {
const query = token.trim();
if (!query) return prs.slice(0, MAX_SUGGESTIONS);
if (/^\d+$/.test(query)) {
const numericMatches = prs.filter((pr) =>
String(pr.number).startsWith(query),
);
if (numericMatches.length > 0) {
return numericMatches.slice(0, MAX_SUGGESTIONS);
}
}
return fuzzyFilter(
prs,
query,
(pr) => `${pr.number} ${pr.title} ${pr.headRefName} ${pr.state} ${pr.author}`,
).slice(0, MAX_SUGGESTIONS);
}
// --- Provider factory ---
interface ProviderRefreshOptions {
refreshFromDisk?: () => Promise<void>;
refreshIfStale?: () => void;
fetchIssueByNumber?: (number: number) => Promise<IssueInfo | null>;
fetchPRByNumber?: (number: number) => Promise<PRInfo | null>;
}
export function createGithubAutocompleteProvider(
current: AutocompleteProvider,
data: CachedData,
refreshOptions: ProviderRefreshOptions = {},
): AutocompleteProvider {
return {
async getSuggestions(
lines: string[],
cursorLine: number,
cursorCol: number,
options,
): Promise<AutocompleteSuggestions | null> {
const currentLine = lines[cursorLine] ?? "";
const textBeforeCursor = currentLine.slice(0, cursorCol);
const issueMatch = textBeforeCursor.match(ISSUE_TOKEN_RE);
const prMatch = textBeforeCursor.match(PR_TOKEN_RE);
const issuePrefix = issueMatch?.[1];
const prPrefix = prMatch?.[1];
const issueToken = issueMatch ? issueMatch[2] ?? "" : undefined;
const prToken = prMatch ? prMatch[2] ?? "" : undefined;
// Neither complete prefix matched — check for partial prefix candidates first.
// Do not await the built-in @file provider for @iss/@pr-like input: pi queues
// autocomplete requests serially, so slow file scans can delay issue results.
if (issueToken === undefined && prToken === undefined) {
const prefixItems: AutocompleteItem[] = [];
const issueCandidate = extractPrefixCandidate(textBeforeCursor, `${ISSUE_PREFIX}:`);
if (issueCandidate !== undefined) {
prefixItems.push(
createPrefixCompletionItem(ISSUE_PREFIX, ISSUE_PREFIX, "GitHub issues"),
);
}
const prCandidate = extractPrefixCandidate(textBeforeCursor, `${PR_PREFIX}:`);
if (prCandidate !== undefined) {
prefixItems.push(
createPrefixCompletionItem(PR_PREFIX, PR_PREFIX, "GitHub pull requests"),
);
}
if (prefixItems.length > 0) {
return {
items: prefixItems,
prefix: issueCandidate ?? prCandidate ?? "",
};
}
return current.getSuggestions(lines, cursorLine, cursorCol, options);
}
if (options.signal.aborted) return null;
await refreshOptions.refreshFromDisk?.();
refreshOptions.refreshIfStale?.();
const repoPrefix = data.repo ? `${data.repo.fullName}#` : "#";
// --- Issues ---
if (issueToken !== undefined) {
let filtered = filterIssues(data.issues, issueToken);
if (
filtered.length === 0 &&
/^\d{2,}$/.test(issueToken) &&
refreshOptions.fetchIssueByNumber
) {
const fetched = await refreshOptions.fetchIssueByNumber(Number(issueToken));
if (fetched) filtered = [fetched];
}
if (options.signal.aborted || filtered.length === 0) return null;
const items: AutocompleteItem[] = filtered.map((i) => ({
value: `issue ${repoPrefix}${i.number}`,
label: `#${i.number} ${i.title}`,
description: [i.labels, i.author].filter(Boolean).join(" · "),
}));
return { items, prefix: issuePrefix ?? `${ISSUE_PREFIX}:${issueToken}` };
}
// --- PRs ---
if (prToken !== undefined) {
let filtered = filterPRs(data.prs, prToken);
if (
filtered.length === 0 &&
/^\d{2,}$/.test(prToken) &&
refreshOptions.fetchPRByNumber
) {
const fetched = await refreshOptions.fetchPRByNumber(Number(prToken));
if (fetched) filtered = [fetched];
}
if (options.signal.aborted || filtered.length === 0) return null;
const items: AutocompleteItem[] = filtered.map((p) => ({
value: `pr ${repoPrefix}${p.number}`,
label: `#${p.number} ${p.title}`,
description: [
p.state.toLowerCase(),
p.headRefName,
p.author,
p.isDraft ? "draft" : "",
]
.filter(Boolean)
.join(" · "),
}));
return { items, prefix: prPrefix ?? `${PR_PREFIX}:${prToken}` };
}
return current.getSuggestions(lines, cursorLine, cursorCol, options);
},
applyCompletion(
lines: string[],
cursorLine: number,
cursorCol: number,
item: AutocompleteItem,
prefix: string,
) {
if (prefix.startsWith(ISSUE_PREFIX) || prefix.startsWith(PR_PREFIX)) {
return replaceAutocompletePrefix(
lines,
cursorLine,
cursorCol,
prefix,
item.value,
);
}
// Prefix item completion (typing toward `@issue` or `@pr`)
if (item.value === ISSUE_PREFIX || item.value === PR_PREFIX) {
return replaceAutocompletePrefix(
lines,
cursorLine,
cursorCol,
prefix,
item.value,
);
}
return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
},
shouldTriggerFileCompletion(
lines: string[],
cursorLine: number,
cursorCol: number,
) {
const currentLine = lines[cursorLine] ?? "";
const textBeforeCursor = currentLine.slice(0, cursorCol);
if (
textBeforeCursor.match(ISSUE_TOKEN_RE) ||
textBeforeCursor.match(PR_TOKEN_RE)
) {
return true;
}
return (
current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ??
true
);
},
};
}
// --- Nudge on before_agent_start ---
/** Match `issue org/repo#123` or `issue #123` */
const ISSUE_REF_RE = /issue\s+(\S+?#(\d+))/g;
/** Match `pr org/repo#45` or `pr #45` */
const PR_REF_RE = /pr\s+(\S+?#(\d+))/g;
interface RefMatch {
type: "issue" | "pr";
/** Full reference string e.g. `owner/repo#123` */
refs: string[];
}
function buildNudge(matches: RefMatch[]): string {
const lines: string[] = [];
for (const m of matches) {
const uniqueRefs = [...new Set(m.refs)];
if (m.type === "issue" && uniqueRefs.length > 0) {
lines.push(
`GitHub issues referenced: ${uniqueRefs.map((r) => `issue ${r}`).join(", ")}.`,
`To fetch issue details, use: gh issue view <number> --json number,title,body,labels,author,assignees,comments`,
`To list issues, use: gh issue list --json number,title,labels,author,updatedAt --state open`,
);
}
if (m.type === "pr" && uniqueRefs.length > 0) {
lines.push(
`GitHub PRs referenced: ${uniqueRefs.map((r) => `pr ${r}`).join(", ")}.`,
`To fetch PR details, use: gh pr view <number> --json number,title,body,headRefName,baseRefName,author,reviews,mergeable`,
`To list PRs, use: gh pr list --json number,title,state,author,headRefName,updatedAt,isDraft --state all`,
);
}
}
return lines.join("\n");
}
// --- Extension entry point ---
export default async function (pi: ExtensionAPI) {
let refreshTimer: ReturnType<typeof setInterval> | undefined;
pi.on("session_start", async (_event, ctx) => {
const cwd = ctx.cwd;
const data = await readCache(cwd);
let issueRefreshInFlight: Promise<void> | undefined;
let prRefreshInFlight: Promise<void> | undefined;
const refreshFromDisk = async () => {
const cached = await readCache(cwd);
if (
cached.repo !== null ||
cached.issues.length > 0 ||
cached.prs.length > 0 ||
cached.fetchedAt !== undefined
) {
replaceDataIfNewer(data, cached);
}
};
const refreshIssuesFromGithub = (force = false): Promise<void> => {
if (!force && !shouldRefresh(data)) return Promise.resolve();
issueRefreshInFlight ??= fetchIssueSnapshot(pi.exec, cwd, data)
.then(async (fresh) => {
replaceData(data, fresh);
await writeCache(cwd, fresh);
})
.catch(() => {
// Keep stale cache when gh is unavailable or slow.
})
.finally(() => {
issueRefreshInFlight = undefined;
});
return issueRefreshInFlight;
};
const refreshPRsFromGithub = (force = false): Promise<void> => {
if (!force && !shouldRefresh(data)) return Promise.resolve();
prRefreshInFlight ??= fetchPRSnapshot(pi.exec, cwd, data)
.then(async (fresh) => {
replaceData(data, fresh);
await writeCache(cwd, fresh);
})
.catch(() => {
// Keep stale cache when gh is unavailable or slow.
})
.finally(() => {
prRefreshInFlight = undefined;
});
return prRefreshInFlight;
};
const fetchAndCacheIssueByNumber = async (number: number) => {
const issue = await fetchIssueByNumber(pi.exec, cwd, number);
if (issue) {
upsertIssue(data, issue);
data.fetchedAt = new Date().toISOString();
await writeCache(cwd, data).catch(() => undefined);
}
return issue;
};
const fetchAndCachePRByNumber = async (number: number) => {
const pr = await fetchPRByNumber(pi.exec, cwd, number);
if (pr) {
upsertPR(data, pr);
data.fetchedAt = new Date().toISOString();
await writeCache(cwd, data).catch(() => undefined);
}
return pr;
};
ctx.ui.addAutocompleteProvider((current) =>
createGithubAutocompleteProvider(current, data, {
refreshFromDisk,
refreshIfStale: () => void refreshIssuesFromGithub(false),
fetchIssueByNumber: fetchAndCacheIssueByNumber,
fetchPRByNumber: fetchAndCachePRByNumber,
}),
);
void refreshIssuesFromGithub(true);
setTimeout(() => void refreshPRsFromGithub(true), 1_000).unref?.();
refreshTimer = setInterval(
() => void refreshIssuesFromGithub(false),
CACHE_REFRESH_INTERVAL_MS,
);
refreshTimer.unref?.();
});
pi.on("session_shutdown", async () => {
if (refreshTimer) clearInterval(refreshTimer);
refreshTimer = undefined;
});
pi.on("before_agent_start", async (event) => {
const text = event.prompt;
const issueRefs: string[] = [];
const prRefs: string[] = [];
let match: RegExpExecArray | null;
const issueRe = new RegExp(ISSUE_REF_RE.source, "g");
match = issueRe.exec(text);
while (match !== null) {
if (match[1]) issueRefs.push(match[1]);
match = issueRe.exec(text);
}
const prRe = new RegExp(PR_REF_RE.source, "g");
match = prRe.exec(text);
while (match !== null) {
if (match[1]) prRefs.push(match[1]);
match = prRe.exec(text);
}
if (issueRefs.length === 0 && prRefs.length === 0) return;
const nudge = buildNudge([
{ type: "issue", refs: issueRefs },
{ type: "pr", refs: prRefs },
]);
return {
message: {
customType: "github-ref:nudge",
content: nudge,
display: false,
},
} as const;
});
}
{
"name": "pi-github-issues-autocomplete",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"clean": "echo 'nothing to clean'",
"build": "echo 'nothing to build'",
"check": "tsc --noEmit"
},
"pi": {
"extensions": [
"./index.ts"
]
},
"devDependencies": {
"@earendil-works/pi-coding-agent": "latest",
"@earendil-works/pi-tui": "latest",
"@types/node": "latest",
"typescript": "latest"
}
}
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noEmit": true,
"skipLibCheck": true
},
"include": ["index.ts"]
}
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import { SessionManager } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import { cp, mkdir, readFile, realpath, rm, stat, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const SOURCE_CHECKOUT = "/home/can/Projects/herdr";
const DEFAULT_BASE = "master";
const EXTENSION_DIR = dirname(fileURLToPath(import.meta.url));
const EXTENSION_NAME = "herdr-worktree";
type HerdrWorktreeResult = {
result?: {
workspace?: { workspace_id?: string; label?: string };
tab?: { tab_id?: string };
root_pane?: { pane_id?: string };
worktree?: { path?: string; branch?: string; label?: string };
};
error?: { code?: string; message?: string };
};
type StartOptions = {
branch?: string;
base?: string;
label?: string;
sourceCheckout?: string;
closeOldPane: boolean;
copyExtension: boolean;
};
export default function (pi: ExtensionAPI) {
pi.registerTool({
name: "herdr_start_worktree",
label: "Start Herdr Worktree",
description:
"Create a Herdr-linked git worktree from the Herdr master checkout, continue the active pi session in it, " +
"start pi in the new Herdr pane, then shut down and clean up the old pane.",
promptSnippet: "Create a Herdr worktree workspace and continue the active pi session in it",
promptGuidelines: [
"Use herdr_start_worktree when work in the Herdr repo should continue in a fresh git worktree.",
"herdr_start_worktree creates the checkout from /home/can/Projects/herdr on master by default.",
"Prefer passing a clear branch name such as issue/123-short-slug when the work relates to an issue.",
"After herdr_start_worktree succeeds, the current pi process will shut down and the old Herdr pane will close.",
],
parameters: Type.Object({
branch: Type.Optional(
Type.String({
description:
"Branch name for the new worktree. If omitted, Herdr generates a worktree/* branch.",
}),
),
base: Type.Optional(
Type.String({
description: "Base ref for the new worktree. Defaults to master.",
}),
),
label: Type.Optional(
Type.String({
description: "Workspace label for the new Herdr worktree workspace.",
}),
),
closeOldPane: Type.Optional(
Type.Boolean({
description: "Close the old Herdr pane after the old pi process exits. Defaults to true.",
}),
),
copyExtension: Type.Optional(
Type.Boolean({
description:
"Copy this project-local extension into the new worktree before starting pi there. Defaults to true.",
}),
),
}),
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
return startHerdrWorktree(pi, ctx, signal, {
branch: cleanOptional(params.branch),
base: cleanOptional(params.base) ?? DEFAULT_BASE,
label: cleanOptional(params.label),
sourceCheckout: SOURCE_CHECKOUT,
closeOldPane: params.closeOldPane ?? true,
copyExtension: params.copyExtension ?? true,
});
},
});
pi.registerCommand("herdr-worktree-start", {
description:
"Create a Herdr worktree from master, continue this pi session in it, and clean up the old pane",
handler: async (args, ctx) => {
await ctx.waitForIdle();
try {
const parsed = parseCommandArgs(args ?? "");
const result = await startHerdrWorktree(pi, ctx, undefined, parsed);
const text = result.content?.[0]?.type === "text" ? result.content[0].text : "Started worktree";
ctx.ui.notify(text, "info");
} catch (err: any) {
ctx.ui.notify(err?.message ?? String(err), "error");
}
},
});
}
async function startHerdrWorktree(
pi: ExtensionAPI,
ctx: ExtensionContext,
signal: AbortSignal | undefined,
options: StartOptions,
) {
if (process.env.HERDR_ENV !== "1") {
throw new Error("herdr_start_worktree must run inside a Herdr-managed pane");
}
const oldPaneId = process.env.HERDR_PANE_ID;
if (options.closeOldPane && !oldPaneId) {
throw new Error("HERDR_PANE_ID is missing; cannot close the old Herdr pane safely");
}
const currentFile = ctx.sessionManager.getSessionFile();
if (!currentFile) {
throw new Error("Current pi session is not persisted, so it cannot be continued in a worktree");
}
const sourceCheckout = await canonicalDirectory(options.sourceCheckout || SOURCE_CHECKOUT);
ctx.ui.setStatus("herdr-worktree", "creating worktree");
let newSessionFile: string | undefined;
try {
const created = await createHerdrWorktree(pi, signal, sourceCheckout, options);
const worktreePath = await canonicalDirectory(created.worktreePath);
if (options.copyExtension) {
await copyThisExtension(worktreePath);
}
newSessionFile = await forkSessionFile(currentFile, worktreePath);
await runInNewPane(pi, signal, created.rootPaneId, newSessionFile, worktreePath);
if (created.workspaceId) {
await herdr(pi, ["workspace", "focus", created.workspaceId], signal, sourceCheckout, 10_000);
}
if (options.closeOldPane && oldPaneId) {
await scheduleOldPaneCleanup(pi, signal, currentFile, oldPaneId, process.pid);
}
ctx.ui.setStatus("herdr-worktree", undefined);
ctx.ui.notify(`Started pi in Herdr worktree: ${worktreePath}`, "info");
ctx.shutdown();
return {
content: [
{
type: "text" as const,
text:
`Started replacement pi in Herdr worktree: ${worktreePath}\n` +
`Workspace: ${created.workspaceId ?? "unknown"}\n` +
`Pane: ${created.rootPaneId}\n` +
`Branch: ${created.branch ?? "generated by Herdr"}\n\n` +
"The old pi process is shutting down. The old pane will close after it exits.",
},
],
details: {
worktreePath,
branch: created.branch,
workspaceId: created.workspaceId,
tabId: created.tabId,
paneId: created.rootPaneId,
newSessionFile,
oldSessionFile: currentFile,
oldPaneId,
},
terminate: true,
};
} catch (err) {
ctx.ui.setStatus("herdr-worktree", undefined);
if (newSessionFile) {
await rm(newSessionFile, { force: true }).catch(() => undefined);
}
throw err;
}
}
async function createHerdrWorktree(
pi: ExtensionAPI,
signal: AbortSignal | undefined,
sourceCheckout: string,
options: StartOptions,
): Promise<{
worktreePath: string;
branch?: string;
workspaceId?: string;
tabId?: string;
rootPaneId: string;
}> {
const args = [
"worktree",
"create",
"--cwd",
sourceCheckout,
"--base",
options.base || DEFAULT_BASE,
"--focus",
"--json",
];
if (options.branch) args.push("--branch", options.branch);
if (options.label) args.push("--label", options.label);
const json = await herdrJson(pi, args, signal, sourceCheckout, 120_000);
const worktreePath = json.result?.worktree?.path;
const rootPaneId = json.result?.root_pane?.pane_id;
if (!worktreePath || !rootPaneId) {
throw new Error("Herdr worktree create response did not include worktree.path and root_pane.pane_id");
}
return {
worktreePath,
rootPaneId,
branch: json.result?.worktree?.branch,
workspaceId: json.result?.workspace?.workspace_id,
tabId: json.result?.tab?.tab_id,
};
}
async function runInNewPane(
pi: ExtensionAPI,
signal: AbortSignal | undefined,
paneId: string,
sessionFile: string,
worktreePath: string,
): Promise<void> {
const continuation = `Moved to worktree ${worktreePath}. Continue.`;
const command = ["pi", "--session", sessionFile, continuation].map(shellQuote).join(" ");
await herdr(pi, ["pane", "run", paneId, command], signal, undefined, 10_000);
}
async function scheduleOldPaneCleanup(
pi: ExtensionAPI,
signal: AbortSignal | undefined,
oldSessionFile: string,
oldPaneId: string,
oldPid: number,
): Promise<void> {
const cleanup = [
`old_pid=${oldPid}`,
`old_session=${shellQuote(oldSessionFile)}`,
`old_pane=${shellQuote(oldPaneId)}`,
"i=0",
"while kill -0 \"$old_pid\" 2>/dev/null && [ \"$i\" -lt 600 ]; do i=$((i + 1)); sleep 0.1; done",
"rm -f -- \"$old_session\"",
"herdr pane close \"$old_pane\" >/dev/null 2>&1 || true",
].join("; ");
const launcher =
"if command -v setsid >/dev/null 2>&1; then " +
`setsid sh -c ${shellQuote(cleanup)} >/dev/null 2>&1 < /dev/null & ` +
"else " +
`nohup sh -c ${shellQuote(cleanup)} >/dev/null 2>&1 < /dev/null & ` +
"fi";
const result = await pi.exec("sh", ["-lc", launcher], { signal, timeout: 5_000 });
if (result.code !== 0) {
throw new Error(`Failed to schedule old pane cleanup: ${result.stderr || result.stdout}`);
}
}
async function forkSessionFile(currentFile: string, worktreePath: string): Promise<string> {
const forked = SessionManager.forkFrom(currentFile, worktreePath);
const newFile = forked.getSessionFile();
if (!newFile) {
throw new Error("Failed to create forked session file for the new worktree");
}
const raw = await readFile(newFile, "utf8");
const lines = raw.trimEnd().split("\n");
if (lines.length > 0 && lines[0]) {
const header = JSON.parse(lines[0]);
if (header.parentSession !== undefined) {
delete header.parentSession;
lines[0] = JSON.stringify(header);
await writeFile(newFile, lines.join("\n") + "\n", "utf8");
}
}
return newFile;
}
async function copyThisExtension(worktreePath: string): Promise<void> {
const targetDir = join(worktreePath, ".pi", "extensions", EXTENSION_NAME);
const source = await realpath(EXTENSION_DIR);
const targetParent = dirname(targetDir);
await mkdir(targetParent, { recursive: true });
await cp(source, targetDir, {
recursive: true,
force: true,
filter: (src) => !src.includes(`${EXTENSION_NAME}/node_modules`),
});
}
async function canonicalDirectory(path: string): Promise<string> {
const resolved = resolve(path.replace(/^@/, ""));
const s = await stat(resolved).catch(() => undefined);
if (!s?.isDirectory()) {
throw new Error(`Directory does not exist: ${resolved}`);
}
return realpath(resolved);
}
async function herdrJson(
pi: ExtensionAPI,
args: string[],
signal: AbortSignal | undefined,
cwd: string | undefined,
timeout: number,
): Promise<HerdrWorktreeResult> {
const result = await herdr(pi, args, signal, cwd, timeout);
const raw = result.stdout.trim() || result.stderr.trim();
let json: HerdrWorktreeResult;
try {
json = JSON.parse(raw) as HerdrWorktreeResult;
} catch {
throw new Error(`Herdr returned non-JSON output for ${args.join(" ")}: ${raw}`);
}
if (json.error) {
throw new Error(`${json.error.code ?? "herdr_error"}: ${json.error.message ?? "unknown Herdr error"}`);
}
return json;
}
async function herdr(
pi: ExtensionAPI,
args: string[],
signal: AbortSignal | undefined,
cwd: string | undefined,
timeout: number,
) {
const result = await pi.exec("herdr", args, { cwd, signal, timeout });
if (result.code !== 0) {
throw new Error(`herdr ${args.join(" ")} failed: ${result.stderr || result.stdout}`);
}
return result;
}
function parseCommandArgs(args: string): StartOptions {
const tokens = tokenize(args);
const options: StartOptions = {
base: DEFAULT_BASE,
sourceCheckout: SOURCE_CHECKOUT,
closeOldPane: true,
copyExtension: true,
};
for (let i = 0; i < tokens.length; i += 1) {
const token = tokens[i];
if (token === "--branch") options.branch = requireValue(tokens, ++i, token);
else if (token === "--base") options.base = requireValue(tokens, ++i, token);
else if (token === "--label") options.label = requireValue(tokens, ++i, token);
else if (token === "--source") options.sourceCheckout = requireValue(tokens, ++i, token);
else if (token === "--no-close-pane") options.closeOldPane = false;
else if (token === "--no-copy-extension") options.copyExtension = false;
else if (!options.branch) options.branch = token;
else if (!options.label) options.label = token;
else throw new Error(`Unexpected argument: ${token}`);
}
options.branch = cleanOptional(options.branch);
options.base = cleanOptional(options.base) ?? DEFAULT_BASE;
options.label = cleanOptional(options.label);
options.sourceCheckout = cleanOptional(options.sourceCheckout) ?? SOURCE_CHECKOUT;
return options;
}
function tokenize(input: string): string[] {
const tokens: string[] = [];
let current = "";
let quote: '"' | "'" | undefined;
let escaping = false;
for (const ch of input.trim()) {
if (escaping) {
current += ch;
escaping = false;
continue;
}
if (ch === "\\") {
escaping = true;
continue;
}
if (quote) {
if (ch === quote) quote = undefined;
else current += ch;
continue;
}
if (ch === '"' || ch === "'") {
quote = ch;
continue;
}
if (/\s/.test(ch)) {
if (current) {
tokens.push(current);
current = "";
}
continue;
}
current += ch;
}
if (quote) throw new Error("Unclosed quote in command arguments");
if (escaping) current += "\\";
if (current) tokens.push(current);
return tokens;
}
function requireValue(tokens: string[], index: number, flag: string): string {
const value = tokens[index];
if (!value || value.startsWith("--")) {
throw new Error(`Missing value for ${flag}`);
}
return value;
}
function cleanOptional(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'"'"'`)}'`;
}
{
"name": "pi-herdr-worktree",
"private": true,
"version": "0.1.0",
"type": "module",
"pi": {
"extensions": [
"./index.ts"
]
},
"devDependencies": {
"@earendil-works/pi-coding-agent": "latest",
"@types/node": "latest",
"typebox": "latest",
"typescript": "latest"
}
}
{
// Project-local Rust tuning for herdr
"file_scan_exclusions": [
"**/.git",
"**/.svn",
"**/.hg",
"**/.jj",
"**/CVS",
"**/.DS_Store",
"**/Thumbs.db",
"**/.classpath",
"**/.settings",
"**/target",
"**/vendor",
"**/node_modules",
"**/.zig-cache",
"**/zig-out",
],
"languages": {
"Rust": {
"enable_language_server": false,
"formatter": "auto",
"format_on_save": "off"
}
},
"lsp": {
"rust-analyzer": {
"enable_lsp_tasks": false,
"initialization_options": {
"checkOnSave": false,
"cargo": {
"allTargets": false,
"buildScripts": {
"enable": false
}
},
"check": {
"workspace": false
},
"rust": {
"analyzerTargetDir": true
},
"files": {
"exclude": [
"target",
"vendor",
"src/ghostty/bindings.rs"
]
},
"lru": {
"capacity": 32
},
"cachePriming": {
"enable": false,
"numThreads": 1
},
"procMacro": {
"enable": false
},
"numThreads": 2
}
}
},
}
use std::env;
use std::fs;
use std::path::PathBuf;
use std::process::Command;
fn zig_target(target: &str) -> &str {
match target {
"x86_64-unknown-linux-gnu" => "x86_64-linux-gnu",
"aarch64-unknown-linux-gnu" => "aarch64-linux-gnu",
"x86_64-unknown-linux-musl" => "x86_64-linux-musl",
"aarch64-unknown-linux-musl" => "aarch64-linux-musl",
"x86_64-apple-darwin" => "x86_64-macos",
"aarch64-apple-darwin" => "aarch64-macos",
"x86_64-pc-windows-msvc" => "x86_64-windows-msvc",
"aarch64-pc-windows-msvc" => "aarch64-windows-msvc",
other => panic!("unsupported target for libghostty-vt build: {other}"),
}
}
fn env_bool(name: &str) -> Option<bool> {
match env::var(name) {
Ok(value) => match value.to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Some(true),
"0" | "false" | "no" | "off" => Some(false),
other => panic!("invalid boolean value for {name}: {other}"),
},
Err(env::VarError::NotPresent) => None,
Err(err) => panic!("failed to read {name}: {err}"),
}
}
fn main() {
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed=vendor/libghostty-vt.vendor.json");
println!("cargo:rerun-if-changed=vendor/libghostty-vt/build.zig");
println!("cargo:rerun-if-changed=vendor/libghostty-vt/build.zig.zon");
println!("cargo:rerun-if-changed=vendor/libghostty-vt/include");
println!("cargo:rerun-if-changed=vendor/libghostty-vt/pkg");
println!("cargo:rerun-if-changed=vendor/libghostty-vt/src");
println!("cargo:rerun-if-changed=vendor/libghostty-vt/VERSION");
println!("cargo:rerun-if-env-changed=LIBGHOSTTY_VT_OPTIMIZE");
println!("cargo:rerun-if-env-changed=LIBGHOSTTY_VT_SIMD");
println!("cargo:rerun-if-env-changed=LIBGHOSTTY_VT_ZIG_SYSTEM_DIR");
println!("cargo:rerun-if-env-changed=HERDR_BUILD_CHANNEL");
println!("cargo:rerun-if-env-changed=HERDR_BUILD_ID");
println!("cargo:rerun-if-env-changed=HERDR_BUILD_COMMIT");
println!("cargo:rerun-if-env-changed=ZIG");
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"));
let vendored_dir = manifest_dir.join("vendor/libghostty-vt");
let optimize = env::var("LIBGHOSTTY_VT_OPTIMIZE").unwrap_or_else(|_| "ReleaseFast".into());
let simd = env_bool("LIBGHOSTTY_VT_SIMD").unwrap_or(true);
let target = env::var("TARGET").expect("TARGET");
let zig_target = zig_target(&target);
let version_string = fs::read_to_string(vendored_dir.join("VERSION"))
.expect("failed to read vendored libghostty-vt VERSION")
.trim()
.to_string();
let zig = env::var("ZIG").unwrap_or_else(|_| "zig".into());
let mut command = Command::new(zig);
command
.arg("build")
.arg("-Demit-lib-vt")
.arg(format!("-Doptimize={optimize}"))
.arg(format!("-Dsimd={simd}"))
.arg(format!("-Dtarget={zig_target}"))
.arg(format!("-Dversion-string={version_string}"))
.arg("-Demit-xcframework=false");
if let Ok(system_dir) = env::var("LIBGHOSTTY_VT_ZIG_SYSTEM_DIR") {
command.arg("--system").arg(system_dir);
}
let status = command
.current_dir(&vendored_dir)
.status()
.expect("failed to execute zig build for vendored libghostty-vt");
assert!(
status.success(),
"zig build for vendored libghostty-vt failed: {status}"
);
let lib_dir = vendored_dir.join("zig-out/lib");
println!("cargo:rustc-link-search=native={}", lib_dir.display());
if target.contains("apple-darwin") {
let static_lib = lib_dir.join("libghostty-vt.a");
println!("cargo:rustc-link-arg={}", static_lib.display());
} else if target.contains("windows-msvc") {
println!("cargo:rustc-link-lib=static=ghostty-vt-static");
} else {
println!("cargo:rustc-link-lib=static=ghostty-vt");
}
}
[package]
name = "herdr"
version = "0.7.0"
edition = "2021"
build = "build.rs"
description = "terminal workspace manager for AI coding agents"
license = "AGPL-3.0-or-later"
repository = "https://github.com/ogulcancelik/herdr"
homepage = "https://herdr.dev"
keywords = ["terminal", "tui", "ai", "agents", "multiplexer"]
categories = ["command-line-utilities"]
include = ["src/**/*", "assets/sounds/*", "README.md", "LICENSE", "Cargo.toml"]
[dependencies]
base64 = "0.22.1"
bincode = { version = "2", features = ["serde"] }
bytes = "1"
crossterm = "0.29"
ctrlc = "3"
interprocess = "2.4.2"
libc = "0.2"
portable-pty = "0.9"
png = "0.17"
ratatui = { version = "0.30", features = ["unstable-rendered-line-info"] }
regex = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] }
toml = "0.8"
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
unicode-width = "0.2"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61.2", features = [
"Wdk_System_Threading",
"Win32_Foundation",
"Win32_System_Diagnostics_Debug",
"Win32_System_Diagnostics_ToolHelp",
"Win32_System_Console",
"Win32_System_Kernel",
"Win32_System_Threading",
"Win32_UI_Shell",
"Win32_UI_WindowsAndMessaging",
] }
AGENTS.md
too-many-arguments-threshold = 11
null
Related skills
FAQ
Who is herdr for?
Developers using agents to execute herdr workflows from SKILL.md.
When should I use herdr?
Control herdr from inside it. Manage workspaces and tabs, split panes, spawn agents, read output, and wait for state changes - all via CLI commands that talk to the running herdr
Is herdr safe to install?
Review the Security Audits panel on this page before installing in production.