From c155da5a2b46cc19cd8965c12e7fc0fbcbd70769 Mon Sep 17 00:00:00 2001 From: Michael Schmidt Date: Sun, 12 Apr 2026 21:42:17 +0200 Subject: [PATCH 1/7] =?UTF-8?q?Release=20v0.127.2=20=E2=80=94=20Lockfile?= =?UTF-8?q?=20Path=20Fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Fixes - Resolve lockfile path to `.beastmode/.beastmode-watch.lock` instead of `cli/.beastmode-watch.lock` - Update `.gitignore` entry to match new lockfile path ## Docs - Update lockfile path references in orchestration and CLI context docs ## Artifacts - Design: .beastmode/artifacts/design/2026-04-12-lockfile-path-fix-dcd0.md - Plan: .beastmode/artifacts/plan/2026-04-12-lockfile-path-fix-dcd0--lockfile-path-fix.1.md - Release: .beastmode/artifacts/release/2026-04-12-lockfile-path-fix-dcd0.md Co-Authored-By: Claude Opus 4.6 --- .../2026-04-12-lockfile-path-fix-dcd0.md | 45 ++++ ...path-fix-dcd0--lockfile-path-fix-dcd0.1.md | 35 +++ ...ix-dcd0--lockfile-path-fix-dcd0.1.tasks.md | 220 ++++++++++++++++++ ...file-path-fix-dcd0--lockfile-path-fix.1.md | 41 ++++ .../2026-04-12-lockfile-path-fix-dcd0.md | 34 +++ .../2026-04-12-lockfile-path-fix-dcd0.md | 56 +++++ .beastmode/context/design/cli.md | 2 +- .beastmode/context/design/orchestration.md | 2 +- .claude-plugin/marketplace.json | 2 +- .gitignore | 2 +- CHANGELOG.md | 15 ++ cli/src/__tests__/watch.test.ts | 2 +- cli/src/lockfile.ts | 4 +- plugin/plugin.json | 2 +- 14 files changed, 454 insertions(+), 8 deletions(-) create mode 100644 .beastmode/artifacts/design/2026-04-12-lockfile-path-fix-dcd0.md create mode 100644 .beastmode/artifacts/implement/2026-04-12-lockfile-path-fix-dcd0--lockfile-path-fix-dcd0.1.md create mode 100644 .beastmode/artifacts/implement/2026-04-12-lockfile-path-fix-dcd0--lockfile-path-fix-dcd0.1.tasks.md create mode 100644 .beastmode/artifacts/plan/2026-04-12-lockfile-path-fix-dcd0--lockfile-path-fix.1.md create mode 100644 .beastmode/artifacts/release/2026-04-12-lockfile-path-fix-dcd0.md create mode 100644 .beastmode/artifacts/validate/2026-04-12-lockfile-path-fix-dcd0.md diff --git a/.beastmode/artifacts/design/2026-04-12-lockfile-path-fix-dcd0.md b/.beastmode/artifacts/design/2026-04-12-lockfile-path-fix-dcd0.md new file mode 100644 index 00000000..5a732e38 --- /dev/null +++ b/.beastmode/artifacts/design/2026-04-12-lockfile-path-fix-dcd0.md @@ -0,0 +1,45 @@ +--- +phase: design +epic-id: bm-dcd0 +epic-slug: lockfile-path-fix-dcd0 +epic-name: Lockfile Path Fix +--- + +## Problem Statement + +The watch loop lockfile path is hardcoded to `cli/.beastmode-watch.lock`. When the CLI runs from a different working directory (e.g., inside a worktree), the resolved path points to a non-existent directory, causing an ENOENT error that prevents the dashboard from starting. + +## Solution + +Move the lockfile from `cli/.beastmode-watch.lock` to `.beastmode/.beastmode-watch.lock`. The `.beastmode/` directory is project-rooted and always exists, making the lockfile resilient to working directory changes. + +## User Stories + +1. As a developer running `beastmode dashboard`, I want the lockfile to resolve correctly regardless of working directory, so that the dashboard starts without ENOENT errors. +2. As a developer with a stale lockfile from a previous session, I want the stale-PID detection to work at the new path, so that I don't get locked out. +3. As a developer, I want the lockfile gitignored at its new path, so that it never gets committed. + +## Implementation Decisions + +- Change `lockfilePath()` in `cli/src/lockfile.ts` to resolve to `.beastmode/` instead of `cli/`. +- Update the test in `cli/src/__tests__/watch.test.ts` that hardcodes the `cli/` path for the stale lockfile scenario. +- Update `.gitignore` entry from `.beastmode-watch.lock` to `.beastmode/.beastmode-watch.lock`. +- Update context doc references in `context/design/orchestration.md` and `context/design/cli.md` to reflect the new path. + +## Testing Decisions + +- Existing lockfile tests (acquire, release, stale detection) cover the behavior — only the path constant and the stale-lockfile test setup need updating. +- No new tests required; the fix is a path change, not a behavior change. + +## Out of Scope + +- Lockfile format changes. +- Additional lockfile features (timeout, retry, etc.). + +## Further Notes + +None. + +## Deferred Ideas + +None. diff --git a/.beastmode/artifacts/implement/2026-04-12-lockfile-path-fix-dcd0--lockfile-path-fix-dcd0.1.md b/.beastmode/artifacts/implement/2026-04-12-lockfile-path-fix-dcd0--lockfile-path-fix-dcd0.1.md new file mode 100644 index 00000000..69f28aa3 --- /dev/null +++ b/.beastmode/artifacts/implement/2026-04-12-lockfile-path-fix-dcd0--lockfile-path-fix-dcd0.1.md @@ -0,0 +1,35 @@ +--- +phase: implement +epic-id: bm-dcd0 +epic-slug: lockfile-path-fix-dcd0 +feature-id: lockfile-path-fix-dcd0.1 +feature-name: Lockfile Path Fix +feature-slug: lockfile-path-fix-dcd0.1 +status: completed +--- + +# Implementation Report: Lockfile Path Fix + +**Date:** 2026-04-12 +**Feature Plan:** .beastmode/artifacts/plan/2026-04-12-lockfile-path-fix-dcd0--lockfile-path-fix.1.md +**Tasks completed:** 3/3 +**Review cycles:** 6 (spec: 3, quality: 3) +**Concerns:** 0 +**BDD verification:** skipped + +## Completed Tasks +- Task 1: Update lockfile path and test (haiku) — clean +- Task 2: Update .gitignore entry (haiku) — clean +- Task 3: Update context documentation (haiku) — clean + +## Concerns +None. + +## Blocked Tasks +None. + +## BDD Verification +- Result: skipped +- Reason: No Integration Test Scenarios in feature plan — skip gate classified this feature as non-behavioral. + +All tasks completed cleanly — no concerns or blockers. diff --git a/.beastmode/artifacts/implement/2026-04-12-lockfile-path-fix-dcd0--lockfile-path-fix-dcd0.1.tasks.md b/.beastmode/artifacts/implement/2026-04-12-lockfile-path-fix-dcd0--lockfile-path-fix-dcd0.1.tasks.md new file mode 100644 index 00000000..057bd5f9 --- /dev/null +++ b/.beastmode/artifacts/implement/2026-04-12-lockfile-path-fix-dcd0--lockfile-path-fix-dcd0.1.tasks.md @@ -0,0 +1,220 @@ +# Lockfile Path Fix -- Write Tasks + +## Goal + +Move the watch loop lockfile from `cli/.beastmode-watch.lock` to `.beastmode/.beastmode-watch.lock` so that the lockfile resolves correctly regardless of working directory. The `.beastmode/` directory is project-rooted and always exists, eliminating ENOENT errors when the dashboard runs from worktree directories. + +## Architecture + +Single-constant path change in `cli/src/lockfile.ts`. All lockfile consumers (acquire, release, read, stale detection) call the same `lockfilePath()` function, so changing the constant propagates everywhere. No behavioral changes -- only the resolved filesystem path differs. + +## Tech Stack + +- Runtime: Bun +- Language: TypeScript +- Test runner: vitest (run via `bun --bun vitest run`) +- Test location: `cli/src/__tests__/watch.test.ts` + +## File Structure + +| File | Action | Responsibility | +|------|--------|---------------| +| `cli/src/lockfile.ts` | Modify | Change path resolution from `cli/` to `.beastmode/`; update module doc comment | +| `cli/src/__tests__/watch.test.ts` | Modify | Update hardcoded stale-lockfile path in test from `cli/` to `.beastmode/` | +| `.gitignore` | Modify | Replace bare `.beastmode-watch.lock` with specific `.beastmode/.beastmode-watch.lock` | +| `.beastmode/context/design/orchestration.md` | Modify | Update path reference in Recovery section | +| `.beastmode/context/design/cli.md` | Modify | Update path reference in Recovery Model section | + +## Wave Isolation + +| Wave | Tasks | Files | Parallel-safe | Reason | +|------|-------|-------|---------------|--------| +| 1 | T1, T2, T3 | T1: `cli/src/lockfile.ts`, `cli/src/__tests__/watch.test.ts` / T2: `.gitignore` / T3: `.beastmode/context/design/orchestration.md`, `.beastmode/context/design/cli.md` | no | T2 and T3 are independent but T1 must precede test verification; sequential is safer for 3 trivial tasks | + +## Tasks + +### Task 1: Update lockfile path and test + +**Wave:** 1 +**Depends on:** - + +**Files:** +- Modify: `cli/src/lockfile.ts:4,17` +- Modify: `cli/src/__tests__/watch.test.ts:57` +- Test: `cli/src/__tests__/watch.test.ts` + +- [x] **Step 1: Update the module doc comment in lockfile.ts** + +In `cli/src/lockfile.ts`, replace the doc comment on line 4 that says `cli/.beastmode-watch.lock` with `.beastmode/.beastmode-watch.lock`: + +```typescript +/** + * Lockfile manager — prevents duplicate watch instances. + * + * Creates .beastmode/.beastmode-watch.lock on start, removes on clean shutdown. + * Detects stale lockfiles by checking if the PID is still running. + */ +``` + +The old comment reads: + +```typescript +/** + * Lockfile manager — prevents duplicate watch instances. + * + * Creates cli/.beastmode-watch.lock on start, removes on clean shutdown. + * Detects stale lockfiles by checking if the PID is still running. + */ +``` + +- [x] **Step 2: Change the path constant in lockfilePath()** + +In `cli/src/lockfile.ts`, line 17, change `"cli"` to `".beastmode"` in the `lockfilePath` function: + +```typescript +function lockfilePath(projectRoot: string): string { + return resolve(projectRoot, ".beastmode", LOCKFILE_NAME); +} +``` + +The old code reads: + +```typescript +function lockfilePath(projectRoot: string): string { + return resolve(projectRoot, "cli", LOCKFILE_NAME); +} +``` + +- [x] **Step 3: Update the stale-lockfile test path** + +In `cli/src/__tests__/watch.test.ts`, line 57, change the hardcoded lockfile path in the "detects stale lockfile (dead PID)" test from `"cli"` to `".beastmode"`: + +```typescript + const lockPath = resolve(TEST_ROOT, ".beastmode", ".beastmode-watch.lock"); +``` + +The old code reads: + +```typescript + const lockPath = resolve(TEST_ROOT, "cli", ".beastmode-watch.lock"); +``` + +- [x] **Step 4: Run all lockfile tests to verify they pass** + +Run: `cd cli && bun --bun vitest run src/__tests__/watch.test.ts -t "lockfile" --reporter=verbose` + +Expected: All 4 lockfile tests PASS: +- acquires lock when no lockfile exists +- prevents duplicate lock acquisition +- releases lock cleanly +- detects stale lockfile (dead PID) + +Note: The test setup already creates both `cli/` and `.beastmode/` directories under `TEST_ROOT` (see `setupTestRoot()` at line 13-17 of watch.test.ts), so no test fixture changes are needed beyond the path string. + +- [x] **Step 5: Run the full watch test suite to verify nothing else broke** + +Run: `cd cli && bun --bun vitest run src/__tests__/watch.test.ts --reporter=verbose` + +Expected: All tests in the file PASS (lockfile, DispatchTracker, and WatchLoop tests). + +- [x] **Step 6: Commit** + +```bash +git add cli/src/lockfile.ts cli/src/__tests__/watch.test.ts +git commit -m "fix(lockfile): resolve lockfile path to .beastmode/ instead of cli/" +``` + +### Task 2: Update .gitignore entry + +**Wave:** 1 +**Depends on:** Task 1 + +**Files:** +- Modify: `.gitignore:10` + +- [x] **Step 1: Replace the gitignore entry** + +In `.gitignore`, line 10, replace the bare pattern `.beastmode-watch.lock` with the specific path `.beastmode/.beastmode-watch.lock`: + +``` +.beastmode/.beastmode-watch.lock +``` + +The old entry reads: + +``` +.beastmode-watch.lock +``` + +The full `.gitignore` after this change should read: + +``` +# Beastmode session state and worktrees +.beastmode/state/ +.beastmode/sessions/ +.beastmode/worktrees/ +.beastmode/pipeline/ +.beastmode/config.yaml +.claude/worktrees/ +.claude/settings.local.json +.beastmode/artifacts/**/*.output.json +.beastmode/.beastmode-watch.lock +.DS_Store +``` + +- [x] **Step 2: Verify the gitignore entry works** + +Run: `cd /Users/D038720/Code/github.com/bugroger/beastmode/.claude/worktrees/lockfile-path-fix-dcd0 && git check-ignore .beastmode/.beastmode-watch.lock` + +Expected: Output `.beastmode/.beastmode-watch.lock` (the path is ignored). + +- [x] **Step 3: Commit** + +```bash +git add .gitignore +git commit -m "fix(gitignore): update lockfile entry to match new .beastmode/ path" +``` + +### Task 3: Update context documentation + +**Wave:** 1 +**Depends on:** Task 1 + +**Files:** +- Modify: `.beastmode/context/design/orchestration.md:31` +- Modify: `.beastmode/context/design/cli.md:34` + +- [x] **Step 1: Update orchestration.md** + +In `.beastmode/context/design/orchestration.md`, line 31, replace the old path with the new one: + +```markdown +- Lockfile (`.beastmode/.beastmode-watch.lock`) prevents duplicate watch instances — single orchestrator guarantee +``` + +The old line reads: + +```markdown +- Lockfile (`cli/.beastmode-watch.lock`) prevents duplicate watch instances — single orchestrator guarantee +``` + +- [x] **Step 2: Update cli.md** + +In `.beastmode/context/design/cli.md`, line 34, replace the old path with the new one: + +```markdown +- Lockfile (`.beastmode/.beastmode-watch.lock`) prevents duplicate watch instances +``` + +The old line reads: + +```markdown +- Lockfile (`cli/.beastmode-watch.lock`) prevents duplicate watch instances +``` + +- [x] **Step 3: Commit** + +```bash +git add .beastmode/context/design/orchestration.md .beastmode/context/design/cli.md +git commit -m "docs: update lockfile path references in context docs" +``` diff --git a/.beastmode/artifacts/plan/2026-04-12-lockfile-path-fix-dcd0--lockfile-path-fix.1.md b/.beastmode/artifacts/plan/2026-04-12-lockfile-path-fix-dcd0--lockfile-path-fix.1.md new file mode 100644 index 00000000..09cf7239 --- /dev/null +++ b/.beastmode/artifacts/plan/2026-04-12-lockfile-path-fix-dcd0--lockfile-path-fix.1.md @@ -0,0 +1,41 @@ +--- +phase: plan +epic-id: bm-dcd0 +epic-slug: lockfile-path-fix-dcd0 +feature-name: Lockfile Path Fix +wave: 1 +--- + +# Lockfile Path Fix + +**Design:** `.beastmode/artifacts/design/2026-04-12-lockfile-path-fix-dcd0.md` + +## User Stories + +1. As a developer running `beastmode dashboard`, I want the lockfile to resolve correctly regardless of working directory, so that the dashboard starts without ENOENT errors. +2. As a developer with a stale lockfile from a previous session, I want the stale-PID detection to work at the new path, so that I don't get locked out. +3. As a developer, I want the lockfile gitignored at its new path, so that it never gets committed. + +## What to Build + +Change the lockfile resolution from the `cli/` subdirectory to the `.beastmode/` directory, which is project-rooted and always exists regardless of working directory. + +The lockfile module's path function currently resolves to `/cli/`. Change it to resolve to `/.beastmode/`. This is a single-constant change — all consumers (acquire, release, read, stale detection) go through the same path function, so behavior is preserved. + +Update the test that manually constructs the lockfile path for the stale-PID scenario to use the new `.beastmode/` directory. + +Update the `.gitignore` entry to match the new specific path `.beastmode/.beastmode-watch.lock` instead of the bare pattern `.beastmode-watch.lock`. + +Update context documentation references in orchestration and CLI design docs that cite the old `cli/.beastmode-watch.lock` path. + +## Integration Test Scenarios + + + +## Acceptance Criteria + +- [ ] `lockfilePath()` resolves to `/.beastmode/.beastmode-watch.lock` +- [ ] Existing lockfile tests pass (acquire, release, stale detection) with no behavior changes +- [ ] `.gitignore` contains the new path entry +- [ ] Context docs reference the new path +- [ ] Dashboard starts without ENOENT when run from a worktree directory diff --git a/.beastmode/artifacts/release/2026-04-12-lockfile-path-fix-dcd0.md b/.beastmode/artifacts/release/2026-04-12-lockfile-path-fix-dcd0.md new file mode 100644 index 00000000..bfa93f32 --- /dev/null +++ b/.beastmode/artifacts/release/2026-04-12-lockfile-path-fix-dcd0.md @@ -0,0 +1,34 @@ +--- +phase: release +epic-id: lockfile-path-fix-dcd0 +epic-slug: lockfile-path-fix-dcd0 +bump: patch +--- + +# Release: lockfile-path-fix-dcd0 + +**Version:** v0.127.2 +**Date:** 2026-04-12 + +## Highlights + +Fixes ENOENT error when starting the dashboard from a worktree by moving the watch loop lockfile from `cli/` to the project-rooted `.beastmode/` directory. + +## Fixes + +- Resolve lockfile path to `.beastmode/.beastmode-watch.lock` instead of `cli/.beastmode-watch.lock` +- Update `.gitignore` entry to match new lockfile path + +## Docs + +- Update lockfile path references in `context/design/orchestration.md` and `context/design/cli.md` + +## Full Changelog + +- `8ba83c61` fix(lockfile): resolve lockfile path to .beastmode/ instead of cli/ +- `e4876797` fix(gitignore): update lockfile entry to match new .beastmode/ path +- `6a4bcb9a` docs: update lockfile path references in context docs +- `cacdd19c` implement(lockfile-path-fix-dcd0): checkpoint +- `5f49351b` validate(lockfile-path-fix-dcd0): checkpoint +- `64dcf079` plan(lockfile-path-fix-dcd0): checkpoint +- `c076f7ed` design(lockfile-path-fix-dcd0): checkpoint diff --git a/.beastmode/artifacts/validate/2026-04-12-lockfile-path-fix-dcd0.md b/.beastmode/artifacts/validate/2026-04-12-lockfile-path-fix-dcd0.md new file mode 100644 index 00000000..3cf4d043 --- /dev/null +++ b/.beastmode/artifacts/validate/2026-04-12-lockfile-path-fix-dcd0.md @@ -0,0 +1,56 @@ +--- +phase: validate +epic-id: lockfile-path-fix-dcd0 +epic-slug: lockfile-path-fix-dcd0 +status: passed +--- + +# Validation Report: Lockfile Path Fix + +**Date:** 2026-04-12 +**Epic:** lockfile-path-fix-dcd0 + +## Status: PASS + +### Tests + +**Lockfile-specific tests:** 4/4 PASS +- acquires lock when no lockfile exists +- prevents duplicate lock acquisition +- releases lock cleanly +- detects stale lockfile (dead PID) + +**Full watch test suite:** 26/26 PASS + +**Full project test suite:** 1812 pass, 2 fail + +| Failing Test | File Touched by Epic | Verdict | +|---|---|---| +| readme-update.integration.test.ts — "contains npx beastmode uninstall command" | No | Pre-existing | +| tree-view.test.ts — "renders leaf entries under feature" | No | Pre-existing | + +Both failures are pre-existing on main, unrelated to lockfile path changes. + +**Baseline comparison:** 1812 passing vs 1773 baseline (post dashboard-spinner-bug-fixes) — net +39, no regressions introduced. + +### Types + +37 type errors — all pre-existing in untouched files. Matches baseline (37 errors across 13 files from dashboard-spinner-bug-fixes baseline). + +No new type errors introduced. + +### Lint + +Skipped — no lint command configured. + +### Custom Gates + +None configured. + +### Acceptance Criteria Verification + +From design doc: +1. **Lockfile resolves to `.beastmode/.beastmode-watch.lock`** — PASS (verified via `lockfilePath()` constant change + all 4 lockfile tests passing) +2. **No behavioral changes** — PASS (26/26 watch tests pass, same behavior, different path) +3. **`.gitignore` covers new path** — PASS (entry updated to `.beastmode/.beastmode-watch.lock`) +4. **Context docs updated** — PASS (orchestration.md and cli.md updated) diff --git a/.beastmode/context/design/cli.md b/.beastmode/context/design/cli.md index 6e077176..eeba5a73 100644 --- a/.beastmode/context/design/cli.md +++ b/.beastmode/context/design/cli.md @@ -31,7 +31,7 @@ ## Recovery Model - State files are the recovery point, not sessions — stateless session model - On startup, scan for existing worktrees with uncommitted changes and re-dispatch from last committed state -- Lockfile (`cli/.beastmode-watch.lock`) prevents duplicate watch instances +- Lockfile (`.beastmode/.beastmode-watch.lock`) prevents duplicate watch instances ## Manifest Lifecycle - CLI creates manifest at first phase dispatch (design) via store.create(slug) before dispatching — manifest exists throughout the entire skill session diff --git a/.beastmode/context/design/orchestration.md b/.beastmode/context/design/orchestration.md index d5f69ba5..e8f41ec9 100644 --- a/.beastmode/context/design/orchestration.md +++ b/.beastmode/context/design/orchestration.md @@ -28,7 +28,7 @@ ## Recovery - State files are the recovery point, not sessions — on startup, scan for existing worktrees with uncommitted changes - ALWAYS re-dispatch from last committed state on recovery — no session persistence required -- Lockfile (`cli/.beastmode-watch.lock`) prevents duplicate watch instances — single orchestrator guarantee +- Lockfile (`.beastmode/.beastmode-watch.lock`) prevents duplicate watch instances — single orchestrator guarantee ## Lifecycle - Start via `beastmode dashboard`, stop via Ctrl+C — foreground process with explicit control diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index e9fdb0ce..34c51ea1 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -8,7 +8,7 @@ { "name": "beastmode", "description": "Agentic workflow skills for Claude Code. Activate beastmode.", - "version": "0.127.1", + "version": "0.127.2", "source": "./plugin" } ] diff --git a/.gitignore b/.gitignore index 22ee0073..a486737f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,5 +7,5 @@ .claude/worktrees/ .claude/settings.local.json .beastmode/artifacts/**/*.output.json -.beastmode-watch.lock +.beastmode/.beastmode-watch.lock .DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d202368..223ca0c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ All notable changes to beastmode. --- +## v0.127.2 — Lockfile Path Fix (2026-04-12) + +Fix ENOENT error when starting the dashboard from a worktree by moving the watch loop lockfile from `cli/` to the project-rooted `.beastmode/` directory. + +### Fixes + +- Resolve lockfile path to `.beastmode/.beastmode-watch.lock` instead of `cli/.beastmode-watch.lock` +- Update `.gitignore` entry to match new lockfile path + +### Docs + +- Update lockfile path references in orchestration and CLI context docs + +--- + ## v0.127.1 — One-Sentence Project Bootstrap (2026-04-12) Replace the multi-step Install section in the README with a single "Get the Party Started" prose paragraph that users paste into Claude Code to bootstrap their entire project in one shot. diff --git a/cli/src/__tests__/watch.test.ts b/cli/src/__tests__/watch.test.ts index 17f5df8f..42379287 100644 --- a/cli/src/__tests__/watch.test.ts +++ b/cli/src/__tests__/watch.test.ts @@ -54,7 +54,7 @@ describe("lockfile", () => { it("detects stale lockfile (dead PID)", () => { // Write a lockfile with a definitely-dead PID - const lockPath = resolve(TEST_ROOT, "cli", ".beastmode-watch.lock"); + const lockPath = resolve(TEST_ROOT, ".beastmode", ".beastmode-watch.lock"); writeFileSync( lockPath, JSON.stringify({ pid: 999999999, startedAt: new Date().toISOString() }), diff --git a/cli/src/lockfile.ts b/cli/src/lockfile.ts index b03b19cc..93bfa14b 100644 --- a/cli/src/lockfile.ts +++ b/cli/src/lockfile.ts @@ -1,7 +1,7 @@ /** * Lockfile manager — prevents duplicate watch instances. * - * Creates cli/.beastmode-watch.lock on start, removes on clean shutdown. + * Creates .beastmode/.beastmode-watch.lock on start, removes on clean shutdown. * Detects stale lockfiles by checking if the PID is still running. */ @@ -14,7 +14,7 @@ import type { LockfileInfo } from "./dispatch/index.js"; const LOCKFILE_NAME = ".beastmode-watch.lock"; function lockfilePath(projectRoot: string): string { - return resolve(projectRoot, "cli", LOCKFILE_NAME); + return resolve(projectRoot, ".beastmode", LOCKFILE_NAME); } /** Check if a process with the given PID is still running. */ diff --git a/plugin/plugin.json b/plugin/plugin.json index 953c890b..b6d2740e 100644 --- a/plugin/plugin.json +++ b/plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "beastmode", "description": "Agentic workflow skills for Claude Code. Activate beastmode.", - "version": "0.127.1", + "version": "0.127.2", "author": { "name": "bugroger" }, From 75a850cd9e8a3fca588d5a4b3c70f80110cba0ad Mon Sep 17 00:00:00 2001 From: Ralf Heiringhoff Date: Fri, 1 May 2026 11:18:25 +0200 Subject: [PATCH 2/7] feat: add Windows and Linux terminal support - Add TerminalSessionFactory for Windows Terminal (wt) and GNOME Terminal - Extract shared file-watcher logic to session-watcher.ts - Fix proc.kill() calls to use no-arg form (SIGTERM unsupported on Windows) - Guard SIGTERM listener behind platform check in watch-loop - Fix --version/-v flag parsing in args.ts (was rejected before index.ts switch) - npx installer: support win32/linux platforms (bun install, prereq checks) - npx installer: create beastmode.cmd in npm bin dir on Windows - npx installer: fix plugin marketplace source type (directory, not npm) - npx installer: place marketplace.json in .claude-plugin/ subdir - npx installer: use Windows junctions for plugin/ symlink (no admin rights) - npx installer: print error message on install failure - Tests: add Windows/Linux variants for bun-installer and prereqs Co-Authored-By: Claude Sonnet 4.6 --- cli/src/args.ts | 5 + cli/src/commands/compact.ts | 2 +- cli/src/commands/dashboard.ts | 9 +- cli/src/commands/watch-loop.ts | 4 +- cli/src/dispatch/factory.ts | 2 +- cli/src/dispatch/index.ts | 4 + cli/src/dispatch/it2.ts | 7 +- cli/src/dispatch/session-watcher.ts | 197 ++++++++++++++++++ cli/src/dispatch/terminal.ts | 148 +++++++++++++ src/npx-cli/__tests__/bun-installer.test.mjs | 69 +++++- .../install-command.integration.test.mjs | 6 +- src/npx-cli/__tests__/prereqs.test.mjs | 34 ++- src/npx-cli/bun-installer.mjs | 17 +- src/npx-cli/cli-linker.mjs | 60 +++++- src/npx-cli/config-merger.mjs | 4 +- src/npx-cli/index.mjs | 3 + src/npx-cli/plugin-copier.mjs | 15 +- src/npx-cli/prereqs.mjs | 11 +- src/npx-cli/verify.mjs | 4 +- 19 files changed, 557 insertions(+), 44 deletions(-) create mode 100644 cli/src/dispatch/session-watcher.ts create mode 100644 cli/src/dispatch/terminal.ts diff --git a/cli/src/args.ts b/cli/src/args.ts index 8295adcb..21043132 100644 --- a/cli/src/args.ts +++ b/cli/src/args.ts @@ -61,6 +61,11 @@ export function parseArgs(argv: string[]): ParsedCommand { const command = userArgs[0]; + // Version flags are handled directly in index.ts before dispatch + if (command === "--version" || command === "-v") { + return { command: command as Command, args: [], verbosity: 0, force: false }; + } + if (!ALL_COMMANDS.has(command)) { process.stderr.write(`Unknown command: ${command}\n`); process.stderr.write(`Phases: ${VALID_PHASES.join(", ")}\n`); diff --git a/cli/src/commands/compact.ts b/cli/src/commands/compact.ts index 82a43d35..6ea6cfb3 100644 --- a/cli/src/commands/compact.ts +++ b/cli/src/commands/compact.ts @@ -40,7 +40,7 @@ export async function compactCommand(): Promise { let cancelled = false; const onSigint = () => { cancelled = true; - proc.kill("SIGINT"); + proc.kill(); // cross-platform: SIGTERM on Unix, TerminateProcess on Windows }; process.on("SIGINT", onSigint); diff --git a/cli/src/commands/dashboard.ts b/cli/src/commands/dashboard.ts index 215b7dff..dd732de6 100644 --- a/cli/src/commands/dashboard.ts +++ b/cli/src/commands/dashboard.ts @@ -4,7 +4,7 @@ import { WatchLoop } from "./watch-loop.js"; import type { WatchDeps } from "./watch-loop.js"; import { listEnrichedFromStore } from "../store/scan.js"; import { JsonFileStore } from "../store/json-file-store.js"; -import { ReconcilingFactory, ITermSessionFactory, It2Client } from "../dispatch/index.js"; +import { ReconcilingFactory, ITermSessionFactory, It2Client, TerminalSessionFactory } from "../dispatch/index.js"; import type { SessionFactory } from "../dispatch/index.js"; import { discoverGitHub } from "../github/index.js"; import { FallbackEntryStore } from "../dashboard/lifecycle-entries.js"; @@ -30,8 +30,11 @@ export async function dashboardCommand( const dashboardSink = new DashboardSink({ fallbackStore, systemRef }); const logger = createLogger(dashboardSink); - // --- Create iTerm2 dispatch factory --- - const innerFactory: SessionFactory = new ITermSessionFactory(new It2Client()); + // --- Create dispatch factory (platform-aware) --- + // iTerm2 is macOS-only; Windows and Linux use the native terminal factory. + const innerFactory: SessionFactory = process.platform === "darwin" + ? new ITermSessionFactory(new It2Client()) + : new TerminalSessionFactory(); const sessionFactory = new ReconcilingFactory(innerFactory, projectRoot, logger); diff --git a/cli/src/commands/watch-loop.ts b/cli/src/commands/watch-loop.ts index 80f73af0..407f9f67 100644 --- a/cli/src/commands/watch-loop.ts +++ b/cli/src/commands/watch-loop.ts @@ -471,7 +471,9 @@ export class WatchLoop extends EventEmitter { }; process.on("SIGINT", handler); - process.on("SIGTERM", handler); + if (process.platform !== "win32") { + process.on("SIGTERM", handler); + } } } diff --git a/cli/src/dispatch/factory.ts b/cli/src/dispatch/factory.ts index d5c3e892..62f968d1 100644 --- a/cli/src/dispatch/factory.ts +++ b/cli/src/dispatch/factory.ts @@ -85,7 +85,7 @@ export async function runInteractive( let cancelled = false; const onSigint = () => { cancelled = true; - proc.kill("SIGINT"); + proc.kill(); // cross-platform: SIGTERM on Unix, TerminateProcess on Windows }; process.on("SIGINT", onSigint); diff --git a/cli/src/dispatch/index.ts b/cli/src/dispatch/index.ts index b512db4a..7f4a37dc 100644 --- a/cli/src/dispatch/index.ts +++ b/cli/src/dispatch/index.ts @@ -53,5 +53,9 @@ export type { ITerm2AvailabilityResult, } from "./it2.js"; +// Cross-platform terminal factory (Windows Terminal / GNOME Terminal) +export { TerminalSessionFactory } from "./terminal.js"; +export type { CreateWorktreeFn as TerminalCreateWorktreeFn } from "./terminal.js"; + // Reconciling factory export { ReconcilingFactory } from "./reconciling.js"; diff --git a/cli/src/dispatch/it2.ts b/cli/src/dispatch/it2.ts index af17af0f..db283ee8 100644 --- a/cli/src/dispatch/it2.ts +++ b/cli/src/dispatch/it2.ts @@ -90,16 +90,17 @@ export type SpawnFn = ( exited: Promise; }; -/** Resolve the it2 binary path. Checks PATH via which(1). */ +/** Resolve the it2 binary path. Checks PATH via which(1) on Unix, where on Windows. */ function resolveIt2Binary(): string { + const whichCmd = process.platform === "win32" ? "where" : "which"; try { - const proc = Bun.spawnSync(["which", "it2"], { + const proc = Bun.spawnSync([whichCmd, "it2"], { stdout: "pipe", stderr: "pipe", }); if (proc.exitCode === 0) return "it2"; } catch { - // which not available or failed + // which/where not available or failed } return "it2"; // let it fail at exec time } diff --git a/cli/src/dispatch/session-watcher.ts b/cli/src/dispatch/session-watcher.ts new file mode 100644 index 00000000..24e362c3 --- /dev/null +++ b/cli/src/dispatch/session-watcher.ts @@ -0,0 +1,197 @@ +/** + * Shared file-based session completion detection. + * + * Both ITermSessionFactory and TerminalSessionFactory use this to watch for + * .output.json files written by beastmode phase hooks. + */ + +import { watch, type FSWatcher } from "node:fs"; +import { readFileSync, readdirSync, mkdirSync, statSync } from "node:fs"; +import { resolve } from "node:path"; +import type { SessionResult } from "./types.js"; +import { filenameMatchesEpic } from "../artifacts/index.js"; + +export interface WatchMarkerOpts { + sessionId: string; + artifactDir: string; + startTime: number; + signal: AbortSignal; + outputSuffix: string; + epicSlug: string; + broadMatch: boolean; + watchTimeoutMs: number; + resolvers: Map void>; + watchers: Map; +} + +export function watchForMarker(opts: WatchMarkerOpts): Promise { + const { + sessionId, artifactDir, startTime, signal, outputSuffix, + epicSlug, broadMatch, watchTimeoutMs, resolvers, watchers, + } = opts; + + return new Promise((resolvePromise, rejectPromise) => { + resolvers.set(sessionId, (result: SessionResult) => { + resolvers.delete(sessionId); + cleanupWatcher(sessionId, watchers); + resolvePromise(result); + }); + + const existing = findOutputJson(artifactDir, startTime, outputSuffix) + ?? (broadMatch ? findOutputJsonBroad(artifactDir, epicSlug, startTime) : null); + if (existing) { + const result = readOutputJson(existing, startTime); + if (result) { + resolvers.delete(sessionId); + resolvePromise(result); + return; + } + } + + const cleanup = () => { + cleanupWatcher(sessionId, watchers); + clearTimeout(timeout); + }; + + let watcher: FSWatcher; + + try { + mkdirSync(artifactDir, { recursive: true }); + + watcher = watch(artifactDir, (_eventType, filename) => { + if (!filename || !filename.endsWith(".output.json")) return; + if (!filename.endsWith(outputSuffix) && + !(broadMatch && filenameMatchesEpic(filename, epicSlug))) return; + const filePath = resolve(artifactDir, filename); + try { + if (statSync(filePath).mtimeMs < startTime) return; + } catch { + return; + } + const result = readOutputJson(filePath, startTime); + if (result) { + cleanup(); + resolvers.delete(sessionId); + resolvePromise(result); + } + }); + watchers.set(sessionId, watcher); + } catch { + // Fall back to polling + const pollInterval = setInterval(() => { + const found = findOutputJson(artifactDir, startTime, outputSuffix) + ?? (broadMatch ? findOutputJsonBroad(artifactDir, epicSlug, startTime) : null); + if (found) { + clearInterval(pollInterval); + clearTimeout(timeout); + const result = readOutputJson(found, startTime); + resolvers.delete(sessionId); + resolvePromise( + result ?? { success: false, exitCode: 1, durationMs: Date.now() - startTime }, + ); + } + }, 5_000); + + const timeout = setTimeout(() => { + clearInterval(pollInterval); + const broad = findOutputJsonBroad(artifactDir, epicSlug, startTime); + if (broad) { + const result = readOutputJson(broad, startTime); + if (result) { + resolvers.delete(sessionId); + resolvePromise(result); + return; + } + } + resolvers.delete(sessionId); + resolvePromise({ success: false, exitCode: 1, durationMs: Date.now() - startTime }); + }, watchTimeoutMs); + + signal.addEventListener("abort", () => { + clearInterval(pollInterval); + clearTimeout(timeout); + rejectPromise(new DOMException("Aborted", "AbortError")); + }, { once: true }); + + return; + } + + const timeout = setTimeout(() => { + cleanup(); + const broad = findOutputJsonBroad(artifactDir, epicSlug, startTime); + if (broad) { + const result = readOutputJson(broad, startTime); + if (result) { + resolvers.delete(sessionId); + resolvePromise(result); + return; + } + } + resolvers.delete(sessionId); + resolvePromise({ success: false, exitCode: 1, durationMs: Date.now() - startTime }); + }, watchTimeoutMs); + + signal.addEventListener("abort", () => { + cleanup(); + rejectPromise(new DOMException("Aborted", "AbortError")); + }, { once: true }); + }); +} + +export function findOutputJsonBroad(dir: string, epicSlug: string, newerThanMs?: number): string | null { + try { + const files = readdirSync(dir) as string[]; + const candidates = files + .filter((f: string) => f.endsWith(".output.json") && filenameMatchesEpic(f, epicSlug)) + .map((f: string) => resolve(dir, f)) + .filter((fullPath: string) => { + if (newerThanMs === undefined) return true; + try { return statSync(fullPath).mtimeMs >= newerThanMs; } catch { return false; } + }) + .sort(); + return candidates.length > 0 ? candidates[candidates.length - 1] : null; + } catch { + return null; + } +} + +export function findOutputJson(dir: string, newerThanMs?: number, suffix?: string): string | null { + try { + const files = readdirSync(dir) as string[]; + const matchSuffix = suffix ?? ".output.json"; + const candidates = files + .filter((f: string) => f.endsWith(matchSuffix)) + .map((f: string) => resolve(dir, f)) + .filter((fullPath: string) => { + if (newerThanMs === undefined) return true; + try { return statSync(fullPath).mtimeMs >= newerThanMs; } catch { return false; } + }) + .sort(); + return candidates.length > 0 ? candidates[candidates.length - 1] : null; + } catch { + return null; + } +} + +export function readOutputJson(filePath: string, startTime: number): SessionResult | null { + try { + const raw = readFileSync(filePath, "utf-8"); + const output = JSON.parse(raw); + if (!output.status || !output.artifacts) return null; + return { + success: output.status === "completed", + exitCode: output.status === "completed" ? 0 : 1, + durationMs: Date.now() - startTime, + }; + } catch { + return null; + } +} + +export function cleanupWatcher(sessionId: string, watchers: Map): void { + const watcher = watchers.get(sessionId); + if (watcher) { + watcher.close(); + watchers.delete(sessionId); + } +} diff --git a/cli/src/dispatch/terminal.ts b/cli/src/dispatch/terminal.ts new file mode 100644 index 00000000..ec8898bc --- /dev/null +++ b/cli/src/dispatch/terminal.ts @@ -0,0 +1,148 @@ +/** + * Cross-platform terminal session factory. + * + * Windows : opens a new tab in Windows Terminal (wt.exe) + * Linux : opens a new tab in GNOME Terminal, falling back to xterm + * + * Completion detection is file-based (same .output.json mechanism as the + * iTerm2 factory). Liveness checking is skipped — the timeout handles + * sessions that die without writing output.json. + */ + +import { type FSWatcher } from "node:fs"; +import { resolve } from "node:path"; +import type { SessionFactory, SessionCreateOpts, SessionHandle } from "./factory.js"; +import type { SessionResult } from "./types.js"; +import * as worktree from "../git/index.js"; +import { + watchForMarker, + findOutputJson, + findOutputJsonBroad, + cleanupWatcher, +} from "./session-watcher.js"; + +export type CreateWorktreeFn = ( + slug: string, + opts: { cwd: string }, +) => Promise<{ path: string }>; + +export class TerminalSessionFactory implements SessionFactory { + private createWorktree: CreateWorktreeFn; + private watchTimeoutMs: number; + private resolvers = new Map void>(); + private watchers = new Map(); + + constructor(opts?: { watchTimeoutMs?: number; createWorktree?: CreateWorktreeFn }) { + this.createWorktree = opts?.createWorktree ?? ((slug, o) => worktree.create(slug, o)); + this.watchTimeoutMs = opts?.watchTimeoutMs ?? 3_600_000; + } + + async create(opts: SessionCreateOpts): Promise { + const { epicSlug, phase, featureSlug, args, projectRoot } = opts; + const startTime = Date.now(); + const worktreeSlug = epicSlug; + const id = `term-${worktreeSlug}-${Date.now()}`; + + const wt = await this.createWorktree(worktreeSlug, { cwd: projectRoot }); + + const beastmodeCmd = `beastmode ${phase} ${args.join(" ")}`.trim(); + openTerminalTab({ title: `bm-${epicSlug}`, cwd: wt.path, command: beastmodeCmd }); + + const artifactDir = resolve(wt.path, ".beastmode", "artifacts", phase); + const outputSuffix = featureSlug + ? `-${epicSlug}--${featureSlug}.output.json` + : `-${epicSlug}.output.json`; + + // Check for a pre-existing output.json from a prior run before wiring the watcher + const preExisting = findOutputJson(artifactDir, startTime, outputSuffix) + ?? (!featureSlug ? findOutputJsonBroad(artifactDir, epicSlug, startTime) : null); + + const promise = preExisting + ? Promise.resolve({ + success: false, + exitCode: 1, + durationMs: Date.now() - startTime, + }) + : watchForMarker({ + sessionId: id, + artifactDir, + startTime, + signal: opts.signal, + outputSuffix, + epicSlug, + broadMatch: !featureSlug, + watchTimeoutMs: this.watchTimeoutMs, + resolvers: this.resolvers, + watchers: this.watchers, + }); + + opts.signal.addEventListener("abort", () => { + cleanupWatcher(id, this.watchers); + this.resolvers.delete(id); + }, { once: true }); + + return { id, worktreeSlug, promise }; + } + + async cleanup(_epicSlug: string): Promise { + // Windows Terminal and GNOME Terminal don't expose programmatic tab-close APIs. + // Sessions close naturally when the beastmode process exits. + } + + async setBadgeOnContainer(_epicSlug: string, _text: string): Promise { + // Not supported on Windows Terminal or GNOME Terminal. + } +} + +/** + * Open a new terminal tab/window running `command` in the given `cwd`. + * Best-effort — errors are swallowed so a failed terminal open doesn't + * abort the dispatch. + */ +function openTerminalTab(opts: { title: string; cwd: string; command: string }): void { + const { title, cwd, command } = opts; + try { + if (process.platform === "win32") { + openWindowsTerminalTab(title, cwd, command); + } else { + openLinuxTerminalTab(title, cwd, command); + } + } catch { + // Best-effort — watcher still resolves via .output.json + } +} + +function openWindowsTerminalTab(title: string, cwd: string, command: string): void { + // Escape single quotes in paths for PowerShell string literals + const safeCwd = cwd.replace(/'/g, "''"); + const psCommand = `Set-Location '${safeCwd}'; ${command}`; + // -w 0 targets the existing WT window; falls back to new window if none + Bun.spawn( + ["wt", "-w", "0", "new-tab", "--title", title, "--", "pwsh", "-NoExit", "-Command", psCommand], + { stdio: ["ignore", "ignore", "ignore"] }, + ); +} + +function openLinuxTerminalTab(title: string, cwd: string, command: string): void { + // Keep terminal open after command exits so user can inspect output + const shellCmd = `cd '${cwd.replace(/'/g, "'\\''")}' && ${command}; exec bash`; + + const gnomeAvail = Bun.spawnSync( + ["which", "gnome-terminal"], + { stdout: "pipe", stderr: "pipe" }, + ).exitCode === 0; + + if (gnomeAvail) { + Bun.spawn( + ["gnome-terminal", "--tab", `--title=${title}`, "--", "bash", "-c", shellCmd], + { stdio: ["ignore", "ignore", "ignore"] }, + ); + return; + } + + // xterm fallback + Bun.spawn( + ["xterm", "-title", title, "-e", `bash -c '${shellCmd.replace(/'/g, "'\\''")}'`], + { stdio: ["ignore", "ignore", "ignore"] }, + ); +} diff --git a/src/npx-cli/__tests__/bun-installer.test.mjs b/src/npx-cli/__tests__/bun-installer.test.mjs index 6c7af7ce..8bdb0b19 100644 --- a/src/npx-cli/__tests__/bun-installer.test.mjs +++ b/src/npx-cli/__tests__/bun-installer.test.mjs @@ -4,10 +4,11 @@ import assert from 'node:assert/strict'; import { ensureBun } from '../bun-installer.mjs'; describe('ensureBun', () => { - it('skips install when bun is already available', async () => { + it('skips install when bun is already available (unix)', async () => { const commands = []; const result = await ensureBun({ + platform: 'linux', execCommand: (cmd) => { commands.push(cmd); if (cmd.includes('command -v bun')) { @@ -23,11 +24,32 @@ describe('ensureBun', () => { assert.equal(installCmd, undefined); }); - it('installs bun when not found', async () => { + it('skips install when bun is already available (windows)', async () => { + const commands = []; + + const result = await ensureBun({ + platform: 'win32', + execCommand: (cmd) => { + commands.push(cmd); + if (cmd.includes('where bun')) { + return { stdout: 'C:\\bun.exe', exitCode: 0 }; + } + return { stdout: '', exitCode: 0 }; + }, + }); + + assert.equal(result.installed, false); + assert.equal(result.alreadyPresent, true); + const installCmd = commands.find(c => c.includes('bun.sh/install')); + assert.equal(installCmd, undefined); + }); + + it('installs bun when not found (unix)', async () => { const commands = []; let bunAvailable = false; const result = await ensureBun({ + platform: 'linux', execCommand: (cmd) => { commands.push(cmd); if (cmd.includes('command -v bun')) { @@ -50,9 +72,36 @@ describe('ensureBun', () => { assert.ok(installCmd, 'Should have run bun install script'); }); - it('throws when bun install fails', async () => { + it('installs bun when not found (windows)', async () => { + const commands = []; + let bunAvailable = false; + + const result = await ensureBun({ + platform: 'win32', + execCommand: (cmd) => { + commands.push(cmd); + if (cmd.includes('where bun')) { + if (!bunAvailable) throw new Error('not found'); + return { stdout: 'C:\\bun.exe', exitCode: 0 }; + } + if (cmd.includes('bun.sh/install.ps1')) { + bunAvailable = true; + return { stdout: 'Bun installed', exitCode: 0 }; + } + return { stdout: '', exitCode: 0 }; + }, + }); + + assert.equal(result.installed, true); + assert.equal(result.alreadyPresent, false); + const installCmd = commands.find(c => c.includes('bun.sh/install.ps1')); + assert.ok(installCmd, 'Should have run bun install PowerShell script'); + }); + + it('throws when bun install fails (unix)', async () => { await assert.rejects( () => ensureBun({ + platform: 'linux', execCommand: (cmd) => { if (cmd.includes('command -v bun')) throw new Error('not found'); if (cmd.includes('bun.sh/install')) throw new Error('curl failed'); @@ -62,4 +111,18 @@ describe('ensureBun', () => { /Failed to install bun/ ); }); + + it('throws when bun install fails (windows)', async () => { + await assert.rejects( + () => ensureBun({ + platform: 'win32', + execCommand: (cmd) => { + if (cmd.includes('where bun')) throw new Error('not found'); + if (cmd.includes('bun.sh/install.ps1')) throw new Error('powershell failed'); + return { stdout: '', exitCode: 0 }; + }, + }), + /Failed to install bun/ + ); + }); }); diff --git a/src/npx-cli/__tests__/install-command.integration.test.mjs b/src/npx-cli/__tests__/install-command.integration.test.mjs index 554a043b..28d88a79 100644 --- a/src/npx-cli/__tests__/install-command.integration.test.mjs +++ b/src/npx-cli/__tests__/install-command.integration.test.mjs @@ -243,7 +243,7 @@ describe('Install failure produces clear diagnostic message', () => { await rm(sandbox.home, { recursive: true, force: true }); }); - it('rejects non-macOS with a clear message', async () => { + it('rejects unsupported platforms with a clear message', async () => { const { install } = await import('../install.mjs'); const result = await install({ @@ -251,11 +251,11 @@ describe('Install failure produces clear diagnostic message', () => { skipPrereqs: false, execCommand: mockExecCommand({ bun: true, claude: true, beastmode: true }), packageDir: getPackageDir(), - platform: 'linux', + platform: 'freebsd', }); assert.equal(result.success, false); - assert.ok(result.error.includes('macOS'), 'Error should mention macOS'); + assert.ok(result.error.includes('freebsd'), 'Error should mention the detected platform'); }); it('reports missing Claude Code', async () => { diff --git a/src/npx-cli/__tests__/prereqs.test.mjs b/src/npx-cli/__tests__/prereqs.test.mjs index 6e166e51..5d04c1b8 100644 --- a/src/npx-cli/__tests__/prereqs.test.mjs +++ b/src/npx-cli/__tests__/prereqs.test.mjs @@ -19,14 +19,42 @@ describe('checkPrereqs', () => { assert.equal(result.errors.length, 0); }); - it('rejects non-macOS platforms', async () => { + it('rejects unsupported platforms', async () => { const result = await checkPrereqs({ - platform: 'linux', + platform: 'freebsd', execCommand: () => ({ stdout: '', exitCode: 0 }), }); assert.equal(result.ok, false); - assert.ok(result.errors[0].includes('macOS')); + assert.ok(result.errors[0].includes('freebsd')); + }); + + it('accepts linux platform', async () => { + const result = await checkPrereqs({ + platform: 'linux', + execCommand: (cmd) => { + if (cmd.includes('command -v claude')) { + return { stdout: '/usr/bin/claude', exitCode: 0 }; + } + return { stdout: '', exitCode: 0 }; + }, + }); + + assert.equal(result.ok, true); + }); + + it('accepts win32 platform', async () => { + const result = await checkPrereqs({ + platform: 'win32', + execCommand: (cmd) => { + if (cmd.includes('where claude')) { + return { stdout: 'C:\\claude.exe', exitCode: 0 }; + } + return { stdout: '', exitCode: 0 }; + }, + }); + + assert.equal(result.ok, true); }); it('rejects when Claude Code is not installed', async () => { diff --git a/src/npx-cli/bun-installer.mjs b/src/npx-cli/bun-installer.mjs index 2130d116..d12f9c62 100644 --- a/src/npx-cli/bun-installer.mjs +++ b/src/npx-cli/bun-installer.mjs @@ -7,10 +7,11 @@ * @param {object} opts * @param {function} opts.execCommand - shell command executor */ -export async function ensureBun({ execCommand }) { +export async function ensureBun({ execCommand, platform = process.platform }) { // Check if bun is already available + const bunCheck = platform === 'win32' ? 'where bun' : 'command -v bun'; try { - execCommand('command -v bun'); + execCommand(bunCheck); return { installed: false, alreadyPresent: true }; } catch { // bun not found — install it @@ -19,11 +20,17 @@ export async function ensureBun({ execCommand }) { console.log('bun not found. Installing...'); try { - execCommand('curl -fsSL https://bun.sh/install | bash'); + if (platform === 'win32') { + execCommand('powershell -c "irm bun.sh/install.ps1 | iex"'); + } else { + execCommand('curl -fsSL https://bun.sh/install | bash'); + } } catch (err) { + const hint = platform === 'win32' + ? 'powershell -c "irm bun.sh/install.ps1 | iex"' + : 'curl -fsSL https://bun.sh/install | bash'; throw new Error( - `Failed to install bun. ` + - `You can install it manually: curl -fsSL https://bun.sh/install | bash\n` + + `Failed to install bun. You can install it manually: ${hint}\n` + `Error: ${err.message}` ); } diff --git a/src/npx-cli/cli-linker.mjs b/src/npx-cli/cli-linker.mjs index eba78f52..8f29f8f8 100644 --- a/src/npx-cli/cli-linker.mjs +++ b/src/npx-cli/cli-linker.mjs @@ -1,5 +1,8 @@ // src/npx-cli/cli-linker.mjs +import { writeFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; + /** * Install CLI dependencies and link the beastmode command. * @@ -22,15 +25,56 @@ export async function linkCli({ cliDir, execCommand }) { console.log('Linking beastmode CLI...'); - try { - execCommand(`cd "${cliDir}" && bun link`); - } catch (err) { - throw new Error( - `Failed to link CLI. The beastmode command may not be available on your PATH.\n` + - `Try running manually: cd "${cliDir}" && bun link\n` + - `Error: ${err.message}` - ); + if (process.platform === 'win32') { + await linkCliWindows({ cliDir, execCommand }); + } else { + try { + execCommand(`cd "${cliDir}" && bun link`); + } catch (err) { + throw new Error( + `Failed to link CLI. The beastmode command may not be available on your PATH.\n` + + `Try running manually: cd "${cliDir}" && bun link\n` + + `Error: ${err.message}` + ); + } } console.log('CLI linked.'); } + +/** + * Windows-specific CLI linking. + * + * `bun link` on Windows creates shims in ~/.bun/bin/ which may not be in PATH, + * especially when bun was installed via npm rather than the official installer. + * Instead, we find the directory containing the `bun` command (which IS in PATH) + * and drop a beastmode.cmd wrapper there. + */ +async function linkCliWindows({ cliDir, execCommand }) { + // Locate the bun binary directory (first result of `where bun`) + let bunDir; + try { + const result = execCommand('where bun'); + const firstLine = String(result.stdout ?? result).split('\n')[0].trim(); + bunDir = dirname(firstLine); + } catch { + // where failed — fall back to bun link and hope for the best + try { + execCommand(`cd "${cliDir}" && bun link`); + } catch { + // best-effort + } + return; + } + + const entryPoint = join(cliDir, 'src', 'index.ts'); + const wrapperPath = join(bunDir, 'beastmode.cmd'); + + // .cmd wrapper that delegates to bun with the full entry-point path + const cmdContent = [ + '@echo off', + `bun "${entryPoint}" %*`, + ].join('\r\n') + '\r\n'; + + writeFileSync(wrapperPath, cmdContent, 'utf8'); +} diff --git a/src/npx-cli/config-merger.mjs b/src/npx-cli/config-merger.mjs index 2948c668..1a0389cb 100644 --- a/src/npx-cli/config-merger.mjs +++ b/src/npx-cli/config-merger.mjs @@ -37,8 +37,8 @@ async function mergeKnownMarketplaces(homeDir) { data['beastmode-marketplace'] = { source: { - source: 'npm', - name: 'beastmode', + source: 'directory', + path: join(homeDir, '.claude', 'plugins', 'marketplaces', 'bugroger'), }, installLocation: join(homeDir, '.claude', 'plugins', 'marketplaces', 'bugroger'), lastUpdated: new Date().toISOString(), diff --git a/src/npx-cli/index.mjs b/src/npx-cli/index.mjs index 7f142d9d..958e5ff4 100755 --- a/src/npx-cli/index.mjs +++ b/src/npx-cli/index.mjs @@ -18,6 +18,9 @@ switch (command) { homeDir: homedir(), packageDir, }); + if (!result.success) { + console.error(`\nInstallation failed: ${result.error}`); + } process.exit(result.success ? 0 : 1); break; } diff --git a/src/npx-cli/plugin-copier.mjs b/src/npx-cli/plugin-copier.mjs index 01c76646..6cebf301 100644 --- a/src/npx-cli/plugin-copier.mjs +++ b/src/npx-cli/plugin-copier.mjs @@ -1,5 +1,5 @@ // src/npx-cli/plugin-copier.mjs -import { cp, rm, mkdir } from 'node:fs/promises'; +import { cp, rm, mkdir, symlink } from 'node:fs/promises'; import { join } from 'node:path'; /** @@ -19,12 +19,12 @@ export async function copyPlugin({ homeDir, packageDir, version }) { // Clean-replace marketplace directory await rm(marketplaceDir, { recursive: true, force: true }); - await mkdir(marketplaceDir, { recursive: true }); + await mkdir(join(marketplaceDir, '.claude-plugin'), { recursive: true }); - // Copy marketplace.json to marketplace dir + // Copy marketplace.json into .claude-plugin/ subdir (where Claude Code expects it) await cp( join(pluginMetaDir, 'marketplace.json'), - join(marketplaceDir, 'marketplace.json') + join(marketplaceDir, '.claude-plugin', 'marketplace.json') ); // Clean-replace cache directory @@ -34,6 +34,13 @@ export async function copyPlugin({ homeDir, packageDir, version }) { // Copy plugin tree to cache dir (includes plugin.json, skills/, agents/, hooks/) await cp(pluginSourceDir, cacheDir, { recursive: true }); + // Link plugin content into marketplace dir so "source": "./plugin" resolves. + // Windows: use 'junction' (no admin rights required for directories). + // Unix: use default symlink. + const linkTarget = join(marketplaceDir, 'plugin'); + const linkType = process.platform === 'win32' ? 'junction' : undefined; + await symlink(cacheDir, linkTarget, linkType); + console.log(`Plugin files copied to ${marketplaceDir}`); console.log(`Plugin cache written to ${cacheDir}`); } diff --git a/src/npx-cli/prereqs.mjs b/src/npx-cli/prereqs.mjs index 4b42f607..02bf3703 100644 --- a/src/npx-cli/prereqs.mjs +++ b/src/npx-cli/prereqs.mjs @@ -11,19 +11,18 @@ export async function checkPrereqs({ platform, execCommand }) { const errors = []; - // Check macOS - if (platform !== 'darwin') { + // Check supported platform (macOS, Linux, Windows) + if (platform !== 'darwin' && platform !== 'linux' && platform !== 'win32') { errors.push( - `beastmode requires macOS. Detected platform: ${platform}. ` + - `Linux and Windows are not supported yet.` + `beastmode requires macOS, Linux, or Windows. Detected platform: ${platform}.` ); - // Fail fast — no point checking other prereqs on wrong OS return { ok: false, errors }; } // Check Claude Code + const claudeCheck = platform === 'win32' ? 'where claude' : 'command -v claude'; try { - execCommand('command -v claude'); + execCommand(claudeCheck); } catch { errors.push( 'Claude Code is not installed. ' + diff --git a/src/npx-cli/verify.mjs b/src/npx-cli/verify.mjs index 23d16e48..acce4517 100644 --- a/src/npx-cli/verify.mjs +++ b/src/npx-cli/verify.mjs @@ -12,8 +12,10 @@ export async function verifyInstall({ execCommand }) { let beastmodeOk = false; let claudeOk = false; + // Use 'help' instead of '--version' — help exits 0 on all platforms + const beastmodeCmd = process.platform === 'win32' ? 'beastmode help' : 'beastmode --version'; try { - execCommand('beastmode --version'); + execCommand(beastmodeCmd); beastmodeOk = true; } catch { failures.push( From 8944226d355b3fedfb805533d90f95701601584d Mon Sep 17 00:00:00 2001 From: Ralf Heiringhoff Date: Fri, 1 May 2026 11:20:58 +0200 Subject: [PATCH 3/7] docs: update README for Windows and Linux support --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 495b8255..ff8f8b69 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,12 @@ Turns Claude Code into a disciplined engineering partner. Five phases. Context t **Prerequisites:** -- **macOS** — only supported platform (required) +- **macOS, Windows, or Linux** — supported platforms (required) - **[Node.js](https://nodejs.org/) >= 18** — runtime for npx (required) - **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** — AI coding assistant (required) - **[Git](https://git-scm.com/)** — branch and commit operations (required) - **[GitHub CLI](https://cli.github.com/)** — issue and project board sync (optional, for GitHub integration) -- **[iTerm2](https://iterm2.com/)** — pipeline dashboard terminal (optional, for pipeline dashboard) +- **[iTerm2](https://iterm2.com/)** (macOS) / **[Windows Terminal](https://aka.ms/terminal)** (Windows) / **GNOME Terminal** (Linux) — pipeline dashboard terminal (optional, for pipeline dashboard) **Paste this into Claude Code:** From ae9864b754f7e0598ff1f4a3a005fb40d88c1043 Mon Sep 17 00:00:00 2001 From: Ralf Heiringhoff Date: Fri, 1 May 2026 11:32:04 +0200 Subject: [PATCH 4/7] fix: route wt through pwsh to resolve App Execution Alias; use EncodedCommand to avoid quoting issues --- cli/src/dispatch/terminal.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/cli/src/dispatch/terminal.ts b/cli/src/dispatch/terminal.ts index ec8898bc..5232afb7 100644 --- a/cli/src/dispatch/terminal.ts +++ b/cli/src/dispatch/terminal.ts @@ -113,12 +113,19 @@ function openTerminalTab(opts: { title: string; cwd: string; command: string }): } function openWindowsTerminalTab(title: string, cwd: string, command: string): void { - // Escape single quotes in paths for PowerShell string literals const safeCwd = cwd.replace(/'/g, "''"); const psCommand = `Set-Location '${safeCwd}'; ${command}`; - // -w 0 targets the existing WT window; falls back to new window if none + + // wt.exe is a Windows App Execution Alias and cannot be spawned directly via + // CreateProcess — route through pwsh, which resolves App Execution Aliases. + // Use -EncodedCommand (UTF-16LE base64) for the inner pwsh to avoid quoting + // issues when the command travels through two shell argument parsers. + const encoded = Buffer.from(psCommand, "utf16le").toString("base64"); + const safeTitle = title.replace(/'/g, "''"); + // -w 0 targets the existing WT window; opens a new window if none is running Bun.spawn( - ["wt", "-w", "0", "new-tab", "--title", title, "--", "pwsh", "-NoExit", "-Command", psCommand], + ["pwsh", "-NoProfile", "-WindowStyle", "Hidden", "-Command", + `wt -w 0 new-tab --title '${safeTitle}' -- pwsh -NoExit -EncodedCommand ${encoded}`], { stdio: ["ignore", "ignore", "ignore"] }, ); } From b34f25c015a7adb7c21b99ada80c490283373d0e Mon Sep 17 00:00:00 2001 From: Ralf Heiringhoff Date: Fri, 1 May 2026 11:38:34 +0200 Subject: [PATCH 5/7] fix: add platform param to linkCli and verifyInstall; fix test assertions for new plugin layout --- src/npx-cli/__tests__/cli-linker.test.mjs | 130 +++++++++++++------ src/npx-cli/__tests__/plugin-copier.test.mjs | 4 +- src/npx-cli/__tests__/verify.test.mjs | 45 ++++++- src/npx-cli/cli-linker.mjs | 5 +- src/npx-cli/verify.mjs | 5 +- 5 files changed, 141 insertions(+), 48 deletions(-) diff --git a/src/npx-cli/__tests__/cli-linker.test.mjs b/src/npx-cli/__tests__/cli-linker.test.mjs index 3579344e..f503c2cf 100644 --- a/src/npx-cli/__tests__/cli-linker.test.mjs +++ b/src/npx-cli/__tests__/cli-linker.test.mjs @@ -1,53 +1,109 @@ // src/npx-cli/__tests__/cli-linker.test.mjs -import { describe, it } from 'node:test'; +import { describe, it, afterEach } from 'node:test'; import assert from 'node:assert/strict'; +import { rm, access } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { linkCli } from '../cli-linker.mjs'; describe('linkCli', () => { - it('runs bun install and bun link in the cli directory', async () => { - const commands = []; - - await linkCli({ - cliDir: '/fake/cli', - execCommand: (cmd) => { - commands.push(cmd); - return { stdout: '', stderr: '', exitCode: 0 }; - }, - }); - - assert.ok( - commands.some(c => c.includes('bun install') && c.includes('--production')), - 'Should run bun install --production' - ); - assert.ok( - commands.some(c => c.includes('bun link')), - 'Should run bun link' - ); - }); + describe('unix', () => { + it('runs bun install and bun link in the cli directory', async () => { + const commands = []; - it('throws on bun install failure', async () => { - await assert.rejects( - () => linkCli({ + await linkCli({ + platform: 'linux', cliDir: '/fake/cli', execCommand: (cmd) => { - if (cmd.includes('bun install')) throw new Error('install failed'); - return { stdout: '', exitCode: 0 }; + commands.push(cmd); + return { stdout: '', stderr: '', exitCode: 0 }; }, - }), - /Failed to install CLI dependencies/ - ); + }); + + assert.ok( + commands.some(c => c.includes('bun install') && c.includes('--production')), + 'Should run bun install --production' + ); + assert.ok( + commands.some(c => c.includes('bun link')), + 'Should run bun link' + ); + }); + + it('throws on bun install failure', async () => { + await assert.rejects( + () => linkCli({ + platform: 'linux', + cliDir: '/fake/cli', + execCommand: (cmd) => { + if (cmd.includes('bun install')) throw new Error('install failed'); + return { stdout: '', exitCode: 0 }; + }, + }), + /Failed to install CLI dependencies/ + ); + }); + + it('throws on bun link failure', async () => { + await assert.rejects( + () => linkCli({ + platform: 'linux', + cliDir: '/fake/cli', + execCommand: (cmd) => { + if (cmd.includes('bun link')) throw new Error('link failed'); + return { stdout: '', exitCode: 0 }; + }, + }), + /Failed to link CLI/ + ); + }); }); - it('throws on bun link failure', async () => { - await assert.rejects( - () => linkCli({ - cliDir: '/fake/cli', + describe('win32', () => { + const fakeBunCmd = join(tmpdir(), 'fake-bun.cmd'); + const expectedWrapper = join(tmpdir(), 'beastmode.cmd'); + + afterEach(async () => { + await rm(expectedWrapper, { force: true }); + }); + + it('runs bun install and creates beastmode.cmd via where bun', async () => { + const commands = []; + + await linkCli({ + platform: 'win32', + cliDir: 'C:\\fake\\cli', execCommand: (cmd) => { - if (cmd.includes('bun link')) throw new Error('link failed'); + commands.push(cmd); + if (cmd === 'where bun') return { stdout: fakeBunCmd + '\n', exitCode: 0 }; return { stdout: '', exitCode: 0 }; }, - }), - /Failed to link CLI/ - ); + }); + + assert.ok( + commands.some(c => c.includes('bun install') && c.includes('--production')), + 'Should run bun install --production' + ); + assert.ok( + commands.some(c => c === 'where bun'), + 'Should call where bun to locate bun directory' + ); + // beastmode.cmd should have been written next to fake bun + await assert.doesNotReject(access(expectedWrapper)); + }); + + it('throws on bun install failure', async () => { + await assert.rejects( + () => linkCli({ + platform: 'win32', + cliDir: 'C:\\fake\\cli', + execCommand: (cmd) => { + if (cmd.includes('bun install')) throw new Error('install failed'); + return { stdout: '', exitCode: 0 }; + }, + }), + /Failed to install CLI dependencies/ + ); + }); }); }); diff --git a/src/npx-cli/__tests__/plugin-copier.test.mjs b/src/npx-cli/__tests__/plugin-copier.test.mjs index ec71fd46..80d3e58b 100644 --- a/src/npx-cli/__tests__/plugin-copier.test.mjs +++ b/src/npx-cli/__tests__/plugin-copier.test.mjs @@ -45,8 +45,8 @@ describe('copyPlugin', () => { const marketplaceDir = join(homeDir, '.claude', 'plugins', 'marketplaces', 'bugroger'); const cacheDir = join(homeDir, '.claude', 'plugins', 'cache', 'bugroger', 'beastmode', '0.99.0'); - // Marketplace dir has marketplace.json - const mktJson = JSON.parse(await readFile(join(marketplaceDir, 'marketplace.json'), 'utf8')); + // Marketplace dir has marketplace.json in .claude-plugin/ subdir + const mktJson = JSON.parse(await readFile(join(marketplaceDir, '.claude-plugin', 'marketplace.json'), 'utf8')); assert.equal(mktJson.name, 'beastmode-marketplace'); // Cache dir has plugin files diff --git a/src/npx-cli/__tests__/verify.test.mjs b/src/npx-cli/__tests__/verify.test.mjs index 36ada520..319a5bfe 100644 --- a/src/npx-cli/__tests__/verify.test.mjs +++ b/src/npx-cli/__tests__/verify.test.mjs @@ -4,12 +4,13 @@ import assert from 'node:assert/strict'; import { verifyInstall } from '../verify.mjs'; describe('verifyInstall', () => { - it('returns success when both commands work', async () => { + it('returns success when both commands work (unix)', async () => { const result = await verifyInstall({ + platform: 'linux', execCommand: (cmd) => { if (cmd.includes('beastmode --version')) return { stdout: '0.99.0', exitCode: 0 }; if (cmd.includes('claude --version')) return { stdout: '1.0.0', exitCode: 0 }; - return { stdout: '', exitCode: 0 }; + throw new Error(`unexpected command: ${cmd}`); }, }); @@ -18,12 +19,44 @@ describe('verifyInstall', () => { assert.deepEqual(result.failures, []); }); - it('reports beastmode failure', async () => { + it('returns success when both commands work (win32)', async () => { const result = await verifyInstall({ + platform: 'win32', + execCommand: (cmd) => { + if (cmd.includes('beastmode help')) return { stdout: '', exitCode: 0 }; + if (cmd.includes('claude --version')) return { stdout: '1.0.0', exitCode: 0 }; + throw new Error(`unexpected command: ${cmd}`); + }, + }); + + assert.equal(result.beastmode, true); + assert.equal(result.claude, true); + assert.deepEqual(result.failures, []); + }); + + it('reports beastmode failure (unix)', async () => { + const result = await verifyInstall({ + platform: 'linux', execCommand: (cmd) => { if (cmd.includes('beastmode --version')) throw new Error('not found'); if (cmd.includes('claude --version')) return { stdout: '1.0.0', exitCode: 0 }; - return { stdout: '', exitCode: 0 }; + throw new Error(`unexpected command: ${cmd}`); + }, + }); + + assert.equal(result.beastmode, false); + assert.equal(result.claude, true); + assert.ok(result.failures.length > 0); + assert.ok(result.failures[0].includes('beastmode')); + }); + + it('reports beastmode failure (win32)', async () => { + const result = await verifyInstall({ + platform: 'win32', + execCommand: (cmd) => { + if (cmd.includes('beastmode help')) throw new Error('not found'); + if (cmd.includes('claude --version')) return { stdout: '1.0.0', exitCode: 0 }; + throw new Error(`unexpected command: ${cmd}`); }, }); @@ -35,10 +68,11 @@ describe('verifyInstall', () => { it('reports claude failure', async () => { const result = await verifyInstall({ + platform: 'linux', execCommand: (cmd) => { if (cmd.includes('beastmode --version')) return { stdout: '0.99.0', exitCode: 0 }; if (cmd.includes('claude --version')) throw new Error('not found'); - return { stdout: '', exitCode: 0 }; + throw new Error(`unexpected command: ${cmd}`); }, }); @@ -49,6 +83,7 @@ describe('verifyInstall', () => { it('reports both failures', async () => { const result = await verifyInstall({ + platform: 'linux', execCommand: () => { throw new Error('not found'); }, }); diff --git a/src/npx-cli/cli-linker.mjs b/src/npx-cli/cli-linker.mjs index 8f29f8f8..6984b4fe 100644 --- a/src/npx-cli/cli-linker.mjs +++ b/src/npx-cli/cli-linker.mjs @@ -9,8 +9,9 @@ import { join, dirname } from 'node:path'; * @param {object} opts * @param {string} opts.cliDir - path to the cli/ directory in the cache * @param {function} opts.execCommand - shell command executor + * @param {string} [opts.platform] - override process.platform (for testing) */ -export async function linkCli({ cliDir, execCommand }) { +export async function linkCli({ cliDir, execCommand, platform = process.platform }) { console.log('Installing CLI dependencies...'); try { @@ -25,7 +26,7 @@ export async function linkCli({ cliDir, execCommand }) { console.log('Linking beastmode CLI...'); - if (process.platform === 'win32') { + if (platform === 'win32') { await linkCliWindows({ cliDir, execCommand }); } else { try { diff --git a/src/npx-cli/verify.mjs b/src/npx-cli/verify.mjs index acce4517..d95e6102 100644 --- a/src/npx-cli/verify.mjs +++ b/src/npx-cli/verify.mjs @@ -6,14 +6,15 @@ * * @param {object} opts * @param {function} opts.execCommand - shell command executor + * @param {string} [opts.platform] - override process.platform (for testing) */ -export async function verifyInstall({ execCommand }) { +export async function verifyInstall({ execCommand, platform = process.platform }) { const failures = []; let beastmodeOk = false; let claudeOk = false; // Use 'help' instead of '--version' — help exits 0 on all platforms - const beastmodeCmd = process.platform === 'win32' ? 'beastmode help' : 'beastmode --version'; + const beastmodeCmd = platform === 'win32' ? 'beastmode help' : 'beastmode --version'; try { execCommand(beastmodeCmd); beastmodeOk = true; From 8bf9524e529a9f59e1464cee087260528c16eb3f Mon Sep 17 00:00:00 2001 From: Ralf Heiringhoff Date: Fri, 1 May 2026 12:00:31 +0200 Subject: [PATCH 6/7] fix: convert shell script paths to POSIX slashes for Git's sh on Windows --- cli/src/git/commit-issue-ref.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/cli/src/git/commit-issue-ref.ts b/cli/src/git/commit-issue-ref.ts index d36cd419..769c015b 100644 --- a/cli/src/git/commit-issue-ref.ts +++ b/cli/src/git/commit-issue-ref.ts @@ -243,7 +243,10 @@ export async function amendCommitsInRange( // Shell script that reads current HEAD subject, looks up in map, amends. const scriptPath = join(gitDir, "beastmode-amend.sh"); - const escapedMapPath = mapPath.replace(/'/g, "'\\''"); + // Convert to POSIX forward slashes: Git's bundled sh on Windows handles + // these, but backslashes are treated as escape characters inside sh strings. + const toPosix = (p: string) => p.replace(/\\/g, "/"); + const escapedMapPath = toPosix(mapPath).replace(/'/g, "'\\''"); const script = `#!/bin/sh SUBJECT=$(git log -1 --format=%s) MAP_FILE='${escapedMapPath}' @@ -260,9 +263,10 @@ fi `; await writeFile(scriptPath, script, { mode: 0o755 }); - // Run rebase with exec + // Run rebase with exec — use POSIX path so Git's sh doesn't misinterpret + // Windows backslashes as escape characters. const rebaseResult = await git( - ["rebase", "--exec", `sh '${scriptPath.replace(/'/g, "'\\''")}'`, rangeStart], + ["rebase", "--exec", `sh '${toPosix(scriptPath).replace(/'/g, "'\\''")}'`, rangeStart], { cwd: opts.cwd, allowFailure: true }, ); From 7ba0fd37585b88fcd7ada0e361fe578727135e1d Mon Sep 17 00:00:00 2001 From: Ralf Heiringhoff Date: Fri, 1 May 2026 12:06:27 +0200 Subject: [PATCH 7/7] fix: resolve version from cache-layout plugin.json (root) as fallback to source-layout (plugin/) --- cli/src/version.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cli/src/version.ts b/cli/src/version.ts index 5d84c064..d699f236 100644 --- a/cli/src/version.ts +++ b/cli/src/version.ts @@ -11,7 +11,9 @@ import { resolve } from "node:path"; export function resolveVersion(projectRoot?: string): string { try { const root = projectRoot ?? resolve(import.meta.dirname, "..", ".."); - const pluginJsonPath = resolve(root, "plugin", "plugin.json"); + // Source tree: plugin/plugin.json — cache install: plugin.json at root + const candidates = [resolve(root, "plugin", "plugin.json"), resolve(root, "plugin.json")]; + const pluginJsonPath = candidates.find(p => { try { readFileSync(p); return true; } catch { return false; } })!; const content = JSON.parse(readFileSync(pluginJsonPath, "utf-8")); const version = content.version; if (typeof version !== "string" || !version) return "unknown";