Compare commits

...

8 Commits

Author SHA1 Message Date
Stefano Fiorini 193cd45db8 feat(installer): improve cursor and opencode skill handling 2026-04-24 02:20:06 -05:00
Stefano Fiorini d62899308a feat(installer): support pi package remove and update 2026-04-23 22:55:41 -05:00
Stefano Fiorini 8ea6d08e77 docs: add pi manual install guidance 2026-04-23 21:53:27 -05:00
Stefano Fiorini 3966b77623 chore: package skill manager resources 2026-04-23 21:37:15 -05:00
Stefano Fiorini 494e29f797 docs: add skill manager documentation 2026-04-23 21:27:52 -05:00
Stefano Fiorini f01721a45b feat: add multi-client skill manager 2026-04-23 21:21:31 -05:00
Stefano Fiorini 231a66f2b1 feat: add pi reviewer support to workflow variants 2026-04-23 21:03:45 -05:00
Stefano Fiorini ce4746b769 test: add reviewer support verification 2026-04-23 20:49:09 -05:00
58 changed files with 6254 additions and 77 deletions
+50 -6
View File
@@ -18,11 +18,17 @@ ai-coding-skills/
├── README.md
├── docs/
│ ├── README.md
│ ├── INSTALLER.md
│ ├── CODEX.md
│ ├── CLAUDE-CODE.md
│ ├── CURSOR.md
│ ├── OPENCODE.md
│ ├── ATLASSIAN.md
│ ├── CREATE-PLAN.md
│ ├── DO-TASK.md
│ ├── IMPLEMENT-PLAN.md
│ ├── PI.md
│ ├── PI-COMMON-REVIEWER.md
│ ├── PI-RESEARCH.md
│ ├── PI-SUPERPOWERS.md
│ ├── TELEGRAM-NOTIFICATIONS.md
@@ -61,6 +67,7 @@ ai-coding-skills/
│ └── web-automation/
│ ├── codex/
│ ├── claude-code/
│ ├── cursor/
│ ├── opencode/
│ └── pi/
├── .codex/
@@ -98,20 +105,54 @@ ai-coding-skills/
| implement-plan | pi | Worktree-isolated plan execution with iterative cross-model milestone review | Ready | [IMPLEMENT-PLAN](docs/IMPLEMENT-PLAN.md) |
| web-automation | codex | CloakBrowser-backed browsing, scraping, auth, flow automation, and install validation | Ready | [WEB-AUTOMATION](docs/WEB-AUTOMATION.md) |
| web-automation | claude-code | CloakBrowser-backed browsing, scraping, auth, flow automation, and install validation | Ready | [WEB-AUTOMATION](docs/WEB-AUTOMATION.md) |
| web-automation | cursor | CloakBrowser-backed browsing, scraping, auth, flow automation, and install validation | Ready | [WEB-AUTOMATION](docs/WEB-AUTOMATION.md) |
| web-automation | opencode | CloakBrowser-backed browsing, scraping, auth, flow automation, and install validation | Ready | [WEB-AUTOMATION](docs/WEB-AUTOMATION.md) |
| web-automation | pi | CloakBrowser-backed browsing, scraping, auth, flow automation, and install validation | Ready | [WEB-AUTOMATION](docs/WEB-AUTOMATION.md) |
- Docs index: `docs/README.md`
- Atlassian guide: `docs/ATLASSIAN.md`
- Create-plan guide: `docs/CREATE-PLAN.md`
- Do-task guide: `docs/DO-TASK.md`
- Implement-plan guide: `docs/IMPLEMENT-PLAN.md`
- Web-automation guide: `docs/WEB-AUTOMATION.md`
- Start with the docs index: `docs/README.md`
- Automated install/update/remove wizard: `docs/INSTALLER.md`
- Manual install by client: `docs/CODEX.md`, `docs/CLAUDE-CODE.md`, `docs/CURSOR.md`, `docs/OPENCODE.md`, `docs/PI.md`
- Skill guides: `docs/ATLASSIAN.md`, `docs/CREATE-PLAN.md`, `docs/DO-TASK.md`, `docs/IMPLEMENT-PLAN.md`, `docs/WEB-AUTOMATION.md`
## Compatibility Policy
Each skill should explicitly document agent compatibility and any prerequisites directly in its own `SKILL.md`.
## Skill Manager Wizard
Use the repository skill manager to install, update/reinstall, or remove skills for supported local clients:
```bash
./scripts/manage-skills.sh
# or
node scripts/manage-skills.mjs
```
Useful non-interactive modes and examples:
```bash
# Preview detected clients and planned changes without writing files
node scripts/manage-skills.mjs --dry-run
# Emit a machine-readable operation plan from an answers file
node scripts/manage-skills.mjs --plan-only --answers answers.json
# Install/update a skill for a specific client
node scripts/manage-skills.mjs --client codex --scope global --skill create-plan --action install --yes
node scripts/manage-skills.mjs --client codex --scope global --skill create-plan --action update --yes
# Remove a skill from a specific client
node scripts/manage-skills.mjs --client claude-code --scope global --skill do-task --action remove --yes
# Install the Pi package globally or project-locally through the manager
node scripts/manage-skills.mjs --client pi --scope packageGlobal --pi-package --action install --yes
node scripts/manage-skills.mjs --client pi --scope packageLocal --pi-package --action install --yes
```
The wizard detects Codex, Claude Code, Cursor, OpenCode, and Pi, previews operations, checks Superpowers dependencies for workflow skills, and prints a final operation report.
`ai_plan/` is gitignored local planning state used by `create-plan` and `do-task`. The skill manager does not install, sync, or publish `ai_plan/` contents.
## Pi Package
The repo root now includes a pi package manifest that ships only the pi-specific surface:
@@ -119,8 +160,11 @@ The repo root now includes a pi package manifest that ships only the pi-specific
- `pi-package/skills/*/` for the five packaged Pi skills
- `skills/reviewer-runtime/pi/`
- `docs/PI*.md`
- `scripts/manage-skills.mjs` and `scripts/manage-skills.sh`
- `scripts/sync-pi-package-skills.sh`
- `scripts/verify-pi-resources.sh`
- `scripts/verify-pi-workflows.sh`
- `scripts/verify-reviewer-support.sh`
Install it from a cloned checkout with the repo-owned one-liner:
+23 -1
View File
@@ -2,7 +2,7 @@
## Purpose
Provide a portable Atlassian Cloud skill for Codex, Claude Code, Cursor Agent, and OpenCode using one shared CLI surface for common Jira and Confluence workflows.
Provide a portable Atlassian Cloud skill for Codex, Claude Code, Cursor Agent, OpenCode, and Pi using one shared CLI surface for common Jira and Confluence workflows.
## Why This Skill Exists
@@ -117,6 +117,28 @@ cd ~/.cursor/skills/atlassian/scripts
pnpm install
```
### Pi
Recommended full Pi package install:
```bash
./scripts/install-pi-package.sh --global
# or, for project-local Pi package install
./scripts/install-pi-package.sh --local
```
Manual single-skill Pi install from the package mirror:
```bash
./scripts/sync-pi-package-skills.sh
mkdir -p .pi/skills/atlassian
cp -R pi-package/skills/atlassian/* .pi/skills/atlassian/
cd .pi/skills/atlassian/scripts
pnpm install --frozen-lockfile
```
Global manual installs use `~/.pi/agent/skills/atlassian/` instead of `.pi/skills/atlassian/`.
## Verify Installation
Run in the installed `scripts/` folder:
+57
View File
@@ -0,0 +1,57 @@
# Claude Code Manual Install
## Skill Root
Claude Code's Skill tool auto-discovers skills installed under:
```bash
~/.claude/skills/<skill-name>/
```
Manual install example:
```bash
mkdir -p ~/.claude/skills/do-task
cp -R skills/do-task/claude-code/* ~/.claude/skills/do-task/
```
Use `skills/<skill>/claude-code/*` for each supported skill.
## Reviewer Runtime
```bash
mkdir -p ~/.claude/skills/reviewer-runtime
cp skills/reviewer-runtime/run-review.sh ~/.claude/skills/reviewer-runtime/
cp skills/reviewer-runtime/notify-telegram.sh ~/.claude/skills/reviewer-runtime/
chmod +x ~/.claude/skills/reviewer-runtime/*.sh
```
## Superpowers
Workflow skills invoke Superpowers through Claude Code's skill system. Install or link the Obra Superpowers skills under:
```bash
~/.claude/skills/superpowers/
```
Example:
```bash
ln -s /absolute/path/to/obra/superpowers/skills ~/.claude/skills/superpowers
```
Verify:
```bash
test -f ~/.claude/skills/superpowers/brainstorming/SKILL.md
test -f ~/.claude/skills/superpowers/test-driven-development/SKILL.md
test -f ~/.claude/skills/superpowers/verification-before-completion/SKILL.md
```
## Verify
```bash
claude --version
test -f ~/.claude/skills/do-task/SKILL.md
test -x ~/.claude/skills/reviewer-runtime/run-review.sh
```
+61
View File
@@ -0,0 +1,61 @@
# Codex Manual Install
## Skill Root
Codex skills are installed under:
```bash
~/.codex/skills/<skill-name>/
```
Manual install example:
```bash
mkdir -p ~/.codex/skills/create-plan
cp -R skills/create-plan/codex/* ~/.codex/skills/create-plan/
```
Repeat with `skills/<skill>/codex/*` for `atlassian`, `create-plan`, `do-task`, `implement-plan`, and `web-automation`.
## Reviewer Runtime
Workflow skills need the shared runtime helpers:
```bash
mkdir -p ~/.codex/skills/reviewer-runtime
cp skills/reviewer-runtime/run-review.sh ~/.codex/skills/reviewer-runtime/
cp skills/reviewer-runtime/notify-telegram.sh ~/.codex/skills/reviewer-runtime/
chmod +x ~/.codex/skills/reviewer-runtime/*.sh
```
## Superpowers
Workflow skills require Obra Superpowers. Codex variants expect the shared root:
```bash
mkdir -p ~/.agents/skills
ln -s /absolute/path/to/obra/superpowers/skills ~/.agents/skills/superpowers
```
Verify planning skills:
```bash
test -f ~/.agents/skills/superpowers/brainstorming/SKILL.md
test -f ~/.agents/skills/superpowers/writing-plans/SKILL.md
```
Verify execution skills:
```bash
test -f ~/.agents/skills/superpowers/test-driven-development/SKILL.md
test -f ~/.agents/skills/superpowers/verification-before-completion/SKILL.md
test -f ~/.agents/skills/superpowers/finishing-a-development-branch/SKILL.md
```
## Verify
```bash
test -f ~/.codex/skills/create-plan/SKILL.md
test -x ~/.codex/skills/reviewer-runtime/run-review.sh
codex --version
```
+48 -6
View File
@@ -14,10 +14,11 @@ Create structured implementation plans with milestone and story tracking, and op
- `~/.agents/skills/superpowers -> ~/.codex/superpowers/skills`
- For Cursor, skills must be installed under `.cursor/skills/` (repo-local) or `~/.cursor/skills/` (global)
- Shared reviewer runtime must be installed beside agent skills when using reviewer CLIs:
- Codex: `~/.codex/skills/reviewer-runtime/run-review.sh`
- Claude Code: `~/.claude/skills/reviewer-runtime/run-review.sh`
- OpenCode: `~/.config/opencode/skills/reviewer-runtime/run-review.sh`
- Cursor: `.cursor/skills/reviewer-runtime/run-review.sh` or `~/.cursor/skills/reviewer-runtime/run-review.sh`
- Codex: `~/.codex/skills/reviewer-runtime/{run-review.sh,notify-telegram.sh}`
- Claude Code: `~/.claude/skills/reviewer-runtime/{run-review.sh,notify-telegram.sh}`
- OpenCode: `~/.config/opencode/skills/reviewer-runtime/{run-review.sh,notify-telegram.sh}`
- Cursor: `.cursor/skills/reviewer-runtime/{run-review.sh,notify-telegram.sh}` or `~/.cursor/skills/reviewer-runtime/{run-review.sh,notify-telegram.sh}`
- Pi: `.pi/skills/reviewer-runtime/pi/{run-review.sh,notify-telegram.sh}` or `~/.pi/agent/skills/reviewer-runtime/pi/{run-review.sh,notify-telegram.sh}`
- Telegram notification setup is documented in [TELEGRAM-NOTIFICATIONS.md](./TELEGRAM-NOTIFICATIONS.md)
If dependencies are missing, stop and return:
@@ -33,6 +34,7 @@ To use the iterative plan review feature, one of these CLIs must be installed:
| `codex` | `npm install -g @openai/codex` | `codex --version` |
| `claude` | `npm install -g @anthropic-ai/claude-code` | `claude --version` |
| `cursor` | `curl https://cursor.com/install -fsS \| bash` | `cursor-agent --version` (binary: `cursor-agent`; alias `cursor agent` also works) |
| `pi` | Install Pi coding agent | `pi --version`; list models with `pi --list-models [search]` |
The reviewer CLI is independent of which agent is running the planning — e.g., Claude Code can send plans to Codex for review, and vice versa.
@@ -87,6 +89,31 @@ mkdir -p ~/.cursor/skills/reviewer-runtime
cp -R skills/reviewer-runtime/* ~/.cursor/skills/reviewer-runtime/
```
### Pi
Recommended full Pi package install:
```bash
./scripts/install-pi-package.sh --global
# or, for project-local Pi package install
./scripts/install-pi-package.sh --local
```
Manual single-skill Pi install from the package mirror:
```bash
./scripts/sync-pi-package-skills.sh
mkdir -p .pi/skills/create-plan
cp -R pi-package/skills/create-plan/* .pi/skills/create-plan/
mkdir -p .pi/skills/reviewer-runtime/pi
cp -R skills/reviewer-runtime/pi/* .pi/skills/reviewer-runtime/pi/
chmod +x .pi/skills/reviewer-runtime/pi/*.sh
```
Global manual installs use `~/.pi/agent/skills/create-plan/` and `~/.pi/agent/skills/reviewer-runtime/pi/` instead of `.pi/skills/...`.
Pi workflow skills also require Superpowers. See [PI-SUPERPOWERS.md](./PI-SUPERPOWERS.md) and [PI-COMMON-REVIEWER.md](./PI-COMMON-REVIEWER.md).
## Verify Installation
```bash
@@ -94,10 +121,12 @@ test -f ~/.codex/skills/create-plan/SKILL.md || true
test -f ~/.claude/skills/create-plan/SKILL.md || true
test -f ~/.config/opencode/skills/create-plan/SKILL.md || true
test -f .cursor/skills/create-plan/SKILL.md || test -f ~/.cursor/skills/create-plan/SKILL.md || true
test -f .pi/skills/create-plan/SKILL.md || test -f ~/.pi/agent/skills/create-plan/SKILL.md || true
test -x ~/.codex/skills/reviewer-runtime/run-review.sh || true
test -x ~/.claude/skills/reviewer-runtime/run-review.sh || true
test -x ~/.config/opencode/skills/reviewer-runtime/run-review.sh || true
test -x .cursor/skills/reviewer-runtime/run-review.sh || test -x ~/.cursor/skills/reviewer-runtime/run-review.sh || true
test -x .pi/skills/reviewer-runtime/pi/run-review.sh || test -x ~/.pi/agent/skills/reviewer-runtime/pi/run-review.sh || true
```
Verify Superpowers dependencies exist in your agent skills root:
@@ -110,6 +139,8 @@ Verify Superpowers dependencies exist in your agent skills root:
- OpenCode: `~/.config/opencode/skills/superpowers/writing-plans/SKILL.md`
- Cursor: `.cursor/skills/superpowers/skills/brainstorming/SKILL.md` or `~/.cursor/skills/superpowers/skills/brainstorming/SKILL.md`
- Cursor: `.cursor/skills/superpowers/skills/writing-plans/SKILL.md` or `~/.cursor/skills/superpowers/skills/writing-plans/SKILL.md`
- Pi: `.pi/skills/superpowers/brainstorming/SKILL.md` or `~/.pi/agent/skills/superpowers/brainstorming/SKILL.md` or `~/.agents/skills/superpowers/brainstorming/SKILL.md`
- Pi: `.pi/skills/superpowers/writing-plans/SKILL.md` or `~/.pi/agent/skills/superpowers/writing-plans/SKILL.md` or `~/.agents/skills/superpowers/writing-plans/SKILL.md`
## Key Behavior
@@ -134,7 +165,7 @@ Verify Superpowers dependencies exist in your agent skills root:
After the plan is created (design + milestones + stories), the skill sends it to a second model for review:
1. **Configure** — user picks a reviewer CLI (`codex`, `claude`, `cursor`), a model, and optional max rounds (default 10), or skips
1. **Configure** — user picks a reviewer CLI (`codex`, `claude`, `cursor`, `pi`), a model, and optional max rounds (default 10), or skips
2. **Prepare** — plan payload and a bash reviewer command script are written to temp files
3. **Run** — the command script is executed through `reviewer-runtime/run-review.sh` when installed
4. **Feedback** — reviewer evaluates correctness, risks, missing steps, alternatives, security, and returns `## Summary`, `## Findings`, and `## Verdict`
@@ -188,13 +219,24 @@ ts=<ISO-8601> level=<info|warn|error> state=<running-silent|running-active|in-pr
| `codex` | `codex exec -m <model> -s read-only` | Yes (`codex exec resume <id>`) | `-s read-only` |
| `claude` | `claude -p --model <model> --strict-mcp-config --setting-sources user` | No (fresh call each round) | `--strict-mcp-config --setting-sources user` |
| `cursor` | `cursor-agent -p --mode=ask --model <model> --trust --output-format json` | Yes (`--resume <id>`) | `--mode=ask` |
| `pi` | See [PI-COMMON-REVIEWER.md](./PI-COMMON-REVIEWER.md) | No (fresh call each round) | `--tools read,grep,find,ls` |
For all three CLIs, the preferred execution path is:
For all supported reviewer CLIs, the preferred execution path is:
1. write the reviewer command to a bash script
2. run that script through `reviewer-runtime/run-review.sh`
3. fall back to direct synchronous execution only if the helper is missing or not executable
## Pi Reviewer Support
All workflow variants can use Pi itself as a reviewer CLI. Use `pi/<pi-model-name>` shorthand, for example `pi/claude-opus-4-7`; this means `REVIEWER_CLI=pi` and `REVIEWER_MODEL=claude-opus-4-7`. Provider-qualified or multi-slash Pi model IDs are preserved after the first `pi/` prefix, for example `pi/anthropic/claude-opus-4-7`.
The canonical isolated read-only Pi reviewer flag contract lives in [PI-COMMON-REVIEWER.md](./PI-COMMON-REVIEWER.md). This workflow passes the plan review payload at `/tmp/plan-${REVIEW_ID}.md` and expects the standard `## Summary`, `## Findings`, and `## Verdict` response. Pi reviewer output is captured as markdown stdout, not JSON.
If the Pi reviewer model or provider is unavailable, surface the helper stderr/status and use `pi --list-models [search]` to inspect configured models.
## Notifications
- Telegram is the only supported notification path.
+103
View File
@@ -0,0 +1,103 @@
# Cursor Manual Install
## Skill Roots
Cursor supports repo-local and global skills:
```bash
.cursor/skills/<skill-name>/
~/.cursor/skills/<skill-name>/
```
Manual repo-local install example:
```bash
mkdir -p .cursor/skills/create-plan
cp -R skills/create-plan/cursor/* .cursor/skills/create-plan/
```
Global install example:
```bash
mkdir -p ~/.cursor/skills/create-plan
cp -R skills/create-plan/cursor/* ~/.cursor/skills/create-plan/
```
Cursor variants exist for `atlassian`, `create-plan`, `do-task`, `implement-plan`, and `web-automation`.
Web automation repo-local install:
```bash
mkdir -p .cursor/skills/web-automation
cp -R skills/web-automation/cursor/* .cursor/skills/web-automation/
cd .cursor/skills/web-automation/scripts
pnpm install
npx cloakbrowser install
pnpm approve-builds
pnpm rebuild better-sqlite3 esbuild
```
Global web automation installs use `~/.cursor/skills/web-automation/` instead.
## Reviewer Runtime
Repo-local:
```bash
mkdir -p .cursor/skills/reviewer-runtime
cp skills/reviewer-runtime/run-review.sh .cursor/skills/reviewer-runtime/
cp skills/reviewer-runtime/notify-telegram.sh .cursor/skills/reviewer-runtime/
chmod +x .cursor/skills/reviewer-runtime/*.sh
```
Global uses `~/.cursor/skills/reviewer-runtime/` instead.
## Superpowers
Cursor can discover Superpowers from the Cursor plugin cache or from manual
repo-local/global skill roots. Prefer the plugin install when it is present;
do not also install a manual Superpowers copy, or Cursor may show each
Superpowers skill twice.
```bash
.cursor/plugins/cache/cursor-public/superpowers/<revision>/skills/<superpower>/SKILL.md
~/.cursor/plugins/cache/cursor-public/superpowers/<revision>/skills/<superpower>/SKILL.md
.cursor/skills/superpowers/skills/<superpower>/SKILL.md
~/.cursor/skills/superpowers/skills/<superpower>/SKILL.md
```
Manual symlink, only when the Cursor plugin is not installed:
```bash
mkdir -p .cursor/skills/superpowers
ln -s /absolute/path/to/obra/superpowers/skills .cursor/skills/superpowers/skills
```
## Cursor Reviewer Notes
Cursor reviewer calls use JSON output and require `jq` where the skill variant parses reviewer responses:
```bash
cursor-agent --version
jq --version
```
Reviewer calls must use `--mode=ask --trust --output-format json`, never write-capable modes.
## Verify
Repo-local scope:
```bash
cursor-agent --version
test -f .cursor/skills/create-plan/SKILL.md
test -f .cursor/skills/superpowers/skills/brainstorming/SKILL.md || find ~/.cursor/plugins/cache/cursor-public/superpowers -path '*/skills/brainstorming/SKILL.md' -print -quit 2>/dev/null | grep -q .
```
Global scope:
```bash
cursor-agent --version
test -f ~/.cursor/skills/create-plan/SKILL.md
test -f ~/.cursor/skills/superpowers/skills/brainstorming/SKILL.md || find ~/.cursor/plugins/cache/cursor-public/superpowers -path '*/skills/brainstorming/SKILL.md' -print -quit 2>/dev/null | grep -q .
```
+52
View File
@@ -25,6 +25,7 @@ Execute a single user-supplied prompt end-to-end with **two reviewer loops** (pl
- Claude Code: `~/.claude/skills/reviewer-runtime/{run-review.sh,notify-telegram.sh}`
- OpenCode: `~/.config/opencode/skills/reviewer-runtime/{run-review.sh,notify-telegram.sh}`
- Cursor: `.cursor/skills/reviewer-runtime/{run-review.sh,notify-telegram.sh}` (repo-local, preferred) or `~/.cursor/skills/reviewer-runtime/{run-review.sh,notify-telegram.sh}` (global fallback)
- Pi: `.pi/skills/reviewer-runtime/pi/{run-review.sh,notify-telegram.sh}` (repo-local) or `~/.pi/agent/skills/reviewer-runtime/pi/{run-review.sh,notify-telegram.sh}` (global)
- Variant-specific prerequisites:
- **Claude Code:** `claude --version`, explicit `Skill`-tool invocation of sub-skills.
- **Codex:** `codex --version`; `~/.agents/skills/superpowers -> ~/.codex/superpowers/skills` symlink present.
@@ -38,6 +39,7 @@ Dependency-missing messages are variant-specific:
- **Codex:** `Missing dependency: [specific missing item]. Install required Superpowers skills (https://github.com/obra/superpowers) and the reviewer-runtime helper, then retry.`
- **Cursor:** `Missing dependency: [specific missing item]. Install Cursor Agent CLI, jq, and Superpowers skills under .cursor/skills/ or ~/.cursor/skills/, then retry.`
- **OpenCode:** `Missing dependency: [specific missing item]. Install required OpenCode Superpowers skills (https://github.com/obra/superpowers, OpenCode setup) and the reviewer-runtime helper, then retry.`
- **Pi:** `Missing dependency: [specific missing item]. Install Pi, required Superpowers skills, and the Pi reviewer-runtime helper, then retry.`
### Reviewer CLI Requirements
@@ -49,6 +51,7 @@ One of these CLIs must be installed to drive either of the two review loops:
| `claude` | `npm install -g @anthropic-ai/claude-code` | `claude --version` | `--strict-mcp-config --setting-sources user` | No (fresh call each round) |
| `cursor` | `curl https://cursor.com/install -fsS \| bash` | `cursor-agent --version` (binary: `cursor-agent`; alias `cursor agent` also works) | `--mode=ask` | Yes (`--resume <id>`) |
| `opencode` | `brew install opencode` or your package manager | `opencode --version` | `--agent plan` | Opt-in (`-s <id>`; fresh call is the default) |
| `pi` | Install Pi coding agent | `pi --version`; list models with `pi --list-models [search]` | `--tools read,grep,find,ls` | No (fresh call each round) |
The reviewer CLI is independent of which agent is running the skill — e.g., Claude Code can send both the plan and the implementation to Codex for review.
@@ -103,6 +106,31 @@ mkdir -p ~/.cursor/skills/reviewer-runtime
cp -R skills/reviewer-runtime/* ~/.cursor/skills/reviewer-runtime/
```
### Pi
Recommended full Pi package install:
```bash
./scripts/install-pi-package.sh --global
# or, for project-local Pi package install
./scripts/install-pi-package.sh --local
```
Manual single-skill Pi install from the package mirror:
```bash
./scripts/sync-pi-package-skills.sh
mkdir -p .pi/skills/do-task
cp -R pi-package/skills/do-task/* .pi/skills/do-task/
mkdir -p .pi/skills/reviewer-runtime/pi
cp -R skills/reviewer-runtime/pi/* .pi/skills/reviewer-runtime/pi/
chmod +x .pi/skills/reviewer-runtime/pi/*.sh
```
Global manual installs use `~/.pi/agent/skills/do-task/` and `~/.pi/agent/skills/reviewer-runtime/pi/` instead of `.pi/skills/...`.
Pi workflow skills also require Superpowers. See [PI-SUPERPOWERS.md](./PI-SUPERPOWERS.md) and [PI-COMMON-REVIEWER.md](./PI-COMMON-REVIEWER.md).
## Verify Installation
Run the per-variant checks for everything the corresponding `SKILL.md` enforces. Each check is structured: (1) CLI binary version, (2) skill file presence, (3) reviewer-runtime + notifier helper presence, (4) Superpowers sub-skill discovery, (5) variant-specific extras.
@@ -162,6 +190,19 @@ test -f .cursor/skills/superpowers/skills/verification-before-completion/SKILL.m
test -f .cursor/skills/superpowers/skills/finishing-a-development-branch/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/finishing-a-development-branch/SKILL.md
```
### Pi
```bash
pi --version
test -f .pi/skills/do-task/SKILL.md || test -f ~/.pi/agent/skills/do-task/SKILL.md
test -x .pi/skills/reviewer-runtime/pi/run-review.sh || test -x ~/.pi/agent/skills/reviewer-runtime/pi/run-review.sh
test -x .pi/skills/reviewer-runtime/pi/notify-telegram.sh || test -x ~/.pi/agent/skills/reviewer-runtime/pi/notify-telegram.sh
test -f .pi/skills/superpowers/brainstorming/SKILL.md || test -f ~/.pi/agent/skills/superpowers/brainstorming/SKILL.md || test -f ~/.agents/skills/superpowers/brainstorming/SKILL.md
test -f .pi/skills/superpowers/test-driven-development/SKILL.md || test -f ~/.pi/agent/skills/superpowers/test-driven-development/SKILL.md || test -f ~/.agents/skills/superpowers/test-driven-development/SKILL.md
test -f .pi/skills/superpowers/verification-before-completion/SKILL.md || test -f ~/.pi/agent/skills/superpowers/verification-before-completion/SKILL.md || test -f ~/.agents/skills/superpowers/verification-before-completion/SKILL.md
test -f .pi/skills/superpowers/finishing-a-development-branch/SKILL.md || test -f ~/.pi/agent/skills/superpowers/finishing-a-development-branch/SKILL.md || test -f ~/.agents/skills/superpowers/finishing-a-development-branch/SKILL.md
```
## Key Behavior
- Creates one persistent plan artifact at `ai_plan/YYYY-MM-DD-<slug>/task-plan.md`.
@@ -290,6 +331,7 @@ The user answers `yes` / `no` / `redact`:
| `claude` | `claude -p "<prompt>" --model <model> --strict-mcp-config --setting-sources user` | Fresh call with prior-round context summary | `cp <runner.out> <out.md>` |
| `cursor` | `cursor-agent -p --mode=ask --model <model> --trust --output-format json "<prompt>" > <out.json>` | `cursor-agent --resume <id> -p --mode=ask --model <model> --trust --output-format json "<prompt>" > <out.json>` | `jq -r '.result' <out.json> > <out.md>` |
| `opencode` | `opencode run -m <provider>/<model> --agent plan --format json "<prompt>" > <out.json>` | Fresh call (default) OR `opencode run -s <id> -m <provider>/<model> --agent plan --format json "<prompt>" > <out.json>` (opt-in) | `jq -r '.[] \| select(.type == "message" and .role == "assistant") \| .content' <out.json> > <out.md>` |
| `pi` | See [PI-COMMON-REVIEWER.md](./PI-COMMON-REVIEWER.md) | Fresh call | Markdown stdout copied to `<out.md>` |
For all four CLIs, the preferred execution path is:
@@ -297,6 +339,16 @@ For all four CLIs, the preferred execution path is:
2. Run that script through `reviewer-runtime/run-review.sh`.
3. Fall back to direct synchronous execution only if the helper is missing or not executable.
## Pi Reviewer Support
All workflow variants can use Pi itself as a reviewer CLI. Use `pi/<pi-model-name>` shorthand, for example `pi/claude-opus-4-7`; this means `REVIEWER_CLI=pi` and `REVIEWER_MODEL=claude-opus-4-7`. Provider-qualified or multi-slash Pi model IDs are preserved after the first `pi/` prefix, for example `pi/anthropic/claude-opus-4-7`.
The canonical isolated read-only Pi reviewer flag contract lives in [PI-COMMON-REVIEWER.md](./PI-COMMON-REVIEWER.md). This workflow passes the plan and implementation review payload at `/tmp/do-task-${REVIEW_KIND}-${REVIEW_ID}.md` and expects the standard `## Summary`, `## Findings`, and `## Verdict` response. Pi reviewer output is captured as markdown stdout, not JSON.
If the Pi reviewer model or provider is unavailable, surface the helper stderr/status and use `pi --list-models [search]` to inspect configured models.
## Notifications
- Telegram is the only supported notification path.
+51 -7
View File
@@ -21,10 +21,11 @@ Execute an existing plan (created by `create-plan`) in an isolated git worktree,
- `~/.agents/skills/superpowers -> ~/.codex/superpowers/skills`
- For Cursor, skills must be installed under `.cursor/skills/` (repo-local) or `~/.cursor/skills/` (global)
- Shared reviewer runtime must be installed beside agent skills when using reviewer CLIs:
- Codex: `~/.codex/skills/reviewer-runtime/run-review.sh`
- Claude Code: `~/.claude/skills/reviewer-runtime/run-review.sh`
- OpenCode: `~/.config/opencode/skills/reviewer-runtime/run-review.sh`
- Cursor: `.cursor/skills/reviewer-runtime/run-review.sh` or `~/.cursor/skills/reviewer-runtime/run-review.sh`
- Codex: `~/.codex/skills/reviewer-runtime/{run-review.sh,notify-telegram.sh}`
- Claude Code: `~/.claude/skills/reviewer-runtime/{run-review.sh,notify-telegram.sh}`
- OpenCode: `~/.config/opencode/skills/reviewer-runtime/{run-review.sh,notify-telegram.sh}`
- Cursor: `.cursor/skills/reviewer-runtime/{run-review.sh,notify-telegram.sh}` or `~/.cursor/skills/reviewer-runtime/{run-review.sh,notify-telegram.sh}`
- Pi: `.pi/skills/reviewer-runtime/pi/{run-review.sh,notify-telegram.sh}` or `~/.pi/agent/skills/reviewer-runtime/pi/{run-review.sh,notify-telegram.sh}`
- Telegram notification setup is documented in [TELEGRAM-NOTIFICATIONS.md](./TELEGRAM-NOTIFICATIONS.md)
If dependencies are missing, stop and return:
@@ -40,6 +41,7 @@ To use the iterative milestone review feature, one of these CLIs must be install
| `codex` | `npm install -g @openai/codex` | `codex --version` |
| `claude` | `npm install -g @anthropic-ai/claude-code` | `claude --version` |
| `cursor` | `curl https://cursor.com/install -fsS \| bash` | `cursor-agent --version` (binary: `cursor-agent`; alias `cursor agent` also works) |
| `pi` | Install Pi coding agent | `pi --version`; list models with `pi --list-models [search]` |
The reviewer CLI is independent of which agent is running the implementation — e.g., Claude Code can send milestones to Codex for review, and vice versa.
@@ -94,6 +96,31 @@ mkdir -p ~/.cursor/skills/reviewer-runtime
cp -R skills/reviewer-runtime/* ~/.cursor/skills/reviewer-runtime/
```
### Pi
Recommended full Pi package install:
```bash
./scripts/install-pi-package.sh --global
# or, for project-local Pi package install
./scripts/install-pi-package.sh --local
```
Manual single-skill Pi install from the package mirror:
```bash
./scripts/sync-pi-package-skills.sh
mkdir -p .pi/skills/implement-plan
cp -R pi-package/skills/implement-plan/* .pi/skills/implement-plan/
mkdir -p .pi/skills/reviewer-runtime/pi
cp -R skills/reviewer-runtime/pi/* .pi/skills/reviewer-runtime/pi/
chmod +x .pi/skills/reviewer-runtime/pi/*.sh
```
Global manual installs use `~/.pi/agent/skills/implement-plan/` and `~/.pi/agent/skills/reviewer-runtime/pi/` instead of `.pi/skills/...`.
Pi workflow skills also require Superpowers. See [PI-SUPERPOWERS.md](./PI-SUPERPOWERS.md) and [PI-COMMON-REVIEWER.md](./PI-COMMON-REVIEWER.md).
## Verify Installation
```bash
@@ -101,10 +128,12 @@ test -f ~/.codex/skills/implement-plan/SKILL.md || true
test -f ~/.claude/skills/implement-plan/SKILL.md || true
test -f ~/.config/opencode/skills/implement-plan/SKILL.md || true
test -f .cursor/skills/implement-plan/SKILL.md || test -f ~/.cursor/skills/implement-plan/SKILL.md || true
test -f .pi/skills/implement-plan/SKILL.md || test -f ~/.pi/agent/skills/implement-plan/SKILL.md || true
test -x ~/.codex/skills/reviewer-runtime/run-review.sh || true
test -x ~/.claude/skills/reviewer-runtime/run-review.sh || true
test -x ~/.config/opencode/skills/reviewer-runtime/run-review.sh || true
test -x .cursor/skills/reviewer-runtime/run-review.sh || test -x ~/.cursor/skills/reviewer-runtime/run-review.sh || true
test -x .pi/skills/reviewer-runtime/pi/run-review.sh || test -x ~/.pi/agent/skills/reviewer-runtime/pi/run-review.sh || true
```
Verify Superpowers execution dependencies exist in your agent skills root:
@@ -125,6 +154,10 @@ Verify Superpowers execution dependencies exist in your agent skills root:
- Cursor: `.cursor/skills/superpowers/skills/using-git-worktrees/SKILL.md` or `~/.cursor/skills/superpowers/skills/using-git-worktrees/SKILL.md`
- Cursor: `.cursor/skills/superpowers/skills/verification-before-completion/SKILL.md` or `~/.cursor/skills/superpowers/skills/verification-before-completion/SKILL.md`
- Cursor: `.cursor/skills/superpowers/skills/finishing-a-development-branch/SKILL.md` or `~/.cursor/skills/superpowers/skills/finishing-a-development-branch/SKILL.md`
- Pi: `.pi/skills/superpowers/executing-plans/SKILL.md` or `~/.pi/agent/skills/superpowers/executing-plans/SKILL.md` or `~/.agents/skills/superpowers/executing-plans/SKILL.md`
- Pi: `.pi/skills/superpowers/using-git-worktrees/SKILL.md` or `~/.pi/agent/skills/superpowers/using-git-worktrees/SKILL.md` or `~/.agents/skills/superpowers/using-git-worktrees/SKILL.md`
- Pi: `.pi/skills/superpowers/verification-before-completion/SKILL.md` or `~/.pi/agent/skills/superpowers/verification-before-completion/SKILL.md` or `~/.agents/skills/superpowers/verification-before-completion/SKILL.md`
- Pi: `.pi/skills/superpowers/finishing-a-development-branch/SKILL.md` or `~/.pi/agent/skills/superpowers/finishing-a-development-branch/SKILL.md` or `~/.agents/skills/superpowers/finishing-a-development-branch/SKILL.md`
## Key Behavior
@@ -146,14 +179,14 @@ Verify Superpowers execution dependencies exist in your agent skills root:
After each milestone is implemented and verified, the skill sends it to a second model for review:
1. **Configure** — user picks a reviewer CLI (`codex`, `claude`, `cursor`) and model, or skips
1. **Configure** — user picks a reviewer CLI (`codex`, `claude`, `cursor`, `pi`) and model, or skips
2. **Prepare** — milestone payload and a bash reviewer command script are written to temp files
3. **Run** — the command script is executed through `reviewer-runtime/run-review.sh` when installed
4. **Feedback** — reviewer evaluates correctness, acceptance criteria, code quality, test coverage, security, and returns `## Summary`, `## Findings`, and `## Verdict`
5. **Prioritize** — findings are ordered `P0`, `P1`, `P2`, `P3`
6. **Revise** — the implementing agent addresses findings in priority order, re-verifies, and re-submits
7. **Repeat** — up to max rounds (default 10) until the reviewer returns `VERDICT: APPROVED`
7. **Approve** — milestone is marked approved in `story-tracker.md`
8. **Approve** — milestone is marked approved in `story-tracker.md`
### Reviewer Output Contract
@@ -200,13 +233,24 @@ ts=<ISO-8601> level=<info|warn|error> state=<running-silent|running-active|in-pr
| `codex` | `codex exec -m <model> -s read-only` | Yes (`codex exec resume <id>`) | `-s read-only` |
| `claude` | `claude -p --model <model> --strict-mcp-config --setting-sources user` | No (fresh call each round) | `--strict-mcp-config --setting-sources user` |
| `cursor` | `cursor-agent -p --mode=ask --model <model> --trust --output-format json` | Yes (`--resume <id>`) | `--mode=ask` |
| `pi` | See [PI-COMMON-REVIEWER.md](./PI-COMMON-REVIEWER.md) | No (fresh call each round) | `--tools read,grep,find,ls` |
For all three CLIs, the preferred execution path is:
For all supported reviewer CLIs, the preferred execution path is:
1. write the reviewer command to a bash script
2. run that script through `reviewer-runtime/run-review.sh`
3. fall back to direct synchronous execution only if the helper is missing or not executable
## Pi Reviewer Support
All workflow variants can use Pi itself as a reviewer CLI. Use `pi/<pi-model-name>` shorthand, for example `pi/claude-opus-4-7`; this means `REVIEWER_CLI=pi` and `REVIEWER_MODEL=claude-opus-4-7`. Provider-qualified or multi-slash Pi model IDs are preserved after the first `pi/` prefix, for example `pi/anthropic/claude-opus-4-7`.
The canonical isolated read-only Pi reviewer flag contract lives in [PI-COMMON-REVIEWER.md](./PI-COMMON-REVIEWER.md). This workflow passes the milestone review payload at `/tmp/milestone-${REVIEW_ID}.md` and expects the standard `## Summary`, `## Findings`, and `## Verdict` response. Pi reviewer output is captured as markdown stdout, not JSON.
If the Pi reviewer model or provider is unavailable, surface the helper stderr/status and use `pi --list-models [search]` to inspect configured models.
## Notifications
- Telegram is the only supported notification path.
+145
View File
@@ -0,0 +1,145 @@
# Skill Manager Installer
Use the skill manager wizard to install, update/reinstall, or remove skills from this repository for supported local coding clients.
## Quick Start
```bash
./scripts/manage-skills.sh
# or
node scripts/manage-skills.mjs
```
Preview without changing files:
```bash
node scripts/manage-skills.mjs --dry-run
```
Drive the planner non-interactively:
```bash
node scripts/manage-skills.mjs --plan-only --answers answers.json
```
## Supported Clients
The wizard detects these clients by CLI and known skill directories:
| Client | CLI check | Default skill root |
|---|---|---|
| Codex | `codex --version` | `~/.codex/skills` |
| Claude Code | `claude --version` | `~/.claude/skills` |
| Cursor | `cursor-agent --version` then `cursor agent --version` | `.cursor/skills` or `~/.cursor/skills` |
| OpenCode | `opencode --version` | `~/.config/opencode/skills` |
| Pi | `pi --version` | package mode or `.pi/skills` / `~/.pi/agent/skills` |
## Actions
For each selected client/scope, the wizard lists repository skills and offers actions based on installed state:
- missing skill: `install` or `skip`
- installed/stale/unknown skill: `update`, `reinstall`, `remove`, or `skip`
- unsupported variant: reported as `unsupported` and skipped
The current skill matrix is:
| Skill | Codex | Claude Code | Cursor | OpenCode | Pi |
|---|---:|---:|---:|---:|---:|
| `atlassian` | yes | yes | yes | yes | yes |
| `create-plan` | yes | yes | yes | yes | yes |
| `do-task` | yes | yes | yes | yes | yes |
| `implement-plan` | yes | yes | yes | yes | yes |
| `web-automation` | yes | yes | yes | yes | yes |
## Superpowers Handling
Workflow skills require Obra Superpowers:
- `create-plan`: `brainstorming`, `writing-plans`
- `implement-plan`: `executing-plans`, `using-git-worktrees`, `verification-before-completion`, `finishing-a-development-branch`
- `do-task`: `brainstorming`, `test-driven-development`, `verification-before-completion`, `finishing-a-development-branch` plus conditional `using-git-worktrees`
When a selected install/update needs Superpowers and none are detected for the target client/scope, the wizard asks whether to install them. It asks for an absolute path to an Obra Superpowers `skills` directory and lets you choose symlink or copy:
- symlink is recommended because updates to the source checkout are immediately visible
- copy is more self-contained but must be updated manually
For Cursor, the wizard installs Superpowers at `<scope>/superpowers/skills` because Cursor variants expect `superpowers/skills/<skill>/SKILL.md`. Codex commonly reuses the shared `~/.agents/skills/superpowers` convention documented in [CODEX.md](./CODEX.md).
When removing the last repository workflow skill from a client/scope, the wizard asks whether to remove Superpowers for that variant too.
## Reviewer Runtime Helpers
Workflow skills install/update the reviewer-runtime helper bundle automatically when needed:
- non-Pi clients receive `run-review.sh` and `notify-telegram.sh` from `skills/reviewer-runtime/`
- Pi receives `run-review.sh` and `notify-telegram.sh` from `skills/reviewer-runtime/pi/`
- diagnostics tests and nested Pi helper directories are not copied into non-Pi installs
## Pi Package Mode
Pi can be managed as a package install or by manual skill copy. Package mode always manages the full Pi bundle; per-skill prompts and `--skill` narrowing are ignored for `packageGlobal` and `packageLocal`.
Package-mode actions:
- `install`: register the package if needed, list bundled skills, and skip already-bootstrapped runtimes.
- `update`: sync the Pi package mirror, reinstall the package registration, and rerun runtime dependency bootstrapping.
- `reinstall`: same behavior as `update`, kept for action parity with manual skill scopes.
- `remove`: unregister the package with `pi remove`; this does not delete repo files or `node_modules`.
Examples:
```bash
node scripts/manage-skills.mjs --client pi --scope packageGlobal --pi-package --action install --yes
node scripts/manage-skills.mjs --client pi --scope packageLocal --pi-package --action install --yes
node scripts/manage-skills.mjs --client pi --scope packageGlobal --pi-package --action update --yes
node scripts/manage-skills.mjs --client pi --scope packageGlobal --pi-package --action remove --yes
```
The compatibility script remains available:
```bash
./scripts/install-pi-package.sh --global
./scripts/install-pi-package.sh --local
```
## Answers JSON
`--plan-only --answers` accepts this shape:
```json
{
"selections": [
{
"clientId": "codex",
"scope": "global",
"actions": {
"create-plan": "install",
"web-automation": "skip"
}
}
]
}
```
The output is JSON containing planned operations, dependency prompts, and final-report rows. No filesystem changes are made.
## Final Report
The final report uses these columns:
| Column | Meaning |
|---|---|
| client | client id |
| scope | selected target scope |
| skill/helper | skill name or helper bundle |
| action | install, update, reinstall, remove, sync, bootstrap, etc. |
| status | `ok`, `skipped`, `failed`, or `warning` |
| details | target path or error detail |
Exit code is non-zero if any selected operation fails.
Dangling symlink warnings are surfaced as `warning` rows. For example, if a previously symlinked Superpowers source has moved or been deleted, the final report keeps the operation non-destructive and shows the dangling symlink target in `details` so you can repair or remove it deliberately.
See [PI.md](./PI.md) for Pi package layout details and mirror maintenance.
+72
View File
@@ -0,0 +1,72 @@
# OpenCode Manual Install
## Skill Root
OpenCode skills are installed under:
```bash
~/.config/opencode/skills/<skill-name>/
```
Manual install example:
```bash
mkdir -p ~/.config/opencode/skills/implement-plan
cp -R skills/implement-plan/opencode/* ~/.config/opencode/skills/implement-plan/
```
Use `skills/<skill>/opencode/*` for each supported skill.
## Reviewer Runtime
```bash
mkdir -p ~/.config/opencode/skills/reviewer-runtime
cp skills/reviewer-runtime/run-review.sh ~/.config/opencode/skills/reviewer-runtime/
cp skills/reviewer-runtime/notify-telegram.sh ~/.config/opencode/skills/reviewer-runtime/
chmod +x ~/.config/opencode/skills/reviewer-runtime/*.sh
```
## Superpowers
OpenCode can discover Superpowers from the shared agents skill root or the
OpenCode-specific skills root:
```bash
~/.agents/skills/superpowers
~/.config/opencode/skills/superpowers
```
OpenCode's native setup commonly exposes the shared agents root:
```bash
ln -s /absolute/path/to/obra/superpowers/skills ~/.agents/skills/superpowers
```
OpenCode-specific setup is also supported:
```bash
ln -s /absolute/path/to/obra/superpowers/skills ~/.config/opencode/skills/superpowers
```
Verify:
```bash
test -f ~/.agents/skills/superpowers/brainstorming/SKILL.md || test -f ~/.config/opencode/skills/superpowers/brainstorming/SKILL.md
test -f ~/.agents/skills/superpowers/verification-before-completion/SKILL.md || test -f ~/.config/opencode/skills/superpowers/verification-before-completion/SKILL.md
```
## OpenCode Reviewer Notes
OpenCode reviewer calls use the built-in read-oriented plan agent:
```bash
opencode run -m <provider>/<model> --agent plan --format json "review prompt"
```
## Verify
```bash
opencode --version
test -f ~/.config/opencode/skills/implement-plan/SKILL.md
test -x ~/.config/opencode/skills/reviewer-runtime/run-review.sh
```
+12
View File
@@ -16,6 +16,18 @@ The canonical isolated, read-only reviewer command is:
pi --no-session --no-skills --no-prompt-templates --no-extensions --no-context-files --model "$REVIEWER_MODEL" --tools read,grep,find,ls -p "Read the review payload and return the requested verdict."
```
Workflow docs should link to this section instead of restating the full flag contract. Each workflow substitutes its own payload path and verdict prompt:
- `create-plan`: `/tmp/plan-${REVIEW_ID}.md`
- `implement-plan`: `/tmp/milestone-${REVIEW_ID}.md`
- `do-task`: `/tmp/do-task-${REVIEW_KIND}-${REVIEW_ID}.md`
User-facing shorthand `pi/<pi-model-name>` means `REVIEWER_CLI=pi` and `REVIEWER_MODEL=<pi-model-name>`. Split only on the first slash when the prefix before that slash is exactly `pi`; keep the remainder verbatim. Examples:
- `pi/claude-opus-4-7` -> `claude-opus-4-7`
- `pi/anthropic/claude-opus-4-7` -> `anthropic/claude-opus-4-7`
- `pi/openrouter/anthropic/claude-opus-4-7` -> `openrouter/anthropic/claude-opus-4-7`
Pi reviewer calls must stay isolated from the main workflow:
- Use `--no-session` so the reviewer does not continue or persist the workflow session.
+11 -2
View File
@@ -52,7 +52,16 @@ Workflow-heavy Pi skills split their shared setup across two docs:
## Package Install
The user-facing install flow is the repo-owned installer script, not a raw `pi install` command.
The multi-client skill manager can guide Pi install/update/remove operations alongside the other supported clients:
```bash
./scripts/manage-skills.sh
node scripts/manage-skills.mjs --dry-run
```
The compatibility Pi package installer remains available for the focused Pi package path.
The user-facing Pi package install flow is the repo-owned installer script, not a raw `pi install` command.
Global install from a cloned checkout:
@@ -115,7 +124,7 @@ When a source Pi variant changes:
npm pack --dry-run --json
```
The installer intentionally does not run sync. It assumes the checked-in `pi-package/skills/*` mirror is already current.
The focused `scripts/install-pi-package.sh` installer intentionally does not run sync. It assumes the checked-in `pi-package/skills/*` mirror is already current. The multi-client skill manager plans a sync step before manual Pi skill-copy operations so manual installs use the current package-facing mirror.
The verifier is responsible for catching:
+12
View File
@@ -2,8 +2,20 @@
This directory contains user-facing docs for each skill.
## Recommended Reading Flow
1. Use [INSTALLER.md](./INSTALLER.md) for the guided install/update/remove wizard (`./scripts/manage-skills.sh`).
2. Use the client docs ([CODEX.md](./CODEX.md), [CLAUDE-CODE.md](./CLAUDE-CODE.md), [CURSOR.md](./CURSOR.md), [OPENCODE.md](./OPENCODE.md)) for manual installs, and [PI.md](./PI.md) for Pi overview, package install, and manual single-skill copy.
3. Use the skill docs below for skill-specific requirements, runtime setup, verification, and examples.
4. For Pi workflow skills, also consult [PI-SUPERPOWERS.md](./PI-SUPERPOWERS.md) and [PI-COMMON-REVIEWER.md](./PI-COMMON-REVIEWER.md).
## Index
- [INSTALLER.md](./INSTALLER.md) — Skill manager wizard for installing, updating/reinstalling, and removing repo skills across supported clients.
- [CODEX.md](./CODEX.md) — Manual install instructions for Codex variants.
- [CLAUDE-CODE.md](./CLAUDE-CODE.md) — Manual install instructions for Claude Code variants.
- [CURSOR.md](./CURSOR.md) — Manual install instructions for Cursor variants.
- [OPENCODE.md](./OPENCODE.md) — Manual install instructions for OpenCode variants.
- [ATLASSIAN.md](./ATLASSIAN.md) — Includes requirements, generated bundle sync, install, auth, safety rules, and usage examples for the Atlassian skill.
- [CREATE-PLAN.md](./CREATE-PLAN.md) — Includes requirements, install, verification, and execution workflow for create-plan.
- [DO-TASK.md](./DO-TASK.md) — Single-prompt end-to-end execution with dual reviewer loops (plan + implementation), TDD-first, single task commit. Sibling of create-plan/implement-plan scoped to ad-hoc tasks.
+41
View File
@@ -48,6 +48,22 @@ pnpm approve-builds
pnpm rebuild better-sqlite3 esbuild
```
### Cursor
Repo-local install:
```bash
mkdir -p .cursor/skills/web-automation
cp -R skills/web-automation/cursor/* .cursor/skills/web-automation/
cd .cursor/skills/web-automation/scripts
pnpm install
npx cloakbrowser install
pnpm approve-builds
pnpm rebuild better-sqlite3 esbuild
```
Global installs use `~/.cursor/skills/web-automation/` instead.
### OpenCode
```bash
@@ -60,6 +76,31 @@ pnpm approve-builds
pnpm rebuild better-sqlite3 esbuild
```
### Pi
Recommended full Pi package install:
```bash
./scripts/install-pi-package.sh --global
# or, for project-local Pi package install
./scripts/install-pi-package.sh --local
```
Manual single-skill Pi install from the package mirror:
```bash
./scripts/sync-pi-package-skills.sh
mkdir -p .pi/skills/web-automation
cp -R pi-package/skills/web-automation/* .pi/skills/web-automation/
cd .pi/skills/web-automation/scripts
pnpm install --frozen-lockfile
npx cloakbrowser install
pnpm approve-builds
pnpm rebuild better-sqlite3 esbuild
```
Global manual installs use `~/.pi/agent/skills/web-automation/` instead of `.pi/skills/web-automation/`.
## Update To The Latest CloakBrowser
Run inside the installed `scripts/` directory for the variant you are using:
+17 -1
View File
@@ -12,9 +12,14 @@
"files": [
"README.md",
"docs/ATLASSIAN.md",
"docs/CLAUDE-CODE.md",
"docs/CODEX.md",
"docs/CREATE-PLAN.md",
"docs/CURSOR.md",
"docs/DO-TASK.md",
"docs/IMPLEMENT-PLAN.md",
"docs/INSTALLER.md",
"docs/OPENCODE.md",
"docs/README.md",
"docs/TELEGRAM-NOTIFICATIONS.md",
"docs/PI.md",
@@ -25,9 +30,20 @@
"pi-package/skills",
"skills/reviewer-runtime/pi",
"scripts/install-pi-package.sh",
"scripts/lib/skill-manager-core.mjs",
"scripts/manage-skills.mjs",
"scripts/manage-skills.sh",
"scripts/sync-pi-package-skills.sh",
"scripts/verify-pi-resources.sh"
"scripts/verify-pi-resources.sh",
"scripts/verify-pi-workflows.sh",
"scripts/verify-reviewer-support.sh"
],
"scripts": {
"sync:pi": "./scripts/sync-pi-package-skills.sh",
"verify:pi": "./scripts/verify-pi-resources.sh && ./scripts/verify-pi-workflows.sh",
"verify:reviewers": "./scripts/verify-reviewer-support.sh",
"test:installer": "node --test scripts/tests/*.test.mjs"
},
"pi": {
"skills": [
"./pi-package/skills/atlassian",
+8
View File
@@ -85,6 +85,14 @@ Reviewer CLI: `codex`, `claude`, `cursor`, `opencode`, `pi`, or `skip`
Store `REVIEWER_CLI`, `REVIEWER_MODEL`, and `MAX_ROUNDS` for the review loop.
If `REVIEWER_CLI=pi`, verify the Pi reviewer binary before entering the review loop:
```bash
pi --version
```
For shorthand `pi/<pi-model-name>`, split only on the first slash when the prefix is exactly `pi`; store the complete remainder in `REVIEWER_MODEL`. Examples: `pi/claude-opus-4-7` -> `claude-opus-4-7`, `pi/anthropic/claude-opus-4-7` -> `anthropic/claude-opus-4-7`, and `pi/openrouter/anthropic/claude-opus-4-7` -> `openrouter/anthropic/claude-opus-4-7`.
When `REVIEWER_CLI=pi`, the reviewer model is configured independently from the pi model running this workflow. Use any configured pi model string, including provider-qualified model IDs. If the reviewer model or provider is unavailable, surface the review helper stderr/status and ask for a configured model; use `pi --list-models [search]` to inspect configured models.
The pi reviewer command rendered into `/tmp/plan-review-${REVIEW_ID}.sh` must be isolated and read-only:
+8
View File
@@ -103,6 +103,14 @@ Reviewer CLI: `codex`, `claude`, `cursor`, `opencode`, `pi`, or `skip`
Store `REVIEWER_CLI`, `REVIEWER_MODEL`, and `MAX_ROUNDS`.
If `REVIEWER_CLI=pi`, verify the Pi reviewer binary before entering the review loop:
```bash
pi --version
```
For shorthand `pi/<pi-model-name>`, split only on the first slash when the prefix is exactly `pi`; store the complete remainder in `REVIEWER_MODEL`. Examples: `pi/claude-opus-4-7` -> `claude-opus-4-7`, `pi/anthropic/claude-opus-4-7` -> `anthropic/claude-opus-4-7`, and `pi/openrouter/anthropic/claude-opus-4-7` -> `openrouter/anthropic/claude-opus-4-7`.
When `REVIEWER_CLI=pi`, the reviewer model is configured independently from the pi model running this workflow. Use any configured pi model string, including provider-qualified model IDs. If the reviewer model or provider is unavailable, surface the review helper stderr/status and ask for a configured model; use `pi --list-models [search]` to inspect configured models.
The pi reviewer command rendered into `/tmp/do-task-${REVIEW_KIND}-review-${REVIEW_ID}.sh` must be isolated and read-only:
@@ -86,6 +86,14 @@ Reviewer CLI: `codex`, `claude`, `cursor`, `opencode`, `pi`, or `skip`
Store `REVIEWER_CLI`, `REVIEWER_MODEL`, and `MAX_ROUNDS`.
If `REVIEWER_CLI=pi`, verify the Pi reviewer binary before entering the review loop:
```bash
pi --version
```
For shorthand `pi/<pi-model-name>`, split only on the first slash when the prefix is exactly `pi`; store the complete remainder in `REVIEWER_MODEL`. Examples: `pi/claude-opus-4-7` -> `claude-opus-4-7`, `pi/anthropic/claude-opus-4-7` -> `anthropic/claude-opus-4-7`, and `pi/openrouter/anthropic/claude-opus-4-7` -> `openrouter/anthropic/claude-opus-4-7`.
When `REVIEWER_CLI=pi`, the reviewer model is configured independently from the pi model running this workflow. Use any configured pi model string, including provider-qualified model IDs. If the reviewer model or provider is unavailable, surface the review helper stderr/status and ask for a configured model; use `pi --list-models [search]` to inspect configured models.
The pi reviewer command rendered into `/tmp/milestone-review-${REVIEW_ID}.sh` must be isolated and read-only:
+658
View File
@@ -0,0 +1,658 @@
import { access, cp, lstat, mkdir, readFile, realpath, rm, stat, symlink, chmod, unlink, readdir } from "node:fs/promises";
import { constants, existsSync } from "node:fs";
import { homedir } from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
export const REPO_ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), "../..");
export const CLIENTS = {
codex: {
id: "codex",
label: "Codex",
detectCommands: [["codex", "--version"]],
scopes: { global: { skillsRoot: "~/.codex/skills" } },
variant: "codex",
superpowers: {
roots: ["~/.agents/skills/superpowers", "~/.codex/superpowers/skills"],
layout: "flat",
},
reviewerRuntime: {
root: "~/.codex/skills/reviewer-runtime",
source: "skills/reviewer-runtime",
files: ["run-review.sh", "notify-telegram.sh"],
},
},
"claude-code": {
id: "claude-code",
label: "Claude Code",
detectCommands: [["claude", "--version"]],
scopes: { global: { skillsRoot: "~/.claude/skills" } },
variant: "claude-code",
superpowers: {
roots: ["~/.claude/skills/superpowers"],
layout: "flat",
},
reviewerRuntime: {
root: "~/.claude/skills/reviewer-runtime",
source: "skills/reviewer-runtime",
files: ["run-review.sh", "notify-telegram.sh"],
},
},
cursor: {
id: "cursor",
label: "Cursor",
detectCommands: [["cursor-agent", "--version"], ["cursor", "agent", "--version"]],
scopes: {
local: { skillsRoot: ".cursor/skills" },
global: { skillsRoot: "~/.cursor/skills" },
},
variant: "cursor",
superpowers: {
roots: [".cursor/skills/superpowers/skills", "~/.cursor/skills/superpowers/skills"],
layout: "cursor-nested",
targetSuffix: "superpowers/skills",
},
reviewerRuntime: {
root: "<skillsRoot>/reviewer-runtime",
source: "skills/reviewer-runtime",
files: ["run-review.sh", "notify-telegram.sh"],
},
},
opencode: {
id: "opencode",
label: "OpenCode",
detectCommands: [["opencode", "--version"]],
scopes: { global: { skillsRoot: "~/.config/opencode/skills" } },
variant: "opencode",
superpowers: {
roots: ["~/.agents/skills/superpowers", "~/.config/opencode/skills/superpowers"],
layout: "flat",
},
reviewerRuntime: {
root: "~/.config/opencode/skills/reviewer-runtime",
source: "skills/reviewer-runtime",
files: ["run-review.sh", "notify-telegram.sh"],
},
},
pi: {
id: "pi",
label: "Pi",
detectCommands: [["pi", "--version"]],
scopes: {
packageGlobal: { skillsRoot: "~/.pi/agent/skills", packageMode: true, piInstallArg: "" },
packageLocal: { skillsRoot: ".pi/skills", packageMode: true, piInstallArg: "-l" },
global: { skillsRoot: "~/.pi/agent/skills" },
local: { skillsRoot: ".pi/skills" },
},
variant: "pi",
superpowers: {
roots: ["~/.agents/skills/superpowers", "~/.pi/agent/skills/superpowers", ".pi/skills/superpowers"],
layout: "flat",
},
reviewerRuntime: {
root: "<skillsRoot>/reviewer-runtime/pi",
source: "skills/reviewer-runtime/pi",
files: ["run-review.sh", "notify-telegram.sh"],
},
},
};
export const SKILLS = {
atlassian: {
name: "atlassian",
variants: ["codex", "claude-code", "cursor", "opencode", "pi"],
requiresSuperpowers: [],
requiresReviewerRuntime: false,
bootstrap: "pnpm-install",
bootstrapPrerequisites: ["node>=20", "pnpm|corepack"],
auxiliary: { shared: "build-time/shared-not-installed" },
},
"create-plan": {
name: "create-plan",
variants: ["codex", "claude-code", "cursor", "opencode", "pi"],
requiresSuperpowers: ["brainstorming", "writing-plans"],
requiresReviewerRuntime: true,
},
"do-task": {
name: "do-task",
variants: ["codex", "claude-code", "cursor", "opencode", "pi"],
requiresSuperpowers: ["brainstorming", "test-driven-development", "verification-before-completion", "finishing-a-development-branch"],
conditionalSuperpowers: ["using-git-worktrees"],
requiresReviewerRuntime: true,
requiresNotifier: true,
},
"implement-plan": {
name: "implement-plan",
variants: ["codex", "claude-code", "cursor", "opencode", "pi"],
requiresSuperpowers: ["executing-plans", "using-git-worktrees", "verification-before-completion", "finishing-a-development-branch"],
requiresReviewerRuntime: true,
},
"web-automation": {
name: "web-automation",
variants: ["codex", "claude-code", "cursor", "opencode", "pi"],
requiresSuperpowers: [],
requiresReviewerRuntime: false,
bootstrap: "web-automation",
bootstrapPrerequisites: ["node>=20", "pnpm|corepack", "cloakbrowser"],
},
};
export function expandHome(value, cwd = process.cwd(), homeDir = homedir()) {
if (!value) return value;
let expanded = value.replace(/^~(?=$|\/)/, homeDir);
if (!path.isAbsolute(expanded)) expanded = path.resolve(cwd, expanded);
return expanded;
}
export function resolveClientScope(clientId, scope = "global", cwd = process.cwd()) {
const client = CLIENTS[clientId];
if (!client) throw new Error(`Unsupported client: ${clientId}`);
const scopeConfig = client.scopes[scope];
if (!scopeConfig) throw new Error(`Unsupported scope for ${clientId}: ${scope}`);
const skillsRoot = expandHome(scopeConfig.skillsRoot, cwd);
return { ...scopeConfig, skillsRoot };
}
export function reviewerRuntimeRoot(clientId, skillsRoot, cwd = process.cwd()) {
const template = CLIENTS[clientId].reviewerRuntime.root;
return expandHome(template.replace("<skillsRoot>", skillsRoot), cwd);
}
export function parseReviewerShorthand(value) {
if (typeof value !== "string") return null;
const slash = value.indexOf("/");
if (slash <= 0) return null;
if (value.slice(0, slash) !== "pi") return null;
const model = value.slice(slash + 1);
if (!model) return null;
return { reviewerCli: "pi", reviewerModel: model };
}
export function getSkillSource(skillName, clientId, repoRoot = process.cwd()) {
const skill = SKILLS[skillName];
const client = CLIENTS[clientId];
if (!skill || !client || !skill.variants.includes(client.variant)) return null;
if (clientId === "pi") return path.join(repoRoot, "pi-package", "skills", skillName);
return path.join(repoRoot, "skills", skillName, client.variant);
}
export async function pathExists(candidate) {
try {
await access(candidate, constants.F_OK);
return true;
} catch {
return false;
}
}
export async function detectInstalledClients({ cwd = process.cwd() } = {}) {
const result = {};
for (const [clientId, client] of Object.entries(CLIENTS)) {
let confidence = "not-found";
let detail = "not detected";
for (const command of client.detectCommands) {
const proc = spawnSync(command[0], command.slice(1), { encoding: "utf8" });
if (proc.status === 0) {
confidence = "cli";
detail = (proc.stdout || proc.stderr || "detected").trim().split("\n")[0];
break;
}
}
if (confidence === "not-found") {
for (const scopeName of Object.keys(client.scopes)) {
const { skillsRoot } = resolveClientScope(clientId, scopeName, cwd);
if (await pathExists(skillsRoot)) {
confidence = "directory";
detail = skillsRoot;
break;
}
}
}
result[clientId] = { clientId, label: client.label, confidence, detail };
}
return result;
}
export async function detectInstalledSkills({ skillsRoot, clientId, repoRoot = process.cwd() }) {
const state = {};
for (const skillName of Object.keys(SKILLS)) {
if (getSkillSource(skillName, clientId) === null) {
state[skillName] = { state: "unsupported", path: null };
continue;
}
const target = path.join(skillsRoot, skillName);
const skillMd = path.join(target, "SKILL.md");
let installState = "not-installed";
if (existsSync(skillMd)) {
installState = "installed";
const source = getSkillSource(skillName, clientId, repoRoot);
const sourceSkill = source && path.join(source, "SKILL.md");
if (sourceSkill && existsSync(sourceSkill)) {
const [targetStat, sourceStat] = await Promise.all([stat(skillMd), stat(sourceSkill)]);
if (sourceStat.mtimeMs > targetStat.mtimeMs + 1000) installState = "stale";
} else {
installState = "unknown";
}
}
state[skillName] = {
state: installState,
path: target,
};
}
return state;
}
export async function findInstalledSuperpowers(clientId, cwd = process.cwd(), { homeDir = homedir() } = {}) {
const client = CLIENTS[clientId];
const found = new Set();
for (const root of client.superpowers.roots) {
const expanded = expandHome(root, cwd, homeDir);
if (await pathExists(expanded)) found.add(expanded);
}
if (clientId === "claude-code") {
for (const root of await findClaudeCodeSuperpowersPluginRoots(homeDir)) found.add(root);
}
if (clientId === "cursor") {
for (const root of await findCursorSuperpowersPluginRoots(homeDir)) found.add(root);
}
return [...found];
}
async function findClaudeCodeSuperpowersPluginRoots(homeDir) {
const pluginId = "superpowers@claude-plugins-official";
const settings = await readJsonIfExists(path.join(homeDir, ".claude", "settings.json"));
if (settings?.enabledPlugins?.[pluginId] !== true) return [];
const installed = await readJsonIfExists(path.join(homeDir, ".claude", "plugins", "installed_plugins.json"));
const entries = installed?.plugins?.[pluginId];
if (!Array.isArray(entries)) return [];
const roots = [];
for (const entry of entries) {
if (!entry?.installPath) continue;
const skillsRoot = path.join(entry.installPath, "skills");
if (await pathExists(skillsRoot)) roots.push(skillsRoot);
}
return roots;
}
async function findCursorSuperpowersPluginRoots(homeDir) {
const pluginRoot = path.join(homeDir, ".cursor", "plugins", "cache", "cursor-public", "superpowers");
let entries = [];
try {
entries = await readdir(pluginRoot, { withFileTypes: true });
} catch (error) {
if (error?.code === "ENOENT") return [];
throw error;
}
const roots = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const skillsRoot = path.join(pluginRoot, entry.name, "skills");
if (await pathExists(skillsRoot)) roots.push(skillsRoot);
}
return roots.sort();
}
function piPackageSettingsPath(scope, repoRoot) {
if (scope === "packageLocal") return path.join(repoRoot, ".pi", "settings.json");
return path.join(homedir(), ".pi", "agent", "settings.json");
}
async function readJsonIfExists(filePath) {
try {
return JSON.parse(await readFile(filePath, "utf8"));
} catch (error) {
if (error?.code === "ENOENT") return null;
throw error;
}
}
export async function isPiPackageInstalled({ scope, repoRoot }) {
const settingsPath = piPackageSettingsPath(scope, repoRoot);
const settings = await readJsonIfExists(settingsPath);
if (!Array.isArray(settings?.packages)) return false;
const settingsDir = path.dirname(settingsPath);
const expected = path.resolve(repoRoot);
return settings.packages.some((packagePath) => path.resolve(settingsDir, packagePath) === expected);
}
export function isBootstrapInstalled(action, target) {
const scriptsDir = path.join(target, "scripts");
if (action === "pnpm-install") return existsSync(path.join(scriptsDir, "node_modules"));
if (action === "web-automation") {
return existsSync(path.join(scriptsDir, "node_modules"))
&& existsSync(path.join(scriptsDir, "node_modules", ".bin", "cloakbrowser"));
}
return false;
}
export async function detectHelperInstallState({ source, target, files }) {
let differs = false;
for (const file of files) {
const sourceFile = path.join(source, file);
const targetFile = path.join(target, file);
if (!existsSync(targetFile)) return "not-installed";
if (!existsSync(sourceFile)) return "unknown";
const [sourceContents, targetContents] = await Promise.all([readFile(sourceFile), readFile(targetFile)]);
if (!sourceContents.equals(targetContents)) differs = true;
}
return differs ? "stale" : "installed";
}
export function piPackageCommand({ action, repoRoot, piInstallArg = "" }) {
if (["install", "update", "reinstall"].includes(action)) {
const args = ["install"];
if (piInstallArg) args.push(piInstallArg);
args.push(repoRoot);
return ["pi", args];
}
if (action === "remove") {
const args = ["remove"];
if (piInstallArg) args.push(piInstallArg);
args.push(repoRoot);
return ["pi", args];
}
throw new Error(`pi package mode does not support action: ${action}`);
}
export function requiredSuperpowersFor(actions) {
const required = new Set();
for (const [skillName, action] of Object.entries(actions || {})) {
if (!["install", "update", "reinstall"].includes(action)) continue;
for (const skill of SKILLS[skillName]?.requiresSuperpowers || []) required.add(skill);
}
return [...required];
}
async function buildReviewerRuntimeOperation({ clientId, scope, skillsRoot, repoRoot, action = null }) {
if (action === "skip") return null;
const helperTarget = reviewerRuntimeRoot(clientId, skillsRoot, repoRoot);
const helperSource = path.join(repoRoot, CLIENTS[clientId].reviewerRuntime.source);
const helperFiles = CLIENTS[clientId].reviewerRuntime.files;
const helperState = await detectHelperInstallState({ source: helperSource, target: helperTarget, files: helperFiles });
const effectiveAction = action || (helperState === "stale" || helperState === "unknown" ? "update" : "install");
const operation = {
kind: "helper",
helper: "reviewer-runtime",
clientId,
scope,
action: effectiveAction,
source: helperSource,
target: helperTarget,
files: helperFiles,
skillsRoot,
};
if (!action && helperState === "installed") {
operation.status = "skipped";
operation.details = "runtime helper already installed";
}
return operation;
}
export async function buildOperationPlan({ selections, repoRoot = process.cwd(), assumeYes = false, superpowersByClient = null }) {
const operations = [];
const prompts = [];
const reportRows = [];
const plannedHelpers = new Set();
for (const selection of selections || []) {
const { clientId, scope = "global" } = selection;
const client = CLIENTS[clientId];
if (!client) throw new Error(`Unsupported client: ${clientId}`);
const scopeInfo = selection.skillsRoot ? { skillsRoot: selection.skillsRoot } : resolveClientScope(clientId, scope, repoRoot);
const skillsRoot = scopeInfo.skillsRoot;
if (clientId === "pi" && scopeInfo.packageMode) {
const action = selection.action || "install";
if (action === "skip") continue;
if (["update", "reinstall"].includes(action)) {
operations.push({ kind: "sync-pi-package", clientId, scope, action: "sync", repoRoot });
}
const packageInstalled = await isPiPackageInstalled({ scope, repoRoot });
if (action === "remove") {
operations.push({
kind: "pi-package",
clientId,
scope,
action,
repoRoot,
piInstallArg: scopeInfo.piInstallArg || "",
status: packageInstalled ? undefined : "skipped",
details: packageInstalled ? "" : "not installed in Pi settings",
});
continue;
}
operations.push({
kind: "pi-package",
clientId,
scope,
action,
repoRoot,
piInstallArg: scopeInfo.piInstallArg || "",
status: packageInstalled && action === "install" ? "skipped" : undefined,
details: packageInstalled && action === "install" ? "already installed in Pi settings" : "",
});
for (const skillName of Object.keys(SKILLS)) {
if (getSkillSource(skillName, clientId, repoRoot)) {
operations.push({ kind: "package-skill", clientId, scope, skill: skillName, action: "included", status: "included", details: "included in Pi package" });
}
}
for (const bootstrap of [
{ skill: "atlassian", action: "pnpm-install" },
{ skill: "web-automation", action: "web-automation" },
]) {
const target = path.join(repoRoot, "pi-package", "skills", bootstrap.skill);
const installed = isBootstrapInstalled(bootstrap.action, target);
const skipBootstrap = action === "install" && installed;
operations.push({
kind: "bootstrap",
clientId,
scope,
skill: bootstrap.skill,
action: bootstrap.action,
displayAction: "bootstrap-deps",
target,
status: skipBootstrap ? "skipped" : undefined,
details: skipBootstrap ? "runtime dependencies already installed" : target,
});
}
continue;
}
const installed = await detectInstalledSkills({ skillsRoot, clientId, repoRoot });
const actions = selection.actions || {};
const helperActions = selection.helperActions || {};
const missingSuperpowers = requiredSuperpowersFor(actions);
if (missingSuperpowers.length > 0) {
const installedSuperpowers = superpowersByClient && Object.hasOwn(superpowersByClient, clientId)
? superpowersByClient[clientId]
: await findInstalledSuperpowers(clientId, repoRoot);
if (installedSuperpowers.length === 0) {
prompts.push({ kind: "missing-superpowers", clientId, scope, required: missingSuperpowers });
}
}
for (const [helper, action] of Object.entries(helperActions)) {
if (helper !== "reviewer-runtime") continue;
const helperTarget = reviewerRuntimeRoot(clientId, skillsRoot, repoRoot);
const helperKey = `${clientId}\0${scope}\0${helperTarget}`;
if (plannedHelpers.has(helperKey)) continue;
plannedHelpers.add(helperKey);
const helperOperation = await buildReviewerRuntimeOperation({ clientId, scope, skillsRoot, repoRoot, action });
if (helperOperation) operations.push(helperOperation);
}
for (const [skillName, action] of Object.entries(actions)) {
const source = getSkillSource(skillName, clientId, repoRoot);
if (!source) {
operations.push({ kind: "skill", clientId, scope, skill: skillName, action: "unsupported", status: "skipped", details: "variant unavailable" });
continue;
}
if (action === "skip") continue;
const target = path.join(skillsRoot, skillName);
if (clientId === "pi" && !operations.some((op) => op.kind === "sync-pi-package")) {
operations.push({ kind: "sync-pi-package", clientId, scope, action: "sync", repoRoot });
}
operations.push({ kind: "skill", clientId, scope, skill: skillName, action, source, target, skillsRoot });
if (["install", "update", "reinstall"].includes(action) && SKILLS[skillName].requiresReviewerRuntime) {
const helperTarget = reviewerRuntimeRoot(clientId, skillsRoot, repoRoot);
const helperKey = `${clientId}\0${scope}\0${helperTarget}`;
if (!plannedHelpers.has(helperKey)) {
plannedHelpers.add(helperKey);
const helperOperation = await buildReviewerRuntimeOperation({ clientId, scope, skillsRoot, repoRoot });
if (helperOperation) operations.push(helperOperation);
}
}
if (["install", "update", "reinstall"].includes(action) && SKILLS[skillName].bootstrap) {
operations.push({ kind: "bootstrap", clientId, scope, skill: skillName, item: `${skillName} deps`, action: SKILLS[skillName].bootstrap, displayAction: "bootstrap-deps", target });
}
}
const workflowSkills = Object.keys(SKILLS).filter((skillName) => (SKILLS[skillName].requiresSuperpowers || []).length > 0);
const removing = workflowSkills.filter((skillName) => actions[skillName] === "remove");
if (removing.length > 0) {
const remaining = workflowSkills.some((skillName) => actions[skillName] !== "remove" && installed[skillName]?.state === "installed");
if (!remaining) prompts.push({ kind: "remove-superpowers", clientId, scope, removedWorkflowSkills: removing });
}
}
for (const op of operations) {
reportRows.push({
client: op.clientId,
scope: op.scope,
item: op.item || op.skill || op.helper || op.kind,
action: op.displayAction || op.action,
status: op.status || "planned",
details: op.details || op.target || "",
});
}
return { operations, prompts, reportRows, assumeYes };
}
export async function validateRemoveTarget(target, skillsRoot, { repoRoot = process.cwd() } = {}) {
const resolvedRoot = path.resolve(skillsRoot);
const resolvedTarget = path.resolve(target);
const home = path.resolve(homedir());
const resolvedRepo = path.resolve(repoRoot);
if (resolvedTarget === resolvedRoot) throw new Error("refusing to remove skills root itself");
if (resolvedTarget === home) throw new Error("refusing to remove home directory");
if (resolvedTarget === resolvedRepo) throw new Error("refusing to remove repository root");
if (resolvedTarget === path.parse(resolvedTarget).root) throw new Error("refusing to remove filesystem root");
const relative = path.relative(resolvedRoot, resolvedTarget);
if (relative.startsWith("..") || path.isAbsolute(relative) || relative === "") {
throw new Error(`refusing to remove target outside skills root: ${target}`);
}
try {
const info = await lstat(resolvedTarget);
if (!info.isSymbolicLink()) {
const [realRoot, realTarget] = await Promise.all([realpath(resolvedRoot), realpath(resolvedTarget)]);
const realRelative = path.relative(realRoot, realTarget);
if (realRelative.startsWith("..") || path.isAbsolute(realRelative) || realRelative === "") {
throw new Error(`refusing to remove real target outside skills root: ${target}`);
}
}
} catch (error) {
if (error && error.code !== "ENOENT") throw error;
}
return true;
}
export async function copyDirectoryReplacing(source, target) {
await mkdir(path.dirname(target), { recursive: true });
await rm(target, { recursive: true, force: true });
await cp(source, target, { recursive: true, force: true, dereference: false });
}
export async function installHelperAllowlist({ source, target, files }) {
await mkdir(target, { recursive: true });
for (const file of files) {
await cp(path.join(source, file), path.join(target, file), { force: true });
if (file.endsWith(".sh")) await chmod(path.join(target, file), 0o755);
}
}
function runCommand(command, args, options = {}) {
const result = spawnSync(command, args, { encoding: "utf8", stdio: "pipe", ...options });
if (result.status !== 0) {
const detail = (result.stderr || result.stdout || `${command} exited ${result.status}`).trim();
throw new Error(detail);
}
return (result.stdout || result.stderr || "ok").trim();
}
function resolvePnpmCommand() {
if (spawnSync("pnpm", ["--version"], { encoding: "utf8" }).status === 0) return ["pnpm"];
if (spawnSync("corepack", ["--version"], { encoding: "utf8" }).status === 0) return ["corepack", "pnpm"];
throw new Error("missing prerequisite: pnpm or corepack");
}
function requireNode20() {
const major = Number.parseInt(process.versions.node.split(".")[0], 10);
if (major < 20) throw new Error(`missing prerequisite: Node.js 20+ (found ${process.version})`);
}
export async function executeOperation(op) {
if (op.action === "unsupported" || op.status === "skipped") return { ...op, status: "skipped" };
if (op.kind === "package-skill") return { ...op, status: "included" };
if (op.kind === "sync-pi-package") {
runCommand(path.join(op.repoRoot, "scripts", "sync-pi-package-skills.sh"), [], { cwd: op.repoRoot });
return { ...op, status: "ok" };
}
if (op.kind === "pi-package") {
const [command, args] = piPackageCommand(op);
runCommand(command, args, { cwd: op.repoRoot });
return { ...op, status: "ok" };
}
if (op.kind === "skill") {
if (op.action === "remove") {
await validateRemoveTarget(op.target, op.skillsRoot, { repoRoot: REPO_ROOT });
const info = existsSync(op.target) ? await lstat(op.target) : null;
if (info?.isSymbolicLink()) await unlink(op.target);
else await rm(op.target, { recursive: true, force: true });
return { ...op, status: "ok" };
}
await copyDirectoryReplacing(op.source, op.target);
return { ...op, status: "ok" };
}
if (op.kind === "helper") {
if (op.action === "remove") {
await validateRemoveTarget(op.target, op.skillsRoot, { repoRoot: REPO_ROOT });
const info = existsSync(op.target) ? await lstat(op.target) : null;
if (info?.isSymbolicLink()) await unlink(op.target);
else await rm(op.target, { recursive: true, force: true });
return { ...op, status: "ok" };
}
await installHelperAllowlist(op);
return { ...op, status: "ok" };
}
if (op.kind === "superpowers") {
await mkdir(path.dirname(op.target), { recursive: true });
if (op.action === "remove") {
await validateRemoveTarget(op.target, op.skillsRoot, { repoRoot: REPO_ROOT });
await rm(op.target, { recursive: true, force: true });
} else if (op.mode === "copy") {
await copyDirectoryReplacing(op.source, op.target);
} else {
await rm(op.target, { recursive: true, force: true });
await symlink(op.source, op.target, "dir");
}
return { ...op, status: "ok" };
}
if (op.kind === "bootstrap") {
requireNode20();
const pnpm = resolvePnpmCommand();
const scriptsDir = path.join(op.target, "scripts");
if (op.action === "pnpm-install") {
runCommand(pnpm[0], [...pnpm.slice(1), "install", "--frozen-lockfile", "--dir", scriptsDir]);
} else if (op.action === "web-automation") {
runCommand(pnpm[0], [...pnpm.slice(1), "install", "--frozen-lockfile", "--dir", scriptsDir]);
runCommand(pnpm[0], [...pnpm.slice(1), "--dir", scriptsDir, "exec", "cloakbrowser", "install"]);
runCommand(pnpm[0], [...pnpm.slice(1), "rebuild", "--dir", scriptsDir, "better-sqlite3", "esbuild"]);
}
return { ...op, status: "ok" };
}
return { ...op, status: "warning", details: "operation is planned/manual" };
}
+320
View File
@@ -0,0 +1,320 @@
#!/usr/bin/env node
import { readFile } from "node:fs/promises";
import { stdin as input, stdout as output } from "node:process";
import readline from "node:readline/promises";
import path from "node:path";
import {
CLIENTS,
SKILLS,
buildOperationPlan,
detectHelperInstallState,
detectInstalledClients,
detectInstalledSkills,
executeOperation,
isPiPackageInstalled,
parseReviewerShorthand,
resolveClientScope,
reviewerRuntimeRoot,
} from "./lib/skill-manager-core.mjs";
function usage() {
return `Usage:
node scripts/manage-skills.mjs [--dry-run]
node scripts/manage-skills.mjs --plan-only --answers <answers.json>
node scripts/manage-skills.mjs --client <client> --scope <scope> --skill <skill> --action <install|update|reinstall|remove|skip> [--yes]
Options:
--dry-run Prompt or detect normally, but do not modify files.
--plan-only Non-interactive mode. Requires --answers and emits JSON.
--answers <file> JSON answers file with { "selections": [...] }.
--client <client> codex, claude-code, cursor, opencode, or pi.
--scope <scope> Client scope. Examples: global, local, packageGlobal, packageLocal.
--skill <skill> Skill to manage. May be repeated.
--action <action> Action for --skill entries. Defaults to install.
--yes Execute planned operations without final confirmation.
--pi-package With --client pi, default to packageGlobal scope (full bundle).
--reviewer <value> Parse/display reviewer shorthand, e.g. pi/claude-opus-4-7.
-h, --help Show this help.
Answers JSON example:
{
"selections": [
{
"clientId": "codex",
"scope": "global",
"actions": { "create-plan": "install", "web-automation": "skip" },
"helperActions": { "reviewer-runtime": "skip" }
}
]
}
Final report columns: client, scope, skill/helper, action, status, details.
`;
}
function parseArgs(argv) {
const args = { skills: [] };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
switch (arg) {
case "--dry-run": args.dryRun = true; break;
case "--plan-only": args.planOnly = true; break;
case "--answers": args.answers = argv[++i]; break;
case "--client": args.client = argv[++i]; break;
case "--scope": args.scope = argv[++i]; break;
case "--skill": args.skills.push(argv[++i]); break;
case "--action": args.action = argv[++i]; break;
case "--yes": args.yes = true; break;
case "--pi-package": args.piPackage = true; break;
case "--reviewer": args.reviewer = argv[++i]; break;
case "-h":
case "--help": args.help = true; break;
default:
throw new Error(`Unknown argument: ${arg}`);
}
}
return args;
}
function renderTable(rows) {
if (!rows.length) return "No operations planned.";
const headers = ["client", "scope", "skill/helper", "action", "status", "details"];
const data = rows.map((row) => [row.client, row.scope, row.item, row.action, row.status, row.details]);
const widths = headers.map((header, index) => Math.max(header.length, ...data.map((row) => String(row[index] ?? "").length)));
const format = (row) => row.map((cell, index) => String(cell ?? "").padEnd(widths[index])).join(" ");
return [format(headers), format(widths.map((width) => "-".repeat(width))), ...data.map(format)].join("\n");
}
async function answersToPlan(answers, { assumeYes = false } = {}) {
const plan = await buildOperationPlan({ selections: answers.selections || [], assumeYes, repoRoot: process.cwd(), superpowersByClient: answers.superpowersByClient });
return plan;
}
async function buildCliSelection(args) {
if (!args.client) return null;
const clientId = args.client;
if (!CLIENTS[clientId]) throw new Error(`Unsupported client: ${clientId}`);
const scope = args.scope || (clientId === "pi" && args.piPackage ? "packageGlobal" : "global");
const scopeInfo = resolveClientScope(clientId, scope, process.cwd());
if (clientId === "pi" && scopeInfo.packageMode) {
return { selections: [{ clientId, scope, action: args.action || "install", actions: {} }] };
}
const skills = args.skills.length ? args.skills : Object.keys(SKILLS);
const action = args.action || "install";
const actions = Object.fromEntries(skills.map((skill) => [skill, action]));
return { selections: [{ clientId, scope, actions }] };
}
async function interactiveAnswers({ dryRun = false } = {}) {
const detected = await detectInstalledClients();
console.log("Detected supported clients:");
for (const info of Object.values(detected)) {
console.log(`- ${info.clientId}: ${info.confidence} (${info.detail})`);
}
if (dryRun && !input.isTTY) {
return { selections: [] };
}
const rl = readline.createInterface({ input, output });
try {
const clientLine = await rl.question("Clients to manage (comma-separated, blank for detected CLI clients): ");
const chosenClients = clientLine.trim()
? clientLine.split(",").map((value) => value.trim()).filter(Boolean)
: Object.values(detected).filter((info) => info.confidence !== "not-found").map((info) => info.clientId);
const selections = [];
for (const clientId of chosenClients) {
if (!CLIENTS[clientId]) {
console.log(`Skipping unsupported client: ${clientId}`);
continue;
}
const scopeNames = Object.keys(CLIENTS[clientId].scopes);
let scope = scopeNames[0];
if (scopeNames.length > 1) {
const answer = await rl.question(`${clientId} scope (${scopeNames.join("/")}) [${scope}]: `);
if (answer.trim()) scope = answer.trim();
}
const scopeInfo = resolveClientScope(clientId, scope, process.cwd());
if (scopeInfo.packageMode) {
console.log(`${clientId}/${scope}: package mode manages the full Pi package bundle; per-skill prompts are skipped.`);
const installed = await isPiPackageInstalled({ scope, repoRoot: process.cwd() });
const choices = "install/update/reinstall/remove/skip";
const defaultAction = installed ? "skip" : "install";
const answer = await rl.question(`${clientId}/${scope} package is ${installed ? "installed" : "not-installed"}; action (${choices}) [${defaultAction}]: `);
const chosen = answer.trim() || defaultAction;
if (!choices.split("/").includes(chosen)) {
console.log(`Invalid action '${chosen}', using skip.`);
selections.push({ clientId, scope, action: "skip", actions: {} });
} else {
selections.push({ clientId, scope, action: chosen, actions: {} });
}
continue;
}
const installed = await detectInstalledSkills({ clientId, skillsRoot: scopeInfo.skillsRoot });
const actions = {};
for (const skill of Object.keys(SKILLS)) {
const state = installed[skill]?.state || "unsupported";
if (state === "unsupported") {
console.log(`${clientId}/${scope}/${skill}: unsupported`);
actions[skill] = "skip";
continue;
}
const choices = state === "installed" || state === "stale" || state === "unknown" ? "update/reinstall/remove/skip" : "install/skip";
const defaultAction = "skip";
const answer = await rl.question(`${clientId}/${scope}/${skill} is ${state}; action (${choices}) [${defaultAction}]: `);
const chosen = answer.trim() || defaultAction;
const allowed = choices.split("/");
if (!allowed.includes(chosen)) {
console.log(`Invalid action '${chosen}', using skip.`);
actions[skill] = "skip";
} else {
actions[skill] = chosen;
}
}
const helperActions = {};
const workflowNeedsReviewerRuntime = Object.entries(actions).some(([skill, action]) => (
["install", "update", "reinstall"].includes(action) && SKILLS[skill]?.requiresReviewerRuntime
));
const helperTarget = reviewerRuntimeRoot(clientId, scopeInfo.skillsRoot, process.cwd());
const helperState = await detectHelperInstallState({
source: path.join(process.cwd(), CLIENTS[clientId].reviewerRuntime.source),
target: helperTarget,
files: CLIENTS[clientId].reviewerRuntime.files,
});
if (workflowNeedsReviewerRuntime || helperState !== "not-installed") {
const choices = helperState === "not-installed" ? "install/skip" : "update/reinstall/remove/skip";
let defaultAction = "skip";
if (workflowNeedsReviewerRuntime && helperState === "not-installed") defaultAction = "install";
if (workflowNeedsReviewerRuntime && ["stale", "unknown"].includes(helperState)) defaultAction = "update";
const answer = await rl.question(`${clientId}/${scope}/reviewer-runtime is ${helperState}; action (${choices}) [${defaultAction}]: `);
const chosen = answer.trim() || defaultAction;
const allowed = choices.split("/");
if (!allowed.includes(chosen)) {
console.log(`Invalid action '${chosen}', using skip.`);
helperActions["reviewer-runtime"] = "skip";
} else {
helperActions["reviewer-runtime"] = chosen;
}
}
selections.push({ clientId, scope, actions, helperActions });
}
return { selections };
} finally {
rl.close();
}
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
console.log(usage());
return;
}
if (args.reviewer) {
console.log(JSON.stringify(parseReviewerShorthand(args.reviewer), null, 2));
return;
}
let answers;
if (args.answers) {
answers = JSON.parse(await readFile(path.resolve(args.answers), "utf8"));
} else {
answers = await buildCliSelection(args);
}
if (!answers) answers = await interactiveAnswers({ dryRun: args.dryRun });
const plan = await answersToPlan(answers, { assumeYes: args.yes });
if (args.planOnly) {
console.log(JSON.stringify(plan, null, 2));
return;
}
console.log("Operation preview:");
console.log(renderTable(plan.reportRows));
if (plan.prompts.length) {
console.log("\nDependency prompts:");
for (const prompt of plan.prompts) {
const promptDetail = prompt.required ? prompt.required.join(",") : (prompt.removedWorkflowSkills ? prompt.removedWorkflowSkills.join(",") : "");
console.log(`- ${prompt.kind}: ${prompt.clientId}/${prompt.scope} ${promptDetail}`);
}
}
if (plan.operations.length === 0) return;
if (args.dryRun) {
console.log("\nDry-run mode: no filesystem changes performed.");
return;
}
if (plan.prompts.length && input.isTTY && !args.yes) {
const rl = readline.createInterface({ input, output });
try {
for (const prompt of plan.prompts) {
if (prompt.kind === "missing-superpowers") {
const install = await rl.question(`Install/link Superpowers for ${prompt.clientId}/${prompt.scope}? (yes/no) [no]: `);
if (install.trim().toLowerCase() === "yes") {
const source = await rl.question("Absolute path to Obra Superpowers skills directory: ");
const modeAnswer = await rl.question("Install mode (symlink/copy) [symlink]: ");
const client = CLIENTS[prompt.clientId];
const scope = resolveClientScope(prompt.clientId, prompt.scope, process.cwd());
const target = client.superpowers.layout === "cursor-nested"
? `${scope.skillsRoot}/superpowers/skills`
: `${scope.skillsRoot}/superpowers`;
plan.operations.push({ kind: "superpowers", clientId: prompt.clientId, scope: prompt.scope, action: "install", source: source.trim(), target, skillsRoot: scope.skillsRoot, mode: modeAnswer.trim() === "copy" ? "copy" : "symlink" });
}
}
if (prompt.kind === "remove-superpowers") {
const removeAnswer = await rl.question(`Remove Superpowers for ${prompt.clientId}/${prompt.scope} too? (yes/no) [no]: `);
if (removeAnswer.trim().toLowerCase() === "yes") {
const scope = resolveClientScope(prompt.clientId, prompt.scope, process.cwd());
const client = CLIENTS[prompt.clientId];
const target = `${scope.skillsRoot}/superpowers`;
plan.operations.push({ kind: "superpowers", clientId: prompt.clientId, scope: prompt.scope, action: "remove", target, skillsRoot: scope.skillsRoot });
}
}
}
} finally {
rl.close();
}
}
if (!args.yes) {
if (!input.isTTY) {
console.log("Refusing to execute without --yes in non-interactive mode.");
process.exitCode = 2;
return;
}
const rl = readline.createInterface({ input, output });
const answer = await rl.question("Proceed with these operations? (yes/no): ");
rl.close();
if (answer.trim().toLowerCase() !== "yes") {
console.log("Skipped by user.");
return;
}
}
const results = [];
for (const operation of plan.operations) {
try {
results.push(await executeOperation(operation));
} catch (error) {
results.push({ ...operation, status: "failed", details: error.message });
}
}
const rows = results.map((op) => ({
client: op.clientId,
scope: op.scope,
item: op.item || op.skill || op.helper || op.kind,
action: op.displayAction || op.action,
status: op.status,
details: op.details || op.target || "",
}));
console.log("\nFinal report:");
console.log(renderTable(rows));
if (results.some((result) => result.status === "failed")) process.exitCode = 1;
}
main().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env bash
set -euo pipefail
exec node "$(dirname "$0")/manage-skills.mjs" "$@"
+429
View File
@@ -0,0 +1,429 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import {
CLIENTS,
SKILLS,
buildOperationPlan,
detectInstalledSkills,
findInstalledSuperpowers,
getSkillSource,
piPackageCommand,
parseReviewerShorthand,
validateRemoveTarget,
} from "../lib/skill-manager-core.mjs";
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
test("manifest records supported variants and helper allowlists", () => {
assert.deepEqual(SKILLS["web-automation"].variants, ["codex", "claude-code", "cursor", "opencode", "pi"]);
assert.deepEqual(CLIENTS.codex.reviewerRuntime.files, ["run-review.sh", "notify-telegram.sh"]);
assert.deepEqual(CLIENTS.pi.reviewerRuntime.files, ["run-review.sh", "notify-telegram.sh"]);
});
test("parseReviewerShorthand keeps provider-qualified model ids verbatim", () => {
assert.deepEqual(parseReviewerShorthand("pi/claude-opus-4-7"), {
reviewerCli: "pi",
reviewerModel: "claude-opus-4-7",
});
assert.deepEqual(parseReviewerShorthand("pi/anthropic/claude-opus-4-7"), {
reviewerCli: "pi",
reviewerModel: "anthropic/claude-opus-4-7",
});
assert.equal(parseReviewerShorthand("codex/gpt-5"), null);
});
test("unsupported skill variant is reported as unsupported", () => {
assert.ok(getSkillSource("web-automation", "cursor").endsWith("skills/web-automation/cursor"));
assert.ok(getSkillSource("web-automation", "pi").endsWith("pi-package/skills/web-automation"));
});
test("detectInstalledSkills reports installed and missing skills", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-detect-"));
try {
await mkdir(path.join(dir, "skills", "create-plan"), { recursive: true });
await writeFile(path.join(dir, "skills", "create-plan", "SKILL.md"), "---\nname: create-plan\n---\n");
const state = await detectInstalledSkills({ skillsRoot: path.join(dir, "skills"), clientId: "codex" });
assert.equal(state["create-plan"].state, "installed");
assert.equal(state["web-automation"].state, "not-installed");
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("plan install workflow skill includes reviewer-runtime and missing Superpowers prompt", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-plan-"));
try {
await mkdir(path.join(dir, "repo", "skills", "create-plan", "codex"), { recursive: true });
await writeFile(path.join(dir, "repo", "skills", "create-plan", "codex", "SKILL.md"), "---\nname: create-plan\n---\n");
const plan = await buildOperationPlan({
selections: [{ clientId: "codex", scope: "global", skillsRoot: path.join(dir, "install"), actions: { "create-plan": "install" } }],
assumeYes: true,
repoRoot: path.join(dir, "repo"),
superpowersByClient: { codex: [] },
});
assert.equal(plan.operations.some((op) => op.kind === "skill" && op.skill === "create-plan" && op.action === "install"), true);
assert.equal(plan.operations.some((op) => op.kind === "helper" && op.helper === "reviewer-runtime"), true);
assert.equal(plan.prompts.some((prompt) => prompt.kind === "missing-superpowers" && prompt.clientId === "codex"), true);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("plan skips already current reviewer-runtime helper for workflow skill updates", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-helper-current-"));
try {
const repo = path.join(dir, "repo");
const install = path.join(dir, "install");
await mkdir(path.join(repo, "skills", "create-plan", "cursor"), { recursive: true });
await mkdir(path.join(repo, "skills", "do-task", "cursor"), { recursive: true });
await writeFile(path.join(repo, "skills", "create-plan", "cursor", "SKILL.md"), "---\nname: create-plan\n---\n");
await writeFile(path.join(repo, "skills", "do-task", "cursor", "SKILL.md"), "---\nname: do-task\n---\n");
await mkdir(path.join(repo, "skills", "reviewer-runtime"), { recursive: true });
await mkdir(path.join(install, "reviewer-runtime"), { recursive: true });
for (const file of CLIENTS.cursor.reviewerRuntime.files) {
await writeFile(path.join(repo, "skills", "reviewer-runtime", file), `${file}\n`);
await writeFile(path.join(install, "reviewer-runtime", file), `${file}\n`);
}
const plan = await buildOperationPlan({
selections: [{ clientId: "cursor", scope: "global", skillsRoot: install, actions: { "create-plan": "update", "do-task": "update" } }],
repoRoot: repo,
superpowersByClient: { cursor: [path.join(dir, "superpowers")] },
});
const helperRows = plan.reportRows.filter((row) => row.item === "reviewer-runtime");
assert.equal(helperRows.length, 1);
assert.equal(helperRows[0].action, "install");
assert.equal(helperRows[0].status, "skipped");
assert.match(helperRows[0].details, /already installed/);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("plan auto-updates stale reviewer-runtime helper for workflow skill updates", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-helper-stale-"));
try {
const repo = path.join(dir, "repo");
const install = path.join(dir, "install");
await mkdir(path.join(repo, "skills", "create-plan", "cursor"), { recursive: true });
await writeFile(path.join(repo, "skills", "create-plan", "cursor", "SKILL.md"), "---\nname: create-plan\n---\n");
await mkdir(path.join(repo, "skills", "reviewer-runtime"), { recursive: true });
await mkdir(path.join(install, "reviewer-runtime"), { recursive: true });
for (const file of CLIENTS.cursor.reviewerRuntime.files) {
await writeFile(path.join(repo, "skills", "reviewer-runtime", file), `${file}:new\n`);
await writeFile(path.join(install, "reviewer-runtime", file), `${file}:old\n`);
}
const plan = await buildOperationPlan({
selections: [{ clientId: "cursor", scope: "global", skillsRoot: install, actions: { "create-plan": "update" } }],
repoRoot: repo,
superpowersByClient: { cursor: [path.join(dir, "superpowers")] },
});
const helperRows = plan.reportRows.filter((row) => row.item === "reviewer-runtime");
assert.equal(helperRows.length, 1);
assert.equal(helperRows[0].action, "update");
assert.equal(helperRows[0].status, "planned");
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("plan honors explicit reviewer-runtime helper actions", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-helper-explicit-"));
try {
const repo = path.join(dir, "repo");
const install = path.join(dir, "install");
await mkdir(path.join(repo, "skills", "reviewer-runtime"), { recursive: true });
await mkdir(path.join(install, "reviewer-runtime"), { recursive: true });
for (const file of CLIENTS.cursor.reviewerRuntime.files) {
await writeFile(path.join(repo, "skills", "reviewer-runtime", file), `${file}\n`);
await writeFile(path.join(install, "reviewer-runtime", file), `${file}\n`);
}
const plan = await buildOperationPlan({
selections: [{ clientId: "cursor", scope: "global", skillsRoot: install, actions: {}, helperActions: { "reviewer-runtime": "reinstall" } }],
repoRoot: repo,
});
assert.deepEqual(plan.reportRows.map((row) => [row.item, row.action, row.status]), [
["reviewer-runtime", "reinstall", "planned"],
]);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("plan labels skill bootstrap rows as dependency rows", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-bootstrap-label-"));
try {
const repo = path.join(dir, "repo");
const install = path.join(dir, "install");
await mkdir(path.join(repo, "skills", "web-automation", "claude-code"), { recursive: true });
await writeFile(path.join(repo, "skills", "web-automation", "claude-code", "SKILL.md"), "---\nname: web-automation\n---\n");
const plan = await buildOperationPlan({
selections: [{ clientId: "claude-code", scope: "global", skillsRoot: install, actions: { "web-automation": "update" } }],
repoRoot: repo,
});
assert.deepEqual(plan.reportRows.map((row) => [row.item, row.action]), [
["web-automation", "update"],
["web-automation deps", "bootstrap-deps"],
]);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("findInstalledSuperpowers detects Claude Code Superpowers plugin installs", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-claude-superpowers-"));
try {
const installPath = path.join(dir, ".claude", "plugins", "cache", "claude-plugins-official", "superpowers", "4.2.0");
await mkdir(path.join(installPath, "skills", "brainstorming"), { recursive: true });
await writeFile(path.join(installPath, "skills", "brainstorming", "SKILL.md"), "---\nname: brainstorming\n---\n");
await mkdir(path.join(dir, ".claude", "plugins"), { recursive: true });
await writeFile(path.join(dir, ".claude", "settings.json"), JSON.stringify({
enabledPlugins: {
"superpowers@claude-plugins-official": true,
},
}));
await writeFile(path.join(dir, ".claude", "plugins", "installed_plugins.json"), JSON.stringify({
plugins: {
"superpowers@claude-plugins-official": [
{
scope: "user",
installPath,
version: "4.2.0",
},
],
},
}));
assert.deepEqual(await findInstalledSuperpowers("claude-code", process.cwd(), { homeDir: dir }), [
path.join(installPath, "skills"),
]);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("findInstalledSuperpowers detects OpenCode shared agents Superpowers installs", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-opencode-superpowers-"));
try {
const sharedRoot = path.join(dir, ".agents", "skills", "superpowers");
await mkdir(path.join(sharedRoot, "brainstorming"), { recursive: true });
await writeFile(path.join(sharedRoot, "brainstorming", "SKILL.md"), "---\nname: brainstorming\n---\n");
assert.deepEqual(await findInstalledSuperpowers("opencode", process.cwd(), { homeDir: dir }), [
sharedRoot,
]);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("findInstalledSuperpowers detects Cursor Superpowers plugin installs", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-cursor-superpowers-"));
try {
const pluginSkills = path.join(dir, ".cursor", "plugins", "cache", "cursor-public", "superpowers", "abc123", "skills");
await mkdir(path.join(pluginSkills, "brainstorming"), { recursive: true });
await writeFile(path.join(pluginSkills, "brainstorming", "SKILL.md"), "---\nname: brainstorming\n---\n");
assert.deepEqual(await findInstalledSuperpowers("cursor", process.cwd(), { homeDir: dir }), [
pluginSkills,
]);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("plan removing last workflow skill prompts for optional Superpowers removal", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-remove-"));
try {
await mkdir(path.join(dir, "skills", "do-task"), { recursive: true });
await writeFile(path.join(dir, "skills", "do-task", "SKILL.md"), "---\nname: do-task\n---\n");
const plan = await buildOperationPlan({
selections: [{ clientId: "codex", scope: "global", skillsRoot: path.join(dir, "skills"), actions: { "do-task": "remove" } }],
assumeYes: true,
repoRoot: process.cwd(),
});
assert.equal(plan.prompts.some((prompt) => prompt.kind === "remove-superpowers" && prompt.clientId === "codex"), true);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("pi package mode plans full package install instead of per-skill copy", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-pi-package-"));
try {
const plan = await buildOperationPlan({
selections: [{ clientId: "pi", scope: "packageLocal", action: "install", actions: { "create-plan": "skip", atlassian: "remove" } }],
repoRoot: dir,
});
assert.equal(plan.operations.some((op) => op.kind === "pi-package" && op.piInstallArg === "-l"), true);
assert.equal(plan.operations.some((op) => op.kind === "skill"), false);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("pi package mode surfaces bundled skills and skips already installed package resources", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-pi-package-installed-"));
try {
const repo = path.join(dir, "repo");
await mkdir(path.join(repo, ".pi"), { recursive: true });
await writeFile(path.join(repo, ".pi", "settings.json"), JSON.stringify({ packages: [".."] }));
for (const skill of Object.keys(SKILLS)) {
await mkdir(path.join(repo, "pi-package", "skills", skill), { recursive: true });
}
await mkdir(path.join(repo, "pi-package", "skills", "atlassian", "scripts", "node_modules"), { recursive: true });
await mkdir(path.join(repo, "pi-package", "skills", "web-automation", "scripts", "node_modules", ".bin"), { recursive: true });
await writeFile(path.join(repo, "pi-package", "skills", "web-automation", "scripts", "node_modules", ".bin", "cloakbrowser"), "");
const plan = await buildOperationPlan({
selections: [{ clientId: "pi", scope: "packageLocal", action: "install", actions: {} }],
repoRoot: repo,
});
const packageInstall = plan.operations.find((op) => op.kind === "pi-package");
assert.equal(packageInstall.status, "skipped");
assert.match(packageInstall.details, /already installed/);
assert.deepEqual(
plan.reportRows.filter((row) => row.action === "included").map((row) => row.item).sort(),
Object.keys(SKILLS).sort()
);
assert.deepEqual(
plan.reportRows.filter((row) => row.action === "bootstrap-deps").map((row) => [row.item, row.status]).sort(),
[["atlassian", "skipped"], ["web-automation", "skipped"]]
);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("pi package mode remove skips when package is not installed", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-pi-package-remove-"));
try {
const plan = await buildOperationPlan({
selections: [{ clientId: "pi", scope: "packageLocal", action: "remove", actions: {} }],
repoRoot: dir,
});
assert.deepEqual(plan.operations.map((op) => op.kind), ["pi-package"]);
assert.equal(plan.operations[0].action, "remove");
assert.equal(plan.operations[0].status, "skipped");
assert.match(plan.operations[0].details, /not installed/);
assert.equal(plan.reportRows[0].item, "pi-package");
assert.equal(plan.reportRows[0].action, "remove");
assert.equal(plan.reportRows[0].status, "skipped");
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("pi package mode remove plans removal when package is installed", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-pi-package-remove-installed-"));
try {
const repo = path.join(dir, "repo");
await mkdir(path.join(repo, ".pi"), { recursive: true });
await writeFile(path.join(repo, ".pi", "settings.json"), JSON.stringify({ packages: [".."] }));
const plan = await buildOperationPlan({
selections: [{ clientId: "pi", scope: "packageLocal", action: "remove", actions: {} }],
repoRoot: repo,
});
assert.deepEqual(plan.operations.map((op) => op.kind), ["pi-package"]);
assert.equal(plan.operations[0].action, "remove");
assert.equal(plan.operations[0].status, undefined);
assert.equal(plan.reportRows[0].status, "planned");
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("pi package mode update syncs and forces package reinstall plus dependency bootstrap", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-pi-package-update-"));
try {
const repo = path.join(dir, "repo");
await mkdir(path.join(repo, ".pi"), { recursive: true });
await writeFile(path.join(repo, ".pi", "settings.json"), JSON.stringify({ packages: [".."] }));
await mkdir(path.join(repo, "pi-package", "skills", "atlassian", "scripts", "node_modules"), { recursive: true });
await mkdir(path.join(repo, "pi-package", "skills", "web-automation", "scripts", "node_modules", ".bin"), { recursive: true });
await writeFile(path.join(repo, "pi-package", "skills", "web-automation", "scripts", "node_modules", ".bin", "cloakbrowser"), "");
const plan = await buildOperationPlan({
selections: [{ clientId: "pi", scope: "packageLocal", action: "update", actions: {} }],
repoRoot: repo,
});
assert.equal(plan.operations[0].kind, "sync-pi-package");
const packageUpdate = plan.operations.find((op) => op.kind === "pi-package");
assert.equal(packageUpdate.action, "update");
assert.equal(packageUpdate.status, undefined);
assert.deepEqual(
plan.reportRows.filter((row) => row.action === "bootstrap-deps").map((row) => [row.item, row.status]).sort(),
[["atlassian", "planned"], ["web-automation", "planned"]]
);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("pi package command helper builds exact install and remove argv", () => {
assert.deepEqual(piPackageCommand({ action: "install", repoRoot: "/repo", piInstallArg: "" }), ["pi", ["install", "/repo"]]);
assert.deepEqual(piPackageCommand({ action: "update", repoRoot: "/repo", piInstallArg: "-l" }), ["pi", ["install", "-l", "/repo"]]);
assert.deepEqual(piPackageCommand({ action: "reinstall", repoRoot: "/repo", piInstallArg: "-l" }), ["pi", ["install", "-l", "/repo"]]);
assert.deepEqual(piPackageCommand({ action: "remove", repoRoot: "/repo", piInstallArg: "" }), ["pi", ["remove", "/repo"]]);
assert.deepEqual(piPackageCommand({ action: "remove", repoRoot: "/repo", piInstallArg: "-l" }), ["pi", ["remove", "-l", "/repo"]]);
});
test("cli package mode preserves package action and ignores skill narrowing", () => {
const output = execFileSync(process.execPath, [
path.join(REPO_ROOT, "scripts", "manage-skills.mjs"),
"--client", "pi",
"--scope", "packageGlobal",
"--pi-package",
"--skill", "create-plan",
"--action", "remove",
"--plan-only",
], { cwd: REPO_ROOT, encoding: "utf8" });
const plan = JSON.parse(output);
assert.deepEqual(plan.operations.map((op) => op.kind), ["pi-package"]);
assert.equal(plan.operations[0].action, "remove");
});
test("cli exits without confirmation when no operations are planned", () => {
const output = execFileSync(process.execPath, [
path.join(REPO_ROOT, "scripts", "manage-skills.mjs"),
"--answers",
"/dev/stdin",
], {
cwd: REPO_ROOT,
encoding: "utf8",
input: JSON.stringify({ selections: [{ clientId: "pi", scope: "packageGlobal", action: "skip", actions: {} }] }),
});
assert.match(output, /No operations planned\./);
assert.doesNotMatch(output, /Proceed with these operations/);
assert.doesNotMatch(output, /Final report/);
});
test("validateRemoveTarget rejects paths outside the manifest root", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-safe-"));
try {
await mkdir(path.join(dir, "skills", "create-plan"), { recursive: true });
await mkdir(path.join(dir, "outside"), { recursive: true });
assert.equal(await validateRemoveTarget(path.join(dir, "skills", "create-plan"), path.join(dir, "skills")), true);
await assert.rejects(() => validateRemoveTarget(path.join(dir, "outside"), path.join(dir, "skills")), /outside skills root/);
await assert.rejects(() => validateRemoveTarget(dir, dir), /refusing to remove skills root itself/);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
+186
View File
@@ -0,0 +1,186 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
export ROOT_DIR
if ! command -v python3 >/dev/null 2>&1; then
echo "Missing required command: python3" >&2
exit 2
fi
python3 <<'PY'
from pathlib import Path
import os
import re
import sys
ROOT = Path(os.environ["ROOT_DIR"])
WORKFLOWS = {
"create-plan": {
"payload": "/tmp/plan-",
"review_output": "/tmp/plan-review-",
},
"implement-plan": {
"payload": "/tmp/milestone-",
"review_output": "/tmp/milestone-review-",
},
"do-task": {
"payload": "/tmp/do-task-",
"review_output": "/tmp/do-task-",
},
}
VARIANTS = ["claude-code", "codex", "cursor", "opencode", "pi"]
PI_FLAGS = [
"--no-session",
"--no-skills",
"--no-prompt-templates",
"--no-extensions",
"--no-context-files",
]
FORBIDDEN_PI_TOOLS = {"write", "edit", "bash"}
errors: list[str] = []
def compact(text: str) -> str:
return re.sub(r"\\\s*\n", " ", text)
def has_reviewer_choice(text: str) -> bool:
normalized = compact(text)
patterns = [
r"Reviewer CLI:\s*`codex`,\s*`claude`,\s*`cursor`,\s*`opencode`,\s*`pi`,\s*or\s*`skip`",
r"Reviewer CLI\s*\|[^\n]*\bpi\b[^\n]*\bskip\b",
r"REVIEWER_CLI[^\n]*`pi`[^\n]*`skip`",
]
return any(re.search(pattern, normalized, re.I) for pattern in patterns)
def fenced_code_blocks(text: str) -> list[str]:
return [match.group(1) for match in re.finditer(r"```(?:bash|sh|shell)?\s*\n(.*?)```", text, re.S)]
def pi_command_blocks(text: str) -> list[str]:
blocks: list[str] = []
for block in fenced_code_blocks(text):
normalized = compact(block)
if re.search(r"\bpi\s+", normalized) and "--no-session" in normalized:
blocks.append(normalized)
# Fallback for inline examples outside fenced code. Walk from the pi command
# until the next blank line/heading instead of relying on a fixed window.
lines = text.splitlines()
for index, line in enumerate(lines):
if re.search(r"\bpi\s+.*--no-session", line):
collected = []
for candidate in lines[index:]:
if collected and (not candidate.strip() or candidate.startswith("#")):
break
collected.append(candidate)
block = compact("\n".join(collected))
if block not in blocks:
blocks.append(block)
return blocks
def block_tools(block: str) -> list[str] | None:
match = re.search(r"--tools(?:=|\s+)(['\"]?)([^\s'\"]+)\1", block)
if not match:
return None
return [tool.strip() for tool in match.group(2).split(",") if tool.strip()]
def block_has_exact_tools(block: str) -> bool:
return block_tools(block) == ["read", "grep", "find", "ls"]
for workflow, spec in WORKFLOWS.items():
for variant in VARIANTS:
path = ROOT / "skills" / workflow / variant / "SKILL.md"
if not path.exists():
errors.append(f"missing workflow variant: {path}")
continue
text = path.read_text()
if not has_reviewer_choice(text):
errors.append(f"{path}: reviewer choices must include pi and skip")
if "pi/<pi-model-name>" not in text and "pi/claude-opus-4-7" not in text:
errors.append(f"{path}: must document pi/<pi-model-name> reviewer shorthand")
if "pi --list-models [search]" not in text:
errors.append(f"{path}: must mention pi --list-models [search] for unavailable models")
if not re.search(r"reviewer model is configured independently|model is configured independently", text, re.I):
errors.append(f"{path}: must state Pi reviewer model is configured independently")
blocks = pi_command_blocks(text)
if not blocks:
errors.append(f"{path}: missing isolated pi reviewer command block")
continue
matching_blocks = [block for block in blocks if spec["payload"] in block]
if not matching_blocks:
errors.append(f"{path}: pi reviewer command must reference {spec['payload']} payload path")
matching_blocks = blocks
if not any(all(flag in block for flag in PI_FLAGS) for block in matching_blocks):
errors.append(f"{path}: pi reviewer command missing one or more isolation flags")
if not any(block_has_exact_tools(block) for block in matching_blocks):
errors.append(f"{path}: pi reviewer command must use exactly --tools read,grep,find,ls")
# The forbidden-tool check is scoped to the --tools allowlist in the Pi
# reviewer command. Prose that says these tools are forbidden is allowed.
for block in matching_blocks:
tools = block_tools(block)
if tools is None:
continue
forbidden = sorted(set(tools) & FORBIDDEN_PI_TOOLS)
if forbidden:
errors.append(f"{path}: pi reviewer command includes forbidden tools: {', '.join(forbidden)}")
if variant != "pi" and ".pi/skills/reviewer-runtime/pi" in text:
errors.append(f"{path}: non-Pi variant must not use Pi reviewer-runtime helper path as its own runtime")
for variant in VARIANTS:
template = ROOT / "skills" / "do-task" / variant / "templates" / "task-plan.md"
if not template.exists():
errors.append(f"missing do-task template: {template}")
continue
text = template.read_text()
if "Reviewer CLI | codex \\| claude \\| cursor \\| opencode \\| pi" not in text:
errors.append(f"{template}: Reviewer CLI metadata must include pi")
canonical = ROOT / "docs" / "PI-COMMON-REVIEWER.md"
if not canonical.exists():
errors.append("docs/PI-COMMON-REVIEWER.md is missing")
else:
text = canonical.read_text()
for flag in PI_FLAGS:
if flag not in text:
errors.append(f"docs/PI-COMMON-REVIEWER.md: missing {flag}")
if "--tools read,grep,find,ls" not in compact(text):
errors.append("docs/PI-COMMON-REVIEWER.md: missing exact read-only tool allowlist")
if "MUST NOT include `write`, `edit`, or `bash`" not in text:
errors.append("docs/PI-COMMON-REVIEWER.md: must forbid write/edit/bash tools")
for doc in ["CREATE-PLAN.md", "IMPLEMENT-PLAN.md", "DO-TASK.md"]:
path = ROOT / "docs" / doc
if not path.exists():
errors.append(f"docs/{doc} is missing")
continue
text = path.read_text()
if "PI-COMMON-REVIEWER.md" not in text:
errors.append(f"docs/{doc}: must link to PI-COMMON-REVIEWER.md")
if "pi/claude-opus-4-7" not in text and "pi/<pi-model-name>" not in text:
errors.append(f"docs/{doc}: must document Pi reviewer shorthand")
if errors:
print("Reviewer support verification failed:", file=sys.stderr)
for error in errors:
print(f"- {error}", file=sys.stderr)
sys.exit(1)
print("reviewer support verified")
PY
+38 -1
View File
@@ -33,6 +33,19 @@ If any dependency is missing, stop immediately and return:
### Phase 3: Configure Reviewer
Reviewer CLI: `codex`, `claude`, `cursor`, `opencode`, `pi`, or `skip`.
If `REVIEWER_CLI=pi`, verify the Pi reviewer binary before entering the review loop:
```bash
pi --version
```
For shorthand `pi/<pi-model-name>`, split only on the first slash when the prefix is exactly `pi`; store the complete remainder in `REVIEWER_MODEL`. Examples: `pi/claude-opus-4-7` -> `claude-opus-4-7`, `pi/anthropic/claude-opus-4-7` -> `anthropic/claude-opus-4-7`, and `pi/openrouter/anthropic/claude-opus-4-7` -> `openrouter/anthropic/claude-opus-4-7`.
When `REVIEWER_CLI=pi`, the reviewer model is configured independently from the model running this workflow. If the model/provider is unavailable, surface helper stderr/status and use `pi --list-models [search]` to inspect configured models.
If the user has already specified a reviewer CLI and model (e.g., "create a plan, review with codex o4-mini"), use those values. Otherwise, ask:
1. **Which CLI should review the plan?**
@@ -152,6 +165,18 @@ Write the reviewer invocation to `/tmp/plan-review-${REVIEW_ID}.sh` as a bash sc
set -euo pipefail
```
**If `REVIEWER_CLI` is `pi`:**
Fresh call every round (Pi reviewer calls do not use session resume):
```bash
pi --no-session --no-skills --no-prompt-templates --no-extensions --no-context-files \
--model "$REVIEWER_MODEL" \
--tools read,grep,find,ls \
-p "Read the file /tmp/plan-${REVIEW_ID}.md and review. Return exactly the required ## Summary, ## Findings, and ## Verdict structure."
```
**If `REVIEWER_CLI` is `codex`:**
```bash
@@ -281,7 +306,7 @@ jq -r '.result' /tmp/plan-review-${REVIEW_ID}.json > /tmp/plan-review-${REVIEW_I
```
- If `REVIEWER_CLI=codex`, extract `CODEX_SESSION_ID` from `/tmp/plan-review-${REVIEW_ID}.runner.out` after the helper or fallback run. If the review text is only in `.runner.out`, move or copy the actual review body into `/tmp/plan-review-${REVIEW_ID}.md` before verdict parsing.
- If `REVIEWER_CLI=claude`, promote stdout captured by the helper or fallback runner into the markdown review file:
- If `REVIEWER_CLI=claude` or `REVIEWER_CLI=pi`, promote stdout captured by the helper or fallback runner into the markdown review file:
```bash
cp /tmp/plan-review-${REVIEW_ID}.runner.out /tmp/plan-review-${REVIEW_ID}.md
@@ -331,6 +356,18 @@ If a revision contradicts the user's explicit requirements, skip it and note it
Rewrite `/tmp/plan-review-${REVIEW_ID}.sh` for the next round. The script should contain the reviewer invocation only; do not run it directly.
**If `REVIEWER_CLI` is `pi`:**
Fresh call with prior-round context (Pi reviewer calls do not use session resume):
```bash
pi --no-session --no-skills --no-prompt-templates --no-extensions --no-context-files \
--model "$REVIEWER_MODEL" \
--tools read,grep,find,ls \
-p "You previously reviewed this plan and requested revisions. Read the updated payload at /tmp/plan-${REVIEW_ID}.md and re-review using the same ## Summary, ## Findings, and ## Verdict structure."
```
**If `REVIEWER_CLI` is `codex`:**
Resume the existing session:
+38 -1
View File
@@ -58,6 +58,19 @@ If any dependency is missing, stop and return:
### Phase 3: Configure Reviewer
Reviewer CLI: `codex`, `claude`, `cursor`, `opencode`, `pi`, or `skip`.
If `REVIEWER_CLI=pi`, verify the Pi reviewer binary before entering the review loop:
```bash
pi --version
```
For shorthand `pi/<pi-model-name>`, split only on the first slash when the prefix is exactly `pi`; store the complete remainder in `REVIEWER_MODEL`. Examples: `pi/claude-opus-4-7` -> `claude-opus-4-7`, `pi/anthropic/claude-opus-4-7` -> `anthropic/claude-opus-4-7`, and `pi/openrouter/anthropic/claude-opus-4-7` -> `openrouter/anthropic/claude-opus-4-7`.
When `REVIEWER_CLI=pi`, the reviewer model is configured independently from the model running this workflow. If the model/provider is unavailable, surface helper stderr/status and use `pi --list-models [search]` to inspect configured models.
If the user has already specified a reviewer CLI and model (e.g., "create a plan, review with claude sonnet"), use those values. Otherwise, ask:
1. **Which CLI should review the plan?**
@@ -175,6 +188,18 @@ Write the reviewer invocation to `/tmp/plan-review-${REVIEW_ID}.sh` as a bash sc
set -euo pipefail
```
**If `REVIEWER_CLI` is `pi`:**
Fresh call every round (Pi reviewer calls do not use session resume):
```bash
pi --no-session --no-skills --no-prompt-templates --no-extensions --no-context-files \
--model "$REVIEWER_MODEL" \
--tools read,grep,find,ls \
-p "Read the file /tmp/plan-${REVIEW_ID}.md and review. Return exactly the required ## Summary, ## Findings, and ## Verdict structure."
```
**If `REVIEWER_CLI` is `codex`:**
```bash
@@ -304,7 +329,7 @@ jq -r '.result' /tmp/plan-review-${REVIEW_ID}.json > /tmp/plan-review-${REVIEW_I
```
- If `REVIEWER_CLI=codex`, extract `CODEX_SESSION_ID` from `/tmp/plan-review-${REVIEW_ID}.runner.out` after the helper or fallback run. If the review text is only in `.runner.out`, move or copy the actual review body into `/tmp/plan-review-${REVIEW_ID}.md` before verdict parsing.
- If `REVIEWER_CLI=claude`, promote stdout captured by the helper or fallback runner into the markdown review file:
- If `REVIEWER_CLI=claude` or `REVIEWER_CLI=pi`, promote stdout captured by the helper or fallback runner into the markdown review file:
```bash
cp /tmp/plan-review-${REVIEW_ID}.runner.out /tmp/plan-review-${REVIEW_ID}.md
@@ -354,6 +379,18 @@ If a revision contradicts the user's explicit requirements, skip it and note it
Rewrite `/tmp/plan-review-${REVIEW_ID}.sh` for the next round. The script should contain the reviewer invocation only; do not run it directly.
**If `REVIEWER_CLI` is `pi`:**
Fresh call with prior-round context (Pi reviewer calls do not use session resume):
```bash
pi --no-session --no-skills --no-prompt-templates --no-extensions --no-context-files \
--model "$REVIEWER_MODEL" \
--tools read,grep,find,ls \
-p "You previously reviewed this plan and requested revisions. Read the updated payload at /tmp/plan-${REVIEW_ID}.md and re-review using the same ## Summary, ## Findings, and ## Verdict structure."
```
**If `REVIEWER_CLI` is `codex`:**
Resume the existing session:
+43 -6
View File
@@ -15,7 +15,7 @@ This skill wraps the current Superpowers flow for the Cursor Agent CLI (`cursor-
3. Review the plan iteratively with a second model/provider
4. Persist a local execution package in `ai_plan/YYYY-MM-DD-<short-title>/`
**Core principle:** Cursor Agent CLI discovers skills from `.cursor/skills/` (repo-local or `~/.cursor/skills/` global). It also reads `AGENTS.md` at the repo root for additional instructions.
**Core principle:** Cursor Agent CLI discovers skills from `.cursor/skills/` (repo-local), `~/.cursor/skills/` (global), and installed Cursor plugin cache entries. It also reads `AGENTS.md` at the repo root for additional instructions.
## Prerequisite Check (MANDATORY)
@@ -23,7 +23,7 @@ Required:
- Cursor Agent CLI: `cursor-agent --version` (install via `curl https://cursor.com/install -fsS | bash`). The binary is `cursor-agent` (installed to `~/.local/bin/`). Some environments alias it as `cursor agent` (subcommand of the Cursor IDE CLI) — both forms work, but this skill uses `cursor-agent` throughout.
- `jq` (required only if using `cursor` as the reviewer CLI): `jq --version` (install via `brew install jq` or your package manager)
- Superpowers repo: `https://github.com/obra/superpowers`
- Superpowers skills installed under `.cursor/skills/` (repo-local) or `~/.cursor/skills/` (global)
- Superpowers skills available from the Cursor plugin cache, `.cursor/skills/` (repo-local), or `~/.cursor/skills/` (global). Do not install both the plugin and a manual Superpowers copy, or Cursor may show duplicate skill entries.
- `superpowers:brainstorming`
- `superpowers:writing-plans`
@@ -31,15 +31,15 @@ Verify before proceeding:
```bash
cursor-agent --version
test -f .cursor/skills/superpowers/skills/brainstorming/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/brainstorming/SKILL.md
test -f .cursor/skills/superpowers/skills/writing-plans/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/writing-plans/SKILL.md
test -f .cursor/skills/superpowers/skills/brainstorming/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/brainstorming/SKILL.md || find ~/.cursor/plugins/cache/cursor-public/superpowers -path '*/skills/brainstorming/SKILL.md' -print -quit 2>/dev/null | grep -q .
test -f .cursor/skills/superpowers/skills/writing-plans/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/writing-plans/SKILL.md || find ~/.cursor/plugins/cache/cursor-public/superpowers -path '*/skills/writing-plans/SKILL.md' -print -quit 2>/dev/null | grep -q .
# Only if using cursor as reviewer CLI:
# jq --version
```
If any dependency is missing, stop and return:
`Missing dependency: Superpowers planning skills are required (superpowers:brainstorming, superpowers:writing-plans). Install from https://github.com/obra/superpowers and copy into .cursor/skills/ or ~/.cursor/skills/, then retry.`
`Missing dependency: Superpowers planning skills are required (superpowers:brainstorming, superpowers:writing-plans). Install the Cursor Superpowers plugin or install Superpowers under .cursor/skills/ or ~/.cursor/skills/, then retry.`
## Required Skill Invocation Rules
@@ -59,6 +59,19 @@ If any dependency is missing, stop and return:
### Phase 3: Configure Reviewer
Reviewer CLI: `codex`, `claude`, `cursor`, `opencode`, `pi`, or `skip`.
If `REVIEWER_CLI=pi`, verify the Pi reviewer binary before entering the review loop:
```bash
pi --version
```
For shorthand `pi/<pi-model-name>`, split only on the first slash when the prefix is exactly `pi`; store the complete remainder in `REVIEWER_MODEL`. Examples: `pi/claude-opus-4-7` -> `claude-opus-4-7`, `pi/anthropic/claude-opus-4-7` -> `anthropic/claude-opus-4-7`, and `pi/openrouter/anthropic/claude-opus-4-7` -> `openrouter/anthropic/claude-opus-4-7`.
When `REVIEWER_CLI=pi`, the reviewer model is configured independently from the model running this workflow. If the model/provider is unavailable, surface helper stderr/status and use `pi --list-models [search]` to inspect configured models.
If the user has already specified a reviewer CLI and model (e.g., "create a plan, review with codex o4-mini"), use those values. Otherwise, ask:
1. **Which CLI should review the plan?**
@@ -181,6 +194,18 @@ Write the reviewer invocation to `/tmp/plan-review-${REVIEW_ID}.sh` as a bash sc
set -euo pipefail
```
**If `REVIEWER_CLI` is `pi`:**
Fresh call every round (Pi reviewer calls do not use session resume):
```bash
pi --no-session --no-skills --no-prompt-templates --no-extensions --no-context-files \
--model "$REVIEWER_MODEL" \
--tools read,grep,find,ls \
-p "Read the file /tmp/plan-${REVIEW_ID}.md and review. Return exactly the required ## Summary, ## Findings, and ## Verdict structure."
```
**If `REVIEWER_CLI` is `codex`:**
```bash
@@ -316,7 +341,7 @@ jq -r '.result' /tmp/plan-review-${REVIEW_ID}.json > /tmp/plan-review-${REVIEW_I
```
- If `REVIEWER_CLI=codex`, extract `CODEX_SESSION_ID` from `/tmp/plan-review-${REVIEW_ID}.runner.out` after the helper or fallback run. If the review text is only in `.runner.out`, move or copy the actual review body into `/tmp/plan-review-${REVIEW_ID}.md` before verdict parsing.
- If `REVIEWER_CLI=claude`, promote stdout captured by the helper or fallback runner into the markdown review file:
- If `REVIEWER_CLI=claude` or `REVIEWER_CLI=pi`, promote stdout captured by the helper or fallback runner into the markdown review file:
```bash
cp /tmp/plan-review-${REVIEW_ID}.runner.out /tmp/plan-review-${REVIEW_ID}.md
@@ -364,6 +389,18 @@ If a revision contradicts the user's explicit requirements, skip it and note it
Rewrite `/tmp/plan-review-${REVIEW_ID}.sh` for the next round. The script should contain the reviewer invocation only; do not run it directly.
**If `REVIEWER_CLI` is `pi`:**
Fresh call with prior-round context (Pi reviewer calls do not use session resume):
```bash
pi --no-session --no-skills --no-prompt-templates --no-extensions --no-context-files \
--model "$REVIEWER_MODEL" \
--tools read,grep,find,ls \
-p "You previously reviewed this plan and requested revisions. Read the updated payload at /tmp/plan-${REVIEW_ID}.md and re-review using the same ## Summary, ## Findings, and ## Verdict structure."
```
**If `REVIEWER_CLI` is `codex`:**
Resume the existing session:
+42 -4
View File
@@ -9,18 +9,19 @@ Create and maintain a local plan folder under `ai_plan/` at project root.
## Prerequisite Check (MANDATORY)
This OpenCode variant depends on Superpowers skills being installed via OpenCode's native skill system.
This OpenCode variant depends on Superpowers skills being available through OpenCode's native skill system.
Required:
- Superpowers repo: `https://github.com/obra/superpowers`
- OpenCode Superpowers skills symlink: `~/.config/opencode/skills/superpowers`
- OpenCode Superpowers skills available at `~/.agents/skills/superpowers` or `~/.config/opencode/skills/superpowers`
- `superpowers/brainstorming`
- `superpowers/writing-plans`
Verify before proceeding:
```bash
ls -l ~/.config/opencode/skills/superpowers
test -f ~/.agents/skills/superpowers/brainstorming/SKILL.md || test -f ~/.config/opencode/skills/superpowers/brainstorming/SKILL.md
test -f ~/.agents/skills/superpowers/writing-plans/SKILL.md || test -f ~/.config/opencode/skills/superpowers/writing-plans/SKILL.md
```
If dependencies are missing, stop immediately and return:
@@ -64,6 +65,19 @@ If the user has already specified a reviewer CLI and model (e.g., "create a plan
Store the chosen `REVIEWER_CLI`, `REVIEWER_MODEL`, and `MAX_ROUNDS` for Phase 7 (Iterative Plan Review).
Reviewer CLI: `codex`, `claude`, `cursor`, `opencode`, `pi`, or `skip`.
If `REVIEWER_CLI=pi`, verify the Pi reviewer binary before entering the review loop:
```bash
pi --version
```
For shorthand `pi/<pi-model-name>`, split only on the first slash when the prefix is exactly `pi`; store the complete remainder in `REVIEWER_MODEL`. Examples: `pi/claude-opus-4-7` -> `claude-opus-4-7`, `pi/anthropic/claude-opus-4-7` -> `anthropic/claude-opus-4-7`, and `pi/openrouter/anthropic/claude-opus-4-7` -> `openrouter/anthropic/claude-opus-4-7`.
When `REVIEWER_CLI=pi`, the reviewer model is configured independently from the model running this workflow. If the model/provider is unavailable, surface helper stderr/status and use `pi --list-models [search]` to inspect configured models.
### Phase 5: Design (REQUIRED SUB-SKILL)
Use OpenCode's native skill tool to load:
@@ -169,6 +183,18 @@ Write the reviewer invocation to `/tmp/plan-review-${REVIEW_ID}.sh` as a bash sc
set -euo pipefail
```
**If `REVIEWER_CLI` is `pi`:**
Fresh call every round (Pi reviewer calls do not use session resume):
```bash
pi --no-session --no-skills --no-prompt-templates --no-extensions --no-context-files \
--model "$REVIEWER_MODEL" \
--tools read,grep,find,ls \
-p "Read the file /tmp/plan-${REVIEW_ID}.md and review. Return exactly the required ## Summary, ## Findings, and ## Verdict structure."
```
**If `REVIEWER_CLI` is `codex`:**
```bash
@@ -298,7 +324,7 @@ jq -r '.result' /tmp/plan-review-${REVIEW_ID}.json > /tmp/plan-review-${REVIEW_I
```
- If `REVIEWER_CLI=codex`, extract `CODEX_SESSION_ID` from `/tmp/plan-review-${REVIEW_ID}.runner.out` after the helper or fallback run. If the review text is only in `.runner.out`, move or copy the actual review body into `/tmp/plan-review-${REVIEW_ID}.md` before verdict parsing.
- If `REVIEWER_CLI=claude`, promote stdout captured by the helper or fallback runner into the markdown review file:
- If `REVIEWER_CLI=claude` or `REVIEWER_CLI=pi`, promote stdout captured by the helper or fallback runner into the markdown review file:
```bash
cp /tmp/plan-review-${REVIEW_ID}.runner.out /tmp/plan-review-${REVIEW_ID}.md
@@ -346,6 +372,18 @@ If a revision contradicts the user's explicit requirements, skip it and note it
Rewrite `/tmp/plan-review-${REVIEW_ID}.sh` for the next round. The script should contain the reviewer invocation only; do not run it directly.
**If `REVIEWER_CLI` is `pi`:**
Fresh call with prior-round context (Pi reviewer calls do not use session resume):
```bash
pi --no-session --no-skills --no-prompt-templates --no-extensions --no-context-files \
--model "$REVIEWER_MODEL" \
--tools read,grep,find,ls \
-p "You previously reviewed this plan and requested revisions. Read the updated payload at /tmp/plan-${REVIEW_ID}.md and re-review using the same ## Summary, ## Findings, and ## Verdict structure."
```
**If `REVIEWER_CLI` is `codex`:**
Resume the existing session:
+8
View File
@@ -85,6 +85,14 @@ Reviewer CLI: `codex`, `claude`, `cursor`, `opencode`, `pi`, or `skip`
Store `REVIEWER_CLI`, `REVIEWER_MODEL`, and `MAX_ROUNDS` for the review loop.
If `REVIEWER_CLI=pi`, verify the Pi reviewer binary before entering the review loop:
```bash
pi --version
```
For shorthand `pi/<pi-model-name>`, split only on the first slash when the prefix is exactly `pi`; store the complete remainder in `REVIEWER_MODEL`. Examples: `pi/claude-opus-4-7` -> `claude-opus-4-7`, `pi/anthropic/claude-opus-4-7` -> `anthropic/claude-opus-4-7`, and `pi/openrouter/anthropic/claude-opus-4-7` -> `openrouter/anthropic/claude-opus-4-7`.
When `REVIEWER_CLI=pi`, the reviewer model is configured independently from the pi model running this workflow. Use any configured pi model string, including provider-qualified model IDs. If the reviewer model or provider is unavailable, surface the review helper stderr/status and ask for a configured model; use `pi --list-models [search]` to inspect configured models.
The pi reviewer command rendered into `/tmp/plan-review-${REVIEW_ID}.sh` must be isolated and read-only:
+27 -2
View File
@@ -111,6 +111,19 @@ If the user has already specified a reviewer CLI and model (e.g., "do task X, re
Store `REVIEWER_CLI`, `REVIEWER_MODEL`, and `MAX_ROUNDS` for Phases 5 and 8.
Reviewer CLI: `codex`, `claude`, `cursor`, `opencode`, `pi`, or `skip`.
If `REVIEWER_CLI=pi`, verify the Pi reviewer binary before entering the review loop:
```bash
pi --version
```
For shorthand `pi/<pi-model-name>`, split only on the first slash when the prefix is exactly `pi`; store the complete remainder in `REVIEWER_MODEL`. Examples: `pi/claude-opus-4-7` -> `claude-opus-4-7`, `pi/anthropic/claude-opus-4-7` -> `anthropic/claude-opus-4-7`, and `pi/openrouter/anthropic/claude-opus-4-7` -> `openrouter/anthropic/claude-opus-4-7`.
When `REVIEWER_CLI=pi`, the reviewer model is configured independently from the model running this workflow. If the model/provider is unavailable, surface helper stderr/status and use `pi --list-models [search]` to inspect configured models.
### Phase 4: Initialize Plan Workspace
**PLAN MODE CHECK:** If currently in plan mode:
@@ -344,7 +357,7 @@ This subroutine is invoked twice per `do-task` run: once in Phase 5 (`REVIEW_KIN
| `REVIEW_ID` | 8-char hex (from `uuidgen`); reused across rounds of the same loop |
| `PAYLOAD_PATH` | `/tmp/do-task-${REVIEW_KIND}-${REVIEW_ID}.md` |
| `PROMPT_TEMPLATE` | `PLAN_REVIEW_PROMPT` or `IMPL_REVIEW_PROMPT` |
| `REVIEWER_CLI` | `codex` \| `claude` \| `cursor` \| `opencode` |
| `REVIEWER_CLI` | `codex` \| `claude` \| `cursor` \| `opencode` \| `pi` |
| `REVIEWER_MODEL` | Model name |
| `MAX_ROUNDS` | Default 10 |
| `SESSION_ID_VAR` | `CODEX_PLAN_SESSION_ID` \| `CODEX_IMPL_SESSION_ID` \| `CURSOR_PLAN_SESSION_ID` \| `CURSOR_IMPL_SESSION_ID` \| `OPENCODE_PLAN_SESSION_ID` \| `OPENCODE_IMPL_SESSION_ID` |
@@ -437,6 +450,18 @@ Write the reviewer invocation to `/tmp/do-task-${REVIEW_KIND}-review-${REVIEW_ID
set -euo pipefail
```
**If `REVIEWER_CLI` is `pi`:**
Fresh call every round (Pi reviewer calls do not use session resume):
```bash
pi --no-session --no-skills --no-prompt-templates --no-extensions --no-context-files \
--model "$REVIEWER_MODEL" \
--tools read,grep,find,ls \
-p "Read the file /tmp/do-task-${REVIEW_KIND}-${REVIEW_ID}.md and review. Return exactly the required ## Summary, ## Findings, and ## Verdict structure."
```
**If `REVIEWER_CLI` is `codex`:**
Round 1 — fresh `codex exec`:
@@ -633,7 +658,7 @@ After the command completes:
cp /tmp/do-task-${REVIEW_KIND}-review-${REVIEW_ID}.runner.out \
/tmp/do-task-${REVIEW_KIND}-review-${REVIEW_ID}.md
```
- `claude`: promote `.runner.out` into the `.md` file:
- `claude` or `pi`: promote `.runner.out` into the `.md` file:
```bash
cp /tmp/do-task-${REVIEW_KIND}-review-${REVIEW_ID}.runner.out \
/tmp/do-task-${REVIEW_KIND}-review-${REVIEW_ID}.md
@@ -9,7 +9,7 @@
| Created | YYYY-MM-DD |
| Slug | YYYY-MM-DD-<slug> |
| Runtime | claude-code |
| Reviewer CLI | codex \| claude \| cursor \| opencode |
| Reviewer CLI | codex \| claude \| cursor \| opencode \| pi |
| Reviewer Model | <model> |
| MAX_ROUNDS | 10 |
| Branch Strategy | current-branch \| worktree |
+27 -2
View File
@@ -133,6 +133,19 @@ If the user has already specified a reviewer CLI and model (e.g., "do task X, re
Store `REVIEWER_CLI`, `REVIEWER_MODEL`, and `MAX_ROUNDS` for Phases 5 and 8.
Reviewer CLI: `codex`, `claude`, `cursor`, `opencode`, `pi`, or `skip`.
If `REVIEWER_CLI=pi`, verify the Pi reviewer binary before entering the review loop:
```bash
pi --version
```
For shorthand `pi/<pi-model-name>`, split only on the first slash when the prefix is exactly `pi`; store the complete remainder in `REVIEWER_MODEL`. Examples: `pi/claude-opus-4-7` -> `claude-opus-4-7`, `pi/anthropic/claude-opus-4-7` -> `anthropic/claude-opus-4-7`, and `pi/openrouter/anthropic/claude-opus-4-7` -> `openrouter/anthropic/claude-opus-4-7`.
When `REVIEWER_CLI=pi`, the reviewer model is configured independently from the model running this workflow. If the model/provider is unavailable, surface helper stderr/status and use `pi --list-models [search]` to inspect configured models.
### Phase 4: Initialize Plan Workspace
Codex has no plan-mode concept; there is no plan-mode guard here.
@@ -363,7 +376,7 @@ This subroutine is invoked twice per `do-task` run: once in Phase 5 (`REVIEW_KIN
| `REVIEW_ID` | 8-char hex (from `uuidgen`); reused across rounds of the same loop |
| `PAYLOAD_PATH` | `/tmp/do-task-${REVIEW_KIND}-${REVIEW_ID}.md` |
| `PROMPT_TEMPLATE` | `PLAN_REVIEW_PROMPT` or `IMPL_REVIEW_PROMPT` |
| `REVIEWER_CLI` | `codex` \| `claude` \| `cursor` \| `opencode` |
| `REVIEWER_CLI` | `codex` \| `claude` \| `cursor` \| `opencode` \| `pi` |
| `REVIEWER_MODEL` | Model name |
| `MAX_ROUNDS` | Default 10 |
| `SESSION_ID_VAR` | `CODEX_PLAN_SESSION_ID` \| `CODEX_IMPL_SESSION_ID` \| `CURSOR_PLAN_SESSION_ID` \| `CURSOR_IMPL_SESSION_ID` \| `OPENCODE_PLAN_SESSION_ID` \| `OPENCODE_IMPL_SESSION_ID` |
@@ -456,6 +469,18 @@ Write the reviewer invocation to `/tmp/do-task-${REVIEW_KIND}-review-${REVIEW_ID
set -euo pipefail
```
**If `REVIEWER_CLI` is `pi`:**
Fresh call every round (Pi reviewer calls do not use session resume):
```bash
pi --no-session --no-skills --no-prompt-templates --no-extensions --no-context-files \
--model "$REVIEWER_MODEL" \
--tools read,grep,find,ls \
-p "Read the file /tmp/do-task-${REVIEW_KIND}-${REVIEW_ID}.md and review. Return exactly the required ## Summary, ## Findings, and ## Verdict structure."
```
**If `REVIEWER_CLI` is `codex`:**
Round 1 — fresh `codex exec`:
@@ -652,7 +677,7 @@ After the command completes:
cp /tmp/do-task-${REVIEW_KIND}-review-${REVIEW_ID}.runner.out \
/tmp/do-task-${REVIEW_KIND}-review-${REVIEW_ID}.md
```
- `claude`: promote `.runner.out` into the `.md` file:
- `claude` or `pi`: promote `.runner.out` into the `.md` file:
```bash
cp /tmp/do-task-${REVIEW_KIND}-review-${REVIEW_ID}.runner.out \
/tmp/do-task-${REVIEW_KIND}-review-${REVIEW_ID}.md
+1 -1
View File
@@ -9,7 +9,7 @@
| Created | YYYY-MM-DD |
| Slug | YYYY-MM-DD-<slug> |
| Runtime | codex |
| Reviewer CLI | codex \| claude \| cursor \| opencode |
| Reviewer CLI | codex \| claude \| cursor \| opencode \| pi |
| Reviewer Model | <model> |
| MAX_ROUNDS | 10 |
| Branch Strategy | current-branch \| worktree |
+34 -9
View File
@@ -9,7 +9,7 @@ Execute an ad-hoc user prompt end-to-end: parse → clarify → plan (with revie
This is a single-artifact sibling of `create-plan` + `implement-plan`. Unlike `implement-plan`, `do-task` operates on one persistent `task-plan.md` (not a full milestone plan) and defaults to the **current branch** (not a worktree).
**Core principle:** Cursor Agent CLI discovers skills from `.cursor/skills/` (repo-local) or `~/.cursor/skills/` (global). It also reads `AGENTS.md` at the repo root for additional instructions.
**Core principle:** Cursor Agent CLI discovers skills from `.cursor/skills/` (repo-local), `~/.cursor/skills/` (global), and installed Cursor plugin cache entries. It also reads `AGENTS.md` at the repo root for additional instructions.
## Prerequisite Check (MANDATORY)
@@ -17,7 +17,7 @@ Required:
- Cursor Agent CLI: `cursor-agent --version` (install via `curl https://cursor.com/install -fsS | bash`). Binary is `cursor-agent`; the alias `cursor agent` also works.
- `jq` (**required** — `do-task` always parses JSON output from at least the cursor reviewer branch, and other reviewers may produce JSON). Install via `brew install jq` (macOS) or your package manager. Verify: `jq --version`.
- Superpowers repo: `https://github.com/obra/superpowers`
- Superpowers skills installed under `.cursor/skills/` (repo-local) or `~/.cursor/skills/` (global)
- Superpowers skills available from the Cursor plugin cache, `.cursor/skills/` (repo-local), or `~/.cursor/skills/` (global). Do not install both the plugin and a manual Superpowers copy, or Cursor may show duplicate skill entries.
- `superpowers:brainstorming`
- `superpowers:test-driven-development`
- `superpowers:verification-before-completion`
@@ -31,15 +31,15 @@ Verify before proceeding:
```bash
cursor-agent --version
jq --version
test -f .cursor/skills/superpowers/skills/brainstorming/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/brainstorming/SKILL.md
test -f .cursor/skills/superpowers/skills/test-driven-development/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/test-driven-development/SKILL.md
test -f .cursor/skills/superpowers/skills/verification-before-completion/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/verification-before-completion/SKILL.md
test -f .cursor/skills/superpowers/skills/finishing-a-development-branch/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/finishing-a-development-branch/SKILL.md
test -f .cursor/skills/superpowers/skills/brainstorming/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/brainstorming/SKILL.md || find ~/.cursor/plugins/cache/cursor-public/superpowers -path '*/skills/brainstorming/SKILL.md' -print -quit 2>/dev/null | grep -q .
test -f .cursor/skills/superpowers/skills/test-driven-development/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/test-driven-development/SKILL.md || find ~/.cursor/plugins/cache/cursor-public/superpowers -path '*/skills/test-driven-development/SKILL.md' -print -quit 2>/dev/null | grep -q .
test -f .cursor/skills/superpowers/skills/verification-before-completion/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/verification-before-completion/SKILL.md || find ~/.cursor/plugins/cache/cursor-public/superpowers -path '*/skills/verification-before-completion/SKILL.md' -print -quit 2>/dev/null | grep -q .
test -f .cursor/skills/superpowers/skills/finishing-a-development-branch/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/finishing-a-development-branch/SKILL.md || find ~/.cursor/plugins/cache/cursor-public/superpowers -path '*/skills/finishing-a-development-branch/SKILL.md' -print -quit 2>/dev/null | grep -q .
```
If any required dependency is missing, stop immediately and return:
`Missing dependency: [specific missing item]. Install Cursor Agent CLI, jq, and Superpowers skills under .cursor/skills/ or ~/.cursor/skills/, then retry.`
`Missing dependency: [specific missing item]. Install Cursor Agent CLI, jq, and the Cursor Superpowers plugin or Superpowers skills under .cursor/skills/ or ~/.cursor/skills/, then retry.`
## Required Skill Invocation Rules
@@ -132,6 +132,19 @@ If the user has already specified a reviewer CLI and model (e.g., "do task X, re
Store `REVIEWER_CLI`, `REVIEWER_MODEL`, and `MAX_ROUNDS` for Phases 5 and 8.
Reviewer CLI: `codex`, `claude`, `cursor`, `opencode`, `pi`, or `skip`.
If `REVIEWER_CLI=pi`, verify the Pi reviewer binary before entering the review loop:
```bash
pi --version
```
For shorthand `pi/<pi-model-name>`, split only on the first slash when the prefix is exactly `pi`; store the complete remainder in `REVIEWER_MODEL`. Examples: `pi/claude-opus-4-7` -> `claude-opus-4-7`, `pi/anthropic/claude-opus-4-7` -> `anthropic/claude-opus-4-7`, and `pi/openrouter/anthropic/claude-opus-4-7` -> `openrouter/anthropic/claude-opus-4-7`.
When `REVIEWER_CLI=pi`, the reviewer model is configured independently from the model running this workflow. If the model/provider is unavailable, surface helper stderr/status and use `pi --list-models [search]` to inspect configured models.
### Phase 4: Initialize Plan Workspace
Cursor Agent CLI has no plan-mode concept; there is no plan-mode guard here.
@@ -366,7 +379,7 @@ This subroutine is invoked twice per `do-task` run: once in Phase 5 (`REVIEW_KIN
| `REVIEW_ID` | 8-char hex (from `uuidgen`); reused across rounds of the same loop |
| `PAYLOAD_PATH` | `/tmp/do-task-${REVIEW_KIND}-${REVIEW_ID}.md` |
| `PROMPT_TEMPLATE` | `PLAN_REVIEW_PROMPT` or `IMPL_REVIEW_PROMPT` |
| `REVIEWER_CLI` | `codex` \| `claude` \| `cursor` \| `opencode` |
| `REVIEWER_CLI` | `codex` \| `claude` \| `cursor` \| `opencode` \| `pi` |
| `REVIEWER_MODEL` | Model name |
| `MAX_ROUNDS` | Default 10 |
| `SESSION_ID_VAR` | `CODEX_PLAN_SESSION_ID` \| `CODEX_IMPL_SESSION_ID` \| `CURSOR_PLAN_SESSION_ID` \| `CURSOR_IMPL_SESSION_ID` \| `OPENCODE_PLAN_SESSION_ID` \| `OPENCODE_IMPL_SESSION_ID` |
@@ -463,6 +476,18 @@ Write the reviewer invocation to `/tmp/do-task-${REVIEW_KIND}-review-${REVIEW_ID
set -euo pipefail
```
**If `REVIEWER_CLI` is `pi`:**
Fresh call every round (Pi reviewer calls do not use session resume):
```bash
pi --no-session --no-skills --no-prompt-templates --no-extensions --no-context-files \
--model "$REVIEWER_MODEL" \
--tools read,grep,find,ls \
-p "Read the file /tmp/do-task-${REVIEW_KIND}-${REVIEW_ID}.md and review. Return exactly the required ## Summary, ## Findings, and ## Verdict structure."
```
**If `REVIEWER_CLI` is `codex`:**
Round 1 — fresh `codex exec`:
@@ -659,7 +684,7 @@ After the command completes:
cp /tmp/do-task-${REVIEW_KIND}-review-${REVIEW_ID}.runner.out \
/tmp/do-task-${REVIEW_KIND}-review-${REVIEW_ID}.md
```
- `claude`: promote `.runner.out` into the `.md` file:
- `claude` or `pi`: promote `.runner.out` into the `.md` file:
```bash
cp /tmp/do-task-${REVIEW_KIND}-review-${REVIEW_ID}.runner.out \
/tmp/do-task-${REVIEW_KIND}-review-${REVIEW_ID}.md
+1 -1
View File
@@ -9,7 +9,7 @@
| Created | YYYY-MM-DD |
| Slug | YYYY-MM-DD-<slug> |
| Runtime | cursor |
| Reviewer CLI | codex \| claude \| cursor \| opencode |
| Reviewer CLI | codex \| claude \| cursor \| opencode \| pi |
| Reviewer Model | <model> |
| MAX_ROUNDS | 10 |
| Branch Strategy | current-branch \| worktree |
+36 -12
View File
@@ -9,14 +9,14 @@ Execute an ad-hoc user prompt end-to-end: parse → clarify → plan (with revie
This is a single-artifact sibling of `create-plan` + `implement-plan`. Unlike `implement-plan`, `do-task` operates on one persistent `task-plan.md` (not a full milestone plan) and defaults to the **current branch** (not a worktree).
**Core principle:** OpenCode loads skills through its native skill tool from `~/.config/opencode/skills/`. Sub-skill invocations use OpenCode's native mechanism — not Claude's `Skill` tool, not Codex's native-discovery patterns, not Cursor's workspace discovery.
**Core principle:** OpenCode loads skills through its native skill tool. Local skills live under `~/.config/opencode/skills/`, and OpenCode can also expose shared agent skills from `~/.agents/skills/`. Sub-skill invocations use OpenCode's native mechanism — not Claude's `Skill` tool, not Cursor's workspace discovery.
## Prerequisite Check (MANDATORY)
Required:
- OpenCode CLI: `opencode --version` (install via your package manager or `brew install opencode`).
- Superpowers repo: `https://github.com/obra/superpowers`
- OpenCode Superpowers skills symlink: `~/.config/opencode/skills/superpowers`
- OpenCode Superpowers skills available at `~/.agents/skills/superpowers` or `~/.config/opencode/skills/superpowers`
- `superpowers/brainstorming`
- `superpowers/test-driven-development`
- `superpowers/verification-before-completion`
@@ -29,11 +29,10 @@ Verify before proceeding:
```bash
opencode --version
ls -l ~/.config/opencode/skills/superpowers
test -f ~/.config/opencode/skills/superpowers/brainstorming/SKILL.md
test -f ~/.config/opencode/skills/superpowers/test-driven-development/SKILL.md
test -f ~/.config/opencode/skills/superpowers/verification-before-completion/SKILL.md
test -f ~/.config/opencode/skills/superpowers/finishing-a-development-branch/SKILL.md
test -f ~/.agents/skills/superpowers/brainstorming/SKILL.md || test -f ~/.config/opencode/skills/superpowers/brainstorming/SKILL.md
test -f ~/.agents/skills/superpowers/test-driven-development/SKILL.md || test -f ~/.config/opencode/skills/superpowers/test-driven-development/SKILL.md
test -f ~/.agents/skills/superpowers/verification-before-completion/SKILL.md || test -f ~/.config/opencode/skills/superpowers/verification-before-completion/SKILL.md
test -f ~/.agents/skills/superpowers/finishing-a-development-branch/SKILL.md || test -f ~/.config/opencode/skills/superpowers/finishing-a-development-branch/SKILL.md
```
If any required dependency is missing, stop immediately and return:
@@ -46,7 +45,7 @@ If any required dependency is missing, stop immediately and return:
- Announce skill usage explicitly:
- `I've read the [Skill Name] skill and I'm using it to [purpose].`
- For skills with checklists, track checklist items explicitly in conversation.
- Do NOT use Claude's `Skill` tool syntax, Codex's `~/.agents/skills/` native-discovery paths, or Cursor's workspace discovery. OpenCode's skill system is independent.
- Do NOT use Claude's `Skill` tool syntax or Cursor's workspace discovery. OpenCode's skill system may expose shared files from `~/.agents/skills/`, but invocation still goes through OpenCode's native skill mechanism.
## Trigger Phrase Detection
@@ -132,6 +131,19 @@ If the user has already specified a reviewer CLI and model (e.g., "do task X, re
Store `REVIEWER_CLI`, `REVIEWER_MODEL`, and `MAX_ROUNDS` for Phases 5 and 8.
Reviewer CLI: `codex`, `claude`, `cursor`, `opencode`, `pi`, or `skip`.
If `REVIEWER_CLI=pi`, verify the Pi reviewer binary before entering the review loop:
```bash
pi --version
```
For shorthand `pi/<pi-model-name>`, split only on the first slash when the prefix is exactly `pi`; store the complete remainder in `REVIEWER_MODEL`. Examples: `pi/claude-opus-4-7` -> `claude-opus-4-7`, `pi/anthropic/claude-opus-4-7` -> `anthropic/claude-opus-4-7`, and `pi/openrouter/anthropic/claude-opus-4-7` -> `openrouter/anthropic/claude-opus-4-7`.
When `REVIEWER_CLI=pi`, the reviewer model is configured independently from the model running this workflow. If the model/provider is unavailable, surface helper stderr/status and use `pi --list-models [search]` to inspect configured models.
### Phase 4: Initialize Plan Workspace
OpenCode has no plan-mode concept; there is no plan-mode guard here.
@@ -362,7 +374,7 @@ This subroutine is invoked twice per `do-task` run: once in Phase 5 (`REVIEW_KIN
| `REVIEW_ID` | 8-char hex (from `uuidgen`); reused across rounds of the same loop |
| `PAYLOAD_PATH` | `/tmp/do-task-${REVIEW_KIND}-${REVIEW_ID}.md` |
| `PROMPT_TEMPLATE` | `PLAN_REVIEW_PROMPT` or `IMPL_REVIEW_PROMPT` |
| `REVIEWER_CLI` | `codex` \| `claude` \| `cursor` \| `opencode` |
| `REVIEWER_CLI` | `codex` \| `claude` \| `cursor` \| `opencode` \| `pi` |
| `REVIEWER_MODEL` | Model name |
| `MAX_ROUNDS` | Default 10 |
| `SESSION_ID_VAR` | `CODEX_PLAN_SESSION_ID` \| `CODEX_IMPL_SESSION_ID` \| `CURSOR_PLAN_SESSION_ID` \| `CURSOR_IMPL_SESSION_ID` \| `OPENCODE_PLAN_SESSION_ID` \| `OPENCODE_IMPL_SESSION_ID` |
@@ -455,6 +467,18 @@ Write the reviewer invocation to `/tmp/do-task-${REVIEW_KIND}-review-${REVIEW_ID
set -euo pipefail
```
**If `REVIEWER_CLI` is `pi`:**
Fresh call every round (Pi reviewer calls do not use session resume):
```bash
pi --no-session --no-skills --no-prompt-templates --no-extensions --no-context-files \
--model "$REVIEWER_MODEL" \
--tools read,grep,find,ls \
-p "Read the file /tmp/do-task-${REVIEW_KIND}-${REVIEW_ID}.md and review. Return exactly the required ## Summary, ## Findings, and ## Verdict structure."
```
**If `REVIEWER_CLI` is `codex`:**
Round 1 — fresh `codex exec`:
@@ -651,7 +675,7 @@ After the command completes:
cp /tmp/do-task-${REVIEW_KIND}-review-${REVIEW_ID}.runner.out \
/tmp/do-task-${REVIEW_KIND}-review-${REVIEW_ID}.md
```
- `claude`: promote `.runner.out` into the `.md` file:
- `claude` or `pi`: promote `.runner.out` into the `.md` file:
```bash
cp /tmp/do-task-${REVIEW_KIND}-review-${REVIEW_ID}.runner.out \
/tmp/do-task-${REVIEW_KIND}-review-${REVIEW_ID}.md
@@ -770,7 +794,7 @@ Review History is append-only.
## Variant Hardening Notes — OpenCode
- Must use OpenCode's native skill tool for sub-skill invocation. Do NOT use Claude's `Skill` tool syntax or Codex's `~/.agents/skills/` paths.
- Must use OpenCode's native skill tool for sub-skill invocation. Do NOT use Claude's `Skill` tool syntax. OpenCode may load shared skill files from `~/.agents/skills/`, but invocation is still OpenCode-native.
- Phase 1 includes a Bootstrap Superpowers Context step that lists installed skills and confirms `superpowers/brainstorming`, `superpowers/test-driven-development`, `superpowers/verification-before-completion`, and `superpowers/finishing-a-development-branch` are discoverable before any other phase runs.
- Helper paths are `~/.config/opencode/skills/reviewer-runtime/{run-review.sh,notify-telegram.sh}`.
- OpenCode reviewer CLI branch (when `REVIEWER_CLI=opencode`):
@@ -784,7 +808,7 @@ Review History is append-only.
## Common Mistakes
- Skipping the Bootstrap Superpowers Context step in Phase 1 (breaks native skill discovery).
- Using Claude `Skill` tool syntax or Codex `~/.agents/skills/` paths.
- Using Claude `Skill` tool syntax, or treating shared `~/.agents/skills/` files as anything other than OpenCode-native skill entries.
- Forgetting to set `--agent plan` on opencode reviewer calls (would use the default `build` agent which can write files).
- Asking multiple clarifying questions in a single message.
- Skipping the per-payload secret scan because "the previous round was clean".
@@ -9,7 +9,7 @@
| Created | YYYY-MM-DD |
| Slug | YYYY-MM-DD-<slug> |
| Runtime | opencode |
| Reviewer CLI | codex \| claude \| cursor \| opencode |
| Reviewer CLI | codex \| claude \| cursor \| opencode \| pi |
| Reviewer Model | <model> |
| MAX_ROUNDS | 10 |
| Branch Strategy | current-branch \| worktree |
+8
View File
@@ -103,6 +103,14 @@ Reviewer CLI: `codex`, `claude`, `cursor`, `opencode`, `pi`, or `skip`
Store `REVIEWER_CLI`, `REVIEWER_MODEL`, and `MAX_ROUNDS`.
If `REVIEWER_CLI=pi`, verify the Pi reviewer binary before entering the review loop:
```bash
pi --version
```
For shorthand `pi/<pi-model-name>`, split only on the first slash when the prefix is exactly `pi`; store the complete remainder in `REVIEWER_MODEL`. Examples: `pi/claude-opus-4-7` -> `claude-opus-4-7`, `pi/anthropic/claude-opus-4-7` -> `anthropic/claude-opus-4-7`, and `pi/openrouter/anthropic/claude-opus-4-7` -> `openrouter/anthropic/claude-opus-4-7`.
When `REVIEWER_CLI=pi`, the reviewer model is configured independently from the pi model running this workflow. Use any configured pi model string, including provider-qualified model IDs. If the reviewer model or provider is unavailable, surface the review helper stderr/status and ask for a configured model; use `pi --list-models [search]` to inspect configured models.
The pi reviewer command rendered into `/tmp/do-task-${REVIEW_KIND}-review-${REVIEW_ID}.sh` must be isolated and read-only:
+38 -1
View File
@@ -61,6 +61,19 @@ If the user has already specified a reviewer CLI and model (e.g., "implement the
Store `REVIEWER_CLI`, `REVIEWER_MODEL`, and `MAX_ROUNDS`. These values are fixed for the entire run.
Reviewer CLI: `codex`, `claude`, `cursor`, `opencode`, `pi`, or `skip`.
If `REVIEWER_CLI=pi`, verify the Pi reviewer binary before entering the review loop:
```bash
pi --version
```
For shorthand `pi/<pi-model-name>`, split only on the first slash when the prefix is exactly `pi`; store the complete remainder in `REVIEWER_MODEL`. Examples: `pi/claude-opus-4-7` -> `claude-opus-4-7`, `pi/anthropic/claude-opus-4-7` -> `anthropic/claude-opus-4-7`, and `pi/openrouter/anthropic/claude-opus-4-7` -> `openrouter/anthropic/claude-opus-4-7`.
When `REVIEWER_CLI=pi`, the reviewer model is configured independently from the model running this workflow. If the model/provider is unavailable, surface helper stderr/status and use `pi --list-models [search]` to inspect configured models.
### Phase 3: Set Up Worktree (REQUIRED SUB-SKILL)
Invoke `superpowers:using-git-worktrees` explicitly.
@@ -235,6 +248,18 @@ Write the reviewer invocation to `/tmp/milestone-review-${REVIEW_ID}.sh` as a ba
set -euo pipefail
```
**If `REVIEWER_CLI` is `pi`:**
Fresh call every round (Pi reviewer calls do not use session resume):
```bash
pi --no-session --no-skills --no-prompt-templates --no-extensions --no-context-files \
--model "$REVIEWER_MODEL" \
--tools read,grep,find,ls \
-p "Read the file /tmp/milestone-${REVIEW_ID}.md and review. Return exactly the required ## Summary, ## Findings, and ## Verdict structure."
```
**If `REVIEWER_CLI` is `codex`:**
```bash
@@ -372,7 +397,7 @@ jq -r '.result' /tmp/milestone-review-${REVIEW_ID}.json > /tmp/milestone-review-
```
- If `REVIEWER_CLI=codex`, extract `CODEX_SESSION_ID` from `/tmp/milestone-review-${REVIEW_ID}.runner.out` after the helper or fallback run. If the review text is only in `.runner.out`, move or copy the actual review body into `/tmp/milestone-review-${REVIEW_ID}.md` before verdict parsing.
- If `REVIEWER_CLI=claude`, promote stdout captured by the helper or fallback runner into the markdown review file:
- If `REVIEWER_CLI=claude` or `REVIEWER_CLI=pi`, promote stdout captured by the helper or fallback runner into the markdown review file:
```bash
cp /tmp/milestone-review-${REVIEW_ID}.runner.out /tmp/milestone-review-${REVIEW_ID}.md
@@ -424,6 +449,18 @@ If a revision contradicts the user's explicit requirements, skip it and note it
Rewrite `/tmp/milestone-review-${REVIEW_ID}.sh` for the next round. The script should contain the reviewer invocation only; do not run it directly.
**If `REVIEWER_CLI` is `pi`:**
Fresh call with prior-round context (Pi reviewer calls do not use session resume):
```bash
pi --no-session --no-skills --no-prompt-templates --no-extensions --no-context-files \
--model "$REVIEWER_MODEL" \
--tools read,grep,find,ls \
-p "You previously reviewed this milestone and requested revisions. Read the updated payload at /tmp/milestone-${REVIEW_ID}.md and re-review using the same ## Summary, ## Findings, and ## Verdict structure."
```
**If `REVIEWER_CLI` is `codex`:**
Resume the existing session:
+38 -1
View File
@@ -94,6 +94,19 @@ If the user has already specified a reviewer CLI and model (e.g., "implement the
Store `REVIEWER_CLI`, `REVIEWER_MODEL`, and `MAX_ROUNDS`. These values are fixed for the entire run.
Reviewer CLI: `codex`, `claude`, `cursor`, `opencode`, `pi`, or `skip`.
If `REVIEWER_CLI=pi`, verify the Pi reviewer binary before entering the review loop:
```bash
pi --version
```
For shorthand `pi/<pi-model-name>`, split only on the first slash when the prefix is exactly `pi`; store the complete remainder in `REVIEWER_MODEL`. Examples: `pi/claude-opus-4-7` -> `claude-opus-4-7`, `pi/anthropic/claude-opus-4-7` -> `anthropic/claude-opus-4-7`, and `pi/openrouter/anthropic/claude-opus-4-7` -> `openrouter/anthropic/claude-opus-4-7`.
When `REVIEWER_CLI=pi`, the reviewer model is configured independently from the model running this workflow. If the model/provider is unavailable, surface helper stderr/status and use `pi --list-models [search]` to inspect configured models.
### Phase 3: Set Up Worktree (REQUIRED SUB-SKILL)
Invoke `superpowers:using-git-worktrees`.
@@ -268,6 +281,18 @@ Write the reviewer invocation to `/tmp/milestone-review-${REVIEW_ID}.sh` as a ba
set -euo pipefail
```
**If `REVIEWER_CLI` is `pi`:**
Fresh call every round (Pi reviewer calls do not use session resume):
```bash
pi --no-session --no-skills --no-prompt-templates --no-extensions --no-context-files \
--model "$REVIEWER_MODEL" \
--tools read,grep,find,ls \
-p "Read the file /tmp/milestone-${REVIEW_ID}.md and review. Return exactly the required ## Summary, ## Findings, and ## Verdict structure."
```
**If `REVIEWER_CLI` is `codex`:**
```bash
@@ -405,7 +430,7 @@ jq -r '.result' /tmp/milestone-review-${REVIEW_ID}.json > /tmp/milestone-review-
```
- If `REVIEWER_CLI=codex`, extract `CODEX_SESSION_ID` from `/tmp/milestone-review-${REVIEW_ID}.runner.out` after the helper or fallback run. If the review text is only in `.runner.out`, move or copy the actual review body into `/tmp/milestone-review-${REVIEW_ID}.md` before verdict parsing.
- If `REVIEWER_CLI=claude`, promote stdout captured by the helper or fallback runner into the markdown review file:
- If `REVIEWER_CLI=claude` or `REVIEWER_CLI=pi`, promote stdout captured by the helper or fallback runner into the markdown review file:
```bash
cp /tmp/milestone-review-${REVIEW_ID}.runner.out /tmp/milestone-review-${REVIEW_ID}.md
@@ -457,6 +482,18 @@ If a revision contradicts the user's explicit requirements, skip it and note it
Rewrite `/tmp/milestone-review-${REVIEW_ID}.sh` for the next round. The script should contain the reviewer invocation only; do not run it directly.
**If `REVIEWER_CLI` is `pi`:**
Fresh call with prior-round context (Pi reviewer calls do not use session resume):
```bash
pi --no-session --no-skills --no-prompt-templates --no-extensions --no-context-files \
--model "$REVIEWER_MODEL" \
--tools read,grep,find,ls \
-p "You previously reviewed this milestone and requested revisions. Read the updated payload at /tmp/milestone-${REVIEW_ID}.md and re-review using the same ## Summary, ## Findings, and ## Verdict structure."
```
**If `REVIEWER_CLI` is `codex`:**
Resume the existing session:
+45 -8
View File
@@ -16,7 +16,7 @@ This skill wraps the Superpowers execution flow for the Cursor Agent CLI (`curso
4. Review each milestone with a second model/provider
5. Commit approved milestones, merge to parent branch, and delete worktree
**Core principle:** Cursor Agent CLI discovers skills from `.cursor/skills/` (repo-local or `~/.cursor/skills/` global). It also reads `AGENTS.md` at the repo root for additional instructions.
**Core principle:** Cursor Agent CLI discovers skills from `.cursor/skills/` (repo-local), `~/.cursor/skills/` (global), and installed Cursor plugin cache entries. It also reads `AGENTS.md` at the repo root for additional instructions.
## Prerequisite Check (MANDATORY)
@@ -28,7 +28,7 @@ Required:
- `milestone-plan.md` exists in plan folder
- `story-tracker.md` exists in plan folder
- Git repo with worktree support: `git worktree list`
- Superpowers skills installed under `.cursor/skills/` (repo-local) or `~/.cursor/skills/` (global)
- Superpowers skills available from the Cursor plugin cache, `.cursor/skills/` (repo-local), or `~/.cursor/skills/` (global). Do not install both the plugin and a manual Superpowers copy, or Cursor may show duplicate skill entries.
- Superpowers execution skills:
- `superpowers:executing-plans`
- `superpowers:using-git-worktrees`
@@ -39,17 +39,17 @@ Verify before proceeding:
```bash
cursor-agent --version
test -f .cursor/skills/superpowers/skills/executing-plans/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/executing-plans/SKILL.md
test -f .cursor/skills/superpowers/skills/using-git-worktrees/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/using-git-worktrees/SKILL.md
test -f .cursor/skills/superpowers/skills/verification-before-completion/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/verification-before-completion/SKILL.md
test -f .cursor/skills/superpowers/skills/finishing-a-development-branch/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/finishing-a-development-branch/SKILL.md
test -f .cursor/skills/superpowers/skills/executing-plans/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/executing-plans/SKILL.md || find ~/.cursor/plugins/cache/cursor-public/superpowers -path '*/skills/executing-plans/SKILL.md' -print -quit 2>/dev/null | grep -q .
test -f .cursor/skills/superpowers/skills/using-git-worktrees/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/using-git-worktrees/SKILL.md || find ~/.cursor/plugins/cache/cursor-public/superpowers -path '*/skills/using-git-worktrees/SKILL.md' -print -quit 2>/dev/null | grep -q .
test -f .cursor/skills/superpowers/skills/verification-before-completion/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/verification-before-completion/SKILL.md || find ~/.cursor/plugins/cache/cursor-public/superpowers -path '*/skills/verification-before-completion/SKILL.md' -print -quit 2>/dev/null | grep -q .
test -f .cursor/skills/superpowers/skills/finishing-a-development-branch/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/finishing-a-development-branch/SKILL.md || find ~/.cursor/plugins/cache/cursor-public/superpowers -path '*/skills/finishing-a-development-branch/SKILL.md' -print -quit 2>/dev/null | grep -q .
# Only if using cursor as reviewer CLI:
# jq --version
```
If any dependency is missing, stop and return:
`Missing dependency: [specific missing item]. Install from https://github.com/obra/superpowers and copy into .cursor/skills/ or ~/.cursor/skills/, then retry.`
`Missing dependency: [specific missing item]. Install the Cursor Superpowers plugin or install Superpowers under .cursor/skills/ or ~/.cursor/skills/, then retry.`
If no plan folder exists:
@@ -94,6 +94,19 @@ If the user has already specified a reviewer CLI and model (e.g., "implement the
Store `REVIEWER_CLI`, `REVIEWER_MODEL`, and `MAX_ROUNDS`. These values are fixed for the entire run.
Reviewer CLI: `codex`, `claude`, `cursor`, `opencode`, `pi`, or `skip`.
If `REVIEWER_CLI=pi`, verify the Pi reviewer binary before entering the review loop:
```bash
pi --version
```
For shorthand `pi/<pi-model-name>`, split only on the first slash when the prefix is exactly `pi`; store the complete remainder in `REVIEWER_MODEL`. Examples: `pi/claude-opus-4-7` -> `claude-opus-4-7`, `pi/anthropic/claude-opus-4-7` -> `anthropic/claude-opus-4-7`, and `pi/openrouter/anthropic/claude-opus-4-7` -> `openrouter/anthropic/claude-opus-4-7`.
When `REVIEWER_CLI=pi`, the reviewer model is configured independently from the model running this workflow. If the model/provider is unavailable, surface helper stderr/status and use `pi --list-models [search]` to inspect configured models.
### Phase 3: Set Up Worktree (REQUIRED SUB-SKILL)
Invoke `superpowers:using-git-worktrees`.
@@ -272,6 +285,18 @@ Write the reviewer invocation to `/tmp/milestone-review-${REVIEW_ID}.sh` as a ba
set -euo pipefail
```
**If `REVIEWER_CLI` is `pi`:**
Fresh call every round (Pi reviewer calls do not use session resume):
```bash
pi --no-session --no-skills --no-prompt-templates --no-extensions --no-context-files \
--model "$REVIEWER_MODEL" \
--tools read,grep,find,ls \
-p "Read the file /tmp/milestone-${REVIEW_ID}.md and review. Return exactly the required ## Summary, ## Findings, and ## Verdict structure."
```
**If `REVIEWER_CLI` is `codex`:**
```bash
@@ -415,7 +440,7 @@ jq -r '.result' /tmp/milestone-review-${REVIEW_ID}.json > /tmp/milestone-review-
```
- If `REVIEWER_CLI=codex`, extract `CODEX_SESSION_ID` from `/tmp/milestone-review-${REVIEW_ID}.runner.out` after the helper or fallback run. If the review text is only in `.runner.out`, move or copy the actual review body into `/tmp/milestone-review-${REVIEW_ID}.md` before verdict parsing.
- If `REVIEWER_CLI=claude`, promote stdout captured by the helper or fallback runner into the markdown review file:
- If `REVIEWER_CLI=claude` or `REVIEWER_CLI=pi`, promote stdout captured by the helper or fallback runner into the markdown review file:
```bash
cp /tmp/milestone-review-${REVIEW_ID}.runner.out /tmp/milestone-review-${REVIEW_ID}.md
@@ -467,6 +492,18 @@ If a revision contradicts the user's explicit requirements, skip it and note it
Rewrite `/tmp/milestone-review-${REVIEW_ID}.sh` for the next round. The script should contain the reviewer invocation only; do not run it directly.
**If `REVIEWER_CLI` is `pi`:**
Fresh call with prior-round context (Pi reviewer calls do not use session resume):
```bash
pi --no-session --no-skills --no-prompt-templates --no-extensions --no-context-files \
--model "$REVIEWER_MODEL" \
--tools read,grep,find,ls \
-p "You previously reviewed this milestone and requested revisions. Read the updated payload at /tmp/milestone-${REVIEW_ID}.md and re-review using the same ## Summary, ## Findings, and ## Verdict structure."
```
**If `REVIEWER_CLI` is `codex`:**
Resume the existing session:
+43 -3
View File
@@ -17,7 +17,7 @@ Required:
- `milestone-plan.md` exists in plan folder
- `story-tracker.md` exists in plan folder
- Git repo with worktree support: `git worktree list`
- OpenCode Superpowers skills symlink: `~/.config/opencode/skills/superpowers`
- OpenCode Superpowers skills available at `~/.agents/skills/superpowers` or `~/.config/opencode/skills/superpowers`
- Superpowers execution skills:
- `superpowers/executing-plans`
- `superpowers/using-git-worktrees`
@@ -27,7 +27,10 @@ Required:
Verify before proceeding:
```bash
ls -l ~/.config/opencode/skills/superpowers
test -f ~/.agents/skills/superpowers/executing-plans/SKILL.md || test -f ~/.config/opencode/skills/superpowers/executing-plans/SKILL.md
test -f ~/.agents/skills/superpowers/using-git-worktrees/SKILL.md || test -f ~/.config/opencode/skills/superpowers/using-git-worktrees/SKILL.md
test -f ~/.agents/skills/superpowers/verification-before-completion/SKILL.md || test -f ~/.config/opencode/skills/superpowers/verification-before-completion/SKILL.md
test -f ~/.agents/skills/superpowers/finishing-a-development-branch/SKILL.md || test -f ~/.config/opencode/skills/superpowers/finishing-a-development-branch/SKILL.md
```
If dependencies are missing, stop immediately and return:
@@ -76,6 +79,19 @@ If the user has already specified a reviewer CLI and model (e.g., "implement the
Store `REVIEWER_CLI`, `REVIEWER_MODEL`, and `MAX_ROUNDS`. These values are fixed for the entire run.
Reviewer CLI: `codex`, `claude`, `cursor`, `opencode`, `pi`, or `skip`.
If `REVIEWER_CLI=pi`, verify the Pi reviewer binary before entering the review loop:
```bash
pi --version
```
For shorthand `pi/<pi-model-name>`, split only on the first slash when the prefix is exactly `pi`; store the complete remainder in `REVIEWER_MODEL`. Examples: `pi/claude-opus-4-7` -> `claude-opus-4-7`, `pi/anthropic/claude-opus-4-7` -> `anthropic/claude-opus-4-7`, and `pi/openrouter/anthropic/claude-opus-4-7` -> `openrouter/anthropic/claude-opus-4-7`.
When `REVIEWER_CLI=pi`, the reviewer model is configured independently from the model running this workflow. If the model/provider is unavailable, surface helper stderr/status and use `pi --list-models [search]` to inspect configured models.
### Phase 4: Set Up Worktree (REQUIRED SUB-SKILL)
Use OpenCode's native skill tool to load:
@@ -253,6 +269,18 @@ Write the reviewer invocation to `/tmp/milestone-review-${REVIEW_ID}.sh` as a ba
set -euo pipefail
```
**If `REVIEWER_CLI` is `pi`:**
Fresh call every round (Pi reviewer calls do not use session resume):
```bash
pi --no-session --no-skills --no-prompt-templates --no-extensions --no-context-files \
--model "$REVIEWER_MODEL" \
--tools read,grep,find,ls \
-p "Read the file /tmp/milestone-${REVIEW_ID}.md and review. Return exactly the required ## Summary, ## Findings, and ## Verdict structure."
```
**If `REVIEWER_CLI` is `codex`:**
```bash
@@ -390,7 +418,7 @@ jq -r '.result' /tmp/milestone-review-${REVIEW_ID}.json > /tmp/milestone-review-
```
- If `REVIEWER_CLI=codex`, extract `CODEX_SESSION_ID` from `/tmp/milestone-review-${REVIEW_ID}.runner.out` after the helper or fallback run. If the review text is only in `.runner.out`, move or copy the actual review body into `/tmp/milestone-review-${REVIEW_ID}.md` before verdict parsing.
- If `REVIEWER_CLI=claude`, promote stdout captured by the helper or fallback runner into the markdown review file:
- If `REVIEWER_CLI=claude` or `REVIEWER_CLI=pi`, promote stdout captured by the helper or fallback runner into the markdown review file:
```bash
cp /tmp/milestone-review-${REVIEW_ID}.runner.out /tmp/milestone-review-${REVIEW_ID}.md
@@ -442,6 +470,18 @@ If a revision contradicts the user's explicit requirements, skip it and note it
Rewrite `/tmp/milestone-review-${REVIEW_ID}.sh` for the next round. The script should contain the reviewer invocation only; do not run it directly.
**If `REVIEWER_CLI` is `pi`:**
Fresh call with prior-round context (Pi reviewer calls do not use session resume):
```bash
pi --no-session --no-skills --no-prompt-templates --no-extensions --no-context-files \
--model "$REVIEWER_MODEL" \
--tools read,grep,find,ls \
-p "You previously reviewed this milestone and requested revisions. Read the updated payload at /tmp/milestone-${REVIEW_ID}.md and re-review using the same ## Summary, ## Findings, and ## Verdict structure."
```
**If `REVIEWER_CLI` is `codex`:**
Resume the existing session:
+8
View File
@@ -86,6 +86,14 @@ Reviewer CLI: `codex`, `claude`, `cursor`, `opencode`, `pi`, or `skip`
Store `REVIEWER_CLI`, `REVIEWER_MODEL`, and `MAX_ROUNDS`.
If `REVIEWER_CLI=pi`, verify the Pi reviewer binary before entering the review loop:
```bash
pi --version
```
For shorthand `pi/<pi-model-name>`, split only on the first slash when the prefix is exactly `pi`; store the complete remainder in `REVIEWER_MODEL`. Examples: `pi/claude-opus-4-7` -> `claude-opus-4-7`, `pi/anthropic/claude-opus-4-7` -> `anthropic/claude-opus-4-7`, and `pi/openrouter/anthropic/claude-opus-4-7` -> `openrouter/anthropic/claude-opus-4-7`.
When `REVIEWER_CLI=pi`, the reviewer model is configured independently from the pi model running this workflow. Use any configured pi model string, including provider-qualified model IDs. If the reviewer model or provider is unavailable, surface the review helper stderr/status and ask for a configured model; use `pi --list-models [search]` to inspect configured models.
The pi reviewer command rendered into `/tmp/milestone-review-${REVIEW_ID}.sh` must be isolated and read-only:
+112
View File
@@ -0,0 +1,112 @@
---
name: web-automation
description: Browse and scrape web pages using Playwright-compatible CloakBrowser. Use when automating web workflows, extracting rendered page content, handling authenticated sessions, or running multi-step browser flows.
---
# Web Automation with CloakBrowser (Cursor)
Automated web browsing and scraping using Playwright-compatible CloakBrowser with two execution paths:
- one-shot extraction via `extract.js`
- broader stateful automation via `auth.ts`, `browse.ts`, `flow.ts`, `scan-local-app.ts`, and `scrape.ts`
## Requirements
- Node.js 20+
- pnpm
- Network access to download the CloakBrowser binary on first use
## First-Time Setup
Repo-local install:
```bash
cd .cursor/skills/web-automation/scripts
pnpm install
npx cloakbrowser install
pnpm approve-builds
pnpm rebuild better-sqlite3 esbuild
```
Global install:
```bash
cd ~/.cursor/skills/web-automation/scripts
pnpm install
npx cloakbrowser install
pnpm approve-builds
pnpm rebuild better-sqlite3 esbuild
```
## Updating CloakBrowser
Run from the installed `scripts/` directory:
```bash
pnpm up cloakbrowser playwright-core
npx cloakbrowser install
pnpm approve-builds
pnpm rebuild better-sqlite3 esbuild
```
## Prerequisite Check (MANDATORY)
Before running automation, verify CloakBrowser and Playwright Core are installed and wired correctly.
```bash
cd .cursor/skills/web-automation/scripts || cd ~/.cursor/skills/web-automation/scripts
node check-install.js
```
If the check fails, stop and return:
"Missing dependency/config: web-automation requires `cloakbrowser` and `playwright-core` with CloakBrowser-based scripts. Run setup in this skill, then retry."
If runtime fails with missing native bindings for `better-sqlite3` or `esbuild`, run:
```bash
cd .cursor/skills/web-automation/scripts || cd ~/.cursor/skills/web-automation/scripts
pnpm approve-builds
pnpm rebuild better-sqlite3 esbuild
```
## When To Use Which Command
- Use `node extract.js "<URL>"` for a one-shot rendered fetch with JSON output.
- Use `npx tsx scrape.ts ...` when you need markdown extraction, Readability cleanup, or selector-based scraping.
- Use `npx tsx browse.ts ...`, `auth.ts`, or `flow.ts` when the task needs login handling, persistent sessions, clicks, typing, screenshots, or multi-step navigation.
- Use `npx tsx scan-local-app.ts` when you need a configurable local-app smoke pass driven by `SCAN_*` and `CLOAKBROWSER_*` environment variables.
## Quick Reference
- Install check: `node check-install.js`
- One-shot JSON extract: `node extract.js "https://example.com"`
- Browse page: `npx tsx browse.ts --url "https://example.com"`
- Scrape markdown: `npx tsx scrape.ts --url "https://example.com" --mode main --output page.md`
- Authenticate: `npx tsx auth.ts --url "https://example.com/login"`
- Natural-language flow: `npx tsx flow.ts --instruction 'go to https://example.com then click on "Login" then type "user@example.com" in #email then press enter'`
- Local app smoke scan: `SCAN_BASE_URL=http://localhost:3000 SCAN_ROUTES=/,/dashboard npx tsx scan-local-app.ts`
## Local App Smoke Scan
`scan-local-app.ts` is intentionally generic. Configure it with environment variables instead of editing the file:
- `SCAN_BASE_URL`
- `SCAN_LOGIN_PATH`
- `SCAN_USERNAME`
- `SCAN_PASSWORD`
- `SCAN_USERNAME_SELECTOR`
- `SCAN_PASSWORD_SELECTOR`
- `SCAN_SUBMIT_SELECTOR`
- `SCAN_ROUTES`
- `SCAN_REPORT_PATH`
- `SCAN_HEADLESS`
If `SCAN_USERNAME` or `SCAN_PASSWORD` are omitted, the script falls back to `CLOAKBROWSER_USERNAME` and `CLOAKBROWSER_PASSWORD`.
## Notes
- Sessions persist in CloakBrowser profile storage.
- Use `--wait` for dynamic pages.
- Use `--mode selector --selector "..."` for targeted extraction.
- `extract.js` keeps a bounded stealth/rendered fetch path without needing a long-lived automation session.
@@ -0,0 +1,575 @@
#!/usr/bin/env npx tsx
/**
* Authentication handler for web automation
* Supports generic form login and Microsoft SSO (MSAL)
*
* Usage:
* npx tsx auth.ts --url "https://example.com/login" --type form
* npx tsx auth.ts --url "https://example.com" --type msal
* npx tsx auth.ts --url "https://example.com" --type auto
*/
import { getPage, launchBrowser } from './browse.js';
import parseArgs from 'minimist';
import type { Page, BrowserContext } from 'playwright-core';
import { createInterface } from 'readline';
// Types
type AuthType = 'auto' | 'form' | 'msal';
interface AuthOptions {
url: string;
authType: AuthType;
credentials?: {
username: string;
password: string;
};
headless?: boolean;
timeout?: number;
}
interface AuthResult {
success: boolean;
finalUrl: string;
authType: AuthType;
message: string;
}
// Get credentials from environment or options
function getCredentials(options?: {
username?: string;
password?: string;
}): { username: string; password: string } | null {
const username = options?.username || process.env.CLOAKBROWSER_USERNAME;
const password = options?.password || process.env.CLOAKBROWSER_PASSWORD;
if (!username || !password) {
return null;
}
return { username, password };
}
// Prompt user for input (for MFA or credentials)
async function promptUser(question: string, hidden = false): Promise<string> {
const rl = createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
if (hidden) {
process.stdout.write(question);
// Note: This is a simple implementation. For production, use a proper hidden input library
}
rl.question(question, (answer) => {
rl.close();
resolve(answer);
});
});
}
// Detect authentication type from page
async function detectAuthType(page: Page): Promise<AuthType> {
const url = page.url();
// Check for Microsoft login
if (
url.includes('login.microsoftonline.com') ||
url.includes('login.live.com') ||
url.includes('login.windows.net')
) {
return 'msal';
}
// Check for common form login patterns
const hasLoginForm = await page.evaluate(() => {
const passwordField = document.querySelector(
'input[type="password"], input[name*="password"], input[id*="password"]'
);
const usernameField = document.querySelector(
'input[type="email"], input[type="text"][name*="user"], input[type="text"][name*="email"], input[id*="user"], input[id*="email"]'
);
return !!(passwordField && usernameField);
});
if (hasLoginForm) {
return 'form';
}
return 'auto';
}
// Handle generic form login
async function handleFormLogin(
page: Page,
credentials: { username: string; password: string },
timeout: number
): Promise<boolean> {
console.log('Attempting form login...');
// Find and fill username/email field
const usernameSelectors = [
'input[type="email"]',
'input[name*="user" i]',
'input[name*="email" i]',
'input[id*="user" i]',
'input[id*="email" i]',
'input[autocomplete="username"]',
'input[type="text"]:first-of-type',
];
let usernameField = null;
for (const selector of usernameSelectors) {
usernameField = await page.$(selector);
if (usernameField) break;
}
if (!usernameField) {
console.error('Could not find username/email field');
return false;
}
await usernameField.fill(credentials.username);
console.log('Filled username field');
// Find and fill password field
const passwordSelectors = [
'input[type="password"]',
'input[name*="password" i]',
'input[id*="password" i]',
'input[autocomplete="current-password"]',
];
let passwordField = null;
for (const selector of passwordSelectors) {
passwordField = await page.$(selector);
if (passwordField) break;
}
if (!passwordField) {
console.error('Could not find password field');
return false;
}
await passwordField.fill(credentials.password);
console.log('Filled password field');
// Check for "Remember me" checkbox and check it
const rememberCheckbox = await page.$(
'input[type="checkbox"][name*="remember" i], input[type="checkbox"][id*="remember" i]'
);
if (rememberCheckbox) {
await rememberCheckbox.check();
console.log('Checked "Remember me" checkbox');
}
// Find and click submit button
const submitSelectors = [
'button[type="submit"]',
'input[type="submit"]',
'button:has-text("Sign in")',
'button:has-text("Log in")',
'button:has-text("Login")',
'button:has-text("Submit")',
'[role="button"]:has-text("Sign in")',
];
let submitButton = null;
for (const selector of submitSelectors) {
submitButton = await page.$(selector);
if (submitButton) break;
}
if (!submitButton) {
// Try pressing Enter as fallback
await passwordField.press('Enter');
} else {
await submitButton.click();
}
console.log('Submitted login form');
// Wait for navigation or error
try {
await page.waitForNavigation({ timeout, waitUntil: 'domcontentloaded' });
return true;
} catch {
// Check if we're still on login page with error
const errorMessages = await page.$$eval(
'.error, .alert-danger, [role="alert"], .login-error',
(els) => els.map((el) => el.textContent?.trim()).filter(Boolean)
);
if (errorMessages.length > 0) {
console.error('Login error:', errorMessages.join(', '));
return false;
}
return true; // Might have succeeded without navigation
}
}
// Handle Microsoft SSO login
async function handleMsalLogin(
page: Page,
credentials: { username: string; password: string },
timeout: number
): Promise<boolean> {
console.log('Attempting Microsoft SSO login...');
const currentUrl = page.url();
// If not already on Microsoft login, wait for redirect
if (!currentUrl.includes('login.microsoftonline.com')) {
try {
await page.waitForURL('**/login.microsoftonline.com/**', { timeout: 10000 });
} catch {
console.log('Not redirected to Microsoft login');
return false;
}
}
// Wait for email input
const emailInput = await page.waitForSelector(
'input[type="email"], input[name="loginfmt"]',
{ timeout }
);
if (!emailInput) {
console.error('Could not find email input on Microsoft login');
return false;
}
// Fill email and submit
await emailInput.fill(credentials.username);
console.log('Filled email field');
const nextButton = await page.$('input[type="submit"], button[type="submit"]');
if (nextButton) {
await nextButton.click();
} else {
await emailInput.press('Enter');
}
// Wait for password page
try {
await page.waitForSelector(
'input[type="password"], input[name="passwd"]',
{ timeout }
);
} catch {
// Might be using passwordless auth or different flow
console.log('Password field not found - might be using different auth flow');
return false;
}
// Fill password
const passwordInput = await page.$('input[type="password"], input[name="passwd"]');
if (!passwordInput) {
console.error('Could not find password input');
return false;
}
await passwordInput.fill(credentials.password);
console.log('Filled password field');
// Submit
const signInButton = await page.$('input[type="submit"], button[type="submit"]');
if (signInButton) {
await signInButton.click();
} else {
await passwordInput.press('Enter');
}
// Handle "Stay signed in?" prompt
try {
const staySignedInButton = await page.waitForSelector(
'input[value="Yes"], button:has-text("Yes")',
{ timeout: 5000 }
);
if (staySignedInButton) {
await staySignedInButton.click();
console.log('Clicked "Stay signed in" button');
}
} catch {
// Prompt might not appear
}
// Check for Conditional Access Policy error
const caError = await page.$('text=Conditional Access policy');
if (caError) {
console.error('Blocked by Conditional Access Policy');
// Take screenshot for debugging
await page.screenshot({ path: 'ca-policy-error.png' });
console.log('Screenshot saved: ca-policy-error.png');
return false;
}
// Wait for redirect away from Microsoft login
try {
await page.waitForURL(
(url) => !url.href.includes('login.microsoftonline.com'),
{ timeout }
);
return true;
} catch {
return false;
}
}
// Check if user is already authenticated
async function isAuthenticated(page: Page, targetUrl: string): Promise<boolean> {
const currentUrl = page.url();
// If we're on the target URL (not a login page), we're likely authenticated
if (currentUrl.startsWith(targetUrl)) {
// Check for common login page indicators
const isLoginPage = await page.evaluate(() => {
const loginIndicators = [
'input[type="password"]',
'form[action*="login"]',
'form[action*="signin"]',
'.login-form',
'#login',
];
return loginIndicators.some((sel) => document.querySelector(sel) !== null);
});
return !isLoginPage;
}
return false;
}
// Main authentication function
export async function authenticate(options: AuthOptions): Promise<AuthResult> {
const browser = await launchBrowser({ headless: options.headless ?? true });
const page = await browser.newPage();
const timeout = options.timeout ?? 30000;
try {
// Navigate to URL
console.log(`Navigating to: ${options.url}`);
await page.goto(options.url, { timeout: 60000, waitUntil: 'domcontentloaded' });
// Check if already authenticated
if (await isAuthenticated(page, options.url)) {
return {
success: true,
finalUrl: page.url(),
authType: 'auto',
message: 'Already authenticated (session persisted from profile)',
};
}
// Get credentials
const credentials = options.credentials
? options.credentials
: getCredentials();
if (!credentials) {
// No credentials - open interactive browser
console.log('\nNo credentials provided. Opening browser for manual login...');
console.log('Please complete the login process manually.');
console.log('The session will be saved to your profile.');
// Switch to headed mode for manual login
await browser.close();
const interactiveBrowser = await launchBrowser({ headless: false });
const interactivePage = await interactiveBrowser.newPage();
await interactivePage.goto(options.url);
await promptUser('\nPress Enter when you have completed login...');
const finalUrl = interactivePage.url();
await interactiveBrowser.close();
return {
success: true,
finalUrl,
authType: 'auto',
message: 'Manual login completed - session saved to profile',
};
}
// Detect auth type if auto
let authType = options.authType;
if (authType === 'auto') {
authType = await detectAuthType(page);
console.log(`Detected auth type: ${authType}`);
}
// Handle authentication based on type
let success = false;
switch (authType) {
case 'msal':
success = await handleMsalLogin(page, credentials, timeout);
break;
case 'form':
default:
success = await handleFormLogin(page, credentials, timeout);
break;
}
const finalUrl = page.url();
return {
success,
finalUrl,
authType,
message: success
? `Authentication successful - session saved to profile`
: 'Authentication failed',
};
} finally {
await browser.close();
}
}
// Navigate to authenticated page (handles auth if needed)
export async function navigateAuthenticated(
url: string,
options?: {
credentials?: { username: string; password: string };
headless?: boolean;
}
): Promise<{ page: Page; browser: BrowserContext }> {
const { page, browser } = await getPage({ headless: options?.headless ?? true });
await page.goto(url, { timeout: 60000, waitUntil: 'domcontentloaded' });
// Check if we need to authenticate
if (!(await isAuthenticated(page, url))) {
console.log('Session expired or not authenticated. Attempting login...');
// Get credentials
const credentials = options?.credentials ?? getCredentials();
if (!credentials) {
throw new Error(
'Authentication required but no credentials provided. ' +
'Set CLOAKBROWSER_USERNAME and CLOAKBROWSER_PASSWORD environment variables.'
);
}
// Detect and handle auth
const authType = await detectAuthType(page);
let success = false;
if (authType === 'msal') {
success = await handleMsalLogin(page, credentials, 30000);
} else {
success = await handleFormLogin(page, credentials, 30000);
}
if (!success) {
await browser.close();
throw new Error('Authentication failed');
}
// Navigate back to original URL if we were redirected
if (!page.url().startsWith(url)) {
await page.goto(url, { timeout: 60000, waitUntil: 'domcontentloaded' });
}
}
return { page, browser };
}
// CLI entry point
async function main() {
const args = parseArgs(process.argv.slice(2), {
string: ['url', 'type', 'username', 'password'],
boolean: ['headless', 'help'],
default: {
type: 'auto',
headless: false, // Default to headed for auth so user can see/interact
},
alias: {
u: 'url',
t: 'type',
h: 'help',
},
});
if (args.help || !args.url) {
console.log(`
Web Authentication Handler
Usage:
npx tsx auth.ts --url <url> [options]
Options:
-u, --url <url> URL to authenticate (required)
-t, --type <type> Auth type: auto, form, or msal (default: auto)
--username <user> Username/email (or set CLOAKBROWSER_USERNAME env var)
--password <pass> Password (or set CLOAKBROWSER_PASSWORD env var)
--headless <bool> Run in headless mode (default: false for auth)
-h, --help Show this help message
Auth Types:
auto Auto-detect authentication type
form Generic username/password form
msal Microsoft SSO (login.microsoftonline.com)
Environment Variables:
CLOAKBROWSER_USERNAME Default username/email for authentication
CLOAKBROWSER_PASSWORD Default password for authentication
Examples:
# Interactive login (no credentials, opens browser)
npx tsx auth.ts --url "https://example.com/login"
# Form login with credentials
npx tsx auth.ts --url "https://example.com/login" --type form \\
--username "user@example.com" --password "secret"
# Microsoft SSO login
CLOAKBROWSER_USERNAME=user@company.com CLOAKBROWSER_PASSWORD=secret \\
npx tsx auth.ts --url "https://internal.company.com" --type msal
Notes:
- Session is saved to ~/.cloakbrowser-profile/ for persistence
- After successful auth, subsequent browses will be authenticated
- Use --headless false if you need to handle MFA manually
`);
process.exit(args.help ? 0 : 1);
}
const authType = args.type as AuthType;
if (!['auto', 'form', 'msal'].includes(authType)) {
console.error(`Invalid auth type: ${authType}. Must be auto, form, or msal.`);
process.exit(1);
}
try {
const result = await authenticate({
url: args.url,
authType,
credentials:
args.username && args.password
? { username: args.username, password: args.password }
: undefined,
headless: args.headless,
});
console.log(`\nAuthentication result:`);
console.log(` Success: ${result.success}`);
console.log(` Auth type: ${result.authType}`);
console.log(` Final URL: ${result.finalUrl}`);
console.log(` Message: ${result.message}`);
process.exit(result.success ? 0 : 1);
} catch (error) {
console.error('Error:', error instanceof Error ? error.message : error);
process.exit(1);
}
}
// Run if executed directly
const isMainModule = process.argv[1]?.includes('auth.ts');
if (isMainModule) {
main();
}
@@ -0,0 +1,188 @@
#!/usr/bin/env npx tsx
/**
* Browser launcher using CloakBrowser with persistent profile
*
* Usage:
* npx tsx browse.ts --url "https://example.com"
* npx tsx browse.ts --url "https://example.com" --screenshot --output page.png
* npx tsx browse.ts --url "https://example.com" --headless false --wait 5000
*/
import { launchPersistentContext } from 'cloakbrowser';
import { homedir } from 'os';
import { join } from 'path';
import { existsSync, mkdirSync } from 'fs';
import parseArgs from 'minimist';
import type { Page, BrowserContext } from 'playwright-core';
interface BrowseOptions {
url: string;
headless?: boolean;
screenshot?: boolean;
output?: string;
wait?: number;
timeout?: number;
interactive?: boolean;
}
interface BrowseResult {
title: string;
url: string;
screenshotPath?: string;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
const getProfilePath = (): string => {
const customPath = process.env.CLOAKBROWSER_PROFILE_PATH;
if (customPath) return customPath;
const profileDir = join(homedir(), '.cloakbrowser-profile');
if (!existsSync(profileDir)) {
mkdirSync(profileDir, { recursive: true });
}
return profileDir;
};
export async function launchBrowser(options: {
headless?: boolean;
}): Promise<BrowserContext> {
const profilePath = getProfilePath();
const envHeadless = process.env.CLOAKBROWSER_HEADLESS;
const headless = options.headless ?? (envHeadless ? envHeadless === 'true' : true);
console.log(`Using profile: ${profilePath}`);
console.log(`Headless mode: ${headless}`);
const context = await launchPersistentContext({
userDataDir: profilePath,
headless,
humanize: true,
});
return context;
}
export async function browse(options: BrowseOptions): Promise<BrowseResult> {
const browser = await launchBrowser({ headless: options.headless });
const page = browser.pages()[0] || await browser.newPage();
try {
console.log(`Navigating to: ${options.url}`);
await page.goto(options.url, {
timeout: options.timeout ?? 60000,
waitUntil: 'domcontentloaded',
});
if (options.wait) {
console.log(`Waiting ${options.wait}ms...`);
await sleep(options.wait);
}
const result: BrowseResult = {
title: await page.title(),
url: page.url(),
};
console.log(`Page title: ${result.title}`);
console.log(`Final URL: ${result.url}`);
if (options.screenshot) {
const outputPath = options.output ?? 'screenshot.png';
await page.screenshot({ path: outputPath, fullPage: true });
result.screenshotPath = outputPath;
console.log(`Screenshot saved: ${outputPath}`);
}
if (options.interactive) {
console.log('\nInteractive mode - browser will stay open.');
console.log('Press Ctrl+C to close.');
await new Promise(() => {});
}
return result;
} finally {
if (!options.interactive) {
await browser.close();
}
}
}
export async function getPage(options?: {
headless?: boolean;
}): Promise<{ page: Page; browser: BrowserContext }> {
const browser = await launchBrowser({ headless: options?.headless });
const page = browser.pages()[0] || await browser.newPage();
return { page, browser };
}
async function main() {
const args = parseArgs(process.argv.slice(2), {
string: ['url', 'output'],
boolean: ['screenshot', 'headless', 'interactive', 'help'],
default: {
headless: true,
screenshot: false,
interactive: false,
},
alias: {
u: 'url',
o: 'output',
s: 'screenshot',
h: 'help',
i: 'interactive',
},
});
if (args.help || !args.url) {
console.log(`
Web Browser with CloakBrowser
Usage:
npx tsx browse.ts --url <url> [options]
Options:
-u, --url <url> URL to navigate to (required)
-s, --screenshot Take a screenshot of the page
-o, --output <path> Output path for screenshot (default: screenshot.png)
--headless <bool> Run in headless mode (default: true)
--wait <ms> Wait time after page load in milliseconds
--timeout <ms> Navigation timeout (default: 60000)
-i, --interactive Keep browser open for manual interaction
-h, --help Show this help message
Examples:
npx tsx browse.ts --url "https://example.com"
npx tsx browse.ts --url "https://example.com" --screenshot --output page.png
npx tsx browse.ts --url "https://example.com" --headless false --interactive
Environment Variables:
CLOAKBROWSER_PROFILE_PATH Custom profile directory (default: ~/.cloakbrowser-profile/)
CLOAKBROWSER_HEADLESS Default headless mode (true/false)
`);
process.exit(args.help ? 0 : 1);
}
try {
await browse({
url: args.url,
headless: args.headless,
screenshot: args.screenshot,
output: args.output,
wait: args.wait ? parseInt(args.wait, 10) : undefined,
timeout: args.timeout ? parseInt(args.timeout, 10) : undefined,
interactive: args.interactive,
});
} catch (error) {
console.error('Error:', error instanceof Error ? error.message : error);
process.exit(1);
}
}
const isMainModule = process.argv[1]?.includes('browse.ts');
if (isMainModule) {
main();
}
@@ -0,0 +1,40 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
function fail(message, details) {
const payload = { error: message };
if (details) payload.details = details;
process.stderr.write(`${JSON.stringify(payload)}\n`);
process.exit(1);
}
async function main() {
try {
await import("cloakbrowser");
await import("playwright-core");
} catch (error) {
fail(
"Missing dependency/config: web-automation requires cloakbrowser and playwright-core.",
error instanceof Error ? error.message : String(error)
);
}
const browsePath = path.join(__dirname, "browse.ts");
const browseSource = fs.readFileSync(browsePath, "utf8");
if (!/launchPersistentContext/.test(browseSource) || !/from ['"]cloakbrowser['"]/.test(browseSource)) {
fail("browse.ts is not configured for CloakBrowser.");
}
process.stdout.write("OK: cloakbrowser + playwright-core installed\n");
process.stdout.write("OK: CloakBrowser integration detected in browse.ts\n");
}
main().catch((error) => {
fail("Install check failed.", error instanceof Error ? error.message : String(error));
});
+188
View File
@@ -0,0 +1,188 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const DEFAULT_WAIT_MS = 5000;
const MAX_WAIT_MS = 20000;
const NAV_TIMEOUT_MS = 30000;
const EXTRA_CHALLENGE_WAIT_MS = 8000;
const CONTENT_LIMIT = 12000;
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
function fail(message, details) {
const payload = { error: message };
if (details) payload.details = details;
process.stderr.write(`${JSON.stringify(payload)}\n`);
process.exit(1);
}
function parseWaitTime(raw) {
const value = Number.parseInt(raw || `${DEFAULT_WAIT_MS}`, 10);
if (!Number.isFinite(value) || value < 0) return DEFAULT_WAIT_MS;
return Math.min(value, MAX_WAIT_MS);
}
function parseTarget(rawUrl) {
if (!rawUrl) {
fail("Missing URL. Usage: node extract.js <URL>");
}
let parsed;
try {
parsed = new URL(rawUrl);
} catch (error) {
fail("Invalid URL.", error.message);
}
if (!["http:", "https:"].includes(parsed.protocol)) {
fail("Only http and https URLs are allowed.");
}
return parsed.toString();
}
function ensureParentDir(filePath) {
if (!filePath) return;
fs.mkdirSync(path.dirname(filePath), { recursive: true });
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function detectChallenge(page) {
try {
return await page.evaluate(() => {
const text = (document.body?.innerText || "").toLowerCase();
return (
text.includes("checking your browser") ||
text.includes("just a moment") ||
text.includes("verify you are human") ||
text.includes("press and hold") ||
document.querySelector('iframe[src*="challenge"]') !== null ||
document.querySelector('iframe[src*="cloudflare"]') !== null
);
});
} catch {
return false;
}
}
async function loadCloakBrowser() {
try {
return await import("cloakbrowser");
} catch (error) {
fail(
"CloakBrowser is not installed for this skill. Run pnpm install in this skill's scripts directory first.",
error.message
);
}
}
async function runWithStderrLogs(fn) {
const originalLog = console.log;
const originalError = console.error;
console.log = (...args) => process.stderr.write(`${args.join(" ")}\n`);
console.error = (...args) => process.stderr.write(`${args.join(" ")}\n`);
try {
return await fn();
} finally {
console.log = originalLog;
console.error = originalError;
}
}
async function main() {
const requestedUrl = parseTarget(process.argv[2]);
const waitTime = parseWaitTime(process.env.WAIT_TIME);
const screenshotPath = process.env.SCREENSHOT_PATH || "";
const saveHtml = process.env.SAVE_HTML === "true";
const headless = process.env.HEADLESS !== "false";
const userAgent = process.env.USER_AGENT || undefined;
const startedAt = Date.now();
const { ensureBinary, launchContext } = await loadCloakBrowser();
let context;
try {
await runWithStderrLogs(() => ensureBinary());
context = await runWithStderrLogs(() => launchContext({
headless,
userAgent,
locale: "en-US",
viewport: { width: 1440, height: 900 },
humanize: true,
}));
const page = await context.newPage();
const response = await page.goto(requestedUrl, {
waitUntil: "domcontentloaded",
timeout: NAV_TIMEOUT_MS
});
await sleep(waitTime);
let challengeDetected = await detectChallenge(page);
if (challengeDetected) {
await sleep(EXTRA_CHALLENGE_WAIT_MS);
challengeDetected = await detectChallenge(page);
}
const extracted = await page.evaluate((contentLimit) => {
const bodyText = document.body?.innerText || "";
return {
finalUrl: window.location.href,
title: document.title || "",
content: bodyText.slice(0, contentLimit),
metaDescription:
document.querySelector('meta[name="description"]')?.content ||
document.querySelector('meta[property="og:description"]')?.content ||
""
};
}, CONTENT_LIMIT);
const result = {
requestedUrl,
finalUrl: extracted.finalUrl,
title: extracted.title,
content: extracted.content,
metaDescription: extracted.metaDescription,
status: response ? response.status() : null,
challengeDetected,
elapsedSeconds: ((Date.now() - startedAt) / 1000).toFixed(2)
};
if (screenshotPath) {
ensureParentDir(screenshotPath);
await page.screenshot({ path: screenshotPath, fullPage: false, timeout: 10000 });
result.screenshot = screenshotPath;
}
if (saveHtml) {
const htmlTarget = screenshotPath
? screenshotPath.replace(/\.[^.]+$/, ".html")
: path.resolve(__dirname, `page-${Date.now()}.html`);
ensureParentDir(htmlTarget);
fs.writeFileSync(htmlTarget, await page.content());
result.htmlFile = htmlTarget;
}
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
await context.close();
} catch (error) {
if (context) {
try {
await context.close();
} catch {
// Ignore close errors after the primary failure.
}
}
fail("Scrape failed.", error.message);
}
}
main();
@@ -0,0 +1,329 @@
#!/usr/bin/env npx tsx
import parseArgs from 'minimist';
import type { Page } from 'playwright-core';
import { launchBrowser } from './browse';
type Step =
| { action: 'goto'; url: string }
| { action: 'click'; selector?: string; text?: string; role?: string; name?: string }
| { action: 'type'; selector?: string; text: string }
| { action: 'press'; key: string; selector?: string }
| { action: 'wait'; ms: number }
| { action: 'screenshot'; path: string }
| { action: 'extract'; selector: string; count?: number };
function normalizeNavigationUrl(rawUrl: string): string {
let parsed: URL;
try {
parsed = new URL(rawUrl);
} catch {
throw new Error(`Invalid navigation URL: ${rawUrl}`);
}
if (!['http:', 'https:'].includes(parsed.protocol)) {
throw new Error(`Only http and https URLs are allowed in flow steps: ${rawUrl}`);
}
return parsed.toString();
}
function normalizeKey(k: string): string {
if (!k) return 'Enter';
const lower = k.toLowerCase();
if (lower === 'enter' || lower === 'return') return 'Enter';
if (lower === 'tab') return 'Tab';
if (lower === 'escape' || lower === 'esc') return 'Escape';
return k;
}
function splitInstructions(instruction: string): string[] {
return instruction
.split(/\bthen\b|;/gi)
.map((s) => s.trim())
.filter(Boolean);
}
function parseInstruction(instruction: string): Step[] {
const parts = splitInstructions(instruction);
const steps: Step[] = [];
for (const p of parts) {
// go to https://...
const goto = p.match(/^(?:go to|open|navigate to)\s+(https?:\/\/\S+)/i);
if (goto) {
steps.push({ action: 'goto', url: normalizeNavigationUrl(goto[1]) });
continue;
}
// click on "text" or click #selector or click button "name"
const clickRole = p.match(/^click\s+(button|link|textbox|img|image|tab)\s+"([^"]+)"$/i);
if (clickRole) {
const role = clickRole[1].toLowerCase() === 'image' ? 'img' : clickRole[1].toLowerCase();
steps.push({ action: 'click', role, name: clickRole[2] });
continue;
}
const clickText = p.match(/^click(?: on)?\s+"([^"]+)"/i);
if (clickText) {
steps.push({ action: 'click', text: clickText[1] });
continue;
}
const clickSelector = p.match(/^click(?: on)?\s+(#[\w-]+|\.[\w-]+|[a-z]+\[[^\]]+\])/i);
if (clickSelector) {
steps.push({ action: 'click', selector: clickSelector[1] });
continue;
}
// type "text" [in selector]
const typeInto = p.match(/^type\s+"([^"]+)"\s+in\s+(.+)$/i);
if (typeInto) {
steps.push({ action: 'type', text: typeInto[1], selector: typeInto[2].trim() });
continue;
}
const typeOnly = p.match(/^type\s+"([^"]+)"$/i);
if (typeOnly) {
steps.push({ action: 'type', text: typeOnly[1] });
continue;
}
// press enter [in selector]
const pressIn = p.match(/^press\s+(\w+)\s+in\s+(.+)$/i);
if (pressIn) {
steps.push({ action: 'press', key: normalizeKey(pressIn[1]), selector: pressIn[2].trim() });
continue;
}
const pressOnly = p.match(/^press\s+(\w+)$/i);
if (pressOnly) {
steps.push({ action: 'press', key: normalizeKey(pressOnly[1]) });
continue;
}
// wait 2s / wait 500ms
const waitS = p.match(/^wait\s+(\d+)\s*s(?:ec(?:onds?)?)?$/i);
if (waitS) {
steps.push({ action: 'wait', ms: parseInt(waitS[1], 10) * 1000 });
continue;
}
const waitMs = p.match(/^wait\s+(\d+)\s*ms$/i);
if (waitMs) {
steps.push({ action: 'wait', ms: parseInt(waitMs[1], 10) });
continue;
}
// screenshot path
const shot = p.match(/^screenshot(?: to)?\s+(.+)$/i);
if (shot) {
steps.push({ action: 'screenshot', path: shot[1].trim() });
continue;
}
throw new Error(`Could not parse step: "${p}"`);
}
return steps;
}
function validateSteps(steps: Step[]): Step[] {
return steps.map((step) =>
step.action === 'goto'
? {
...step,
url: normalizeNavigationUrl(step.url),
}
: step
);
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function isLikelyLoginText(text: string): boolean {
return /(login|accedi|sign\s*in|entra)/i.test(text);
}
async function clickByText(page: Page, text: string): Promise<boolean> {
const patterns = [new RegExp(`^${escapeRegExp(text)}$`, 'i'), new RegExp(escapeRegExp(text), 'i')];
for (const pattern of patterns) {
const targets = [
page.getByRole('button', { name: pattern }).first(),
page.getByRole('link', { name: pattern }).first(),
page.getByText(pattern).first(),
];
for (const target of targets) {
if (await target.count()) {
try {
await target.click({ timeout: 8000 });
return true;
} catch {
// keep trying next candidate
}
}
}
}
return false;
}
async function fallbackLoginNavigation(page: Page, requestedText: string): Promise<boolean> {
if (!isLikelyLoginText(requestedText)) return false;
const current = new URL(page.url());
const candidateLinks = await page.evaluate(() => {
const loginTerms = ['login', 'accedi', 'sign in', 'entra'];
const anchors = Array.from(document.querySelectorAll('a[href], a[onclick], button[onclick]')) as Array<HTMLAnchorElement | HTMLButtonElement>;
return anchors
.map((el) => {
const text = (el.textContent || '').trim().toLowerCase();
const href = (el as HTMLAnchorElement).getAttribute('href') || '';
return { text, href };
})
.filter((x) => x.text && loginTerms.some((t) => x.text.includes(t)))
.map((x) => x.href)
.filter(Boolean);
});
// Prefer real URLs (not javascript:)
const realCandidate = candidateLinks.find((h) => /login|account\/login/i.test(h) && !h.startsWith('javascript:'));
if (realCandidate) {
const target = new URL(realCandidate, page.url()).toString();
await page.goto(target, { waitUntil: 'domcontentloaded', timeout: 60000 });
return true;
}
// Site-specific fallback for Corriere
if (/corriere\.it$/i.test(current.hostname) || /\.corriere\.it$/i.test(current.hostname)) {
await page.goto('https://www.corriere.it/account/login', {
waitUntil: 'domcontentloaded',
timeout: 60000,
});
return true;
}
return false;
}
async function typeInBestTarget(page: Page, text: string, selector?: string) {
if (selector) {
await page.locator(selector).first().click({ timeout: 10000 });
await page.locator(selector).first().fill(text);
return;
}
const loc = page.locator('input[name="q"], input[type="search"], input[type="text"], textarea').first();
await loc.click({ timeout: 10000 });
await loc.fill(text);
}
async function pressOnTarget(page: Page, key: string, selector?: string) {
if (selector) {
await page.locator(selector).first().press(key);
return;
}
await page.keyboard.press(key);
}
async function runSteps(page: Page, steps: Step[]) {
for (const step of steps) {
switch (step.action) {
case 'goto':
await page.goto(normalizeNavigationUrl(step.url), {
waitUntil: 'domcontentloaded',
timeout: 60000,
});
break;
case 'click':
if (step.selector) {
await page.locator(step.selector).first().click({ timeout: 15000 });
} else if (step.role && step.name) {
await page.getByRole(step.role as any, { name: new RegExp(escapeRegExp(step.name), 'i') }).first().click({ timeout: 15000 });
} else if (step.text) {
const clicked = await clickByText(page, step.text);
if (!clicked) {
const recovered = await fallbackLoginNavigation(page, step.text);
if (!recovered) {
throw new Error(`Could not click target text: ${step.text}`);
}
}
} else {
throw new Error('click step missing selector/text/role');
}
try {
await page.waitForLoadState('domcontentloaded', { timeout: 10000 });
} catch {
// no navigation is fine
}
break;
case 'type':
await typeInBestTarget(page, step.text, step.selector);
break;
case 'press':
await pressOnTarget(page, step.key, step.selector);
break;
case 'wait':
await page.waitForTimeout(step.ms);
break;
case 'screenshot':
await page.screenshot({ path: step.path, fullPage: true });
break;
case 'extract': {
const items = await page.locator(step.selector).allTextContents();
const out = items.slice(0, step.count ?? items.length).map((t) => t.trim()).filter(Boolean);
console.log(JSON.stringify(out, null, 2));
break;
}
default:
throw new Error('Unknown step');
}
}
}
async function main() {
const args = parseArgs(process.argv.slice(2), {
string: ['instruction', 'steps'],
boolean: ['headless', 'help'],
default: { headless: true },
alias: { i: 'instruction', s: 'steps', h: 'help' },
});
if (args.help || (!args.instruction && !args.steps)) {
console.log(`
General Web Flow Runner (CloakBrowser)
Usage:
npx tsx flow.ts --instruction "go to https://example.com then type \"hello\" then press enter"
npx tsx flow.ts --steps '[{"action":"goto","url":"https://example.com"}]'
Supported natural steps:
- go to/open/navigate to <url>
- click on "Text"
- click <css-selector>
- type "text"
- type "text" in <css-selector>
- press <key>
- press <key> in <css-selector>
- wait <N>s | wait <N>ms
- screenshot <path>
`);
process.exit(args.help ? 0 : 1);
}
const steps = validateSteps(args.steps ? JSON.parse(args.steps) : parseInstruction(args.instruction));
const browser = await launchBrowser({ headless: args.headless });
const page = await browser.newPage();
try {
await runSteps(page, steps);
console.log('Flow complete. Final URL:', page.url());
} finally {
await browser.close();
}
}
main().catch((e) => {
console.error('Error:', e instanceof Error ? e.message : e);
process.exit(1);
});
@@ -0,0 +1,36 @@
{
"name": "web-automation-scripts",
"version": "1.0.0",
"description": "Web browsing and scraping scripts using CloakBrowser",
"type": "module",
"scripts": {
"check-install": "node check-install.js",
"extract": "node extract.js",
"browse": "tsx browse.ts",
"auth": "tsx auth.ts",
"flow": "tsx flow.ts",
"scrape": "tsx scrape.ts",
"typecheck": "tsc --noEmit -p tsconfig.json",
"lint": "pnpm run typecheck && node --check check-install.js && node --check extract.js",
"fetch-browser": "npx cloakbrowser install"
},
"dependencies": {
"@mozilla/readability": "^0.5.0",
"better-sqlite3": "^12.6.2",
"cloakbrowser": "^0.3.22",
"jsdom": "^24.0.0",
"minimist": "^1.2.8",
"playwright-core": "^1.59.1",
"turndown": "^7.1.2",
"turndown-plugin-gfm": "^1.0.2"
},
"devDependencies": {
"@types/jsdom": "^21.1.6",
"@types/minimist": "^1.2.5",
"@types/turndown": "^5.0.4",
"esbuild": "0.27.0",
"tsx": "^4.7.0",
"typescript": "^5.3.0"
},
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34"
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,174 @@
#!/usr/bin/env npx tsx
import { mkdirSync, writeFileSync } from 'fs';
import { dirname, resolve } from 'path';
import { getPage } from './browse.js';
type NavResult = {
requestedUrl: string;
url: string;
status: number | null;
title: string;
error?: string;
};
type RouteCheck = {
route: string;
result: NavResult;
heading: string | null;
};
const DEFAULT_BASE_URL = 'http://localhost:3000';
const DEFAULT_REPORT_PATH = resolve(process.cwd(), 'scan-local-app.md');
function env(name: string): string | undefined {
const value = process.env[name]?.trim();
return value ? value : undefined;
}
function getRoutes(baseUrl: string): string[] {
const routeList = env('SCAN_ROUTES');
if (routeList) {
return routeList
.split(',')
.map((route) => route.trim())
.filter(Boolean)
.map((route) => new URL(route, baseUrl).toString());
}
return [baseUrl];
}
async function gotoWithStatus(page: any, url: string): Promise<NavResult> {
const response = await page
.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 })
.catch((error: unknown) => ({ error }));
if (response?.error) {
return {
requestedUrl: url,
url: page.url(),
status: null,
title: await page.title().catch(() => ''),
error: String(response.error),
};
}
return {
requestedUrl: url,
url: page.url(),
status: response ? response.status() : null,
title: await page.title().catch(() => ''),
};
}
async function textOrNull(page: any, selector: string): Promise<string | null> {
const locator = page.locator(selector).first();
try {
if ((await locator.count()) === 0) return null;
const value = await locator.textContent();
return value ? value.trim().replace(/\s+/g, ' ') : null;
} catch {
return null;
}
}
async function loginIfConfigured(page: any, baseUrl: string, lines: string[]) {
const loginPath = env('SCAN_LOGIN_PATH');
const username = env('SCAN_USERNAME') ?? env('CLOAKBROWSER_USERNAME');
const password = env('SCAN_PASSWORD') ?? env('CLOAKBROWSER_PASSWORD');
const usernameSelector = env('SCAN_USERNAME_SELECTOR') ?? 'input[type="email"], input[name="email"]';
const passwordSelector = env('SCAN_PASSWORD_SELECTOR') ?? 'input[type="password"], input[name="password"]';
const submitSelector = env('SCAN_SUBMIT_SELECTOR') ?? 'button[type="submit"], input[type="submit"]';
if (!loginPath) {
lines.push('## Login');
lines.push('- Skipped: set `SCAN_LOGIN_PATH` to enable login smoke checks.');
lines.push('');
return;
}
const loginUrl = new URL(loginPath, baseUrl).toString();
lines.push('## Login');
lines.push(`- Login URL: ${loginUrl}`);
await gotoWithStatus(page, loginUrl);
if (!username || !password) {
lines.push('- Skipped: set `SCAN_USERNAME`/`SCAN_PASSWORD` or `CLOAKBROWSER_USERNAME`/`CLOAKBROWSER_PASSWORD`.');
lines.push('');
return;
}
await page.locator(usernameSelector).first().fill(username);
await page.locator(passwordSelector).first().fill(password);
await page.locator(submitSelector).first().click();
await page.waitForTimeout(2500);
lines.push(`- After submit URL: ${page.url()}`);
lines.push(`- Cookie count: ${(await page.context().cookies()).length}`);
lines.push('');
}
async function checkRoutes(page: any, baseUrl: string, lines: string[]) {
const routes = getRoutes(baseUrl);
const routeChecks: RouteCheck[] = [];
for (const url of routes) {
const result = await gotoWithStatus(page, url);
const heading = await textOrNull(page, 'h1');
routeChecks.push({
route: url,
result,
heading,
});
}
lines.push('## Route Checks');
for (const check of routeChecks) {
const relativeUrl = check.route.startsWith(baseUrl) ? check.route.slice(baseUrl.length) || '/' : check.route;
const finalPath = check.result.url.startsWith(baseUrl)
? check.result.url.slice(baseUrl.length) || '/'
: check.result.url;
const suffix = check.heading ? `, h1="${check.heading}"` : '';
const errorSuffix = check.result.error ? `, error="${check.result.error}"` : '';
lines.push(
`- ${relativeUrl} → status ${check.result.status ?? 'ERR'} (final ${finalPath})${suffix}${errorSuffix}`
);
}
lines.push('');
}
async function main() {
const baseUrl = env('SCAN_BASE_URL') ?? DEFAULT_BASE_URL;
const reportPath = resolve(env('SCAN_REPORT_PATH') ?? DEFAULT_REPORT_PATH);
const headless = (env('SCAN_HEADLESS') ?? env('CLOAKBROWSER_HEADLESS') ?? 'true') === 'true';
const { page, browser } = await getPage({ headless });
const lines: string[] = [];
lines.push('# Web Automation Scan (local)');
lines.push('');
lines.push(`- Base URL: ${baseUrl}`);
lines.push(`- Timestamp: ${new Date().toISOString()}`);
lines.push(`- Headless: ${headless}`);
lines.push(`- Report Path: ${reportPath}`);
lines.push('');
try {
await loginIfConfigured(page, baseUrl, lines);
await checkRoutes(page, baseUrl, lines);
lines.push('## Notes');
lines.push('- This generic smoke helper records route availability and top-level headings for a local app.');
lines.push('- Configure login and route coverage with `SCAN_*` environment variables.');
} finally {
await browser.close();
}
mkdirSync(dirname(reportPath), { recursive: true });
writeFileSync(reportPath, `${lines.join('\n')}\n`, 'utf-8');
console.log(`Report written to ${reportPath}`);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
@@ -0,0 +1,351 @@
#!/usr/bin/env npx tsx
/**
* Web scraper that extracts content to markdown
*
* Usage:
* npx tsx scrape.ts --url "https://example.com" --mode main
* npx tsx scrape.ts --url "https://example.com" --mode full --output page.md
* npx tsx scrape.ts --url "https://example.com" --mode selector --selector ".content"
*/
import TurndownService from 'turndown';
import * as turndownPluginGfm from 'turndown-plugin-gfm';
import { Readability } from '@mozilla/readability';
import { JSDOM } from 'jsdom';
import { writeFileSync } from 'fs';
import parseArgs from 'minimist';
import { getPage } from './browse.js';
// Types
type ScrapeMode = 'main' | 'full' | 'selector';
interface ScrapeOptions {
url: string;
mode: ScrapeMode;
selector?: string;
output?: string;
includeLinks?: boolean;
includeTables?: boolean;
includeImages?: boolean;
headless?: boolean;
wait?: number;
}
interface ScrapeResult {
title: string;
url: string;
markdown: string;
byline?: string;
excerpt?: string;
}
// Configure Turndown for markdown conversion
function createTurndownService(options: {
includeLinks?: boolean;
includeTables?: boolean;
includeImages?: boolean;
}): TurndownService {
const turndown = new TurndownService({
headingStyle: 'atx',
hr: '---',
bulletListMarker: '-',
codeBlockStyle: 'fenced',
fence: '```',
emDelimiter: '*',
strongDelimiter: '**',
linkStyle: 'inlined',
});
// Add GFM support (tables, strikethrough, task lists)
turndown.use(turndownPluginGfm.gfm);
// Custom rule for code blocks with language detection
turndown.addRule('codeBlockWithLanguage', {
filter: (node) => {
return (
node.nodeName === 'PRE' &&
node.firstChild?.nodeName === 'CODE'
);
},
replacement: (_content, node) => {
const codeNode = node.firstChild as HTMLElement;
const className = codeNode.getAttribute('class') || '';
const langMatch = className.match(/language-(\w+)/);
const lang = langMatch ? langMatch[1] : '';
const code = codeNode.textContent || '';
return `\n\n\`\`\`${lang}\n${code}\n\`\`\`\n\n`;
},
});
// Remove images if not included
if (!options.includeImages) {
turndown.addRule('removeImages', {
filter: 'img',
replacement: () => '',
});
}
// Remove links but keep text if not included
if (!options.includeLinks) {
turndown.addRule('removeLinks', {
filter: 'a',
replacement: (content) => content,
});
}
// Remove script, style, nav, footer, aside elements
turndown.remove(['script', 'style', 'nav', 'footer', 'aside', 'noscript']);
return turndown;
}
// Extract main content using Readability
function extractMainContent(html: string, url: string): {
content: string;
title: string;
byline?: string;
excerpt?: string;
} {
const dom = new JSDOM(html, { url });
const reader = new Readability(dom.window.document);
const article = reader.parse();
if (!article) {
throw new Error('Could not extract main content from page');
}
return {
content: article.content,
title: article.title,
byline: article.byline || undefined,
excerpt: article.excerpt || undefined,
};
}
// Scrape a URL and return markdown
export async function scrape(options: ScrapeOptions): Promise<ScrapeResult> {
const { page, browser } = await getPage({ headless: options.headless ?? true });
try {
// Navigate to URL
console.log(`Navigating to: ${options.url}`);
await page.goto(options.url, {
timeout: 60000,
waitUntil: 'domcontentloaded',
});
// Wait if specified
if (options.wait) {
console.log(`Waiting ${options.wait}ms for dynamic content...`);
await page.waitForTimeout(options.wait);
}
const pageTitle = await page.title();
const pageUrl = page.url();
let html: string;
let title = pageTitle;
let byline: string | undefined;
let excerpt: string | undefined;
// Get HTML based on mode
switch (options.mode) {
case 'main': {
// Get full page HTML and extract with Readability
const fullHtml = await page.content();
const extracted = extractMainContent(fullHtml, pageUrl);
html = extracted.content;
title = extracted.title || pageTitle;
byline = extracted.byline;
excerpt = extracted.excerpt;
break;
}
case 'selector': {
if (!options.selector) {
throw new Error('Selector mode requires --selector option');
}
const element = await page.$(options.selector);
if (!element) {
throw new Error(`Selector not found: ${options.selector}`);
}
html = await element.innerHTML();
break;
}
case 'full':
default: {
// Get body content, excluding common non-content elements
html = await page.evaluate(() => {
// Remove common non-content elements
const selectorsToRemove = [
'script', 'style', 'noscript', 'iframe',
'nav', 'header', 'footer', '.cookie-banner',
'.advertisement', '.ads', '#ads', '.social-share',
'.comments', '#comments', '.sidebar'
];
selectorsToRemove.forEach(selector => {
document.querySelectorAll(selector).forEach(el => el.remove());
});
return document.body.innerHTML;
});
break;
}
}
// Convert to markdown
const turndown = createTurndownService({
includeLinks: options.includeLinks ?? true,
includeTables: options.includeTables ?? true,
includeImages: options.includeImages ?? false,
});
let markdown = turndown.turndown(html);
// Add title as H1 if not already present
if (!markdown.startsWith('# ')) {
markdown = `# ${title}\n\n${markdown}`;
}
// Add metadata header
const metadataLines = [
`<!-- Scraped from: ${pageUrl} -->`,
byline ? `<!-- Author: ${byline} -->` : null,
excerpt ? `<!-- Excerpt: ${excerpt} -->` : null,
`<!-- Scraped at: ${new Date().toISOString()} -->`,
'',
].filter(Boolean);
markdown = metadataLines.join('\n') + '\n' + markdown;
// Clean up excessive whitespace
markdown = markdown
.replace(/\n{4,}/g, '\n\n\n')
.replace(/[ \t]+$/gm, '')
.trim();
const result: ScrapeResult = {
title,
url: pageUrl,
markdown,
byline,
excerpt,
};
// Save to file if output specified
if (options.output) {
writeFileSync(options.output, markdown, 'utf-8');
console.log(`Markdown saved to: ${options.output}`);
}
return result;
} finally {
await browser.close();
}
}
// CLI entry point
async function main() {
const args = parseArgs(process.argv.slice(2), {
string: ['url', 'mode', 'selector', 'output'],
boolean: ['headless', 'links', 'tables', 'images', 'help'],
default: {
mode: 'main',
headless: true,
links: true,
tables: true,
images: false,
},
alias: {
u: 'url',
m: 'mode',
s: 'selector',
o: 'output',
h: 'help',
},
});
if (args.help || !args.url) {
console.log(`
Web Scraper - Extract content to Markdown
Usage:
npx tsx scrape.ts --url <url> [options]
Options:
-u, --url <url> URL to scrape (required)
-m, --mode <mode> Scrape mode: main, full, or selector (default: main)
-s, --selector <sel> CSS selector for selector mode
-o, --output <path> Output file path for markdown
--headless <bool> Run in headless mode (default: true)
--wait <ms> Wait time for dynamic content
--links Include links in output (default: true)
--tables Include tables in output (default: true)
--images Include images in output (default: false)
-h, --help Show this help message
Scrape Modes:
main Extract main article content using Readability (best for articles)
full Full page content with common elements removed
selector Extract specific element by CSS selector
Examples:
npx tsx scrape.ts --url "https://docs.example.com/guide" --mode main
npx tsx scrape.ts --url "https://example.com" --mode full --output page.md
npx tsx scrape.ts --url "https://example.com" --mode selector --selector ".api-docs"
npx tsx scrape.ts --url "https://example.com" --mode main --no-links --output clean.md
Output Format:
- GitHub Flavored Markdown (tables, strikethrough, task lists)
- Proper heading hierarchy
- Code blocks with language detection
- Metadata comments at top (source URL, date)
`);
process.exit(args.help ? 0 : 1);
}
const mode = args.mode as ScrapeMode;
if (!['main', 'full', 'selector'].includes(mode)) {
console.error(`Invalid mode: ${mode}. Must be main, full, or selector.`);
process.exit(1);
}
try {
const result = await scrape({
url: args.url,
mode,
selector: args.selector,
output: args.output,
includeLinks: args.links,
includeTables: args.tables,
includeImages: args.images,
headless: args.headless,
wait: args.wait ? parseInt(args.wait, 10) : undefined,
});
// Print result summary
console.log(`\nScrape complete:`);
console.log(` Title: ${result.title}`);
console.log(` URL: ${result.url}`);
if (result.byline) console.log(` Author: ${result.byline}`);
console.log(` Markdown length: ${result.markdown.length} chars`);
// Print markdown if not saved to file
if (!args.output) {
console.log('\n--- Markdown Output ---\n');
console.log(result.markdown);
}
} catch (error) {
console.error('Error:', error instanceof Error ? error.message : error);
process.exit(1);
}
}
// Run if executed directly
const isMainModule = process.argv[1]?.includes('scrape.ts');
if (isMainModule) {
main();
}
@@ -0,0 +1,36 @@
import { launchPersistentContext } from 'cloakbrowser';
import { homedir } from 'os';
import { join } from 'path';
import { mkdirSync, existsSync } from 'fs';
async function test() {
const profilePath = join(homedir(), '.cloakbrowser-profile');
if (!existsSync(profilePath)) {
mkdirSync(profilePath, { recursive: true });
}
console.log('Profile path:', profilePath);
console.log('Launching CloakBrowser with full options...');
const browser = await launchPersistentContext({
headless: true,
userDataDir: profilePath,
humanize: true,
});
console.log('Browser launched');
const page = browser.pages()[0] || await browser.newPage();
console.log('Page created');
await page.goto('https://github.com', { timeout: 30000 });
console.log('Navigated to:', page.url());
console.log('Title:', await page.title());
await page.screenshot({ path: '/tmp/github-test.png' });
console.log('Screenshot saved');
await browser.close();
console.log('Done');
}
test().catch(console.error);
@@ -0,0 +1,23 @@
import { launch } from 'cloakbrowser';
async function test() {
console.log('Launching CloakBrowser with minimal config...');
const browser = await launch({
headless: true,
humanize: true,
});
console.log('Browser launched');
const page = await browser.newPage();
console.log('Page created');
await page.goto('https://example.com', { timeout: 30000 });
console.log('Navigated to:', page.url());
console.log('Title:', await page.title());
await browser.close();
console.log('Done');
}
test().catch(console.error);
@@ -0,0 +1,33 @@
import { launchPersistentContext } from 'cloakbrowser';
import { homedir } from 'os';
import { join } from 'path';
import { mkdirSync, existsSync } from 'fs';
async function test() {
const profilePath = join(homedir(), '.cloakbrowser-profile');
if (!existsSync(profilePath)) {
mkdirSync(profilePath, { recursive: true });
}
console.log('Profile path:', profilePath);
console.log('Launching with persistent userDataDir...');
const browser = await launchPersistentContext({
headless: true,
userDataDir: profilePath,
humanize: true,
});
console.log('Browser launched');
const page = browser.pages()[0] || await browser.newPage();
console.log('Page created');
await page.goto('https://example.com', { timeout: 30000 });
console.log('Navigated to:', page.url());
console.log('Title:', await page.title());
await browser.close();
console.log('Done');
}
test().catch(console.error);
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"outDir": "./dist",
"rootDir": "."
},
"include": ["*.ts"],
"exclude": ["node_modules", "dist"]
}
@@ -0,0 +1,8 @@
declare module 'turndown-plugin-gfm' {
import TurndownService from 'turndown';
export function gfm(turndownService: TurndownService): void;
export function strikethrough(turndownService: TurndownService): void;
export function tables(turndownService: TurndownService): void;
export function taskListItems(turndownService: TurndownService): void;
}
@@ -5,6 +5,7 @@ ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
SOURCE_DIR="${ROOT_DIR}/codex/scripts"
TARGETS=(
"claude-code"
"cursor"
"opencode"
"pi"
)