Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 193cd45db8 | |||
| d62899308a | |||
| 8ea6d08e77 | |||
| 3966b77623 | |||
| 494e29f797 | |||
| f01721a45b | |||
| 231a66f2b1 | |||
| ce4746b769 |
@@ -18,11 +18,17 @@ ai-coding-skills/
|
|||||||
├── README.md
|
├── README.md
|
||||||
├── docs/
|
├── docs/
|
||||||
│ ├── README.md
|
│ ├── README.md
|
||||||
|
│ ├── INSTALLER.md
|
||||||
|
│ ├── CODEX.md
|
||||||
|
│ ├── CLAUDE-CODE.md
|
||||||
|
│ ├── CURSOR.md
|
||||||
|
│ ├── OPENCODE.md
|
||||||
│ ├── ATLASSIAN.md
|
│ ├── ATLASSIAN.md
|
||||||
│ ├── CREATE-PLAN.md
|
│ ├── CREATE-PLAN.md
|
||||||
│ ├── DO-TASK.md
|
│ ├── DO-TASK.md
|
||||||
│ ├── IMPLEMENT-PLAN.md
|
│ ├── IMPLEMENT-PLAN.md
|
||||||
│ ├── PI.md
|
│ ├── PI.md
|
||||||
|
│ ├── PI-COMMON-REVIEWER.md
|
||||||
│ ├── PI-RESEARCH.md
|
│ ├── PI-RESEARCH.md
|
||||||
│ ├── PI-SUPERPOWERS.md
|
│ ├── PI-SUPERPOWERS.md
|
||||||
│ ├── TELEGRAM-NOTIFICATIONS.md
|
│ ├── TELEGRAM-NOTIFICATIONS.md
|
||||||
@@ -61,6 +67,7 @@ ai-coding-skills/
|
|||||||
│ └── web-automation/
|
│ └── web-automation/
|
||||||
│ ├── codex/
|
│ ├── codex/
|
||||||
│ ├── claude-code/
|
│ ├── claude-code/
|
||||||
|
│ ├── cursor/
|
||||||
│ ├── opencode/
|
│ ├── opencode/
|
||||||
│ └── pi/
|
│ └── pi/
|
||||||
├── .codex/
|
├── .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) |
|
| 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 | 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 | 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 | 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) |
|
| 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`
|
- Start with the docs index: `docs/README.md`
|
||||||
- Atlassian guide: `docs/ATLASSIAN.md`
|
- Automated install/update/remove wizard: `docs/INSTALLER.md`
|
||||||
- Create-plan guide: `docs/CREATE-PLAN.md`
|
- Manual install by client: `docs/CODEX.md`, `docs/CLAUDE-CODE.md`, `docs/CURSOR.md`, `docs/OPENCODE.md`, `docs/PI.md`
|
||||||
- Do-task guide: `docs/DO-TASK.md`
|
- Skill guides: `docs/ATLASSIAN.md`, `docs/CREATE-PLAN.md`, `docs/DO-TASK.md`, `docs/IMPLEMENT-PLAN.md`, `docs/WEB-AUTOMATION.md`
|
||||||
- Implement-plan guide: `docs/IMPLEMENT-PLAN.md`
|
|
||||||
- Web-automation guide: `docs/WEB-AUTOMATION.md`
|
|
||||||
|
|
||||||
## Compatibility Policy
|
## Compatibility Policy
|
||||||
|
|
||||||
Each skill should explicitly document agent compatibility and any prerequisites directly in its own `SKILL.md`.
|
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
|
## Pi Package
|
||||||
|
|
||||||
The repo root now includes a pi package manifest that ships only the pi-specific surface:
|
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
|
- `pi-package/skills/*/` for the five packaged Pi skills
|
||||||
- `skills/reviewer-runtime/pi/`
|
- `skills/reviewer-runtime/pi/`
|
||||||
- `docs/PI*.md`
|
- `docs/PI*.md`
|
||||||
|
- `scripts/manage-skills.mjs` and `scripts/manage-skills.sh`
|
||||||
- `scripts/sync-pi-package-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`
|
||||||
|
|
||||||
Install it from a cloned checkout with the repo-owned one-liner:
|
Install it from a cloned checkout with the repo-owned one-liner:
|
||||||
|
|
||||||
|
|||||||
+23
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Purpose
|
## 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
|
## Why This Skill Exists
|
||||||
|
|
||||||
@@ -117,6 +117,28 @@ cd ~/.cursor/skills/atlassian/scripts
|
|||||||
pnpm install
|
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
|
## Verify Installation
|
||||||
|
|
||||||
Run in the installed `scripts/` folder:
|
Run in the installed `scripts/` folder:
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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
@@ -14,10 +14,11 @@ Create structured implementation plans with milestone and story tracking, and op
|
|||||||
- `~/.agents/skills/superpowers -> ~/.codex/superpowers/skills`
|
- `~/.agents/skills/superpowers -> ~/.codex/superpowers/skills`
|
||||||
- For Cursor, skills must be installed under `.cursor/skills/` (repo-local) or `~/.cursor/skills/` (global)
|
- 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:
|
- Shared reviewer runtime must be installed beside agent skills when using reviewer CLIs:
|
||||||
- Codex: `~/.codex/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`
|
- Claude Code: `~/.claude/skills/reviewer-runtime/{run-review.sh,notify-telegram.sh}`
|
||||||
- OpenCode: `~/.config/opencode/skills/reviewer-runtime/run-review.sh`
|
- OpenCode: `~/.config/opencode/skills/reviewer-runtime/{run-review.sh,notify-telegram.sh}`
|
||||||
- Cursor: `.cursor/skills/reviewer-runtime/run-review.sh` or `~/.cursor/skills/reviewer-runtime/run-review.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)
|
- Telegram notification setup is documented in [TELEGRAM-NOTIFICATIONS.md](./TELEGRAM-NOTIFICATIONS.md)
|
||||||
|
|
||||||
If dependencies are missing, stop and return:
|
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` |
|
| `codex` | `npm install -g @openai/codex` | `codex --version` |
|
||||||
| `claude` | `npm install -g @anthropic-ai/claude-code` | `claude --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) |
|
| `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.
|
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/
|
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
|
## Verify Installation
|
||||||
|
|
||||||
```bash
|
```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 ~/.claude/skills/create-plan/SKILL.md || true
|
||||||
test -f ~/.config/opencode/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 .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 ~/.codex/skills/reviewer-runtime/run-review.sh || true
|
||||||
test -x ~/.claude/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 ~/.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 .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:
|
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`
|
- 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/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`
|
- 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
|
## 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:
|
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
|
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
|
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`
|
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` |
|
| `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` |
|
| `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` |
|
| `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
|
1. write the reviewer command to a bash script
|
||||||
2. run that script through `reviewer-runtime/run-review.sh`
|
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
|
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
|
## Notifications
|
||||||
|
|
||||||
- Telegram is the only supported notification path.
|
- Telegram is the only supported notification path.
|
||||||
|
|||||||
+103
@@ -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 .
|
||||||
|
```
|
||||||
@@ -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}`
|
- Claude Code: `~/.claude/skills/reviewer-runtime/{run-review.sh,notify-telegram.sh}`
|
||||||
- OpenCode: `~/.config/opencode/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)
|
- 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:
|
- Variant-specific prerequisites:
|
||||||
- **Claude Code:** `claude --version`, explicit `Skill`-tool invocation of sub-skills.
|
- **Claude Code:** `claude --version`, explicit `Skill`-tool invocation of sub-skills.
|
||||||
- **Codex:** `codex --version`; `~/.agents/skills/superpowers -> ~/.codex/superpowers/skills` symlink present.
|
- **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.`
|
- **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.`
|
- **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.`
|
- **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
|
### 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) |
|
| `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>`) |
|
| `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) |
|
| `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.
|
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/
|
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
|
## 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.
|
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
|
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
|
## Key Behavior
|
||||||
|
|
||||||
- Creates one persistent plan artifact at `ai_plan/YYYY-MM-DD-<slug>/task-plan.md`.
|
- 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>` |
|
| `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>` |
|
| `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>` |
|
| `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:
|
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`.
|
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.
|
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
|
## Notifications
|
||||||
|
|
||||||
- Telegram is the only supported notification path.
|
- Telegram is the only supported notification path.
|
||||||
|
|||||||
+51
-7
@@ -21,10 +21,11 @@ Execute an existing plan (created by `create-plan`) in an isolated git worktree,
|
|||||||
- `~/.agents/skills/superpowers -> ~/.codex/superpowers/skills`
|
- `~/.agents/skills/superpowers -> ~/.codex/superpowers/skills`
|
||||||
- For Cursor, skills must be installed under `.cursor/skills/` (repo-local) or `~/.cursor/skills/` (global)
|
- 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:
|
- Shared reviewer runtime must be installed beside agent skills when using reviewer CLIs:
|
||||||
- Codex: `~/.codex/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`
|
- Claude Code: `~/.claude/skills/reviewer-runtime/{run-review.sh,notify-telegram.sh}`
|
||||||
- OpenCode: `~/.config/opencode/skills/reviewer-runtime/run-review.sh`
|
- OpenCode: `~/.config/opencode/skills/reviewer-runtime/{run-review.sh,notify-telegram.sh}`
|
||||||
- Cursor: `.cursor/skills/reviewer-runtime/run-review.sh` or `~/.cursor/skills/reviewer-runtime/run-review.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)
|
- Telegram notification setup is documented in [TELEGRAM-NOTIFICATIONS.md](./TELEGRAM-NOTIFICATIONS.md)
|
||||||
|
|
||||||
If dependencies are missing, stop and return:
|
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` |
|
| `codex` | `npm install -g @openai/codex` | `codex --version` |
|
||||||
| `claude` | `npm install -g @anthropic-ai/claude-code` | `claude --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) |
|
| `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.
|
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/
|
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
|
## Verify Installation
|
||||||
|
|
||||||
```bash
|
```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 ~/.claude/skills/implement-plan/SKILL.md || true
|
||||||
test -f ~/.config/opencode/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 .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 ~/.codex/skills/reviewer-runtime/run-review.sh || true
|
||||||
test -x ~/.claude/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 ~/.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 .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:
|
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/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/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`
|
- 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
|
## 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:
|
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
|
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
|
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`
|
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`
|
5. **Prioritize** — findings are ordered `P0`, `P1`, `P2`, `P3`
|
||||||
6. **Revise** — the implementing agent addresses findings in priority order, re-verifies, and re-submits
|
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. **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
|
### 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` |
|
| `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` |
|
| `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` |
|
| `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
|
1. write the reviewer command to a bash script
|
||||||
2. run that script through `reviewer-runtime/run-review.sh`
|
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
|
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
|
## Notifications
|
||||||
|
|
||||||
- Telegram is the only supported notification path.
|
- Telegram is the only supported notification path.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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."
|
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:
|
Pi reviewer calls must stay isolated from the main workflow:
|
||||||
|
|
||||||
- Use `--no-session` so the reviewer does not continue or persist the workflow session.
|
- Use `--no-session` so the reviewer does not continue or persist the workflow session.
|
||||||
|
|||||||
+11
-2
@@ -52,7 +52,16 @@ Workflow-heavy Pi skills split their shared setup across two docs:
|
|||||||
|
|
||||||
## Package Install
|
## 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:
|
Global install from a cloned checkout:
|
||||||
|
|
||||||
@@ -115,7 +124,7 @@ When a source Pi variant changes:
|
|||||||
npm pack --dry-run --json
|
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:
|
The verifier is responsible for catching:
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,20 @@
|
|||||||
|
|
||||||
This directory contains user-facing docs for each skill.
|
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
|
## 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.
|
- [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.
|
- [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.
|
- [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.
|
||||||
|
|||||||
@@ -48,6 +48,22 @@ pnpm approve-builds
|
|||||||
pnpm rebuild better-sqlite3 esbuild
|
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
|
### OpenCode
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -60,6 +76,31 @@ pnpm approve-builds
|
|||||||
pnpm rebuild better-sqlite3 esbuild
|
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
|
## Update To The Latest CloakBrowser
|
||||||
|
|
||||||
Run inside the installed `scripts/` directory for the variant you are using:
|
Run inside the installed `scripts/` directory for the variant you are using:
|
||||||
|
|||||||
+17
-1
@@ -12,9 +12,14 @@
|
|||||||
"files": [
|
"files": [
|
||||||
"README.md",
|
"README.md",
|
||||||
"docs/ATLASSIAN.md",
|
"docs/ATLASSIAN.md",
|
||||||
|
"docs/CLAUDE-CODE.md",
|
||||||
|
"docs/CODEX.md",
|
||||||
"docs/CREATE-PLAN.md",
|
"docs/CREATE-PLAN.md",
|
||||||
|
"docs/CURSOR.md",
|
||||||
"docs/DO-TASK.md",
|
"docs/DO-TASK.md",
|
||||||
"docs/IMPLEMENT-PLAN.md",
|
"docs/IMPLEMENT-PLAN.md",
|
||||||
|
"docs/INSTALLER.md",
|
||||||
|
"docs/OPENCODE.md",
|
||||||
"docs/README.md",
|
"docs/README.md",
|
||||||
"docs/TELEGRAM-NOTIFICATIONS.md",
|
"docs/TELEGRAM-NOTIFICATIONS.md",
|
||||||
"docs/PI.md",
|
"docs/PI.md",
|
||||||
@@ -25,9 +30,20 @@
|
|||||||
"pi-package/skills",
|
"pi-package/skills",
|
||||||
"skills/reviewer-runtime/pi",
|
"skills/reviewer-runtime/pi",
|
||||||
"scripts/install-pi-package.sh",
|
"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/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": {
|
"pi": {
|
||||||
"skills": [
|
"skills": [
|
||||||
"./pi-package/skills/atlassian",
|
"./pi-package/skills/atlassian",
|
||||||
|
|||||||
@@ -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.
|
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.
|
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:
|
The pi reviewer command rendered into `/tmp/plan-review-${REVIEW_ID}.sh` must be isolated and read-only:
|
||||||
|
|||||||
@@ -103,6 +103,14 @@ Reviewer CLI: `codex`, `claude`, `cursor`, `opencode`, `pi`, or `skip`
|
|||||||
|
|
||||||
Store `REVIEWER_CLI`, `REVIEWER_MODEL`, and `MAX_ROUNDS`.
|
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.
|
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:
|
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`.
|
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.
|
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:
|
The pi reviewer command rendered into `/tmp/milestone-review-${REVIEW_ID}.sh` must be isolated and read-only:
|
||||||
|
|||||||
@@ -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" };
|
||||||
|
}
|
||||||
Executable
+320
@@ -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;
|
||||||
|
});
|
||||||
Executable
+3
@@ -0,0 +1,3 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
exec node "$(dirname "$0")/manage-skills.mjs" "$@"
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
Executable
+186
@@ -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
|
||||||
@@ -33,6 +33,19 @@ If any dependency is missing, stop immediately and return:
|
|||||||
|
|
||||||
### Phase 3: Configure Reviewer
|
### 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:
|
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?**
|
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
|
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`:**
|
**If `REVIEWER_CLI` is `codex`:**
|
||||||
|
|
||||||
```bash
|
```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=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
|
```bash
|
||||||
cp /tmp/plan-review-${REVIEW_ID}.runner.out /tmp/plan-review-${REVIEW_ID}.md
|
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.
|
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`:**
|
**If `REVIEWER_CLI` is `codex`:**
|
||||||
|
|
||||||
Resume the existing session:
|
Resume the existing session:
|
||||||
|
|||||||
@@ -58,6 +58,19 @@ If any dependency is missing, stop and return:
|
|||||||
|
|
||||||
### Phase 3: Configure Reviewer
|
### 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:
|
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?**
|
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
|
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`:**
|
**If `REVIEWER_CLI` is `codex`:**
|
||||||
|
|
||||||
```bash
|
```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=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
|
```bash
|
||||||
cp /tmp/plan-review-${REVIEW_ID}.runner.out /tmp/plan-review-${REVIEW_ID}.md
|
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.
|
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`:**
|
**If `REVIEWER_CLI` is `codex`:**
|
||||||
|
|
||||||
Resume the existing session:
|
Resume the existing session:
|
||||||
|
|||||||
@@ -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
|
3. Review the plan iteratively with a second model/provider
|
||||||
4. Persist a local execution package in `ai_plan/YYYY-MM-DD-<short-title>/`
|
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)
|
## 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.
|
- 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)
|
- `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 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:brainstorming`
|
||||||
- `superpowers:writing-plans`
|
- `superpowers:writing-plans`
|
||||||
|
|
||||||
@@ -31,15 +31,15 @@ Verify before proceeding:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cursor-agent --version
|
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/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
|
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:
|
# Only if using cursor as reviewer CLI:
|
||||||
# jq --version
|
# jq --version
|
||||||
```
|
```
|
||||||
|
|
||||||
If any dependency is missing, stop and return:
|
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
|
## Required Skill Invocation Rules
|
||||||
|
|
||||||
@@ -59,6 +59,19 @@ If any dependency is missing, stop and return:
|
|||||||
|
|
||||||
### Phase 3: Configure Reviewer
|
### 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:
|
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?**
|
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
|
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`:**
|
**If `REVIEWER_CLI` is `codex`:**
|
||||||
|
|
||||||
```bash
|
```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=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
|
```bash
|
||||||
cp /tmp/plan-review-${REVIEW_ID}.runner.out /tmp/plan-review-${REVIEW_ID}.md
|
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.
|
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`:**
|
**If `REVIEWER_CLI` is `codex`:**
|
||||||
|
|
||||||
Resume the existing session:
|
Resume the existing session:
|
||||||
|
|||||||
@@ -9,18 +9,19 @@ Create and maintain a local plan folder under `ai_plan/` at project root.
|
|||||||
|
|
||||||
## Prerequisite Check (MANDATORY)
|
## 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:
|
Required:
|
||||||
- Superpowers repo: `https://github.com/obra/superpowers`
|
- 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/brainstorming`
|
||||||
- `superpowers/writing-plans`
|
- `superpowers/writing-plans`
|
||||||
|
|
||||||
Verify before proceeding:
|
Verify before proceeding:
|
||||||
|
|
||||||
```bash
|
```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:
|
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).
|
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)
|
### Phase 5: Design (REQUIRED SUB-SKILL)
|
||||||
|
|
||||||
Use OpenCode's native skill tool to load:
|
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
|
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`:**
|
**If `REVIEWER_CLI` is `codex`:**
|
||||||
|
|
||||||
```bash
|
```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=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
|
```bash
|
||||||
cp /tmp/plan-review-${REVIEW_ID}.runner.out /tmp/plan-review-${REVIEW_ID}.md
|
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.
|
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`:**
|
**If `REVIEWER_CLI` is `codex`:**
|
||||||
|
|
||||||
Resume the existing session:
|
Resume the existing session:
|
||||||
|
|||||||
@@ -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.
|
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.
|
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:
|
The pi reviewer command rendered into `/tmp/plan-review-${REVIEW_ID}.sh` must be isolated and read-only:
|
||||||
|
|||||||
@@ -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.
|
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
|
### Phase 4: Initialize Plan Workspace
|
||||||
|
|
||||||
**PLAN MODE CHECK:** If currently in plan mode:
|
**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 |
|
| `REVIEW_ID` | 8-char hex (from `uuidgen`); reused across rounds of the same loop |
|
||||||
| `PAYLOAD_PATH` | `/tmp/do-task-${REVIEW_KIND}-${REVIEW_ID}.md` |
|
| `PAYLOAD_PATH` | `/tmp/do-task-${REVIEW_KIND}-${REVIEW_ID}.md` |
|
||||||
| `PROMPT_TEMPLATE` | `PLAN_REVIEW_PROMPT` or `IMPL_REVIEW_PROMPT` |
|
| `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 |
|
| `REVIEWER_MODEL` | Model name |
|
||||||
| `MAX_ROUNDS` | Default 10 |
|
| `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` |
|
| `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
|
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`:**
|
**If `REVIEWER_CLI` is `codex`:**
|
||||||
|
|
||||||
Round 1 — fresh `codex exec`:
|
Round 1 — fresh `codex exec`:
|
||||||
@@ -633,7 +658,7 @@ After the command completes:
|
|||||||
cp /tmp/do-task-${REVIEW_KIND}-review-${REVIEW_ID}.runner.out \
|
cp /tmp/do-task-${REVIEW_KIND}-review-${REVIEW_ID}.runner.out \
|
||||||
/tmp/do-task-${REVIEW_KIND}-review-${REVIEW_ID}.md
|
/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
|
```bash
|
||||||
cp /tmp/do-task-${REVIEW_KIND}-review-${REVIEW_ID}.runner.out \
|
cp /tmp/do-task-${REVIEW_KIND}-review-${REVIEW_ID}.runner.out \
|
||||||
/tmp/do-task-${REVIEW_KIND}-review-${REVIEW_ID}.md
|
/tmp/do-task-${REVIEW_KIND}-review-${REVIEW_ID}.md
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
| Created | YYYY-MM-DD |
|
| Created | YYYY-MM-DD |
|
||||||
| Slug | YYYY-MM-DD-<slug> |
|
| Slug | YYYY-MM-DD-<slug> |
|
||||||
| Runtime | claude-code |
|
| Runtime | claude-code |
|
||||||
| Reviewer CLI | codex \| claude \| cursor \| opencode |
|
| Reviewer CLI | codex \| claude \| cursor \| opencode \| pi |
|
||||||
| Reviewer Model | <model> |
|
| Reviewer Model | <model> |
|
||||||
| MAX_ROUNDS | 10 |
|
| MAX_ROUNDS | 10 |
|
||||||
| Branch Strategy | current-branch \| worktree |
|
| Branch Strategy | current-branch \| worktree |
|
||||||
|
|||||||
@@ -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.
|
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
|
### Phase 4: Initialize Plan Workspace
|
||||||
|
|
||||||
Codex has no plan-mode concept; there is no plan-mode guard here.
|
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 |
|
| `REVIEW_ID` | 8-char hex (from `uuidgen`); reused across rounds of the same loop |
|
||||||
| `PAYLOAD_PATH` | `/tmp/do-task-${REVIEW_KIND}-${REVIEW_ID}.md` |
|
| `PAYLOAD_PATH` | `/tmp/do-task-${REVIEW_KIND}-${REVIEW_ID}.md` |
|
||||||
| `PROMPT_TEMPLATE` | `PLAN_REVIEW_PROMPT` or `IMPL_REVIEW_PROMPT` |
|
| `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 |
|
| `REVIEWER_MODEL` | Model name |
|
||||||
| `MAX_ROUNDS` | Default 10 |
|
| `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` |
|
| `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
|
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`:**
|
**If `REVIEWER_CLI` is `codex`:**
|
||||||
|
|
||||||
Round 1 — fresh `codex exec`:
|
Round 1 — fresh `codex exec`:
|
||||||
@@ -652,7 +677,7 @@ After the command completes:
|
|||||||
cp /tmp/do-task-${REVIEW_KIND}-review-${REVIEW_ID}.runner.out \
|
cp /tmp/do-task-${REVIEW_KIND}-review-${REVIEW_ID}.runner.out \
|
||||||
/tmp/do-task-${REVIEW_KIND}-review-${REVIEW_ID}.md
|
/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
|
```bash
|
||||||
cp /tmp/do-task-${REVIEW_KIND}-review-${REVIEW_ID}.runner.out \
|
cp /tmp/do-task-${REVIEW_KIND}-review-${REVIEW_ID}.runner.out \
|
||||||
/tmp/do-task-${REVIEW_KIND}-review-${REVIEW_ID}.md
|
/tmp/do-task-${REVIEW_KIND}-review-${REVIEW_ID}.md
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
| Created | YYYY-MM-DD |
|
| Created | YYYY-MM-DD |
|
||||||
| Slug | YYYY-MM-DD-<slug> |
|
| Slug | YYYY-MM-DD-<slug> |
|
||||||
| Runtime | codex |
|
| Runtime | codex |
|
||||||
| Reviewer CLI | codex \| claude \| cursor \| opencode |
|
| Reviewer CLI | codex \| claude \| cursor \| opencode \| pi |
|
||||||
| Reviewer Model | <model> |
|
| Reviewer Model | <model> |
|
||||||
| MAX_ROUNDS | 10 |
|
| MAX_ROUNDS | 10 |
|
||||||
| Branch Strategy | current-branch \| worktree |
|
| Branch Strategy | current-branch \| worktree |
|
||||||
|
|||||||
@@ -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).
|
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)
|
## 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.
|
- 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`.
|
- `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 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:brainstorming`
|
||||||
- `superpowers:test-driven-development`
|
- `superpowers:test-driven-development`
|
||||||
- `superpowers:verification-before-completion`
|
- `superpowers:verification-before-completion`
|
||||||
@@ -31,15 +31,15 @@ Verify before proceeding:
|
|||||||
```bash
|
```bash
|
||||||
cursor-agent --version
|
cursor-agent --version
|
||||||
jq --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/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
|
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
|
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
|
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:
|
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
|
## 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.
|
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
|
### Phase 4: Initialize Plan Workspace
|
||||||
|
|
||||||
Cursor Agent CLI has no plan-mode concept; there is no plan-mode guard here.
|
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 |
|
| `REVIEW_ID` | 8-char hex (from `uuidgen`); reused across rounds of the same loop |
|
||||||
| `PAYLOAD_PATH` | `/tmp/do-task-${REVIEW_KIND}-${REVIEW_ID}.md` |
|
| `PAYLOAD_PATH` | `/tmp/do-task-${REVIEW_KIND}-${REVIEW_ID}.md` |
|
||||||
| `PROMPT_TEMPLATE` | `PLAN_REVIEW_PROMPT` or `IMPL_REVIEW_PROMPT` |
|
| `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 |
|
| `REVIEWER_MODEL` | Model name |
|
||||||
| `MAX_ROUNDS` | Default 10 |
|
| `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` |
|
| `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
|
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`:**
|
**If `REVIEWER_CLI` is `codex`:**
|
||||||
|
|
||||||
Round 1 — fresh `codex exec`:
|
Round 1 — fresh `codex exec`:
|
||||||
@@ -659,7 +684,7 @@ After the command completes:
|
|||||||
cp /tmp/do-task-${REVIEW_KIND}-review-${REVIEW_ID}.runner.out \
|
cp /tmp/do-task-${REVIEW_KIND}-review-${REVIEW_ID}.runner.out \
|
||||||
/tmp/do-task-${REVIEW_KIND}-review-${REVIEW_ID}.md
|
/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
|
```bash
|
||||||
cp /tmp/do-task-${REVIEW_KIND}-review-${REVIEW_ID}.runner.out \
|
cp /tmp/do-task-${REVIEW_KIND}-review-${REVIEW_ID}.runner.out \
|
||||||
/tmp/do-task-${REVIEW_KIND}-review-${REVIEW_ID}.md
|
/tmp/do-task-${REVIEW_KIND}-review-${REVIEW_ID}.md
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
| Created | YYYY-MM-DD |
|
| Created | YYYY-MM-DD |
|
||||||
| Slug | YYYY-MM-DD-<slug> |
|
| Slug | YYYY-MM-DD-<slug> |
|
||||||
| Runtime | cursor |
|
| Runtime | cursor |
|
||||||
| Reviewer CLI | codex \| claude \| cursor \| opencode |
|
| Reviewer CLI | codex \| claude \| cursor \| opencode \| pi |
|
||||||
| Reviewer Model | <model> |
|
| Reviewer Model | <model> |
|
||||||
| MAX_ROUNDS | 10 |
|
| MAX_ROUNDS | 10 |
|
||||||
| Branch Strategy | current-branch \| worktree |
|
| Branch Strategy | current-branch \| worktree |
|
||||||
|
|||||||
@@ -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).
|
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)
|
## Prerequisite Check (MANDATORY)
|
||||||
|
|
||||||
Required:
|
Required:
|
||||||
- OpenCode CLI: `opencode --version` (install via your package manager or `brew install opencode`).
|
- OpenCode CLI: `opencode --version` (install via your package manager or `brew install opencode`).
|
||||||
- Superpowers repo: `https://github.com/obra/superpowers`
|
- 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/brainstorming`
|
||||||
- `superpowers/test-driven-development`
|
- `superpowers/test-driven-development`
|
||||||
- `superpowers/verification-before-completion`
|
- `superpowers/verification-before-completion`
|
||||||
@@ -29,11 +29,10 @@ Verify before proceeding:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
opencode --version
|
opencode --version
|
||||||
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 ~/.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 ~/.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 ~/.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
|
||||||
test -f ~/.config/opencode/skills/superpowers/finishing-a-development-branch/SKILL.md
|
|
||||||
```
|
```
|
||||||
|
|
||||||
If any required dependency is missing, stop immediately and return:
|
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:
|
- Announce skill usage explicitly:
|
||||||
- `I've read the [Skill Name] skill and I'm using it to [purpose].`
|
- `I've read the [Skill Name] skill and I'm using it to [purpose].`
|
||||||
- For skills with checklists, track checklist items explicitly in conversation.
|
- 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
|
## 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.
|
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
|
### Phase 4: Initialize Plan Workspace
|
||||||
|
|
||||||
OpenCode has no plan-mode concept; there is no plan-mode guard here.
|
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 |
|
| `REVIEW_ID` | 8-char hex (from `uuidgen`); reused across rounds of the same loop |
|
||||||
| `PAYLOAD_PATH` | `/tmp/do-task-${REVIEW_KIND}-${REVIEW_ID}.md` |
|
| `PAYLOAD_PATH` | `/tmp/do-task-${REVIEW_KIND}-${REVIEW_ID}.md` |
|
||||||
| `PROMPT_TEMPLATE` | `PLAN_REVIEW_PROMPT` or `IMPL_REVIEW_PROMPT` |
|
| `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 |
|
| `REVIEWER_MODEL` | Model name |
|
||||||
| `MAX_ROUNDS` | Default 10 |
|
| `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` |
|
| `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
|
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`:**
|
**If `REVIEWER_CLI` is `codex`:**
|
||||||
|
|
||||||
Round 1 — fresh `codex exec`:
|
Round 1 — fresh `codex exec`:
|
||||||
@@ -651,7 +675,7 @@ After the command completes:
|
|||||||
cp /tmp/do-task-${REVIEW_KIND}-review-${REVIEW_ID}.runner.out \
|
cp /tmp/do-task-${REVIEW_KIND}-review-${REVIEW_ID}.runner.out \
|
||||||
/tmp/do-task-${REVIEW_KIND}-review-${REVIEW_ID}.md
|
/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
|
```bash
|
||||||
cp /tmp/do-task-${REVIEW_KIND}-review-${REVIEW_ID}.runner.out \
|
cp /tmp/do-task-${REVIEW_KIND}-review-${REVIEW_ID}.runner.out \
|
||||||
/tmp/do-task-${REVIEW_KIND}-review-${REVIEW_ID}.md
|
/tmp/do-task-${REVIEW_KIND}-review-${REVIEW_ID}.md
|
||||||
@@ -770,7 +794,7 @@ Review History is append-only.
|
|||||||
|
|
||||||
## Variant Hardening Notes — OpenCode
|
## 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.
|
- 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}`.
|
- Helper paths are `~/.config/opencode/skills/reviewer-runtime/{run-review.sh,notify-telegram.sh}`.
|
||||||
- OpenCode reviewer CLI branch (when `REVIEWER_CLI=opencode`):
|
- OpenCode reviewer CLI branch (when `REVIEWER_CLI=opencode`):
|
||||||
@@ -784,7 +808,7 @@ Review History is append-only.
|
|||||||
## Common Mistakes
|
## Common Mistakes
|
||||||
|
|
||||||
- Skipping the Bootstrap Superpowers Context step in Phase 1 (breaks native skill discovery).
|
- 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).
|
- 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.
|
- Asking multiple clarifying questions in a single message.
|
||||||
- Skipping the per-payload secret scan because "the previous round was clean".
|
- Skipping the per-payload secret scan because "the previous round was clean".
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
| Created | YYYY-MM-DD |
|
| Created | YYYY-MM-DD |
|
||||||
| Slug | YYYY-MM-DD-<slug> |
|
| Slug | YYYY-MM-DD-<slug> |
|
||||||
| Runtime | opencode |
|
| Runtime | opencode |
|
||||||
| Reviewer CLI | codex \| claude \| cursor \| opencode |
|
| Reviewer CLI | codex \| claude \| cursor \| opencode \| pi |
|
||||||
| Reviewer Model | <model> |
|
| Reviewer Model | <model> |
|
||||||
| MAX_ROUNDS | 10 |
|
| MAX_ROUNDS | 10 |
|
||||||
| Branch Strategy | current-branch \| worktree |
|
| Branch Strategy | current-branch \| worktree |
|
||||||
|
|||||||
@@ -103,6 +103,14 @@ Reviewer CLI: `codex`, `claude`, `cursor`, `opencode`, `pi`, or `skip`
|
|||||||
|
|
||||||
Store `REVIEWER_CLI`, `REVIEWER_MODEL`, and `MAX_ROUNDS`.
|
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.
|
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:
|
The pi reviewer command rendered into `/tmp/do-task-${REVIEW_KIND}-review-${REVIEW_ID}.sh` must be isolated and read-only:
|
||||||
|
|||||||
@@ -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.
|
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)
|
### Phase 3: Set Up Worktree (REQUIRED SUB-SKILL)
|
||||||
|
|
||||||
Invoke `superpowers:using-git-worktrees` explicitly.
|
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
|
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`:**
|
**If `REVIEWER_CLI` is `codex`:**
|
||||||
|
|
||||||
```bash
|
```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=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
|
```bash
|
||||||
cp /tmp/milestone-review-${REVIEW_ID}.runner.out /tmp/milestone-review-${REVIEW_ID}.md
|
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.
|
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`:**
|
**If `REVIEWER_CLI` is `codex`:**
|
||||||
|
|
||||||
Resume the existing session:
|
Resume the existing session:
|
||||||
|
|||||||
@@ -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.
|
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)
|
### Phase 3: Set Up Worktree (REQUIRED SUB-SKILL)
|
||||||
|
|
||||||
Invoke `superpowers:using-git-worktrees`.
|
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
|
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`:**
|
**If `REVIEWER_CLI` is `codex`:**
|
||||||
|
|
||||||
```bash
|
```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=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
|
```bash
|
||||||
cp /tmp/milestone-review-${REVIEW_ID}.runner.out /tmp/milestone-review-${REVIEW_ID}.md
|
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.
|
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`:**
|
**If `REVIEWER_CLI` is `codex`:**
|
||||||
|
|
||||||
Resume the existing session:
|
Resume the existing session:
|
||||||
|
|||||||
@@ -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
|
4. Review each milestone with a second model/provider
|
||||||
5. Commit approved milestones, merge to parent branch, and delete worktree
|
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)
|
## Prerequisite Check (MANDATORY)
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ Required:
|
|||||||
- `milestone-plan.md` exists in plan folder
|
- `milestone-plan.md` exists in plan folder
|
||||||
- `story-tracker.md` exists in plan folder
|
- `story-tracker.md` exists in plan folder
|
||||||
- Git repo with worktree support: `git worktree list`
|
- 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 execution skills:
|
||||||
- `superpowers:executing-plans`
|
- `superpowers:executing-plans`
|
||||||
- `superpowers:using-git-worktrees`
|
- `superpowers:using-git-worktrees`
|
||||||
@@ -39,17 +39,17 @@ Verify before proceeding:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cursor-agent --version
|
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/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
|
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
|
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
|
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:
|
# Only if using cursor as reviewer CLI:
|
||||||
# jq --version
|
# jq --version
|
||||||
```
|
```
|
||||||
|
|
||||||
If any dependency is missing, stop and return:
|
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:
|
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.
|
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)
|
### Phase 3: Set Up Worktree (REQUIRED SUB-SKILL)
|
||||||
|
|
||||||
Invoke `superpowers:using-git-worktrees`.
|
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
|
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`:**
|
**If `REVIEWER_CLI` is `codex`:**
|
||||||
|
|
||||||
```bash
|
```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=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
|
```bash
|
||||||
cp /tmp/milestone-review-${REVIEW_ID}.runner.out /tmp/milestone-review-${REVIEW_ID}.md
|
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.
|
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`:**
|
**If `REVIEWER_CLI` is `codex`:**
|
||||||
|
|
||||||
Resume the existing session:
|
Resume the existing session:
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ Required:
|
|||||||
- `milestone-plan.md` exists in plan folder
|
- `milestone-plan.md` exists in plan folder
|
||||||
- `story-tracker.md` exists in plan folder
|
- `story-tracker.md` exists in plan folder
|
||||||
- Git repo with worktree support: `git worktree list`
|
- 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 execution skills:
|
||||||
- `superpowers/executing-plans`
|
- `superpowers/executing-plans`
|
||||||
- `superpowers/using-git-worktrees`
|
- `superpowers/using-git-worktrees`
|
||||||
@@ -27,7 +27,10 @@ Required:
|
|||||||
Verify before proceeding:
|
Verify before proceeding:
|
||||||
|
|
||||||
```bash
|
```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:
|
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.
|
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)
|
### Phase 4: Set Up Worktree (REQUIRED SUB-SKILL)
|
||||||
|
|
||||||
Use OpenCode's native skill tool to load:
|
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
|
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`:**
|
**If `REVIEWER_CLI` is `codex`:**
|
||||||
|
|
||||||
```bash
|
```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=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
|
```bash
|
||||||
cp /tmp/milestone-review-${REVIEW_ID}.runner.out /tmp/milestone-review-${REVIEW_ID}.md
|
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.
|
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`:**
|
**If `REVIEWER_CLI` is `codex`:**
|
||||||
|
|
||||||
Resume the existing session:
|
Resume the existing session:
|
||||||
|
|||||||
@@ -86,6 +86,14 @@ Reviewer CLI: `codex`, `claude`, `cursor`, `opencode`, `pi`, or `skip`
|
|||||||
|
|
||||||
Store `REVIEWER_CLI`, `REVIEWER_MODEL`, and `MAX_ROUNDS`.
|
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.
|
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:
|
The pi reviewer command rendered into `/tmp/milestone-review-${REVIEW_ID}.sh` must be isolated and read-only:
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
}
|
||||||
+1292
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"
|
SOURCE_DIR="${ROOT_DIR}/codex/scripts"
|
||||||
TARGETS=(
|
TARGETS=(
|
||||||
"claude-code"
|
"claude-code"
|
||||||
|
"cursor"
|
||||||
"opencode"
|
"opencode"
|
||||||
"pi"
|
"pi"
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user