Compare commits
29 Commits
e917387d4f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 251148c3ff | |||
| 2deab1c1b4 | |||
| 193cd45db8 | |||
| d62899308a | |||
| 8ea6d08e77 | |||
| 3966b77623 | |||
| 494e29f797 | |||
| f01721a45b | |||
| 231a66f2b1 | |||
| ce4746b769 | |||
| 912aed93a7 | |||
| 9e29c34c62 | |||
| 3429dac894 | |||
| 0456c51291 | |||
| f2c4d39abd | |||
| d7651ad57c | |||
| 3d868a852c | |||
| 51372eb420 | |||
| 7ba6f90e14 | |||
| c98f27f461 | |||
| 9853d4937b | |||
| f5161f584d | |||
| f404792927 | |||
| d69da3a4a8 | |||
| 437b2024cd | |||
| c344e96984 | |||
| 879cccf383 | |||
| c97b7d44e5 | |||
| 99fe6eab4e |
@@ -0,0 +1,40 @@
|
|||||||
|
name: check-online
|
||||||
|
|
||||||
|
# Runs full external link checking on a schedule and on manual trigger.
|
||||||
|
# Kept separate from the main `check` workflow so everyday push/PR CI
|
||||||
|
# is not slowed down or broken by transient network issues.
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# Every Monday at 09:00 UTC
|
||||||
|
- cron: "0 9 * * 1"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-online:
|
||||||
|
name: check-online (ubuntu-latest)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install shellcheck
|
||||||
|
run: sudo apt-get update -q && sudo apt-get install -y -q shellcheck
|
||||||
|
|
||||||
|
- name: Install ripgrep
|
||||||
|
run: sudo apt-get install -y -q ripgrep
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Run full quality suite with external link checking
|
||||||
|
run: pnpm run verify:docs:online
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
name: check
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["**"]
|
||||||
|
pull_request:
|
||||||
|
branches: ["**"]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
name: check (${{ matrix.os }})
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-latest, macos-latest]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install shellcheck (Ubuntu)
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
run: sudo apt-get update -q && sudo apt-get install -y -q shellcheck
|
||||||
|
|
||||||
|
- name: Install shellcheck (macOS)
|
||||||
|
if: runner.os == 'macOS'
|
||||||
|
run: brew install shellcheck
|
||||||
|
|
||||||
|
- name: Install ripgrep (Ubuntu)
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
run: sudo apt-get install -y -q ripgrep
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Run full quality suite (offline link-checking)
|
||||||
|
run: pnpm run check
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
/ai_plan/
|
/ai_plan/
|
||||||
|
/.pi/
|
||||||
/.worktrees/
|
/.worktrees/
|
||||||
|
/node_modules/
|
||||||
/skills/atlassian/shared/scripts/.env
|
/skills/atlassian/shared/scripts/.env
|
||||||
/skills/atlassian/shared/scripts/node_modules/
|
/skills/atlassian/shared/scripts/node_modules/
|
||||||
/skills/atlassian/*/scripts/.env
|
/skills/atlassian/*/scripts/.env
|
||||||
/skills/atlassian/*/scripts/node_modules/
|
/skills/atlassian/*/scripts/node_modules/
|
||||||
|
/skills/web-automation/*/scripts/node_modules/
|
||||||
|
/skills/web-automation/shared/node_modules/
|
||||||
|
/pi-package/skills/*/scripts/node_modules/
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// markdownlint-cli2 configuration — ai-coding-skills (M2)
|
||||||
|
//
|
||||||
|
// This file controls which files are linted and which are ignored.
|
||||||
|
// The markdownlint *rules* are configured in .markdownlint.jsonc (root) and
|
||||||
|
// skills/.markdownlint.jsonc (agent-facing skill files).
|
||||||
|
//
|
||||||
|
// Scope: README.md, docs/, and canonical SKILL.md files.
|
||||||
|
// Excluded: all node_modules, generated agent-variant directories, pi-package.
|
||||||
|
{
|
||||||
|
// Glob patterns for which files to lint
|
||||||
|
"globs": [
|
||||||
|
"README.md",
|
||||||
|
"docs/**/*.md",
|
||||||
|
// Canonical skill files only — agent variants are excluded below
|
||||||
|
"skills/atlassian/shared/**/*.md",
|
||||||
|
"skills/create-plan/**/*.md",
|
||||||
|
"skills/do-task/**/*.md",
|
||||||
|
"skills/implement-plan/**/*.md",
|
||||||
|
"skills/reviewer-runtime/**/*.md",
|
||||||
|
"skills/web-automation/codex/**/*.md",
|
||||||
|
"skills/web-automation/pi/**/*.md",
|
||||||
|
"skills/web-automation/claude-code/SKILL.md",
|
||||||
|
"skills/web-automation/cursor/SKILL.md",
|
||||||
|
"skills/web-automation/opencode/SKILL.md",
|
||||||
|
"skills/atlassian/codex/SKILL.md",
|
||||||
|
"skills/atlassian/claude-code/SKILL.md",
|
||||||
|
"skills/atlassian/cursor/SKILL.md",
|
||||||
|
"skills/atlassian/opencode/SKILL.md",
|
||||||
|
"skills/atlassian/pi/SKILL.md"
|
||||||
|
],
|
||||||
|
|
||||||
|
// Ignore patterns — always exclude node_modules, generated artefacts, and canonical _source dirs
|
||||||
|
"ignores": [
|
||||||
|
"**/node_modules/**",
|
||||||
|
"pi-package/**",
|
||||||
|
// Canonical source directories — contain per-agent source with relative links
|
||||||
|
// calibrated to their generated location (one level up); exclude from linting.
|
||||||
|
"skills/**/_source/**"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// markdownlint configuration — ai-coding-skills (M1)
|
||||||
|
//
|
||||||
|
// Pre-existing violations in docs/ and skills/ SKILL.md files are recorded
|
||||||
|
// in docs/CLEANUP-BASELINE.md and deferred to a later milestone for fixing.
|
||||||
|
// New markdown files added from M1 onward must satisfy all enabled rules.
|
||||||
|
{
|
||||||
|
// Inherit all default rules, then override below
|
||||||
|
"default": true,
|
||||||
|
|
||||||
|
// MD013 — line length
|
||||||
|
// This project contains long technical strings, code snippets, and URLs
|
||||||
|
// in markdown prose. Enforce a generous limit rather than the strict 80-char
|
||||||
|
// default to avoid noise on otherwise-clean documents.
|
||||||
|
"MD013": {
|
||||||
|
"line_length": 120,
|
||||||
|
"heading_line_length": 120,
|
||||||
|
"code_block_line_length": 160,
|
||||||
|
"code_blocks": false,
|
||||||
|
"tables": false
|
||||||
|
},
|
||||||
|
|
||||||
|
// MD033 — inline HTML
|
||||||
|
// Allow HTML in markdown (used in some SKILL.md files for tables/details).
|
||||||
|
"MD033": false,
|
||||||
|
|
||||||
|
// MD034 — bare URLs
|
||||||
|
// Disabled: existing docs include many plain URLs intentionally.
|
||||||
|
"MD034": false,
|
||||||
|
|
||||||
|
// MD041 — first line should be top-level heading
|
||||||
|
// Disabled: some files intentionally start with front-matter or a preamble.
|
||||||
|
"MD041": false,
|
||||||
|
|
||||||
|
// MD060 — table column style
|
||||||
|
// Disabled: existing tables use compact pipe style throughout.
|
||||||
|
"MD060": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
node_modules/
|
||||||
|
**/node_modules/**
|
||||||
|
pnpm-lock.yaml
|
||||||
|
# Generated agent-variant directories (non-mutating M1 policy)
|
||||||
|
skills/atlassian/codex/
|
||||||
|
skills/atlassian/claude-code/
|
||||||
|
skills/atlassian/cursor/
|
||||||
|
skills/atlassian/opencode/
|
||||||
|
skills/atlassian/pi/
|
||||||
|
skills/web-automation/claude-code/
|
||||||
|
skills/web-automation/cursor/
|
||||||
|
skills/web-automation/opencode/
|
||||||
|
skills/web-automation/pi/
|
||||||
|
pi-package/
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
node_modules/
|
||||||
|
pnpm-lock.yaml
|
||||||
|
# Generated agent-variant directories (non-mutating M1 policy)
|
||||||
|
skills/atlassian/codex/
|
||||||
|
skills/atlassian/claude-code/
|
||||||
|
skills/atlassian/cursor/
|
||||||
|
skills/atlassian/opencode/
|
||||||
|
skills/atlassian/pi/
|
||||||
|
skills/web-automation/claude-code/
|
||||||
|
skills/web-automation/cursor/
|
||||||
|
skills/web-automation/opencode/
|
||||||
|
skills/web-automation/pi/
|
||||||
|
pi-package/
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"endOfLine": "lf"
|
||||||
|
}
|
||||||
+204
@@ -0,0 +1,204 @@
|
|||||||
|
# Changelog — ai-coding-skills
|
||||||
|
|
||||||
|
All notable changes to the ai-coding-skills repository are recorded here.
|
||||||
|
Entries are milestone-scoped; stories within each milestone are listed for traceability.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## M5 — Final hardening, CI surface, and documentation closeout
|
||||||
|
|
||||||
|
### What changed in M5
|
||||||
|
|
||||||
|
**S-501 — CI workflow added**
|
||||||
|
|
||||||
|
`.github/workflows/check.yml` runs on every push and pull request against a
|
||||||
|
matrix of `ubuntu-latest` and `macos-latest`. Each job:
|
||||||
|
|
||||||
|
1. Installs `shellcheck` (apt on Ubuntu, brew on macOS).
|
||||||
|
2. Installs `ripgrep` (apt on Ubuntu; pre-installed on macOS runners).
|
||||||
|
3. Installs Node.js 22 and pnpm 10.
|
||||||
|
4. Runs `pnpm install --frozen-lockfile`.
|
||||||
|
5. Runs `pnpm run check` (full quality suite, offline link-checking).
|
||||||
|
|
||||||
|
`.github/workflows/check-online.yml` runs `verify:docs:online` on a weekly
|
||||||
|
schedule (Monday 09:00 UTC) and on manual `workflow_dispatch`. This keeps
|
||||||
|
external link checking out of the push/PR critical path.
|
||||||
|
|
||||||
|
**S-502 — Root README contributing section**
|
||||||
|
|
||||||
|
`README.md` now includes a "Contributing / Development" section that names
|
||||||
|
`pnpm run check` as the single quality gate, lists prerequisites, and links
|
||||||
|
to `docs/DEVELOPMENT.md`.
|
||||||
|
|
||||||
|
**S-503 — `docs/DEVELOPMENT.md` finalised**
|
||||||
|
|
||||||
|
Added:
|
||||||
|
|
||||||
|
- **"Adding a new agent variant"** recipe (6 steps from canonical source to CI).
|
||||||
|
- **"Adding a new skill"** recipe (6 steps).
|
||||||
|
- **"CI"** section describing both workflows and how to add new prerequisites.
|
||||||
|
- **Permanent `check` contract** replacing the transitional M1 wording.
|
||||||
|
- Cross-reference links to `.github/workflows/check.yml` and
|
||||||
|
`.github/workflows/check-online.yml`.
|
||||||
|
|
||||||
|
**S-504 — Baseline report closed**
|
||||||
|
|
||||||
|
`docs/CLEANUP-BASELINE.md` updated with a "Final state" section showing every
|
||||||
|
baseline failure resolved, a baseline status summary table, and the
|
||||||
|
post-M5 `pnpm run check` output. The document is now closed.
|
||||||
|
|
||||||
|
**S-505 — `package.json` file list synced**
|
||||||
|
|
||||||
|
`docs/DEVELOPMENT.md` and `docs/REVIEWERS.md` added to the `files` array.
|
||||||
|
All listed entries verified to exist in the post-M5 tree.
|
||||||
|
|
||||||
|
**S-506 — Changelog extended (this entry)**
|
||||||
|
|
||||||
|
**S-507 — Final green run confirmed**
|
||||||
|
|
||||||
|
`pnpm run check` exits 0 on macOS 15 (arm64) with Node 22.14.0 and
|
||||||
|
pnpm 10.18.1. CI workflow exercises the same gate on both `ubuntu-latest`
|
||||||
|
and `macos-latest`.
|
||||||
|
|
||||||
|
### `pnpm run check` status after M5
|
||||||
|
|
||||||
|
```text
|
||||||
|
PASS lint
|
||||||
|
PASS typecheck
|
||||||
|
PASS test
|
||||||
|
PASS verify:pi
|
||||||
|
PASS verify:reviewers
|
||||||
|
PASS verify:docs
|
||||||
|
PASS verify:generated
|
||||||
|
```
|
||||||
|
|
||||||
|
All checks green. CI enforces this on every push and PR.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## M4 — Shared-abstraction extraction and dead-code removal
|
||||||
|
|
||||||
|
*See `docs/CLEANUP-BASELINE.md` § "Post-M4 state" for the full M4 delta.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## M3 — Shared-source generator for agent variants
|
||||||
|
|
||||||
|
### Package metadata change ⚠️
|
||||||
|
|
||||||
|
**All generated agent-variant `package.json` files have been renamed to unique
|
||||||
|
private scoped names.** This is an intentional breaking rename in the artifact
|
||||||
|
structure, but all packages carry `"private": true` and are **never published**
|
||||||
|
to any registry. End-user manual-install workflows are unaffected: copying a
|
||||||
|
`skills/<skill>/<agent>/` directory continues to work unchanged.
|
||||||
|
|
||||||
|
| Old `name` | New `name` | Path |
|
||||||
|
|-----------|-----------|------|
|
||||||
|
| `atlassian-skill-scripts` | `@ai-coding-skills/atlassian-claude-code` | `skills/atlassian/claude-code/scripts/package.json` |
|
||||||
|
| `atlassian-skill-scripts` | `@ai-coding-skills/atlassian-codex` | `skills/atlassian/codex/scripts/package.json` |
|
||||||
|
| `atlassian-skill-scripts` | `@ai-coding-skills/atlassian-cursor` | `skills/atlassian/cursor/scripts/package.json` |
|
||||||
|
| `atlassian-skill-scripts` | `@ai-coding-skills/atlassian-opencode` | `skills/atlassian/opencode/scripts/package.json` |
|
||||||
|
| `atlassian-skill-scripts` | `@ai-coding-skills/atlassian-pi` | `skills/atlassian/pi/scripts/package.json` |
|
||||||
|
| `atlassian-skill-scripts` | `@ai-coding-skills/atlassian-pi-mirror` | `pi-package/skills/atlassian/scripts/package.json` |
|
||||||
|
| `web-automation-scripts` | `@ai-coding-skills/web-automation-claude-code` | `skills/web-automation/claude-code/scripts/package.json` |
|
||||||
|
| `web-automation-scripts` | `@ai-coding-skills/web-automation-codex` | `skills/web-automation/codex/scripts/package.json` |
|
||||||
|
| `web-automation-scripts` | `@ai-coding-skills/web-automation-cursor` | `skills/web-automation/cursor/scripts/package.json` |
|
||||||
|
| `web-automation-scripts` | `@ai-coding-skills/web-automation-opencode` | `skills/web-automation/opencode/scripts/package.json` |
|
||||||
|
| `web-automation-scripts` | `@ai-coding-skills/web-automation-pi` | `skills/web-automation/pi/scripts/package.json` |
|
||||||
|
| `web-automation-scripts` | `@ai-coding-skills/web-automation-pi-mirror` | `pi-package/skills/web-automation/scripts/package.json` |
|
||||||
|
|
||||||
|
All renamed packages also gained `"private": true`.
|
||||||
|
|
||||||
|
### What else changed in M3
|
||||||
|
|
||||||
|
**S-301 — Canonical sources documented**
|
||||||
|
|
||||||
|
Canonical source directories introduced (all outside every generated root):
|
||||||
|
|
||||||
|
- `skills/atlassian/_source/<agent>/SKILL.md` — per-agent Atlassian skill descriptions
|
||||||
|
- `skills/web-automation/_source/<agent>/SKILL.md` — per-agent Web Automation skill descriptions
|
||||||
|
- `skills/web-automation/shared/` — shared web-automation TypeScript runtime scripts
|
||||||
|
- `skills/create-plan/_source/<agent>/` — per-agent create-plan SKILL.md + templates
|
||||||
|
- `skills/do-task/_source/<agent>/` — per-agent do-task SKILL.md + templates
|
||||||
|
- `skills/implement-plan/_source/<agent>/` — per-agent implement-plan SKILL.md
|
||||||
|
- `skills/reviewer-runtime/` (base) — canonical `run-review.sh` and `notify-telegram.sh`
|
||||||
|
|
||||||
|
**S-302 — Generator built**
|
||||||
|
|
||||||
|
`scripts/generate-skills.mjs` regenerates all 31 generated roots from canonical
|
||||||
|
sources in one pass. Exports `detectFileType`, `applyHeader`,
|
||||||
|
`makePackageJsonContent`, `getGeneratedRoots`, `buildManifest`, and
|
||||||
|
`generateSkills`. 35 unit tests.
|
||||||
|
|
||||||
|
**S-303 — Manual-workflow skills migrated**
|
||||||
|
|
||||||
|
`create-plan`, `do-task`, `implement-plan` agent variants now generated from
|
||||||
|
`_source/` canonical sources. Only diffs vs pre-migration: file-type-aware
|
||||||
|
headers and new `.generated-manifest.json` files.
|
||||||
|
|
||||||
|
**S-304 — Web-automation runtime scripts migrated**
|
||||||
|
|
||||||
|
`skills/web-automation/shared/` created from the former codex canonical source.
|
||||||
|
All five web-automation agent variants and the pi-package mirror now generated
|
||||||
|
from this shared location.
|
||||||
|
|
||||||
|
**S-305 — Reviewer-runtime Pi variants migrated**
|
||||||
|
|
||||||
|
`skills/reviewer-runtime/pi/run-review.sh` and `notify-telegram.sh` are now
|
||||||
|
generated from the canonical base scripts. The "keep this file in sync"
|
||||||
|
comments are replaced by generated-file headers.
|
||||||
|
|
||||||
|
**S-306 — `sync:pi` and `verify:generated` implemented**
|
||||||
|
|
||||||
|
- `pnpm run sync:pi` now calls `node scripts/generate-skills.mjs`.
|
||||||
|
- `scripts/verify-generated.mjs` implements the full comparison contract:
|
||||||
|
walks declared roots only, uses `.generated-manifest.json` as oracle, reports
|
||||||
|
structured diffs, exits non-zero on any mismatch. 5 unit tests including the
|
||||||
|
required `_source/` stray-file boundary test.
|
||||||
|
- `pnpm run verify:generated` wired to the real implementation.
|
||||||
|
|
||||||
|
**S-307 — Workspace updated**
|
||||||
|
|
||||||
|
`pnpm-workspace.yaml` updated to **include** all generated agent-variant
|
||||||
|
packages (now uniquely named) alongside the canonical sources. The M1
|
||||||
|
negative-glob exclusions are replaced by positive includes.
|
||||||
|
|
||||||
|
**S-308 — Documentation updated**
|
||||||
|
|
||||||
|
- `docs/DEVELOPMENT.md`: new "How variants are generated" section with
|
||||||
|
canonical source table, generated-root list, contributor workflow, header
|
||||||
|
policy table, and manifest contract.
|
||||||
|
- `CHANGELOG.md` (this file): full rename table and story summaries.
|
||||||
|
|
||||||
|
### Byte-equivalence diff allow-list (M3 vs pre-M3 generated roots)
|
||||||
|
|
||||||
|
The only permitted diffs between the M3-generated output and the pre-M3
|
||||||
|
manually-maintained variants are:
|
||||||
|
|
||||||
|
1. **File-type-aware generated-file headers** — added per the policy table in
|
||||||
|
`docs/DEVELOPMENT.md`.
|
||||||
|
2. **New `.generated-manifest.json` files** — one per generated root.
|
||||||
|
3. **`package.json` `name` field** — renamed to `@ai-coding-skills/<skill>-<agent>`.
|
||||||
|
4. **`"private": true` added** — to each generated `package.json`.
|
||||||
|
|
||||||
|
No other content diffs are permitted; `verify:generated` and the generator
|
||||||
|
itself reject any other change.
|
||||||
|
|
||||||
|
### `pnpm run check` status after M3
|
||||||
|
|
||||||
|
```text
|
||||||
|
PASS lint (was FAIL; all pre-existing violations fixed)
|
||||||
|
PASS typecheck
|
||||||
|
PASS test
|
||||||
|
PASS verify:pi
|
||||||
|
PASS verify:reviewers
|
||||||
|
PASS verify:docs
|
||||||
|
PASS verify:generated (was stub; now real implementation)
|
||||||
|
```
|
||||||
|
|
||||||
|
All checks green for the first time.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Previous milestones (M1, M2) did not have a CHANGELOG.md entry. See
|
||||||
|
`docs/CLEANUP-BASELINE.md` for the M1 baseline and M2 delta.*
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
# ai-coding-skills
|
# ai-coding-skills
|
||||||
|
|
||||||
Cross-agent skill collection for **Codex**, **Claude Code**, **OpenCode**, and **Cursor**.
|
Cross-agent skill collection for **Codex**, **Claude Code**, **OpenCode**, **Cursor**, and **pi**.
|
||||||
|
|
||||||
|
Pi package support is also included for the pi-native variants in this repo.
|
||||||
|
|
||||||
This repo is organized similarly to `obra/superpowers` and is designed to scale to many skills over time.
|
This repo is organized similarly to `obra/superpowers` and is designed to scale to many skills over time.
|
||||||
|
|
||||||
@@ -14,44 +16,36 @@ This repo is organized similarly to `obra/superpowers` and is designed to scale
|
|||||||
```text
|
```text
|
||||||
ai-coding-skills/
|
ai-coding-skills/
|
||||||
├── README.md
|
├── README.md
|
||||||
├── docs/
|
├── docs/ — user-facing docs (see docs/README.md)
|
||||||
│ ├── README.md
|
|
||||||
│ ├── ATLASSIAN.md
|
|
||||||
│ ├── CREATE-PLAN.md
|
|
||||||
│ ├── IMPLEMENT-PLAN.md
|
|
||||||
│ └── WEB-AUTOMATION.md
|
|
||||||
├── skills/
|
├── skills/
|
||||||
│ ├── _template/
|
|
||||||
│ │ └── SKILL.md
|
|
||||||
│ ├── atlassian/
|
│ ├── atlassian/
|
||||||
│ │ ├── codex/
|
│ │ ├── codex/ claude-code/ cursor/ opencode/ pi/ shared/
|
||||||
│ │ ├── claude-code/
|
|
||||||
│ │ ├── cursor/
|
|
||||||
│ │ ├── opencode/
|
|
||||||
│ │ └── shared/
|
|
||||||
│ ├── create-plan/
|
│ ├── create-plan/
|
||||||
│ │ ├── codex/
|
│ │ ├── codex/ claude-code/ cursor/ opencode/ pi/
|
||||||
│ │ ├── claude-code/
|
│ ├── do-task/
|
||||||
│ │ ├── opencode/
|
│ │ ├── codex/ claude-code/ cursor/ opencode/ pi/
|
||||||
│ │ └── cursor/
|
|
||||||
│ ├── implement-plan/
|
│ ├── implement-plan/
|
||||||
│ │ ├── codex/
|
│ │ ├── codex/ claude-code/ cursor/ opencode/ pi/
|
||||||
│ │ ├── claude-code/
|
│ ├── reviewer-runtime/
|
||||||
│ │ ├── opencode/
|
│ │ ├── pi/ — Pi-specific run-review.sh + notify-telegram.sh
|
||||||
│ │ └── cursor/
|
│ │ └── tests/ — reviewer-runtime smoke tests
|
||||||
│ └── web-automation/
|
│ └── web-automation/
|
||||||
│ ├── codex/
|
│ ├── codex/ claude-code/ cursor/ opencode/ pi/
|
||||||
│ ├── claude-code/
|
├── pi-package/
|
||||||
│ └── opencode/
|
│ └── skills/ — Pi-facing mirror synced by sync:pi
|
||||||
├── .codex/
|
├── scripts/
|
||||||
├── .claude-plugin/
|
│ ├── lib/ — shared Node helpers + portable.sh
|
||||||
├── .opencode/
|
│ └── tests/ — Node.js unit tests
|
||||||
│ └── plugins/
|
├── package.json
|
||||||
├── commands/
|
└── pnpm-workspace.yaml
|
||||||
├── hooks/
|
|
||||||
└── tests/
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Where to Read Next
|
||||||
|
|
||||||
|
See **[docs/README.md](docs/README.md)** for the full documentation index with
|
||||||
|
ordered reading flow, one-line summaries for every doc, and links to per-agent
|
||||||
|
install guides, skill docs, Pi docs, Telegram setup, and development notes.
|
||||||
|
|
||||||
## Skills
|
## Skills
|
||||||
|
|
||||||
| Skill | Agent Variant | Purpose | Status | Docs |
|
| Skill | Agent Variant | Purpose | Status | Docs |
|
||||||
@@ -60,24 +54,135 @@ ai-coding-skills/
|
|||||||
| atlassian | claude-code | Portable Jira and Confluence workflows through a shared Cloud-first CLI | Ready | [ATLASSIAN](docs/ATLASSIAN.md) |
|
| atlassian | claude-code | Portable Jira and Confluence workflows through a shared Cloud-first CLI | Ready | [ATLASSIAN](docs/ATLASSIAN.md) |
|
||||||
| atlassian | opencode | Portable Jira and Confluence workflows through a shared Cloud-first CLI | Ready | [ATLASSIAN](docs/ATLASSIAN.md) |
|
| atlassian | opencode | Portable Jira and Confluence workflows through a shared Cloud-first CLI | Ready | [ATLASSIAN](docs/ATLASSIAN.md) |
|
||||||
| atlassian | cursor | Portable Jira and Confluence workflows through a shared Cloud-first CLI | Ready | [ATLASSIAN](docs/ATLASSIAN.md) |
|
| atlassian | cursor | Portable Jira and Confluence workflows through a shared Cloud-first CLI | Ready | [ATLASSIAN](docs/ATLASSIAN.md) |
|
||||||
|
| atlassian | pi | Portable Jira and Confluence workflows through a shared Cloud-first CLI | Ready | [ATLASSIAN](docs/ATLASSIAN.md) |
|
||||||
| create-plan | codex | Structured planning with milestones, iterative cross-model review, and runbook-first execution workflow | Ready | [CREATE-PLAN](docs/CREATE-PLAN.md) |
|
| create-plan | codex | Structured planning with milestones, iterative cross-model review, and runbook-first execution workflow | Ready | [CREATE-PLAN](docs/CREATE-PLAN.md) |
|
||||||
| create-plan | claude-code | Structured planning with milestones, iterative cross-model review, and runbook-first execution workflow | Ready | [CREATE-PLAN](docs/CREATE-PLAN.md) |
|
| create-plan | claude-code | Structured planning with milestones, iterative cross-model review, and runbook-first execution workflow | Ready | [CREATE-PLAN](docs/CREATE-PLAN.md) |
|
||||||
| create-plan | opencode | Structured planning with milestones, iterative cross-model review, and runbook-first execution workflow | Ready | [CREATE-PLAN](docs/CREATE-PLAN.md) |
|
| create-plan | opencode | Structured planning with milestones, iterative cross-model review, and runbook-first execution workflow | Ready | [CREATE-PLAN](docs/CREATE-PLAN.md) |
|
||||||
| create-plan | cursor | Structured planning with milestones, iterative cross-model review, and runbook-first execution workflow | Ready | [CREATE-PLAN](docs/CREATE-PLAN.md) |
|
| create-plan | cursor | Structured planning with milestones, iterative cross-model review, and runbook-first execution workflow | Ready | [CREATE-PLAN](docs/CREATE-PLAN.md) |
|
||||||
|
| create-plan | pi | Structured planning with milestones, iterative cross-model review, and runbook-first execution workflow | Ready | [CREATE-PLAN](docs/CREATE-PLAN.md) |
|
||||||
|
| do-task | codex | Single-prompt end-to-end execution with dual reviewer loops (plan + implementation), TDD-first, single task commit | Ready | [DO-TASK](docs/DO-TASK.md) |
|
||||||
|
| do-task | claude-code | Single-prompt end-to-end execution with dual reviewer loops (plan + implementation), TDD-first, single task commit | Ready | [DO-TASK](docs/DO-TASK.md) |
|
||||||
|
| do-task | opencode | Single-prompt end-to-end execution with dual reviewer loops (plan + implementation), TDD-first, single task commit | Ready | [DO-TASK](docs/DO-TASK.md) |
|
||||||
|
| do-task | cursor | Single-prompt end-to-end execution with dual reviewer loops (plan + implementation), TDD-first, single task commit | Ready | [DO-TASK](docs/DO-TASK.md) |
|
||||||
|
| do-task | pi | Single-prompt end-to-end execution with dual reviewer loops (plan + implementation), TDD-first, single task commit | Ready | [DO-TASK](docs/DO-TASK.md) |
|
||||||
| implement-plan | codex | Worktree-isolated plan execution with iterative cross-model milestone review | Ready | [IMPLEMENT-PLAN](docs/IMPLEMENT-PLAN.md) |
|
| implement-plan | codex | Worktree-isolated plan execution with iterative cross-model milestone review | Ready | [IMPLEMENT-PLAN](docs/IMPLEMENT-PLAN.md) |
|
||||||
| implement-plan | claude-code | Worktree-isolated plan execution with iterative cross-model milestone review | Ready | [IMPLEMENT-PLAN](docs/IMPLEMENT-PLAN.md) |
|
| implement-plan | claude-code | Worktree-isolated plan execution with iterative cross-model milestone review | Ready | [IMPLEMENT-PLAN](docs/IMPLEMENT-PLAN.md) |
|
||||||
| implement-plan | opencode | Worktree-isolated plan execution with iterative cross-model milestone review | Ready | [IMPLEMENT-PLAN](docs/IMPLEMENT-PLAN.md) |
|
| implement-plan | opencode | Worktree-isolated plan execution with iterative cross-model milestone review | Ready | [IMPLEMENT-PLAN](docs/IMPLEMENT-PLAN.md) |
|
||||||
| implement-plan | cursor | Worktree-isolated plan execution with iterative cross-model milestone review | Ready | [IMPLEMENT-PLAN](docs/IMPLEMENT-PLAN.md) |
|
| implement-plan | cursor | Worktree-isolated plan execution with iterative cross-model milestone review | Ready | [IMPLEMENT-PLAN](docs/IMPLEMENT-PLAN.md) |
|
||||||
| web-automation | codex | Playwright + Camoufox browsing/scraping/auth automation | Ready | [WEB-AUTOMATION](docs/WEB-AUTOMATION.md) |
|
| implement-plan | pi | Worktree-isolated plan execution with iterative cross-model milestone review | Ready | [IMPLEMENT-PLAN](docs/IMPLEMENT-PLAN.md) |
|
||||||
| web-automation | claude-code | Playwright + Camoufox browsing/scraping/auth automation | 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 | opencode | Playwright + Camoufox browsing/scraping/auth automation | Ready | [WEB-AUTOMATION](docs/WEB-AUTOMATION.md) |
|
| web-automation | claude-code | CloakBrowser-backed browsing, scraping, auth, flow automation, and install validation | Ready | [WEB-AUTOMATION](docs/WEB-AUTOMATION.md) |
|
||||||
|
| web-automation | cursor | CloakBrowser-backed browsing, scraping, auth, flow automation, and install validation | Ready | [WEB-AUTOMATION](docs/WEB-AUTOMATION.md) |
|
||||||
|
| web-automation | opencode | CloakBrowser-backed browsing, scraping, auth, flow automation, and install validation | Ready | [WEB-AUTOMATION](docs/WEB-AUTOMATION.md) |
|
||||||
|
| web-automation | pi | CloakBrowser-backed browsing, scraping, auth, flow automation, and install validation | Ready | [WEB-AUTOMATION](docs/WEB-AUTOMATION.md) |
|
||||||
|
|
||||||
- Docs index: `docs/README.md`
|
- 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`
|
||||||
- Implement-plan guide: `docs/IMPLEMENT-PLAN.md`
|
- Skill guides: `docs/ATLASSIAN.md`, `docs/CREATE-PLAN.md`, `docs/DO-TASK.md`, `docs/IMPLEMENT-PLAN.md`, `docs/WEB-AUTOMATION.md`
|
||||||
- Web-automation guide: `docs/WEB-AUTOMATION.md`
|
- Shared workflow setup: `docs/TELEGRAM-NOTIFICATIONS.md`, `docs/PI-SUPERPOWERS.md`, `docs/PI-COMMON-REVIEWER.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
|
||||||
|
|
||||||
|
The repo root now includes a pi package manifest that ships only the pi-specific surface:
|
||||||
|
|
||||||
|
- `pi-package/skills/*/` for the five packaged Pi skills
|
||||||
|
- `skills/reviewer-runtime/pi/`
|
||||||
|
- `docs/PI*.md`
|
||||||
|
- `scripts/manage-skills.mjs` and `scripts/manage-skills.sh`
|
||||||
|
- `scripts/generate-skills.mjs`
|
||||||
|
- `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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/install-pi-package.sh --global
|
||||||
|
```
|
||||||
|
|
||||||
|
For a project-local install instead:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/install-pi-package.sh --local
|
||||||
|
```
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
|
||||||
|
- Node.js 20+
|
||||||
|
- `pi`
|
||||||
|
- either `pnpm` on `PATH`, or `corepack` support from the Node install
|
||||||
|
|
||||||
|
The repo pins its pnpm version in `package.json` so Corepack-backed installs resolve consistently.
|
||||||
|
|
||||||
|
Before publishing or sharing a tarball, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm run sync:pi
|
||||||
|
npm run verify:pi
|
||||||
|
npm run verify:reviewers
|
||||||
|
npm pack --dry-run --json
|
||||||
|
```
|
||||||
|
|
||||||
|
Additional pi-specific guidance lives in [docs/PI.md](docs/PI.md).
|
||||||
|
|
||||||
|
For development prerequisites, quality tooling, and the `pnpm run check`
|
||||||
|
contract, see [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md).
|
||||||
|
|
||||||
|
## Contributing / Development
|
||||||
|
|
||||||
|
See **[docs/DEVELOPMENT.md](docs/DEVELOPMENT.md)** for the full development guide.
|
||||||
|
|
||||||
|
Quick reference — the single quality gate every contributor must pass:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm run check # lint · typecheck · test · verify:pi · verify:reviewers · verify:docs · verify:generated
|
||||||
|
```
|
||||||
|
|
||||||
|
Prerequisites: Node.js 20+, pnpm 10+, `shellcheck` (brew / apt), `ripgrep` (brew / apt).
|
||||||
|
|
||||||
|
CI runs the same `pnpm run check` command on both `ubuntu-latest` and `macos-latest`
|
||||||
|
(see `.github/workflows/check.yml`). External link checking runs separately on a
|
||||||
|
weekly schedule (`.github/workflows/check-online.yml`).
|
||||||
|
|||||||
+37
-7
@@ -2,13 +2,17 @@
|
|||||||
|
|
||||||
## 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
|
||||||
|
|
||||||
The repo targets multiple agent environments with uneven MCP availability. This skill packages a consistent CLI contract so the same task-oriented workflows can be used across all supported agents without depending on MCP-specific integrations.
|
The repo targets multiple agent environments with uneven MCP availability. This skill packages a
|
||||||
|
consistent CLI contract so the same task-oriented workflows can be used across all supported agents
|
||||||
|
without depending on MCP-specific integrations.
|
||||||
|
|
||||||
The canonical runtime lives in `skills/atlassian/shared/scripts/`. Installable per-agent `scripts/` bundles are generated from that source with:
|
The canonical runtime lives in `skills/atlassian/shared/scripts/`. Installable per-agent
|
||||||
|
`scripts/` bundles are generated from that source with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm --dir skills/atlassian/shared/scripts sync:agents
|
pnpm --dir skills/atlassian/shared/scripts sync:agents
|
||||||
@@ -23,7 +27,8 @@ pnpm --dir skills/atlassian/shared/scripts sync:agents
|
|||||||
- `ATLASSIAN_EMAIL`
|
- `ATLASSIAN_EMAIL`
|
||||||
- `ATLASSIAN_API_TOKEN`
|
- `ATLASSIAN_API_TOKEN`
|
||||||
|
|
||||||
The `ATLASSIAN_*` values may come from the shell environment or a `.env` file in the installed agent-specific `scripts/` folder.
|
The `ATLASSIAN_*` values may come from the shell environment or a `.env` file in the installed
|
||||||
|
agent-specific `scripts/` folder.
|
||||||
|
|
||||||
Optional:
|
Optional:
|
||||||
|
|
||||||
@@ -52,12 +57,15 @@ Optional:
|
|||||||
|
|
||||||
## Command Notes
|
## Command Notes
|
||||||
|
|
||||||
- `health` validates local configuration, probes Jira and Confluence separately, and reports one product as unavailable without masking the other.
|
- `health` validates local configuration, probes Jira and Confluence separately, and reports one
|
||||||
|
product as unavailable without masking the other.
|
||||||
- `jira-create` requires `--type`, `--summary`, and either `--project` or `ATLASSIAN_DEFAULT_PROJECT`.
|
- `jira-create` requires `--type`, `--summary`, and either `--project` or `ATLASSIAN_DEFAULT_PROJECT`.
|
||||||
- `jira-update` requires `--issue` and at least one of `--summary` or `--description-file`.
|
- `jira-update` requires `--issue` and at least one of `--summary` or `--description-file`.
|
||||||
- `conf-create` requires `--title`, `--body-file`, and either `--space` or `ATLASSIAN_DEFAULT_SPACE`.
|
- `conf-create` requires `--title`, `--body-file`, and either `--space` or `ATLASSIAN_DEFAULT_SPACE`.
|
||||||
- `conf-update` requires `--page`, `--title`, and `--body-file`; it fetches the current page version before building the update payload.
|
- `conf-update` requires `--page`, `--title`, and `--body-file`; it fetches the current page
|
||||||
- `raw --body-file` expects a workspace-scoped JSON file and is limited to validated Atlassian API prefixes.
|
version before building the update payload.
|
||||||
|
- `raw --body-file` expects a workspace-scoped JSON file and is limited to validated Atlassian
|
||||||
|
API prefixes.
|
||||||
|
|
||||||
## Safety Model
|
## Safety Model
|
||||||
|
|
||||||
@@ -117,6 +125,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
|
||||||
|
pnpm run sync:pi
|
||||||
|
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,61 @@
|
|||||||
|
# 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/writing-plans/SKILL.md
|
||||||
|
test -f ~/.claude/skills/superpowers/test-driven-development/SKILL.md
|
||||||
|
test -f ~/.claude/skills/superpowers/verification-before-completion/SKILL.md
|
||||||
|
test -f ~/.claude/skills/superpowers/finishing-a-development-branch/SKILL.md
|
||||||
|
test -f ~/.claude/skills/superpowers/executing-plans/SKILL.md
|
||||||
|
test -f ~/.claude/skills/superpowers/using-git-worktrees/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,558 @@
|
|||||||
|
# Cleanup Baseline — ai-coding-skills (M1)
|
||||||
|
|
||||||
|
Captured: 2026-05-03 · Platform: macOS (arm64, Apple Silicon) · Node 22.14.0 · pnpm 10.18.1
|
||||||
|
|
||||||
|
This document records the as-is state of every quality check at the start of
|
||||||
|
the refactor series. All pre-existing failures listed here have since been
|
||||||
|
resolved. As of M5 `pnpm run check` exits **0** on a clean checkout. Any
|
||||||
|
failure now represents a regression and must be fixed before merge.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Byte-equivalence assertion
|
||||||
|
|
||||||
|
Before any file was modified in M1, `git status` showed a clean working tree.
|
||||||
|
After all S-101 through S-107 changes were applied, `git status` confirms:
|
||||||
|
|
||||||
|
- **Zero** modifications to any nested `package.json` under `skills/` or `pi-package/`.
|
||||||
|
- **Zero** modifications to any file under `skills/*/{codex,claude-code,cursor,opencode,pi}/`
|
||||||
|
(the generated agent-variant directories).
|
||||||
|
- All changes are confined to new root-level config files, new scripts, updated
|
||||||
|
root `package.json` (devDependencies + scripts), and new docs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Pre-existing root scripts
|
||||||
|
|
||||||
|
### `pnpm run sync:pi`
|
||||||
|
|
||||||
|
```text
|
||||||
|
Synced pi package skill mirror into …/pi-package/skills.
|
||||||
|
EXIT: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### `pnpm run verify:pi`
|
||||||
|
|
||||||
|
```text
|
||||||
|
package metadata ok
|
||||||
|
pi resources verified
|
||||||
|
pi workflow skill docs verified
|
||||||
|
EXIT: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### `pnpm run verify:reviewers`
|
||||||
|
|
||||||
|
```text
|
||||||
|
reviewer support verified
|
||||||
|
EXIT: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### `pnpm run test:installer`
|
||||||
|
|
||||||
|
```text
|
||||||
|
TAP version 13
|
||||||
|
# tests 22
|
||||||
|
# pass 22
|
||||||
|
# fail 0
|
||||||
|
EXIT: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Nested package scripts
|
||||||
|
|
||||||
|
### atlassian/shared/scripts — `test`
|
||||||
|
|
||||||
|
```text
|
||||||
|
TAP version 13
|
||||||
|
# tests 23
|
||||||
|
# pass 23
|
||||||
|
# fail 0
|
||||||
|
EXIT: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### atlassian/shared/scripts — `typecheck`
|
||||||
|
|
||||||
|
```text
|
||||||
|
EXIT: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### atlassian/codex/scripts — `typecheck`
|
||||||
|
|
||||||
|
```text
|
||||||
|
EXIT: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### atlassian/claude-code/scripts — `typecheck`
|
||||||
|
|
||||||
|
```text
|
||||||
|
EXIT: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### atlassian/cursor/scripts — `typecheck`
|
||||||
|
|
||||||
|
```text
|
||||||
|
EXIT: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### atlassian/opencode/scripts — `typecheck`
|
||||||
|
|
||||||
|
```text
|
||||||
|
EXIT: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### atlassian/pi/scripts — `typecheck`
|
||||||
|
|
||||||
|
```text
|
||||||
|
EXIT: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### web-automation/codex/scripts — `typecheck`
|
||||||
|
|
||||||
|
```text
|
||||||
|
EXIT: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### web-automation/codex/scripts — `lint`
|
||||||
|
|
||||||
|
```text
|
||||||
|
EXIT: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### web-automation/claude-code/scripts — `typecheck`
|
||||||
|
|
||||||
|
```text
|
||||||
|
EXIT: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### web-automation/cursor/scripts — `typecheck`
|
||||||
|
|
||||||
|
```text
|
||||||
|
EXIT: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### web-automation/opencode/scripts — `typecheck`
|
||||||
|
|
||||||
|
```text
|
||||||
|
EXIT: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### web-automation/pi/scripts — `typecheck`
|
||||||
|
|
||||||
|
```text
|
||||||
|
EXIT: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. reviewer-runtime tests
|
||||||
|
|
||||||
|
All three test scripts under `skills/reviewer-runtime/tests/` passed:
|
||||||
|
|
||||||
|
```text
|
||||||
|
claude-review-template-guard.sh PASS
|
||||||
|
smoke-test.sh PASS
|
||||||
|
telegram-notify-test.sh PASS
|
||||||
|
EXIT: 0 (each)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. M1 quality tooling — baseline violations
|
||||||
|
|
||||||
|
### 4a. ESLint (`pnpm run lint` — ESLint stage)
|
||||||
|
|
||||||
|
Exit code: **1** — 2 pre-existing errors (no new errors from M1 additions).
|
||||||
|
|
||||||
|
Files with violations in the existing codebase:
|
||||||
|
|
||||||
|
| File | Line | Rule | Message |
|
||||||
|
|------|------|------|---------|
|
||||||
|
| `scripts/lib/skill-manager-core.mjs` | 282 | `no-useless-assignment` | Value assigned to `entries` is not used |
|
||||||
|
| `scripts/manage-skills.mjs` | 270 | `no-unused-vars` | `client` is assigned a value but never used |
|
||||||
|
|
||||||
|
Action: Fix in a later milestone. Do not suppress with eslint-disable comments
|
||||||
|
unless the code is intentionally dead.
|
||||||
|
|
||||||
|
### 4b. shellcheck (`pnpm run lint` — shellcheck stage)
|
||||||
|
|
||||||
|
Exit code: **1** — 7 files with pre-existing findings (no new violations from M1).
|
||||||
|
|
||||||
|
| File | Finding |
|
||||||
|
|------|---------|
|
||||||
|
| `scripts/verify-pi-resources.sh` | SC2016 (info, ×3): single-quoted backtick strings; SC2251 (info, ×1): `!` outside condition |
|
||||||
|
| `scripts/verify-pi-workflows.sh` | SC2016 (info, ×2): single-quoted backtick strings; SC2251 (info, ×1): `!` outside condition |
|
||||||
|
| `skills/reviewer-runtime/pi/run-review.sh` | SC2329 (info): unused `handle_signal`; SC2034 (warning, ×2): unused variables |
|
||||||
|
| `skills/reviewer-runtime/run-review.sh` | SC2329 (info): unused `handle_signal`; SC2034 (warning, ×2): unused variables |
|
||||||
|
| `skills/reviewer-runtime/tests/claude-review-template-guard.sh` | SC2016 (info, ×1): single-quoted expansion |
|
||||||
|
| `skills/reviewer-runtime/tests/smoke-test.sh` | SC2064 (warning, ×1): `trap` should use single quotes |
|
||||||
|
| `skills/reviewer-runtime/tests/telegram-notify-test.sh` | SC2064 (warning, ×1): `trap` should use single quotes |
|
||||||
|
|
||||||
|
The following files pass shellcheck with EXIT 0:
|
||||||
|
|
||||||
|
- `scripts/install-pi-package.sh`
|
||||||
|
- `scripts/manage-skills.sh`
|
||||||
|
- `scripts/sync-pi-package-skills.sh`
|
||||||
|
- `scripts/verify-reviewer-support.sh`
|
||||||
|
- `skills/reviewer-runtime/notify-telegram.sh`
|
||||||
|
- `skills/reviewer-runtime/pi/notify-telegram.sh`
|
||||||
|
- `skills/web-automation/scripts/sync-variants.sh`
|
||||||
|
|
||||||
|
Action: Fix `SC2064` trap issues and `SC2034` warnings in a later milestone.
|
||||||
|
SC2016 findings are intentional (single-quoted strings containing backticks are
|
||||||
|
used as literal grep patterns to match markdown text).
|
||||||
|
|
||||||
|
### 4c. markdownlint (`pnpm run verify:docs`)
|
||||||
|
|
||||||
|
Exit code: **1** — 1160 errors across 62 files (no new violations from M1 additions).
|
||||||
|
|
||||||
|
markdownlint-cli2 v0.22.1 scanned `README.md`, `docs/**/*.md`, and canonical
|
||||||
|
`SKILL.md` files (excluding node\_modules and generated agent-variant
|
||||||
|
directories).
|
||||||
|
|
||||||
|
Rule breakdown (selected):
|
||||||
|
|
||||||
|
| Rule | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| MD013 | Line length > 120 chars (majority of errors) |
|
||||||
|
| MD012 | Multiple consecutive blank lines |
|
||||||
|
| MD024 | Duplicate headings |
|
||||||
|
| MD029 | Ordered list item prefix |
|
||||||
|
| MD032 | Blanks around lists |
|
||||||
|
| MD038 | Spaces inside code span |
|
||||||
|
| MD040 | Fenced code without language |
|
||||||
|
| MD058 | Blanks around tables |
|
||||||
|
|
||||||
|
Affected files include (non-exhaustive): `docs/ATLASSIAN.md`,
|
||||||
|
`docs/CREATE-PLAN.md`, `docs/DO-TASK.md`, `docs/IMPLEMENT-PLAN.md`,
|
||||||
|
`docs/INSTALLER.md`, `docs/PI-COMMON-REVIEWER.md`, `docs/PI-RESEARCH.md`,
|
||||||
|
`docs/PI-SUPERPOWERS.md`, `docs/PI.md`, `docs/README.md`,
|
||||||
|
`docs/TELEGRAM-NOTIFICATIONS.md`, `docs/WEB-AUTOMATION.md`, `README.md`,
|
||||||
|
and all `skills/*/*/SKILL.md` variants.
|
||||||
|
|
||||||
|
Action: Bulk-fix in a dedicated doc-cleanup milestone. New docs added in M1
|
||||||
|
(`docs/DEVELOPMENT.md`, `docs/CLEANUP-BASELINE.md`) pass all enabled rules.
|
||||||
|
|
||||||
|
### 4d. markdown-link-check (offline, `pnpm run verify:docs`)
|
||||||
|
|
||||||
|
Exit code: **0** — no broken repo-relative or anchor links found.
|
||||||
|
|
||||||
|
53 markdown files scanned (offline mode: external http/https links ignored).
|
||||||
|
All internal links are valid.
|
||||||
|
|
||||||
|
### 4e. `pnpm run typecheck` (workspace aggregate)
|
||||||
|
|
||||||
|
Exit code: **0**.
|
||||||
|
|
||||||
|
Ran `typecheck` in workspace members:
|
||||||
|
|
||||||
|
- `skills/atlassian/shared/scripts` — PASS
|
||||||
|
- `skills/web-automation/codex/scripts` — PASS
|
||||||
|
|
||||||
|
### 4f. `pnpm run test` (workspace aggregate)
|
||||||
|
|
||||||
|
Exit code: **0**.
|
||||||
|
|
||||||
|
Ran:
|
||||||
|
|
||||||
|
- `pnpm run test:installer` (22/22 tests)
|
||||||
|
- `skills/atlassian/shared/scripts test` (23/23 tests)
|
||||||
|
|
||||||
|
### 4g. `pnpm run check` aggregate (M1 transitional state)
|
||||||
|
|
||||||
|
```text
|
||||||
|
FAIL lint (exit 1) — ESLint + shellcheck pre-existing violations (§4a, §4b)
|
||||||
|
PASS typecheck
|
||||||
|
PASS test
|
||||||
|
PASS verify:pi
|
||||||
|
PASS verify:reviewers
|
||||||
|
FAIL verify:docs (exit 1) — markdownlint pre-existing violations (§4c)
|
||||||
|
PASS verify:generated (stub)
|
||||||
|
```
|
||||||
|
|
||||||
|
Overall exit: **1** — expected per transitional contract; all failures are
|
||||||
|
pre-existing and documented in this file.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Platform notes
|
||||||
|
|
||||||
|
- Tested on macOS 15 (arm64). No GNU/Linux run available at M1 capture time.
|
||||||
|
- shellcheck 0.11.0 installed via `brew install shellcheck`.
|
||||||
|
- `stat` variant: BSD stat (macOS default). The `scripts/` shell scripts do
|
||||||
|
not call `stat`; this affects only M5 CI configuration.
|
||||||
|
- `rg` (ripgrep) is required by `scripts/verify-pi-workflows.sh`. It is
|
||||||
|
present on the test machine; absent systems should install via
|
||||||
|
`brew install ripgrep` / `apt-get install ripgrep`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*This file is generated once (M1) and updated only when a subsequent milestone
|
||||||
|
changes the baseline status of a check.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Post-M3 state
|
||||||
|
|
||||||
|
Captured: 2026-05-03 · Platform: macOS 15 (arm64) · Node 22.14.0 · pnpm 10.18.1
|
||||||
|
|
||||||
|
M3 resolved all remaining pre-existing `lint` violations (2 ESLint errors and
|
||||||
|
7 shellcheck findings). `verify:generated` is now a real implementation
|
||||||
|
(was a stub in M2).
|
||||||
|
|
||||||
|
### `pnpm run check` aggregate (post-M3)
|
||||||
|
|
||||||
|
```text
|
||||||
|
PASS lint (was FAIL in M1/M2; pre-existing violations fixed in M3)
|
||||||
|
PASS typecheck
|
||||||
|
PASS test
|
||||||
|
PASS verify:pi
|
||||||
|
PASS verify:reviewers
|
||||||
|
PASS verify:docs
|
||||||
|
PASS verify:generated (was stub in M2; real implementation in M3)
|
||||||
|
```
|
||||||
|
|
||||||
|
Overall exit: **0** — all checks green for the first time.
|
||||||
|
|
||||||
|
### What changed in M3
|
||||||
|
|
||||||
|
- `scripts/generate-skills.mjs` added (generator for all 31 agent-variant roots).
|
||||||
|
- `scripts/verify-generated.mjs` added (drift detector).
|
||||||
|
- Canonical source directories created: `skills/<skill>/_source/`,
|
||||||
|
`skills/web-automation/shared/`.
|
||||||
|
- All 31 generated roots now carry `.generated-manifest.json` and file-type-aware
|
||||||
|
headers.
|
||||||
|
- `package.json` renames: all generated agent-variant packages renamed to
|
||||||
|
`@ai-coding-skills/<skill>-<agent>` with `"private": true`.
|
||||||
|
- `pnpm-workspace.yaml` updated: M1 negative-glob exclusions replaced by
|
||||||
|
positive includes for all uniquely-named generated variants.
|
||||||
|
- Pre-existing ESLint violations fixed: `skill-manager-core.mjs:282`
|
||||||
|
(`no-useless-assignment`) and `manage-skills.mjs:270` (`no-unused-vars`).
|
||||||
|
- Pre-existing shellcheck findings fixed: SC2034 and SC2329 in
|
||||||
|
`reviewer-runtime/run-review.sh`; SC2064 in test trap statements;
|
||||||
|
SC2016 / SC2251 in verify scripts (suppress intentional patterns).
|
||||||
|
- `pnpm run sync:pi` now calls the generator instead of
|
||||||
|
`sync-pi-package-skills.sh`.
|
||||||
|
- `docs/DEVELOPMENT.md` extended with generation workflow and metadata docs.
|
||||||
|
- `CHANGELOG.md` created with package-metadata change callout.
|
||||||
|
|
||||||
|
### Full byte-equivalence diff (M3 vs pre-M3)
|
||||||
|
|
||||||
|
See `CHANGELOG.md` § "Byte-equivalence diff allow-list" for the complete
|
||||||
|
documented diff between M3-generated output and the pre-M3 variants. The
|
||||||
|
only permitted diffs are headers, `.generated-manifest.json`, `name` rename,
|
||||||
|
and `"private": true`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Post-M2 state
|
||||||
|
|
||||||
|
Captured: 2026-05-03 · Platform: macOS 15 (arm64) · Node 22.14.0 · pnpm 10.18.1
|
||||||
|
|
||||||
|
M2 resolved all `verify:docs` violations and the `verify:pi` portability
|
||||||
|
issue (`stat -f` BSD-ism in `scripts/verify-pi-resources.sh`).
|
||||||
|
|
||||||
|
### `pnpm run check` aggregate (post-M2)
|
||||||
|
|
||||||
|
```text
|
||||||
|
FAIL lint (exit 1) — same 2 ESLint + 7 shellcheck pre-existing violations (§4a, §4b)
|
||||||
|
PASS typecheck
|
||||||
|
PASS test
|
||||||
|
PASS verify:pi
|
||||||
|
PASS verify:reviewers
|
||||||
|
PASS verify:docs (was FAIL in M1; now clean)
|
||||||
|
PASS verify:generated (stub)
|
||||||
|
```
|
||||||
|
|
||||||
|
Overall exit: **1** — only `lint` still fails on pre-existing violations.
|
||||||
|
`verify:docs` is now green (0 markdownlint errors, offline link-check clean,
|
||||||
|
docs-flow verifier passes).
|
||||||
|
|
||||||
|
### What changed in M2
|
||||||
|
|
||||||
|
- `scripts/lib/portable.sh` added with `portable_stat_perms` helper.
|
||||||
|
- `scripts/verify-pi-resources.sh` updated to use `portable_stat_perms`
|
||||||
|
(replaces `stat -f '%Lp'` BSD-only call).
|
||||||
|
- `scripts/lib/run-shellcheck.mjs` updated to pass `-x --source-path=SCRIPTDIR`
|
||||||
|
so shellcheck follows `source` directives correctly on both platforms.
|
||||||
|
- `skills/.markdownlint.jsonc` added to disable MD013 and MD024 for
|
||||||
|
agent-facing SKILL.md files.
|
||||||
|
- All 1160 M1 markdownlint violations resolved (auto-fix + targeted edits).
|
||||||
|
- `docs/README.md` reorganized into ordered reading flow.
|
||||||
|
- `README.md` layout section updated to reflect actual repo tree.
|
||||||
|
- `docs/REVIEWERS.md` added as canonical reviewer CLI matrix.
|
||||||
|
- `docs/TELEGRAM-NOTIFICATIONS.md` extended with Pi section.
|
||||||
|
- `docs/CREATE-PLAN.md`, `docs/IMPLEMENT-PLAN.md`, `docs/DO-TASK.md` updated
|
||||||
|
with REVIEWERS.md references and unique variant headings.
|
||||||
|
- OpenCode reviewer branches added to `skills/create-plan/opencode/SKILL.md`
|
||||||
|
and `skills/implement-plan/opencode/SKILL.md`.
|
||||||
|
- `scripts/verify-docs-flow.mjs` implemented and wired into `verify:docs`.
|
||||||
|
- `pnpm run verify:docs:online` added for external link checks.
|
||||||
|
|
||||||
|
### Ubuntu smoke note
|
||||||
|
|
||||||
|
No Docker/Ubuntu run was available at M2 capture time. The portability
|
||||||
|
fix (`portable_stat_perms`) replaces the only identified BSD-ism. The
|
||||||
|
Ubuntu Docker command is documented in
|
||||||
|
[DEVELOPMENT.md](./DEVELOPMENT.md#cross-platform-shell-support-m2).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Post-M4 state
|
||||||
|
|
||||||
|
Captured: 2026-05-03 · Platform: macOS 15 (arm64) · Node 22.14.0 · pnpm 10.18.1
|
||||||
|
|
||||||
|
M4 extracted reusable abstractions, consolidated shared helpers, tightened
|
||||||
|
types, and removed the legacy dead-code path. `pnpm run check` remains fully
|
||||||
|
green.
|
||||||
|
|
||||||
|
### `pnpm run check` aggregate (post-M4)
|
||||||
|
|
||||||
|
```text
|
||||||
|
PASS lint
|
||||||
|
PASS typecheck
|
||||||
|
PASS test
|
||||||
|
PASS verify:pi
|
||||||
|
PASS verify:reviewers
|
||||||
|
PASS verify:docs
|
||||||
|
PASS verify:generated
|
||||||
|
```
|
||||||
|
|
||||||
|
Overall exit: **0** — all checks green (no regressions from M3).
|
||||||
|
|
||||||
|
### What changed in M4
|
||||||
|
|
||||||
|
- **S-401** — `scripts/lib/safe-replace-dir.mjs` added: Node.js helper that
|
||||||
|
validates a target is a strict descendant of a safety root before replacing
|
||||||
|
it. Thin shell wrapper `scripts/lib/safe-replace-dir.sh` provided for
|
||||||
|
sourcing in shell scripts. `scripts/sync-pi-package-skills.sh` updated to
|
||||||
|
use `safe_replace_dir` from the shared helper (inline `replace_dir` removed).
|
||||||
|
|
||||||
|
- **S-402** — `removeTarget(op)` extracted from `executeOperation()` in
|
||||||
|
`scripts/lib/skill-manager-core.mjs` and exported. The helper handles
|
||||||
|
skill, helper, and symlink removal with idempotent semantics.
|
||||||
|
`executeOperation` now delegates to `removeTarget` for all remove branches.
|
||||||
|
|
||||||
|
- **S-403** — `skills/atlassian/shared/scripts/src/command-helpers.ts` added
|
||||||
|
with `dryRunResponse<T>()` and `resolveFormat()` helpers. `confluence.ts`,
|
||||||
|
`jira.ts`, and `raw.ts` consume `dryRunResponse` (8 inline objects removed).
|
||||||
|
`cli.ts` imports `resolveFormat` from `command-helpers` instead of defining
|
||||||
|
it locally. All atlassian agent variants regenerated.
|
||||||
|
|
||||||
|
- **S-404** — `skills/web-automation/shared/lib/browser.ts` created with
|
||||||
|
`getProfilePath`, `launchBrowser`, and `getPage`. `browse.ts` imports and
|
||||||
|
re-exports them. `auth.ts`, `flow.ts`, and `scan-local-app.ts` now import
|
||||||
|
directly from `lib/browser.js`. Generator updated to include `lib/`
|
||||||
|
directory in `scriptFiles` for web-automation variants. `tsconfig.json`
|
||||||
|
updated to include `lib/**/*.ts`.
|
||||||
|
|
||||||
|
- **S-405** — `scan-local-app.ts` `page: any` parameters replaced with
|
||||||
|
`Page` from `playwright-core`. Added `GotoError` discriminated type to
|
||||||
|
narrow the `page.goto().catch()` union type safely.
|
||||||
|
|
||||||
|
- **S-406** — `scripts/sync-pi-package-skills.sh` deleted (retired in M3,
|
||||||
|
inline `replace_dir` migrated to shared helper as part of S-401). Comment
|
||||||
|
in `skill-manager-core.mjs` referencing the deleted file updated.
|
||||||
|
Generator's `clearGeneratedRoot` fixed to preserve `node_modules` at all
|
||||||
|
depths (was only protected at root level, causing pnpm workspace packages
|
||||||
|
inside `scripts/` subdirs to lose their node_modules on regeneration).
|
||||||
|
|
||||||
|
- **S-407** — Tests added:
|
||||||
|
- `scripts/tests/safe-replace-dir.test.mjs` (6 tests for S-401 helper)
|
||||||
|
- `scripts/tests/skill-manager-core-remove.test.mjs` (5 tests for S-402)
|
||||||
|
- `skills/atlassian/shared/scripts/tests/command-helpers.test.ts` (7 tests
|
||||||
|
for S-403 `dryRunResponse` and `resolveFormat`)
|
||||||
|
|
||||||
|
### Test count (post-M4)
|
||||||
|
|
||||||
|
| Suite | Tests |
|
||||||
|
|---|---|
|
||||||
|
| `pnpm run test:installer` (root scripts) | 80 |
|
||||||
|
| `atlassian/shared/scripts` | 29 |
|
||||||
|
| **Total** | **109** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final state (post-M5) — CLOSED
|
||||||
|
|
||||||
|
Captured: 2026-05-03 · Platform: macOS 15 (arm64) · Node 22.14.0 · pnpm 10.18.1
|
||||||
|
|
||||||
|
M5 added CI configuration, finalised documentation, closed out this baseline
|
||||||
|
report, and locked in a permanently green `pnpm run check` gate. No
|
||||||
|
pre-existing failures remain open.
|
||||||
|
|
||||||
|
### `pnpm run check` aggregate (post-M5 — macOS)
|
||||||
|
|
||||||
|
```text
|
||||||
|
PASS lint
|
||||||
|
PASS typecheck
|
||||||
|
PASS test
|
||||||
|
PASS verify:pi
|
||||||
|
PASS verify:reviewers
|
||||||
|
PASS verify:docs
|
||||||
|
PASS verify:generated
|
||||||
|
|
||||||
|
All checks passed.
|
||||||
|
```
|
||||||
|
|
||||||
|
Overall exit: **0** — all checks green (no regressions from M4).
|
||||||
|
|
||||||
|
### What changed in M5
|
||||||
|
|
||||||
|
- **S-501** — `.github/workflows/check.yml` added: matrix over `ubuntu-latest`
|
||||||
|
and `macos-latest`; installs `shellcheck` (apt/brew), `ripgrep` (apt), pnpm,
|
||||||
|
and Node.js 22; runs `pnpm run check`.
|
||||||
|
- **S-501** — `.github/workflows/check-online.yml` added: weekly schedule +
|
||||||
|
`workflow_dispatch`; runs `verify:docs:online` for full external link checking.
|
||||||
|
- **S-502** — Root `README.md` gained a "Contributing / Development" section
|
||||||
|
naming `pnpm run check` as the single quality gate and linking to
|
||||||
|
`docs/DEVELOPMENT.md`.
|
||||||
|
- **S-503** — `docs/DEVELOPMENT.md` finalised: added "Adding a new agent",
|
||||||
|
"Adding a new skill", and "CI" sections; transitional contract replaced by
|
||||||
|
the permanent M5 contract; baseline links updated.
|
||||||
|
- **S-504** — This file (`docs/CLEANUP-BASELINE.md`) closed out with the
|
||||||
|
"Final state" section.
|
||||||
|
- **S-505** — Root `package.json` `files` list updated: `docs/DEVELOPMENT.md`
|
||||||
|
and `docs/REVIEWERS.md` added.
|
||||||
|
- **S-506** — `CHANGELOG.md` extended with the M5 consolidated entry.
|
||||||
|
- **S-507** — Final `pnpm run check` run confirmed green (output above).
|
||||||
|
|
||||||
|
### Baseline status summary
|
||||||
|
|
||||||
|
Every pre-existing failure recorded in this document is now **resolved**.
|
||||||
|
No waivers are required.
|
||||||
|
|
||||||
|
| Check | M1 status | Final status | Resolution |
|
||||||
|
|-------|-----------|--------------|------------|
|
||||||
|
| `lint` (ESLint) | FAIL — 2 errors | **PASS** | Fixed in M3 (S-302) |
|
||||||
|
| `lint` (shellcheck) | FAIL — 7 findings | **PASS** | Fixed in M3 (S-302) |
|
||||||
|
| `typecheck` | PASS | **PASS** | Never regressed |
|
||||||
|
| `test` | PASS | **PASS** | Never regressed |
|
||||||
|
| `verify:pi` | PASS | **PASS** | Portability hardened in M2 |
|
||||||
|
| `verify:reviewers` | PASS | **PASS** | Never regressed |
|
||||||
|
| `verify:docs` (markdownlint) | FAIL — 1160 errors | **PASS** | Fixed in M2 |
|
||||||
|
| `verify:docs` (link-check) | PASS | **PASS** | Never regressed |
|
||||||
|
| `verify:generated` | PASS (stub) | **PASS** | Real implementation in M3 |
|
||||||
|
|
||||||
|
### Ubuntu smoke test
|
||||||
|
|
||||||
|
The CI workflow (`.github/workflows/check.yml`) runs the same `pnpm run check`
|
||||||
|
command on `ubuntu-latest`. This replaces the Docker one-liner that was the
|
||||||
|
only documented Ubuntu smoke test path in M1–M4.
|
||||||
|
|
||||||
|
For local reproduction on Linux:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Requires Docker
|
||||||
|
docker run --rm \
|
||||||
|
-v "$PWD:/w" \
|
||||||
|
-w /w \
|
||||||
|
node:22-bookworm \
|
||||||
|
bash -lc 'apt-get update -q && apt-get install -y -q shellcheck ripgrep python3 \
|
||||||
|
&& corepack enable \
|
||||||
|
&& pnpm install --frozen-lockfile \
|
||||||
|
&& pnpm run check'
|
||||||
|
```
|
||||||
|
|
||||||
|
*This baseline report is now closed. Future regressions must be caught by CI
|
||||||
|
and fixed before merge; they should NOT reopen this document.*
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# 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
|
||||||
|
test -f ~/.agents/skills/superpowers/executing-plans/SKILL.md
|
||||||
|
test -f ~/.agents/skills/superpowers/using-git-worktrees/SKILL.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
test -f ~/.codex/skills/create-plan/SKILL.md
|
||||||
|
test -x ~/.codex/skills/reviewer-runtime/run-review.sh
|
||||||
|
codex --version
|
||||||
|
```
|
||||||
+111
-34
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
Create structured implementation plans with milestone and story tracking, and optionally review them iteratively with a second model/provider before finalizing.
|
Create structured implementation plans with milestone and story tracking, and optionally review
|
||||||
|
them iteratively with a second model/provider before finalizing.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@@ -12,31 +13,41 @@ Create structured implementation plans with milestone and story tracking, and op
|
|||||||
- `superpowers:writing-plans`
|
- `superpowers:writing-plans`
|
||||||
- For Codex, native skill discovery must be configured:
|
- For Codex, native skill discovery must be configured:
|
||||||
- `~/.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)
|
- Cursor can use the Cursor Superpowers plugin cache or manual `.cursor/skills/superpowers/skills`
|
||||||
|
/ `~/.cursor/skills/superpowers/skills` installs.
|
||||||
|
- OpenCode can use `~/.agents/skills/superpowers` or `~/.config/opencode/skills/superpowers`.
|
||||||
- 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:
|
||||||
|
|
||||||
"Missing dependency: native Superpowers skills are required (`superpowers:brainstorming`, `superpowers:writing-plans`). Install from https://github.com/obra/superpowers, then retry."
|
"Missing dependency: native Superpowers skills are required (`superpowers:brainstorming`,
|
||||||
|
`superpowers:writing-plans`). Install from https://github.com/obra/superpowers, then retry."
|
||||||
|
|
||||||
### Reviewer CLI Requirements (Optional)
|
### Reviewer CLI Requirements (Optional)
|
||||||
|
|
||||||
To use the iterative plan review feature, one of these CLIs must be installed:
|
The canonical reviewer CLI support matrix is documented in
|
||||||
|
[REVIEWERS.md](./REVIEWERS.md). To use the iterative plan review feature, one
|
||||||
|
of these CLIs must be installed:
|
||||||
|
|
||||||
| Reviewer CLI | Install | Verify |
|
| Reviewer CLI | Install | Verify |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `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) |
|
||||||
|
| `opencode` | `brew install opencode` or your package manager | `opencode --version` |
|
||||||
|
| `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.
|
||||||
|
|
||||||
**Additional dependency for `cursor` reviewer:** `jq` is required to parse Cursor's JSON output. Install via `brew install jq` (macOS) or your package manager. Verify: `jq --version`.
|
**Additional dependency for `cursor` reviewer:** `jq` is required to parse Cursor's JSON output.
|
||||||
|
Install via `brew install jq` (macOS) or your package manager. Verify: `jq --version`.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
@@ -46,7 +57,8 @@ The reviewer CLI is independent of which agent is running the planning — e.g.,
|
|||||||
mkdir -p ~/.codex/skills/create-plan
|
mkdir -p ~/.codex/skills/create-plan
|
||||||
cp -R skills/create-plan/codex/* ~/.codex/skills/create-plan/
|
cp -R skills/create-plan/codex/* ~/.codex/skills/create-plan/
|
||||||
mkdir -p ~/.codex/skills/reviewer-runtime
|
mkdir -p ~/.codex/skills/reviewer-runtime
|
||||||
cp -R skills/reviewer-runtime/* ~/.codex/skills/reviewer-runtime/
|
cp skills/reviewer-runtime/run-review.sh skills/reviewer-runtime/notify-telegram.sh ~/.codex/skills/reviewer-runtime/
|
||||||
|
chmod +x ~/.codex/skills/reviewer-runtime/*.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
### Claude Code
|
### Claude Code
|
||||||
@@ -55,7 +67,8 @@ cp -R skills/reviewer-runtime/* ~/.codex/skills/reviewer-runtime/
|
|||||||
mkdir -p ~/.claude/skills/create-plan
|
mkdir -p ~/.claude/skills/create-plan
|
||||||
cp -R skills/create-plan/claude-code/* ~/.claude/skills/create-plan/
|
cp -R skills/create-plan/claude-code/* ~/.claude/skills/create-plan/
|
||||||
mkdir -p ~/.claude/skills/reviewer-runtime
|
mkdir -p ~/.claude/skills/reviewer-runtime
|
||||||
cp -R skills/reviewer-runtime/* ~/.claude/skills/reviewer-runtime/
|
cp skills/reviewer-runtime/run-review.sh skills/reviewer-runtime/notify-telegram.sh ~/.claude/skills/reviewer-runtime/
|
||||||
|
chmod +x ~/.claude/skills/reviewer-runtime/*.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
### OpenCode
|
### OpenCode
|
||||||
@@ -64,7 +77,8 @@ cp -R skills/reviewer-runtime/* ~/.claude/skills/reviewer-runtime/
|
|||||||
mkdir -p ~/.config/opencode/skills/create-plan
|
mkdir -p ~/.config/opencode/skills/create-plan
|
||||||
cp -R skills/create-plan/opencode/* ~/.config/opencode/skills/create-plan/
|
cp -R skills/create-plan/opencode/* ~/.config/opencode/skills/create-plan/
|
||||||
mkdir -p ~/.config/opencode/skills/reviewer-runtime
|
mkdir -p ~/.config/opencode/skills/reviewer-runtime
|
||||||
cp -R skills/reviewer-runtime/* ~/.config/opencode/skills/reviewer-runtime/
|
cp skills/reviewer-runtime/run-review.sh skills/reviewer-runtime/notify-telegram.sh ~/.config/opencode/skills/reviewer-runtime/
|
||||||
|
chmod +x ~/.config/opencode/skills/reviewer-runtime/*.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
### Cursor
|
### Cursor
|
||||||
@@ -75,7 +89,8 @@ Copy into the repo-local `.cursor/skills/` directory (where the Cursor Agent CLI
|
|||||||
mkdir -p .cursor/skills/create-plan
|
mkdir -p .cursor/skills/create-plan
|
||||||
cp -R skills/create-plan/cursor/* .cursor/skills/create-plan/
|
cp -R skills/create-plan/cursor/* .cursor/skills/create-plan/
|
||||||
mkdir -p .cursor/skills/reviewer-runtime
|
mkdir -p .cursor/skills/reviewer-runtime
|
||||||
cp -R skills/reviewer-runtime/* .cursor/skills/reviewer-runtime/
|
cp skills/reviewer-runtime/run-review.sh skills/reviewer-runtime/notify-telegram.sh .cursor/skills/reviewer-runtime/
|
||||||
|
chmod +x .cursor/skills/reviewer-runtime/*.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Or install globally (loaded via `~/.cursor/skills/`):
|
Or install globally (loaded via `~/.cursor/skills/`):
|
||||||
@@ -84,9 +99,35 @@ Or install globally (loaded via `~/.cursor/skills/`):
|
|||||||
mkdir -p ~/.cursor/skills/create-plan
|
mkdir -p ~/.cursor/skills/create-plan
|
||||||
cp -R skills/create-plan/cursor/* ~/.cursor/skills/create-plan/
|
cp -R skills/create-plan/cursor/* ~/.cursor/skills/create-plan/
|
||||||
mkdir -p ~/.cursor/skills/reviewer-runtime
|
mkdir -p ~/.cursor/skills/reviewer-runtime
|
||||||
cp -R skills/reviewer-runtime/* ~/.cursor/skills/reviewer-runtime/
|
cp skills/reviewer-runtime/run-review.sh skills/reviewer-runtime/notify-telegram.sh ~/.cursor/skills/reviewer-runtime/
|
||||||
|
chmod +x ~/.cursor/skills/reviewer-runtime/*.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
|
pnpm run sync:pi
|
||||||
|
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 +135,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:
|
||||||
@@ -106,10 +149,14 @@ Verify Superpowers dependencies exist in your agent skills root:
|
|||||||
- Codex: `~/.agents/skills/superpowers/writing-plans/SKILL.md`
|
- Codex: `~/.agents/skills/superpowers/writing-plans/SKILL.md`
|
||||||
- Claude Code: `~/.claude/skills/superpowers/brainstorming/SKILL.md`
|
- Claude Code: `~/.claude/skills/superpowers/brainstorming/SKILL.md`
|
||||||
- Claude Code: `~/.claude/skills/superpowers/writing-plans/SKILL.md`
|
- Claude Code: `~/.claude/skills/superpowers/writing-plans/SKILL.md`
|
||||||
- OpenCode: `~/.config/opencode/skills/superpowers/brainstorming/SKILL.md`
|
- OpenCode: `~/.agents/skills/superpowers/brainstorming/SKILL.md` or `~/.config/opencode/skills/superpowers/brainstorming/SKILL.md`
|
||||||
- OpenCode: `~/.config/opencode/skills/superpowers/writing-plans/SKILL.md`
|
- OpenCode: `~/.agents/skills/superpowers/writing-plans/SKILL.md` or `~/.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`,
|
||||||
- Cursor: `.cursor/skills/superpowers/skills/writing-plans/SKILL.md` or `~/.cursor/skills/superpowers/skills/writing-plans/SKILL.md`
|
`~/.cursor/skills/superpowers/skills/brainstorming/SKILL.md`, or the Cursor Superpowers plugin cache
|
||||||
|
- Cursor: `.cursor/skills/superpowers/skills/writing-plans/SKILL.md`,
|
||||||
|
`~/.cursor/skills/superpowers/skills/writing-plans/SKILL.md`, or the Cursor Superpowers plugin cache
|
||||||
|
- 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
|
||||||
|
|
||||||
@@ -118,11 +165,14 @@ Verify Superpowers dependencies exist in your agent skills root:
|
|||||||
- Commits `.gitignore` update locally when added.
|
- Commits `.gitignore` update locally when added.
|
||||||
- Asks which reviewer CLI, model, and max rounds to use (or accepts `skip` for no review).
|
- Asks which reviewer CLI, model, and max rounds to use (or accepts `skip` for no review).
|
||||||
- Iteratively reviews the plan with the chosen reviewer (default max 10 rounds) before generating files.
|
- Iteratively reviews the plan with the chosen reviewer (default max 10 rounds) before generating files.
|
||||||
- Runs reviewer commands through `reviewer-runtime/run-review.sh` when available, with fallback to direct synchronous execution only if the helper is missing.
|
- Runs reviewer commands through `reviewer-runtime/run-review.sh` when available, with fallback to
|
||||||
|
direct synchronous execution only if the helper is missing.
|
||||||
- Waits as long as the reviewer runtime keeps emitting per-minute `In progress N` heartbeats.
|
- Waits as long as the reviewer runtime keeps emitting per-minute `In progress N` heartbeats.
|
||||||
- Requires reviewer findings to be ordered `P0` through `P3`, with `P3` explicitly non-blocking.
|
- Requires reviewer findings to be ordered `P0` through `P3`, with `P3` explicitly non-blocking.
|
||||||
- Captures reviewer stderr and helper status logs for diagnostics and retains them on failed, empty-output, or operator-decision review rounds.
|
- Captures reviewer stderr and helper status logs for diagnostics and retains them on failed,
|
||||||
- Sends completion notifications through Telegram only when the shared setup in [TELEGRAM-NOTIFICATIONS.md](./TELEGRAM-NOTIFICATIONS.md) is installed and configured.
|
empty-output, or operator-decision review rounds.
|
||||||
|
- Sends completion notifications through Telegram only when the shared setup in
|
||||||
|
[TELEGRAM-NOTIFICATIONS.md](./TELEGRAM-NOTIFICATIONS.md) is installed and configured.
|
||||||
- Produces:
|
- Produces:
|
||||||
- `original-plan.md`
|
- `original-plan.md`
|
||||||
- `final-transcript.md`
|
- `final-transcript.md`
|
||||||
@@ -134,10 +184,12 @@ 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`, `opencode`, `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`
|
||||||
5. **Prioritize** — findings are ordered `P0`, `P1`, `P2`, `P3`
|
5. **Prioritize** — findings are ordered `P0`, `P1`, `P2`, `P3`
|
||||||
6. **Revise** — the planning agent addresses findings in priority order and re-submits
|
6. **Revise** — the planning agent addresses findings in priority order and re-submits
|
||||||
7. **Repeat** — up to max rounds until the reviewer returns `VERDICT: APPROVED`
|
7. **Repeat** — up to max rounds until the reviewer returns `VERDICT: APPROVED`
|
||||||
@@ -172,13 +224,17 @@ ts=<ISO-8601> level=<info|warn|error> state=<running-silent|running-active|in-pr
|
|||||||
```
|
```
|
||||||
|
|
||||||
`in-progress` is the liveness heartbeat emitted roughly once per minute with `note="In progress N"`.
|
`in-progress` is the liveness heartbeat emitted roughly once per minute with `note="In progress N"`.
|
||||||
`stall-warning` is a non-terminal status-log state only. It does not mean the caller should stop waiting if `in-progress` heartbeats continue.
|
`stall-warning` is a non-terminal status-log state only. It does not mean the caller should stop
|
||||||
|
waiting if `in-progress` heartbeats continue.
|
||||||
|
|
||||||
### Failure Handling
|
### Failure Handling
|
||||||
|
|
||||||
- `completed-empty-output` means the reviewer exited without producing review text; surface `.stderr` and `.status`, then retry only after diagnosing the cause.
|
- `completed-empty-output` means the reviewer exited without producing review text; surface
|
||||||
- `needs-operator-decision` means the helper reached hard-timeout escalation; surface `.status` and decide whether to extend the timeout, abort, or retry with different parameters.
|
`.stderr` and `.status`, then retry only after diagnosing the cause.
|
||||||
- Successful rounds clean up temp artifacts. Failed, empty-output, and operator-decision rounds should retain `.stderr`, `.status`, and `.runner.out` until diagnosed.
|
- `needs-operator-decision` means the helper reached hard-timeout escalation; surface `.status` and
|
||||||
|
decide whether to extend the timeout, abort, or retry with different parameters.
|
||||||
|
- Successful rounds clean up temp artifacts. Failed, empty-output, and operator-decision rounds
|
||||||
|
should retain `.stderr`, `.status`, and `.runner.out` until diagnosed.
|
||||||
- As long as fresh `in-progress` heartbeats continue to arrive roughly once per minute, the caller should keep waiting.
|
- As long as fresh `in-progress` heartbeats continue to arrive roughly once per minute, the caller should keep waiting.
|
||||||
|
|
||||||
### Supported Reviewer CLIs
|
### Supported Reviewer CLIs
|
||||||
@@ -188,13 +244,30 @@ 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` |
|
||||||
|
| `opencode` | `opencode run -m <provider>/<model> --agent plan --format json` | Fresh call default; optional `-s <id>` | `--agent plan` |
|
||||||
|
| `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.
|
||||||
@@ -207,17 +280,20 @@ For all three CLIs, the preferred execution path is:
|
|||||||
All plan templates now include guardrail sections that enforce:
|
All plan templates now include guardrail sections that enforce:
|
||||||
|
|
||||||
**Planning Guardrails** (`milestone-plan.md`):
|
**Planning Guardrails** (`milestone-plan.md`):
|
||||||
|
|
||||||
- Design validation before implementation planning
|
- Design validation before implementation planning
|
||||||
- Native skill discovery (no deprecated CLI wrappers)
|
- Native skill discovery (no deprecated CLI wrappers)
|
||||||
- Milestone verification + local commits + explicit approval
|
- Milestone verification + local commits + explicit approval
|
||||||
|
|
||||||
**Tracking Guardrails** (`story-tracker.md`):
|
**Tracking Guardrails** (`story-tracker.md`):
|
||||||
|
|
||||||
- Immediate status updates when work starts/completes
|
- Immediate status updates when work starts/completes
|
||||||
- Explicit user approval at each milestone boundary
|
- Explicit user approval at each milestone boundary
|
||||||
- No pushes until all milestones approved
|
- No pushes until all milestones approved
|
||||||
- Synchronization between tracker and plan files
|
- Synchronization between tracker and plan files
|
||||||
|
|
||||||
**Skill Workflow Guardrails** (`continuation-runbook.md`):
|
**Skill Workflow Guardrails** (`continuation-runbook.md`):
|
||||||
|
|
||||||
- Native skill invocation before action
|
- Native skill invocation before action
|
||||||
- Explicit skill announcements
|
- Explicit skill announcements
|
||||||
- Checklist tracking for driven skills
|
- Checklist tracking for driven skills
|
||||||
@@ -225,7 +301,7 @@ All plan templates now include guardrail sections that enforce:
|
|||||||
|
|
||||||
## Variant Hardening Notes
|
## Variant Hardening Notes
|
||||||
|
|
||||||
### Claude Code
|
### Claude Code Hardening
|
||||||
|
|
||||||
- Must invoke explicit required sub-skills:
|
- Must invoke explicit required sub-skills:
|
||||||
- `superpowers:brainstorming`
|
- `superpowers:brainstorming`
|
||||||
@@ -234,7 +310,7 @@ All plan templates now include guardrail sections that enforce:
|
|||||||
- if in plan mode, instruct user to exit plan mode before generating files
|
- if in plan mode, instruct user to exit plan mode before generating files
|
||||||
- Must copy `original-plan.md` from `~/.claude/plans/` when available.
|
- Must copy `original-plan.md` from `~/.claude/plans/` when available.
|
||||||
|
|
||||||
### Codex
|
### Codex Hardening
|
||||||
|
|
||||||
- Must use native skill discovery from `~/.agents/skills/` (no CLI wrappers).
|
- Must use native skill discovery from `~/.agents/skills/` (no CLI wrappers).
|
||||||
- Must verify Superpowers skills symlink: `~/.agents/skills/superpowers -> ~/.codex/superpowers/skills`
|
- Must verify Superpowers skills symlink: `~/.agents/skills/superpowers -> ~/.codex/superpowers/skills`
|
||||||
@@ -244,18 +320,19 @@ All plan templates now include guardrail sections that enforce:
|
|||||||
- Must track checklist-driven skills with `update_plan` todos.
|
- Must track checklist-driven skills with `update_plan` todos.
|
||||||
- Deprecated CLI commands (`superpowers-codex bootstrap`, `use-skill`) must NOT be used.
|
- Deprecated CLI commands (`superpowers-codex bootstrap`, `use-skill`) must NOT be used.
|
||||||
|
|
||||||
### OpenCode
|
### OpenCode Hardening
|
||||||
|
|
||||||
- Must use OpenCode native skill tool (not Claude/Codex invocation syntax).
|
- Must use OpenCode native skill tool (not Claude/Codex invocation syntax).
|
||||||
- Must verify Superpowers skill discovery under:
|
- Must verify Superpowers skill discovery under:
|
||||||
|
- `~/.agents/skills/superpowers`
|
||||||
- `~/.config/opencode/skills/superpowers`
|
- `~/.config/opencode/skills/superpowers`
|
||||||
- Must explicitly load:
|
- Must explicitly load:
|
||||||
- `superpowers/brainstorming`
|
- `superpowers/brainstorming`
|
||||||
- `superpowers/writing-plans`
|
- `superpowers/writing-plans`
|
||||||
|
|
||||||
### Cursor
|
### Cursor Hardening
|
||||||
|
|
||||||
- Must use workspace discovery from `.cursor/skills/` (repo-local or `~/.cursor/skills/` global).
|
- Must use Cursor-native discovery from `.cursor/skills/`, `~/.cursor/skills/`, or installed Cursor plugin cache entries.
|
||||||
- Must announce skill usage explicitly before invocation.
|
- Must announce skill usage explicitly before invocation.
|
||||||
- Must use `--mode=ask` (read-only) and `--trust` when running reviewer non-interactively.
|
- Must use `--mode=ask` (read-only) and `--trust` when running reviewer non-interactively.
|
||||||
- Must not use `--force` or `--mode=agent` for review (reviewer should never write files).
|
- Must not use `--force` or `--mode=agent` for review (reviewer should never write files).
|
||||||
|
|||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
# 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/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 .
|
||||||
|
```
|
||||||
@@ -0,0 +1,460 @@
|
|||||||
|
# Development Guide — ai-coding-skills
|
||||||
|
|
||||||
|
This document covers prerequisites, how to run checks locally, the quality
|
||||||
|
tooling, the workspace policy, and the `pnpm run check` contract.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
| Tool | Minimum version | Install |
|
||||||
|
|------|----------------|---------|
|
||||||
|
| Node.js | 20 | `fnm install 22` or `nvm install 22` |
|
||||||
|
| pnpm | 10 | `npm install -g pnpm` |
|
||||||
|
| `rg` (ripgrep) | any | `brew install ripgrep` / `apt-get install ripgrep` |
|
||||||
|
| **shellcheck** | **any** | `brew install shellcheck` / `apt-get install shellcheck` |
|
||||||
|
| `stat` (BSD or GNU) | any | pre-installed on macOS; GNU variant on Linux |
|
||||||
|
| Python 3 | 3.8+ | pre-installed on most systems |
|
||||||
|
|
||||||
|
**`shellcheck` is required.** The `pnpm run lint` script will exit 2 with a
|
||||||
|
clear error message if `shellcheck` is not on `PATH`. Every contributor must
|
||||||
|
install it before running any quality checks.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies (workspace-aware, no nested package.json modifications)
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# Run the full quality suite
|
||||||
|
pnpm run check
|
||||||
|
```
|
||||||
|
|
||||||
|
## Individual checks
|
||||||
|
|
||||||
|
| Command | What it does |
|
||||||
|
|---------|-------------|
|
||||||
|
| `pnpm run sync:pi` | Mirror pi skill variants into `pi-package/skills/` |
|
||||||
|
| `pnpm run verify:pi` | Assert pi resource and workflow invariants |
|
||||||
|
| `pnpm run verify:reviewers` | Assert reviewer-runtime skill invariants |
|
||||||
|
| `pnpm run test:installer` | Run root-level Node.js unit tests (22 tests) |
|
||||||
|
| `pnpm run lint` | ESLint on root scripts + shellcheck on all `.sh` files |
|
||||||
|
| `pnpm run lint:fix` | Auto-fix ESLint + Prettier (do not run on pre-existing code until M2) |
|
||||||
|
| `pnpm run typecheck` | TypeScript `tsc --noEmit` in workspace packages |
|
||||||
|
| `pnpm run test` | Run all tests (root + workspace packages) |
|
||||||
|
| `pnpm run verify:docs` | markdownlint + offline link-check + docs-flow verifier |
|
||||||
|
| `pnpm run verify:docs:online` | Same as `verify:docs` but with full external link checking |
|
||||||
|
| `pnpm run verify:generated` | Assert generated output freshness (stub; fleshed out in M3) |
|
||||||
|
| `pnpm run verify:ci` | Assert CI workflow files carry no pnpm version pins (see [pnpm version pinning](#pnpm-version-pinning)) |
|
||||||
|
| `pnpm run check` | Aggregate: run every gate above and report a summary |
|
||||||
|
|
||||||
|
## Quality tooling (added in M1)
|
||||||
|
|
||||||
|
### ESLint
|
||||||
|
|
||||||
|
Root-level flat config in `eslint.config.mjs`. Covers `scripts/**/*.mjs`
|
||||||
|
and `scripts/**/*.js`. Uses `@eslint/js` recommended rules and Node.js
|
||||||
|
globals. Nested workspace packages are responsible for their own ESLint
|
||||||
|
configuration.
|
||||||
|
|
||||||
|
### Prettier
|
||||||
|
|
||||||
|
Config in `.prettierrc.json` (print-width 100, LF line endings). Ignore
|
||||||
|
file at `.prettierignore` excludes generated agent-variant directories and
|
||||||
|
`pnpm-lock.yaml`.
|
||||||
|
|
||||||
|
### markdownlint
|
||||||
|
|
||||||
|
Config in `.markdownlint.jsonc` (rules) and `.markdownlint-cli2.jsonc`
|
||||||
|
(file globs and ignores). Key overrides vs defaults:
|
||||||
|
|
||||||
|
- `MD013` line-length relaxed to 120 chars (code blocks and tables excluded).
|
||||||
|
- `MD033` (inline HTML) disabled.
|
||||||
|
- `MD034` (bare URLs) disabled.
|
||||||
|
- `MD041` (first-line heading) disabled.
|
||||||
|
- `MD060` (table column style) disabled.
|
||||||
|
|
||||||
|
Run `pnpm run verify:docs` to lint all `README.md`, `docs/*.md`, and
|
||||||
|
`skills/**/SKILL.md` files (node\_modules excluded automatically).
|
||||||
|
|
||||||
|
### markdown-link-check
|
||||||
|
|
||||||
|
Two configs:
|
||||||
|
|
||||||
|
- `markdown-link-check.json` — **offline** mode (default): ignores all
|
||||||
|
`http://` and `https://` links. Safe for local dev and CI.
|
||||||
|
- `markdown-link-check.online.json` — **online** mode: checks external links
|
||||||
|
with a 10 s timeout, 2 retries, and retry-on-429. Use `--online` flag on
|
||||||
|
`scripts/lib/run-link-check.mjs`.
|
||||||
|
|
||||||
|
`pnpm run verify:docs` uses the offline config by default.
|
||||||
|
|
||||||
|
### shellcheck
|
||||||
|
|
||||||
|
Wrapper script at `scripts/lib/run-shellcheck.mjs`. Discovers every `*.sh`
|
||||||
|
file under `scripts/` and `skills/` (excluding node\_modules and generated
|
||||||
|
agent-variant directories) and runs shellcheck on each.
|
||||||
|
|
||||||
|
**Installation:**
|
||||||
|
|
||||||
|
- macOS: `brew install shellcheck`
|
||||||
|
- Debian/Ubuntu: `sudo apt-get install shellcheck`
|
||||||
|
- Other: <https://github.com/koalaman/shellcheck#installing>
|
||||||
|
|
||||||
|
The wrapper exits with code **2** (not 1) when shellcheck is missing, so CI
|
||||||
|
can distinguish "shellcheck absent" from "shellcheck found violations".
|
||||||
|
|
||||||
|
## Cross-platform shell support (M2)
|
||||||
|
|
||||||
|
All shell scripts under `scripts/` and `skills/reviewer-runtime/` that are
|
||||||
|
exercised by `verify:pi`, `verify:reviewers`, `sync:pi`, and `test:installer`
|
||||||
|
must work on both **macOS** (BSD userland) and **Ubuntu/Debian** (GNU
|
||||||
|
userland) without modification.
|
||||||
|
|
||||||
|
### BSD vs GNU differences encountered
|
||||||
|
|
||||||
|
| Feature | macOS (BSD) | Linux (GNU) | Portable form |
|
||||||
|
|---------|-------------|-------------|---------------|
|
||||||
|
| `stat` permissions | `stat -f '%Lp' <path>` | `stat -c '%a' <path>` | `portable_stat_perms` helper |
|
||||||
|
| herestrings (`<<<`) | supported (bash) | supported (bash) | OK (scripts use `#!/usr/bin/env bash`) |
|
||||||
|
| `find -E` (extended regex) | macOS-only | not available | use `grep` or POSIX `-name` |
|
||||||
|
| `sed -i ''` | macOS form | use `sed -i` on Linux | detect or avoid in-place sed |
|
||||||
|
| `date -j` (date arithmetic) | macOS-only | use `date -d` on Linux | Node helper or `date +%s` |
|
||||||
|
| `readlink -f` | not on macOS by default | GNU standard | `realpath` or `cd && pwd` |
|
||||||
|
|
||||||
|
### `scripts/lib/portable.sh`
|
||||||
|
|
||||||
|
A shared helper that abstracts the two known BSD/GNU divergences hit in
|
||||||
|
this repo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Source from any script that needs portable stat
|
||||||
|
# shellcheck source=lib/portable.sh
|
||||||
|
source "$(dirname "${BASH_SOURCE[0]}")/lib/portable.sh"
|
||||||
|
|
||||||
|
# Returns octal permission string: e.g. "755"
|
||||||
|
portable_stat_perms "$path"
|
||||||
|
```
|
||||||
|
|
||||||
|
The `shellcheck` wrapper passes `-x --source-path=SCRIPTDIR` so source
|
||||||
|
directives resolve relative to the script file, not the working directory.
|
||||||
|
|
||||||
|
### How to run the Ubuntu smoke test locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Requires Docker
|
||||||
|
docker run --rm \
|
||||||
|
-v "$PWD:/w" \
|
||||||
|
-w /w \
|
||||||
|
node:20-bookworm \
|
||||||
|
bash -lc 'apt-get update -q && apt-get install -y -q shellcheck ripgrep python3 \
|
||||||
|
&& corepack enable \
|
||||||
|
&& pnpm install --frozen-lockfile \
|
||||||
|
&& pnpm run check'
|
||||||
|
```
|
||||||
|
|
||||||
|
This runs the full `pnpm run check` suite (lint, typecheck, test, verify:pi,
|
||||||
|
verify:reviewers, verify:docs, verify:generated) inside a clean Debian Bookworm
|
||||||
|
container with Node 20.
|
||||||
|
|
||||||
|
## `check` contract (final — M5)
|
||||||
|
|
||||||
|
`pnpm run check` is **fully green** on a clean checkout with the documented
|
||||||
|
prerequisites installed.
|
||||||
|
|
||||||
|
```text
|
||||||
|
PASS lint
|
||||||
|
PASS typecheck
|
||||||
|
PASS test
|
||||||
|
PASS verify:pi
|
||||||
|
PASS verify:reviewers
|
||||||
|
PASS verify:docs
|
||||||
|
PASS verify:generated
|
||||||
|
PASS verify:ci
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the only acceptable state for merge. Any failure on a check not in
|
||||||
|
`docs/CLEANUP-BASELINE.md` is a regression and must be fixed before merge.
|
||||||
|
|
||||||
|
> **History:** In M1 the contract was transitional and allowed pre-existing
|
||||||
|
> failures recorded in the baseline. M2 fixed `verify:docs`. M3 fixed
|
||||||
|
> `lint` and promoted `verify:generated` from stub to real implementation.
|
||||||
|
> M4 added abstractions without regressions. M5 added CI and finalized docs.
|
||||||
|
> The baseline is now closed out — see `docs/CLEANUP-BASELINE.md`.
|
||||||
|
|
||||||
|
## pnpm workspace policy (updated in M3)
|
||||||
|
|
||||||
|
The `pnpm-workspace.yaml` at the repo root uses a **positive-include** policy
|
||||||
|
introduced in M3. There are no negative-glob exclusions.
|
||||||
|
|
||||||
|
**Canonical source packages** (never generated):
|
||||||
|
|
||||||
|
- `skills/atlassian/shared/scripts` — shared Atlassian TypeScript runtime
|
||||||
|
- `skills/web-automation/shared` — shared web-automation runtime template
|
||||||
|
|
||||||
|
**Generated agent-variant packages** (uniquely named, positively included):
|
||||||
|
|
||||||
|
- `skills/atlassian/{claude-code,codex,cursor,opencode,pi}/scripts`
|
||||||
|
→ names `@ai-coding-skills/atlassian-{claude-code,codex,cursor,opencode,pi}`
|
||||||
|
- `skills/web-automation/{claude-code,codex,cursor,opencode,pi}/scripts`
|
||||||
|
→ names `@ai-coding-skills/web-automation-{claude-code,codex,cursor,opencode,pi}`
|
||||||
|
- `pi-package/skills/atlassian/scripts`
|
||||||
|
→ name `@ai-coding-skills/atlassian-pi-mirror`
|
||||||
|
- `pi-package/skills/web-automation/scripts`
|
||||||
|
→ name `@ai-coding-skills/web-automation-pi-mirror`
|
||||||
|
|
||||||
|
**Why unique names matter:**
|
||||||
|
|
||||||
|
Each package in a pnpm workspace must have a distinct `name` field. In M1 all
|
||||||
|
generated agent-variant packages shared the same non-unique name as their
|
||||||
|
canonical source package. In M3 every package receives a scoped unique name
|
||||||
|
of the form `@ai-coding-skills/<skill>-<agent>`, enabling pnpm to include them
|
||||||
|
alongside canonical source packages without conflicts. The pi-package mirrors
|
||||||
|
use the `-mirror` suffix to distinguish them from the `pi` agent variants.
|
||||||
|
|
||||||
|
All generated packages are `"private": true` and are never published to any
|
||||||
|
registry.
|
||||||
|
|
||||||
|
After `pnpm install`, `git status` should show zero modifications to any
|
||||||
|
package.json file under any generated directory. If it does not, the workspace
|
||||||
|
config or generator is broken.
|
||||||
|
|
||||||
|
## How to interpret the baseline report
|
||||||
|
|
||||||
|
See `docs/CLEANUP-BASELINE.md` for the historical as-is capture from M1. That
|
||||||
|
baseline is now **closed** — all pre-existing failures have been resolved.
|
||||||
|
When a CI run fails on any check it is a regression and must be fixed before
|
||||||
|
merge.
|
||||||
|
|
||||||
|
## How variants are generated (M3)
|
||||||
|
|
||||||
|
Every agent-variant directory under `skills/<skill>/<agent>/` and every
|
||||||
|
`pi-package/skills/<skill>/` mirror is **generator-owned**. Do not edit
|
||||||
|
files inside these directories directly — edit the canonical source instead
|
||||||
|
and run `pnpm run sync:pi` to regenerate.
|
||||||
|
|
||||||
|
### Canonical sources (non-generated)
|
||||||
|
|
||||||
|
These directories are **never** generated and are **never** inside a generated
|
||||||
|
root. They are the single source of truth for all content.
|
||||||
|
|
||||||
|
| Skill | Canonical source |
|
||||||
|
|-------|------------------|
|
||||||
|
| `atlassian` | SKILL.md: `skills/atlassian/_source/<agent>/SKILL.md`; scripts: `skills/atlassian/shared/scripts/` |
|
||||||
|
| `web-automation` | SKILL.md: `skills/web-automation/_source/<agent>/SKILL.md`; scripts: `skills/web-automation/shared/` |
|
||||||
|
| `create-plan` | `skills/create-plan/_source/<agent>/` (SKILL.md + templates) |
|
||||||
|
| `do-task` | `skills/do-task/_source/<agent>/` (SKILL.md + templates) |
|
||||||
|
| `implement-plan` | `skills/implement-plan/_source/<agent>/SKILL.md` |
|
||||||
|
| `reviewer-runtime` (base) | `skills/reviewer-runtime/run-review.sh`, `notify-telegram.sh` |
|
||||||
|
|
||||||
|
The `_source/` directories and `shared/` directories sit under the skill root
|
||||||
|
**outside** every generated root. Stale-file detection (`verify:generated`)
|
||||||
|
never traverses them.
|
||||||
|
|
||||||
|
### Generated roots (authoritative list)
|
||||||
|
|
||||||
|
All paths are relative to the repo root.
|
||||||
|
|
||||||
|
```text
|
||||||
|
skills/atlassian/{claude-code,codex,cursor,opencode,pi}/
|
||||||
|
skills/web-automation/{claude-code,codex,cursor,opencode,pi}/
|
||||||
|
skills/create-plan/{claude-code,codex,cursor,opencode,pi}/
|
||||||
|
skills/do-task/{claude-code,codex,cursor,opencode,pi}/
|
||||||
|
skills/implement-plan/{claude-code,codex,cursor,opencode,pi}/
|
||||||
|
skills/reviewer-runtime/pi/
|
||||||
|
pi-package/skills/{atlassian,create-plan,do-task,implement-plan,web-automation}/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Contributor workflow
|
||||||
|
|
||||||
|
1. **Edit the canonical source** in the appropriate `_source/<agent>/`, `shared/`, or
|
||||||
|
base `skills/reviewer-runtime/` directory.
|
||||||
|
2. **Run the generator:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm run sync:pi # regenerates all 31 generated roots
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Verify no drift:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm run verify:generated # must exit 0
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Stage both the canonical source AND the generated output** in the same commit.
|
||||||
|
Never commit a canonical change without regenerating.
|
||||||
|
|
||||||
|
### File-type-aware header policy
|
||||||
|
|
||||||
|
Every generated file (except JSON and `pnpm-lock.yaml`) receives a header
|
||||||
|
identifying it as generated and pointing to the canonical source. Headers are
|
||||||
|
inserted so they do not break parsers or tools:
|
||||||
|
|
||||||
|
| File type | Header form | Placement |
|
||||||
|
|-----------|-------------|----------|
|
||||||
|
| Markdown (with YAML front matter) | `<!-- ⚠️ GENERATED FILE ... -->` | After closing `---` |
|
||||||
|
| Markdown (no front matter) | `<!-- ⚠️ GENERATED FILE ... -->` | Top of file |
|
||||||
|
| Shell script | `# ⚠️ GENERATED FILE ...` | After `#!` shebang |
|
||||||
|
| TypeScript / JavaScript | `// ⚠️ GENERATED FILE ...` | After `#!` shebang if present, else top |
|
||||||
|
| YAML (non-lockfile) | `# ⚠️ GENERATED FILE ...` | Top of file |
|
||||||
|
| JSON | *(no header — recorded in `.generated-manifest.json`)* | — |
|
||||||
|
| `pnpm-lock.yaml` | *(no header — managed by pnpm)* | — |
|
||||||
|
| JSONC | `// ⚠️ GENERATED FILE ...` | Top of file |
|
||||||
|
|
||||||
|
### `.generated-manifest.json` contract
|
||||||
|
|
||||||
|
Each generated root contains a `.generated-manifest.json` that:
|
||||||
|
|
||||||
|
- Has a `$schema` marker and a `generator` field for identification.
|
||||||
|
- Lists every **other** generator-owned path in that root (relative path,
|
||||||
|
file mode, SHA-256 hash, kind).
|
||||||
|
- **Does NOT list itself** (non-self-referential).
|
||||||
|
- Is scoped to exactly **one** generated root — there is no manifest at
|
||||||
|
`skills/<skill>/`.
|
||||||
|
- Is verified structurally (not byte-for-byte) by `verify:generated`.
|
||||||
|
|
||||||
|
### Package metadata (M3 change)
|
||||||
|
|
||||||
|
Each generated agent-variant `package.json` carries:
|
||||||
|
|
||||||
|
- A **unique private name** in the form `@ai-coding-skills/<skill>-<agent>` (e.g.
|
||||||
|
`@ai-coding-skills/atlassian-claude-code`).
|
||||||
|
- `"private": true` to prevent accidental npm publication.
|
||||||
|
|
||||||
|
The pi-package mirrors (`pi-package/skills/atlassian/scripts` and
|
||||||
|
`pi-package/skills/web-automation/scripts`) use the `-mirror` agent suffix
|
||||||
|
(`@ai-coding-skills/atlassian-pi-mirror`, `@ai-coding-skills/web-automation-pi-mirror`)
|
||||||
|
to distinguish them from the `pi` agent variants in the workspace.
|
||||||
|
|
||||||
|
This replaces the previous non-unique `name` fields (`atlassian-skill-scripts`,
|
||||||
|
`web-automation-scripts`). These packages are private and are never published.
|
||||||
|
See `CHANGELOG.md` for the full rename list.
|
||||||
|
|
||||||
|
## Adding a new agent variant
|
||||||
|
|
||||||
|
To add support for a brand-new agent (e.g. `mynewagent`) across all skills:
|
||||||
|
|
||||||
|
1. **Create canonical source files** for every skill:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p skills/<skill>/_source/mynewagent
|
||||||
|
# Add SKILL.md (copy and adapt from an existing agent variant)
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Update the generator** (`scripts/generate-skills.mjs`):
|
||||||
|
- Add `"mynewagent"` to the `AGENTS` array (or the per-skill agent list).
|
||||||
|
- Ensure any agent-specific substitution logic is handled.
|
||||||
|
|
||||||
|
3. **Regenerate all variants:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm run sync:pi
|
||||||
|
pnpm run verify:generated # must exit 0
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Update the workspace** (`pnpm-workspace.yaml`):
|
||||||
|
- Add entries for every `skills/<skill>/mynewagent/scripts` package that
|
||||||
|
has a `package.json` (e.g. atlassian and web-automation).
|
||||||
|
|
||||||
|
5. **Update documentation:**
|
||||||
|
- Add a row to the skills table in `README.md`.
|
||||||
|
- Add a row to the reviewer matrix in `docs/REVIEWERS.md` if the agent
|
||||||
|
exposes a reviewer CLI.
|
||||||
|
- Add an install guide at `docs/MYNEWAGENT.md` and link it from
|
||||||
|
`docs/README.md`.
|
||||||
|
- Add the doc file to the `files` list in root `package.json`.
|
||||||
|
|
||||||
|
6. **Verify the full suite:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm run check
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adding a new skill
|
||||||
|
|
||||||
|
To add an entirely new skill (e.g. `my-skill`):
|
||||||
|
|
||||||
|
1. **Create the canonical source tree:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p skills/my-skill/_source/{claude-code,codex,cursor,opencode,pi}
|
||||||
|
# Add SKILL.md to each _source/<agent>/ directory
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Update the generator** (`scripts/generate-skills.mjs`):
|
||||||
|
- Add `"my-skill"` to the `SKILLS` array.
|
||||||
|
- Provide a `getSkillMeta(skill)` entry with agents list, source resolver,
|
||||||
|
and any extra files to copy (templates, scripts, etc.).
|
||||||
|
|
||||||
|
3. **Regenerate:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm run sync:pi
|
||||||
|
pnpm run verify:generated
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Add the pi-package entry** if the skill should be distributed via the
|
||||||
|
Pi package:
|
||||||
|
- Update `package.json` → `pi.skills` with the new path.
|
||||||
|
- Update the `files` array to include the new skill directory.
|
||||||
|
|
||||||
|
5. **Write documentation** in `docs/MY-SKILL.md` and link from `docs/README.md`
|
||||||
|
and the skills table in `README.md`.
|
||||||
|
|
||||||
|
6. **Run the full suite:** `pnpm run check`.
|
||||||
|
|
||||||
|
## CI
|
||||||
|
|
||||||
|
Two GitHub Actions workflows live in `.github/workflows/`:
|
||||||
|
|
||||||
|
| File | Trigger | Purpose |
|
||||||
|
|------|---------|---------|
|
||||||
|
| `check.yml` | push, pull_request (all branches) | Full `pnpm run check` on `ubuntu-latest` **and** `macos-latest`. Offline link-checking (no network dependency). |
|
||||||
|
| `check-online.yml` | weekly schedule (Mon 09:00 UTC) + `workflow_dispatch` | `pnpm run verify:docs:online` with full external link checking. |
|
||||||
|
|
||||||
|
### What the `check` workflow does
|
||||||
|
|
||||||
|
1. Checks out the repository.
|
||||||
|
2. Installs `shellcheck` via `apt-get` (Ubuntu) or `brew` (macOS).
|
||||||
|
3. Installs `ripgrep` via `apt-get` (Ubuntu only; pre-installed on macOS runners).
|
||||||
|
4. Installs Node.js 22 via `actions/setup-node`.
|
||||||
|
5. Installs pnpm via `pnpm/action-setup@v4` — **no `version:` key is set**; the action reads the version from
|
||||||
|
`package.json#packageManager` (currently `pnpm@10.18.1+sha512…`), which is the single source of truth.
|
||||||
|
6. Runs `pnpm install --frozen-lockfile`.
|
||||||
|
7. Runs `pnpm run check` (the same command contributors run locally).
|
||||||
|
|
||||||
|
The matrix runs both `ubuntu-latest` and `macos-latest` to guard against
|
||||||
|
platform-specific regressions. Because M2 made all shell scripts portable
|
||||||
|
across BSD and GNU coreutils, both runners should stay green.
|
||||||
|
|
||||||
|
### pnpm version pinning
|
||||||
|
|
||||||
|
The pnpm version is pinned **exclusively** in `package.json#packageManager`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"packageManager": "pnpm@10.18.1+sha512.77a884a..."
|
||||||
|
```
|
||||||
|
|
||||||
|
This field carries an exact version *and* an integrity hash, giving stronger
|
||||||
|
reproducibility than a floating major like `version: "10"`. The
|
||||||
|
`pnpm/action-setup@v4` step in `check.yml` reads this field automatically;
|
||||||
|
do **not** add a `with.version` key to that step.
|
||||||
|
|
||||||
|
`pnpm run verify:ci` (backed by `scripts/lib/assert-no-pnpm-version-pin.mjs`)
|
||||||
|
greps every `.github/workflows/*.yml` for `pnpm/action-setup` blocks that
|
||||||
|
carry a `version:` key and fails if any are found. This prevents
|
||||||
|
reintroduction of the conflict that caused `pnpm/action-setup@v4` to error.
|
||||||
|
|
||||||
|
### Adding new prerequisites to CI
|
||||||
|
|
||||||
|
If a new tool is required (e.g. a new binary called by a verify script),
|
||||||
|
add the install step to **both** OS branches in `check.yml`. Document the
|
||||||
|
new prerequisite in the Prerequisites table at the top of this file.
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- [CLEANUP-BASELINE.md](./CLEANUP-BASELINE.md) — as-is quality baseline
|
||||||
|
- [README.md](../README.md) — project overview
|
||||||
|
- [docs/README.md](./README.md) — documentation index
|
||||||
|
- [CHANGELOG.md](../CHANGELOG.md) — milestone-by-milestone change record
|
||||||
|
- [.github/workflows/check.yml](../.github/workflows/check.yml) — CI workflow
|
||||||
|
- [.github/workflows/check-online.yml](../.github/workflows/check-online.yml) — weekly online link check
|
||||||
+532
@@ -0,0 +1,532 @@
|
|||||||
|
# DO-TASK
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Execute a single user-supplied prompt end-to-end with **two reviewer loops** (plan review +
|
||||||
|
implementation review), with TDD-first execution, a pre-implementation verification gate, and a
|
||||||
|
single task commit — all in one run of the skill. `do-task` is scoped to small-to-medium ad-hoc
|
||||||
|
tasks; for multi-milestone work use `create-plan` + `implement-plan` instead.
|
||||||
|
|
||||||
|
`do-task` persists one plan artifact per run: `ai_plan/YYYY-MM-DD-<slug>/task-plan.md`. The
|
||||||
|
folder is kept as a record after success (not deleted). Resume is supported via the `Status` enum
|
||||||
|
and Runtime State fields.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Git repo with `/ai_plan/` entry in `.gitignore` (the skill adds the entry automatically if
|
||||||
|
missing and commits it as a separate infra commit).
|
||||||
|
- Superpowers skills installed from: https://github.com/obra/superpowers
|
||||||
|
- Required dependencies (vary by variant; see Install below):
|
||||||
|
- `superpowers:brainstorming` (or `superpowers/brainstorming` for OpenCode)
|
||||||
|
- `superpowers:test-driven-development`
|
||||||
|
- `superpowers:verification-before-completion`
|
||||||
|
- `superpowers:finishing-a-development-branch`
|
||||||
|
- `superpowers:using-git-worktrees` (only when the prompt opts in to a worktree)
|
||||||
|
- For Codex, native skill discovery must be configured:
|
||||||
|
- `~/.agents/skills/superpowers -> ~/.codex/superpowers/skills`
|
||||||
|
- Cursor can use the Cursor Superpowers plugin cache or manual `.cursor/skills/superpowers/skills`
|
||||||
|
/ `~/.cursor/skills/superpowers/skills` installs, and `jq` is a hard prerequisite for the
|
||||||
|
Cursor variant.
|
||||||
|
- OpenCode can use `~/.agents/skills/superpowers` or `~/.config/opencode/skills/superpowers`.
|
||||||
|
- Shared reviewer runtime (`run-review.sh`) AND Telegram notifier helper (`notify-telegram.sh`)
|
||||||
|
must be installed beside agent skills. Both scripts ship under `skills/reviewer-runtime/` in this
|
||||||
|
repo and must be copied into the per-variant location:
|
||||||
|
- Codex: `~/.codex/skills/reviewer-runtime/{run-review.sh,notify-telegram.sh}`
|
||||||
|
- Claude Code: `~/.claude/skills/reviewer-runtime/{run-review.sh,notify-telegram.sh}`
|
||||||
|
- OpenCode: `~/.config/opencode/skills/reviewer-runtime/{run-review.sh,notify-telegram.sh}`
|
||||||
|
- Cursor: `.cursor/skills/reviewer-runtime/{run-review.sh,notify-telegram.sh}` (repo-local,
|
||||||
|
preferred) or `~/.cursor/skills/reviewer-runtime/{run-review.sh,notify-telegram.sh}`
|
||||||
|
(global fallback)
|
||||||
|
- Pi: `.pi/skills/reviewer-runtime/pi/{run-review.sh,notify-telegram.sh}` (repo-local) or
|
||||||
|
`~/.pi/agent/skills/reviewer-runtime/pi/{run-review.sh,notify-telegram.sh}` (global)
|
||||||
|
- Variant-specific prerequisites:
|
||||||
|
- **Claude Code:** `claude --version`, explicit `Skill`-tool invocation of sub-skills.
|
||||||
|
- **Codex:** `codex --version`; `~/.agents/skills/superpowers -> ~/.codex/superpowers/skills` symlink present.
|
||||||
|
- **Cursor:** `cursor-agent --version`, `jq --version` (hard prereq), Superpowers available
|
||||||
|
from the Cursor plugin cache or manual Cursor skill roots.
|
||||||
|
- **OpenCode:** `opencode --version`; Superpowers available from `~/.agents/skills/superpowers`
|
||||||
|
or `~/.config/opencode/skills/superpowers`; Phase 1 runs Bootstrap Superpowers Context.
|
||||||
|
- Telegram notification setup is documented in [TELEGRAM-NOTIFICATIONS.md](./TELEGRAM-NOTIFICATIONS.md)
|
||||||
|
|
||||||
|
Dependency-missing messages are variant-specific:
|
||||||
|
|
||||||
|
- **Claude Code:** `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 the
|
||||||
|
Cursor Superpowers plugin or Superpowers skills under .cursor/skills/ or ~/.cursor/skills/,
|
||||||
|
then retry.`
|
||||||
|
- **OpenCode:** `Missing dependency: [specific missing item]. Install required OpenCode
|
||||||
|
Superpowers skills (https://github.com/obra/superpowers, OpenCode setup) and the
|
||||||
|
reviewer-runtime helper, then retry.`
|
||||||
|
- **Pi:** `Missing dependency: [specific missing item]. Install Pi, required Superpowers skills,
|
||||||
|
and the Pi reviewer-runtime helper, then retry.`
|
||||||
|
|
||||||
|
### Reviewer CLI Requirements
|
||||||
|
|
||||||
|
The canonical reviewer CLI support matrix is documented in
|
||||||
|
[REVIEWERS.md](./REVIEWERS.md). One of these CLIs must be installed to drive either of the two
|
||||||
|
review loops:
|
||||||
|
|
||||||
|
| Reviewer CLI | Install | Verify | Read-Only Mode | Session Resume |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `codex` | `npm install -g @openai/codex` | `codex --version` | `-s read-only` | Yes (`codex exec resume <id>`) |
|
||||||
|
| `claude` | `npm install -g @anthropic-ai/claude-code` | `claude --version` | `--strict-mcp-config --setting-sources user` | No (fresh call each round) |
|
||||||
|
| `cursor` | `curl https://cursor.com/install -fsS \| bash` | `cursor-agent --version` (binary: `cursor-agent`; alias `cursor agent` also works) | `--mode=ask` | Yes (`--resume <id>`) |
|
||||||
|
| `opencode` | `brew install opencode` or your package manager | `opencode --version` | `--agent plan` | Opt-in (`-s <id>`; fresh call is the default) |
|
||||||
|
| `pi` | Install Pi coding agent | `pi --version`; list models with `pi --list-models [search]` | `--tools read,grep,find,ls` | No (fresh call each round) |
|
||||||
|
|
||||||
|
The reviewer CLI is independent of which agent is running the skill — e.g., Claude Code can send
|
||||||
|
both the plan and the implementation to Codex for review.
|
||||||
|
|
||||||
|
**Additional dependency for `cursor` reviewer:** `jq` is required to parse Cursor's JSON output.
|
||||||
|
Install via `brew install jq` (macOS) or your package manager. Verify: `jq --version`. The cursor
|
||||||
|
variant of `do-task` makes `jq` a hard prerequisite regardless of which reviewer CLI is selected.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
### Codex
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.codex/skills/do-task
|
||||||
|
cp -R skills/do-task/codex/* ~/.codex/skills/do-task/
|
||||||
|
mkdir -p ~/.codex/skills/reviewer-runtime
|
||||||
|
cp skills/reviewer-runtime/run-review.sh skills/reviewer-runtime/notify-telegram.sh ~/.codex/skills/reviewer-runtime/
|
||||||
|
chmod +x ~/.codex/skills/reviewer-runtime/*.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Claude Code
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.claude/skills/do-task
|
||||||
|
cp -R skills/do-task/claude-code/* ~/.claude/skills/do-task/
|
||||||
|
mkdir -p ~/.claude/skills/reviewer-runtime
|
||||||
|
cp skills/reviewer-runtime/run-review.sh skills/reviewer-runtime/notify-telegram.sh ~/.claude/skills/reviewer-runtime/
|
||||||
|
chmod +x ~/.claude/skills/reviewer-runtime/*.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### OpenCode
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.config/opencode/skills/do-task
|
||||||
|
cp -R skills/do-task/opencode/* ~/.config/opencode/skills/do-task/
|
||||||
|
mkdir -p ~/.config/opencode/skills/reviewer-runtime
|
||||||
|
cp skills/reviewer-runtime/run-review.sh skills/reviewer-runtime/notify-telegram.sh ~/.config/opencode/skills/reviewer-runtime/
|
||||||
|
chmod +x ~/.config/opencode/skills/reviewer-runtime/*.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cursor
|
||||||
|
|
||||||
|
Copy into the repo-local `.cursor/skills/` directory (where the Cursor Agent CLI discovers skills):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p .cursor/skills/do-task
|
||||||
|
cp -R skills/do-task/cursor/* .cursor/skills/do-task/
|
||||||
|
mkdir -p .cursor/skills/reviewer-runtime
|
||||||
|
cp skills/reviewer-runtime/run-review.sh skills/reviewer-runtime/notify-telegram.sh .cursor/skills/reviewer-runtime/
|
||||||
|
chmod +x .cursor/skills/reviewer-runtime/*.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Or install globally (loaded via `~/.cursor/skills/`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.cursor/skills/do-task
|
||||||
|
cp -R skills/do-task/cursor/* ~/.cursor/skills/do-task/
|
||||||
|
mkdir -p ~/.cursor/skills/reviewer-runtime
|
||||||
|
cp skills/reviewer-runtime/run-review.sh skills/reviewer-runtime/notify-telegram.sh ~/.cursor/skills/reviewer-runtime/
|
||||||
|
chmod +x ~/.cursor/skills/reviewer-runtime/*.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
|
pnpm run sync:pi
|
||||||
|
mkdir -p .pi/skills/do-task
|
||||||
|
cp -R pi-package/skills/do-task/* .pi/skills/do-task/
|
||||||
|
mkdir -p .pi/skills/reviewer-runtime/pi
|
||||||
|
cp -R skills/reviewer-runtime/pi/* .pi/skills/reviewer-runtime/pi/
|
||||||
|
chmod +x .pi/skills/reviewer-runtime/pi/*.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Global manual installs use `~/.pi/agent/skills/do-task/` and `~/.pi/agent/skills/reviewer-runtime/pi/` instead of `.pi/skills/...`.
|
||||||
|
|
||||||
|
Pi workflow skills also require Superpowers. See [PI-SUPERPOWERS.md](./PI-SUPERPOWERS.md) and [PI-COMMON-REVIEWER.md](./PI-COMMON-REVIEWER.md).
|
||||||
|
|
||||||
|
## Verify Installation
|
||||||
|
|
||||||
|
Run the per-variant checks for everything the corresponding `SKILL.md` enforces. Each check is
|
||||||
|
structured: (1) CLI binary version, (2) skill file presence, (3) reviewer-runtime + notifier
|
||||||
|
helper presence, (4) Superpowers sub-skill discovery, (5) variant-specific extras.
|
||||||
|
|
||||||
|
### Codex Verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
codex --version
|
||||||
|
test -f ~/.codex/skills/do-task/SKILL.md
|
||||||
|
test -x ~/.codex/skills/reviewer-runtime/run-review.sh
|
||||||
|
test -x ~/.codex/skills/reviewer-runtime/notify-telegram.sh
|
||||||
|
test -L ~/.agents/skills/superpowers
|
||||||
|
test -f ~/.agents/skills/superpowers/brainstorming/SKILL.md
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
### Claude Code Verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
claude --version
|
||||||
|
test -f ~/.claude/skills/do-task/SKILL.md
|
||||||
|
test -x ~/.claude/skills/reviewer-runtime/run-review.sh
|
||||||
|
test -x ~/.claude/skills/reviewer-runtime/notify-telegram.sh
|
||||||
|
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
|
||||||
|
test -f ~/.claude/skills/superpowers/finishing-a-development-branch/SKILL.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### OpenCode Verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
opencode --version
|
||||||
|
test -f ~/.config/opencode/skills/do-task/SKILL.md
|
||||||
|
test -x ~/.config/opencode/skills/reviewer-runtime/run-review.sh
|
||||||
|
test -x ~/.config/opencode/skills/reviewer-runtime/notify-telegram.sh
|
||||||
|
test -f ~/.agents/skills/superpowers/brainstorming/SKILL.md || test -f ~/.config/opencode/skills/superpowers/brainstorming/SKILL.md
|
||||||
|
test -f ~/.agents/skills/superpowers/test-driven-development/SKILL.md || test -f ~/.config/opencode/skills/superpowers/test-driven-development/SKILL.md
|
||||||
|
test -f ~/.agents/skills/superpowers/verification-before-completion/SKILL.md || test -f ~/.config/opencode/skills/superpowers/verification-before-completion/SKILL.md
|
||||||
|
test -f ~/.agents/skills/superpowers/finishing-a-development-branch/SKILL.md || test -f ~/.config/opencode/skills/superpowers/finishing-a-development-branch/SKILL.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cursor Verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cursor-agent --version
|
||||||
|
jq --version
|
||||||
|
test -f .cursor/skills/do-task/SKILL.md || test -f ~/.cursor/skills/do-task/SKILL.md
|
||||||
|
test -x .cursor/skills/reviewer-runtime/run-review.sh || test -x ~/.cursor/skills/reviewer-runtime/run-review.sh
|
||||||
|
test -x .cursor/skills/reviewer-runtime/notify-telegram.sh || test -x ~/.cursor/skills/reviewer-runtime/notify-telegram.sh
|
||||||
|
test -f .cursor/skills/superpowers/skills/brainstorming/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/brainstorming/SKILL.md || find ~/.cursor/plugins/cache/cursor-public/superpowers -path '*/skills/brainstorming/SKILL.md' -print -quit 2>/dev/null | grep -q .
|
||||||
|
test -f .cursor/skills/superpowers/skills/test-driven-development/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/test-driven-development/SKILL.md || find ~/.cursor/plugins/cache/cursor-public/superpowers -path '*/skills/test-driven-development/SKILL.md' -print -quit 2>/dev/null | grep -q .
|
||||||
|
test -f .cursor/skills/superpowers/skills/verification-before-completion/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/verification-before-completion/SKILL.md || find ~/.cursor/plugins/cache/cursor-public/superpowers -path '*/skills/verification-before-completion/SKILL.md' -print -quit 2>/dev/null | grep -q .
|
||||||
|
test -f .cursor/skills/superpowers/skills/finishing-a-development-branch/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/finishing-a-development-branch/SKILL.md || find ~/.cursor/plugins/cache/cursor-public/superpowers -path '*/skills/finishing-a-development-branch/SKILL.md' -print -quit 2>/dev/null | grep -q .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pi Verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pi --version
|
||||||
|
test -f .pi/skills/do-task/SKILL.md || test -f ~/.pi/agent/skills/do-task/SKILL.md
|
||||||
|
test -x .pi/skills/reviewer-runtime/pi/run-review.sh || test -x ~/.pi/agent/skills/reviewer-runtime/pi/run-review.sh
|
||||||
|
test -x .pi/skills/reviewer-runtime/pi/notify-telegram.sh || test -x ~/.pi/agent/skills/reviewer-runtime/pi/notify-telegram.sh
|
||||||
|
test -f .pi/skills/superpowers/brainstorming/SKILL.md || test -f ~/.pi/agent/skills/superpowers/brainstorming/SKILL.md || test -f ~/.agents/skills/superpowers/brainstorming/SKILL.md
|
||||||
|
test -f .pi/skills/superpowers/test-driven-development/SKILL.md || test -f ~/.pi/agent/skills/superpowers/test-driven-development/SKILL.md || test -f ~/.agents/skills/superpowers/test-driven-development/SKILL.md
|
||||||
|
test -f .pi/skills/superpowers/verification-before-completion/SKILL.md || test -f ~/.pi/agent/skills/superpowers/verification-before-completion/SKILL.md || test -f ~/.agents/skills/superpowers/verification-before-completion/SKILL.md
|
||||||
|
test -f .pi/skills/superpowers/finishing-a-development-branch/SKILL.md || test -f ~/.pi/agent/skills/superpowers/finishing-a-development-branch/SKILL.md || test -f ~/.agents/skills/superpowers/finishing-a-development-branch/SKILL.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Behavior
|
||||||
|
|
||||||
|
- Creates one persistent plan artifact at `ai_plan/YYYY-MM-DD-<slug>/task-plan.md`.
|
||||||
|
- Ensures `/ai_plan/` is in `.gitignore`. If missing, adds it and creates a separate
|
||||||
|
`chore(gitignore): ignore ai_plan local planning artifacts` commit.
|
||||||
|
- Parses the user prompt, detects the trigger phrase, and asks 1-3 clarifying questions unless
|
||||||
|
the prompt already has a concrete target + outcome + unambiguous scope + resolvable identifiers.
|
||||||
|
- Invokes `superpowers:brainstorming` for any behavior-changing task (feature creation,
|
||||||
|
non-trivial bug fix, refactor, design decision). The only skip conditions are
|
||||||
|
`pure-documentation` and `pure-comment-whitespace-rename`.
|
||||||
|
- Asks which reviewer CLI, model, and max rounds to use (or accepts `skip` for no review).
|
||||||
|
"Use defaults" maps to `codex / gpt-5.4 / MAX_ROUNDS=10`.
|
||||||
|
- Runs the plan review loop (Phase 5) before implementation, iterating up to `MAX_ROUNDS`
|
||||||
|
(default 10) or until the reviewer returns `VERDICT: APPROVED`.
|
||||||
|
- Executes with TDD-first (Phase 6) via `superpowers:test-driven-development`. Auto-skip
|
||||||
|
permitted only for `pure-documentation` and `pure-comment-whitespace-rename`; all other skips
|
||||||
|
(including config-file additions) require explicit user approval, recorded in the TDD Approach
|
||||||
|
section with an ISO-8601 timestamp.
|
||||||
|
- Runs lint/typecheck/tests as a **verification gate** (Phase 7) before the implementation review loop.
|
||||||
|
- Runs the implementation review loop (Phase 8) against the diff + verification output,
|
||||||
|
iterating up to `MAX_ROUNDS` or until `APPROVED`.
|
||||||
|
- Scans every outbound reviewer payload for secrets (subroutine step 1a). Per-payload, no caching.
|
||||||
|
- Creates a **single commit** after the implementation review approves. Does NOT push. Asks the
|
||||||
|
user for explicit `yes` before any push.
|
||||||
|
- Defaults to the **current branch**. Worktree only on explicit opt-in (`"in a worktree"`,
|
||||||
|
`"use a worktree"`, `"on an isolated branch"`, `"on a new branch called X"`).
|
||||||
|
- Supports resume: detects existing folder by slug and uses `Status` + Runtime State to decide how to re-enter.
|
||||||
|
- Sends completion notifications through Telegram only when the shared setup in
|
||||||
|
[TELEGRAM-NOTIFICATIONS.md](./TELEGRAM-NOTIFICATIONS.md) is installed and configured.
|
||||||
|
|
||||||
|
## Dual Review Loops
|
||||||
|
|
||||||
|
`do-task` runs the reviewer twice per successful run, with separate session IDs so reviewer context never leaks across loops.
|
||||||
|
|
||||||
|
1. **Plan review loop (Phase 5)** — payload is the current `task-plan.md` with `Runtime State`
|
||||||
|
and `Review History` stripped. The reviewer evaluates whether the plan matches the prompt,
|
||||||
|
whether assumptions are surfaced, whether acceptance criteria are testable, whether the TDD
|
||||||
|
approach is appropriate, and whether there are missing files/risks/security concerns.
|
||||||
|
2. **Implementation review loop (Phase 8)** — payload is the approved task plan (without Runtime
|
||||||
|
State) + `git diff` (unstaged + staged) + verification output (lint, typecheck, tests). The
|
||||||
|
reviewer evaluates correctness, code quality, test coverage, security, and regression risk.
|
||||||
|
|
||||||
|
Both loops share the same 9-step subroutine and the same `MAX_ROUNDS` counter (default 10).
|
||||||
|
|
||||||
|
### Subroutine Steps (inside each review loop)
|
||||||
|
|
||||||
|
1. Write payload to `/tmp/do-task-<kind>-<REVIEW_ID>.md`.
|
||||||
|
2. **Secret scan (step 1a)** — per-payload, no caching. See Secret Scan section below.
|
||||||
|
3. Generate reviewer command script at `/tmp/do-task-<kind>-review-<REVIEW_ID>.sh`.
|
||||||
|
4. Run via `reviewer-runtime/run-review.sh`.
|
||||||
|
5. Promote reviewer output and capture the session ID on Round 1; persist it to `task-plan.md`
|
||||||
|
Runtime State under the loop-specific variable (`CODEX_PLAN_SESSION_ID`,
|
||||||
|
`CODEX_IMPL_SESSION_ID`, `CURSOR_PLAN_SESSION_ID`, `CURSOR_IMPL_SESSION_ID`,
|
||||||
|
`OPENCODE_PLAN_SESSION_ID`, or `OPENCODE_IMPL_SESSION_ID`).
|
||||||
|
6. Parse verdict; append an entry to Review History; bump the round counter.
|
||||||
|
7. Branch: `APPROVED` → exit, `REVISE` → caller revises and re-enters, `MAX_ROUNDS` → caller decides.
|
||||||
|
8. Liveness contract: wait while `In progress N` heartbeats arrive from the runner.
|
||||||
|
9. Cleanup temp artifacts on success.
|
||||||
|
|
||||||
|
### Reviewer Output Contract
|
||||||
|
|
||||||
|
- `P0` = total blocker
|
||||||
|
- `P1` = major risk
|
||||||
|
- `P2` = must-fix before approval
|
||||||
|
- `P3` = cosmetic / nice to have
|
||||||
|
- Each severity section uses `- None.` when empty.
|
||||||
|
- `VERDICT: APPROVED` is valid only when no `P0`, `P1`, or `P2` findings remain.
|
||||||
|
- `P3` findings are non-blocking, but the caller should still try to fix them when cheap and safe.
|
||||||
|
|
||||||
|
## Runtime Artifacts
|
||||||
|
|
||||||
|
Per review loop (`<kind>` = `plan` or `implementation`):
|
||||||
|
|
||||||
|
- `/tmp/do-task-<kind>-<REVIEW_ID>.md` — payload
|
||||||
|
- `/tmp/do-task-<kind>-review-<REVIEW_ID>.md` — normalized review text
|
||||||
|
- `/tmp/do-task-<kind>-review-<REVIEW_ID>.json` — raw JSON (cursor always; opencode with `--format json`)
|
||||||
|
- `/tmp/do-task-<kind>-review-<REVIEW_ID>.stderr` — reviewer stderr
|
||||||
|
- `/tmp/do-task-<kind>-review-<REVIEW_ID>.status` — helper heartbeat/status log
|
||||||
|
- `/tmp/do-task-<kind>-review-<REVIEW_ID>.runner.out` — helper-managed stdout
|
||||||
|
- `/tmp/do-task-<kind>-review-<REVIEW_ID>.sh` — reviewer command script
|
||||||
|
|
||||||
|
Status log lines use this format:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ts=<ISO-8601> level=<info|warn|error> state=<running-silent|running-active|in-progress|stall-warning|completed|completed-empty-output|failed|needs-operator-decision> elapsed_s=<int> pid=<int> stdout_bytes=<int> stderr_bytes=<int> note="<short message>"
|
||||||
|
```
|
||||||
|
|
||||||
|
`in-progress` is the liveness heartbeat emitted roughly once per minute with `note="In progress N"`.
|
||||||
|
`stall-warning` is a non-terminal status-log state only. It does not mean the caller should
|
||||||
|
stop waiting if `in-progress` heartbeats continue.
|
||||||
|
|
||||||
|
### Persistent Artifact
|
||||||
|
|
||||||
|
The one file kept across runs is `ai_plan/<slug>/task-plan.md`. Its `Status` enum drives resume decisions:
|
||||||
|
|
||||||
|
| Status | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `draft` | Newly created; plan review not yet started |
|
||||||
|
| `plan-approved` | Plan review loop returned APPROVED |
|
||||||
|
| `implementation-in-progress` | Phase 6 executing |
|
||||||
|
| `implementation-approved` | Phase 8 review loop returned APPROVED; awaiting commit |
|
||||||
|
| `pushed` | Committed + pushed to remote |
|
||||||
|
| `local-only` | Committed locally; user declined push |
|
||||||
|
| `aborted-plan-review` | MAX_ROUNDS reached in Phase 5; user aborted |
|
||||||
|
| `aborted-impl-review` | MAX_ROUNDS reached in Phase 8; user aborted |
|
||||||
|
| `aborted-verification` | Phase 7 retries exhausted; user aborted |
|
||||||
|
| `failed` | Hard tooling failure |
|
||||||
|
|
||||||
|
## Failure Handling
|
||||||
|
|
||||||
|
- `completed-empty-output` — the reviewer exited without producing review text; surface
|
||||||
|
`.stderr` and `.status`, then retry only after diagnosing the cause.
|
||||||
|
- `needs-operator-decision` — the helper reached hard-timeout escalation; surface `.status`
|
||||||
|
and decide whether to extend the timeout, abort, or retry with different parameters.
|
||||||
|
- Successful rounds clean up temp artifacts. Failed, empty-output, and operator-decision rounds
|
||||||
|
retain `.stderr`, `.status`, and `.runner.out` until diagnosed.
|
||||||
|
- Verification gate (Phase 7) retries up to 3 times. On exhaustion, `Status` becomes
|
||||||
|
`aborted-verification` and the user is asked whether to retry, override, or abort.
|
||||||
|
- As long as fresh `in-progress` heartbeats continue to arrive roughly once per minute, the caller keeps waiting.
|
||||||
|
|
||||||
|
## Secret Scan (subroutine step 1a; per-payload; no caching)
|
||||||
|
|
||||||
|
Every outbound reviewer payload is scanned **before** being sent to the reviewer CLI. This scan
|
||||||
|
runs on every round of both loops. No results are cached, because the Phase 8 payload includes
|
||||||
|
newly-introduced diff content that earlier rounds never saw.
|
||||||
|
|
||||||
|
Canonical anchored regex list (10 patterns):
|
||||||
|
|
||||||
|
```text
|
||||||
|
AWS access key: AKIA[0-9A-Z]{16}
|
||||||
|
GCP service-acct: "type"\s*:\s*"service_account"
|
||||||
|
GitHub tokens: (ghp|gho|ghs|ghu|ghr)_[A-Za-z0-9]{36,}
|
||||||
|
Slack tokens: xox[abpsr]-[0-9]+-[0-9]+-[0-9]+-[A-Za-z0-9]{24,}
|
||||||
|
xox[abpsr]-[A-Za-z0-9]{10,48}
|
||||||
|
OpenAI API keys: sk-(proj-)?[A-Za-z0-9_-]{20,}
|
||||||
|
Anthropic API keys: sk-ant-(api|admin)[0-9]+-[A-Za-z0-9_-]{20,}
|
||||||
|
PEM private keys: -----BEGIN [A-Z ]+ PRIVATE KEY-----
|
||||||
|
.env-style: (TOKEN|SECRET|PASSWORD|API_?KEY|ACCESS_?KEY)\s*=\s*["']?[A-Za-z0-9+/=_-]{8,}
|
||||||
|
JWT: eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+
|
||||||
|
```
|
||||||
|
|
||||||
|
If a match is found, the skill **redacts the matched text before showing it to the user** using
|
||||||
|
the fixed token `[REDACTED:<pattern-label>:<match-length>-chars]` (pattern labels:
|
||||||
|
`aws-access-key`, `gcp-service-account`, `github-token`, `slack-token`, `openai-key`,
|
||||||
|
`anthropic-key`, `pem-private-key`, `dotenv-style`, `jwt`). File paths and line numbers are kept.
|
||||||
|
Raw match text is never echoed to terminal, chat log, or any persistent file.
|
||||||
|
|
||||||
|
The user answers `yes` / `no` / `redact`:
|
||||||
|
|
||||||
|
- `yes` — proceed; Runtime State records `last_scan_outcome_<kind>=user-approved-with-matches`.
|
||||||
|
- `redact` — the user supplies redactions, the skill applies them, and re-scans before sending. Runtime State records `last_scan_outcome_<kind>=redacted-and-approved`.
|
||||||
|
- `no` — stop the loop, set `Status: failed`, send Telegram summary.
|
||||||
|
|
||||||
|
## Supported Reviewer CLIs
|
||||||
|
|
||||||
|
| CLI | Round-1 command | Round-N resume | Output capture |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `codex` | `codex exec -m <model> -s read-only -o <out.md> "<prompt>"` | `codex exec resume <session-id> -o <out.md> "<prompt>"` | `<out.md>` directly (helper `--success-file`) |
|
||||||
|
| `claude` | `claude -p "<prompt>" --model <model> --strict-mcp-config --setting-sources user` | Fresh call with prior-round context summary | `cp <runner.out> <out.md>` |
|
||||||
|
| `cursor` | `cursor-agent -p --mode=ask --model <model> --trust --output-format json "<prompt>" > <out.json>` | `cursor-agent --resume <id> -p --mode=ask --model <model> --trust --output-format json "<prompt>" > <out.json>` | `jq -r '.result' <out.json> > <out.md>` |
|
||||||
|
| `opencode` | `opencode run -m <provider>/<model> --agent plan --format json "<prompt>" > <out.json>` | Fresh call (default) OR `opencode run -s <id> -m <provider>/<model> --agent plan --format json "<prompt>" > <out.json>` (opt-in) | `jq -r '.[] \| select(.type == "message" and .role == "assistant") \| .content' <out.json> > <out.md>` |
|
||||||
|
| `pi` | See [PI-COMMON-REVIEWER.md](./PI-COMMON-REVIEWER.md) | Fresh call | Markdown stdout copied to `<out.md>` |
|
||||||
|
|
||||||
|
For all supported reviewer CLIs, the preferred execution path is:
|
||||||
|
|
||||||
|
1. Write the reviewer command to a bash script.
|
||||||
|
2. Run that script through `reviewer-runtime/run-review.sh`.
|
||||||
|
3. Fall back to direct synchronous execution only if the helper is missing or not executable.
|
||||||
|
|
||||||
|
## Pi Reviewer Support
|
||||||
|
|
||||||
|
All workflow variants can use Pi itself as a reviewer CLI. Use `pi/<pi-model-name>` shorthand,
|
||||||
|
for example `pi/claude-opus-4-7`; this means `REVIEWER_CLI=pi` and
|
||||||
|
`REVIEWER_MODEL=claude-opus-4-7`. Provider-qualified or multi-slash Pi model IDs are preserved
|
||||||
|
after the first `pi/` prefix, for example `pi/anthropic/claude-opus-4-7`.
|
||||||
|
|
||||||
|
The canonical isolated read-only Pi reviewer flag contract lives in
|
||||||
|
[PI-COMMON-REVIEWER.md](./PI-COMMON-REVIEWER.md). This workflow passes the plan and
|
||||||
|
implementation review payload at `/tmp/do-task-${REVIEW_KIND}-${REVIEW_ID}.md` and expects the
|
||||||
|
standard `## Summary`, `## Findings`, and `## Verdict` response. Pi reviewer output is captured
|
||||||
|
as markdown stdout, not JSON.
|
||||||
|
|
||||||
|
If the Pi reviewer model or provider is unavailable, surface the helper stderr/status and use
|
||||||
|
`pi --list-models [search]` to inspect configured models.
|
||||||
|
|
||||||
|
## Notifications
|
||||||
|
|
||||||
|
- Telegram is the only supported notification path.
|
||||||
|
- Shared setup: [TELEGRAM-NOTIFICATIONS.md](./TELEGRAM-NOTIFICATIONS.md)
|
||||||
|
- Notification failures are non-blocking, but they must be surfaced to the user.
|
||||||
|
- Before stopping for any user interaction, approval, or manual decision, the skill sends a Telegram summary first if configured.
|
||||||
|
- Terminal outcomes that trigger Telegram: `pushed`, `local-only`, `aborted-plan-review`,
|
||||||
|
`aborted-impl-review`, `aborted-verification`, `failed`.
|
||||||
|
|
||||||
|
The reviewer-runtime helper also supports manual override flags for diagnostics:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
run-review.sh \
|
||||||
|
--command-file <path> \
|
||||||
|
--stdout-file <path> \
|
||||||
|
--stderr-file <path> \
|
||||||
|
--status-file <path> \
|
||||||
|
--poll-seconds 10 \
|
||||||
|
--soft-timeout-seconds 600 \
|
||||||
|
--stall-warning-seconds 300 \
|
||||||
|
--hard-timeout-seconds 1800
|
||||||
|
```
|
||||||
|
|
||||||
|
## Template Guardrails
|
||||||
|
|
||||||
|
All four `templates/task-plan.md` files share identical core sections (14 `##`-level headings)
|
||||||
|
and identical Status enum (10 values). Variant-specific guardrail language is permitted in the
|
||||||
|
leading blockquote and in the `Runtime` field of the Metadata table.
|
||||||
|
|
||||||
|
**Core sections** (appear in every variant, same order):
|
||||||
|
|
||||||
|
1. Metadata
|
||||||
|
2. Prompt
|
||||||
|
3. Interpretation
|
||||||
|
4. Assumptions
|
||||||
|
5. Files
|
||||||
|
6. Approach
|
||||||
|
7. TDD Approach
|
||||||
|
8. Acceptance Criteria
|
||||||
|
9. Verification
|
||||||
|
10. Rollback
|
||||||
|
11. Runtime State
|
||||||
|
12. Review History
|
||||||
|
13. Final Status
|
||||||
|
14. Guardrails (do NOT remove)
|
||||||
|
|
||||||
|
**Runtime State keys** (same across all variants): `plan_review_round`,
|
||||||
|
`implementation_review_round`, `CODEX_PLAN_SESSION_ID`, `CODEX_IMPL_SESSION_ID`,
|
||||||
|
`CURSOR_PLAN_SESSION_ID`, `CURSOR_IMPL_SESSION_ID`, `OPENCODE_PLAN_SESSION_ID`,
|
||||||
|
`OPENCODE_IMPL_SESSION_ID`, `last_phase_entered`, `last_round_ts`, `last_scan_outcome_plan`,
|
||||||
|
`last_scan_outcome_impl`, `verification_attempts`, `tests_added_count`, `tdd_used`.
|
||||||
|
|
||||||
|
## Variant Hardening Notes
|
||||||
|
|
||||||
|
### Claude Code Hardening
|
||||||
|
|
||||||
|
- Must invoke explicit required sub-skills via the `Skill` tool:
|
||||||
|
- `superpowers:brainstorming`
|
||||||
|
- `superpowers:test-driven-development`
|
||||||
|
- `superpowers:verification-before-completion`
|
||||||
|
- `superpowers:finishing-a-development-branch`
|
||||||
|
- `superpowers:using-git-worktrees` (conditional)
|
||||||
|
- Must enforce plan-mode file-write guard in Phase 4:
|
||||||
|
- If currently in plan mode, instruct user to exit plan mode before writing `task-plan.md`.
|
||||||
|
|
||||||
|
### Codex Hardening
|
||||||
|
|
||||||
|
- Must use native skill discovery from `~/.agents/skills/` (no CLI wrappers).
|
||||||
|
- Must verify Superpowers skills symlink: `~/.agents/skills/superpowers -> ~/.codex/superpowers/skills`
|
||||||
|
- Must invoke required sub-skills with explicit announcements before any action.
|
||||||
|
- Must track checklist-driven sub-skills with `update_plan` todos (Codex equivalent of `TodoWrite`).
|
||||||
|
- `Task` subagents are unavailable — do the work directly and state the limitation.
|
||||||
|
- Deprecated CLI commands (`superpowers-codex bootstrap`, `use-skill`) must NOT be used.
|
||||||
|
- Helper paths: `~/.codex/skills/reviewer-runtime/...`.
|
||||||
|
- No plan-mode guard (Codex has no plan-mode concept).
|
||||||
|
|
||||||
|
### OpenCode Hardening
|
||||||
|
|
||||||
|
- Must use OpenCode's native skill tool (not 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
|
||||||
|
the required `superpowers/<skill>` set is discoverable before any other phase runs.
|
||||||
|
- Must verify Superpowers skill discovery under `~/.agents/skills/superpowers` or `~/.config/opencode/skills/superpowers`.
|
||||||
|
- Helper paths: `~/.config/opencode/skills/reviewer-runtime/...`.
|
||||||
|
- Opencode reviewer calls MUST use `--agent plan` (the built-in plan primary agent) for read-only posture.
|
||||||
|
- No plan-mode guard (OpenCode has no plan-mode concept).
|
||||||
|
|
||||||
|
### Cursor Hardening
|
||||||
|
|
||||||
|
- Must use Cursor-native discovery from `.cursor/skills/`, `~/.cursor/skills/`, or installed Cursor plugin cache entries.
|
||||||
|
- Must announce skill usage explicitly before invocation.
|
||||||
|
- `jq` is a hard prerequisite.
|
||||||
|
- Helper paths: `.cursor/skills/reviewer-runtime/...` preferred, `~/.cursor/skills/reviewer-runtime/...` fallback.
|
||||||
|
- Reviewer invocations MUST use `--mode=ask --trust --output-format json`. Never `--mode=agent`,
|
||||||
|
never `--force`, never write-capable modes for reviewer calls.
|
||||||
|
- No plan-mode guard (Cursor has no plan-mode concept).
|
||||||
|
|
||||||
|
## Execution Workflow Rules
|
||||||
|
|
||||||
|
- The skill works from `ai_plan/YYYY-MM-DD-<slug>/task-plan.md` as its single persistent artifact.
|
||||||
|
- Current branch is the default; worktree is opt-in only through explicit trigger phrases.
|
||||||
|
- Plan review completes before any implementation starts.
|
||||||
|
- Phase 7 verification gate must pass before the implementation review starts.
|
||||||
|
- The task commit is a single commit created in Phase 9.
|
||||||
|
- The `.gitignore` infra commit (Phase 1) is explicitly separate from the task commit and is
|
||||||
|
allowed even when the final task ends up `aborted` or `failed`.
|
||||||
|
- No push without explicit `yes` from the user.
|
||||||
|
- Secret scan runs per-payload with no caching.
|
||||||
|
- `MAX_ROUNDS=10` is shared across both loops (single mental model).
|
||||||
+130
-38
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
Execute an existing plan (created by `create-plan`) in an isolated git worktree, with iterative cross-model review at each milestone boundary. Milestones are implemented one-by-one with lint/typecheck/test gates, reviewed by a second model/provider, and committed locally until all are approved.
|
Execute an existing plan (created by `create-plan`) in an isolated git worktree, with iterative
|
||||||
|
cross-model review at each milestone boundary. Milestones are implemented one-by-one with
|
||||||
|
lint/typecheck/test gates, reviewed by a second model/provider, and committed locally until all
|
||||||
|
are approved.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@@ -19,12 +22,15 @@ Execute an existing plan (created by `create-plan`) in an isolated git worktree,
|
|||||||
- `superpowers:finishing-a-development-branch`
|
- `superpowers:finishing-a-development-branch`
|
||||||
- For Codex, native skill discovery must be configured:
|
- For Codex, native skill discovery must be configured:
|
||||||
- `~/.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)
|
- Cursor can use the Cursor Superpowers plugin cache or manual `.cursor/skills/superpowers/skills`
|
||||||
|
/ `~/.cursor/skills/superpowers/skills` installs.
|
||||||
|
- OpenCode can use `~/.agents/skills/superpowers` or `~/.config/opencode/skills/superpowers`.
|
||||||
- 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,17 +39,23 @@ If dependencies are missing, stop and return:
|
|||||||
|
|
||||||
### Reviewer CLI Requirements (Optional)
|
### Reviewer CLI Requirements (Optional)
|
||||||
|
|
||||||
To use the iterative milestone review feature, one of these CLIs must be installed:
|
The canonical reviewer CLI support matrix is documented in
|
||||||
|
[REVIEWERS.md](./REVIEWERS.md). To use the iterative milestone review feature, one of these CLIs
|
||||||
|
must be installed:
|
||||||
|
|
||||||
| Reviewer CLI | Install | Verify |
|
| Reviewer CLI | Install | Verify |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `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) |
|
||||||
|
| `opencode` | `brew install opencode` or your package manager | `opencode --version` |
|
||||||
|
| `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.
|
||||||
|
|
||||||
**Additional dependency for `cursor` reviewer:** `jq` is required to parse Cursor's JSON output. Install via `brew install jq` (macOS) or your package manager. Verify: `jq --version`.
|
**Additional dependency for `cursor` reviewer:** `jq` is required to parse Cursor's JSON output.
|
||||||
|
Install via `brew install jq` (macOS) or your package manager. Verify: `jq --version`.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
@@ -53,7 +65,8 @@ The reviewer CLI is independent of which agent is running the implementation —
|
|||||||
mkdir -p ~/.codex/skills/implement-plan
|
mkdir -p ~/.codex/skills/implement-plan
|
||||||
cp -R skills/implement-plan/codex/* ~/.codex/skills/implement-plan/
|
cp -R skills/implement-plan/codex/* ~/.codex/skills/implement-plan/
|
||||||
mkdir -p ~/.codex/skills/reviewer-runtime
|
mkdir -p ~/.codex/skills/reviewer-runtime
|
||||||
cp -R skills/reviewer-runtime/* ~/.codex/skills/reviewer-runtime/
|
cp skills/reviewer-runtime/run-review.sh skills/reviewer-runtime/notify-telegram.sh ~/.codex/skills/reviewer-runtime/
|
||||||
|
chmod +x ~/.codex/skills/reviewer-runtime/*.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
### Claude Code
|
### Claude Code
|
||||||
@@ -62,7 +75,8 @@ cp -R skills/reviewer-runtime/* ~/.codex/skills/reviewer-runtime/
|
|||||||
mkdir -p ~/.claude/skills/implement-plan
|
mkdir -p ~/.claude/skills/implement-plan
|
||||||
cp -R skills/implement-plan/claude-code/* ~/.claude/skills/implement-plan/
|
cp -R skills/implement-plan/claude-code/* ~/.claude/skills/implement-plan/
|
||||||
mkdir -p ~/.claude/skills/reviewer-runtime
|
mkdir -p ~/.claude/skills/reviewer-runtime
|
||||||
cp -R skills/reviewer-runtime/* ~/.claude/skills/reviewer-runtime/
|
cp skills/reviewer-runtime/run-review.sh skills/reviewer-runtime/notify-telegram.sh ~/.claude/skills/reviewer-runtime/
|
||||||
|
chmod +x ~/.claude/skills/reviewer-runtime/*.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
### OpenCode
|
### OpenCode
|
||||||
@@ -71,7 +85,8 @@ cp -R skills/reviewer-runtime/* ~/.claude/skills/reviewer-runtime/
|
|||||||
mkdir -p ~/.config/opencode/skills/implement-plan
|
mkdir -p ~/.config/opencode/skills/implement-plan
|
||||||
cp -R skills/implement-plan/opencode/* ~/.config/opencode/skills/implement-plan/
|
cp -R skills/implement-plan/opencode/* ~/.config/opencode/skills/implement-plan/
|
||||||
mkdir -p ~/.config/opencode/skills/reviewer-runtime
|
mkdir -p ~/.config/opencode/skills/reviewer-runtime
|
||||||
cp -R skills/reviewer-runtime/* ~/.config/opencode/skills/reviewer-runtime/
|
cp skills/reviewer-runtime/run-review.sh skills/reviewer-runtime/notify-telegram.sh ~/.config/opencode/skills/reviewer-runtime/
|
||||||
|
chmod +x ~/.config/opencode/skills/reviewer-runtime/*.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
### Cursor
|
### Cursor
|
||||||
@@ -82,7 +97,8 @@ Copy into the repo-local `.cursor/skills/` directory (where the Cursor Agent CLI
|
|||||||
mkdir -p .cursor/skills/implement-plan
|
mkdir -p .cursor/skills/implement-plan
|
||||||
cp -R skills/implement-plan/cursor/* .cursor/skills/implement-plan/
|
cp -R skills/implement-plan/cursor/* .cursor/skills/implement-plan/
|
||||||
mkdir -p .cursor/skills/reviewer-runtime
|
mkdir -p .cursor/skills/reviewer-runtime
|
||||||
cp -R skills/reviewer-runtime/* .cursor/skills/reviewer-runtime/
|
cp skills/reviewer-runtime/run-review.sh skills/reviewer-runtime/notify-telegram.sh .cursor/skills/reviewer-runtime/
|
||||||
|
chmod +x .cursor/skills/reviewer-runtime/*.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Or install globally (loaded via `~/.cursor/skills/`):
|
Or install globally (loaded via `~/.cursor/skills/`):
|
||||||
@@ -91,9 +107,36 @@ Or install globally (loaded via `~/.cursor/skills/`):
|
|||||||
mkdir -p ~/.cursor/skills/implement-plan
|
mkdir -p ~/.cursor/skills/implement-plan
|
||||||
cp -R skills/implement-plan/cursor/* ~/.cursor/skills/implement-plan/
|
cp -R skills/implement-plan/cursor/* ~/.cursor/skills/implement-plan/
|
||||||
mkdir -p ~/.cursor/skills/reviewer-runtime
|
mkdir -p ~/.cursor/skills/reviewer-runtime
|
||||||
cp -R skills/reviewer-runtime/* ~/.cursor/skills/reviewer-runtime/
|
cp skills/reviewer-runtime/run-review.sh skills/reviewer-runtime/notify-telegram.sh ~/.cursor/skills/reviewer-runtime/
|
||||||
|
chmod +x ~/.cursor/skills/reviewer-runtime/*.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
|
pnpm run sync:pi
|
||||||
|
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 +144,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:
|
||||||
@@ -117,14 +162,34 @@ Verify Superpowers execution dependencies exist in your agent skills root:
|
|||||||
- Claude Code: `~/.claude/skills/superpowers/using-git-worktrees/SKILL.md`
|
- Claude Code: `~/.claude/skills/superpowers/using-git-worktrees/SKILL.md`
|
||||||
- Claude Code: `~/.claude/skills/superpowers/verification-before-completion/SKILL.md`
|
- Claude Code: `~/.claude/skills/superpowers/verification-before-completion/SKILL.md`
|
||||||
- Claude Code: `~/.claude/skills/superpowers/finishing-a-development-branch/SKILL.md`
|
- Claude Code: `~/.claude/skills/superpowers/finishing-a-development-branch/SKILL.md`
|
||||||
- OpenCode: `~/.config/opencode/skills/superpowers/executing-plans/SKILL.md`
|
- OpenCode: `~/.agents/skills/superpowers/executing-plans/SKILL.md` or `~/.config/opencode/skills/superpowers/executing-plans/SKILL.md`
|
||||||
- OpenCode: `~/.config/opencode/skills/superpowers/using-git-worktrees/SKILL.md`
|
- OpenCode: `~/.agents/skills/superpowers/using-git-worktrees/SKILL.md` or `~/.config/opencode/skills/superpowers/using-git-worktrees/SKILL.md`
|
||||||
- OpenCode: `~/.config/opencode/skills/superpowers/verification-before-completion/SKILL.md`
|
- OpenCode: `~/.agents/skills/superpowers/verification-before-completion/SKILL.md` or `~/.config/opencode/skills/superpowers/verification-before-completion/SKILL.md`
|
||||||
- OpenCode: `~/.config/opencode/skills/superpowers/finishing-a-development-branch/SKILL.md`
|
- OpenCode: `~/.agents/skills/superpowers/finishing-a-development-branch/SKILL.md` or `~/.config/opencode/skills/superpowers/finishing-a-development-branch/SKILL.md`
|
||||||
- Cursor: `.cursor/skills/superpowers/skills/executing-plans/SKILL.md` or `~/.cursor/skills/superpowers/skills/executing-plans/SKILL.md`
|
- Cursor: `.cursor/skills/superpowers/skills/executing-plans/SKILL.md`,
|
||||||
- Cursor: `.cursor/skills/superpowers/skills/using-git-worktrees/SKILL.md` or `~/.cursor/skills/superpowers/skills/using-git-worktrees/SKILL.md`
|
`~/.cursor/skills/superpowers/skills/executing-plans/SKILL.md`, or the Cursor Superpowers
|
||||||
- Cursor: `.cursor/skills/superpowers/skills/verification-before-completion/SKILL.md` or `~/.cursor/skills/superpowers/skills/verification-before-completion/SKILL.md`
|
plugin cache
|
||||||
- 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/using-git-worktrees/SKILL.md`,
|
||||||
|
`~/.cursor/skills/superpowers/skills/using-git-worktrees/SKILL.md`, or the Cursor Superpowers
|
||||||
|
plugin cache
|
||||||
|
- Cursor: `.cursor/skills/superpowers/skills/verification-before-completion/SKILL.md`,
|
||||||
|
`~/.cursor/skills/superpowers/skills/verification-before-completion/SKILL.md`, or the Cursor
|
||||||
|
Superpowers plugin cache
|
||||||
|
- Cursor: `.cursor/skills/superpowers/skills/finishing-a-development-branch/SKILL.md`,
|
||||||
|
`~/.cursor/skills/superpowers/skills/finishing-a-development-branch/SKILL.md`, or the Cursor
|
||||||
|
Superpowers plugin cache
|
||||||
|
- 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
|
||||||
|
|
||||||
@@ -133,27 +198,31 @@ Verify Superpowers execution dependencies exist in your agent skills root:
|
|||||||
- Executes milestones one-by-one, tracking stories in `story-tracker.md`.
|
- Executes milestones one-by-one, tracking stories in `story-tracker.md`.
|
||||||
- Runs lint/typecheck/tests as a gate before each milestone review.
|
- Runs lint/typecheck/tests as a gate before each milestone review.
|
||||||
- Sends each milestone to a reviewer CLI for approval (max rounds configurable, default 10).
|
- Sends each milestone to a reviewer CLI for approval (max rounds configurable, default 10).
|
||||||
- Runs reviewer commands through `reviewer-runtime/run-review.sh` when available, with fallback to direct synchronous execution only if the helper is missing.
|
- Runs reviewer commands through `reviewer-runtime/run-review.sh` when available, with fallback to
|
||||||
|
direct synchronous execution only if the helper is missing.
|
||||||
- Waits as long as the reviewer runtime keeps emitting per-minute `In progress N` heartbeats.
|
- Waits as long as the reviewer runtime keeps emitting per-minute `In progress N` heartbeats.
|
||||||
- Requires reviewer findings to be ordered `P0` through `P3`, with `P3` explicitly non-blocking.
|
- Requires reviewer findings to be ordered `P0` through `P3`, with `P3` explicitly non-blocking.
|
||||||
- Captures reviewer stderr and helper status logs for diagnostics and retains them on failed, empty-output, or operator-decision review rounds.
|
- Captures reviewer stderr and helper status logs for diagnostics and retains them on failed,
|
||||||
|
empty-output, or operator-decision review rounds.
|
||||||
- Commits each milestone locally only after reviewer approval (does not push).
|
- Commits each milestone locally only after reviewer approval (does not push).
|
||||||
- After all milestones approved, merges worktree branch to parent and deletes worktree.
|
- After all milestones approved, merges worktree branch to parent and deletes worktree.
|
||||||
- Supports resume: detects existing worktree and `in-dev`/`completed` stories.
|
- Supports resume: detects existing worktree and `in-dev`/`completed` stories.
|
||||||
- Sends completion notifications through Telegram only when the shared setup in [TELEGRAM-NOTIFICATIONS.md](./TELEGRAM-NOTIFICATIONS.md) is installed and configured.
|
- Sends completion notifications through Telegram only when the shared setup in
|
||||||
|
[TELEGRAM-NOTIFICATIONS.md](./TELEGRAM-NOTIFICATIONS.md) is installed and configured.
|
||||||
|
|
||||||
## Milestone Review Loop
|
## Milestone Review Loop
|
||||||
|
|
||||||
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`, `opencode`, `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
|
||||||
|
|
||||||
@@ -184,13 +253,17 @@ ts=<ISO-8601> level=<info|warn|error> state=<running-silent|running-active|in-pr
|
|||||||
```
|
```
|
||||||
|
|
||||||
`in-progress` is the liveness heartbeat emitted roughly once per minute with `note="In progress N"`.
|
`in-progress` is the liveness heartbeat emitted roughly once per minute with `note="In progress N"`.
|
||||||
`stall-warning` is a non-terminal status-log state only. It does not mean the caller should stop waiting if `in-progress` heartbeats continue.
|
`stall-warning` is a non-terminal status-log state only. It does not mean the caller should stop
|
||||||
|
waiting if `in-progress` heartbeats continue.
|
||||||
|
|
||||||
### Failure Handling
|
### Failure Handling
|
||||||
|
|
||||||
- `completed-empty-output` means the reviewer exited without producing review text; surface `.stderr` and `.status`, then retry only after diagnosing the cause.
|
- `completed-empty-output` means the reviewer exited without producing review text; surface
|
||||||
- `needs-operator-decision` means the helper reached hard-timeout escalation; surface `.status` and decide whether to extend the timeout, abort, or retry with different parameters.
|
`.stderr` and `.status`, then retry only after diagnosing the cause.
|
||||||
- Successful rounds clean up temp artifacts. Failed, empty-output, and operator-decision rounds should retain `.stderr`, `.status`, and `.runner.out` until diagnosed.
|
- `needs-operator-decision` means the helper reached hard-timeout escalation; surface `.status`
|
||||||
|
and decide whether to extend the timeout, abort, or retry with different parameters.
|
||||||
|
- Successful rounds clean up temp artifacts. Failed, empty-output, and operator-decision rounds
|
||||||
|
should retain `.stderr`, `.status`, and `.runner.out` until diagnosed.
|
||||||
- As long as fresh `in-progress` heartbeats continue to arrive roughly once per minute, the caller should keep waiting.
|
- As long as fresh `in-progress` heartbeats continue to arrive roughly once per minute, the caller should keep waiting.
|
||||||
|
|
||||||
### Supported Reviewer CLIs
|
### Supported Reviewer CLIs
|
||||||
@@ -200,13 +273,31 @@ 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` |
|
||||||
|
| `opencode` | `opencode run -m <provider>/<model> --agent plan --format json` | Fresh call default; optional `-s <id>` | `--agent plan` |
|
||||||
|
| `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.
|
||||||
@@ -230,7 +321,7 @@ run-review.sh \
|
|||||||
|
|
||||||
## Variant Hardening Notes
|
## Variant Hardening Notes
|
||||||
|
|
||||||
### Claude Code
|
### Claude Code Hardening
|
||||||
|
|
||||||
- Must invoke explicit required sub-skills:
|
- Must invoke explicit required sub-skills:
|
||||||
- `superpowers:executing-plans`
|
- `superpowers:executing-plans`
|
||||||
@@ -238,7 +329,7 @@ run-review.sh \
|
|||||||
- `superpowers:verification-before-completion`
|
- `superpowers:verification-before-completion`
|
||||||
- `superpowers:finishing-a-development-branch`
|
- `superpowers:finishing-a-development-branch`
|
||||||
|
|
||||||
### Codex
|
### Codex Hardening
|
||||||
|
|
||||||
- Must use native skill discovery from `~/.agents/skills/` (no CLI wrappers).
|
- Must use native skill discovery from `~/.agents/skills/` (no CLI wrappers).
|
||||||
- Must verify Superpowers skills symlink: `~/.agents/skills/superpowers -> ~/.codex/superpowers/skills`
|
- Must verify Superpowers skills symlink: `~/.agents/skills/superpowers -> ~/.codex/superpowers/skills`
|
||||||
@@ -246,16 +337,17 @@ run-review.sh \
|
|||||||
- Must track checklist-driven skills with `update_plan` todos.
|
- Must track checklist-driven skills with `update_plan` todos.
|
||||||
- Deprecated CLI commands (`superpowers-codex bootstrap`, `use-skill`) must NOT be used.
|
- Deprecated CLI commands (`superpowers-codex bootstrap`, `use-skill`) must NOT be used.
|
||||||
|
|
||||||
### OpenCode
|
### OpenCode Hardening
|
||||||
|
|
||||||
- Must use OpenCode native skill tool (not Claude/Codex invocation syntax).
|
- Must use OpenCode native skill tool (not Claude/Codex invocation syntax).
|
||||||
- Must verify Superpowers skill discovery under:
|
- Must verify Superpowers skill discovery under:
|
||||||
|
- `~/.agents/skills/superpowers`
|
||||||
- `~/.config/opencode/skills/superpowers`
|
- `~/.config/opencode/skills/superpowers`
|
||||||
- Must explicitly load all four execution sub-skills.
|
- Must explicitly load all four execution sub-skills.
|
||||||
|
|
||||||
### Cursor
|
### Cursor Hardening
|
||||||
|
|
||||||
- Must use workspace discovery from `.cursor/skills/` (repo-local or `~/.cursor/skills/` global).
|
- Must use Cursor-native discovery from `.cursor/skills/`, `~/.cursor/skills/`, or installed Cursor plugin cache entries.
|
||||||
- Must announce skill usage explicitly before invocation.
|
- Must announce skill usage explicitly before invocation.
|
||||||
- Must use `--mode=ask` (read-only) and `--trust` when running reviewer non-interactively.
|
- Must use `--mode=ask` (read-only) and `--trust` when running reviewer non-interactively.
|
||||||
- Must not use `--force` or `--mode=agent` for review (reviewer should never write files).
|
- Must not use `--force` or `--mode=agent` for review (reviewer should never write files).
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
The wizard recognizes client-native and plugin-managed Superpowers installs before asking to add another copy:
|
||||||
|
|
||||||
|
- Codex: `~/.agents/skills/superpowers` or `~/.codex/superpowers/skills`
|
||||||
|
- Claude Code: `~/.claude/skills/superpowers` or the enabled `superpowers@claude-plugins-official` plugin cache
|
||||||
|
- Cursor: `.cursor/skills/superpowers/skills`, `~/.cursor/skills/superpowers/skills`,
|
||||||
|
or the Cursor public Superpowers plugin cache
|
||||||
|
- OpenCode: `~/.agents/skills/superpowers` or `~/.config/opencode/skills/superpowers`
|
||||||
|
- Pi: `~/.agents/skills/superpowers`, `~/.pi/agent/skills/superpowers`, or `.pi/skills/superpowers`
|
||||||
|
|
||||||
|
When Cursor has no plugin/cache install, the wizard installs Superpowers at
|
||||||
|
`<scope>/superpowers/skills` because Cursor variants expect
|
||||||
|
`superpowers/skills/<skill>/SKILL.md`. Symlinking is still preferred when the source tree is
|
||||||
|
managed locally. Codex and OpenCode can commonly reuse the shared `~/.agents/skills/superpowers`
|
||||||
|
convention documented in [CODEX.md](./CODEX.md) and [OPENCODE.md](./OPENCODE.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
|
||||||
|
```
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# PI COMMON REVIEWER
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This document covers the shared reviewer-runtime helpers used by the Pi workflow skills.
|
||||||
|
|
||||||
|
It is intentionally separate from [PI-SUPERPOWERS.md](./PI-SUPERPOWERS.md). Superpowers are
|
||||||
|
skill dependencies; reviewer-runtime is helper-script setup.
|
||||||
|
|
||||||
|
## Pi As A Reviewer CLI
|
||||||
|
|
||||||
|
Pi workflow skills may use `pi` itself as the reviewer CLI, even when the main workflow is already
|
||||||
|
running in pi. In that case, the reviewer model is configured independently from the running agent
|
||||||
|
model. This lets the operator run a workflow with one model while asking another pi-configured
|
||||||
|
model, including provider-qualified model IDs, to review the plan or implementation.
|
||||||
|
|
||||||
|
The canonical isolated, read-only reviewer command is:
|
||||||
|
|
||||||
|
```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 review payload and return the requested verdict."
|
||||||
|
```
|
||||||
|
|
||||||
|
Workflow docs should link to this section instead of restating the full flag contract. Each
|
||||||
|
workflow substitutes its own payload path and verdict prompt:
|
||||||
|
|
||||||
|
- `create-plan`: `/tmp/plan-${REVIEW_ID}.md`
|
||||||
|
- `implement-plan`: `/tmp/milestone-${REVIEW_ID}.md`
|
||||||
|
- `do-task`: `/tmp/do-task-${REVIEW_KIND}-${REVIEW_ID}.md`
|
||||||
|
|
||||||
|
User-facing shorthand `pi/<pi-model-name>` means `REVIEWER_CLI=pi` and
|
||||||
|
`REVIEWER_MODEL=<pi-model-name>`. Split only on the first slash when the prefix before that slash
|
||||||
|
is exactly `pi`; keep the remainder verbatim. Examples:
|
||||||
|
|
||||||
|
- `pi/claude-opus-4-7` -> `claude-opus-4-7`
|
||||||
|
- `pi/anthropic/claude-opus-4-7` -> `anthropic/claude-opus-4-7`
|
||||||
|
- `pi/openrouter/anthropic/claude-opus-4-7` -> `openrouter/anthropic/claude-opus-4-7`
|
||||||
|
|
||||||
|
Pi reviewer calls must stay isolated from the main workflow:
|
||||||
|
|
||||||
|
- Use `--no-session` so the reviewer does not continue or persist the workflow session.
|
||||||
|
- Use `--no-skills --no-prompt-templates --no-extensions --no-context-files` so the reviewer does
|
||||||
|
not load workflow skills, project context files, or package extensions that could re-enter
|
||||||
|
`create-plan`, `implement-plan`, or `do-task`.
|
||||||
|
- Use exactly `--tools read,grep,find,ls` for review.
|
||||||
|
The pi reviewer command MUST NOT include `write`, `edit`, or `bash`;
|
||||||
|
the reviewer reads payloads and diffs but never modifies files or runs commands.
|
||||||
|
|
||||||
|
If the reviewer subprocess exits non-zero because the provider, credentials, or model ID are
|
||||||
|
unavailable, surface the captured stderr/status from `run-review.sh`, then ask the user for a
|
||||||
|
configured reviewer model. Use `pi --list-models [search]` to inspect available configured models
|
||||||
|
when needed.
|
||||||
|
|
||||||
|
Official references:
|
||||||
|
|
||||||
|
- `https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent` documents pi providers,
|
||||||
|
model selection, skills, extensions, and print mode.
|
||||||
|
- Local `pi --help` for pi `0.70.0` confirms `--model <pattern>` supports `provider/id`,
|
||||||
|
`--print, -p` runs non-interactively, `--tools, -t <tools>` allowlists tools, and the read-only
|
||||||
|
example is `pi --tools read,grep,find,ls -p "Review the code in src/"`.
|
||||||
|
|
||||||
|
## Required Files
|
||||||
|
|
||||||
|
The workflow-heavy Pi skills expect these helper files from `skills/reviewer-runtime/pi/`:
|
||||||
|
|
||||||
|
- `run-review.sh`
|
||||||
|
- `notify-telegram.sh`
|
||||||
|
|
||||||
|
Supported install locations:
|
||||||
|
|
||||||
|
1. `.pi/skills/reviewer-runtime/pi/`
|
||||||
|
2. `~/.pi/agent/skills/reviewer-runtime/pi/`
|
||||||
|
|
||||||
|
## Verify An Existing Install
|
||||||
|
|
||||||
|
Project-local:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
test -x .pi/skills/reviewer-runtime/pi/run-review.sh
|
||||||
|
test -x .pi/skills/reviewer-runtime/pi/notify-telegram.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Global:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
test -x ~/.pi/agent/skills/reviewer-runtime/pi/run-review.sh
|
||||||
|
test -x ~/.pi/agent/skills/reviewer-runtime/pi/notify-telegram.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Install The Common Reviewer Helpers
|
||||||
|
|
||||||
|
Global install:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.pi/agent/skills/reviewer-runtime/pi
|
||||||
|
cp -R skills/reviewer-runtime/pi/* ~/.pi/agent/skills/reviewer-runtime/pi/
|
||||||
|
chmod +x ~/.pi/agent/skills/reviewer-runtime/pi/*.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Project-local install:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
## Telegram
|
||||||
|
|
||||||
|
If you want the workflow skills to send completion messages, configure:
|
||||||
|
|
||||||
|
- `TELEGRAM_BOT_TOKEN`
|
||||||
|
- `TELEGRAM_CHAT_ID`
|
||||||
|
|
||||||
|
The Pi helper uses the same notification behavior documented in [TELEGRAM-NOTIFICATIONS.md](./TELEGRAM-NOTIFICATIONS.md).
|
||||||
|
|
||||||
|
## What This Doc Does Not Cover
|
||||||
|
|
||||||
|
- installing Obra Superpowers
|
||||||
|
- Pi package layout decisions
|
||||||
|
|
||||||
|
Those belong in [PI-SUPERPOWERS.md](./PI-SUPERPOWERS.md) and [PI.md](./PI.md).
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# PI RESEARCH
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This document records the pi-specific findings that drive the `pi` variants in this repo. It is
|
||||||
|
intentionally source-backed so later skill work does not rely on memory or assumptions.
|
||||||
|
|
||||||
|
Checked on `2026-04-23`.
|
||||||
|
|
||||||
|
## Primary Sources
|
||||||
|
|
||||||
|
- [pi skills documentation](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/skills.md)
|
||||||
|
- [pi packages documentation](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/packages.md)
|
||||||
|
- [pi extensions documentation](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/extensions.md)
|
||||||
|
- [pi settings documentation](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/settings.md)
|
||||||
|
- [pi coding-agent README](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/README.md)
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
### Skill Discovery
|
||||||
|
|
||||||
|
Pi loads skills from multiple places:
|
||||||
|
|
||||||
|
- global skill roots: `~/.pi/agent/skills/` and `~/.agents/skills/`
|
||||||
|
- project skill roots: `.pi/skills/` and `.agents/skills/`
|
||||||
|
- package resources: `skills/` convention directories or `pi.skills` entries in `package.json`
|
||||||
|
- settings-provided skill paths
|
||||||
|
- explicit `--skill <path>` CLI arguments
|
||||||
|
|
||||||
|
Important discovery details from the skills docs:
|
||||||
|
|
||||||
|
- directories containing `SKILL.md` are discovered recursively
|
||||||
|
- top-level `.md` files are loaded as skills in pi-native roots like `.pi/skills/`
|
||||||
|
- per the upstream skills docs, top-level `.md` files are ignored in `.agents/skills/`
|
||||||
|
|
||||||
|
Implication for this repo: `skills/<skill>/pi/SKILL.md` fits pi's recursive discovery model
|
||||||
|
cleanly for source authoring and manual copies, but it is not clean for package-discovered installs
|
||||||
|
because Pi requires the immediate parent directory of `SKILL.md` to match the skill frontmatter
|
||||||
|
`name`.
|
||||||
|
|
||||||
|
### Package Support
|
||||||
|
|
||||||
|
Pi packages are a first-class distribution path. The package docs say pi can load resources through
|
||||||
|
conventional directories like `skills/`, or through explicit `pi` manifest entries in `package.json`.
|
||||||
|
|
||||||
|
Relevant package behaviors:
|
||||||
|
|
||||||
|
- `keywords: ["pi-package"]` makes packages show up in the gallery
|
||||||
|
- `pi.skills` can point at specific skill directories
|
||||||
|
- `files` allowlists should be used to keep tarballs tight
|
||||||
|
- installed pi packages can also ship `extensions/`, `prompts/`, and `themes/`
|
||||||
|
- when pi installs npm or git packages, it runs `npm install`
|
||||||
|
|
||||||
|
Implication for this repo: a single repo-level `package.json` is a viable v1 surface for shipping
|
||||||
|
only the Pi resources, but the package-facing skill directories must be shaped like
|
||||||
|
`<skill-name>/SKILL.md`. That means the repo should preserve `skills/<family>/pi/` for editing and
|
||||||
|
expose a separate mirror such as `pi-package/skills/<skill-name>/` to Pi.
|
||||||
|
|
||||||
|
### Settings And Path Overrides
|
||||||
|
|
||||||
|
Pi settings support `packages`, `extensions`, `skills`, `prompts`, and `themes`. In both
|
||||||
|
`~/.pi/agent/settings.json` and `.pi/settings.json`, the `skills` array can point to local files
|
||||||
|
or directories, and the docs explicitly allow absolute paths and `~`.
|
||||||
|
|
||||||
|
Implication for this repo:
|
||||||
|
|
||||||
|
- pi can consume repo-local resources without publishing a package
|
||||||
|
- users can point pi at shared roots like `~/.agents/skills`
|
||||||
|
- workflow skills can document deterministic helper paths and fall back to settings-driven installs when needed
|
||||||
|
|
||||||
|
### Extensions
|
||||||
|
|
||||||
|
Pi extensions are TypeScript modules that can:
|
||||||
|
|
||||||
|
- register custom tools
|
||||||
|
- register commands
|
||||||
|
- intercept lifecycle events and tool calls
|
||||||
|
- add UI interactions and custom components
|
||||||
|
- persist session state
|
||||||
|
|
||||||
|
The coding-agent README explicitly lists advanced behaviors like sub-agents, plan mode, permission
|
||||||
|
gates, MCP integration, and git checkpointing as extension-capable areas.
|
||||||
|
|
||||||
|
Implication for this repo: extensions are a real opportunity for workflow-heavy skills, but they
|
||||||
|
are optional for the initial skill port. The base skill variants should remain usable without any
|
||||||
|
extension dependency.
|
||||||
|
|
||||||
|
### Cross-Harness Compatibility
|
||||||
|
|
||||||
|
The pi skills docs explicitly call out `~/.agents/skills/` as a supported global skill root, and
|
||||||
|
describe adding Codex or Claude skill directories through settings when needed.
|
||||||
|
|
||||||
|
Implication for this repo:
|
||||||
|
|
||||||
|
- existing Superpowers installs exposed through `~/.agents/skills/` can already be visible to pi
|
||||||
|
- pi-specific skill variants still need their own instructions because pi does not share Codex's
|
||||||
|
`update_plan`, plan-mode, or worktree assumptions
|
||||||
|
|
||||||
|
## Decisions Derived From Research
|
||||||
|
|
||||||
|
- Use `skills/<skill>/pi/` for all five skill families.
|
||||||
|
- Package Pi skills through a separate mirror whose immediate directory names match the frontmatter `name` values.
|
||||||
|
- Publish shared pi guidance in repo docs instead of burying pi assumptions inside each skill.
|
||||||
|
- Use one repo-level pi package in v1.
|
||||||
|
- Assess extensions explicitly, but defer them unless they provide clear v1 value beyond documentation and helper scripts.
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# PI SUPERPOWERS
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This document is only about making Obra Superpowers visible to Pi.
|
||||||
|
|
||||||
|
If you need the shared reviewer helpers (`run-review.sh`, `notify-telegram.sh`), use
|
||||||
|
[PI-COMMON-REVIEWER.md](./PI-COMMON-REVIEWER.md) instead.
|
||||||
|
|
||||||
|
## What Pi Needs
|
||||||
|
|
||||||
|
The workflow-heavy Pi skills depend on Superpowers such as:
|
||||||
|
|
||||||
|
- `brainstorming`
|
||||||
|
- `writing-plans`
|
||||||
|
- `executing-plans`
|
||||||
|
- `test-driven-development`
|
||||||
|
- `verification-before-completion`
|
||||||
|
- `finishing-a-development-branch`
|
||||||
|
- `using-git-worktrees`
|
||||||
|
|
||||||
|
Pi can discover them from shared roots like `~/.agents/skills/`, Pi-native roots like
|
||||||
|
`~/.pi/agent/skills/` or `.pi/skills/`, or settings-defined skill directories.
|
||||||
|
|
||||||
|
## Verify An Existing Install
|
||||||
|
|
||||||
|
If you think Superpowers may already be installed, check the common shared-root setup first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
test -L ~/.agents/skills/superpowers
|
||||||
|
test -f ~/.agents/skills/superpowers/brainstorming/SKILL.md
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
If those pass, Pi can usually reuse the same install directly.
|
||||||
|
|
||||||
|
To verify a Pi-native install instead:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
test -f ~/.pi/agent/skills/superpowers/brainstorming/SKILL.md || test -f .pi/skills/superpowers/brainstorming/SKILL.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Install Option 1: Reuse A Shared Skills Root
|
||||||
|
|
||||||
|
If you already have Superpowers available for another harness, the simplest Pi setup is to expose
|
||||||
|
that same tree through `~/.agents/skills/`.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.agents/skills
|
||||||
|
ln -s ~/.codex/superpowers/skills ~/.agents/skills/superpowers
|
||||||
|
```
|
||||||
|
|
||||||
|
Re-run the verification checks above after creating the symlink.
|
||||||
|
|
||||||
|
## Install Option 2: Pi-Native Symlink Or Copy
|
||||||
|
|
||||||
|
If you do not want to use `~/.agents/skills/`, point Pi at a checked-out Superpowers tree directly.
|
||||||
|
|
||||||
|
Global Pi install:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.pi/agent/skills
|
||||||
|
ln -s /absolute/path/to/obra/superpowers/skills ~/.pi/agent/skills/superpowers
|
||||||
|
```
|
||||||
|
|
||||||
|
Project-local Pi install:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p .pi/skills
|
||||||
|
ln -s /absolute/path/to/obra/superpowers/skills .pi/skills/superpowers
|
||||||
|
```
|
||||||
|
|
||||||
|
If you prefer a settings-based path instead of a symlink, add it to either `~/.pi/agent/settings.json` or `.pi/settings.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"skills": [
|
||||||
|
"~/.agents/skills",
|
||||||
|
"/absolute/path/to/obra/superpowers/skills"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Post-Install Verification
|
||||||
|
|
||||||
|
After any install path, verify the specific skills your workflow needs.
|
||||||
|
|
||||||
|
Planning-focused check:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
test -f ~/.agents/skills/superpowers/brainstorming/SKILL.md || test -f ~/.pi/agent/skills/superpowers/brainstorming/SKILL.md || test -f .pi/skills/superpowers/brainstorming/SKILL.md
|
||||||
|
test -f ~/.agents/skills/superpowers/writing-plans/SKILL.md || test -f ~/.pi/agent/skills/superpowers/writing-plans/SKILL.md || test -f .pi/skills/superpowers/writing-plans/SKILL.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Execution-focused check:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
test -f ~/.agents/skills/superpowers/test-driven-development/SKILL.md || test -f ~/.pi/agent/skills/superpowers/test-driven-development/SKILL.md || test -f .pi/skills/superpowers/test-driven-development/SKILL.md
|
||||||
|
test -f ~/.agents/skills/superpowers/verification-before-completion/SKILL.md || test -f ~/.pi/agent/skills/superpowers/verification-before-completion/SKILL.md || test -f .pi/skills/superpowers/verification-before-completion/SKILL.md
|
||||||
|
test -f ~/.agents/skills/superpowers/finishing-a-development-branch/SKILL.md || test -f ~/.pi/agent/skills/superpowers/finishing-a-development-branch/SKILL.md || test -f .pi/skills/superpowers/finishing-a-development-branch/SKILL.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## What This Doc Does Not Cover
|
||||||
|
|
||||||
|
- reviewer-runtime helper installation
|
||||||
|
- Telegram helper installation
|
||||||
|
- package layout or Pi package installation
|
||||||
|
|
||||||
|
Those belong in [PI-COMMON-REVIEWER.md](./PI-COMMON-REVIEWER.md) and [PI.md](./PI.md).
|
||||||
+165
@@ -0,0 +1,165 @@
|
|||||||
|
# PI
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This repo treats pi as a first-class target alongside Codex, Claude Code, Cursor, and OpenCode.
|
||||||
|
|
||||||
|
The Pi support surface has two layers:
|
||||||
|
|
||||||
|
- editable source variants in `skills/<family>/pi/`
|
||||||
|
- a package-facing mirror in `pi-package/skills/<skill>/`
|
||||||
|
|
||||||
|
That split is intentional. Pi requires the immediate parent directory of `SKILL.md` to match the
|
||||||
|
skill's frontmatter `name`, so the package cannot point directly at `skills/<family>/pi/`.
|
||||||
|
|
||||||
|
Related docs:
|
||||||
|
|
||||||
|
- [PI-RESEARCH.md](./PI-RESEARCH.md)
|
||||||
|
- [PI-SUPERPOWERS.md](./PI-SUPERPOWERS.md)
|
||||||
|
- [PI-COMMON-REVIEWER.md](./PI-COMMON-REVIEWER.md)
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
### Source Of Truth
|
||||||
|
|
||||||
|
Edit the **canonical sources** under:
|
||||||
|
|
||||||
|
- `skills/atlassian/_source/pi/SKILL.md`
|
||||||
|
- `skills/create-plan/_source/pi/` (SKILL.md + templates)
|
||||||
|
- `skills/do-task/_source/pi/` (SKILL.md + templates)
|
||||||
|
- `skills/implement-plan/_source/pi/SKILL.md`
|
||||||
|
- `skills/web-automation/_source/pi/SKILL.md`
|
||||||
|
|
||||||
|
After editing a canonical source, run `pnpm run sync:pi` to regenerate all
|
||||||
|
agent-variant directories including `skills/<family>/pi/` and the package mirrors.
|
||||||
|
Do not edit the generated `skills/<family>/pi/` directories directly — they are
|
||||||
|
overwritten by the generator.
|
||||||
|
|
||||||
|
### Package Mirror
|
||||||
|
|
||||||
|
The package exposes:
|
||||||
|
|
||||||
|
- `pi-package/skills/atlassian/`
|
||||||
|
- `pi-package/skills/create-plan/`
|
||||||
|
- `pi-package/skills/do-task/`
|
||||||
|
- `pi-package/skills/implement-plan/`
|
||||||
|
- `pi-package/skills/web-automation/`
|
||||||
|
|
||||||
|
Those directories are generated from the canonical sources by
|
||||||
|
[`scripts/generate-skills.mjs`](../scripts/generate-skills.mjs)
|
||||||
|
(run via `pnpm run sync:pi`).
|
||||||
|
|
||||||
|
### Shared Setup Docs
|
||||||
|
|
||||||
|
Workflow-heavy Pi skills split their shared setup across two docs:
|
||||||
|
|
||||||
|
- [PI-SUPERPOWERS.md](./PI-SUPERPOWERS.md) for installing and verifying Obra Superpowers in Pi
|
||||||
|
- [PI-COMMON-REVIEWER.md](./PI-COMMON-REVIEWER.md) for installing and verifying the shared reviewer-runtime helpers
|
||||||
|
|
||||||
|
## Package Install
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/install-pi-package.sh --global
|
||||||
|
```
|
||||||
|
|
||||||
|
Project-local install from a cloned checkout:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/install-pi-package.sh --local
|
||||||
|
```
|
||||||
|
|
||||||
|
Prerequisites for that one-liner:
|
||||||
|
|
||||||
|
- Node.js 20+
|
||||||
|
- `pi`
|
||||||
|
- either `pnpm` on `PATH`, or Node's bundled `corepack` support
|
||||||
|
|
||||||
|
The installer uses `pnpm` directly when available and falls back to `corepack pnpm` otherwise.
|
||||||
|
The root `package.json` pins the pnpm version so Corepack-backed installs resolve consistently.
|
||||||
|
|
||||||
|
That script:
|
||||||
|
|
||||||
|
- runs `pi install` in the chosen scope
|
||||||
|
- installs the nested runtime dependencies for `atlassian`
|
||||||
|
- installs the nested runtime dependencies for `web-automation`
|
||||||
|
- fetches the CloakBrowser binary for `web-automation`
|
||||||
|
- prints `pi list` at the end so the active install is visible immediately
|
||||||
|
|
||||||
|
For this cloned-checkout flow,
|
||||||
|
local checkout package install keeps the runtime in `pi-package/skills/<skill>/scripts`.
|
||||||
|
Pi loads the skills from the package mirror in this repo;
|
||||||
|
it does not copy them into `~/.pi/agent/skills/<skill>/` or `.pi/skills/<skill>/`
|
||||||
|
unless you do a manual copy install.
|
||||||
|
|
||||||
|
The package surface intentionally ships:
|
||||||
|
|
||||||
|
- `pi-package/skills/**`
|
||||||
|
- `skills/reviewer-runtime/pi/**`
|
||||||
|
- `docs/PI*.md`
|
||||||
|
- `scripts/install-pi-package.sh`
|
||||||
|
- `scripts/manage-skills.mjs` and `scripts/manage-skills.sh`
|
||||||
|
- `scripts/generate-skills.mjs`
|
||||||
|
- `scripts/verify-pi-resources.sh`
|
||||||
|
- `scripts/verify-pi-workflows.sh`
|
||||||
|
- `scripts/verify-reviewer-support.sh`
|
||||||
|
|
||||||
|
It intentionally does not ship `skills/<family>/pi/**` as package-discovered skills.
|
||||||
|
|
||||||
|
## Single-Skill Copy Install
|
||||||
|
|
||||||
|
If you only want one Pi skill without installing the whole package, copy from the package-facing
|
||||||
|
mirror into a Pi skill root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p .pi/skills/create-plan
|
||||||
|
cp -R pi-package/skills/create-plan/* .pi/skills/create-plan/
|
||||||
|
```
|
||||||
|
|
||||||
|
Global installs use `~/.pi/agent/skills/<skill>/` instead of `.pi/skills/<skill>/`.
|
||||||
|
|
||||||
|
## Maintenance Workflow
|
||||||
|
|
||||||
|
When a source Pi variant changes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm run sync:pi
|
||||||
|
npm run verify:pi
|
||||||
|
npm run verify:reviewers
|
||||||
|
npm pack --dry-run --json
|
||||||
|
```
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
- missing mirror directories
|
||||||
|
- source/mirror drift
|
||||||
|
- package metadata pointing at the wrong skill roots
|
||||||
|
- missing shared Pi docs
|
||||||
|
- missing user-facing installer wiring
|
||||||
|
|
||||||
|
## Extension Decision
|
||||||
|
|
||||||
|
Pi extensions are still optional for this repo.
|
||||||
|
|
||||||
|
Current v1 decision:
|
||||||
|
|
||||||
|
- ship usable Pi skills without extensions
|
||||||
|
- keep the package focused on skills, docs, and helpers
|
||||||
|
- revisit extensions only when documentation and helper scripts stop being enough
|
||||||
+65
-7
@@ -1,14 +1,72 @@
|
|||||||
# Skills Documentation
|
# Skills Documentation
|
||||||
|
|
||||||
This directory contains user-facing docs for each skill.
|
This directory contains user-facing docs for all skills, agents, and tooling
|
||||||
|
in the `ai-coding-skills` repository.
|
||||||
|
|
||||||
## Index
|
## Reading Order
|
||||||
|
|
||||||
- [ATLASSIAN.md](./ATLASSIAN.md) — Includes requirements, generated bundle sync, install, auth, safety rules, and usage examples for the Atlassian skill.
|
Work through the sections below in order on a first read. Return to individual
|
||||||
- [CREATE-PLAN.md](./CREATE-PLAN.md) — Includes requirements, install, verification, and execution workflow for create-plan.
|
sections as needed.
|
||||||
- [IMPLEMENT-PLAN.md](./IMPLEMENT-PLAN.md) — Includes requirements, install, verification, and milestone review workflow for implement-plan.
|
|
||||||
- [TELEGRAM-NOTIFICATIONS.md](./TELEGRAM-NOTIFICATIONS.md) — Shared Telegram notification setup used by reviewer-driven skills.
|
### 1. Getting Started
|
||||||
- [WEB-AUTOMATION.md](./WEB-AUTOMATION.md) — Includes requirements, install, dependency verification, and usage examples for web-automation.
|
|
||||||
|
- [INSTALLER.md](./INSTALLER.md) — Guided install/update/remove wizard
|
||||||
|
(`./scripts/manage-skills.sh`). **Start here** if you want the fastest path
|
||||||
|
to installed skills.
|
||||||
|
|
||||||
|
### 2. Per-Agent Manual Install
|
||||||
|
|
||||||
|
Use these guides to install specific skills manually for a single agent:
|
||||||
|
|
||||||
|
- [CODEX.md](./CODEX.md) — Manual install for Codex variants.
|
||||||
|
- [CLAUDE-CODE.md](./CLAUDE-CODE.md) — Manual install for Claude Code variants.
|
||||||
|
- [CURSOR.md](./CURSOR.md) — Manual install for Cursor variants.
|
||||||
|
- [OPENCODE.md](./OPENCODE.md) — Manual install for OpenCode variants.
|
||||||
|
- [PI.md](./PI.md) — Pi overview, source-vs-package layout, and manual install.
|
||||||
|
|
||||||
|
### 3. Skill Guides
|
||||||
|
|
||||||
|
Reference docs for each skill family:
|
||||||
|
|
||||||
|
- [ATLASSIAN.md](./ATLASSIAN.md) — Jira/Confluence CLI: requirements, generated
|
||||||
|
bundle sync, install, auth, safety rules, and usage examples.
|
||||||
|
- [CREATE-PLAN.md](./CREATE-PLAN.md) — Structured planning with milestones,
|
||||||
|
cross-model review, and reviewer CLI requirements.
|
||||||
|
- [DO-TASK.md](./DO-TASK.md) — Single-prompt end-to-end execution with dual
|
||||||
|
reviewer loops, TDD-first, single task commit.
|
||||||
|
- [IMPLEMENT-PLAN.md](./IMPLEMENT-PLAN.md) — Worktree-isolated plan execution
|
||||||
|
with iterative cross-model milestone review.
|
||||||
|
- [WEB-AUTOMATION.md](./WEB-AUTOMATION.md) — CloakBrowser-backed browsing,
|
||||||
|
scraping, auth, and flow automation.
|
||||||
|
|
||||||
|
### 4. Pi-Specific Docs
|
||||||
|
|
||||||
|
- [PI-RESEARCH.md](./PI-RESEARCH.md) — Source-backed Pi findings that inform
|
||||||
|
repo Pi variants and packaging choices.
|
||||||
|
- [PI-SUPERPOWERS.md](./PI-SUPERPOWERS.md) — How to install or verify Obra
|
||||||
|
Superpowers for Pi.
|
||||||
|
- [PI-COMMON-REVIEWER.md](./PI-COMMON-REVIEWER.md) — Shared reviewer-runtime
|
||||||
|
helpers for Pi workflow skills; canonical Pi reviewer flag contract.
|
||||||
|
|
||||||
|
### 5. Telegram Notifications
|
||||||
|
|
||||||
|
- [TELEGRAM-NOTIFICATIONS.md](./TELEGRAM-NOTIFICATIONS.md) — Shared Telegram
|
||||||
|
notification setup for Codex, Claude Code, OpenCode, Cursor, and Pi.
|
||||||
|
|
||||||
|
### 6. Reviewer Matrix
|
||||||
|
|
||||||
|
- [REVIEWERS.md](./REVIEWERS.md) — Canonical reviewer CLI support matrix;
|
||||||
|
single source of truth for all workflow variants.
|
||||||
|
|
||||||
|
### 7. Development
|
||||||
|
|
||||||
|
- [DEVELOPMENT.md](./DEVELOPMENT.md) — Prerequisites, `pnpm run check`,
|
||||||
|
workspace policy, generation workflow, cross-platform shell support, and the
|
||||||
|
quality contract.
|
||||||
|
- [CLEANUP-BASELINE.md](./CLEANUP-BASELINE.md) — As-is quality baseline
|
||||||
|
captured at M1, updated through subsequent milestones.
|
||||||
|
- [../CHANGELOG.md](../CHANGELOG.md) — Milestone-by-milestone change record
|
||||||
|
(includes M3 package-metadata rename table).
|
||||||
|
|
||||||
## Repo Setup
|
## Repo Setup
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# Reviewer CLI Support Matrix
|
||||||
|
|
||||||
|
This document is the **single canonical source** for the reviewer CLI support
|
||||||
|
matrix used by `create-plan`, `implement-plan`, and `do-task`. All workflow
|
||||||
|
docs and `SKILL.md` variants must refer here and stay in sync.
|
||||||
|
|
||||||
|
## Canonical Reviewer CLIs
|
||||||
|
|
||||||
|
| CLI | Install | Verify | Read-Only Mode | Session Resume |
|
||||||
|
|-----|---------|--------|----------------|----------------|
|
||||||
|
| `codex` | `npm install -g @openai/codex` | `codex --version` | `-s read-only` | Yes (`codex exec resume <id>`) |
|
||||||
|
| `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` | `--mode=ask` | Yes (`--resume <id>`) |
|
||||||
|
| `opencode` | `brew install opencode` or your package manager | `opencode --version` | `--agent plan` | Opt-in (`-s <id>`; fresh call is the default) |
|
||||||
|
| `pi` | Install Pi coding agent | `pi --version`; list models with `pi --list-models [search]` | `--tools read,grep,find,ls` | No (fresh call each round) |
|
||||||
|
|
||||||
|
**`skip`** is always a valid value; it bypasses the reviewer loop and requires
|
||||||
|
explicit user approval instead.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The reviewer CLI is independent of which agent is running the skill. For
|
||||||
|
example, Claude Code can send plans to Codex for review and vice versa.
|
||||||
|
- **`cursor` reviewer prerequisite:** `jq` is required to parse Cursor's JSON
|
||||||
|
output. Install via `brew install jq` (macOS) or your package manager.
|
||||||
|
Verify: `jq --version`. The Cursor variant of `do-task` makes `jq` a hard
|
||||||
|
prerequisite regardless of which reviewer CLI is selected.
|
||||||
|
- **`opencode` reviewer:** Uses `--agent plan` (built-in read-oriented agent)
|
||||||
|
for review posture. Session resume is opt-in via `-s <id>`; fresh call is the
|
||||||
|
recommended default for non-interactive headless runs.
|
||||||
|
- **`pi` reviewer:** 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).
|
||||||
|
|
||||||
|
## Reviewer Output Contract
|
||||||
|
|
||||||
|
All reviewer CLIs must return the following structure:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Summary
|
||||||
|
...
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
### P0
|
||||||
|
- ...
|
||||||
|
### P1
|
||||||
|
- ...
|
||||||
|
### P2
|
||||||
|
- ...
|
||||||
|
### P3
|
||||||
|
- ...
|
||||||
|
|
||||||
|
## Verdict
|
||||||
|
VERDICT: APPROVED
|
||||||
|
```
|
||||||
|
|
||||||
|
Severity levels:
|
||||||
|
|
||||||
|
| Level | Meaning |
|
||||||
|
|-------|---------|
|
||||||
|
| `P0` | Total blocker |
|
||||||
|
| `P1` | Major risk |
|
||||||
|
| `P2` | Must-fix before approval |
|
||||||
|
| `P3` | Cosmetic / nice to have (non-blocking) |
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- `VERDICT: APPROVED` is valid only when no `P0`, `P1`, or `P2` findings remain.
|
||||||
|
- `P3` findings are non-blocking, but the caller should still try to fix them
|
||||||
|
when cheap and safe.
|
||||||
|
- Each severity section uses `- None.` when empty.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [PI-COMMON-REVIEWER.md](./PI-COMMON-REVIEWER.md) — Canonical Pi reviewer
|
||||||
|
flag contract and read-only command template.
|
||||||
|
- [TELEGRAM-NOTIFICATIONS.md](./TELEGRAM-NOTIFICATIONS.md) — Telegram
|
||||||
|
notification setup used by reviewer-driven workflows.
|
||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
Shared setup for Telegram notifications used by reviewer-driven skills such as `create-plan` and `implement-plan`, both for completion and for pauses that need user attention.
|
Shared setup for Telegram notifications used by reviewer-driven skills such as
|
||||||
|
`create-plan`, `implement-plan`, and `do-task`, both for completion and for
|
||||||
|
pauses that need user attention.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@@ -12,31 +14,44 @@ Shared setup for Telegram notifications used by reviewer-driven skills such as `
|
|||||||
- Codex: `~/.codex/skills/reviewer-runtime/notify-telegram.sh`
|
- Codex: `~/.codex/skills/reviewer-runtime/notify-telegram.sh`
|
||||||
- Claude Code: `~/.claude/skills/reviewer-runtime/notify-telegram.sh`
|
- Claude Code: `~/.claude/skills/reviewer-runtime/notify-telegram.sh`
|
||||||
- OpenCode: `~/.config/opencode/skills/reviewer-runtime/notify-telegram.sh`
|
- OpenCode: `~/.config/opencode/skills/reviewer-runtime/notify-telegram.sh`
|
||||||
- Cursor: `.cursor/skills/reviewer-runtime/notify-telegram.sh` or `~/.cursor/skills/reviewer-runtime/notify-telegram.sh`
|
- Cursor: `.cursor/skills/reviewer-runtime/notify-telegram.sh`
|
||||||
|
or `~/.cursor/skills/reviewer-runtime/notify-telegram.sh`
|
||||||
|
- Pi: `.pi/skills/reviewer-runtime/pi/notify-telegram.sh`
|
||||||
|
or `~/.pi/agent/skills/reviewer-runtime/pi/notify-telegram.sh`
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
The helper ships from `skills/reviewer-runtime/` together with `run-review.sh`.
|
The helpers ship from `skills/reviewer-runtime/` (non-Pi) and
|
||||||
|
`skills/reviewer-runtime/pi/` (Pi) together with `run-review.sh`.
|
||||||
|
|
||||||
### Codex
|
### Codex
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p ~/.codex/skills/reviewer-runtime
|
mkdir -p ~/.codex/skills/reviewer-runtime
|
||||||
cp -R skills/reviewer-runtime/* ~/.codex/skills/reviewer-runtime/
|
cp skills/reviewer-runtime/run-review.sh \
|
||||||
|
skills/reviewer-runtime/notify-telegram.sh \
|
||||||
|
~/.codex/skills/reviewer-runtime/
|
||||||
|
chmod +x ~/.codex/skills/reviewer-runtime/*.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
### Claude Code
|
### Claude Code
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p ~/.claude/skills/reviewer-runtime
|
mkdir -p ~/.claude/skills/reviewer-runtime
|
||||||
cp -R skills/reviewer-runtime/* ~/.claude/skills/reviewer-runtime/
|
cp skills/reviewer-runtime/run-review.sh \
|
||||||
|
skills/reviewer-runtime/notify-telegram.sh \
|
||||||
|
~/.claude/skills/reviewer-runtime/
|
||||||
|
chmod +x ~/.claude/skills/reviewer-runtime/*.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
### OpenCode
|
### OpenCode
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p ~/.config/opencode/skills/reviewer-runtime
|
mkdir -p ~/.config/opencode/skills/reviewer-runtime
|
||||||
cp -R skills/reviewer-runtime/* ~/.config/opencode/skills/reviewer-runtime/
|
cp skills/reviewer-runtime/run-review.sh \
|
||||||
|
skills/reviewer-runtime/notify-telegram.sh \
|
||||||
|
~/.config/opencode/skills/reviewer-runtime/
|
||||||
|
chmod +x ~/.config/opencode/skills/reviewer-runtime/*.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
### Cursor
|
### Cursor
|
||||||
@@ -45,28 +60,67 @@ Repo-local install:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p .cursor/skills/reviewer-runtime
|
mkdir -p .cursor/skills/reviewer-runtime
|
||||||
cp -R skills/reviewer-runtime/* .cursor/skills/reviewer-runtime/
|
cp skills/reviewer-runtime/run-review.sh \
|
||||||
|
skills/reviewer-runtime/notify-telegram.sh \
|
||||||
|
.cursor/skills/reviewer-runtime/
|
||||||
|
chmod +x .cursor/skills/reviewer-runtime/*.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Global install:
|
Global install:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p ~/.cursor/skills/reviewer-runtime
|
mkdir -p ~/.cursor/skills/reviewer-runtime
|
||||||
cp -R skills/reviewer-runtime/* ~/.cursor/skills/reviewer-runtime/
|
cp skills/reviewer-runtime/run-review.sh \
|
||||||
|
skills/reviewer-runtime/notify-telegram.sh \
|
||||||
|
~/.cursor/skills/reviewer-runtime/
|
||||||
|
chmod +x ~/.cursor/skills/reviewer-runtime/*.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pi
|
||||||
|
|
||||||
|
Pi uses a separate set of helpers from `skills/reviewer-runtime/pi/` that are
|
||||||
|
optimized for the Pi agent environment. See
|
||||||
|
[PI-COMMON-REVIEWER.md](./PI-COMMON-REVIEWER.md) for full details.
|
||||||
|
|
||||||
|
Global install:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.pi/agent/skills/reviewer-runtime/pi
|
||||||
|
cp -R skills/reviewer-runtime/pi/* ~/.pi/agent/skills/reviewer-runtime/pi/
|
||||||
|
chmod +x ~/.pi/agent/skills/reviewer-runtime/pi/*.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Repo-local install:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
## Verify Installation
|
## Verify Installation
|
||||||
|
|
||||||
|
### Verify: Non-Pi agents
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
test -x ~/.codex/skills/reviewer-runtime/notify-telegram.sh || true
|
test -x ~/.codex/skills/reviewer-runtime/notify-telegram.sh || true
|
||||||
test -x ~/.claude/skills/reviewer-runtime/notify-telegram.sh || true
|
test -x ~/.claude/skills/reviewer-runtime/notify-telegram.sh || true
|
||||||
test -x ~/.config/opencode/skills/reviewer-runtime/notify-telegram.sh || true
|
test -x ~/.config/opencode/skills/reviewer-runtime/notify-telegram.sh || true
|
||||||
test -x .cursor/skills/reviewer-runtime/notify-telegram.sh || test -x ~/.cursor/skills/reviewer-runtime/notify-telegram.sh || true
|
test -x .cursor/skills/reviewer-runtime/notify-telegram.sh \
|
||||||
|
|| test -x ~/.cursor/skills/reviewer-runtime/notify-telegram.sh || true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verify: Pi
|
||||||
|
|
||||||
|
```bash
|
||||||
|
test -x .pi/skills/reviewer-runtime/pi/notify-telegram.sh \
|
||||||
|
|| test -x ~/.pi/agent/skills/reviewer-runtime/pi/notify-telegram.sh || true
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configure Telegram
|
## Configure Telegram
|
||||||
|
|
||||||
Export the required variables before running a skill that sends Telegram notifications:
|
Export the required variables before running a skill that sends Telegram
|
||||||
|
notifications:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export TELEGRAM_BOT_TOKEN="<bot-token>"
|
export TELEGRAM_BOT_TOKEN="<bot-token>"
|
||||||
@@ -81,7 +135,7 @@ export TELEGRAM_API_BASE_URL="https://api.telegram.org"
|
|||||||
|
|
||||||
## Test the Helper
|
## Test the Helper
|
||||||
|
|
||||||
Example:
|
Non-Pi agents example:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
TELEGRAM_BOT_TOKEN="<bot-token>" \
|
TELEGRAM_BOT_TOKEN="<bot-token>" \
|
||||||
@@ -89,9 +143,19 @@ TELEGRAM_CHAT_ID="<chat-id>" \
|
|||||||
skills/reviewer-runtime/notify-telegram.sh --message "Telegram notification test"
|
skills/reviewer-runtime/notify-telegram.sh --message "Telegram notification test"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Pi example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TELEGRAM_BOT_TOKEN="<bot-token>" \
|
||||||
|
TELEGRAM_CHAT_ID="<chat-id>" \
|
||||||
|
skills/reviewer-runtime/pi/notify-telegram.sh --message "Pi Telegram test"
|
||||||
|
```
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
- Telegram is the only supported notification path for these skills.
|
- Telegram is the only supported notification path for these skills.
|
||||||
- Notification failures are non-blocking, but they must be surfaced to the user.
|
- Notification failures are non-blocking, but they must be surfaced to the user.
|
||||||
- Before stopping for any user interaction, approval, or manual decision, send a Telegram summary first if configured.
|
- Before stopping for any user interaction, approval, or manual decision, send a
|
||||||
- Skills should report when Telegram is not configured instead of silently pretending a notification was sent.
|
Telegram summary first if configured.
|
||||||
|
- Skills should report when Telegram is not configured instead of silently
|
||||||
|
pretending a notification was sent.
|
||||||
|
|||||||
+125
-16
@@ -2,15 +2,25 @@
|
|||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
Automate browsing and scraping with Playwright + Camoufox.
|
Automate rendered browsing, scraping, authentication, and multi-step browser flows with Playwright-compatible CloakBrowser.
|
||||||
|
|
||||||
|
## What Ships In Every Variant
|
||||||
|
|
||||||
|
- `browse.ts` for direct navigation and screenshots
|
||||||
|
- `auth.ts` for form and Microsoft SSO login flows
|
||||||
|
- `scrape.ts` for markdown extraction
|
||||||
|
- `flow.ts` for natural-language or JSON browser steps
|
||||||
|
- `extract.js` for one-shot rendered JSON extraction
|
||||||
|
- `check-install.js` for install and wiring validation
|
||||||
|
- `scan-local-app.ts` for configurable local-app smoke scans
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Node.js 20+
|
- Node.js 20+
|
||||||
- pnpm
|
- pnpm
|
||||||
|
- `cloakbrowser`
|
||||||
- `playwright-core`
|
- `playwright-core`
|
||||||
- `camoufox-js`
|
- Network access to download the CloakBrowser binary on first use
|
||||||
- Network access to download Camoufox browser artifacts
|
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
@@ -21,8 +31,9 @@ mkdir -p ~/.codex/skills/web-automation
|
|||||||
cp -R skills/web-automation/codex/* ~/.codex/skills/web-automation/
|
cp -R skills/web-automation/codex/* ~/.codex/skills/web-automation/
|
||||||
cd ~/.codex/skills/web-automation/scripts
|
cd ~/.codex/skills/web-automation/scripts
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm add playwright-core camoufox-js
|
npx cloakbrowser install
|
||||||
npx camoufox-js fetch
|
pnpm approve-builds
|
||||||
|
pnpm rebuild better-sqlite3 esbuild
|
||||||
```
|
```
|
||||||
|
|
||||||
### Claude Code
|
### Claude Code
|
||||||
@@ -32,10 +43,27 @@ mkdir -p ~/.claude/skills/web-automation
|
|||||||
cp -R skills/web-automation/claude-code/* ~/.claude/skills/web-automation/
|
cp -R skills/web-automation/claude-code/* ~/.claude/skills/web-automation/
|
||||||
cd ~/.claude/skills/web-automation/scripts
|
cd ~/.claude/skills/web-automation/scripts
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm add playwright-core camoufox-js
|
npx cloakbrowser install
|
||||||
npx camoufox-js fetch
|
pnpm approve-builds
|
||||||
|
pnpm rebuild better-sqlite3 esbuild
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Cursor
|
||||||
|
|
||||||
|
Repo-local install:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p .cursor/skills/web-automation
|
||||||
|
cp -R skills/web-automation/cursor/* .cursor/skills/web-automation/
|
||||||
|
cd .cursor/skills/web-automation/scripts
|
||||||
|
pnpm install
|
||||||
|
npx cloakbrowser install
|
||||||
|
pnpm approve-builds
|
||||||
|
pnpm rebuild better-sqlite3 esbuild
|
||||||
|
```
|
||||||
|
|
||||||
|
Global installs use `~/.cursor/skills/web-automation/` instead.
|
||||||
|
|
||||||
### OpenCode
|
### OpenCode
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -43,25 +71,106 @@ mkdir -p ~/.config/opencode/skills/web-automation
|
|||||||
cp -R skills/web-automation/opencode/* ~/.config/opencode/skills/web-automation/
|
cp -R skills/web-automation/opencode/* ~/.config/opencode/skills/web-automation/
|
||||||
cd ~/.config/opencode/skills/web-automation/scripts
|
cd ~/.config/opencode/skills/web-automation/scripts
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm add playwright-core camoufox-js
|
npx cloakbrowser install
|
||||||
npx camoufox-js fetch
|
pnpm approve-builds
|
||||||
|
pnpm rebuild better-sqlite3 esbuild
|
||||||
```
|
```
|
||||||
|
|
||||||
## Verify Installation & Dependencies
|
### 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
|
||||||
|
pnpm run sync:pi
|
||||||
|
mkdir -p .pi/skills/web-automation
|
||||||
|
cp -R pi-package/skills/web-automation/* .pi/skills/web-automation/
|
||||||
|
cd .pi/skills/web-automation/scripts
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
npx cloakbrowser install
|
||||||
|
pnpm approve-builds
|
||||||
|
pnpm rebuild better-sqlite3 esbuild
|
||||||
|
```
|
||||||
|
|
||||||
|
Global manual installs use `~/.pi/agent/skills/web-automation/` instead of `.pi/skills/web-automation/`.
|
||||||
|
|
||||||
|
## Update To The Latest CloakBrowser
|
||||||
|
|
||||||
|
Run inside the installed `scripts/` directory for the variant you are using:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm up cloakbrowser playwright-core
|
||||||
|
npx cloakbrowser install
|
||||||
|
pnpm approve-builds
|
||||||
|
pnpm rebuild better-sqlite3 esbuild
|
||||||
|
```
|
||||||
|
|
||||||
|
This repo intentionally treats `cloakbrowser` as a refreshable dependency: update to the latest
|
||||||
|
available compatible release, then regenerate the lockfile from that resolved set.
|
||||||
|
|
||||||
|
## Verify Installation & Wiring
|
||||||
|
|
||||||
Run in the installed `scripts/` folder:
|
Run in the installed `scripts/` folder:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node -e "require.resolve('playwright-core/package.json');require.resolve('camoufox-js/package.json');console.log('OK: playwright-core + camoufox-js installed')"
|
node check-install.js
|
||||||
node -e "const fs=require('fs');const t=fs.readFileSync('browse.ts','utf8');if(!/camoufox-js/.test(t)){throw new Error('browse.ts is not configured for Camoufox')}console.log('OK: Camoufox integration detected in browse.ts')"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
If checks fail, stop and return:
|
Expected checks:
|
||||||
|
|
||||||
"Missing dependency/config: web-automation requires `playwright-core` + `camoufox-js` and Camoufox-based scripts. Run setup in this skill, then retry."
|
- `cloakbrowser` and `playwright-core` resolve correctly
|
||||||
|
- `browse.ts` is wired to CloakBrowser
|
||||||
|
|
||||||
|
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 later fails with native-binding issues, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm approve-builds
|
||||||
|
pnpm rebuild better-sqlite3 esbuild
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
- `CLOAKBROWSER_PROFILE_PATH`
|
||||||
|
- `CLOAKBROWSER_HEADLESS`
|
||||||
|
- `CLOAKBROWSER_USERNAME`
|
||||||
|
- `CLOAKBROWSER_PASSWORD`
|
||||||
|
|
||||||
|
There are no `CAMOUFOX_*` compatibility aliases in this migration.
|
||||||
|
|
||||||
## Usage Examples
|
## Usage Examples
|
||||||
|
|
||||||
- Browse: `npx tsx browse.ts --url "https://example.com"`
|
- Browse: `npx tsx browse.ts --url "https://example.com"`
|
||||||
- Scrape: `npx tsx scrape.ts --url "https://example.com" --mode main --output page.md`
|
- Scrape markdown: `npx tsx scrape.ts --url "https://example.com" --mode main --output page.md`
|
||||||
- Auth: `npx tsx auth.ts --url "https://example.com/login"`
|
- 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"'`
|
||||||
|
- JSON extract: `node extract.js "https://example.com"`
|
||||||
|
- Local 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 generic. Configure it with:
|
||||||
|
|
||||||
|
- `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`.
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// ESLint flat config — repo root (M1)
|
||||||
|
// Scoped to root-level scripts only. Nested workspace packages manage
|
||||||
|
// their own lint config (or inherit this in a later milestone).
|
||||||
|
import js from "@eslint/js";
|
||||||
|
import globals from "globals";
|
||||||
|
|
||||||
|
export default [
|
||||||
|
// Apply ESLint recommended rules to root-level JS/MJS scripts
|
||||||
|
{
|
||||||
|
files: ["scripts/**/*.mjs", "scripts/**/*.js"],
|
||||||
|
...js.configs.recommended,
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.node,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Global ignores — never lint generated directories or node_modules
|
||||||
|
{
|
||||||
|
ignores: [
|
||||||
|
"**/node_modules/**",
|
||||||
|
// Generated agent-variant directories (M3: now uniquely named but still
|
||||||
|
// not linted — the canonical source in shared/ or _source/ is the linting target)
|
||||||
|
"skills/atlassian/codex/**",
|
||||||
|
"skills/atlassian/claude-code/**",
|
||||||
|
"skills/atlassian/cursor/**",
|
||||||
|
"skills/atlassian/opencode/**",
|
||||||
|
"skills/atlassian/pi/**",
|
||||||
|
"skills/web-automation/claude-code/**",
|
||||||
|
"skills/web-automation/cursor/**",
|
||||||
|
"skills/web-automation/opencode/**",
|
||||||
|
"skills/web-automation/pi/**",
|
||||||
|
// skill-only generated variants (no scripts to lint)
|
||||||
|
"skills/create-plan/claude-code/**",
|
||||||
|
"skills/create-plan/codex/**",
|
||||||
|
"skills/create-plan/cursor/**",
|
||||||
|
"skills/create-plan/opencode/**",
|
||||||
|
"skills/create-plan/pi/**",
|
||||||
|
"skills/do-task/claude-code/**",
|
||||||
|
"skills/do-task/codex/**",
|
||||||
|
"skills/do-task/cursor/**",
|
||||||
|
"skills/do-task/opencode/**",
|
||||||
|
"skills/do-task/pi/**",
|
||||||
|
"skills/implement-plan/claude-code/**",
|
||||||
|
"skills/implement-plan/codex/**",
|
||||||
|
"skills/implement-plan/cursor/**",
|
||||||
|
"skills/implement-plan/opencode/**",
|
||||||
|
"skills/implement-plan/pi/**",
|
||||||
|
"skills/reviewer-runtime/pi/**",
|
||||||
|
"pi-package/**",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"timeout": "10s",
|
||||||
|
"retryCount": 2,
|
||||||
|
"retryOn429": true,
|
||||||
|
"aliveStatusCodes": [200, 206, 429],
|
||||||
|
"ignorePatterns": [
|
||||||
|
{ "pattern": "^https?://" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"timeout": "10s",
|
||||||
|
"retryCount": 2,
|
||||||
|
"retryOn429": true,
|
||||||
|
"aliveStatusCodes": [200, 206, 429],
|
||||||
|
"ignorePatterns": [
|
||||||
|
{ "pattern": "^https://github.com/obra/" },
|
||||||
|
{ "pattern": "^https://github.com/anthropics/" },
|
||||||
|
{ "pattern": "^https://localhost" },
|
||||||
|
{ "pattern": "^http://localhost" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
{
|
||||||
|
"name": "ai-coding-skills-pi",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Pi variants and shared runtime helpers for ai-coding-skills.",
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"private": true,
|
||||||
|
"keywords": [
|
||||||
|
"pi-package",
|
||||||
|
"agent-skills",
|
||||||
|
"pi"
|
||||||
|
],
|
||||||
|
"files": [
|
||||||
|
"README.md",
|
||||||
|
"docs/ATLASSIAN.md",
|
||||||
|
"docs/CLAUDE-CODE.md",
|
||||||
|
"docs/CODEX.md",
|
||||||
|
"docs/CREATE-PLAN.md",
|
||||||
|
"docs/CURSOR.md",
|
||||||
|
"docs/DO-TASK.md",
|
||||||
|
"docs/IMPLEMENT-PLAN.md",
|
||||||
|
"docs/INSTALLER.md",
|
||||||
|
"docs/OPENCODE.md",
|
||||||
|
"docs/README.md",
|
||||||
|
"docs/TELEGRAM-NOTIFICATIONS.md",
|
||||||
|
"docs/PI.md",
|
||||||
|
"docs/PI-RESEARCH.md",
|
||||||
|
"docs/PI-SUPERPOWERS.md",
|
||||||
|
"docs/PI-COMMON-REVIEWER.md",
|
||||||
|
"docs/WEB-AUTOMATION.md",
|
||||||
|
"docs/DEVELOPMENT.md",
|
||||||
|
"docs/REVIEWERS.md",
|
||||||
|
"pi-package/skills",
|
||||||
|
"skills/reviewer-runtime/pi",
|
||||||
|
"scripts/install-pi-package.sh",
|
||||||
|
"scripts/lib/skill-manager-core.mjs",
|
||||||
|
"scripts/manage-skills.mjs",
|
||||||
|
"scripts/manage-skills.sh",
|
||||||
|
"CHANGELOG.md",
|
||||||
|
"scripts/generate-skills.mjs",
|
||||||
|
"scripts/verify-pi-resources.sh",
|
||||||
|
"scripts/verify-pi-workflows.sh",
|
||||||
|
"scripts/verify-reviewer-support.sh"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"sync:pi": "node scripts/generate-skills.mjs",
|
||||||
|
"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",
|
||||||
|
"lint": "eslint . ; R1=$?; node scripts/lib/run-shellcheck.mjs ; R2=$?; [ $((R1+R2)) -eq 0 ]",
|
||||||
|
"lint:fix": "eslint . --fix && prettier --write .",
|
||||||
|
"typecheck": "pnpm run -r --if-present typecheck",
|
||||||
|
"test": "pnpm run test:installer && pnpm run -r --if-present test",
|
||||||
|
"verify:docs": "markdownlint-cli2 ; R1=$?; node scripts/lib/run-link-check.mjs ; R2=$?; node scripts/verify-docs-flow.mjs ; R3=$?; [ $((R1+R2+R3)) -eq 0 ]",
|
||||||
|
"verify:docs:online": "markdownlint-cli2 ; R1=$?; node scripts/lib/run-link-check.mjs --online ; R2=$?; node scripts/verify-docs-flow.mjs ; R3=$?; [ $((R1+R2+R3)) -eq 0 ]",
|
||||||
|
"verify:generated": "node scripts/verify-generated.mjs",
|
||||||
|
"verify:ci": "node scripts/lib/assert-no-pnpm-version-pin.mjs",
|
||||||
|
"check": "node scripts/lib/run-check.mjs"
|
||||||
|
},
|
||||||
|
"pi": {
|
||||||
|
"skills": [
|
||||||
|
"./pi-package/skills/atlassian",
|
||||||
|
"./pi-package/skills/create-plan",
|
||||||
|
"./pi-package/skills/do-task",
|
||||||
|
"./pi-package/skills/implement-plan",
|
||||||
|
"./pi-package/skills/web-automation"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34",
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "10.0.1",
|
||||||
|
"eslint": "10.3.0",
|
||||||
|
"globals": "17.6.0",
|
||||||
|
"markdown-link-check": "3.14.2",
|
||||||
|
"markdownlint-cli2": "0.22.1",
|
||||||
|
"prettier": "3.8.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ai-coding-skills.dev/schemas/generated-manifest/v1.json",
|
||||||
|
"generator": "scripts/generate-skills.mjs",
|
||||||
|
"generatedRoot": "pi-package/skills/atlassian",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "scripts/package.json",
|
||||||
|
"kind": "file",
|
||||||
|
"mode": "644",
|
||||||
|
"sha256": "97dd269f922ea83f6abfd29497ca532b94540acd1461b3a012f92853db55e196"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/pnpm-lock.yaml",
|
||||||
|
"kind": "file",
|
||||||
|
"mode": "644",
|
||||||
|
"sha256": "15556a6f53e68bb8d92d2710aae0836bc80af7f29be9d63aa1b87fcbd33732c6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/src/adf.ts",
|
||||||
|
"kind": "file",
|
||||||
|
"mode": "644",
|
||||||
|
"sha256": "c7c3b4a78ccd8fb5a8ab99c82e0eab67a0a0d656b3985c1f56817bda199ad20f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/src/cli.ts",
|
||||||
|
"kind": "file",
|
||||||
|
"mode": "644",
|
||||||
|
"sha256": "90dcc029adf0625b86c5eec44c5c1fd11bbf95ffe1185016d139c8a6982d54ff"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/src/command-helpers.ts",
|
||||||
|
"kind": "file",
|
||||||
|
"mode": "644",
|
||||||
|
"sha256": "aa03d8d288c8c00485ea10d3b3a60804c1b9ee23ef265004e7912f3242dbcee7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/src/config.ts",
|
||||||
|
"kind": "file",
|
||||||
|
"mode": "644",
|
||||||
|
"sha256": "700dcdce96afab5294426e09f539135ae5432632370260190d6292071422eb3f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/src/confluence.ts",
|
||||||
|
"kind": "file",
|
||||||
|
"mode": "644",
|
||||||
|
"sha256": "28f65f280cd9b6119ce7eab583d0083231525ad6dc04b73389cb5dcbab5bf095"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/src/files.ts",
|
||||||
|
"kind": "file",
|
||||||
|
"mode": "644",
|
||||||
|
"sha256": "16296eaa3ae41a4d7c694773036f9bb4bd2baa2db6a9c318078532b713678dba"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/src/health.ts",
|
||||||
|
"kind": "file",
|
||||||
|
"mode": "644",
|
||||||
|
"sha256": "1db4b49e05b16a095b7e7ca31cdc4e22ebda19e20e05c40baaaac648eaec0d08"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/src/http.ts",
|
||||||
|
"kind": "file",
|
||||||
|
"mode": "644",
|
||||||
|
"sha256": "66444b777d4d9b14d9793eb051c586eb811d2b36815b1018dd9d7517666c7eb2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/src/jira.ts",
|
||||||
|
"kind": "file",
|
||||||
|
"mode": "644",
|
||||||
|
"sha256": "bec0e81a0424dd412c36988cef42c01a95f044ee8346ba626e7eb8bd79379f07"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/src/output.ts",
|
||||||
|
"kind": "file",
|
||||||
|
"mode": "644",
|
||||||
|
"sha256": "38e99818582a4962c09a83175634cba2bfead6acf33bd5f43cdca5caed7100a0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/src/raw.ts",
|
||||||
|
"kind": "file",
|
||||||
|
"mode": "644",
|
||||||
|
"sha256": "48fd54bd0cdb421badb58f9be2933a039fe3b9350bbe6191070c9f7bb0054670"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/src/types.ts",
|
||||||
|
"kind": "file",
|
||||||
|
"mode": "644",
|
||||||
|
"sha256": "9f92d27ab68604d5abfd0f5dc9552b96fed6d1f9fc7dc6eb30190d8b617628bf"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/tsconfig.json",
|
||||||
|
"kind": "file",
|
||||||
|
"mode": "644",
|
||||||
|
"sha256": "3c2eb7ba5c95a16cada153de4787ca7a4bf179609bf3848e12ff15b1b7927a68"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "SKILL.md",
|
||||||
|
"kind": "file",
|
||||||
|
"mode": "644",
|
||||||
|
"sha256": "69d83441799f3feada7fbf85691bda16fc30718b724871d7e37cfac574db2253"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
---
|
||||||
|
name: atlassian
|
||||||
|
description: Interact with Atlassian Cloud Jira and Confluence through a portable task-oriented CLI for search, issue/page edits, comments, transitions, and bounded raw requests.
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/atlassian/_source/pi/SKILL.md and run `pnpm run sync:pi`. -->
|
||||||
|
|
||||||
|
# Atlassian (Pi)
|
||||||
|
|
||||||
|
Portable Atlassian workflows for pi using the shared TypeScript CLI in `scripts/`.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Node.js 20+
|
||||||
|
- `pnpm`
|
||||||
|
- Atlassian Cloud account access
|
||||||
|
- `ATLASSIAN_BASE_URL`
|
||||||
|
- `ATLASSIAN_EMAIL`
|
||||||
|
- `ATLASSIAN_API_TOKEN`
|
||||||
|
|
||||||
|
The `ATLASSIAN_*` values may come from the shell environment or a `.env` file in the installed skill's `scripts/` directory.
|
||||||
|
|
||||||
|
## First-Time Setup
|
||||||
|
|
||||||
|
Global install:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.pi/agent/skills/atlassian
|
||||||
|
cp -R skills/atlassian/pi/* ~/.pi/agent/skills/atlassian/
|
||||||
|
cd ~/.pi/agent/skills/atlassian/scripts
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
Project-local install:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p .pi/skills/atlassian
|
||||||
|
cp -R skills/atlassian/pi/* .pi/skills/atlassian/
|
||||||
|
cd .pi/skills/atlassian/scripts
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
Pi can also load this repo through settings or package installs as documented in [docs/PI.md](../../../docs/PI.md).
|
||||||
|
|
||||||
|
If you installed this repo from a local checkout with `./scripts/install-pi-package.sh`, the runtime stays in the checkout mirror at `pi-package/skills/atlassian/scripts`.
|
||||||
|
|
||||||
|
## Prerequisite Check (MANDATORY)
|
||||||
|
|
||||||
|
Run inside the skill runtime directory that matches your install style:
|
||||||
|
|
||||||
|
- local checkout package install: `pi-package/skills/atlassian/scripts`
|
||||||
|
- project-local copied install: `.pi/skills/atlassian/scripts`
|
||||||
|
- global copied install: `~/.pi/agent/skills/atlassian/scripts`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd pi-package/skills/atlassian/scripts
|
||||||
|
node -e "require.resolve('commander');require.resolve('dotenv');console.log('OK: runtime dependencies installed')"
|
||||||
|
node -e 'require("dotenv").config({ path: ".env" }); const required = ["ATLASSIAN_BASE_URL", "ATLASSIAN_EMAIL", "ATLASSIAN_API_TOKEN"]; const missing = required.filter((key) => !(process.env[key] || "").trim()); if (missing.length) { console.error("Missing required Atlassian config: " + missing.join(", ")); process.exit(1); } console.log("OK: Atlassian config present")'
|
||||||
|
pnpm atlassian health
|
||||||
|
```
|
||||||
|
|
||||||
|
If any check fails, stop and return:
|
||||||
|
|
||||||
|
`Missing dependency/config: atlassian requires installed CLI dependencies and valid Atlassian Cloud credentials. Configure ATLASSIAN_* in the shell environment or scripts/.env, then retry.`
|
||||||
|
|
||||||
|
## Supported Commands
|
||||||
|
|
||||||
|
- `pnpm atlassian health`
|
||||||
|
- `pnpm atlassian jira-search --jql "..."`
|
||||||
|
- `pnpm atlassian jira-get --issue ABC-123`
|
||||||
|
- `pnpm atlassian jira-create ... [--dry-run]`
|
||||||
|
- `pnpm atlassian jira-update ... [--dry-run]`
|
||||||
|
- `pnpm atlassian jira-comment ... [--dry-run]`
|
||||||
|
- `pnpm atlassian jira-transitions --issue ABC-123`
|
||||||
|
- `pnpm atlassian jira-transition ... [--dry-run]`
|
||||||
|
- `pnpm atlassian conf-search --query "..."`
|
||||||
|
- `pnpm atlassian conf-get --page 12345`
|
||||||
|
- `pnpm atlassian conf-create ... [--dry-run]`
|
||||||
|
- `pnpm atlassian conf-update ... [--dry-run]`
|
||||||
|
- `pnpm atlassian conf-comment ... [--dry-run]`
|
||||||
|
- `pnpm atlassian conf-children --page 12345`
|
||||||
|
- `pnpm atlassian raw --product jira|confluence --method GET|POST|PUT --path ...`
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
- `pnpm atlassian jira-search --jql "project = ENG ORDER BY updated DESC" --max-results 10`
|
||||||
|
- `pnpm atlassian conf-comment --page 12345 --body-file comment.storage.html --dry-run`
|
||||||
|
- `pnpm atlassian raw --product jira --method GET --path "/rest/api/3/issue/ENG-123"`
|
||||||
|
|
||||||
|
## Safety Rules
|
||||||
|
|
||||||
|
- Default output is JSON; prefer that for agent workflows.
|
||||||
|
- Use `--dry-run` before any mutating command unless the user clearly wants the write to happen immediately.
|
||||||
|
- `raw` is for explicit edge cases only and does not allow `DELETE`.
|
||||||
|
- `--body-file` must stay inside the current workspace.
|
||||||
|
- Confluence write bodies should be storage-format inputs in v1.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Atlassian Cloud is the primary supported platform in v1.
|
||||||
|
- Package installs use the repo's `pi-package/skills/atlassian/` mirror so the installed skill directory name matches `atlassian`.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "@ai-coding-skills/atlassian-pi-mirror",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Shared runtime for the Atlassian skill",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"atlassian": "tsx src/cli.ts",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"commander": "^13.1.0",
|
||||||
|
"dotenv": "^16.4.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.3.0",
|
||||||
|
"tsx": "^4.20.5",
|
||||||
|
"typescript": "^5.9.2"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34",
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
+361
@@ -0,0 +1,361 @@
|
|||||||
|
lockfileVersion: '9.0'
|
||||||
|
|
||||||
|
settings:
|
||||||
|
autoInstallPeers: true
|
||||||
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
|
importers:
|
||||||
|
|
||||||
|
.:
|
||||||
|
dependencies:
|
||||||
|
commander:
|
||||||
|
specifier: ^13.1.0
|
||||||
|
version: 13.1.0
|
||||||
|
dotenv:
|
||||||
|
specifier: ^16.4.7
|
||||||
|
version: 16.6.1
|
||||||
|
devDependencies:
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^24.3.0
|
||||||
|
version: 24.12.0
|
||||||
|
tsx:
|
||||||
|
specifier: ^4.20.5
|
||||||
|
version: 4.21.0
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.9.2
|
||||||
|
version: 5.9.3
|
||||||
|
|
||||||
|
packages:
|
||||||
|
|
||||||
|
'@esbuild/aix-ppc64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [aix]
|
||||||
|
|
||||||
|
'@esbuild/android-arm64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@esbuild/android-arm@0.27.3':
|
||||||
|
resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@esbuild/android-x64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@esbuild/darwin-arm64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@esbuild/darwin-x64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@esbuild/freebsd-arm64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@esbuild/freebsd-x64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@esbuild/linux-arm64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-arm@0.27.3':
|
||||||
|
resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-ia32@0.27.3':
|
||||||
|
resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-loong64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [loong64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-mips64el@0.27.3':
|
||||||
|
resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [mips64el]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-ppc64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-riscv64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-s390x@0.27.3':
|
||||||
|
resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [s390x]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/linux-x64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@esbuild/netbsd-arm64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [netbsd]
|
||||||
|
|
||||||
|
'@esbuild/netbsd-x64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [netbsd]
|
||||||
|
|
||||||
|
'@esbuild/openbsd-arm64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [openbsd]
|
||||||
|
|
||||||
|
'@esbuild/openbsd-x64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [openbsd]
|
||||||
|
|
||||||
|
'@esbuild/openharmony-arm64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [openharmony]
|
||||||
|
|
||||||
|
'@esbuild/sunos-x64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [sunos]
|
||||||
|
|
||||||
|
'@esbuild/win32-arm64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@esbuild/win32-ia32@0.27.3':
|
||||||
|
resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@esbuild/win32-x64@0.27.3':
|
||||||
|
resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@types/node@24.12.0':
|
||||||
|
resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==}
|
||||||
|
|
||||||
|
commander@13.1.0:
|
||||||
|
resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
dotenv@16.6.1:
|
||||||
|
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
esbuild@0.27.3:
|
||||||
|
resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
fsevents@2.3.3:
|
||||||
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||||
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
get-tsconfig@4.13.6:
|
||||||
|
resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==}
|
||||||
|
|
||||||
|
resolve-pkg-maps@1.0.0:
|
||||||
|
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
||||||
|
|
||||||
|
tsx@4.21.0:
|
||||||
|
resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==}
|
||||||
|
engines: {node: '>=18.0.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
typescript@5.9.3:
|
||||||
|
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||||
|
engines: {node: '>=14.17'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
undici-types@7.16.0:
|
||||||
|
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
|
||||||
|
|
||||||
|
snapshots:
|
||||||
|
|
||||||
|
'@esbuild/aix-ppc64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/android-arm64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/android-arm@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/android-x64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/darwin-arm64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/darwin-x64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/freebsd-arm64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/freebsd-x64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-arm64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-arm@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-ia32@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-loong64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-mips64el@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-ppc64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-riscv64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-s390x@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/linux-x64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/netbsd-arm64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/netbsd-x64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/openbsd-arm64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/openbsd-x64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/openharmony-arm64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/sunos-x64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/win32-arm64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/win32-ia32@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@esbuild/win32-x64@0.27.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@types/node@24.12.0':
|
||||||
|
dependencies:
|
||||||
|
undici-types: 7.16.0
|
||||||
|
|
||||||
|
commander@13.1.0: {}
|
||||||
|
|
||||||
|
dotenv@16.6.1: {}
|
||||||
|
|
||||||
|
esbuild@0.27.3:
|
||||||
|
optionalDependencies:
|
||||||
|
'@esbuild/aix-ppc64': 0.27.3
|
||||||
|
'@esbuild/android-arm': 0.27.3
|
||||||
|
'@esbuild/android-arm64': 0.27.3
|
||||||
|
'@esbuild/android-x64': 0.27.3
|
||||||
|
'@esbuild/darwin-arm64': 0.27.3
|
||||||
|
'@esbuild/darwin-x64': 0.27.3
|
||||||
|
'@esbuild/freebsd-arm64': 0.27.3
|
||||||
|
'@esbuild/freebsd-x64': 0.27.3
|
||||||
|
'@esbuild/linux-arm': 0.27.3
|
||||||
|
'@esbuild/linux-arm64': 0.27.3
|
||||||
|
'@esbuild/linux-ia32': 0.27.3
|
||||||
|
'@esbuild/linux-loong64': 0.27.3
|
||||||
|
'@esbuild/linux-mips64el': 0.27.3
|
||||||
|
'@esbuild/linux-ppc64': 0.27.3
|
||||||
|
'@esbuild/linux-riscv64': 0.27.3
|
||||||
|
'@esbuild/linux-s390x': 0.27.3
|
||||||
|
'@esbuild/linux-x64': 0.27.3
|
||||||
|
'@esbuild/netbsd-arm64': 0.27.3
|
||||||
|
'@esbuild/netbsd-x64': 0.27.3
|
||||||
|
'@esbuild/openbsd-arm64': 0.27.3
|
||||||
|
'@esbuild/openbsd-x64': 0.27.3
|
||||||
|
'@esbuild/openharmony-arm64': 0.27.3
|
||||||
|
'@esbuild/sunos-x64': 0.27.3
|
||||||
|
'@esbuild/win32-arm64': 0.27.3
|
||||||
|
'@esbuild/win32-ia32': 0.27.3
|
||||||
|
'@esbuild/win32-x64': 0.27.3
|
||||||
|
|
||||||
|
fsevents@2.3.3:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
get-tsconfig@4.13.6:
|
||||||
|
dependencies:
|
||||||
|
resolve-pkg-maps: 1.0.0
|
||||||
|
|
||||||
|
resolve-pkg-maps@1.0.0: {}
|
||||||
|
|
||||||
|
tsx@4.21.0:
|
||||||
|
dependencies:
|
||||||
|
esbuild: 0.27.3
|
||||||
|
get-tsconfig: 4.13.6
|
||||||
|
optionalDependencies:
|
||||||
|
fsevents: 2.3.3
|
||||||
|
|
||||||
|
typescript@5.9.3: {}
|
||||||
|
|
||||||
|
undici-types@7.16.0: {}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/atlassian/shared/scripts/ and run `pnpm run sync:pi`.
|
||||||
|
const TEXT_NODE = "text";
|
||||||
|
|
||||||
|
function textNode(text: string) {
|
||||||
|
return {
|
||||||
|
type: TEXT_NODE,
|
||||||
|
text,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function paragraphNode(lines: string[]) {
|
||||||
|
const content: Array<{ type: string; text?: string }> = [];
|
||||||
|
|
||||||
|
lines.forEach((line, index) => {
|
||||||
|
if (index > 0) {
|
||||||
|
content.push({ type: "hardBreak" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.length > 0) {
|
||||||
|
content.push(textNode(line));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "paragraph",
|
||||||
|
...(content.length > 0 ? { content } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markdownToAdf(input: string) {
|
||||||
|
const lines = input.replace(/\r\n/g, "\n").split("\n");
|
||||||
|
const content: Array<Record<string, unknown>> = [];
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
while (index < lines.length) {
|
||||||
|
const current = lines[index]?.trimEnd() ?? "";
|
||||||
|
|
||||||
|
if (current.trim().length === 0) {
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const heading = current.match(/^(#{1,6})\s+(.*)$/);
|
||||||
|
|
||||||
|
if (heading) {
|
||||||
|
content.push({
|
||||||
|
type: "heading",
|
||||||
|
attrs: { level: heading[1].length },
|
||||||
|
content: [textNode(heading[2])],
|
||||||
|
});
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^[-*]\s+/.test(current)) {
|
||||||
|
const items: Array<Record<string, unknown>> = [];
|
||||||
|
|
||||||
|
while (index < lines.length && /^[-*]\s+/.test(lines[index] ?? "")) {
|
||||||
|
items.push({
|
||||||
|
type: "listItem",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content: [textNode((lines[index] ?? "").replace(/^[-*]\s+/, ""))],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
content.push({
|
||||||
|
type: "bulletList",
|
||||||
|
content: items,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const paragraphLines: string[] = [];
|
||||||
|
|
||||||
|
while (index < lines.length && (lines[index]?.trim().length ?? 0) > 0) {
|
||||||
|
paragraphLines.push(lines[index] ?? "");
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
content.push(paragraphNode(paragraphLines));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "doc",
|
||||||
|
version: 1,
|
||||||
|
content,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
// ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/atlassian/shared/scripts/ and run `pnpm run sync:pi`.
|
||||||
|
import process from "node:process";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
|
||||||
|
import { Command } from "commander";
|
||||||
|
|
||||||
|
import { resolveFormat } from "./command-helpers.js";
|
||||||
|
import { createConfluenceClient } from "./confluence.js";
|
||||||
|
import { loadConfig } from "./config.js";
|
||||||
|
import { readWorkspaceFile } from "./files.js";
|
||||||
|
import { runHealthCheck } from "./health.js";
|
||||||
|
import { createJiraClient } from "./jira.js";
|
||||||
|
import { writeOutput } from "./output.js";
|
||||||
|
import { runRawCommand } from "./raw.js";
|
||||||
|
import type { FetchLike, Writer } from "./types.js";
|
||||||
|
|
||||||
|
type CliContext = {
|
||||||
|
cwd?: string;
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
fetchImpl?: FetchLike;
|
||||||
|
stdout?: Writer;
|
||||||
|
stderr?: Writer;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createRuntime(context: CliContext) {
|
||||||
|
const cwd = context.cwd ?? process.cwd();
|
||||||
|
const env = context.env ?? process.env;
|
||||||
|
const stdout = context.stdout ?? process.stdout;
|
||||||
|
const stderr = context.stderr ?? process.stderr;
|
||||||
|
let configCache: ReturnType<typeof loadConfig> | undefined;
|
||||||
|
let jiraCache: ReturnType<typeof createJiraClient> | undefined;
|
||||||
|
let confluenceCache: ReturnType<typeof createConfluenceClient> | undefined;
|
||||||
|
|
||||||
|
function getConfig() {
|
||||||
|
configCache ??= loadConfig(env, { cwd });
|
||||||
|
return configCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getJiraClient() {
|
||||||
|
jiraCache ??= createJiraClient({
|
||||||
|
config: getConfig(),
|
||||||
|
fetchImpl: context.fetchImpl,
|
||||||
|
});
|
||||||
|
return jiraCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfluenceClient() {
|
||||||
|
confluenceCache ??= createConfluenceClient({
|
||||||
|
config: getConfig(),
|
||||||
|
fetchImpl: context.fetchImpl,
|
||||||
|
});
|
||||||
|
return confluenceCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readBodyFile(filePath: string | undefined) {
|
||||||
|
if (!filePath) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return readWorkspaceFile(filePath, cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cwd,
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
readBodyFile,
|
||||||
|
getConfig,
|
||||||
|
getJiraClient,
|
||||||
|
getConfluenceClient,
|
||||||
|
fetchImpl: context.fetchImpl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildProgram(context: CliContext = {}) {
|
||||||
|
const runtime = createRuntime(context);
|
||||||
|
const program = new Command()
|
||||||
|
.name("atlassian")
|
||||||
|
.description("Portable Atlassian CLI for multi-agent skills")
|
||||||
|
.version("0.1.0");
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("health")
|
||||||
|
.description("Validate configuration and Atlassian connectivity")
|
||||||
|
.option("--format <format>", "Output format", "json")
|
||||||
|
.action(async (options) => {
|
||||||
|
const payload = await runHealthCheck(runtime.getConfig(), runtime.fetchImpl);
|
||||||
|
writeOutput(
|
||||||
|
runtime.stdout,
|
||||||
|
payload,
|
||||||
|
resolveFormat(options.format),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("conf-search")
|
||||||
|
.requiredOption("--query <query>", "CQL search query")
|
||||||
|
.option("--max-results <number>", "Maximum results to return", "50")
|
||||||
|
.option("--start-at <number>", "Result offset", "0")
|
||||||
|
.option("--format <format>", "Output format", "json")
|
||||||
|
.action(async (options) => {
|
||||||
|
const payload = await runtime.getConfluenceClient().searchPages({
|
||||||
|
query: options.query,
|
||||||
|
maxResults: Number(options.maxResults),
|
||||||
|
startAt: Number(options.startAt),
|
||||||
|
});
|
||||||
|
|
||||||
|
writeOutput(runtime.stdout, payload, resolveFormat(options.format));
|
||||||
|
});
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("conf-get")
|
||||||
|
.requiredOption("--page <page>", "Confluence page ID")
|
||||||
|
.option("--format <format>", "Output format", "json")
|
||||||
|
.action(async (options) => {
|
||||||
|
const payload = await runtime.getConfluenceClient().getPage(options.page);
|
||||||
|
writeOutput(runtime.stdout, payload, resolveFormat(options.format));
|
||||||
|
});
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("conf-create")
|
||||||
|
.requiredOption("--title <title>", "Confluence page title")
|
||||||
|
.requiredOption("--body-file <path>", "Workspace-relative storage-format body file")
|
||||||
|
.option("--space <space>", "Confluence space ID")
|
||||||
|
.option("--dry-run", "Print the request without sending it")
|
||||||
|
.option("--format <format>", "Output format", "json")
|
||||||
|
.action(async (options) => {
|
||||||
|
const payload = await runtime.getConfluenceClient().createPage({
|
||||||
|
space: options.space,
|
||||||
|
title: options.title,
|
||||||
|
body: (await runtime.readBodyFile(options.bodyFile)) as string,
|
||||||
|
dryRun: Boolean(options.dryRun),
|
||||||
|
});
|
||||||
|
|
||||||
|
writeOutput(runtime.stdout, payload, resolveFormat(options.format));
|
||||||
|
});
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("conf-update")
|
||||||
|
.requiredOption("--page <page>", "Confluence page ID")
|
||||||
|
.requiredOption("--title <title>", "Confluence page title")
|
||||||
|
.requiredOption("--body-file <path>", "Workspace-relative storage-format body file")
|
||||||
|
.option("--dry-run", "Print the request without sending it")
|
||||||
|
.option("--format <format>", "Output format", "json")
|
||||||
|
.action(async (options) => {
|
||||||
|
const payload = await runtime.getConfluenceClient().updatePage({
|
||||||
|
pageId: options.page,
|
||||||
|
title: options.title,
|
||||||
|
body: (await runtime.readBodyFile(options.bodyFile)) as string,
|
||||||
|
dryRun: Boolean(options.dryRun),
|
||||||
|
});
|
||||||
|
|
||||||
|
writeOutput(runtime.stdout, payload, resolveFormat(options.format));
|
||||||
|
});
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("conf-comment")
|
||||||
|
.requiredOption("--page <page>", "Confluence page ID")
|
||||||
|
.requiredOption("--body-file <path>", "Workspace-relative storage-format body file")
|
||||||
|
.option("--dry-run", "Print the request without sending it")
|
||||||
|
.option("--format <format>", "Output format", "json")
|
||||||
|
.action(async (options) => {
|
||||||
|
const payload = await runtime.getConfluenceClient().commentPage({
|
||||||
|
pageId: options.page,
|
||||||
|
body: (await runtime.readBodyFile(options.bodyFile)) as string,
|
||||||
|
dryRun: Boolean(options.dryRun),
|
||||||
|
});
|
||||||
|
|
||||||
|
writeOutput(runtime.stdout, payload, resolveFormat(options.format));
|
||||||
|
});
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("conf-children")
|
||||||
|
.requiredOption("--page <page>", "Confluence page ID")
|
||||||
|
.option("--max-results <number>", "Maximum results to return", "50")
|
||||||
|
.option("--start-at <number>", "Cursor/start token", "0")
|
||||||
|
.option("--format <format>", "Output format", "json")
|
||||||
|
.action(async (options) => {
|
||||||
|
const payload = await runtime.getConfluenceClient().listChildren(
|
||||||
|
options.page,
|
||||||
|
Number(options.maxResults),
|
||||||
|
Number(options.startAt),
|
||||||
|
);
|
||||||
|
|
||||||
|
writeOutput(runtime.stdout, payload, resolveFormat(options.format));
|
||||||
|
});
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("raw")
|
||||||
|
.requiredOption("--product <product>", "jira or confluence")
|
||||||
|
.requiredOption("--method <method>", "GET, POST, or PUT")
|
||||||
|
.requiredOption("--path <path>", "Validated API path")
|
||||||
|
.option("--body-file <path>", "Workspace-relative JSON file")
|
||||||
|
.option("--dry-run", "Print the request without sending it")
|
||||||
|
.option("--format <format>", "Output format", "json")
|
||||||
|
.action(async (options) => {
|
||||||
|
const payload = await runRawCommand(runtime.getConfig(), runtime.fetchImpl, {
|
||||||
|
product: options.product,
|
||||||
|
method: String(options.method).toUpperCase(),
|
||||||
|
path: options.path,
|
||||||
|
bodyFile: options.bodyFile,
|
||||||
|
cwd: runtime.cwd,
|
||||||
|
dryRun: Boolean(options.dryRun),
|
||||||
|
});
|
||||||
|
|
||||||
|
writeOutput(runtime.stdout, payload, resolveFormat(options.format));
|
||||||
|
});
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("jira-search")
|
||||||
|
.requiredOption("--jql <jql>", "JQL expression to execute")
|
||||||
|
.option("--max-results <number>", "Maximum results to return", "50")
|
||||||
|
.option("--start-at <number>", "Result offset", "0")
|
||||||
|
.option("--format <format>", "Output format", "json")
|
||||||
|
.action(async (options) => {
|
||||||
|
const payload = await runtime.getJiraClient().searchIssues({
|
||||||
|
jql: options.jql,
|
||||||
|
maxResults: Number(options.maxResults),
|
||||||
|
startAt: Number(options.startAt),
|
||||||
|
});
|
||||||
|
|
||||||
|
writeOutput(runtime.stdout, payload, resolveFormat(options.format));
|
||||||
|
});
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("jira-get")
|
||||||
|
.requiredOption("--issue <issue>", "Issue key")
|
||||||
|
.option("--format <format>", "Output format", "json")
|
||||||
|
.action(async (options) => {
|
||||||
|
const payload = await runtime.getJiraClient().getIssue(options.issue);
|
||||||
|
writeOutput(runtime.stdout, payload, resolveFormat(options.format));
|
||||||
|
});
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("jira-create")
|
||||||
|
.requiredOption("--type <type>", "Issue type name")
|
||||||
|
.requiredOption("--summary <summary>", "Issue summary")
|
||||||
|
.option("--project <project>", "Project key")
|
||||||
|
.option("--description-file <path>", "Workspace-relative markdown/text file")
|
||||||
|
.option("--dry-run", "Print the request without sending it")
|
||||||
|
.option("--format <format>", "Output format", "json")
|
||||||
|
.action(async (options) => {
|
||||||
|
const payload = await runtime.getJiraClient().createIssue({
|
||||||
|
project: options.project,
|
||||||
|
type: options.type,
|
||||||
|
summary: options.summary,
|
||||||
|
description: await runtime.readBodyFile(options.descriptionFile),
|
||||||
|
dryRun: Boolean(options.dryRun),
|
||||||
|
});
|
||||||
|
|
||||||
|
writeOutput(runtime.stdout, payload, resolveFormat(options.format));
|
||||||
|
});
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("jira-update")
|
||||||
|
.requiredOption("--issue <issue>", "Issue key")
|
||||||
|
.option("--summary <summary>", "Updated summary")
|
||||||
|
.option("--description-file <path>", "Workspace-relative markdown/text file")
|
||||||
|
.option("--dry-run", "Print the request without sending it")
|
||||||
|
.option("--format <format>", "Output format", "json")
|
||||||
|
.action(async (options) => {
|
||||||
|
const payload = await runtime.getJiraClient().updateIssue({
|
||||||
|
issue: options.issue,
|
||||||
|
summary: options.summary,
|
||||||
|
description: await runtime.readBodyFile(options.descriptionFile),
|
||||||
|
dryRun: Boolean(options.dryRun),
|
||||||
|
});
|
||||||
|
|
||||||
|
writeOutput(runtime.stdout, payload, resolveFormat(options.format));
|
||||||
|
});
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("jira-comment")
|
||||||
|
.requiredOption("--issue <issue>", "Issue key")
|
||||||
|
.requiredOption("--body-file <path>", "Workspace-relative markdown/text file")
|
||||||
|
.option("--dry-run", "Print the request without sending it")
|
||||||
|
.option("--format <format>", "Output format", "json")
|
||||||
|
.action(async (options) => {
|
||||||
|
const payload = await runtime.getJiraClient().commentIssue({
|
||||||
|
issue: options.issue,
|
||||||
|
body: (await runtime.readBodyFile(options.bodyFile)) as string,
|
||||||
|
dryRun: Boolean(options.dryRun),
|
||||||
|
});
|
||||||
|
|
||||||
|
writeOutput(runtime.stdout, payload, resolveFormat(options.format));
|
||||||
|
});
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("jira-transitions")
|
||||||
|
.requiredOption("--issue <issue>", "Issue key")
|
||||||
|
.option("--format <format>", "Output format", "json")
|
||||||
|
.action(async (options) => {
|
||||||
|
const payload = await runtime.getJiraClient().getTransitions(options.issue);
|
||||||
|
writeOutput(runtime.stdout, payload, resolveFormat(options.format));
|
||||||
|
});
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("jira-transition")
|
||||||
|
.requiredOption("--issue <issue>", "Issue key")
|
||||||
|
.requiredOption("--transition <transition>", "Transition ID")
|
||||||
|
.option("--dry-run", "Print the request without sending it")
|
||||||
|
.option("--format <format>", "Output format", "json")
|
||||||
|
.action(async (options) => {
|
||||||
|
const payload = await runtime.getJiraClient().transitionIssue({
|
||||||
|
issue: options.issue,
|
||||||
|
transition: options.transition,
|
||||||
|
dryRun: Boolean(options.dryRun),
|
||||||
|
});
|
||||||
|
|
||||||
|
writeOutput(runtime.stdout, payload, resolveFormat(options.format));
|
||||||
|
});
|
||||||
|
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runCli(argv = process.argv, context: CliContext = {}) {
|
||||||
|
const program = buildProgram(context);
|
||||||
|
await program.parseAsync(argv);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDirectExecution =
|
||||||
|
Boolean(process.argv[1]) && import.meta.url === pathToFileURL(process.argv[1]).href;
|
||||||
|
|
||||||
|
if (isDirectExecution) {
|
||||||
|
runCli().catch((error: unknown) => {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
process.stderr.write(`${message}\n`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/atlassian/shared/scripts/ and run `pnpm run sync:pi`.
|
||||||
|
import type { CommandOutput, OutputFormat } from "./types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Produce the standard dry-run response payload for write operations.
|
||||||
|
*
|
||||||
|
* Use this when `--dry-run` is passed to skip the actual API call and
|
||||||
|
* echo the pending request back to the caller.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* if (input.dryRun) return dryRunResponse(request);
|
||||||
|
*/
|
||||||
|
export function dryRunResponse<T>(data: T): CommandOutput<T> {
|
||||||
|
return { ok: true, dryRun: true, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the `--format` CLI option to a typed OutputFormat.
|
||||||
|
*
|
||||||
|
* Returns `"text"` only for the exact string `"text"`;
|
||||||
|
* all other values (including `undefined`) fall back to `"json"`.
|
||||||
|
*/
|
||||||
|
export function resolveFormat(format: string | undefined): OutputFormat {
|
||||||
|
return format === "text" ? "text" : "json";
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/atlassian/shared/scripts/ and run `pnpm run sync:pi`.
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { config as loadDotEnv } from "dotenv";
|
||||||
|
|
||||||
|
import type { AtlassianConfig } from "./types.js";
|
||||||
|
|
||||||
|
function normalizeBaseUrl(value: string) {
|
||||||
|
return value.replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRequired(env: NodeJS.ProcessEnv, key: string) {
|
||||||
|
const value = env[key]?.trim();
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`Missing required environment variable: ${key}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadConfig(
|
||||||
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
options?: {
|
||||||
|
cwd?: string;
|
||||||
|
},
|
||||||
|
): AtlassianConfig {
|
||||||
|
loadDotEnv({
|
||||||
|
path: path.resolve(options?.cwd ?? process.cwd(), ".env"),
|
||||||
|
processEnv: env as Record<string, string>,
|
||||||
|
override: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseUrl = normalizeBaseUrl(readRequired(env, "ATLASSIAN_BASE_URL"));
|
||||||
|
|
||||||
|
return {
|
||||||
|
baseUrl,
|
||||||
|
jiraBaseUrl: normalizeBaseUrl(env.ATLASSIAN_JIRA_BASE_URL?.trim() || baseUrl),
|
||||||
|
confluenceBaseUrl: normalizeBaseUrl(env.ATLASSIAN_CONFLUENCE_BASE_URL?.trim() || baseUrl),
|
||||||
|
email: readRequired(env, "ATLASSIAN_EMAIL"),
|
||||||
|
apiToken: readRequired(env, "ATLASSIAN_API_TOKEN"),
|
||||||
|
defaultProject: env.ATLASSIAN_DEFAULT_PROJECT?.trim() || undefined,
|
||||||
|
defaultSpace: env.ATLASSIAN_DEFAULT_SPACE?.trim() || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBasicAuthHeader(config: {
|
||||||
|
email: string;
|
||||||
|
apiToken: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}) {
|
||||||
|
return `Basic ${Buffer.from(`${config.email}:${config.apiToken}`).toString("base64")}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
// ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/atlassian/shared/scripts/ and run `pnpm run sync:pi`.
|
||||||
|
import { dryRunResponse } from "./command-helpers.js";
|
||||||
|
import { sendJsonRequest } from "./http.js";
|
||||||
|
import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js";
|
||||||
|
|
||||||
|
type ConfluenceClientOptions = {
|
||||||
|
config: AtlassianConfig;
|
||||||
|
fetchImpl?: FetchLike;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SearchInput = {
|
||||||
|
query: string;
|
||||||
|
maxResults: number;
|
||||||
|
startAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CreateInput = {
|
||||||
|
space?: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
dryRun?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UpdateInput = {
|
||||||
|
pageId: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
dryRun?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CommentInput = {
|
||||||
|
pageId: string;
|
||||||
|
body: string;
|
||||||
|
dryRun?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PageSummary = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
type: string;
|
||||||
|
status?: string;
|
||||||
|
spaceId?: string;
|
||||||
|
url?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildUrl(baseUrl: string, path: string) {
|
||||||
|
return new URL(path, `${baseUrl}/`).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePage(baseUrl: string, page: Record<string, unknown>, excerpt?: string) {
|
||||||
|
const links = (page._links ?? {}) as Record<string, unknown>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(page.id ?? ""),
|
||||||
|
title: String(page.title ?? ""),
|
||||||
|
type: String(page.type ?? "page"),
|
||||||
|
...(page.status ? { status: String(page.status) } : {}),
|
||||||
|
...(page.spaceId ? { spaceId: String(page.spaceId) } : {}),
|
||||||
|
...(excerpt ? { excerpt } : {}),
|
||||||
|
...(links.webui ? { url: `${baseUrl}${String(links.webui)}` } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createConfluenceClient(options: ConfluenceClientOptions) {
|
||||||
|
const config = options.config;
|
||||||
|
|
||||||
|
async function getPageForUpdate(pageId: string) {
|
||||||
|
return (await sendJsonRequest({
|
||||||
|
config,
|
||||||
|
fetchImpl: options.fetchImpl,
|
||||||
|
url: buildUrl(config.confluenceBaseUrl, `/wiki/api/v2/pages/${pageId}?body-format=storage`),
|
||||||
|
method: "GET",
|
||||||
|
errorPrefix: "Confluence request failed",
|
||||||
|
})) as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async searchPages(input: SearchInput): Promise<CommandOutput<unknown>> {
|
||||||
|
const url = new URL("/wiki/rest/api/search", `${config.confluenceBaseUrl}/`);
|
||||||
|
url.searchParams.set("cql", input.query);
|
||||||
|
url.searchParams.set("limit", String(input.maxResults));
|
||||||
|
url.searchParams.set("start", String(input.startAt));
|
||||||
|
|
||||||
|
const raw = (await sendJsonRequest({
|
||||||
|
config,
|
||||||
|
fetchImpl: options.fetchImpl,
|
||||||
|
url: url.toString(),
|
||||||
|
method: "GET",
|
||||||
|
errorPrefix: "Confluence request failed",
|
||||||
|
})) as Record<string, unknown>;
|
||||||
|
|
||||||
|
const results = Array.isArray(raw.results) ? raw.results : [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
pages: results.map((entry) => {
|
||||||
|
const result = entry as Record<string, unknown>;
|
||||||
|
return normalizePage(
|
||||||
|
config.baseUrl,
|
||||||
|
(result.content ?? {}) as Record<string, unknown>,
|
||||||
|
result.excerpt ? String(result.excerpt) : undefined,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
startAt: Number(raw.start ?? input.startAt),
|
||||||
|
maxResults: Number(raw.limit ?? input.maxResults),
|
||||||
|
total: Number(raw.totalSize ?? raw.size ?? results.length),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async getPage(pageId: string): Promise<CommandOutput<unknown>> {
|
||||||
|
const raw = (await sendJsonRequest({
|
||||||
|
config,
|
||||||
|
fetchImpl: options.fetchImpl,
|
||||||
|
url: buildUrl(config.confluenceBaseUrl, `/wiki/api/v2/pages/${pageId}?body-format=storage`),
|
||||||
|
method: "GET",
|
||||||
|
errorPrefix: "Confluence request failed",
|
||||||
|
})) as Record<string, unknown>;
|
||||||
|
|
||||||
|
const body = ((raw.body ?? {}) as Record<string, unknown>).storage as Record<string, unknown> | undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
page: {
|
||||||
|
...normalizePage(config.baseUrl, raw),
|
||||||
|
version: Number((((raw.version ?? {}) as Record<string, unknown>).number ?? 0)),
|
||||||
|
body: body?.value ? String(body.value) : "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
raw,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async listChildren(pageId: string, maxResults: number, startAt: number): Promise<CommandOutput<unknown>> {
|
||||||
|
const url = new URL(`/wiki/api/v2/pages/${pageId}/direct-children`, `${config.confluenceBaseUrl}/`);
|
||||||
|
url.searchParams.set("limit", String(maxResults));
|
||||||
|
url.searchParams.set("cursor", String(startAt));
|
||||||
|
|
||||||
|
const raw = (await sendJsonRequest({
|
||||||
|
config,
|
||||||
|
fetchImpl: options.fetchImpl,
|
||||||
|
url: url.toString(),
|
||||||
|
method: "GET",
|
||||||
|
errorPrefix: "Confluence request failed",
|
||||||
|
})) as Record<string, unknown>;
|
||||||
|
|
||||||
|
const results = Array.isArray(raw.results) ? raw.results : [];
|
||||||
|
const links = (raw._links ?? {}) as Record<string, unknown>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
pages: results.map((page) => normalizePage(config.baseUrl, page as Record<string, unknown>)),
|
||||||
|
nextCursor: links.next ? String(links.next) : null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async createPage(input: CreateInput): Promise<CommandOutput<unknown>> {
|
||||||
|
const spaceId = input.space || config.defaultSpace;
|
||||||
|
|
||||||
|
if (!spaceId) {
|
||||||
|
throw new Error("conf-create requires --space or ATLASSIAN_DEFAULT_SPACE");
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = {
|
||||||
|
method: "POST" as const,
|
||||||
|
url: buildUrl(config.confluenceBaseUrl, "/wiki/api/v2/pages"),
|
||||||
|
body: {
|
||||||
|
spaceId,
|
||||||
|
title: input.title,
|
||||||
|
status: "current",
|
||||||
|
body: {
|
||||||
|
representation: "storage",
|
||||||
|
value: input.body,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (input.dryRun) return dryRunResponse(request);
|
||||||
|
|
||||||
|
const raw = await sendJsonRequest({
|
||||||
|
config,
|
||||||
|
fetchImpl: options.fetchImpl,
|
||||||
|
url: request.url,
|
||||||
|
method: request.method,
|
||||||
|
body: request.body,
|
||||||
|
errorPrefix: "Confluence request failed",
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
data: raw,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async updatePage(input: UpdateInput): Promise<CommandOutput<unknown>> {
|
||||||
|
const currentPage = await getPageForUpdate(input.pageId);
|
||||||
|
const version = (((currentPage.version ?? {}) as Record<string, unknown>).number ?? 0) as number;
|
||||||
|
const spaceId = String(currentPage.spaceId ?? "");
|
||||||
|
|
||||||
|
const request = {
|
||||||
|
method: "PUT" as const,
|
||||||
|
url: buildUrl(config.confluenceBaseUrl, `/wiki/api/v2/pages/${input.pageId}`),
|
||||||
|
body: {
|
||||||
|
id: input.pageId,
|
||||||
|
status: String(currentPage.status ?? "current"),
|
||||||
|
title: input.title,
|
||||||
|
spaceId,
|
||||||
|
version: {
|
||||||
|
number: Number(version) + 1,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
representation: "storage",
|
||||||
|
value: input.body,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (input.dryRun) return dryRunResponse(request);
|
||||||
|
|
||||||
|
const raw = await sendJsonRequest({
|
||||||
|
config,
|
||||||
|
fetchImpl: options.fetchImpl,
|
||||||
|
url: request.url,
|
||||||
|
method: request.method,
|
||||||
|
body: request.body,
|
||||||
|
errorPrefix: "Confluence request failed",
|
||||||
|
handleResponseError(response) {
|
||||||
|
if (response.status === 409) {
|
||||||
|
return new Error(`Confluence update conflict: page ${input.pageId} was updated by someone else`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
data: raw,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async commentPage(input: CommentInput): Promise<CommandOutput<unknown>> {
|
||||||
|
const request = {
|
||||||
|
method: "POST" as const,
|
||||||
|
url: buildUrl(config.confluenceBaseUrl, "/wiki/api/v2/footer-comments"),
|
||||||
|
body: {
|
||||||
|
pageId: input.pageId,
|
||||||
|
body: {
|
||||||
|
representation: "storage",
|
||||||
|
value: input.body,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (input.dryRun) return dryRunResponse(request);
|
||||||
|
|
||||||
|
const raw = await sendJsonRequest({
|
||||||
|
config,
|
||||||
|
fetchImpl: options.fetchImpl,
|
||||||
|
url: request.url,
|
||||||
|
method: request.method,
|
||||||
|
body: request.body,
|
||||||
|
errorPrefix: "Confluence request failed",
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
data: raw,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/atlassian/shared/scripts/ and run `pnpm run sync:pi`.
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export async function readWorkspaceFile(filePath: string, cwd: string) {
|
||||||
|
const resolved = path.resolve(cwd, filePath);
|
||||||
|
const relative = path.relative(cwd, resolved);
|
||||||
|
|
||||||
|
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||||
|
throw new Error(`--body-file must stay within the active workspace: ${filePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return readFile(resolved, "utf8");
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/atlassian/shared/scripts/ and run `pnpm run sync:pi`.
|
||||||
|
import { createJsonHeaders, createStatusError } from "./http.js";
|
||||||
|
import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js";
|
||||||
|
|
||||||
|
type ProductHealth = {
|
||||||
|
ok: boolean;
|
||||||
|
status?: number;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildUrl(baseUrl: string, path: string) {
|
||||||
|
return new URL(path, `${baseUrl}/`).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runHealthCheck(
|
||||||
|
config: AtlassianConfig,
|
||||||
|
fetchImpl: FetchLike | undefined,
|
||||||
|
): Promise<CommandOutput<unknown>> {
|
||||||
|
const client = fetchImpl ?? globalThis.fetch;
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
throw new Error("Fetch API is not available in this runtime");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probe(product: "Jira" | "Confluence", url: string): Promise<ProductHealth> {
|
||||||
|
try {
|
||||||
|
const response = await client(url, {
|
||||||
|
method: "GET",
|
||||||
|
headers: createJsonHeaders(config, false),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = createStatusError(`${product} health check failed`, response);
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: response.status,
|
||||||
|
message: error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: response.status,
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const jira = await probe("Jira", buildUrl(config.jiraBaseUrl, "/rest/api/3/myself"));
|
||||||
|
const confluence = await probe("Confluence", buildUrl(config.confluenceBaseUrl, "/wiki/api/v2/spaces?limit=1"));
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: jira.ok && confluence.ok,
|
||||||
|
data: {
|
||||||
|
baseUrl: config.baseUrl,
|
||||||
|
jiraBaseUrl: config.jiraBaseUrl,
|
||||||
|
confluenceBaseUrl: config.confluenceBaseUrl,
|
||||||
|
defaultProject: config.defaultProject,
|
||||||
|
defaultSpace: config.defaultSpace,
|
||||||
|
products: {
|
||||||
|
jira,
|
||||||
|
confluence,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/atlassian/shared/scripts/ and run `pnpm run sync:pi`.
|
||||||
|
import { createBasicAuthHeader } from "./config.js";
|
||||||
|
import type { AtlassianConfig, FetchLike } from "./types.js";
|
||||||
|
|
||||||
|
export type HttpMethod = "GET" | "POST" | "PUT";
|
||||||
|
|
||||||
|
export function createJsonHeaders(config: AtlassianConfig, includeJsonBody: boolean) {
|
||||||
|
const headers: Array<[string, string]> = [
|
||||||
|
["Accept", "application/json"],
|
||||||
|
["Authorization", createBasicAuthHeader(config)],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (includeJsonBody) {
|
||||||
|
headers.push(["Content-Type", "application/json"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseResponse(response: Response) {
|
||||||
|
if (response.status === 204) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get("content-type") ?? "";
|
||||||
|
|
||||||
|
if (contentType.includes("application/json")) {
|
||||||
|
try {
|
||||||
|
return await response.json();
|
||||||
|
} catch {
|
||||||
|
throw new Error("Malformed JSON response from Atlassian API");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStatusError(errorPrefix: string, response: Response) {
|
||||||
|
const base = `${errorPrefix}: ${response.status} ${response.statusText}`;
|
||||||
|
|
||||||
|
switch (response.status) {
|
||||||
|
case 401:
|
||||||
|
return new Error(`${base} - check ATLASSIAN_EMAIL and ATLASSIAN_API_TOKEN`);
|
||||||
|
case 403:
|
||||||
|
return new Error(`${base} - verify product permissions for this account`);
|
||||||
|
case 404:
|
||||||
|
return new Error(`${base} - verify the resource identifier or API path`);
|
||||||
|
case 429:
|
||||||
|
return new Error(`${base} - retry later or reduce request rate`);
|
||||||
|
default:
|
||||||
|
return new Error(base);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendJsonRequest(options: {
|
||||||
|
config: AtlassianConfig;
|
||||||
|
fetchImpl?: FetchLike;
|
||||||
|
url: string;
|
||||||
|
method: HttpMethod;
|
||||||
|
body?: unknown;
|
||||||
|
errorPrefix: string;
|
||||||
|
handleResponseError?: (response: Response) => Error | undefined;
|
||||||
|
}) {
|
||||||
|
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
||||||
|
|
||||||
|
if (!fetchImpl) {
|
||||||
|
throw new Error("Fetch API is not available in this runtime");
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetchImpl(options.url, {
|
||||||
|
method: options.method,
|
||||||
|
headers: createJsonHeaders(options.config, options.body !== undefined),
|
||||||
|
...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const customError = options.handleResponseError?.(response);
|
||||||
|
|
||||||
|
if (customError) {
|
||||||
|
throw customError;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw createStatusError(options.errorPrefix, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseResponse(response);
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
// ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/atlassian/shared/scripts/ and run `pnpm run sync:pi`.
|
||||||
|
import { markdownToAdf } from "./adf.js";
|
||||||
|
import { dryRunResponse } from "./command-helpers.js";
|
||||||
|
import { sendJsonRequest } from "./http.js";
|
||||||
|
import type { AtlassianConfig, CommandOutput, FetchLike, JiraIssueSummary } from "./types.js";
|
||||||
|
|
||||||
|
const ISSUE_FIELDS = ["summary", "issuetype", "status", "assignee", "created", "updated"] as const;
|
||||||
|
|
||||||
|
type JiraClientOptions = {
|
||||||
|
config: AtlassianConfig;
|
||||||
|
fetchImpl?: FetchLike;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SearchInput = {
|
||||||
|
jql: string;
|
||||||
|
maxResults: number;
|
||||||
|
startAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CreateInput = {
|
||||||
|
project?: string;
|
||||||
|
type: string;
|
||||||
|
summary: string;
|
||||||
|
description?: string;
|
||||||
|
dryRun?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UpdateInput = {
|
||||||
|
issue: string;
|
||||||
|
summary?: string;
|
||||||
|
description?: string;
|
||||||
|
dryRun?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CommentInput = {
|
||||||
|
issue: string;
|
||||||
|
body: string;
|
||||||
|
dryRun?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TransitionInput = {
|
||||||
|
issue: string;
|
||||||
|
transition: string;
|
||||||
|
dryRun?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeIssue(config: AtlassianConfig, issue: Record<string, unknown>): JiraIssueSummary {
|
||||||
|
const fields = (issue.fields ?? {}) as Record<string, unknown>;
|
||||||
|
const issueType = (fields.issuetype ?? {}) as Record<string, unknown>;
|
||||||
|
const status = (fields.status ?? {}) as Record<string, unknown>;
|
||||||
|
const assignee = (fields.assignee ?? {}) as Record<string, unknown>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: String(issue.key ?? ""),
|
||||||
|
summary: String(fields.summary ?? ""),
|
||||||
|
issueType: String(issueType.name ?? ""),
|
||||||
|
status: String(status.name ?? ""),
|
||||||
|
assignee: assignee.displayName ? String(assignee.displayName) : undefined,
|
||||||
|
created: String(fields.created ?? ""),
|
||||||
|
updated: String(fields.updated ?? ""),
|
||||||
|
url: `${config.baseUrl}/browse/${issue.key ?? ""}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRequest(config: AtlassianConfig, method: "GET" | "POST" | "PUT", path: string, body?: unknown) {
|
||||||
|
const url = new URL(path, `${config.jiraBaseUrl}/`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
method,
|
||||||
|
url: url.toString(),
|
||||||
|
...(body === undefined ? {} : { body }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createJiraClient(options: JiraClientOptions) {
|
||||||
|
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
||||||
|
|
||||||
|
if (!fetchImpl) {
|
||||||
|
throw new Error("Fetch API is not available in this runtime");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function send(method: "GET" | "POST" | "PUT", path: string, body?: unknown) {
|
||||||
|
const request = createRequest(options.config, method, path, body);
|
||||||
|
return sendJsonRequest({
|
||||||
|
config: options.config,
|
||||||
|
fetchImpl,
|
||||||
|
url: request.url,
|
||||||
|
method,
|
||||||
|
body,
|
||||||
|
errorPrefix: "Jira request failed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async searchIssues(input: SearchInput): Promise<CommandOutput<unknown>> {
|
||||||
|
const raw = (await send("POST", "/rest/api/3/search", {
|
||||||
|
jql: input.jql,
|
||||||
|
maxResults: input.maxResults,
|
||||||
|
startAt: input.startAt,
|
||||||
|
fields: [...ISSUE_FIELDS],
|
||||||
|
})) as Record<string, unknown>;
|
||||||
|
|
||||||
|
const issues = Array.isArray(raw.issues) ? raw.issues : [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
issues: issues.map((issue) => normalizeIssue(options.config, issue as Record<string, unknown>)),
|
||||||
|
startAt: Number(raw.startAt ?? input.startAt),
|
||||||
|
maxResults: Number(raw.maxResults ?? input.maxResults),
|
||||||
|
total: Number(raw.total ?? issues.length),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async getIssue(issue: string): Promise<CommandOutput<unknown>> {
|
||||||
|
const url = new URL(`/rest/api/3/issue/${issue}`, `${options.config.jiraBaseUrl}/`);
|
||||||
|
url.searchParams.set("fields", ISSUE_FIELDS.join(","));
|
||||||
|
|
||||||
|
const raw = (await send("GET", `${url.pathname}${url.search}`)) as Record<string, unknown>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
issue: normalizeIssue(options.config, raw),
|
||||||
|
},
|
||||||
|
raw,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async getTransitions(issue: string): Promise<CommandOutput<unknown>> {
|
||||||
|
const raw = (await send(
|
||||||
|
"GET",
|
||||||
|
`/rest/api/3/issue/${issue}/transitions`,
|
||||||
|
)) as { transitions?: Array<Record<string, unknown>> };
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
transitions: (raw.transitions ?? []).map((transition) => ({
|
||||||
|
id: String(transition.id ?? ""),
|
||||||
|
name: String(transition.name ?? ""),
|
||||||
|
toStatus: String(((transition.to ?? {}) as Record<string, unknown>).name ?? ""),
|
||||||
|
hasScreen: Boolean(transition.hasScreen),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async createIssue(input: CreateInput): Promise<CommandOutput<unknown>> {
|
||||||
|
const project = input.project || options.config.defaultProject;
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
throw new Error("jira-create requires --project or ATLASSIAN_DEFAULT_PROJECT");
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = createRequest(options.config, "POST", "/rest/api/3/issue", {
|
||||||
|
fields: {
|
||||||
|
project: { key: project },
|
||||||
|
issuetype: { name: input.type },
|
||||||
|
summary: input.summary,
|
||||||
|
...(input.description ? { description: markdownToAdf(input.description) } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (input.dryRun) return dryRunResponse(request);
|
||||||
|
|
||||||
|
const raw = await send("POST", "/rest/api/3/issue", request.body);
|
||||||
|
return { ok: true, data: raw };
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateIssue(input: UpdateInput): Promise<CommandOutput<unknown>> {
|
||||||
|
const fields: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (input.summary) {
|
||||||
|
fields.summary = input.summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.description) {
|
||||||
|
fields.description = markdownToAdf(input.description);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(fields).length === 0) {
|
||||||
|
throw new Error("jira-update requires --summary and/or --description-file");
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = createRequest(options.config, "PUT", `/rest/api/3/issue/${input.issue}`, {
|
||||||
|
fields,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (input.dryRun) return dryRunResponse(request);
|
||||||
|
|
||||||
|
await send("PUT", `/rest/api/3/issue/${input.issue}`, request.body);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
issue: input.issue,
|
||||||
|
updated: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async commentIssue(input: CommentInput): Promise<CommandOutput<unknown>> {
|
||||||
|
const request = createRequest(options.config, "POST", `/rest/api/3/issue/${input.issue}/comment`, {
|
||||||
|
body: markdownToAdf(input.body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (input.dryRun) return dryRunResponse(request);
|
||||||
|
|
||||||
|
const raw = await send("POST", `/rest/api/3/issue/${input.issue}/comment`, request.body);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
data: raw,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async transitionIssue(input: TransitionInput): Promise<CommandOutput<unknown>> {
|
||||||
|
const request = createRequest(
|
||||||
|
options.config,
|
||||||
|
"POST",
|
||||||
|
`/rest/api/3/issue/${input.issue}/transitions`,
|
||||||
|
{
|
||||||
|
transition: {
|
||||||
|
id: input.transition,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (input.dryRun) return dryRunResponse(request);
|
||||||
|
|
||||||
|
await send("POST", `/rest/api/3/issue/${input.issue}/transitions`, request.body);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
issue: input.issue,
|
||||||
|
transitioned: true,
|
||||||
|
transition: input.transition,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/atlassian/shared/scripts/ and run `pnpm run sync:pi`.
|
||||||
|
import type { CommandOutput, OutputFormat, Writer } from "./types.js";
|
||||||
|
|
||||||
|
function renderText(payload: CommandOutput<unknown>) {
|
||||||
|
const data = payload.data as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (Array.isArray(data?.issues)) {
|
||||||
|
return data.issues
|
||||||
|
.map((issue) => {
|
||||||
|
const item = issue as Record<string, string>;
|
||||||
|
return `${item.key} [${item.status}] ${item.issueType} - ${item.summary}`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data?.issue && typeof data.issue === "object") {
|
||||||
|
const issue = data.issue as Record<string, string>;
|
||||||
|
return [
|
||||||
|
issue.key,
|
||||||
|
`${issue.issueType} | ${issue.status}`,
|
||||||
|
issue.summary,
|
||||||
|
issue.url,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(data?.transitions)) {
|
||||||
|
return data.transitions
|
||||||
|
.map((transition) => {
|
||||||
|
const item = transition as Record<string, string>;
|
||||||
|
return `${item.id} ${item.name} -> ${item.toStatus}`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(payload, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeOutput(
|
||||||
|
writer: Writer,
|
||||||
|
payload: CommandOutput<unknown>,
|
||||||
|
format: OutputFormat = "json",
|
||||||
|
) {
|
||||||
|
const body = format === "text" ? renderText(payload) : JSON.stringify(payload, null, 2);
|
||||||
|
writer.write(`${body}\n`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/atlassian/shared/scripts/ and run `pnpm run sync:pi`.
|
||||||
|
import { dryRunResponse } from "./command-helpers.js";
|
||||||
|
import { readWorkspaceFile } from "./files.js";
|
||||||
|
import { sendJsonRequest } from "./http.js";
|
||||||
|
import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js";
|
||||||
|
|
||||||
|
const JIRA_ALLOWED_PREFIXES = ["/rest/api/3/"] as const;
|
||||||
|
const CONFLUENCE_ALLOWED_PREFIXES = ["/wiki/api/v2/", "/wiki/rest/api/"] as const;
|
||||||
|
|
||||||
|
type RawInput = {
|
||||||
|
product: "jira" | "confluence";
|
||||||
|
method: string;
|
||||||
|
path: string;
|
||||||
|
bodyFile?: string;
|
||||||
|
cwd: string;
|
||||||
|
dryRun?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getAllowedPrefixes(product: RawInput["product"]) {
|
||||||
|
return product === "jira" ? JIRA_ALLOWED_PREFIXES : CONFLUENCE_ALLOWED_PREFIXES;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUrl(config: AtlassianConfig, product: RawInput["product"], path: string) {
|
||||||
|
const baseUrl = product === "jira" ? config.jiraBaseUrl : config.confluenceBaseUrl;
|
||||||
|
return new URL(path, `${baseUrl}/`).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateMethod(method: string): asserts method is "GET" | "POST" | "PUT" {
|
||||||
|
if (!["GET", "POST", "PUT"].includes(method)) {
|
||||||
|
throw new Error("raw only allows GET, POST, and PUT");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatePath(product: RawInput["product"], path: string) {
|
||||||
|
const allowedPrefixes = getAllowedPrefixes(product);
|
||||||
|
|
||||||
|
if (!allowedPrefixes.some((prefix) => path.startsWith(prefix))) {
|
||||||
|
throw new Error(`raw path is not allowed for ${product}: ${path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readRawBody(bodyFile: string | undefined, cwd: string) {
|
||||||
|
if (!bodyFile) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contents = await readWorkspaceFile(bodyFile, cwd);
|
||||||
|
return JSON.parse(contents) as unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runRawCommand(
|
||||||
|
config: AtlassianConfig,
|
||||||
|
fetchImpl: FetchLike | undefined,
|
||||||
|
input: RawInput,
|
||||||
|
): Promise<CommandOutput<unknown>> {
|
||||||
|
validateMethod(input.method);
|
||||||
|
validatePath(input.product, input.path);
|
||||||
|
|
||||||
|
const body = await readRawBody(input.bodyFile, input.cwd);
|
||||||
|
const request = {
|
||||||
|
method: input.method,
|
||||||
|
url: buildUrl(config, input.product, input.path),
|
||||||
|
...(body === undefined ? {} : { body }),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (input.dryRun) return dryRunResponse(request);
|
||||||
|
|
||||||
|
const data = await sendJsonRequest({
|
||||||
|
config,
|
||||||
|
fetchImpl,
|
||||||
|
url: request.url,
|
||||||
|
method: input.method,
|
||||||
|
body,
|
||||||
|
errorPrefix: "Raw request failed",
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/atlassian/shared/scripts/ and run `pnpm run sync:pi`.
|
||||||
|
export type AtlassianConfig = {
|
||||||
|
baseUrl: string;
|
||||||
|
jiraBaseUrl: string;
|
||||||
|
confluenceBaseUrl: string;
|
||||||
|
email: string;
|
||||||
|
apiToken: string;
|
||||||
|
defaultProject?: string;
|
||||||
|
defaultSpace?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CommandOutput<T> = {
|
||||||
|
ok: boolean;
|
||||||
|
data: T;
|
||||||
|
dryRun?: boolean;
|
||||||
|
raw?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type JiraIssueSummary = {
|
||||||
|
key: string;
|
||||||
|
summary: string;
|
||||||
|
issueType: string;
|
||||||
|
status: string;
|
||||||
|
assignee?: string;
|
||||||
|
created: string;
|
||||||
|
updated: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Writer = {
|
||||||
|
write(chunk: string | Uint8Array): unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FetchLike = typeof fetch;
|
||||||
|
|
||||||
|
export type OutputFormat = "json" | "text";
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"types": ["node"],
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "scripts/**/*.ts", "tests/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ai-coding-skills.dev/schemas/generated-manifest/v1.json",
|
||||||
|
"generator": "scripts/generate-skills.mjs",
|
||||||
|
"generatedRoot": "pi-package/skills/create-plan",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "SKILL.md",
|
||||||
|
"kind": "file",
|
||||||
|
"mode": "644",
|
||||||
|
"sha256": "8767db25ce6f03e141ce4c48f37e9d7c4958c3cf3d70729f3bd7214b84f6d065"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "templates/continuation-runbook.md",
|
||||||
|
"kind": "file",
|
||||||
|
"mode": "644",
|
||||||
|
"sha256": "1685cded3d4abaf03122a490175ff03b7da593ce60cbca97ae15fadcb706617f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "templates/milestone-plan.md",
|
||||||
|
"kind": "file",
|
||||||
|
"mode": "644",
|
||||||
|
"sha256": "364c08bf0a5ee3738195a0770db72c5a4c9ad7f7fb89eaa064eb8c67f47ad69a"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "templates/story-tracker.md",
|
||||||
|
"kind": "file",
|
||||||
|
"mode": "644",
|
||||||
|
"sha256": "ecb550ea9dcd9dde6c813e90af9f538bf5a247fc249e8e323f2b7bf583e52196"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
---
|
||||||
|
name: create-plan
|
||||||
|
description: Use when a user asks to create or maintain a structured implementation plan in pi, including milestones, bite-sized stories, and resumable local planning artifacts under ai_plan.
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/create-plan/_source/pi/ and run `pnpm run sync:pi`. -->
|
||||||
|
|
||||||
|
# Create Plan (Pi)
|
||||||
|
|
||||||
|
Create and maintain a local plan workspace under `ai_plan/` at project root.
|
||||||
|
|
||||||
|
## Shared Setup
|
||||||
|
|
||||||
|
Before using this skill, read:
|
||||||
|
|
||||||
|
- [docs/PI-SUPERPOWERS.md](../../../docs/PI-SUPERPOWERS.md)
|
||||||
|
- [docs/PI-COMMON-REVIEWER.md](../../../docs/PI-COMMON-REVIEWER.md)
|
||||||
|
|
||||||
|
The workflow depends on:
|
||||||
|
|
||||||
|
- Obra Superpowers skills being visible to pi
|
||||||
|
- the pi reviewer-runtime helper being installed in a supported location
|
||||||
|
|
||||||
|
## Prerequisite Check (MANDATORY)
|
||||||
|
|
||||||
|
Required:
|
||||||
|
|
||||||
|
- `pi --version`
|
||||||
|
- Superpowers `brainstorming`
|
||||||
|
- Superpowers `writing-plans`
|
||||||
|
- pi reviewer runtime helper:
|
||||||
|
- `.pi/skills/reviewer-runtime/pi/run-review.sh`, or
|
||||||
|
- `~/.pi/agent/skills/reviewer-runtime/pi/run-review.sh`
|
||||||
|
|
||||||
|
Quick checks for common installs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pi --version
|
||||||
|
test -f ~/.agents/skills/superpowers/brainstorming/SKILL.md || test -f ~/.pi/agent/skills/superpowers/brainstorming/SKILL.md
|
||||||
|
test -f ~/.agents/skills/superpowers/writing-plans/SKILL.md || test -f ~/.pi/agent/skills/superpowers/writing-plans/SKILL.md
|
||||||
|
test -x .pi/skills/reviewer-runtime/pi/run-review.sh || test -x ~/.pi/agent/skills/reviewer-runtime/pi/run-review.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
If you use a settings-defined skill path for Superpowers, verify it matches [docs/PI-SUPERPOWERS.md](../../../docs/PI-SUPERPOWERS.md) before continuing.
|
||||||
|
|
||||||
|
If you install the reviewer helper in a nonstandard location, verify it matches [docs/PI-COMMON-REVIEWER.md](../../../docs/PI-COMMON-REVIEWER.md) before continuing.
|
||||||
|
|
||||||
|
If any dependency is missing, stop and return:
|
||||||
|
|
||||||
|
`Missing dependency: pi planning requires Superpowers brainstorming/writing-plans skills plus the reviewer setup documented in docs/PI-SUPERPOWERS.md and docs/PI-COMMON-REVIEWER.md.`
|
||||||
|
|
||||||
|
## Required Workflow Rules
|
||||||
|
|
||||||
|
- Load the relevant workflow skill before entering its phase. If pi did not auto-load it, use `/skill:brainstorming` or `/skill:writing-plans`.
|
||||||
|
- Announce skill usage explicitly:
|
||||||
|
- `I've read the [Skill Name] skill and I'm using it to [purpose].`
|
||||||
|
- Track checklist-style progress inside the plan artifacts that this skill generates.
|
||||||
|
- Do not use deprecated wrapper CLIs.
|
||||||
|
|
||||||
|
## Process
|
||||||
|
|
||||||
|
### Phase 1: Analyze
|
||||||
|
|
||||||
|
- Explore the codebase and existing patterns.
|
||||||
|
- Review any current docs, scripts, or variant layouts that affect the plan.
|
||||||
|
|
||||||
|
### Phase 2: Gather Requirements
|
||||||
|
|
||||||
|
- Ask questions one at a time until the scope is clear.
|
||||||
|
- Confirm constraints, success criteria, dependencies, and what is out of scope.
|
||||||
|
|
||||||
|
### Phase 3: Configure Reviewer
|
||||||
|
|
||||||
|
If the user already specified a reviewer CLI and model, use those values. Otherwise ask:
|
||||||
|
|
||||||
|
Reviewer CLI: `codex`, `claude`, `cursor`, `opencode`, `pi`, or `skip`
|
||||||
|
|
||||||
|
1. Which CLI should review the plan?
|
||||||
|
- `codex`
|
||||||
|
- `claude`
|
||||||
|
- `cursor`
|
||||||
|
- `opencode`
|
||||||
|
- `pi`
|
||||||
|
- `skip`
|
||||||
|
2. Which model?
|
||||||
|
3. Max review rounds? Default: `10`
|
||||||
|
|
||||||
|
Store `REVIEWER_CLI`, `REVIEWER_MODEL`, and `MAX_ROUNDS` for the review loop.
|
||||||
|
|
||||||
|
If `REVIEWER_CLI=pi`, verify the Pi reviewer binary before entering the review loop:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pi --version
|
||||||
|
```
|
||||||
|
|
||||||
|
For shorthand `pi/<pi-model-name>`, split only on the first slash when the prefix is exactly `pi`; store the complete remainder in `REVIEWER_MODEL`. Examples: `pi/claude-opus-4-7` -> `claude-opus-4-7`, `pi/anthropic/claude-opus-4-7` -> `anthropic/claude-opus-4-7`, and `pi/openrouter/anthropic/claude-opus-4-7` -> `openrouter/anthropic/claude-opus-4-7`.
|
||||||
|
|
||||||
|
When `REVIEWER_CLI=pi`, the reviewer model is configured independently from the pi model running this workflow. Use any configured pi model string, including provider-qualified model IDs. If the reviewer model or provider is unavailable, surface the review helper stderr/status and ask for a configured model; use `pi --list-models [search]` to inspect configured models.
|
||||||
|
|
||||||
|
The pi reviewer command rendered into `/tmp/plan-review-${REVIEW_ID}.sh` must be isolated and read-only:
|
||||||
|
|
||||||
|
```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."
|
||||||
|
```
|
||||||
|
|
||||||
|
The pi reviewer invocation must not load workflow skills and must not include `write`, `edit`, or `bash` tools.
|
||||||
|
|
||||||
|
### Phase 4: Design
|
||||||
|
|
||||||
|
- Load `brainstorming`.
|
||||||
|
- Present 2-3 approaches and recommend one.
|
||||||
|
- Resolve open design questions before the milestone breakdown.
|
||||||
|
|
||||||
|
### Phase 5: Plan
|
||||||
|
|
||||||
|
- Load `writing-plans`.
|
||||||
|
- Break the work into milestones and bite-sized stories.
|
||||||
|
- Story IDs should use the `S-101`, `S-102` style.
|
||||||
|
|
||||||
|
### Phase 6: Iterative Plan Review
|
||||||
|
|
||||||
|
Skip this phase if `REVIEWER_CLI=skip`.
|
||||||
|
|
||||||
|
#### Step 1: Generate Session ID
|
||||||
|
|
||||||
|
```bash
|
||||||
|
REVIEW_ID=$(uuidgen | tr '[:upper:]' '[:lower:]' | head -c 8)
|
||||||
|
```
|
||||||
|
|
||||||
|
Use these temp artifacts:
|
||||||
|
|
||||||
|
- `/tmp/plan-${REVIEW_ID}.md`
|
||||||
|
- `/tmp/plan-review-${REVIEW_ID}.md`
|
||||||
|
- `/tmp/plan-review-${REVIEW_ID}.json`
|
||||||
|
- `/tmp/plan-review-${REVIEW_ID}.stderr`
|
||||||
|
- `/tmp/plan-review-${REVIEW_ID}.status`
|
||||||
|
- `/tmp/plan-review-${REVIEW_ID}.runner.out`
|
||||||
|
- `/tmp/plan-review-${REVIEW_ID}.sh`
|
||||||
|
|
||||||
|
Resolve the pi reviewer runtime helper in this order:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
REVIEWER_RUNTIME=""
|
||||||
|
for candidate in ".pi/skills/reviewer-runtime/pi/run-review.sh" "$HOME/.pi/agent/skills/reviewer-runtime/pi/run-review.sh"; do
|
||||||
|
if [ -x "$candidate" ]; then
|
||||||
|
REVIEWER_RUNTIME="$candidate"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 2: Write The Plan Payload
|
||||||
|
|
||||||
|
Write the full plan to `/tmp/plan-${REVIEW_ID}.md`.
|
||||||
|
|
||||||
|
Reviewer responses must use this structure:
|
||||||
|
|
||||||
|
```text
|
||||||
|
## Summary
|
||||||
|
...
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
### P0
|
||||||
|
- ...
|
||||||
|
### P1
|
||||||
|
- ...
|
||||||
|
### P2
|
||||||
|
- ...
|
||||||
|
### P3
|
||||||
|
- ...
|
||||||
|
|
||||||
|
## Verdict
|
||||||
|
VERDICT: APPROVED
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Order findings from `P0` to `P3`
|
||||||
|
- Use `- None.` when a severity has no findings
|
||||||
|
- `VERDICT: APPROVED` is valid only when no `P0`, `P1`, or `P2` findings remain
|
||||||
|
|
||||||
|
#### Step 3: Submit To Reviewer
|
||||||
|
|
||||||
|
Build a bash command script in `/tmp/plan-review-${REVIEW_ID}.sh` and execute it through the shared helper when present:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
"$REVIEWER_RUNTIME" \
|
||||||
|
--command-file /tmp/plan-review-${REVIEW_ID}.sh \
|
||||||
|
--stdout-file /tmp/plan-review-${REVIEW_ID}.runner.out \
|
||||||
|
--stderr-file /tmp/plan-review-${REVIEW_ID}.stderr \
|
||||||
|
--status-file /tmp/plan-review-${REVIEW_ID}.status
|
||||||
|
```
|
||||||
|
|
||||||
|
Fallback to direct execution only if the helper is missing.
|
||||||
|
|
||||||
|
#### Step 4: Wait And Parse Verdict
|
||||||
|
|
||||||
|
- Keep waiting while fresh `state=in-progress note="In progress N"` heartbeats continue to appear.
|
||||||
|
- Treat `P0`, `P1`, or `P2` as must-fix findings.
|
||||||
|
- `P3` findings are non-blocking, but fix them when cheap and safe.
|
||||||
|
|
||||||
|
#### Step 5: Revise And Re-Submit
|
||||||
|
|
||||||
|
- Address findings in priority order.
|
||||||
|
- Rebuild the plan payload.
|
||||||
|
- Re-submit until approved or `MAX_ROUNDS` is reached.
|
||||||
|
|
||||||
|
### Phase 7: Generate Plan Files
|
||||||
|
|
||||||
|
Once the plan is approved:
|
||||||
|
|
||||||
|
1. Ensure `/ai_plan/` exists in `.gitignore`
|
||||||
|
2. Create `ai_plan/YYYY-MM-DD-<slug>/`
|
||||||
|
3. Write:
|
||||||
|
- `original-plan.md`
|
||||||
|
- `final-transcript.md`
|
||||||
|
- `milestone-plan.md`
|
||||||
|
- `story-tracker.md`
|
||||||
|
- `continuation-runbook.md`
|
||||||
|
4. Use the template files from this skill's `templates/` directory
|
||||||
|
|
||||||
|
### Phase 8: Telegram Completion Notification
|
||||||
|
|
||||||
|
Resolve the notification helper in this order:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TELEGRAM_NOTIFY_RUNTIME=""
|
||||||
|
for candidate in ".pi/skills/reviewer-runtime/pi/notify-telegram.sh" "$HOME/.pi/agent/skills/reviewer-runtime/pi/notify-telegram.sh"; do
|
||||||
|
if [ -x "$candidate" ]; then
|
||||||
|
TELEGRAM_NOTIFY_RUNTIME="$candidate"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
If the helper exists and both `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID` are configured, send a short completion summary. If not, state that no Telegram completion notification was sent.
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
<!-- ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/create-plan/_source/pi/ and run `pnpm run sync:pi`. -->
|
||||||
|
# Continuation Runbook: [Plan Title]
|
||||||
|
|
||||||
|
## Reference Files (START HERE)
|
||||||
|
|
||||||
|
Upon resumption, these files in this folder are the ONLY source of truth:
|
||||||
|
|
||||||
|
| File | Purpose | When to Use |
|
||||||
|
|------|---------|-------------|
|
||||||
|
| `continuation-runbook.md` | Full context reproduction + execution workflow | Read FIRST |
|
||||||
|
| `story-tracker.md` | Current progress and status | Check/update BEFORE and AFTER every story |
|
||||||
|
| `milestone-plan.md` | Complete plan with specifications | Reference implementation details |
|
||||||
|
| `original-plan.md` | Original approved plan | Reference original intent |
|
||||||
|
| `final-transcript.md` | Final planning transcript | Reference reasoning/context |
|
||||||
|
|
||||||
|
Do NOT reference planner-private files during implementation.
|
||||||
|
|
||||||
|
## Skill Workflow Guardrails
|
||||||
|
|
||||||
|
- Load relevant skills before action. If pi did not auto-load them, use `/skill:<name>`.
|
||||||
|
- Announce which skill is being used and why.
|
||||||
|
- If a checklist-driven workflow applies, keep its state current in the plan artifacts.
|
||||||
|
- Do not use deprecated wrapper CLIs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Resume Instructions
|
||||||
|
|
||||||
|
1. Read this runbook completely.
|
||||||
|
2. Check `story-tracker.md`.
|
||||||
|
3. Find next `pending` story and mark as `in-dev` before starting.
|
||||||
|
4. Implement the story.
|
||||||
|
5. Update tracker immediately after each change.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mandatory Execution Workflow
|
||||||
|
|
||||||
|
Work from this folder (`ai_plan/YYYY-MM-DD-<short-title>/`) and always follow this order:
|
||||||
|
|
||||||
|
1. Read `continuation-runbook.md` first.
|
||||||
|
2. Execute stories milestone by milestone.
|
||||||
|
3. After completing a milestone:
|
||||||
|
- Run lint/typecheck/tests, prioritizing changed files for speed.
|
||||||
|
- Commit locally (**DO NOT PUSH**).
|
||||||
|
- Stop and ask user for feedback.
|
||||||
|
4. If feedback is provided:
|
||||||
|
- Apply feedback changes.
|
||||||
|
- Re-run checks for changed files.
|
||||||
|
- Commit locally again.
|
||||||
|
- Ask for milestone approval.
|
||||||
|
5. Only move to next milestone after explicit approval.
|
||||||
|
6. After all milestones are completed and approved:
|
||||||
|
- Ask permission to push.
|
||||||
|
- If approved, push.
|
||||||
|
- Mark plan status as `completed`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Git Note
|
||||||
|
|
||||||
|
`ai_plan/` is intentionally local and must stay gitignored. Do not treat inability to commit plan-file updates inside `ai_plan/` as an error.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Full Context Reproduction
|
||||||
|
|
||||||
|
### Project Overview
|
||||||
|
|
||||||
|
[What this project/feature is about]
|
||||||
|
|
||||||
|
### User Requirements
|
||||||
|
|
||||||
|
[All gathered requirements]
|
||||||
|
|
||||||
|
### Scope
|
||||||
|
|
||||||
|
[In scope / out of scope]
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
[External dependencies, prerequisites, related systems]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Specifications
|
||||||
|
|
||||||
|
### Type Definitions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Copy-paste ready type definitions
|
||||||
|
```
|
||||||
|
|
||||||
|
### Enums & Constants
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// All enums/constants needed
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Endpoints
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Request/response shapes
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critical Design Decisions
|
||||||
|
|
||||||
|
| Decision | Chosen Approach | Alternatives Rejected | Rationale |
|
||||||
|
|----------|-----------------|----------------------|-----------|
|
||||||
|
| [Topic] | [What we chose] | [Other options] | [Why] |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification Commands
|
||||||
|
|
||||||
|
### Lint (changed files first)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# example: pnpm eslint <changed-file-1> <changed-file-2>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Typecheck
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# example: pnpm tsc --noEmit
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tests (target changed scope first)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# example: pnpm test -- <related spec/file>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Quick Reference
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `original-plan.md` | Original approved plan |
|
||||||
|
| `final-transcript.md` | Final planning transcript |
|
||||||
|
| `milestone-plan.md` | Full specification |
|
||||||
|
| `story-tracker.md` | Current progress tracker |
|
||||||
|
| `continuation-runbook.md` | This runbook |
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<!-- ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/create-plan/_source/pi/ and run `pnpm run sync:pi`. -->
|
||||||
|
# [Plan Title]
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
- **Goal:** [One sentence describing the end state]
|
||||||
|
- **Created:** YYYY-MM-DD
|
||||||
|
- **Status:** In Progress | Complete
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
[Gathered requirements from user questions]
|
||||||
|
|
||||||
|
### Constraints
|
||||||
|
|
||||||
|
[Technical, business, or timeline constraints]
|
||||||
|
|
||||||
|
### Success Criteria
|
||||||
|
|
||||||
|
[How we know this is complete]
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Design Decisions
|
||||||
|
|
||||||
|
[Key architectural choices and rationale]
|
||||||
|
|
||||||
|
### Component Relationships
|
||||||
|
|
||||||
|
[How pieces fit together]
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
[How data moves through the system]
|
||||||
|
|
||||||
|
## Milestones
|
||||||
|
|
||||||
|
### M1: [Name]
|
||||||
|
|
||||||
|
**Description:** [What this milestone achieves]
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
|
||||||
|
- [ ] [Criterion 1]
|
||||||
|
- [ ] [Criterion 2]
|
||||||
|
|
||||||
|
**Stories:** S-101, S-102, S-103...
|
||||||
|
|
||||||
|
**Milestone Completion Rule (MANDATORY):**
|
||||||
|
|
||||||
|
- Run lint/typecheck/tests for changed files.
|
||||||
|
- Commit locally (DO NOT push).
|
||||||
|
- Stop and ask user for feedback.
|
||||||
|
- Apply feedback, re-check changed files, commit again.
|
||||||
|
- Move to next milestone only after user approval.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M2: [Name]
|
||||||
|
|
||||||
|
**Description:** [What this milestone achieves]
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
|
||||||
|
- [ ] [Criterion 1]
|
||||||
|
- [ ] [Criterion 2]
|
||||||
|
|
||||||
|
**Stories:** S-201, S-202, S-203...
|
||||||
|
|
||||||
|
**Milestone Completion Rule (MANDATORY):**
|
||||||
|
|
||||||
|
- Run lint/typecheck/tests for changed files.
|
||||||
|
- Commit locally (DO NOT push).
|
||||||
|
- Stop and ask user for feedback.
|
||||||
|
- Apply feedback, re-check changed files, commit again.
|
||||||
|
- Move to next milestone only after user approval.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Specifications
|
||||||
|
|
||||||
|
### Types & Interfaces
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Key type definitions
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Contracts
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Endpoint signatures, request/response shapes
|
||||||
|
```
|
||||||
|
|
||||||
|
### Constants & Enums
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Shared constants
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files Inventory
|
||||||
|
|
||||||
|
| File | Purpose | Milestone |
|
||||||
|
|------|---------|-----------|
|
||||||
|
| `path/to/file.ts` | [What it does] | M1 |
|
||||||
|
| `path/to/other.ts` | [What it does] | M2 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related Plan Files
|
||||||
|
|
||||||
|
This file is part of the plan folder under `ai_plan/`:
|
||||||
|
|
||||||
|
- `original-plan.md` - Original approved plan (reference for original intent)
|
||||||
|
- `final-transcript.md` - Final planning transcript (reference for rationale/context)
|
||||||
|
- `milestone-plan.md` - This file (full specification)
|
||||||
|
- `story-tracker.md` - Status tracking (must be kept up to date)
|
||||||
|
- `continuation-runbook.md` - Resume/execution context (read first)
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<!-- ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/create-plan/_source/pi/ and run `pnpm run sync:pi`. -->
|
||||||
|
# Story Tracker: [Plan Title]
|
||||||
|
|
||||||
|
## Progress Summary
|
||||||
|
|
||||||
|
- **Current Milestone:** M1
|
||||||
|
- **Stories Complete:** 0/N
|
||||||
|
- **Milestones Approved:** 0/M
|
||||||
|
- **Last Updated:** YYYY-MM-DD
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Milestones
|
||||||
|
|
||||||
|
### M1: [Name]
|
||||||
|
|
||||||
|
| Story | Description | Status | Notes |
|
||||||
|
|-------|-------------|--------|-------|
|
||||||
|
| S-101 | [Brief description] | pending | |
|
||||||
|
| S-102 | [Brief description] | pending | |
|
||||||
|
| S-103 | [Brief description] | pending | |
|
||||||
|
|
||||||
|
**Approval Status:** pending
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M2: [Name]
|
||||||
|
|
||||||
|
| Story | Description | Status | Notes |
|
||||||
|
|-------|-------------|--------|-------|
|
||||||
|
| S-201 | [Brief description] | pending | |
|
||||||
|
| S-202 | [Brief description] | pending | |
|
||||||
|
| S-203 | [Brief description] | pending | |
|
||||||
|
|
||||||
|
**Approval Status:** pending
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status Legend
|
||||||
|
|
||||||
|
| Status | Meaning |
|
||||||
|
|--------|---------|
|
||||||
|
| `pending` | Not started |
|
||||||
|
| `in-dev` | Currently being worked on |
|
||||||
|
| `completed` | Done - include commit hash in Notes |
|
||||||
|
| `deferred` | Postponed - include reason in Notes |
|
||||||
|
|
||||||
|
## Update Instructions (MANDATORY)
|
||||||
|
|
||||||
|
Before starting any story:
|
||||||
|
|
||||||
|
1. Mark story as `in-dev`
|
||||||
|
2. Update "Last Updated"
|
||||||
|
|
||||||
|
After completing any story:
|
||||||
|
|
||||||
|
1. Mark story as `completed`
|
||||||
|
2. Add local commit hash to Notes
|
||||||
|
3. Update "Stories Complete" and "Last Updated"
|
||||||
|
|
||||||
|
At milestone boundary:
|
||||||
|
|
||||||
|
1. Run lint/typecheck/tests for changed files
|
||||||
|
2. Commit (no push)
|
||||||
|
3. Request feedback
|
||||||
|
4. Apply feedback, re-check changed files, commit again
|
||||||
|
5. Mark milestone **Approval Status: approved** only after user confirms
|
||||||
|
6. Continue only after approval
|
||||||
|
|
||||||
|
After all milestones approved:
|
||||||
|
|
||||||
|
- Ask permission to push and then mark plan completed.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ai-coding-skills.dev/schemas/generated-manifest/v1.json",
|
||||||
|
"generator": "scripts/generate-skills.mjs",
|
||||||
|
"generatedRoot": "pi-package/skills/do-task",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "SKILL.md",
|
||||||
|
"kind": "file",
|
||||||
|
"mode": "644",
|
||||||
|
"sha256": "4920ad0cdeda546b37432c2268159724de54ddb01922308f2df88fcca4db8d31"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "templates/task-plan.md",
|
||||||
|
"kind": "file",
|
||||||
|
"mode": "644",
|
||||||
|
"sha256": "fd38213fabf350e14b48c5209910d00c16ff74c455101618063835fa8c19e73e"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
---
|
||||||
|
name: do-task
|
||||||
|
description: Execute a single user-supplied prompt end-to-end in pi with plan review, implementation review, verification, and one persistent task-plan artifact.
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/do-task/_source/pi/ and run `pnpm run sync:pi`. -->
|
||||||
|
|
||||||
|
# Do Task (Pi)
|
||||||
|
|
||||||
|
Execute an ad-hoc user prompt end-to-end: parse, clarify, plan, implement, verify, review, commit, and optionally push.
|
||||||
|
|
||||||
|
This variant uses one persistent `task-plan.md` under `ai_plan/` and defaults to the current branch unless the prompt explicitly opts into a worktree workflow.
|
||||||
|
|
||||||
|
## Shared Setup
|
||||||
|
|
||||||
|
Before using this skill, read:
|
||||||
|
|
||||||
|
- [docs/PI-SUPERPOWERS.md](../../../docs/PI-SUPERPOWERS.md)
|
||||||
|
- [docs/PI-COMMON-REVIEWER.md](../../../docs/PI-COMMON-REVIEWER.md)
|
||||||
|
|
||||||
|
This workflow depends on:
|
||||||
|
|
||||||
|
- Superpowers skills being visible to pi
|
||||||
|
- the pi reviewer-runtime helper being installed in a supported location
|
||||||
|
|
||||||
|
## Prerequisite Check (MANDATORY)
|
||||||
|
|
||||||
|
Required:
|
||||||
|
|
||||||
|
- `pi --version`
|
||||||
|
- Superpowers `brainstorming`
|
||||||
|
- Superpowers `test-driven-development`
|
||||||
|
- Superpowers `verification-before-completion`
|
||||||
|
- Superpowers `finishing-a-development-branch`
|
||||||
|
- Superpowers `using-git-worktrees` when the prompt opts into a worktree
|
||||||
|
- pi reviewer runtime helper
|
||||||
|
- pi Telegram notifier helper
|
||||||
|
|
||||||
|
Quick checks for common installs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pi --version
|
||||||
|
test -f ~/.agents/skills/superpowers/brainstorming/SKILL.md || test -f ~/.pi/agent/skills/superpowers/brainstorming/SKILL.md
|
||||||
|
test -f ~/.agents/skills/superpowers/test-driven-development/SKILL.md || test -f ~/.pi/agent/skills/superpowers/test-driven-development/SKILL.md
|
||||||
|
test -f ~/.agents/skills/superpowers/verification-before-completion/SKILL.md || test -f ~/.pi/agent/skills/superpowers/verification-before-completion/SKILL.md
|
||||||
|
test -f ~/.agents/skills/superpowers/finishing-a-development-branch/SKILL.md || test -f ~/.pi/agent/skills/superpowers/finishing-a-development-branch/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
|
||||||
|
```
|
||||||
|
|
||||||
|
If you use a settings-defined skill path for Superpowers, confirm it matches [docs/PI-SUPERPOWERS.md](../../../docs/PI-SUPERPOWERS.md) before continuing.
|
||||||
|
|
||||||
|
If you install the reviewer helper in a nonstandard location, confirm it matches [docs/PI-COMMON-REVIEWER.md](../../../docs/PI-COMMON-REVIEWER.md) before continuing.
|
||||||
|
|
||||||
|
If any required dependency is missing, stop immediately and return:
|
||||||
|
|
||||||
|
`Missing dependency: pi do-task requires the workflow skills and reviewer setup documented in docs/PI-SUPERPOWERS.md and docs/PI-COMMON-REVIEWER.md.`
|
||||||
|
|
||||||
|
## Required Workflow Rules
|
||||||
|
|
||||||
|
- Load the relevant workflow skill before entering its phase. If pi did not auto-load it, use `/skill:<name>`.
|
||||||
|
- Announce skill usage explicitly:
|
||||||
|
- `I've read the [Skill Name] skill and I'm using it to [purpose].`
|
||||||
|
- Keep the `task-plan.md` artifact current as work progresses.
|
||||||
|
- Do not use deprecated wrapper CLIs.
|
||||||
|
|
||||||
|
## Trigger Detection
|
||||||
|
|
||||||
|
Always use this skill for:
|
||||||
|
|
||||||
|
- `/do-task`
|
||||||
|
- `do this task`
|
||||||
|
- `do task ...`
|
||||||
|
- `execute this task`
|
||||||
|
- `make it so`
|
||||||
|
- `just do ...` when another skill is not a better fit
|
||||||
|
|
||||||
|
Use current-branch execution by default. Only switch to a worktree when the prompt explicitly asks for one.
|
||||||
|
|
||||||
|
## Process
|
||||||
|
|
||||||
|
### Phase 1: Preflight
|
||||||
|
|
||||||
|
1. Verify the repo: `git rev-parse --is-inside-work-tree`
|
||||||
|
2. Ensure `/ai_plan/` exists in `.gitignore`
|
||||||
|
3. Confirm the required workflow skills are available to pi
|
||||||
|
4. Announce each workflow skill before using it
|
||||||
|
|
||||||
|
### Phase 2: Parse Prompt And Clarify
|
||||||
|
|
||||||
|
1. Capture the user's prompt verbatim
|
||||||
|
2. Detect whether the prompt is concrete enough to proceed without questions
|
||||||
|
3. If needed, ask 1-3 short questions one at a time
|
||||||
|
4. Load `brainstorming` for behavior-changing work unless the task is pure documentation or pure comment/whitespace/rename work
|
||||||
|
|
||||||
|
### Phase 3: Configure Reviewer
|
||||||
|
|
||||||
|
If the user already specified reviewer settings, use them. Otherwise ask:
|
||||||
|
|
||||||
|
Reviewer CLI: `codex`, `claude`, `cursor`, `opencode`, `pi`, or `skip`
|
||||||
|
|
||||||
|
1. Which CLI should review the plan and implementation?
|
||||||
|
2. Reviewer model
|
||||||
|
3. Max rounds, default `10`
|
||||||
|
|
||||||
|
Store `REVIEWER_CLI`, `REVIEWER_MODEL`, and `MAX_ROUNDS`.
|
||||||
|
|
||||||
|
If `REVIEWER_CLI=pi`, verify the Pi reviewer binary before entering the review loop:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pi --version
|
||||||
|
```
|
||||||
|
|
||||||
|
For shorthand `pi/<pi-model-name>`, split only on the first slash when the prefix is exactly `pi`; store the complete remainder in `REVIEWER_MODEL`. Examples: `pi/claude-opus-4-7` -> `claude-opus-4-7`, `pi/anthropic/claude-opus-4-7` -> `anthropic/claude-opus-4-7`, and `pi/openrouter/anthropic/claude-opus-4-7` -> `openrouter/anthropic/claude-opus-4-7`.
|
||||||
|
|
||||||
|
When `REVIEWER_CLI=pi`, the reviewer model is configured independently from the pi model running this workflow. Use any configured pi model string, including provider-qualified model IDs. If the reviewer model or provider is unavailable, surface the review helper stderr/status and ask for a configured model; use `pi --list-models [search]` to inspect configured models.
|
||||||
|
|
||||||
|
The pi reviewer command rendered into `/tmp/do-task-${REVIEW_KIND}-review-${REVIEW_ID}.sh` must be isolated and read-only:
|
||||||
|
|
||||||
|
```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."
|
||||||
|
```
|
||||||
|
|
||||||
|
The pi reviewer invocation must not load workflow skills and must not include `write`, `edit`, or `bash` tools.
|
||||||
|
|
||||||
|
### Phase 4: Initialize `task-plan.md`
|
||||||
|
|
||||||
|
1. Compute `ai_plan/YYYY-MM-DD-<slug>/`
|
||||||
|
2. Resume if an existing plan folder is active, otherwise create a new one
|
||||||
|
3. Write `task-plan.md` from this skill's `templates/task-plan.md`
|
||||||
|
4. Fill `Metadata`, `Prompt`, `Interpretation`, `Assumptions`, `Files`, `Approach`, `TDD Approach`, `Acceptance Criteria`, `Verification`, and `Rollback`
|
||||||
|
5. Set `Status: draft`
|
||||||
|
|
||||||
|
If the prompt explicitly opts into a worktree, load `using-git-worktrees` before implementation. Otherwise remain on the current branch.
|
||||||
|
|
||||||
|
### Phase 5: Plan Review Loop
|
||||||
|
|
||||||
|
Skip this phase if `REVIEWER_CLI=skip`.
|
||||||
|
|
||||||
|
1. Write a reviewer payload from `task-plan.md`
|
||||||
|
2. Strip the runtime-only sections before sending it out
|
||||||
|
3. Run the reviewer through the pi reviewer-runtime helper when available
|
||||||
|
4. Fix `P0`, `P1`, and `P2` findings before proceeding
|
||||||
|
5. Keep `P3` findings for optional cleanup
|
||||||
|
6. Set `Status: plan-approved` when the reviewer approves
|
||||||
|
|
||||||
|
The reviewer response format must be:
|
||||||
|
|
||||||
|
```text
|
||||||
|
## Summary
|
||||||
|
...
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
### P0
|
||||||
|
- ...
|
||||||
|
### P1
|
||||||
|
- ...
|
||||||
|
### P2
|
||||||
|
- ...
|
||||||
|
### P3
|
||||||
|
- ...
|
||||||
|
|
||||||
|
## Verdict
|
||||||
|
VERDICT: APPROVED
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 6: Execute
|
||||||
|
|
||||||
|
1. Set `Status: implementation-in-progress`
|
||||||
|
2. Load `test-driven-development` for every behavior-changing edit unless `task-plan.md` explicitly records an allowed skip
|
||||||
|
3. Update `task-plan.md` as acceptance criteria are completed
|
||||||
|
4. Do not commit yet
|
||||||
|
|
||||||
|
### Phase 7: Verification Gate
|
||||||
|
|
||||||
|
1. Load `verification-before-completion`
|
||||||
|
2. Run the commands listed in `task-plan.md`
|
||||||
|
3. Fix failures and re-run verification until green
|
||||||
|
4. If verification stalls repeatedly, stop and surface the blocker
|
||||||
|
|
||||||
|
### Phase 8: Implementation Review Loop
|
||||||
|
|
||||||
|
Skip this phase if `REVIEWER_CLI=skip`.
|
||||||
|
|
||||||
|
1. Build a review payload from the approved plan, current diff, and verification output
|
||||||
|
2. Run the reviewer through the pi reviewer-runtime helper
|
||||||
|
3. Address `P0`, `P1`, and `P2` findings before approval
|
||||||
|
4. Fix cheap `P3` findings when safe
|
||||||
|
5. Set `Status: implementation-approved` when approved
|
||||||
|
|
||||||
|
### Phase 9: Commit And Push Decision
|
||||||
|
|
||||||
|
1. Load `finishing-a-development-branch`
|
||||||
|
2. Stage only the intended files
|
||||||
|
3. Create one commit for the task
|
||||||
|
4. Ask whether to push or keep the work local
|
||||||
|
|
||||||
|
### Phase 10: Telegram Completion Notification
|
||||||
|
|
||||||
|
Resolve the helper in this order:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TELEGRAM_NOTIFY_RUNTIME=""
|
||||||
|
for candidate in ".pi/skills/reviewer-runtime/pi/notify-telegram.sh" "$HOME/.pi/agent/skills/reviewer-runtime/pi/notify-telegram.sh"; do
|
||||||
|
if [ -x "$candidate" ]; then
|
||||||
|
TELEGRAM_NOTIFY_RUNTIME="$candidate"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
If the helper exists and both `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID` are configured, send a short completion summary. Otherwise state that no Telegram completion notification was sent.
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
<!-- ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/do-task/_source/pi/ and run `pnpm run sync:pi`. -->
|
||||||
|
# Task Plan: [Short Title]
|
||||||
|
|
||||||
|
> **Variant guardrail (pi):** Required workflow skills (`brainstorming`, `test-driven-development`, `verification-before-completion`, `finishing-a-development-branch`, `using-git-worktrees`) must be available to pi as documented in `docs/PI-SUPERPOWERS.md`. Load the relevant workflow skill before entering its matching phase.
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| Created | YYYY-MM-DD |
|
||||||
|
| Slug | YYYY-MM-DD-<slug> |
|
||||||
|
| Runtime | pi |
|
||||||
|
| Reviewer CLI | codex \| claude \| cursor \| opencode \| pi |
|
||||||
|
| Reviewer Model | <model> |
|
||||||
|
| MAX_ROUNDS | 10 |
|
||||||
|
| Branch Strategy | current-branch \| worktree |
|
||||||
|
| Branch Name | <current branch name, or new branch name when worktree is used> |
|
||||||
|
| Worktree Path | <absolute path to worktree dir; blank when Branch Strategy = current-branch> |
|
||||||
|
| Status | draft |
|
||||||
|
|
||||||
|
### Status Enum (authoritative)
|
||||||
|
|
||||||
|
| Value | Meaning |
|
||||||
|
|-------|---------|
|
||||||
|
| `draft` | Newly created; plan review not yet started |
|
||||||
|
| `plan-approved` | Plan review loop returned APPROVED |
|
||||||
|
| `implementation-in-progress` | Phase 6 executing |
|
||||||
|
| `implementation-approved` | Phase 8 review loop returned APPROVED; awaiting commit |
|
||||||
|
| `pushed` | Committed + pushed to remote |
|
||||||
|
| `local-only` | Committed locally; user declined push |
|
||||||
|
| `aborted-plan-review` | MAX_ROUNDS reached in Phase 5; user aborted |
|
||||||
|
| `aborted-impl-review` | MAX_ROUNDS reached in Phase 8; user aborted |
|
||||||
|
| `aborted-verification` | Phase 7 retries exhausted; user aborted |
|
||||||
|
| `failed` | Hard tooling failure |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
<!-- Exact user prompt, verbatim. -->
|
||||||
|
|
||||||
|
## Interpretation
|
||||||
|
|
||||||
|
<!-- Short restatement of goal + out-of-scope items. -->
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
<!-- Anything we're assuming and needs confirmation. Empty list OK after clarifying questions. -->
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
<!-- Files expected to be created / modified / deleted. Paths are absolute or repo-relative. -->
|
||||||
|
|
||||||
|
| Action | Path | Why |
|
||||||
|
|--------|------|-----|
|
||||||
|
| | | |
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
<!-- 3-10 bullets describing implementation order. -->
|
||||||
|
|
||||||
|
## TDD Approach
|
||||||
|
|
||||||
|
<!-- One of:
|
||||||
|
(a) **TDD applies** — list the failing test(s) to write first, then implementation, then confirm green.
|
||||||
|
(b) **TDD auto-skipped** — reason must be exactly one of:
|
||||||
|
- `pure-documentation`
|
||||||
|
- `pure-comment-whitespace-rename`
|
||||||
|
(c) **TDD user-approved skip** — user explicitly approved skipping TDD for this task.
|
||||||
|
Record the approval timestamp (ISO-8601) and the specific reason.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] <criterion 1>
|
||||||
|
- [ ] <criterion 2>
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
<!-- Commands to run:
|
||||||
|
lint: <cmd>
|
||||||
|
typecheck: <cmd>
|
||||||
|
tests: <cmd>
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
<!-- How to undo: `git revert <hash>`, or manual steps if the change is not easily reversible. -->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Runtime State
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
plan_review_round: 0
|
||||||
|
implementation_review_round: 0
|
||||||
|
CODEX_PLAN_SESSION_ID:
|
||||||
|
CODEX_IMPL_SESSION_ID:
|
||||||
|
CURSOR_PLAN_SESSION_ID:
|
||||||
|
CURSOR_IMPL_SESSION_ID:
|
||||||
|
OPENCODE_PLAN_SESSION_ID:
|
||||||
|
OPENCODE_IMPL_SESSION_ID:
|
||||||
|
last_phase_entered:
|
||||||
|
last_round_ts:
|
||||||
|
last_scan_outcome_plan:
|
||||||
|
last_scan_outcome_impl:
|
||||||
|
verification_attempts: 0
|
||||||
|
tests_added_count: 0
|
||||||
|
tdd_used: false
|
||||||
|
```
|
||||||
|
|
||||||
|
## Review History
|
||||||
|
|
||||||
|
| Timestamp (ISO-8601) | Loop | Round | Verdict | Summary |
|
||||||
|
|----------------------|------|-------|---------|---------|
|
||||||
|
| | | | | |
|
||||||
|
|
||||||
|
## Final Status
|
||||||
|
|
||||||
|
<!-- Populate the terminal status, commit hash if any, rounds used, TDD usage, tests added, verification attempts, and any revisit notes. -->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Guardrails (do NOT remove)
|
||||||
|
|
||||||
|
- This file is the single persistent artifact for `do-task`. Do not split it or delete it on success.
|
||||||
|
- `Status` must always match one of the enum values.
|
||||||
|
- `Runtime State` is updated by the skill, not by the user.
|
||||||
|
- Review History is append-only.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ai-coding-skills.dev/schemas/generated-manifest/v1.json",
|
||||||
|
"generator": "scripts/generate-skills.mjs",
|
||||||
|
"generatedRoot": "pi-package/skills/implement-plan",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "SKILL.md",
|
||||||
|
"kind": "file",
|
||||||
|
"mode": "644",
|
||||||
|
"sha256": "578e5b4fbc443ade486e4aa034b0875ebfa7eefd3a1268852ac1c5cbf71f28bf"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
---
|
||||||
|
name: implement-plan
|
||||||
|
description: Use when a plan folder created by create-plan must be executed in pi with milestone verification, reviewer gates, local commits, and resumable tracker updates.
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/implement-plan/_source/pi/ and run `pnpm run sync:pi`. -->
|
||||||
|
|
||||||
|
# Implement Plan (Pi)
|
||||||
|
|
||||||
|
Execute an existing plan under `ai_plan/` milestone by milestone, using verification gates, reviewer approval, and local commits after each approved milestone.
|
||||||
|
|
||||||
|
## Shared Setup
|
||||||
|
|
||||||
|
Before using this skill, read:
|
||||||
|
|
||||||
|
- [docs/PI-SUPERPOWERS.md](../../../docs/PI-SUPERPOWERS.md)
|
||||||
|
- [docs/PI-COMMON-REVIEWER.md](../../../docs/PI-COMMON-REVIEWER.md)
|
||||||
|
|
||||||
|
This workflow depends on:
|
||||||
|
|
||||||
|
- Superpowers execution skills being visible to pi
|
||||||
|
- the pi reviewer-runtime helper being installed in a supported location
|
||||||
|
|
||||||
|
## Prerequisite Check (MANDATORY)
|
||||||
|
|
||||||
|
Required:
|
||||||
|
|
||||||
|
- `pi --version`
|
||||||
|
- a plan folder under `ai_plan/`
|
||||||
|
- `continuation-runbook.md`
|
||||||
|
- `milestone-plan.md`
|
||||||
|
- `story-tracker.md`
|
||||||
|
- git worktree support
|
||||||
|
- Superpowers `executing-plans`
|
||||||
|
- Superpowers `using-git-worktrees`
|
||||||
|
- Superpowers `verification-before-completion`
|
||||||
|
- Superpowers `finishing-a-development-branch`
|
||||||
|
- pi reviewer runtime helper
|
||||||
|
- pi Telegram notifier helper
|
||||||
|
|
||||||
|
Quick checks for common installs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pi --version
|
||||||
|
git worktree list
|
||||||
|
test -f ~/.agents/skills/superpowers/executing-plans/SKILL.md || test -f ~/.pi/agent/skills/superpowers/executing-plans/SKILL.md
|
||||||
|
test -f ~/.agents/skills/superpowers/using-git-worktrees/SKILL.md || test -f ~/.pi/agent/skills/superpowers/using-git-worktrees/SKILL.md
|
||||||
|
test -f ~/.agents/skills/superpowers/verification-before-completion/SKILL.md || test -f ~/.pi/agent/skills/superpowers/verification-before-completion/SKILL.md
|
||||||
|
test -f ~/.agents/skills/superpowers/finishing-a-development-branch/SKILL.md || test -f ~/.pi/agent/skills/superpowers/finishing-a-development-branch/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
|
||||||
|
```
|
||||||
|
|
||||||
|
If you use a settings-defined skill path for Superpowers, confirm it matches [docs/PI-SUPERPOWERS.md](../../../docs/PI-SUPERPOWERS.md) before continuing.
|
||||||
|
|
||||||
|
If you install the reviewer helper in a nonstandard location, confirm it matches [docs/PI-COMMON-REVIEWER.md](../../../docs/PI-COMMON-REVIEWER.md) before continuing.
|
||||||
|
|
||||||
|
If any dependency is missing, stop and return:
|
||||||
|
|
||||||
|
`Missing dependency: pi implement-plan requires the execution skills and reviewer setup documented in docs/PI-SUPERPOWERS.md and docs/PI-COMMON-REVIEWER.md.`
|
||||||
|
|
||||||
|
## Required Workflow Rules
|
||||||
|
|
||||||
|
- Load the relevant workflow skill before entering its phase. If pi did not auto-load it, use `/skill:<name>`.
|
||||||
|
- Announce skill usage explicitly:
|
||||||
|
- `I've read the [Skill Name] skill and I'm using it to [purpose].`
|
||||||
|
- Update `story-tracker.md` before starting and after completing every story.
|
||||||
|
- Do not use deprecated wrapper CLIs.
|
||||||
|
|
||||||
|
## Process
|
||||||
|
|
||||||
|
### Phase 1: Locate Plan
|
||||||
|
|
||||||
|
1. Scan `ai_plan/` and identify the target plan folder
|
||||||
|
2. Read `continuation-runbook.md` first
|
||||||
|
3. Read `story-tracker.md` to identify resume state
|
||||||
|
4. Read `milestone-plan.md` for the implementation spec
|
||||||
|
|
||||||
|
### Phase 2: Configure Reviewer
|
||||||
|
|
||||||
|
If the user already provided reviewer settings, use them. Otherwise ask:
|
||||||
|
|
||||||
|
Reviewer CLI: `codex`, `claude`, `cursor`, `opencode`, `pi`, or `skip`
|
||||||
|
|
||||||
|
1. Which CLI should review milestone implementations?
|
||||||
|
2. Reviewer model
|
||||||
|
3. Max rounds, default `10`
|
||||||
|
|
||||||
|
Store `REVIEWER_CLI`, `REVIEWER_MODEL`, and `MAX_ROUNDS`.
|
||||||
|
|
||||||
|
If `REVIEWER_CLI=pi`, verify the Pi reviewer binary before entering the review loop:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pi --version
|
||||||
|
```
|
||||||
|
|
||||||
|
For shorthand `pi/<pi-model-name>`, split only on the first slash when the prefix is exactly `pi`; store the complete remainder in `REVIEWER_MODEL`. Examples: `pi/claude-opus-4-7` -> `claude-opus-4-7`, `pi/anthropic/claude-opus-4-7` -> `anthropic/claude-opus-4-7`, and `pi/openrouter/anthropic/claude-opus-4-7` -> `openrouter/anthropic/claude-opus-4-7`.
|
||||||
|
|
||||||
|
When `REVIEWER_CLI=pi`, the reviewer model is configured independently from the pi model running this workflow. Use any configured pi model string, including provider-qualified model IDs. If the reviewer model or provider is unavailable, surface the review helper stderr/status and ask for a configured model; use `pi --list-models [search]` to inspect configured models.
|
||||||
|
|
||||||
|
The pi reviewer command rendered into `/tmp/milestone-review-${REVIEW_ID}.sh` must be isolated and read-only:
|
||||||
|
|
||||||
|
```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."
|
||||||
|
```
|
||||||
|
|
||||||
|
The pi reviewer invocation must not load workflow skills and must not include `write`, `edit`, or `bash` tools.
|
||||||
|
|
||||||
|
### Phase 3: Set Up Workspace
|
||||||
|
|
||||||
|
1. Load `using-git-worktrees`
|
||||||
|
2. Create or resume the implementation branch/worktree described by the plan
|
||||||
|
3. Verify baseline setup and tests before changing code
|
||||||
|
|
||||||
|
### Phase 4: Execute Milestones
|
||||||
|
|
||||||
|
For each milestone:
|
||||||
|
|
||||||
|
1. Mark the next story `in-dev` in `story-tracker.md`
|
||||||
|
2. Implement the story
|
||||||
|
3. Mark the story `completed`
|
||||||
|
4. Continue until the milestone stories are done
|
||||||
|
5. Load `verification-before-completion`
|
||||||
|
6. Run lint, typecheck, and tests for the changed scope
|
||||||
|
7. Send the milestone diff and verification output to the reviewer before committing
|
||||||
|
8. Commit only after approval
|
||||||
|
|
||||||
|
### Phase 5: Milestone Review Loop
|
||||||
|
|
||||||
|
Skip this phase if `REVIEWER_CLI=skip`.
|
||||||
|
|
||||||
|
#### Step 1: Generate Session ID
|
||||||
|
|
||||||
|
```bash
|
||||||
|
REVIEW_ID=$(uuidgen | tr '[:upper:]' '[:lower:]' | head -c 8)
|
||||||
|
```
|
||||||
|
|
||||||
|
Use these temp artifacts:
|
||||||
|
|
||||||
|
- `/tmp/milestone-${REVIEW_ID}.md`
|
||||||
|
- `/tmp/milestone-review-${REVIEW_ID}.md`
|
||||||
|
- `/tmp/milestone-review-${REVIEW_ID}.json`
|
||||||
|
- `/tmp/milestone-review-${REVIEW_ID}.stderr`
|
||||||
|
- `/tmp/milestone-review-${REVIEW_ID}.status`
|
||||||
|
- `/tmp/milestone-review-${REVIEW_ID}.runner.out`
|
||||||
|
- `/tmp/milestone-review-${REVIEW_ID}.sh`
|
||||||
|
|
||||||
|
Resolve the pi reviewer runtime helper in this order:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
REVIEWER_RUNTIME=""
|
||||||
|
for candidate in ".pi/skills/reviewer-runtime/pi/run-review.sh" "$HOME/.pi/agent/skills/reviewer-runtime/pi/run-review.sh"; do
|
||||||
|
if [ -x "$candidate" ]; then
|
||||||
|
REVIEWER_RUNTIME="$candidate"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 2: Build Review Payload
|
||||||
|
|
||||||
|
Write the milestone spec, acceptance criteria, diff, and verification output to `/tmp/milestone-${REVIEW_ID}.md`.
|
||||||
|
|
||||||
|
Reviewer responses must use this structure:
|
||||||
|
|
||||||
|
```text
|
||||||
|
## Summary
|
||||||
|
...
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
### P0
|
||||||
|
- ...
|
||||||
|
### P1
|
||||||
|
- ...
|
||||||
|
### P2
|
||||||
|
- ...
|
||||||
|
### P3
|
||||||
|
- ...
|
||||||
|
|
||||||
|
## Verdict
|
||||||
|
VERDICT: APPROVED
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Order findings from `P0` to `P3`
|
||||||
|
- Use `- None.` when a severity has no findings
|
||||||
|
- `VERDICT: APPROVED` is valid only when no `P0`, `P1`, or `P2` findings remain
|
||||||
|
|
||||||
|
#### Step 3: Run Review
|
||||||
|
|
||||||
|
Execute the reviewer command script through the helper when available:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
"$REVIEWER_RUNTIME" \
|
||||||
|
--command-file /tmp/milestone-review-${REVIEW_ID}.sh \
|
||||||
|
--stdout-file /tmp/milestone-review-${REVIEW_ID}.runner.out \
|
||||||
|
--stderr-file /tmp/milestone-review-${REVIEW_ID}.stderr \
|
||||||
|
--status-file /tmp/milestone-review-${REVIEW_ID}.status
|
||||||
|
```
|
||||||
|
|
||||||
|
Fallback to direct execution only if the helper is missing.
|
||||||
|
|
||||||
|
#### Step 4: Handle Findings
|
||||||
|
|
||||||
|
- Keep waiting while fresh `state=in-progress note="In progress N"` heartbeats continue
|
||||||
|
- Fix `P0`, `P1`, and `P2` findings before approval
|
||||||
|
- Fix cheap `P3` findings when safe
|
||||||
|
- Re-run verification after each revision
|
||||||
|
|
||||||
|
### Phase 6: Commit And Track Approval
|
||||||
|
|
||||||
|
After milestone approval:
|
||||||
|
|
||||||
|
1. Commit the milestone locally
|
||||||
|
2. Backfill the commit hash into that milestone's story notes
|
||||||
|
3. Mark the milestone `approved` in `story-tracker.md`
|
||||||
|
4. Move to the next milestone
|
||||||
|
|
||||||
|
### Phase 7: Finalization
|
||||||
|
|
||||||
|
After all milestones are approved:
|
||||||
|
|
||||||
|
1. Load `finishing-a-development-branch`
|
||||||
|
2. Run the full verification suite
|
||||||
|
3. Ask whether to push or keep the work local
|
||||||
|
4. Mark the plan completed in `story-tracker.md`
|
||||||
|
|
||||||
|
### Phase 8: Telegram Completion Notification
|
||||||
|
|
||||||
|
Resolve the helper in this order:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TELEGRAM_NOTIFY_RUNTIME=""
|
||||||
|
for candidate in ".pi/skills/reviewer-runtime/pi/notify-telegram.sh" "$HOME/.pi/agent/skills/reviewer-runtime/pi/notify-telegram.sh"; do
|
||||||
|
if [ -x "$candidate" ]; then
|
||||||
|
TELEGRAM_NOTIFY_RUNTIME="$candidate"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
If the helper exists and both `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID` are configured, send a short completion summary. Otherwise state that no Telegram completion notification was sent.
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ai-coding-skills.dev/schemas/generated-manifest/v1.json",
|
||||||
|
"generator": "scripts/generate-skills.mjs",
|
||||||
|
"generatedRoot": "pi-package/skills/web-automation",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"path": "scripts/auth.ts",
|
||||||
|
"kind": "file",
|
||||||
|
"mode": "644",
|
||||||
|
"sha256": "c0940f452437b05b95e58a9a7ab265fb50aa412bd672e82fedd6a37cbfb3d505"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/browse.ts",
|
||||||
|
"kind": "file",
|
||||||
|
"mode": "644",
|
||||||
|
"sha256": "d7e4b4c50116032e5a00f90bca27e069dfc5bbf6eeb06ec8f8edc9e5a9792ab8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/check-install.js",
|
||||||
|
"kind": "file",
|
||||||
|
"mode": "644",
|
||||||
|
"sha256": "e46ee8cbe103794bf1e9c3466bb0fbd21079ceddc60ad9521299e8bc0150e48f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/extract.js",
|
||||||
|
"kind": "file",
|
||||||
|
"mode": "644",
|
||||||
|
"sha256": "6fa2a0589de8afd6501e332e5fa263e1344187ea43a33590b431cdee59d04217"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/flow.ts",
|
||||||
|
"kind": "file",
|
||||||
|
"mode": "644",
|
||||||
|
"sha256": "94f3e7987cab253dc3c9e80656a11759fada13b3915608bff7ae08418602f366"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/lib/browser.ts",
|
||||||
|
"kind": "file",
|
||||||
|
"mode": "644",
|
||||||
|
"sha256": "879b5f883ff1f888d45ed20be05c2d9bc3d6fe5305a1972b7d49a7e6c0e24934"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/package.json",
|
||||||
|
"kind": "file",
|
||||||
|
"mode": "644",
|
||||||
|
"sha256": "1d9226da585c65106dacd874e5e6c7951f5a5b2b0f0cf5902f305a951ca4b44d"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/pnpm-lock.yaml",
|
||||||
|
"kind": "file",
|
||||||
|
"mode": "644",
|
||||||
|
"sha256": "17017e15e8b04311f5d53bdd37065b2f5a514a3119f40a0403148440ed181437"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/scan-local-app.ts",
|
||||||
|
"kind": "file",
|
||||||
|
"mode": "644",
|
||||||
|
"sha256": "9e1818c254a633e087715609152936dcb3613a0aa724d40a8a13460510691dc7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/scrape.ts",
|
||||||
|
"kind": "file",
|
||||||
|
"mode": "644",
|
||||||
|
"sha256": "a1a3d81d57d9e8ab1854ce3cb230bdd39ae1087ec50c9fe82cc58f5f2663ebeb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/test-full.ts",
|
||||||
|
"kind": "file",
|
||||||
|
"mode": "644",
|
||||||
|
"sha256": "76a647e840753621445c36894bff62e163f6a2e4d0860fa8e64d8df45fe21e08"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/test-minimal.ts",
|
||||||
|
"kind": "file",
|
||||||
|
"mode": "644",
|
||||||
|
"sha256": "59e0b2319d3f7521b2a8a4fca2d779afaa157bf2d160160fdec8cb56bea30b4f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/test-profile.ts",
|
||||||
|
"kind": "file",
|
||||||
|
"mode": "644",
|
||||||
|
"sha256": "6cf0141581a9275bfa8a070a36212cef5f6417d64df3df3e614ec682008376b9"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/tsconfig.json",
|
||||||
|
"kind": "file",
|
||||||
|
"mode": "644",
|
||||||
|
"sha256": "e5f22d72266068cf410976c880511f2ec1875445256e11739a5e1de6ffedf38d"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "scripts/turndown-plugin-gfm.d.ts",
|
||||||
|
"kind": "file",
|
||||||
|
"mode": "644",
|
||||||
|
"sha256": "c5001c059b160eff18a4097a8a0a7b96689b4ebc374543c7d5bf6e40b0d8a5ac"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "SKILL.md",
|
||||||
|
"kind": "file",
|
||||||
|
"mode": "644",
|
||||||
|
"sha256": "7ff56c1c50697439875f4dd0a7f7697962c8ba2105a4f66ab7b170f5dcc762bd"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/web-automation/_source/pi/SKILL.md and run `pnpm run sync:pi`. -->
|
||||||
|
|
||||||
|
# Web Automation with CloakBrowser (Pi)
|
||||||
|
|
||||||
|
Automated web browsing and scraping for pi using the shared runtime bundle in `scripts/`.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Node.js 20+
|
||||||
|
- `pnpm`
|
||||||
|
- Network access to download the CloakBrowser binary on first use
|
||||||
|
|
||||||
|
## First-Time Setup
|
||||||
|
|
||||||
|
Global install:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.pi/agent/skills/web-automation
|
||||||
|
cp -R skills/web-automation/pi/* ~/.pi/agent/skills/web-automation/
|
||||||
|
cd ~/.pi/agent/skills/web-automation/scripts
|
||||||
|
pnpm install
|
||||||
|
npx cloakbrowser install
|
||||||
|
pnpm approve-builds
|
||||||
|
pnpm rebuild better-sqlite3 esbuild
|
||||||
|
```
|
||||||
|
|
||||||
|
Project-local install:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p .pi/skills/web-automation
|
||||||
|
cp -R skills/web-automation/pi/* .pi/skills/web-automation/
|
||||||
|
cd .pi/skills/web-automation/scripts
|
||||||
|
pnpm install
|
||||||
|
npx cloakbrowser install
|
||||||
|
pnpm approve-builds
|
||||||
|
pnpm rebuild better-sqlite3 esbuild
|
||||||
|
```
|
||||||
|
|
||||||
|
Pi can also load this repo through settings or package installs as documented in [docs/PI.md](../../../docs/PI.md).
|
||||||
|
|
||||||
|
If you installed this repo from a local checkout with `./scripts/install-pi-package.sh`, the runtime stays in the checkout mirror at `pi-package/skills/web-automation/scripts`.
|
||||||
|
|
||||||
|
## Updating CloakBrowser
|
||||||
|
|
||||||
|
Run inside the installed `scripts/` directory for the pi skill. The commands below work for both global and project-local installs as long as you run them 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 the runtime from the location that matches your install style:
|
||||||
|
|
||||||
|
- local checkout package install: `pi-package/skills/web-automation/scripts`
|
||||||
|
- project-local copied install: `.pi/skills/web-automation/scripts`
|
||||||
|
- global copied install: `~/.pi/agent/skills/web-automation/scripts`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd pi-package/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 the same commands from your installed `scripts/` directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd pi-package/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.
|
||||||
|
- Package installs use the repo's `pi-package/skills/web-automation/` mirror so the installed skill directory name matches `web-automation`.
|
||||||
@@ -0,0 +1,576 @@
|
|||||||
|
#!/usr/bin/env npx tsx
|
||||||
|
// ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/web-automation/shared/ and run `pnpm run sync:pi`.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 './lib/browser.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,152 @@
|
|||||||
|
#!/usr/bin/env npx tsx
|
||||||
|
// ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/web-automation/shared/ and run `pnpm run sync:pi`.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 parseArgs from 'minimist';
|
||||||
|
import type { BrowserContext } from 'playwright-core';
|
||||||
|
|
||||||
|
import { getProfilePath, launchBrowser, getPage } from './lib/browser.js';
|
||||||
|
|
||||||
|
// Re-export shared helpers so existing imports of browse.ts continue to work.
|
||||||
|
export { getProfilePath, launchBrowser, getPage };
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,41 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/web-automation/shared/ and run `pnpm run sync:pi`.
|
||||||
|
|
||||||
|
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));
|
||||||
|
});
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/web-automation/shared/ and run `pnpm run sync:pi`.
|
||||||
|
|
||||||
|
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,330 @@
|
|||||||
|
#!/usr/bin/env npx tsx
|
||||||
|
// ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/web-automation/shared/ and run `pnpm run sync:pi`.
|
||||||
|
|
||||||
|
import parseArgs from 'minimist';
|
||||||
|
import type { Page } from 'playwright-core';
|
||||||
|
import { launchBrowser } from './lib/browser.js';
|
||||||
|
|
||||||
|
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,76 @@
|
|||||||
|
// ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/web-automation/shared/ and run `pnpm run sync:pi`.
|
||||||
|
/**
|
||||||
|
* Shared browser-launch and profile helpers for web-automation scripts.
|
||||||
|
*
|
||||||
|
* Centralises the three reusable primitives that every command entry point
|
||||||
|
* needs:
|
||||||
|
* - getProfilePath() — resolve the persistent CloakBrowser profile dir
|
||||||
|
* - launchBrowser() — launch a CloakBrowser persistent context
|
||||||
|
* - getPage() — get a ready Page + BrowserContext pair
|
||||||
|
*
|
||||||
|
* All command entry points (auth.ts, browse.ts, flow.ts, scan-local-app.ts)
|
||||||
|
* import from here instead of duplicating these bodies.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { launchPersistentContext } from 'cloakbrowser';
|
||||||
|
import { existsSync, mkdirSync } from 'fs';
|
||||||
|
import { homedir } from 'os';
|
||||||
|
import { join } from 'path';
|
||||||
|
import type { BrowserContext, Page } from 'playwright-core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the path to the persistent CloakBrowser profile directory.
|
||||||
|
*
|
||||||
|
* Uses `CLOAKBROWSER_PROFILE_PATH` env var when set; otherwise defaults to
|
||||||
|
* `~/.cloakbrowser-profile/` and creates it if it does not exist.
|
||||||
|
*/
|
||||||
|
export function 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launch a CloakBrowser persistent context with the shared profile.
|
||||||
|
*
|
||||||
|
* Headless mode is resolved in order:
|
||||||
|
* 1. `options.headless` (explicit caller preference)
|
||||||
|
* 2. `CLOAKBROWSER_HEADLESS` env var
|
||||||
|
* 3. `true` (safe default)
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a ready `{ page, browser }` pair using the shared persistent profile.
|
||||||
|
*
|
||||||
|
* Re-uses the first existing page or opens a new one if the context is empty.
|
||||||
|
*/
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "@ai-coding-skills/web-automation-pi-mirror",
|
||||||
|
"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",
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
+1292
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,180 @@
|
|||||||
|
#!/usr/bin/env npx tsx
|
||||||
|
// ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/web-automation/shared/ and run `pnpm run sync:pi`.
|
||||||
|
|
||||||
|
import { mkdirSync, writeFileSync } from 'fs';
|
||||||
|
import { dirname, resolve } from 'path';
|
||||||
|
import type { Page } from 'playwright-core';
|
||||||
|
import { getPage } from './lib/browser.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];
|
||||||
|
}
|
||||||
|
|
||||||
|
type GotoError = { error: unknown };
|
||||||
|
|
||||||
|
async function gotoWithStatus(page: Page, url: string): Promise<NavResult> {
|
||||||
|
const response = await page
|
||||||
|
.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 })
|
||||||
|
.catch((error: unknown): GotoError => ({ error }));
|
||||||
|
|
||||||
|
if (response !== null && response !== undefined && 'error' in response) {
|
||||||
|
const gotoError = response as GotoError;
|
||||||
|
return {
|
||||||
|
requestedUrl: url,
|
||||||
|
url: page.url(),
|
||||||
|
status: null,
|
||||||
|
title: await page.title().catch(() => ''),
|
||||||
|
error: String(gotoError.error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const httpResponse = response as Awaited<ReturnType<Page['goto']>>;
|
||||||
|
return {
|
||||||
|
requestedUrl: url,
|
||||||
|
url: page.url(),
|
||||||
|
status: httpResponse ? httpResponse.status() : null,
|
||||||
|
title: await page.title().catch(() => ''),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function textOrNull(page: Page, 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: Page, 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: Page, 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,352 @@
|
|||||||
|
#!/usr/bin/env npx tsx
|
||||||
|
// ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/web-automation/shared/ and run `pnpm run sync:pi`.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,37 @@
|
|||||||
|
// ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/web-automation/shared/ and run `pnpm run sync:pi`.
|
||||||
|
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,24 @@
|
|||||||
|
// ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/web-automation/shared/ and run `pnpm run sync:pi`.
|
||||||
|
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,34 @@
|
|||||||
|
// ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/web-automation/shared/ and run `pnpm run sync:pi`.
|
||||||
|
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", "lib/**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/web-automation/shared/ and run `pnpm run sync:pi`.
|
||||||
|
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;
|
||||||
|
}
|
||||||
Generated
+3404
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
|||||||
|
# pnpm workspace — includes canonical sources + uniquely-named generated variants (M3)
|
||||||
|
#
|
||||||
|
# M3 update: every generated agent-variant package.json now carries a unique
|
||||||
|
# private name (@ai-coding-skills/<skill>-<agent>), which allows pnpm to include
|
||||||
|
# ALL generated roots alongside the canonical sources. The M1 negative-glob
|
||||||
|
# exclusions are replaced by explicit positive includes.
|
||||||
|
#
|
||||||
|
# Canonical source packages (never generated):
|
||||||
|
# - skills/atlassian/shared/scripts → atlassian-skill-scripts (source + tests)
|
||||||
|
# - skills/web-automation/shared → web-automation-scripts (source template)
|
||||||
|
#
|
||||||
|
# Generated agent-variant directories (now uniquely named, included):
|
||||||
|
# - skills/atlassian/{claude-code,codex,cursor,opencode,pi}/scripts
|
||||||
|
# - skills/web-automation/{claude-code,codex,cursor,opencode,pi}/scripts
|
||||||
|
# - pi-package/skills/atlassian/scripts (@ai-coding-skills/atlassian-pi-mirror)
|
||||||
|
# - pi-package/skills/web-automation/scripts (@ai-coding-skills/web-automation-pi-mirror)
|
||||||
|
|
||||||
|
packages:
|
||||||
|
# ── Canonical source packages ────────────────────────────────────────────
|
||||||
|
- "skills/atlassian/shared/scripts"
|
||||||
|
- "skills/web-automation/shared"
|
||||||
|
|
||||||
|
# ── Generated atlassian agent-variant packages ───────────────────────────
|
||||||
|
- "skills/atlassian/claude-code/scripts"
|
||||||
|
- "skills/atlassian/codex/scripts"
|
||||||
|
- "skills/atlassian/cursor/scripts"
|
||||||
|
- "skills/atlassian/opencode/scripts"
|
||||||
|
- "skills/atlassian/pi/scripts"
|
||||||
|
|
||||||
|
# ── Generated web-automation agent-variant packages ──────────────────────
|
||||||
|
- "skills/web-automation/claude-code/scripts"
|
||||||
|
- "skills/web-automation/codex/scripts"
|
||||||
|
- "skills/web-automation/cursor/scripts"
|
||||||
|
- "skills/web-automation/opencode/scripts"
|
||||||
|
- "skills/web-automation/pi/scripts"
|
||||||
|
|
||||||
|
# ── Generated pi-package mirrors ─────────────────────────────────────────
|
||||||
|
- "pi-package/skills/atlassian/scripts"
|
||||||
|
- "pi-package/skills/web-automation/scripts"
|
||||||
@@ -0,0 +1,616 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* generate-skills.mjs — shared-source generator for agent variants (M3, S-302)
|
||||||
|
*
|
||||||
|
* Generates every agent-variant directory (`skills/<skill>/<agent>/`) and
|
||||||
|
* `pi-package/skills/<skill>/` mirror from canonical sources. Generated files
|
||||||
|
* carry file-type-aware headers; each generated root gets a non-self-referential
|
||||||
|
* `.generated-manifest.json`.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node scripts/generate-skills.mjs # regenerate everything
|
||||||
|
* pnpm run sync:pi # same via pnpm alias
|
||||||
|
*
|
||||||
|
* Exported helpers (used by verify-generated.mjs and tests):
|
||||||
|
* detectFileType(filePath) → string
|
||||||
|
* applyHeader(content, fileType, canonicalHint) → string
|
||||||
|
* makePackageJsonContent(sourcePkg, skillName, agentName) → object
|
||||||
|
* getGeneratedRoots(repoRoot?) → string[]
|
||||||
|
* buildManifest(generatedRootAbs, generatedRootRel) → Promise<object>
|
||||||
|
* generateSkills(repoRoot, options?) → Promise<{generatedRoots: string[]}>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
lstat,
|
||||||
|
mkdir,
|
||||||
|
readdir,
|
||||||
|
readFile,
|
||||||
|
rm,
|
||||||
|
writeFile,
|
||||||
|
} from "node:fs/promises";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import path from "node:path";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
|
||||||
|
const REPO_ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..");
|
||||||
|
|
||||||
|
// ── Constants ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const MANIFEST_SCHEMA =
|
||||||
|
"https://ai-coding-skills.dev/schemas/generated-manifest/v1.json";
|
||||||
|
const MANIFEST_GENERATOR = "scripts/generate-skills.mjs";
|
||||||
|
const MANIFEST_FILENAME = ".generated-manifest.json";
|
||||||
|
|
||||||
|
const AGENTS = ["claude-code", "codex", "cursor", "opencode", "pi"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical list of all generated roots, relative to repo root.
|
||||||
|
* Verifier uses this to know which directories to walk.
|
||||||
|
*/
|
||||||
|
const GENERATED_ROOTS = [
|
||||||
|
// atlassian agent variants
|
||||||
|
"skills/atlassian/claude-code",
|
||||||
|
"skills/atlassian/codex",
|
||||||
|
"skills/atlassian/cursor",
|
||||||
|
"skills/atlassian/opencode",
|
||||||
|
"skills/atlassian/pi",
|
||||||
|
// web-automation agent variants
|
||||||
|
"skills/web-automation/claude-code",
|
||||||
|
"skills/web-automation/codex",
|
||||||
|
"skills/web-automation/cursor",
|
||||||
|
"skills/web-automation/opencode",
|
||||||
|
"skills/web-automation/pi",
|
||||||
|
// create-plan agent variants
|
||||||
|
"skills/create-plan/claude-code",
|
||||||
|
"skills/create-plan/codex",
|
||||||
|
"skills/create-plan/cursor",
|
||||||
|
"skills/create-plan/opencode",
|
||||||
|
"skills/create-plan/pi",
|
||||||
|
// do-task agent variants
|
||||||
|
"skills/do-task/claude-code",
|
||||||
|
"skills/do-task/codex",
|
||||||
|
"skills/do-task/cursor",
|
||||||
|
"skills/do-task/opencode",
|
||||||
|
"skills/do-task/pi",
|
||||||
|
// implement-plan agent variants
|
||||||
|
"skills/implement-plan/claude-code",
|
||||||
|
"skills/implement-plan/codex",
|
||||||
|
"skills/implement-plan/cursor",
|
||||||
|
"skills/implement-plan/opencode",
|
||||||
|
"skills/implement-plan/pi",
|
||||||
|
// reviewer-runtime pi variant
|
||||||
|
"skills/reviewer-runtime/pi",
|
||||||
|
// pi-package mirrors
|
||||||
|
"pi-package/skills/atlassian",
|
||||||
|
"pi-package/skills/create-plan",
|
||||||
|
"pi-package/skills/do-task",
|
||||||
|
"pi-package/skills/implement-plan",
|
||||||
|
"pi-package/skills/web-automation",
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── File-type detection ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify a file path into a header policy category.
|
||||||
|
* @param {string} filePath - Relative or absolute path to the file.
|
||||||
|
* @returns {'markdown'|'shell'|'ts'|'js'|'json'|'yaml'|'jsonc'|'unknown'}
|
||||||
|
*/
|
||||||
|
export function detectFileType(filePath) {
|
||||||
|
const base = path.basename(filePath);
|
||||||
|
const ext = path.extname(base).toLowerCase();
|
||||||
|
|
||||||
|
if (ext === ".md") return "markdown";
|
||||||
|
if (ext === ".sh") return "shell";
|
||||||
|
if (ext === ".ts") return "ts";
|
||||||
|
if (ext === ".js") return "js";
|
||||||
|
if (ext === ".jsonc") return "jsonc";
|
||||||
|
if (ext === ".json") return "json";
|
||||||
|
if (ext === ".yaml" || ext === ".yml") return "yaml";
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Header insertion ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const HEADER_MSG = (hint) =>
|
||||||
|
`⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in ${hint} and run \`pnpm run sync:pi\`.`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert a file-type-aware generated-file header into content.
|
||||||
|
*
|
||||||
|
* Policy:
|
||||||
|
* markdown → HTML comment after YAML front matter (or at top if no front matter)
|
||||||
|
* shell → # comment after shebang (never before it)
|
||||||
|
* ts/js → // comment at top
|
||||||
|
* jsonc → // comment at top
|
||||||
|
* yaml → # comment at top (pnpm-lock.yaml skipped by caller)
|
||||||
|
* json → no header (recorded in .generated-manifest.json)
|
||||||
|
* unknown → no header
|
||||||
|
*
|
||||||
|
* @param {string} content - Original file content.
|
||||||
|
* @param {string} fileType - Output of detectFileType().
|
||||||
|
* @param {string} canonicalHint - Human-readable hint to canonical source location.
|
||||||
|
* @returns {string} Content with header inserted, or original if no header applies.
|
||||||
|
*/
|
||||||
|
export function applyHeader(content, fileType, canonicalHint) {
|
||||||
|
const msg = HEADER_MSG(canonicalHint);
|
||||||
|
|
||||||
|
switch (fileType) {
|
||||||
|
case "markdown": {
|
||||||
|
// Insert HTML comment after YAML front matter closing ---, or at top
|
||||||
|
if (content.startsWith("---\n")) {
|
||||||
|
const closingIdx = content.indexOf("\n---\n", 4);
|
||||||
|
if (closingIdx !== -1) {
|
||||||
|
const after = closingIdx + 5; // length of "\n---\n"
|
||||||
|
const before = content.slice(0, after);
|
||||||
|
const rest = content.slice(after);
|
||||||
|
// Ensure the comment is on its own line, with a blank line before/after
|
||||||
|
return `${before}\n<!-- ${msg} -->\n${rest}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `<!-- ${msg} -->\n${content}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "shell": {
|
||||||
|
// Insert # comment AFTER shebang line (never before it)
|
||||||
|
const lines = content.split("\n");
|
||||||
|
if (lines[0].startsWith("#!")) {
|
||||||
|
return [lines[0], `# ${msg}`, ...lines.slice(1)].join("\n");
|
||||||
|
}
|
||||||
|
return `# ${msg}\n${content}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "ts":
|
||||||
|
case "js":
|
||||||
|
case "jsonc": {
|
||||||
|
// Insert after shebang if present (TypeScript requires #! on line 1)
|
||||||
|
const lines = content.split("\n");
|
||||||
|
if (lines[0].startsWith("#!")) {
|
||||||
|
return [lines[0], `// ${msg}`, ...lines.slice(1)].join("\n");
|
||||||
|
}
|
||||||
|
return `// ${msg}\n${content}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "yaml": {
|
||||||
|
return `# ${msg}\n${content}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "json":
|
||||||
|
case "unknown":
|
||||||
|
default:
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── package.json transformation ───────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Produce a modified package.json object with a unique scoped name and
|
||||||
|
* `"private": true` for an agent-variant generated root.
|
||||||
|
*
|
||||||
|
* @param {object} sourcePkg - Parsed source package.json object.
|
||||||
|
* @param {string} skillName - Skill identifier (e.g. "atlassian").
|
||||||
|
* @param {string} agentName - Agent identifier (e.g. "claude-code").
|
||||||
|
* @returns {object} New object (source is not mutated).
|
||||||
|
*/
|
||||||
|
export function makePackageJsonContent(sourcePkg, skillName, agentName) {
|
||||||
|
return {
|
||||||
|
...sourcePkg,
|
||||||
|
name: `@ai-coding-skills/${skillName}-${agentName}`,
|
||||||
|
private: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Generated-root list ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the authoritative list of generated roots as repo-relative paths.
|
||||||
|
* Callers (verify-generated.mjs) use this to know which directories to walk.
|
||||||
|
*
|
||||||
|
* @returns {string[]} Sorted array of relative paths.
|
||||||
|
*/
|
||||||
|
export function getGeneratedRoots() {
|
||||||
|
return [...GENERATED_ROOTS];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Manifest construction ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk a directory recursively and return all file paths (abs).
|
||||||
|
* Skips node_modules.
|
||||||
|
*/
|
||||||
|
async function walkDir(dir) {
|
||||||
|
const results = [];
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = await readdir(dir, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.name === "node_modules") continue;
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
const sub = await walkDir(full);
|
||||||
|
results.push(...sub);
|
||||||
|
} else if (entry.isFile()) {
|
||||||
|
results.push(full);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a .generated-manifest.json object for a generated root.
|
||||||
|
*
|
||||||
|
* The manifest lists every file in the generated root EXCEPT itself.
|
||||||
|
* Files are sorted by relative path for stable canonical serialization.
|
||||||
|
*
|
||||||
|
* @param {string} generatedRootAbs - Absolute path to the generated root directory.
|
||||||
|
* @param {string} generatedRootRel - Repo-relative path (e.g. "skills/create-plan/pi").
|
||||||
|
* @returns {Promise<object>} Manifest object (not yet serialized to disk).
|
||||||
|
*/
|
||||||
|
export async function buildManifest(generatedRootAbs, generatedRootRel) {
|
||||||
|
const allFiles = await walkDir(generatedRootAbs);
|
||||||
|
const entries = [];
|
||||||
|
|
||||||
|
for (const absPath of allFiles) {
|
||||||
|
const relPath = path.relative(generatedRootAbs, absPath).replace(/\\/g, "/");
|
||||||
|
|
||||||
|
// Non-self-referential: the manifest never lists itself
|
||||||
|
if (relPath === MANIFEST_FILENAME) continue;
|
||||||
|
|
||||||
|
const contentBuf = await readFile(absPath);
|
||||||
|
const sha256 = crypto.createHash("sha256").update(contentBuf).digest("hex");
|
||||||
|
|
||||||
|
const st = await lstat(absPath);
|
||||||
|
const mode = (st.mode & 0o777).toString(8).padStart(3, "0");
|
||||||
|
|
||||||
|
entries.push({
|
||||||
|
path: relPath,
|
||||||
|
kind: "file",
|
||||||
|
mode,
|
||||||
|
sha256,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.sort((a, b) => a.path.localeCompare(b.path));
|
||||||
|
|
||||||
|
return {
|
||||||
|
$schema: MANIFEST_SCHEMA,
|
||||||
|
generator: MANIFEST_GENERATOR,
|
||||||
|
generatedRoot: generatedRootRel,
|
||||||
|
files: entries,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Core generation helpers ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy a single file from source to destination, inserting a header.
|
||||||
|
* Skips header for:
|
||||||
|
* - pnpm-lock.yaml (managed by pnpm, would be stripped on next install)
|
||||||
|
* - JSON files (per header policy, no in-file header)
|
||||||
|
* - node_modules (never copied)
|
||||||
|
*/
|
||||||
|
async function copyWithHeader(srcAbs, dstAbs, canonicalHint) {
|
||||||
|
const basename = path.basename(srcAbs);
|
||||||
|
// Skip pnpm-lock.yaml header (pnpm regenerates without comment)
|
||||||
|
const skipHeader = basename === "pnpm-lock.yaml";
|
||||||
|
|
||||||
|
const raw = await readFile(srcAbs, "utf8");
|
||||||
|
const fileType = skipHeader ? "unknown" : detectFileType(srcAbs);
|
||||||
|
const content = applyHeader(raw, fileType, canonicalHint);
|
||||||
|
|
||||||
|
await mkdir(path.dirname(dstAbs), { recursive: true });
|
||||||
|
await writeFile(dstAbs, content, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively copy a directory tree, adding headers to text files.
|
||||||
|
*/
|
||||||
|
async function copyDirWithHeaders(srcDir, dstDir, canonicalHint) {
|
||||||
|
const entries = await readdir(srcDir, { withFileTypes: true });
|
||||||
|
await mkdir(dstDir, { recursive: true });
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.name === "node_modules") continue;
|
||||||
|
const src = path.join(srcDir, entry.name);
|
||||||
|
const dst = path.join(dstDir, entry.name);
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
await copyDirWithHeaders(src, dst, canonicalHint);
|
||||||
|
} else if (entry.isFile()) {
|
||||||
|
await copyWithHeader(src, dst, canonicalHint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write a .generated-manifest.json file into a generated root.
|
||||||
|
*/
|
||||||
|
async function writeManifest(generatedRootAbs, generatedRootRel) {
|
||||||
|
const manifest = await buildManifest(generatedRootAbs, generatedRootRel);
|
||||||
|
const dstPath = path.join(generatedRootAbs, MANIFEST_FILENAME);
|
||||||
|
await writeFile(dstPath, JSON.stringify(manifest, null, 2) + "\n", "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Skill-family generators ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate one agent variant for a "skills-only" skill
|
||||||
|
* (create-plan, do-task, implement-plan).
|
||||||
|
*
|
||||||
|
* Canonical source: skills/<skill>/_source/<agent>/
|
||||||
|
* Generated root: skills/<skill>/<agent>/
|
||||||
|
*
|
||||||
|
* @param {string} writeRoot - Root directory to write output into (defaults to repoRoot).
|
||||||
|
*/
|
||||||
|
async function generateSkillOnlyVariant(repoRoot, writeRoot, skillName, agentName, generatedRootRel) {
|
||||||
|
const sourceDir = path.join(repoRoot, "skills", skillName, "_source", agentName);
|
||||||
|
const targetDir = path.join(writeRoot, generatedRootRel);
|
||||||
|
const canonicalHint = `skills/${skillName}/_source/${agentName}/`;
|
||||||
|
|
||||||
|
// Clear previous generated content (preserve node_modules if any)
|
||||||
|
await clearGeneratedRoot(targetDir);
|
||||||
|
|
||||||
|
// Copy all files from source with headers
|
||||||
|
await copyDirWithHeaders(sourceDir, targetDir, canonicalHint);
|
||||||
|
|
||||||
|
// Write manifest
|
||||||
|
await writeManifest(targetDir, generatedRootRel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate one agent variant for a "scripts+skill" skill
|
||||||
|
* (atlassian, web-automation).
|
||||||
|
*
|
||||||
|
* For atlassian:
|
||||||
|
* - SKILL.md from skills/atlassian/_source/<agent>/SKILL.md
|
||||||
|
* - scripts/* from skills/atlassian/shared/scripts/
|
||||||
|
* (only src/, tsconfig.json, pnpm-lock.yaml — not tests/ or scripts/sync-*)
|
||||||
|
*
|
||||||
|
* For web-automation:
|
||||||
|
* - SKILL.md from skills/web-automation/_source/<agent>/SKILL.md
|
||||||
|
* - scripts/* from skills/web-automation/shared/
|
||||||
|
*
|
||||||
|
* @param {string} [packageAgentName] - Override the agent name used only for the
|
||||||
|
* package.json `name` field. Defaults to `agentName`. Use this to give
|
||||||
|
* pi-package mirrors a distinct name (e.g. "pi-mirror") so workspace package
|
||||||
|
* names are unique even when two roots share the same source agent.
|
||||||
|
*/
|
||||||
|
async function generateScriptsSkillVariant(
|
||||||
|
repoRoot,
|
||||||
|
writeRoot,
|
||||||
|
skillName,
|
||||||
|
agentName,
|
||||||
|
generatedRootRel,
|
||||||
|
config,
|
||||||
|
packageAgentName,
|
||||||
|
) {
|
||||||
|
const targetDir = path.join(writeRoot, generatedRootRel);
|
||||||
|
await clearGeneratedRoot(targetDir);
|
||||||
|
|
||||||
|
// 1. Copy SKILL.md from per-agent canonical source
|
||||||
|
const skillMdSrc = path.join(repoRoot, "skills", skillName, "_source", agentName, "SKILL.md");
|
||||||
|
const skillMdDst = path.join(targetDir, "SKILL.md");
|
||||||
|
const skillMdHint = `skills/${skillName}/_source/${agentName}/SKILL.md`;
|
||||||
|
await copyWithHeader(skillMdSrc, skillMdDst, skillMdHint);
|
||||||
|
|
||||||
|
// 2. Copy scripts
|
||||||
|
const scriptsTargetDir = path.join(targetDir, "scripts");
|
||||||
|
await mkdir(scriptsTargetDir, { recursive: true });
|
||||||
|
|
||||||
|
const canonicalScripts = path.join(repoRoot, config.canonicalScripts);
|
||||||
|
const scriptsHint = `${config.canonicalScripts}/`;
|
||||||
|
|
||||||
|
for (const entry of config.scriptFiles) {
|
||||||
|
const srcPath = path.join(canonicalScripts, entry);
|
||||||
|
const dstPath = path.join(scriptsTargetDir, entry); // scriptsTargetDir already uses writeRoot
|
||||||
|
|
||||||
|
// Check if source exists
|
||||||
|
let st;
|
||||||
|
try {
|
||||||
|
st = await lstat(srcPath);
|
||||||
|
} catch {
|
||||||
|
continue; // skip if file doesn't exist in canonical
|
||||||
|
}
|
||||||
|
|
||||||
|
if (st.isDirectory()) {
|
||||||
|
await copyDirWithHeaders(srcPath, dstPath, scriptsHint);
|
||||||
|
} else {
|
||||||
|
await copyWithHeader(srcPath, dstPath, scriptsHint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Generate modified package.json
|
||||||
|
const srcPkgPath = path.join(canonicalScripts, "package.json");
|
||||||
|
const srcPkg = JSON.parse(await readFile(srcPkgPath, "utf8"));
|
||||||
|
|
||||||
|
let targetPkg = makePackageJsonContent(srcPkg, skillName, packageAgentName ?? agentName);
|
||||||
|
|
||||||
|
// For atlassian variants, strip test/sync scripts (not in agent variants)
|
||||||
|
if (skillName === "atlassian") {
|
||||||
|
const agentScripts = {};
|
||||||
|
for (const [k, v] of Object.entries(targetPkg.scripts ?? {})) {
|
||||||
|
if (k !== "test" && k !== "sync:agents") agentScripts[k] = v;
|
||||||
|
}
|
||||||
|
targetPkg = { ...targetPkg, scripts: agentScripts };
|
||||||
|
}
|
||||||
|
|
||||||
|
const dstPkgPath = path.join(scriptsTargetDir, "package.json");
|
||||||
|
await writeFile(dstPkgPath, JSON.stringify(targetPkg, null, 2) + "\n", "utf8");
|
||||||
|
|
||||||
|
// 4. Write manifest
|
||||||
|
await writeManifest(targetDir, generatedRootRel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate the reviewer-runtime pi variant from the non-Pi canonical scripts.
|
||||||
|
*
|
||||||
|
* Canonical source: skills/reviewer-runtime/{run-review.sh,notify-telegram.sh}
|
||||||
|
* Generated root: skills/reviewer-runtime/pi/
|
||||||
|
*
|
||||||
|
* The pi variant is byte-identical to the canonical except for:
|
||||||
|
* - A generated # comment after the shebang (replaces old "keep in sync" comment)
|
||||||
|
*
|
||||||
|
* @param {string} writeRoot - Root directory to write output into (defaults to repoRoot).
|
||||||
|
*/
|
||||||
|
async function generateReviewerRuntimePi(repoRoot, writeRoot) {
|
||||||
|
const srcDir = path.join(repoRoot, "skills", "reviewer-runtime");
|
||||||
|
const dstDir = path.join(writeRoot, "skills", "reviewer-runtime", "pi");
|
||||||
|
const canonicalHint = "skills/reviewer-runtime/";
|
||||||
|
|
||||||
|
// Clear old generated content (preserve tests/ which is canonical)
|
||||||
|
await clearGeneratedRoot(dstDir);
|
||||||
|
|
||||||
|
for (const fname of ["run-review.sh", "notify-telegram.sh"]) {
|
||||||
|
const srcPath = path.join(srcDir, fname);
|
||||||
|
const dstPath = path.join(dstDir, fname);
|
||||||
|
|
||||||
|
const raw = await readFile(srcPath, "utf8");
|
||||||
|
let content = applyHeader(raw, "shell", `${canonicalHint}${fname}`);
|
||||||
|
|
||||||
|
await mkdir(path.dirname(dstPath), { recursive: true });
|
||||||
|
await writeFile(dstPath, content, "utf8");
|
||||||
|
|
||||||
|
// Preserve executable bit
|
||||||
|
const st = await lstat(srcPath);
|
||||||
|
if (st.mode & 0o100) {
|
||||||
|
const { chmod } = await import("node:fs/promises");
|
||||||
|
await chmod(dstPath, 0o755);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write manifest (generatedRootRel is always relative to repo root, not writeRoot)
|
||||||
|
await writeManifest(dstDir, "skills/reviewer-runtime/pi");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear generated content in a root, preserving:
|
||||||
|
* - node_modules (installed by pnpm) — at any depth
|
||||||
|
* - .generated-manifest.json (will be rewritten after generation)
|
||||||
|
*
|
||||||
|
* Subdirectories are always recursed into before removal so that
|
||||||
|
* node_modules trees nested at any depth (e.g. scripts/node_modules inside
|
||||||
|
* atlassian or web-automation variants) are preserved.
|
||||||
|
*/
|
||||||
|
async function clearGeneratedRoot(rootDir) {
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = await readdir(rootDir, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return; // dir doesn't exist yet — nothing to clear
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.name === "node_modules") continue;
|
||||||
|
if (entry.name === MANIFEST_FILENAME) continue;
|
||||||
|
const fullPath = path.join(rootDir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
// Always recurse so node_modules at any depth is preserved.
|
||||||
|
await clearGeneratedRoot(fullPath);
|
||||||
|
// Remove the directory only if nothing protected remains inside it.
|
||||||
|
const remaining = await readdir(fullPath).catch(() => []);
|
||||||
|
if (remaining.length === 0) {
|
||||||
|
await rm(fullPath, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await rm(fullPath, { force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Skill configurations ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const SCRIPTS_SKILL_CONFIGS = {
|
||||||
|
atlassian: {
|
||||||
|
canonicalScripts: "skills/atlassian/shared/scripts",
|
||||||
|
// Files to copy from canonicalScripts into each agent's scripts/ dir
|
||||||
|
scriptFiles: ["src", "tsconfig.json", "pnpm-lock.yaml"],
|
||||||
|
},
|
||||||
|
"web-automation": {
|
||||||
|
canonicalScripts: "skills/web-automation/shared",
|
||||||
|
scriptFiles: [
|
||||||
|
"auth.ts",
|
||||||
|
"browse.ts",
|
||||||
|
"check-install.js",
|
||||||
|
"extract.js",
|
||||||
|
"flow.ts",
|
||||||
|
"lib",
|
||||||
|
"scan-local-app.ts",
|
||||||
|
"scrape.ts",
|
||||||
|
"test-full.ts",
|
||||||
|
"test-minimal.ts",
|
||||||
|
"test-profile.ts",
|
||||||
|
"tsconfig.json",
|
||||||
|
"turndown-plugin-gfm.d.ts",
|
||||||
|
"pnpm-lock.yaml",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Main generator ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regenerate all agent variants from canonical sources.
|
||||||
|
*
|
||||||
|
* @param {string} repoRoot - Absolute path to repo root (canonical sources are read from here).
|
||||||
|
* @param {object} [options]
|
||||||
|
* @param {boolean} [options.dryRun] - If true, don't write files (future use).
|
||||||
|
* @param {string} [options.targetRoot] - Write generated output here instead of `repoRoot`.
|
||||||
|
* Canonical sources are always read from `repoRoot`. Use this to generate into a
|
||||||
|
* temp directory for drift detection without modifying on-disk files.
|
||||||
|
* @returns {Promise<{generatedRoots: string[]}>}
|
||||||
|
*/
|
||||||
|
export async function generateSkills(repoRoot = REPO_ROOT, options = {}) {
|
||||||
|
const { dryRun = false, targetRoot } = options;
|
||||||
|
const writeRoot = targetRoot ?? repoRoot;
|
||||||
|
if (dryRun) {
|
||||||
|
return { generatedRoots: GENERATED_ROOTS };
|
||||||
|
}
|
||||||
|
|
||||||
|
const skillOnlySkills = ["create-plan", "do-task", "implement-plan"];
|
||||||
|
|
||||||
|
// 1. Generate skill-only skills (create-plan, do-task, implement-plan)
|
||||||
|
for (const skillName of skillOnlySkills) {
|
||||||
|
for (const agentName of AGENTS) {
|
||||||
|
const rootRel = `skills/${skillName}/${agentName}`;
|
||||||
|
await generateSkillOnlyVariant(repoRoot, writeRoot, skillName, agentName, rootRel);
|
||||||
|
}
|
||||||
|
// pi-package mirror (same source as pi variant)
|
||||||
|
const piPackageRel = `pi-package/skills/${skillName}`;
|
||||||
|
await generateSkillOnlyVariant(repoRoot, writeRoot, skillName, "pi", piPackageRel);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Generate scripts skills (atlassian, web-automation)
|
||||||
|
for (const [skillName, config] of Object.entries(SCRIPTS_SKILL_CONFIGS)) {
|
||||||
|
for (const agentName of AGENTS) {
|
||||||
|
const rootRel = `skills/${skillName}/${agentName}`;
|
||||||
|
await generateScriptsSkillVariant(repoRoot, writeRoot, skillName, agentName, rootRel, config);
|
||||||
|
}
|
||||||
|
// pi-package mirror: same source as pi variant but a distinct package name
|
||||||
|
// ("pi-mirror" suffix) so the workspace has no duplicate package names.
|
||||||
|
const piPackageRel = `pi-package/skills/${skillName}`;
|
||||||
|
await generateScriptsSkillVariant(repoRoot, writeRoot, skillName, "pi", piPackageRel, config, "pi-mirror");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Generate reviewer-runtime pi variant
|
||||||
|
await generateReviewerRuntimePi(repoRoot, writeRoot);
|
||||||
|
|
||||||
|
return { generatedRoots: GENERATED_ROOTS };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CLI entry point ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||||
|
try {
|
||||||
|
const result = await generateSkills(REPO_ROOT);
|
||||||
|
console.log(
|
||||||
|
`Generated ${result.generatedRoots.length} roots from canonical sources.`,
|
||||||
|
);
|
||||||
|
process.exit(0);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("generate-skills: fatal error:", err.message ?? err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+105
@@ -0,0 +1,105 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage:
|
||||||
|
./scripts/install-pi-package.sh --global
|
||||||
|
./scripts/install-pi-package.sh --local
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--global Install the repo path into ~/.pi/agent/settings.json
|
||||||
|
--local Install the repo path into .pi/settings.json for the current project
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
require_command() {
|
||||||
|
local command_name=$1
|
||||||
|
if ! command -v "$command_name" >/dev/null 2>&1; then
|
||||||
|
echo "Missing required command: $command_name" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
pnpm_cmd=()
|
||||||
|
|
||||||
|
resolve_pnpm() {
|
||||||
|
if command -v pnpm >/dev/null 2>&1; then
|
||||||
|
pnpm_cmd=(pnpm)
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v corepack >/dev/null 2>&1; then
|
||||||
|
pnpm_cmd=(corepack pnpm)
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Missing required command: pnpm (or corepack)" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
run_pnpm() {
|
||||||
|
"${pnpm_cmd[@]}" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
require_node_20() {
|
||||||
|
local node_major
|
||||||
|
node_major=$(node -p "process.versions.node.split('.')[0]")
|
||||||
|
if (( node_major < 20 )); then
|
||||||
|
echo "Node.js 20+ is required. Found Node.js $(node -v)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
install_scope=
|
||||||
|
if [[ $# -ne 1 ]]; then
|
||||||
|
usage >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
--global)
|
||||||
|
install_scope="global"
|
||||||
|
;;
|
||||||
|
--local)
|
||||||
|
install_scope="local"
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
usage >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
require_command pi
|
||||||
|
require_command node
|
||||||
|
require_node_20
|
||||||
|
resolve_pnpm
|
||||||
|
|
||||||
|
case "$install_scope" in
|
||||||
|
global)
|
||||||
|
pi install "$ROOT_DIR"
|
||||||
|
;;
|
||||||
|
local)
|
||||||
|
pi install -l "$ROOT_DIR"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "Bootstrapping Atlassian runtime dependencies..."
|
||||||
|
run_pnpm install --frozen-lockfile --dir "${ROOT_DIR}/pi-package/skills/atlassian/scripts"
|
||||||
|
|
||||||
|
echo "Bootstrapping web-automation runtime dependencies..."
|
||||||
|
run_pnpm install --frozen-lockfile --dir "${ROOT_DIR}/pi-package/skills/web-automation/scripts"
|
||||||
|
run_pnpm --dir "${ROOT_DIR}/pi-package/skills/web-automation/scripts" exec cloakbrowser install
|
||||||
|
echo "Rebuilding native web-automation dependencies..."
|
||||||
|
run_pnpm rebuild --dir "${ROOT_DIR}/pi-package/skills/web-automation/scripts" better-sqlite3 esbuild
|
||||||
|
|
||||||
|
echo "Installed Pi packages now visible to this scope:"
|
||||||
|
pi list
|
||||||
|
|
||||||
|
echo "Pi package installed (${install_scope}) and runtime dependencies bootstrapped."
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* assert-no-pnpm-version-pin.mjs — CI regression guard (followup: fix pnpm version conflict)
|
||||||
|
*
|
||||||
|
* Ensures no .github/workflows/*.yml file pins pnpm via a `version:` key
|
||||||
|
* under a `pnpm/action-setup` step. The canonical version source is
|
||||||
|
* `package.json#packageManager`, which carries an exact version + integrity
|
||||||
|
* hash. Duplicating the version in the workflow creates a conflict that
|
||||||
|
* pnpm/action-setup@v4 treats as an error.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node scripts/lib/assert-no-pnpm-version-pin.mjs
|
||||||
|
* pnpm run verify:ci
|
||||||
|
*
|
||||||
|
* Exit codes:
|
||||||
|
* 0 — no version pin found
|
||||||
|
* 1 — one or more violations found (details on stderr)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, readdirSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const REPO_ROOT = path.resolve(__dirname, "../..");
|
||||||
|
const WORKFLOWS_DIR = path.join(REPO_ROOT, ".github", "workflows");
|
||||||
|
|
||||||
|
let violations = 0;
|
||||||
|
|
||||||
|
// Read workflow files — silently pass if directory doesn't exist
|
||||||
|
let files;
|
||||||
|
try {
|
||||||
|
files = readdirSync(WORKFLOWS_DIR).filter(
|
||||||
|
(f) => f.endsWith(".yml") || f.endsWith(".yaml")
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
process.stdout.write("OK: no .github/workflows directory found; nothing to check.\n");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const fullPath = path.join(WORKFLOWS_DIR, file);
|
||||||
|
const content = readFileSync(fullPath, "utf8");
|
||||||
|
const lines = content.split("\n");
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
// Locate a step that uses pnpm/action-setup
|
||||||
|
if (!lines[i].includes("pnpm/action-setup")) continue;
|
||||||
|
|
||||||
|
// Look ahead up to 10 lines for a `version:` key in the same step
|
||||||
|
const end = Math.min(i + 10, lines.length);
|
||||||
|
for (let j = i + 1; j < end; j++) {
|
||||||
|
const ahead = lines[j];
|
||||||
|
// A new step begins at a `- name:` or `- uses:` list item → stop
|
||||||
|
if (/^\s*-\s+(name|uses)\s*:/.test(ahead)) break;
|
||||||
|
// `version:` key found inside this step → violation
|
||||||
|
if (/^\s+version\s*:/.test(ahead)) {
|
||||||
|
process.stderr.write(
|
||||||
|
`ERROR: ${file}:${j + 1}: 'version:' key found under pnpm/action-setup step.\n` +
|
||||||
|
` Remove 'with.version'; let package.json#packageManager be the single\n` +
|
||||||
|
` source of truth for the pnpm version (exact version + integrity hash).\n\n`
|
||||||
|
);
|
||||||
|
violations++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (violations > 0) {
|
||||||
|
process.stderr.write(`${violations} violation(s) found.\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdout.write("OK: no pnpm version pins found in workflow files.\n");
|
||||||
|
process.exit(0);
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# portable.sh — POSIX-safe helper functions for BSD/GNU shell portability
|
||||||
|
#
|
||||||
|
# Source this file in scripts that need cross-platform variants of:
|
||||||
|
# - stat(1) — BSD uses -f, GNU uses -c
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# source "$(dirname "${BASH_SOURCE[0]}")/portable.sh"
|
||||||
|
# portable_stat_perms "$file" # -> octal permissions string, e.g. "755"
|
||||||
|
#
|
||||||
|
# Supported platforms:
|
||||||
|
# - macOS (BSD stat)
|
||||||
|
# - Linux/Ubuntu (GNU stat)
|
||||||
|
|
||||||
|
# portable_stat_perms <path>
|
||||||
|
# Outputs the file's permission bits as an octal string (e.g. "755").
|
||||||
|
# Exits non-zero if stat fails.
|
||||||
|
portable_stat_perms() {
|
||||||
|
local path="$1"
|
||||||
|
case "$(uname -s)" in
|
||||||
|
Darwin)
|
||||||
|
stat -f '%Lp' "$path"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
stat -c '%a' "$path"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* run-check.mjs — aggregate quality check runner (M1, S-106)
|
||||||
|
*
|
||||||
|
* Runs every quality gate in sequence and reports a summary.
|
||||||
|
* All steps run even if earlier steps fail, so you get a complete
|
||||||
|
* picture of the repository health in one pass.
|
||||||
|
*
|
||||||
|
* Transitional contract (M1):
|
||||||
|
* This script may exit non-zero. Pre-existing failures are recorded in
|
||||||
|
* docs/CLEANUP-BASELINE.md. Only issues introduced by new changes (not
|
||||||
|
* listed in the baseline) constitute a regression.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node scripts/lib/run-check.mjs # full check
|
||||||
|
* pnpm run check # same, via pnpm
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const REPO_ROOT = path.resolve(__dirname, "../..");
|
||||||
|
|
||||||
|
// ── Steps ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
{ label: "lint", cmd: "pnpm", args: ["run", "lint"] },
|
||||||
|
{ label: "typecheck", cmd: "pnpm", args: ["run", "typecheck"] },
|
||||||
|
{ label: "test", cmd: "pnpm", args: ["run", "test"] },
|
||||||
|
{ label: "verify:pi", cmd: "pnpm", args: ["run", "verify:pi"] },
|
||||||
|
{ label: "verify:reviewers", cmd: "pnpm", args: ["run", "verify:reviewers"] },
|
||||||
|
{ label: "verify:docs", cmd: "pnpm", args: ["run", "verify:docs"] },
|
||||||
|
{ label: "verify:generated", cmd: "pnpm", args: ["run", "verify:generated"] },
|
||||||
|
{ label: "verify:ci", cmd: "pnpm", args: ["run", "verify:ci"] },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Runner ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const RESET = "\x1b[0m";
|
||||||
|
const GREEN = "\x1b[32m";
|
||||||
|
const RED = "\x1b[31m";
|
||||||
|
const BOLD = "\x1b[1m";
|
||||||
|
const DIM = "\x1b[2m";
|
||||||
|
|
||||||
|
function colorize(color, text) {
|
||||||
|
// Respect NO_COLOR env variable
|
||||||
|
if (process.env.NO_COLOR || process.env.CI) return text;
|
||||||
|
return `${color}${text}${RESET}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (const step of STEPS) {
|
||||||
|
process.stdout.write(`\n${colorize(BOLD, `=== ${step.label} ===`)}\n`);
|
||||||
|
const result = spawnSync(step.cmd, step.args, {
|
||||||
|
cwd: REPO_ROOT,
|
||||||
|
stdio: "inherit",
|
||||||
|
encoding: "utf8",
|
||||||
|
shell: false,
|
||||||
|
});
|
||||||
|
const ok = result.status === 0 && !result.error;
|
||||||
|
results.push({ label: step.label, ok, status: result.status ?? -1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Summary ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
process.stdout.write(`\n${colorize(BOLD, "=== check summary ===")}\n`);
|
||||||
|
|
||||||
|
const failures = [];
|
||||||
|
for (const r of results) {
|
||||||
|
if (r.ok) {
|
||||||
|
process.stdout.write(
|
||||||
|
` ${colorize(GREEN, "PASS")} ${r.label}\n`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
process.stdout.write(
|
||||||
|
` ${colorize(RED, "FAIL")} ${r.label} ${colorize(DIM, `(exit ${r.status})`)} — see docs/CLEANUP-BASELINE.md if pre-existing\n`
|
||||||
|
);
|
||||||
|
failures.push(r.label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdout.write("\n");
|
||||||
|
|
||||||
|
if (failures.length === 0) {
|
||||||
|
process.stdout.write(colorize(GREEN, "All checks passed.\n"));
|
||||||
|
process.exit(0);
|
||||||
|
} else {
|
||||||
|
process.stdout.write(
|
||||||
|
colorize(
|
||||||
|
RED,
|
||||||
|
`${failures.length} check(s) failed: ${failures.join(", ")}\n`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* run-link-check.mjs — markdown link-check runner (M1, S-104)
|
||||||
|
*
|
||||||
|
* Runs markdown-link-check across README.md, docs/, and every SKILL.md
|
||||||
|
* (excluding node_modules and generated agent-variant directories).
|
||||||
|
*
|
||||||
|
* Modes:
|
||||||
|
* --offline (default) — checks only repo-relative links and #anchor links.
|
||||||
|
* All http/https links are ignored. Safe for CI and local dev
|
||||||
|
* without network access.
|
||||||
|
* --online — checks all links, including external URLs, with timeouts
|
||||||
|
* and retries as configured in markdown-link-check.online.json.
|
||||||
|
*
|
||||||
|
* Exit codes:
|
||||||
|
* 0 — all checked links are alive (or ignored in offline mode)
|
||||||
|
* 1 — one or more broken links found
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { readdirSync, existsSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const REPO_ROOT = path.resolve(__dirname, "../..");
|
||||||
|
|
||||||
|
// ── CLI arguments ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const online = process.argv.includes("--online");
|
||||||
|
const configFile = online
|
||||||
|
? path.join(REPO_ROOT, "markdown-link-check.online.json")
|
||||||
|
: path.join(REPO_ROOT, "markdown-link-check.json");
|
||||||
|
|
||||||
|
// ── File discovery ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const SKIP_PATHS = new Set([
|
||||||
|
"skills/atlassian/codex",
|
||||||
|
"skills/atlassian/claude-code",
|
||||||
|
"skills/atlassian/cursor",
|
||||||
|
"skills/atlassian/opencode",
|
||||||
|
"skills/atlassian/pi",
|
||||||
|
"skills/web-automation/claude-code",
|
||||||
|
"skills/web-automation/cursor",
|
||||||
|
"skills/web-automation/opencode",
|
||||||
|
"skills/web-automation/pi",
|
||||||
|
"pi-package",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Also skip any _source/ subdirectory within skills — canonical source files
|
||||||
|
// use relative paths calibrated to the generated location (one level shallower).
|
||||||
|
const SKIP_SEGMENT = "_source";
|
||||||
|
|
||||||
|
function shouldSkip(absPath) {
|
||||||
|
const rel = path.relative(REPO_ROOT, absPath);
|
||||||
|
for (const skip of SKIP_PATHS) {
|
||||||
|
if (rel === skip || rel.startsWith(skip + path.sep)) return true;
|
||||||
|
}
|
||||||
|
const parts = rel.split(path.sep);
|
||||||
|
if (parts.includes("node_modules")) return true;
|
||||||
|
// Skip canonical _source/ directories (links calibrated to generated location)
|
||||||
|
if (parts.includes(SKIP_SEGMENT)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectMarkdownFiles(dir) {
|
||||||
|
const found = [];
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = readdirSync(dir, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
if (shouldSkip(full)) continue;
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
found.push(...collectMarkdownFiles(full));
|
||||||
|
} else if (
|
||||||
|
entry.isFile() &&
|
||||||
|
(entry.name.endsWith(".md") || entry.name === "SKILL.md")
|
||||||
|
) {
|
||||||
|
found.push(full);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Collect target files ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const files = [
|
||||||
|
path.join(REPO_ROOT, "README.md"),
|
||||||
|
...collectMarkdownFiles(path.join(REPO_ROOT, "docs")),
|
||||||
|
...collectMarkdownFiles(path.join(REPO_ROOT, "skills")),
|
||||||
|
].filter(existsSync);
|
||||||
|
|
||||||
|
// De-duplicate (README.md could appear twice)
|
||||||
|
const uniqueFiles = [...new Set(files)];
|
||||||
|
|
||||||
|
if (uniqueFiles.length === 0) {
|
||||||
|
console.log("link-check: no markdown files found — nothing to check.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`link-check: checking ${uniqueFiles.length} file(s) ` +
|
||||||
|
`[mode: ${online ? "online" : "offline"}]…`
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Run markdown-link-check ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const mlcBin = path.join(
|
||||||
|
REPO_ROOT,
|
||||||
|
"node_modules",
|
||||||
|
".bin",
|
||||||
|
"markdown-link-check"
|
||||||
|
);
|
||||||
|
|
||||||
|
let failures = 0;
|
||||||
|
for (const file of uniqueFiles.sort()) {
|
||||||
|
const result = spawnSync(
|
||||||
|
mlcBin,
|
||||||
|
["--config", configFile, "--quiet", file],
|
||||||
|
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }
|
||||||
|
);
|
||||||
|
const output = (result.stdout + result.stderr).trim();
|
||||||
|
const rel = path.relative(REPO_ROOT, file);
|
||||||
|
if (result.status !== 0) {
|
||||||
|
failures += 1;
|
||||||
|
console.error(`\n--- ${rel} ---`);
|
||||||
|
if (output) console.error(output);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures > 0) {
|
||||||
|
console.error(
|
||||||
|
`\nlink-check: ${failures} file(s) have broken links. ` +
|
||||||
|
`See docs/CLEANUP-BASELINE.md for the as-is baseline.`
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
} else {
|
||||||
|
console.log("link-check: all links OK.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* run-shellcheck.mjs — shell script quality wrapper (M1, S-105)
|
||||||
|
*
|
||||||
|
* Discovers *.sh files under scripts/ and skills/ (excluding node_modules
|
||||||
|
* and generated agent-variant directories), then runs shellcheck on each.
|
||||||
|
*
|
||||||
|
* shellcheck is a REQUIRED prerequisite. The script fails immediately when
|
||||||
|
* shellcheck is not found on PATH. Install it with:
|
||||||
|
* macOS: brew install shellcheck
|
||||||
|
* Debian: apt-get install shellcheck
|
||||||
|
*
|
||||||
|
* Exit codes:
|
||||||
|
* 0 — all files passed shellcheck
|
||||||
|
* 1 — one or more files have shellcheck findings
|
||||||
|
* 2 — shellcheck is missing from PATH
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { readdirSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const REPO_ROOT = path.resolve(__dirname, "../..");
|
||||||
|
|
||||||
|
// ── Prerequisites check ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function checkShellcheck() {
|
||||||
|
const result = spawnSync("shellcheck", ["--version"], { encoding: "utf8" });
|
||||||
|
if (result.error || result.status === null || result.status > 1) {
|
||||||
|
console.error(
|
||||||
|
[
|
||||||
|
"ERROR: shellcheck is not available on PATH.",
|
||||||
|
"",
|
||||||
|
"shellcheck is a required prerequisite for this repository.",
|
||||||
|
"Install it before running lint:",
|
||||||
|
"",
|
||||||
|
" macOS: brew install shellcheck",
|
||||||
|
" Debian/Ubuntu: sudo apt-get install shellcheck",
|
||||||
|
" Other: https://github.com/koalaman/shellcheck#installing",
|
||||||
|
].join("\n")
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── File discovery ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Directories to scan for *.sh files.
|
||||||
|
* Relative paths from REPO_ROOT.
|
||||||
|
*/
|
||||||
|
const SCAN_DIRS = ["scripts", "skills"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path segments that indicate a directory should be skipped entirely.
|
||||||
|
* Matches generated agent-variant bundles and npm/pnpm install artifacts.
|
||||||
|
*/
|
||||||
|
const SKIP_SEGMENTS = new Set([
|
||||||
|
"node_modules",
|
||||||
|
// Generated agent-variant directories (excluded per M1 workspace policy)
|
||||||
|
// skills/<skill>/codex — except web-automation/codex which is canonical
|
||||||
|
// We skip all codex variants to be safe; shellcheck only cares about .sh files
|
||||||
|
// and all variants share the same scripts anyway.
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exact relative paths (from REPO_ROOT) to skip, matched after normalisation.
|
||||||
|
* These are the generated variants that duplicate canonical source.
|
||||||
|
*/
|
||||||
|
const SKIP_PATHS = new Set([
|
||||||
|
"skills/atlassian/codex",
|
||||||
|
"skills/atlassian/claude-code",
|
||||||
|
"skills/atlassian/cursor",
|
||||||
|
"skills/atlassian/opencode",
|
||||||
|
"skills/atlassian/pi",
|
||||||
|
"skills/web-automation/claude-code",
|
||||||
|
"skills/web-automation/cursor",
|
||||||
|
"skills/web-automation/opencode",
|
||||||
|
"skills/web-automation/pi",
|
||||||
|
"pi-package",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function shouldSkip(absPath) {
|
||||||
|
const rel = path.relative(REPO_ROOT, absPath);
|
||||||
|
// Check exact-prefix matches (directory and its children)
|
||||||
|
for (const skip of SKIP_PATHS) {
|
||||||
|
if (rel === skip || rel.startsWith(skip + path.sep)) return true;
|
||||||
|
}
|
||||||
|
// Check path-segment matches (e.g. node_modules anywhere in path)
|
||||||
|
for (const seg of rel.split(path.sep)) {
|
||||||
|
if (SKIP_SEGMENTS.has(seg)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectShellFiles(dir) {
|
||||||
|
const found = [];
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = readdirSync(dir, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return found; // directory does not exist — skip silently
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
if (shouldSkip(full)) continue;
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
found.push(...collectShellFiles(full));
|
||||||
|
} else if (entry.isFile() && entry.name.endsWith(".sh")) {
|
||||||
|
found.push(full);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
checkShellcheck();
|
||||||
|
|
||||||
|
const files = SCAN_DIRS.flatMap((d) =>
|
||||||
|
collectShellFiles(path.join(REPO_ROOT, d))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
console.log("shellcheck: no .sh files found — nothing to check.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`shellcheck: scanning ${files.length} file(s)…`);
|
||||||
|
|
||||||
|
let failures = 0;
|
||||||
|
for (const file of files.sort()) {
|
||||||
|
const result = spawnSync("shellcheck", ["-x", "--source-path=SCRIPTDIR", file], {
|
||||||
|
encoding: "utf8",
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
const output = (result.stdout + result.stderr).trim();
|
||||||
|
const rel = path.relative(REPO_ROOT, file);
|
||||||
|
if (result.status !== 0) {
|
||||||
|
failures += 1;
|
||||||
|
// Print findings prefixed with the relative path so output is reproducible
|
||||||
|
// regardless of cwd.
|
||||||
|
console.error(`\n--- ${rel} ---`);
|
||||||
|
if (output) console.error(output);
|
||||||
|
} else {
|
||||||
|
// Quiet on success — only show problems
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures > 0) {
|
||||||
|
console.error(
|
||||||
|
`\nshellcheck: ${failures} file(s) have findings. ` +
|
||||||
|
`See docs/CLEANUP-BASELINE.md for the as-is baseline.`
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
} else {
|
||||||
|
console.log("shellcheck: all files passed.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* safe-replace-dir.mjs — safely replace a directory within a safety-root boundary
|
||||||
|
*
|
||||||
|
* Exports:
|
||||||
|
* safeReplaceDir(source, target, safetyRoot) → Promise<void>
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* import { safeReplaceDir } from "./lib/safe-replace-dir.mjs";
|
||||||
|
* await safeReplaceDir("/path/to/source", "/safe/root/target", "/safe/root");
|
||||||
|
*
|
||||||
|
* Safety contract:
|
||||||
|
* - `target` must be a strict descendant of `safetyRoot` (not equal to it).
|
||||||
|
* - `target` must be a non-empty path.
|
||||||
|
* - Throws with a descriptive message if either constraint is violated.
|
||||||
|
*
|
||||||
|
* Behaviour:
|
||||||
|
* - Removes any existing content at `target` (rm -rf equivalent).
|
||||||
|
* - Creates `target` (and any missing parent directories).
|
||||||
|
* - Copies all files from `source` into `target`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { cp, mkdir, realpath, rm } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely replace `target` with the contents of `source`, enforcing that
|
||||||
|
* `target` is a strict descendant of `safetyRoot`.
|
||||||
|
*
|
||||||
|
* @param {string} source - Directory to copy from.
|
||||||
|
* @param {string} target - Directory to replace (will be removed then recreated).
|
||||||
|
* @param {string} safetyRoot - Ancestor boundary; `target` must be inside this.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export async function safeReplaceDir(source, target, safetyRoot) {
|
||||||
|
if (!target || target === "") {
|
||||||
|
throw new Error(`Refusing to replace unsafe target: (empty string)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedSafety = path.resolve(safetyRoot);
|
||||||
|
const resolvedTarget = path.resolve(target);
|
||||||
|
|
||||||
|
// Lexical check: target must be a strict descendant of safetyRoot.
|
||||||
|
const relative = path.relative(resolvedSafety, resolvedTarget);
|
||||||
|
if (!relative || relative.startsWith("..") || path.isAbsolute(relative) || relative === "") {
|
||||||
|
throw new Error(`Refusing to replace target outside safety root: ${target}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Real-path check: resolve the deepest existing ancestor of target's parent
|
||||||
|
// and verify it lies inside the real (symlink-resolved) safety root.
|
||||||
|
// This blocks a symlinked parent directory from redirecting outside the boundary.
|
||||||
|
const realSafety = await realpath(resolvedSafety);
|
||||||
|
let checkPath = path.dirname(resolvedTarget);
|
||||||
|
for (;;) {
|
||||||
|
try {
|
||||||
|
const realAncestor = await realpath(checkPath);
|
||||||
|
const realRel = path.relative(realSafety, realAncestor);
|
||||||
|
if (realRel.startsWith("..") || path.isAbsolute(realRel)) {
|
||||||
|
throw new Error(`Refusing to replace target outside safety root: ${target}`);
|
||||||
|
}
|
||||||
|
break; // validation passed
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === "ENOENT") {
|
||||||
|
const parent = path.dirname(checkPath);
|
||||||
|
if (parent === checkPath) {
|
||||||
|
throw new Error(`Refusing to replace target outside safety root: ${target}`, { cause: err });
|
||||||
|
}
|
||||||
|
checkPath = parent;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await rm(resolvedTarget, { recursive: true, force: true });
|
||||||
|
await mkdir(resolvedTarget, { recursive: true });
|
||||||
|
await cp(source, resolvedTarget, { recursive: true, force: true });
|
||||||
|
}
|
||||||
Executable
+102
@@ -0,0 +1,102 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# safe-replace-dir.sh — safely replace a directory within a safety-root boundary
|
||||||
|
#
|
||||||
|
# Provides safe_replace_dir() for sourcing, or run standalone:
|
||||||
|
# ./scripts/lib/safe-replace-dir.sh <source> <target> <safety_root>
|
||||||
|
#
|
||||||
|
# Safety contract (mirrors safe-replace-dir.mjs):
|
||||||
|
# - <target> must be a non-empty path.
|
||||||
|
# - <target> must be a strict descendant of <safety_root> (not equal to it).
|
||||||
|
# - Prints an error and returns/exits 1 if either constraint is violated.
|
||||||
|
#
|
||||||
|
# Usage (sourced):
|
||||||
|
# source "$(dirname "${BASH_SOURCE[0]}")/safe-replace-dir.sh"
|
||||||
|
# safe_replace_dir "$source" "$target" "$safety_root"
|
||||||
|
#
|
||||||
|
# Usage (standalone):
|
||||||
|
# ./scripts/lib/safe-replace-dir.sh /path/to/source /safe/root/target /safe/root
|
||||||
|
|
||||||
|
safe_replace_dir() {
|
||||||
|
local source=$1
|
||||||
|
local target=$2
|
||||||
|
local safety_root=$3
|
||||||
|
|
||||||
|
if [[ -z "$target" ]]; then
|
||||||
|
echo "safe_replace_dir: refusing to replace unsafe target: (empty string)" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Resolve the real (symlink-resolved) safety root.
|
||||||
|
local abs_safety
|
||||||
|
abs_safety=$(cd "$safety_root" 2>/dev/null && pwd -P) || {
|
||||||
|
echo "safe_replace_dir: safety root does not exist: $safety_root" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build an absolute lexical path for target's parent directory.
|
||||||
|
local target_parent target_base
|
||||||
|
target_base=$(basename "$target")
|
||||||
|
target_parent=$(dirname "$target")
|
||||||
|
# Make target_parent absolute without relying on cd (target may not exist yet).
|
||||||
|
if [[ "$target_parent" != /* ]]; then
|
||||||
|
target_parent="${PWD}/${target_parent}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Walk up from target_parent to find the deepest existing directory,
|
||||||
|
# accumulating the non-existing path suffix as we go.
|
||||||
|
local suffix=""
|
||||||
|
local walk="$target_parent"
|
||||||
|
while [[ ! -d "$walk" ]]; do
|
||||||
|
local component
|
||||||
|
component=$(basename "$walk")
|
||||||
|
if [[ -z "$suffix" ]]; then
|
||||||
|
suffix="$component"
|
||||||
|
else
|
||||||
|
suffix="${component}/${suffix}"
|
||||||
|
fi
|
||||||
|
local next
|
||||||
|
next=$(dirname "$walk")
|
||||||
|
if [[ "$next" == "$walk" ]]; then
|
||||||
|
echo "safe_replace_dir: could not find existing ancestor for: $target" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
walk="$next"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Resolve the real path of the existing ancestor (follows symlinks).
|
||||||
|
local abs_parent
|
||||||
|
abs_parent=$(cd "$walk" && pwd -P) || {
|
||||||
|
echo "safe_replace_dir: could not resolve parent directory: $walk" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Reconstruct the full absolute target path.
|
||||||
|
local abs_target
|
||||||
|
if [[ -n "$suffix" ]]; then
|
||||||
|
abs_target="${abs_parent}/${suffix}/${target_base}"
|
||||||
|
else
|
||||||
|
abs_target="${abs_parent}/${target_base}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check that abs_target is strictly inside abs_safety
|
||||||
|
case "$abs_target" in
|
||||||
|
"${abs_safety}/"*) ;;
|
||||||
|
*)
|
||||||
|
echo "safe_replace_dir: refusing to replace target outside safety root: $target" >&2
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
rm -rf "$abs_target"
|
||||||
|
mkdir -p "$abs_target"
|
||||||
|
cp -R "${source}/." "$abs_target/"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Allow standalone use
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
if [[ $# -ne 3 ]]; then
|
||||||
|
echo "Usage: $0 <source> <target> <safety_root>" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
safe_replace_dir "$1" "$2" "$3" || exit 1
|
||||||
|
fi
|
||||||
@@ -0,0 +1,663 @@
|
|||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the target of an operation (skill, helper, or superpowers).
|
||||||
|
*
|
||||||
|
* Validates that the target is within the skills root before removing.
|
||||||
|
* Handles both regular directories and symbolic links.
|
||||||
|
* Idempotent: succeeds even when the target does not exist.
|
||||||
|
*
|
||||||
|
* @param {object} op - Operation object with at least `target` and `skillsRoot`.
|
||||||
|
* @returns {Promise<object>} Operation with `status: "ok"`.
|
||||||
|
*/
|
||||||
|
export async function removeTarget(op) {
|
||||||
|
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" };
|
||||||
|
}
|
||||||
|
|
||||||
|
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") {
|
||||||
|
// Use the canonical generator (pnpm run sync:pi / node scripts/generate-skills.mjs).
|
||||||
|
runCommand(process.execPath, [path.join(op.repoRoot, "scripts", "generate-skills.mjs")], { 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") return removeTarget(op);
|
||||||
|
await copyDirectoryReplacing(op.source, op.target);
|
||||||
|
return { ...op, status: "ok" };
|
||||||
|
}
|
||||||
|
if (op.kind === "helper") {
|
||||||
|
if (op.action === "remove") return removeTarget(op);
|
||||||
|
await installHelperAllowlist(op);
|
||||||
|
return { ...op, status: "ok" };
|
||||||
|
}
|
||||||
|
if (op.kind === "superpowers") {
|
||||||
|
if (op.action === "remove") return removeTarget(op);
|
||||||
|
await mkdir(path.dirname(op.target), { recursive: true });
|
||||||
|
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
+328
@@ -0,0 +1,328 @@
|
|||||||
|
#!/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 readAnswers(source) {
|
||||||
|
if (source === "-") {
|
||||||
|
let content = "";
|
||||||
|
for await (const chunk of input) content += chunk;
|
||||||
|
return JSON.parse(content);
|
||||||
|
}
|
||||||
|
return JSON.parse(await readFile(path.resolve(source), "utf8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = await readAnswers(args.answers);
|
||||||
|
} 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 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,365 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for generate-skills.mjs — RED phase of TDD.
|
||||||
|
*
|
||||||
|
* Tests cover:
|
||||||
|
* - detectFileType: classification of files by extension
|
||||||
|
* - applyHeader: insertion per file-type-aware policy
|
||||||
|
* - makePackageJsonContent: unique name + private:true
|
||||||
|
* - getGeneratedRoots: returns the canonical generated-root list
|
||||||
|
*/
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdtemp, mkdir, writeFile, rm, readFile } from "node:fs/promises";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const SCRIPTS_DIR = path.resolve(__dirname, "..");
|
||||||
|
|
||||||
|
const {
|
||||||
|
detectFileType,
|
||||||
|
applyHeader,
|
||||||
|
makePackageJsonContent,
|
||||||
|
getGeneratedRoots,
|
||||||
|
buildManifest,
|
||||||
|
generateSkills,
|
||||||
|
} = await import(`${SCRIPTS_DIR}/generate-skills.mjs`);
|
||||||
|
|
||||||
|
// ── detectFileType ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test("detectFileType: .md files → markdown", () => {
|
||||||
|
assert.equal(detectFileType("SKILL.md"), "markdown");
|
||||||
|
assert.equal(detectFileType("templates/milestone-plan.md"), "markdown");
|
||||||
|
assert.equal(detectFileType("README.md"), "markdown");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detectFileType: .sh files → shell", () => {
|
||||||
|
assert.equal(detectFileType("run-review.sh"), "shell");
|
||||||
|
assert.equal(detectFileType("scripts/install.sh"), "shell");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detectFileType: .ts and .d.ts files → ts", () => {
|
||||||
|
assert.equal(detectFileType("src/cli.ts"), "ts");
|
||||||
|
assert.equal(detectFileType("turndown-plugin-gfm.d.ts"), "ts");
|
||||||
|
assert.equal(detectFileType("auth.ts"), "ts");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detectFileType: .js files → js", () => {
|
||||||
|
assert.equal(detectFileType("check-install.js"), "js");
|
||||||
|
assert.equal(detectFileType("extract.js"), "js");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detectFileType: .json files → json", () => {
|
||||||
|
assert.equal(detectFileType("package.json"), "json");
|
||||||
|
assert.equal(detectFileType("tsconfig.json"), "json");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detectFileType: .yaml and .yml files → yaml", () => {
|
||||||
|
assert.equal(detectFileType("pnpm-lock.yaml"), "yaml");
|
||||||
|
assert.equal(detectFileType("other.yml"), "yaml");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detectFileType: .jsonc files → jsonc", () => {
|
||||||
|
assert.equal(detectFileType(".markdownlint.jsonc"), "jsonc");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detectFileType: unknown extension → unknown", () => {
|
||||||
|
assert.equal(detectFileType("Makefile"), "unknown");
|
||||||
|
assert.equal(detectFileType("somefile"), "unknown");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── applyHeader ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test("applyHeader: markdown with YAML front matter inserts HTML comment after closing ---", () => {
|
||||||
|
const content = "---\nname: create-plan\n---\n\n# Create Plan\n\nBody.\n";
|
||||||
|
const result = applyHeader(content, "markdown", "skills/create-plan/_source/claude-code/SKILL.md");
|
||||||
|
|
||||||
|
// Front matter block preserved verbatim at start
|
||||||
|
assert.ok(result.startsWith("---\nname: create-plan\n---\n"), "front matter at start");
|
||||||
|
|
||||||
|
// HTML comment present
|
||||||
|
assert.ok(result.includes("<!-- ⚠️"), "HTML comment present");
|
||||||
|
|
||||||
|
// Comment comes after front matter closer and before the title
|
||||||
|
const commentIdx = result.indexOf("<!-- ⚠️");
|
||||||
|
const titleIdx = result.indexOf("# Create Plan");
|
||||||
|
assert.ok(commentIdx !== -1 && titleIdx !== -1, "both positions found");
|
||||||
|
assert.ok(commentIdx < titleIdx, "comment before title");
|
||||||
|
|
||||||
|
// Comment must NOT appear before the first ---
|
||||||
|
assert.ok(commentIdx > 3, "comment not before front matter");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("applyHeader: markdown without front matter inserts HTML comment at top", () => {
|
||||||
|
const content = "# Heading\n\nContent\n";
|
||||||
|
const result = applyHeader(content, "markdown", "skills/create-plan/_source/claude-code/templates/plan.md");
|
||||||
|
assert.ok(result.startsWith("<!-- ⚠️"), "comment at very top");
|
||||||
|
assert.ok(result.includes("# Heading"), "original content preserved");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("applyHeader: shell with shebang inserts # comment after shebang", () => {
|
||||||
|
const content = "#!/usr/bin/env bash\nset -euo pipefail\necho hello\n";
|
||||||
|
const result = applyHeader(content, "shell", "skills/reviewer-runtime/run-review.sh");
|
||||||
|
|
||||||
|
assert.ok(result.startsWith("#!/usr/bin/env bash\n"), "shebang preserved at top");
|
||||||
|
assert.ok(result.includes("# ⚠️"), "hash comment present");
|
||||||
|
|
||||||
|
const shebangEnd = result.indexOf("\n") + 1;
|
||||||
|
const commentStart = result.indexOf("# ⚠️");
|
||||||
|
assert.ok(commentStart === shebangEnd, "comment immediately after shebang line");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("applyHeader: ts file inserts // comment at top", () => {
|
||||||
|
const content = "import path from 'path';\nexport const x = 1;\n";
|
||||||
|
const result = applyHeader(content, "ts", "skills/atlassian/shared/scripts/src/cli.ts");
|
||||||
|
assert.ok(result.startsWith("// ⚠️"), "// comment at top");
|
||||||
|
assert.ok(result.includes("import path from 'path';"), "original content preserved");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("applyHeader: ts file with shebang inserts // comment after shebang", () => {
|
||||||
|
const content = "#!/usr/bin/env npx tsx\nimport foo from 'foo';\n";
|
||||||
|
const result = applyHeader(content, "ts", "auth.ts");
|
||||||
|
assert.ok(result.startsWith("#!/usr/bin/env npx tsx\n"), "shebang preserved at top");
|
||||||
|
assert.ok(result.includes("// ⚠️"), "// comment present");
|
||||||
|
const shebangEnd = result.indexOf("\n") + 1;
|
||||||
|
const commentStart = result.indexOf("// ⚠️");
|
||||||
|
assert.ok(commentStart === shebangEnd, "// comment immediately after shebang");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("applyHeader: js file with shebang inserts // comment after shebang", () => {
|
||||||
|
const content = "#!/usr/bin/env node\nconst x = 1;\n";
|
||||||
|
const result = applyHeader(content, "js", "check-install.js");
|
||||||
|
assert.ok(result.startsWith("#!/usr/bin/env node\n"), "shebang preserved at top");
|
||||||
|
assert.ok(result.includes("// ⚠️"), "// comment present");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("applyHeader: js file inserts // comment at top", () => {
|
||||||
|
const content = "const x = require('x');\n";
|
||||||
|
const result = applyHeader(content, "js", "check-install.js");
|
||||||
|
assert.ok(result.startsWith("// ⚠️"), "// comment at top");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("applyHeader: yaml file inserts # comment at top", () => {
|
||||||
|
const content = "lockfileVersion: '9.0'\n\npackages:\n";
|
||||||
|
const result = applyHeader(content, "yaml", "pnpm-lock.yaml");
|
||||||
|
assert.ok(result.startsWith("# ⚠️"), "# comment at top");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("applyHeader: jsonc file inserts // comment at top", () => {
|
||||||
|
const content = "// existing comment\n{\n \"key\": true\n}\n";
|
||||||
|
const result = applyHeader(content, "jsonc", ".markdownlint.jsonc");
|
||||||
|
assert.ok(result.startsWith("// ⚠️"), "// comment at top");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("applyHeader: json file returns content unchanged (no header)", () => {
|
||||||
|
const content = '{\n "name": "test"\n}\n';
|
||||||
|
const result = applyHeader(content, "json", "package.json");
|
||||||
|
assert.equal(result, content, "JSON file must not be modified");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("applyHeader: unknown type returns content unchanged", () => {
|
||||||
|
const content = "raw content\n";
|
||||||
|
const result = applyHeader(content, "unknown", "Makefile");
|
||||||
|
assert.equal(result, content);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("applyHeader: header contains canonical source hint", () => {
|
||||||
|
const hint = "skills/create-plan/_source/pi/SKILL.md";
|
||||||
|
const content = "---\nname: create-plan\n---\n\n# Title\n";
|
||||||
|
const result = applyHeader(content, "markdown", hint);
|
||||||
|
assert.ok(result.includes(hint), "canonical hint appears in header");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("applyHeader: never inserts header before shebang", () => {
|
||||||
|
const content = "#!/usr/bin/env bash\nset -euo pipefail\n";
|
||||||
|
const result = applyHeader(content, "shell", "run.sh");
|
||||||
|
assert.ok(result.startsWith("#!/"), "shebang still first");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── makePackageJsonContent ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test("makePackageJsonContent: renames name to scoped unique form", () => {
|
||||||
|
const src = { name: "atlassian-skill-scripts", version: "1.0.0" };
|
||||||
|
const result = makePackageJsonContent(src, "atlassian", "claude-code");
|
||||||
|
assert.equal(result.name, "@ai-coding-skills/atlassian-claude-code");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("makePackageJsonContent: adds private:true", () => {
|
||||||
|
const src = { name: "web-automation-scripts", version: "1.0.0" };
|
||||||
|
const result = makePackageJsonContent(src, "web-automation", "codex");
|
||||||
|
assert.equal(result.private, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("makePackageJsonContent: preserves all other top-level fields", () => {
|
||||||
|
const src = {
|
||||||
|
name: "atlassian-skill-scripts",
|
||||||
|
version: "1.0.0",
|
||||||
|
type: "module",
|
||||||
|
scripts: { typecheck: "tsc --noEmit" },
|
||||||
|
dependencies: { commander: "^13.1.0" },
|
||||||
|
devDependencies: { tsx: "^4.20.5" },
|
||||||
|
packageManager: "pnpm@10.18.1",
|
||||||
|
};
|
||||||
|
const result = makePackageJsonContent(src, "atlassian", "cursor");
|
||||||
|
assert.equal(result.version, "1.0.0");
|
||||||
|
assert.equal(result.type, "module");
|
||||||
|
assert.deepEqual(result.scripts, { typecheck: "tsc --noEmit" });
|
||||||
|
assert.deepEqual(result.dependencies, { commander: "^13.1.0" });
|
||||||
|
assert.equal(result.packageManager, "pnpm@10.18.1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("makePackageJsonContent: pi-package mirror uses -pi agent suffix", () => {
|
||||||
|
const src = { name: "atlassian-skill-scripts" };
|
||||||
|
const result = makePackageJsonContent(src, "atlassian", "pi");
|
||||||
|
assert.equal(result.name, "@ai-coding-skills/atlassian-pi");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("makePackageJsonContent: does not mutate the source object", () => {
|
||||||
|
const src = { name: "original", version: "1.0.0" };
|
||||||
|
makePackageJsonContent(src, "atlassian", "codex");
|
||||||
|
assert.equal(src.name, "original", "source object not mutated");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── getGeneratedRoots ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test("getGeneratedRoots: returns at least one root per agent per skills skill", () => {
|
||||||
|
const roots = getGeneratedRoots();
|
||||||
|
assert.ok(Array.isArray(roots), "returns array");
|
||||||
|
assert.ok(roots.length > 0, "non-empty");
|
||||||
|
|
||||||
|
const agents = ["claude-code", "codex", "cursor", "opencode", "pi"];
|
||||||
|
const skillsWithAgents = ["create-plan", "do-task", "implement-plan", "atlassian", "web-automation"];
|
||||||
|
for (const skill of skillsWithAgents) {
|
||||||
|
for (const agent of agents) {
|
||||||
|
const expected = `skills/${skill}/${agent}`;
|
||||||
|
assert.ok(roots.includes(expected), `missing generated root: ${expected}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getGeneratedRoots: includes pi-package mirrors for all skills", () => {
|
||||||
|
const roots = getGeneratedRoots();
|
||||||
|
const piPackageSkills = ["atlassian", "create-plan", "do-task", "implement-plan", "web-automation"];
|
||||||
|
for (const skill of piPackageSkills) {
|
||||||
|
const expected = `pi-package/skills/${skill}`;
|
||||||
|
assert.ok(roots.includes(expected), `missing pi-package mirror root: ${expected}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getGeneratedRoots: includes reviewer-runtime/pi", () => {
|
||||||
|
const roots = getGeneratedRoots();
|
||||||
|
assert.ok(roots.includes("skills/reviewer-runtime/pi"), "reviewer-runtime/pi missing");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getGeneratedRoots: does not include _source or shared directories", () => {
|
||||||
|
const roots = getGeneratedRoots();
|
||||||
|
for (const r of roots) {
|
||||||
|
assert.ok(!r.includes("_source"), `root should not contain _source: ${r}`);
|
||||||
|
assert.ok(!r.endsWith("/shared"), `root should not be shared: ${r}`);
|
||||||
|
assert.ok(!r.includes("shared/scripts"), `root should not contain shared/scripts: ${r}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── buildManifest ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test("buildManifest: returns object with $schema and generator markers", async () => {
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), "manifest-test-"));
|
||||||
|
try {
|
||||||
|
await writeFile(path.join(dir, "SKILL.md"), "---\nname: test\n---\n# Test\n");
|
||||||
|
await mkdir(path.join(dir, "templates"), { recursive: true });
|
||||||
|
await writeFile(path.join(dir, "templates", "plan.md"), "# Plan\n");
|
||||||
|
// Write .generated-manifest.json itself (should be excluded from listing)
|
||||||
|
await writeFile(path.join(dir, ".generated-manifest.json"), "{}");
|
||||||
|
|
||||||
|
const manifest = await buildManifest(dir, "skills/test/claude-code");
|
||||||
|
|
||||||
|
assert.ok(manifest.$schema, "$schema field present");
|
||||||
|
assert.ok(manifest.generator, "generator field present");
|
||||||
|
assert.equal(manifest.generatedRoot, "skills/test/claude-code");
|
||||||
|
} finally {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildManifest: does not include .generated-manifest.json in files list", async () => {
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), "manifest-self-"));
|
||||||
|
try {
|
||||||
|
await writeFile(path.join(dir, "SKILL.md"), "# skill\n");
|
||||||
|
await writeFile(path.join(dir, ".generated-manifest.json"), "{}");
|
||||||
|
|
||||||
|
const manifest = await buildManifest(dir, "skills/test/pi");
|
||||||
|
|
||||||
|
const paths = manifest.files.map((f) => f.path);
|
||||||
|
assert.ok(!paths.includes(".generated-manifest.json"), "manifest must not list itself");
|
||||||
|
} finally {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildManifest: files list includes path, kind, mode, sha256", async () => {
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), "manifest-fields-"));
|
||||||
|
try {
|
||||||
|
await writeFile(path.join(dir, "SKILL.md"), "# skill\n");
|
||||||
|
|
||||||
|
const manifest = await buildManifest(dir, "skills/test/codex");
|
||||||
|
|
||||||
|
assert.equal(manifest.files.length, 1);
|
||||||
|
const entry = manifest.files[0];
|
||||||
|
assert.equal(entry.path, "SKILL.md");
|
||||||
|
assert.equal(entry.kind, "file");
|
||||||
|
assert.ok(typeof entry.mode === "string" && entry.mode.match(/^\d{3}$/), "mode is 3-digit octal string");
|
||||||
|
assert.ok(typeof entry.sha256 === "string" && entry.sha256.length === 64, "sha256 is 64-char hex");
|
||||||
|
} finally {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildManifest: files are sorted by path", async () => {
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), "manifest-sorted-"));
|
||||||
|
try {
|
||||||
|
await mkdir(path.join(dir, "templates"), { recursive: true });
|
||||||
|
await writeFile(path.join(dir, "SKILL.md"), "# skill\n");
|
||||||
|
await writeFile(path.join(dir, "templates", "z.md"), "z\n");
|
||||||
|
await writeFile(path.join(dir, "templates", "a.md"), "a\n");
|
||||||
|
|
||||||
|
const manifest = await buildManifest(dir, "skills/test/cursor");
|
||||||
|
|
||||||
|
const paths = manifest.files.map((f) => f.path);
|
||||||
|
const sorted = [...paths].sort();
|
||||||
|
assert.deepEqual(paths, sorted, "files must be sorted by path");
|
||||||
|
} finally {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildManifest: sha256 matches actual file content", async () => {
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), "manifest-hash-"));
|
||||||
|
try {
|
||||||
|
const content = "---\nname: test\n---\n# Title\n";
|
||||||
|
await writeFile(path.join(dir, "SKILL.md"), content);
|
||||||
|
|
||||||
|
const manifest = await buildManifest(dir, "skills/test/opencode");
|
||||||
|
|
||||||
|
const expected = crypto.createHash("sha256").update(content, "utf8").digest("hex");
|
||||||
|
assert.equal(manifest.files[0].sha256, expected, "sha256 matches file content");
|
||||||
|
} finally {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("generateSkills: clears pre-existing empty generated directories without EISDIR", async () => {
|
||||||
|
const targetRoot = await mkdtemp(path.join(tmpdir(), "generate-skills-target-"));
|
||||||
|
try {
|
||||||
|
await mkdir(path.join(targetRoot, "skills", "create-plan", "claude-code", "templates"), { recursive: true });
|
||||||
|
|
||||||
|
await generateSkills(path.resolve(SCRIPTS_DIR, ".."), { targetRoot });
|
||||||
|
|
||||||
|
await readFile(path.join(targetRoot, "skills", "create-plan", "claude-code", "SKILL.md"), "utf8");
|
||||||
|
await readFile(path.join(targetRoot, "skills", "create-plan", "claude-code", "templates", "milestone-plan.md"), "utf8");
|
||||||
|
} finally {
|
||||||
|
await rm(targetRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdtemp, mkdir, writeFile, readFile, readdir, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { safeReplaceDir } from "../lib/safe-replace-dir.mjs";
|
||||||
|
|
||||||
|
// ── Happy path ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test("safeReplaceDir copies source content into the target", async () => {
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), "safe-replace-copy-"));
|
||||||
|
try {
|
||||||
|
const safetyRoot = path.join(dir, "root");
|
||||||
|
const source = path.join(dir, "source");
|
||||||
|
const target = path.join(safetyRoot, "target");
|
||||||
|
|
||||||
|
await mkdir(source, { recursive: true });
|
||||||
|
await writeFile(path.join(source, "file.txt"), "hello");
|
||||||
|
await mkdir(safetyRoot, { recursive: true });
|
||||||
|
|
||||||
|
await safeReplaceDir(source, target, safetyRoot);
|
||||||
|
|
||||||
|
const content = await readFile(path.join(target, "file.txt"), "utf8");
|
||||||
|
assert.equal(content, "hello");
|
||||||
|
} finally {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("safeReplaceDir removes existing content before replacing", async () => {
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), "safe-replace-stale-"));
|
||||||
|
try {
|
||||||
|
const safetyRoot = path.join(dir, "root");
|
||||||
|
const source = path.join(dir, "source");
|
||||||
|
const target = path.join(safetyRoot, "target");
|
||||||
|
|
||||||
|
await mkdir(target, { recursive: true });
|
||||||
|
await writeFile(path.join(target, "old.txt"), "stale");
|
||||||
|
await mkdir(source, { recursive: true });
|
||||||
|
await writeFile(path.join(source, "new.txt"), "fresh");
|
||||||
|
|
||||||
|
await safeReplaceDir(source, target, safetyRoot);
|
||||||
|
|
||||||
|
const files = await readdir(target);
|
||||||
|
assert.deepEqual(files.sort(), ["new.txt"]);
|
||||||
|
} finally {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("safeReplaceDir creates target parent directories if they do not exist", async () => {
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), "safe-replace-mkdir-"));
|
||||||
|
try {
|
||||||
|
const safetyRoot = path.join(dir, "root");
|
||||||
|
const source = path.join(dir, "source");
|
||||||
|
const target = path.join(safetyRoot, "nested", "target");
|
||||||
|
|
||||||
|
await mkdir(source, { recursive: true });
|
||||||
|
await writeFile(path.join(source, "data.txt"), "data");
|
||||||
|
await mkdir(safetyRoot, { recursive: true });
|
||||||
|
// nested parent does NOT exist yet
|
||||||
|
|
||||||
|
await safeReplaceDir(source, target, safetyRoot);
|
||||||
|
|
||||||
|
const content = await readFile(path.join(target, "data.txt"), "utf8");
|
||||||
|
assert.equal(content, "data");
|
||||||
|
} finally {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("safeReplaceDir creates deeply nested parent directories (2+ levels missing)", async () => {
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), "safe-replace-deep-"));
|
||||||
|
try {
|
||||||
|
const safetyRoot = path.join(dir, "root");
|
||||||
|
const source = path.join(dir, "source");
|
||||||
|
// two parent levels (a/b) do NOT exist under safetyRoot
|
||||||
|
const target = path.join(safetyRoot, "a", "b", "target");
|
||||||
|
|
||||||
|
await mkdir(source, { recursive: true });
|
||||||
|
await writeFile(path.join(source, "deep.txt"), "deep");
|
||||||
|
await mkdir(safetyRoot, { recursive: true });
|
||||||
|
// a/ and a/b/ intentionally NOT created
|
||||||
|
|
||||||
|
await safeReplaceDir(source, target, safetyRoot);
|
||||||
|
|
||||||
|
const content = await readFile(path.join(target, "deep.txt"), "utf8");
|
||||||
|
assert.equal(content, "deep");
|
||||||
|
} finally {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Safety checks ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test("safeReplaceDir refuses when target is outside the safety root", async () => {
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), "safe-replace-outside-"));
|
||||||
|
try {
|
||||||
|
const safetyRoot = path.join(dir, "root");
|
||||||
|
const source = path.join(dir, "source");
|
||||||
|
const outside = path.join(dir, "outside");
|
||||||
|
|
||||||
|
await mkdir(source, { recursive: true });
|
||||||
|
await mkdir(safetyRoot, { recursive: true });
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => safeReplaceDir(source, outside, safetyRoot),
|
||||||
|
/outside safety root/,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("safeReplaceDir refuses when target equals the safety root", async () => {
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), "safe-replace-same-"));
|
||||||
|
try {
|
||||||
|
const safetyRoot = path.join(dir, "root");
|
||||||
|
const source = path.join(dir, "source");
|
||||||
|
|
||||||
|
await mkdir(source, { recursive: true });
|
||||||
|
await mkdir(safetyRoot, { recursive: true });
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => safeReplaceDir(source, safetyRoot, safetyRoot),
|
||||||
|
/outside safety root/,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("safeReplaceDir refuses an empty target string", async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
() => safeReplaceDir("/any", "", "/root"),
|
||||||
|
/unsafe target/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdtemp, mkdir, writeFile, rm, lstat, symlink } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { removeTarget } from "../lib/skill-manager-core.mjs";
|
||||||
|
|
||||||
|
// ── Happy path: remove existing directory ─────────────────────────────────
|
||||||
|
|
||||||
|
test("removeTarget removes an installed skill directory", async () => {
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), "smc-remove-dir-"));
|
||||||
|
try {
|
||||||
|
const skillsRoot = path.join(dir, "skills");
|
||||||
|
const target = path.join(skillsRoot, "create-plan");
|
||||||
|
await mkdir(target, { recursive: true });
|
||||||
|
await writeFile(path.join(target, "SKILL.md"), "---\nname: create-plan\n---\n");
|
||||||
|
|
||||||
|
const op = { kind: "skill", action: "remove", target, skillsRoot };
|
||||||
|
const result = await removeTarget(op);
|
||||||
|
|
||||||
|
assert.equal(result.status, "ok");
|
||||||
|
let exists = true;
|
||||||
|
try {
|
||||||
|
await lstat(target);
|
||||||
|
} catch {
|
||||||
|
exists = false;
|
||||||
|
}
|
||||||
|
assert.equal(exists, false, "target directory should be gone");
|
||||||
|
} finally {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Happy path: remove symbolic link ─────────────────────────────────────
|
||||||
|
|
||||||
|
test("removeTarget removes a symlink without following it", async () => {
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), "smc-remove-sym-"));
|
||||||
|
try {
|
||||||
|
const skillsRoot = path.join(dir, "skills");
|
||||||
|
const realDir = path.join(dir, "real-skill");
|
||||||
|
const target = path.join(skillsRoot, "create-plan");
|
||||||
|
|
||||||
|
await mkdir(skillsRoot, { recursive: true });
|
||||||
|
await mkdir(realDir, { recursive: true });
|
||||||
|
await writeFile(path.join(realDir, "SKILL.md"), "---\nname: create-plan\n---\n");
|
||||||
|
await symlink(realDir, target, "dir");
|
||||||
|
|
||||||
|
const op = { kind: "skill", action: "remove", target, skillsRoot };
|
||||||
|
const result = await removeTarget(op);
|
||||||
|
|
||||||
|
assert.equal(result.status, "ok");
|
||||||
|
|
||||||
|
// symlink itself should be gone
|
||||||
|
let symlinkExists = true;
|
||||||
|
try {
|
||||||
|
await lstat(target);
|
||||||
|
} catch {
|
||||||
|
symlinkExists = false;
|
||||||
|
}
|
||||||
|
assert.equal(symlinkExists, false, "symlink should be removed");
|
||||||
|
|
||||||
|
// real directory should still exist
|
||||||
|
const realStat = await lstat(realDir);
|
||||||
|
assert.ok(realStat.isDirectory(), "real directory must not be touched");
|
||||||
|
} finally {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Missing skill (partial state): target does not exist ─────────────────
|
||||||
|
|
||||||
|
test("removeTarget succeeds when target does not exist (idempotent)", async () => {
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), "smc-remove-missing-"));
|
||||||
|
try {
|
||||||
|
const skillsRoot = path.join(dir, "skills");
|
||||||
|
const target = path.join(skillsRoot, "create-plan");
|
||||||
|
await mkdir(skillsRoot, { recursive: true });
|
||||||
|
// target intentionally NOT created
|
||||||
|
|
||||||
|
const op = { kind: "skill", action: "remove", target, skillsRoot };
|
||||||
|
const result = await removeTarget(op);
|
||||||
|
|
||||||
|
assert.equal(result.status, "ok");
|
||||||
|
} finally {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Partial state: directory exists but is empty ─────────────────────────
|
||||||
|
|
||||||
|
test("removeTarget removes an empty skill directory", async () => {
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), "smc-remove-empty-"));
|
||||||
|
try {
|
||||||
|
const skillsRoot = path.join(dir, "skills");
|
||||||
|
const target = path.join(skillsRoot, "create-plan");
|
||||||
|
await mkdir(target, { recursive: true });
|
||||||
|
// directory exists but has no SKILL.md (partial install state)
|
||||||
|
|
||||||
|
const op = { kind: "skill", action: "remove", target, skillsRoot };
|
||||||
|
const result = await removeTarget(op);
|
||||||
|
|
||||||
|
assert.equal(result.status, "ok");
|
||||||
|
let exists = true;
|
||||||
|
try {
|
||||||
|
await lstat(target);
|
||||||
|
} catch {
|
||||||
|
exists = false;
|
||||||
|
}
|
||||||
|
assert.equal(exists, false);
|
||||||
|
} finally {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Safety: refuses to remove path outside skills root ────────────────────
|
||||||
|
|
||||||
|
test("removeTarget refuses to remove a path outside the skills root", async () => {
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), "smc-remove-outside-"));
|
||||||
|
try {
|
||||||
|
const skillsRoot = path.join(dir, "skills");
|
||||||
|
const outsideTarget = path.join(dir, "outside");
|
||||||
|
await mkdir(skillsRoot, { recursive: true });
|
||||||
|
await mkdir(outsideTarget, { recursive: true });
|
||||||
|
|
||||||
|
const op = {
|
||||||
|
kind: "skill",
|
||||||
|
action: "remove",
|
||||||
|
target: outsideTarget,
|
||||||
|
skillsRoot,
|
||||||
|
};
|
||||||
|
|
||||||
|
await assert.rejects(() => removeTarget(op), /outside skills root/);
|
||||||
|
} finally {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
"-",
|
||||||
|
], {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* verify-docs-flow.test.mjs — unit tests for the docs-flow verifier (M2, S-206)
|
||||||
|
*
|
||||||
|
* Tests the exported functions from scripts/verify-docs-flow.mjs.
|
||||||
|
* Each test is structured as a RED → GREEN cycle: we first verify the function
|
||||||
|
* exists and behaves correctly; any structural violation surfaces as a clear
|
||||||
|
* test failure rather than a cryptic runtime error.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, describe } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const REPO_ROOT = path.resolve(__dirname, "../..");
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import the verifier lazily so missing-module errors surface as test
|
||||||
|
* failures rather than crashing the whole suite.
|
||||||
|
*/
|
||||||
|
async function loadVerifier() {
|
||||||
|
const verifierPath = path.join(REPO_ROOT, "scripts", "verify-docs-flow.mjs");
|
||||||
|
return import(verifierPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── S-206 acceptance checks ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("verify-docs-flow.mjs", () => {
|
||||||
|
test("module exists and exports required functions", async () => {
|
||||||
|
const mod = await loadVerifier();
|
||||||
|
assert.equal(typeof mod.checkDocsIndexCoverage, "function",
|
||||||
|
"must export checkDocsIndexCoverage");
|
||||||
|
assert.equal(typeof mod.checkReviewerMatrixConsistency, "function",
|
||||||
|
"must export checkReviewerMatrixConsistency");
|
||||||
|
assert.equal(typeof mod.checkTelegramAgentCoverage, "function",
|
||||||
|
"must export checkTelegramAgentCoverage");
|
||||||
|
assert.equal(typeof mod.checkRepoPathsExist, "function",
|
||||||
|
"must export checkRepoPathsExist");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("checkDocsIndexCoverage: every docs/*.md is linked from docs/README.md", async () => {
|
||||||
|
const { checkDocsIndexCoverage } = await loadVerifier();
|
||||||
|
const errors = await checkDocsIndexCoverage(REPO_ROOT);
|
||||||
|
assert.deepEqual(errors, [],
|
||||||
|
`docs/README.md coverage errors:\n${errors.join("\n")}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("checkReviewerMatrixConsistency: reviewer tables consistent across canonical sources", async () => {
|
||||||
|
const { checkReviewerMatrixConsistency } = await loadVerifier();
|
||||||
|
const errors = await checkReviewerMatrixConsistency(REPO_ROOT);
|
||||||
|
assert.deepEqual(errors, [],
|
||||||
|
`Reviewer matrix inconsistency errors:\n${errors.join("\n")}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("checkTelegramAgentCoverage: Telegram doc lists all agents with Pi helpers", async () => {
|
||||||
|
const { checkTelegramAgentCoverage } = await loadVerifier();
|
||||||
|
const errors = await checkTelegramAgentCoverage(REPO_ROOT);
|
||||||
|
assert.deepEqual(errors, [],
|
||||||
|
`Telegram coverage errors:\n${errors.join("\n")}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("checkRepoPathsExist: all repo-relative paths in README.md and docs/ exist", async () => {
|
||||||
|
const { checkRepoPathsExist } = await loadVerifier();
|
||||||
|
const errors = await checkRepoPathsExist(REPO_ROOT);
|
||||||
|
assert.deepEqual(errors, [],
|
||||||
|
`Broken repo-relative path references:\n${errors.join("\n")}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for verify-generated.mjs — RED phase of TDD.
|
||||||
|
*
|
||||||
|
* Key contract from acceptance criteria:
|
||||||
|
* - A stray file under skills/<skill>/_source/ or skills/<skill>/shared/
|
||||||
|
* does NOT cause verify:generated to flag it as stale.
|
||||||
|
* - A stray file under skills/<skill>/<agent>/ (other than
|
||||||
|
* .generated-manifest.json) DOES cause verify:generated to flag it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
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";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const SCRIPTS_DIR = path.resolve(__dirname, "..");
|
||||||
|
|
||||||
|
const { verifyGenerated } = await import(`${SCRIPTS_DIR}/verify-generated.mjs`);
|
||||||
|
|
||||||
|
// ── Stray-file detection boundary tests ──────────────────────────────────
|
||||||
|
|
||||||
|
test("verifyGenerated: stray file in _source/ is NOT flagged as stale", async () => {
|
||||||
|
// This uses the real repo's canonical source - if stray file under _source
|
||||||
|
// is added, verify-generated should not complain about it.
|
||||||
|
|
||||||
|
// We test this by verifying the function signature accepts a repoRoot and
|
||||||
|
// generatedRoots override, and that the verifier only walks declared roots.
|
||||||
|
// (Full integration test against the real repo runs in pnpm run verify:generated)
|
||||||
|
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), "vg-stray-source-"));
|
||||||
|
try {
|
||||||
|
// Create a fake skill structure with _source/ and a generated root
|
||||||
|
const skillName = "test-skill";
|
||||||
|
const agentName = "claude-code";
|
||||||
|
|
||||||
|
const sourceDir = path.join(dir, "skills", skillName, "_source", agentName);
|
||||||
|
const agentDir = path.join(dir, "skills", skillName, agentName);
|
||||||
|
const sharedDir = path.join(dir, "skills", skillName, "shared");
|
||||||
|
|
||||||
|
await mkdir(sourceDir, { recursive: true });
|
||||||
|
await mkdir(sharedDir, { recursive: true });
|
||||||
|
await mkdir(agentDir, { recursive: true });
|
||||||
|
|
||||||
|
const skillContent = "---\nname: test-skill\n---\n\n# Test Skill\n";
|
||||||
|
await writeFile(path.join(sourceDir, "SKILL.md"), skillContent);
|
||||||
|
|
||||||
|
// Add a STRAY file in _source/ — this should NOT trigger stale detection
|
||||||
|
await writeFile(path.join(sourceDir, "STRAY.md"), "stray content");
|
||||||
|
|
||||||
|
// Add a STRAY file in shared/ — this should NOT trigger stale detection
|
||||||
|
await writeFile(path.join(sharedDir, "shared-stray.txt"), "shared stray");
|
||||||
|
|
||||||
|
// The generated root is EMPTY (no manifest, no files) — but we're testing
|
||||||
|
// that _source/ and shared/ stray files don't appear in stale detection.
|
||||||
|
// We can test this indirectly: verifyGenerated with no declared roots for
|
||||||
|
// this dir returns no stale errors about _source/ or shared/.
|
||||||
|
const result = await verifyGenerated(dir, {
|
||||||
|
// Override generated roots to be empty for this minimal test
|
||||||
|
generatedRootsOverride: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// No stale errors from _source/ or shared/
|
||||||
|
const sourceErrors = result.errors.filter(
|
||||||
|
(e) => e.includes("_source") || e.includes("shared"),
|
||||||
|
);
|
||||||
|
assert.equal(sourceErrors.length, 0, `unexpected stale errors from _source/shared: ${JSON.stringify(sourceErrors)}`);
|
||||||
|
} finally {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("verifyGenerated: stray file in agent dir IS flagged as stale", async () => {
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), "vg-stray-agent-"));
|
||||||
|
try {
|
||||||
|
const skillName = "test-skill";
|
||||||
|
const agentName = "claude-code";
|
||||||
|
|
||||||
|
const sourceDir = path.join(dir, "skills", skillName, "_source", agentName);
|
||||||
|
const agentDir = path.join(dir, "skills", skillName, agentName);
|
||||||
|
|
||||||
|
await mkdir(sourceDir, { recursive: true });
|
||||||
|
await mkdir(agentDir, { recursive: true });
|
||||||
|
|
||||||
|
const skillContent = "---\nname: test-skill\n---\n\n# Test Skill\n";
|
||||||
|
await writeFile(path.join(sourceDir, "SKILL.md"), skillContent);
|
||||||
|
|
||||||
|
// Generate the agent dir with proper content + manifest
|
||||||
|
const headerLine =
|
||||||
|
`<!-- ⚠️ GENERATED FILE – do not edit directly. ` +
|
||||||
|
`Edit the canonical source in skills/${skillName}/_source/${agentName}/SKILL.md ` +
|
||||||
|
`and run \`pnpm run sync:pi\`. -->`;
|
||||||
|
const generatedContent = `---\nname: test-skill\n---\n\n${headerLine}\n\n# Test Skill\n`;
|
||||||
|
await writeFile(path.join(agentDir, "SKILL.md"), generatedContent);
|
||||||
|
|
||||||
|
// Add a STRAY file in the agent dir — this SHOULD be flagged
|
||||||
|
await writeFile(path.join(agentDir, "STRAY.md"), "stray content");
|
||||||
|
|
||||||
|
// Write a manifest that does NOT include STRAY.md
|
||||||
|
const { buildManifest } = await import(`${SCRIPTS_DIR}/generate-skills.mjs`);
|
||||||
|
const manifest = await buildManifest(agentDir, `skills/${skillName}/${agentName}`);
|
||||||
|
// Remove STRAY.md from manifest (simulate pre-stray-add manifest)
|
||||||
|
manifest.files = manifest.files.filter((f) => f.path !== "STRAY.md");
|
||||||
|
await writeFile(
|
||||||
|
path.join(agentDir, ".generated-manifest.json"),
|
||||||
|
JSON.stringify(manifest, null, 2) + "\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await verifyGenerated(dir, {
|
||||||
|
generatedRootsOverride: [`skills/${skillName}/${agentName}`],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.ok, false, "should fail when stray file present");
|
||||||
|
const strayError = result.errors.some((e) => e.includes("STRAY.md"));
|
||||||
|
assert.ok(strayError, `STRAY.md should appear in errors: ${JSON.stringify(result.errors)}`);
|
||||||
|
} finally {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("verifyGenerated: .generated-manifest.json is excluded from stale-file detection", async () => {
|
||||||
|
// Even though .generated-manifest.json is in the generated root, it should
|
||||||
|
// not be considered a "stale file" just because it's not in the files list
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), "vg-manifest-self-"));
|
||||||
|
try {
|
||||||
|
const skillName = "test-skill";
|
||||||
|
const agentName = "pi";
|
||||||
|
|
||||||
|
const sourceDir = path.join(dir, "skills", skillName, "_source", agentName);
|
||||||
|
const agentDir = path.join(dir, "skills", skillName, agentName);
|
||||||
|
|
||||||
|
await mkdir(sourceDir, { recursive: true });
|
||||||
|
await mkdir(agentDir, { recursive: true });
|
||||||
|
|
||||||
|
const skillContent = "---\nname: test-skill\n---\n\n# Test Skill\n";
|
||||||
|
await writeFile(path.join(sourceDir, "SKILL.md"), skillContent);
|
||||||
|
|
||||||
|
const headerLine =
|
||||||
|
`<!-- ⚠️ GENERATED FILE – do not edit directly. ` +
|
||||||
|
`Edit the canonical source in skills/${skillName}/_source/${agentName}/SKILL.md ` +
|
||||||
|
`and run \`pnpm run sync:pi\`. -->`;
|
||||||
|
const generatedContent = `---\nname: test-skill\n---\n\n${headerLine}\n\n# Test Skill\n`;
|
||||||
|
await writeFile(path.join(agentDir, "SKILL.md"), generatedContent);
|
||||||
|
|
||||||
|
// Write manifest (will include SKILL.md, not itself)
|
||||||
|
const { buildManifest } = await import(`${SCRIPTS_DIR}/generate-skills.mjs`);
|
||||||
|
const manifest = await buildManifest(agentDir, `skills/${skillName}/${agentName}`);
|
||||||
|
await writeFile(
|
||||||
|
path.join(agentDir, ".generated-manifest.json"),
|
||||||
|
JSON.stringify(manifest, null, 2) + "\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await verifyGenerated(dir, {
|
||||||
|
generatedRootsOverride: [`skills/${skillName}/${agentName}`],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should pass — .generated-manifest.json is excluded from stale detection
|
||||||
|
const manifestErrors = result.errors.filter((e) => e.includes(".generated-manifest.json"));
|
||||||
|
assert.equal(manifestErrors.length, 0, `manifest file should not appear as stale: ${JSON.stringify(manifestErrors)}`);
|
||||||
|
} finally {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("verifyGenerated: missing file from manifest is flagged as deleted", async () => {
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), "vg-missing-file-"));
|
||||||
|
try {
|
||||||
|
const skillName = "test-skill";
|
||||||
|
const agentName = "cursor";
|
||||||
|
|
||||||
|
const sourceDir = path.join(dir, "skills", skillName, "_source", agentName);
|
||||||
|
const agentDir = path.join(dir, "skills", skillName, agentName);
|
||||||
|
|
||||||
|
await mkdir(sourceDir, { recursive: true });
|
||||||
|
await mkdir(agentDir, { recursive: true });
|
||||||
|
|
||||||
|
const skillContent = "---\nname: test-skill\n---\n\n# Test Skill\n";
|
||||||
|
await writeFile(path.join(sourceDir, "SKILL.md"), skillContent);
|
||||||
|
await writeFile(path.join(agentDir, "SKILL.md"), "generated content\n");
|
||||||
|
|
||||||
|
// Manifest claims templates/plan.md exists, but the file doesn't
|
||||||
|
const manifest = {
|
||||||
|
$schema: "https://ai-coding-skills.dev/schemas/generated-manifest/v1.json",
|
||||||
|
generator: "scripts/generate-skills.mjs",
|
||||||
|
generatedRoot: `skills/${skillName}/${agentName}`,
|
||||||
|
files: [
|
||||||
|
{ path: "SKILL.md", kind: "file", mode: "644", sha256: "aaa" },
|
||||||
|
{ path: "templates/plan.md", kind: "file", mode: "644", sha256: "bbb" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
await writeFile(
|
||||||
|
path.join(agentDir, ".generated-manifest.json"),
|
||||||
|
JSON.stringify(manifest, null, 2) + "\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await verifyGenerated(dir, {
|
||||||
|
generatedRootsOverride: [`skills/${skillName}/${agentName}`],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.ok, false, "should fail on missing file");
|
||||||
|
const missingError = result.errors.some((e) => e.includes("templates/plan.md"));
|
||||||
|
assert.ok(missingError, `missing file should appear in errors: ${JSON.stringify(result.errors)}`);
|
||||||
|
} finally {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("verifyGenerated: content mismatch is flagged", async () => {
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), "vg-content-mismatch-"));
|
||||||
|
try {
|
||||||
|
const skillName = "test-skill";
|
||||||
|
const agentName = "opencode";
|
||||||
|
|
||||||
|
const sourceDir = path.join(dir, "skills", skillName, "_source", agentName);
|
||||||
|
const agentDir = path.join(dir, "skills", skillName, agentName);
|
||||||
|
|
||||||
|
await mkdir(sourceDir, { recursive: true });
|
||||||
|
await mkdir(agentDir, { recursive: true });
|
||||||
|
|
||||||
|
await writeFile(path.join(sourceDir, "SKILL.md"), "---\nname: test-skill\n---\n\n# Original\n");
|
||||||
|
// Agent dir has DIFFERENT content than what manifest says
|
||||||
|
await writeFile(path.join(agentDir, "SKILL.md"), "---\nname: test-skill\n---\n\n# Modified!\n");
|
||||||
|
|
||||||
|
const manifest = {
|
||||||
|
$schema: "https://ai-coding-skills.dev/schemas/generated-manifest/v1.json",
|
||||||
|
generator: "scripts/generate-skills.mjs",
|
||||||
|
generatedRoot: `skills/${skillName}/${agentName}`,
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
path: "SKILL.md",
|
||||||
|
kind: "file",
|
||||||
|
mode: "644",
|
||||||
|
// SHA of the ORIGINAL content (not what's on disk)
|
||||||
|
sha256: "0000000000000000000000000000000000000000000000000000000000000000",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
await writeFile(
|
||||||
|
path.join(agentDir, ".generated-manifest.json"),
|
||||||
|
JSON.stringify(manifest, null, 2) + "\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await verifyGenerated(dir, {
|
||||||
|
generatedRootsOverride: [`skills/${skillName}/${agentName}`],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.ok, false, "should fail on content mismatch");
|
||||||
|
const mismatchError = result.errors.some((e) => e.includes("SKILL.md"));
|
||||||
|
assert.ok(mismatchError, `content mismatch should appear in errors: ${JSON.stringify(result.errors)}`);
|
||||||
|
} finally {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("verifyGenerated: manifest entry with wrong sha256 is flagged even when paths match", async () => {
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), "vg-manifest-sha-"));
|
||||||
|
try {
|
||||||
|
const skillName = "test-skill";
|
||||||
|
const agentName = "codex";
|
||||||
|
|
||||||
|
const sourceDir = path.join(dir, "skills", skillName, "_source", agentName);
|
||||||
|
const agentDir = path.join(dir, "skills", skillName, agentName);
|
||||||
|
|
||||||
|
await mkdir(sourceDir, { recursive: true });
|
||||||
|
await mkdir(agentDir, { recursive: true });
|
||||||
|
|
||||||
|
const content = "---\nname: test-skill\n---\n\n# Test Skill\n";
|
||||||
|
await writeFile(path.join(sourceDir, "SKILL.md"), content);
|
||||||
|
await writeFile(path.join(agentDir, "SKILL.md"), content);
|
||||||
|
|
||||||
|
// Manifest has correct path but deliberately wrong sha256 (simulates corrupted metadata)
|
||||||
|
const manifest = {
|
||||||
|
$schema: "https://ai-coding-skills.dev/schemas/generated-manifest/v1.json",
|
||||||
|
generator: "scripts/generate-skills.mjs",
|
||||||
|
generatedRoot: `skills/${skillName}/${agentName}`,
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
path: "SKILL.md",
|
||||||
|
kind: "file",
|
||||||
|
mode: "644",
|
||||||
|
sha256: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
await writeFile(
|
||||||
|
path.join(agentDir, ".generated-manifest.json"),
|
||||||
|
JSON.stringify(manifest, null, 2) + "\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await verifyGenerated(dir, {
|
||||||
|
generatedRootsOverride: [`skills/${skillName}/${agentName}`],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Even though file paths match, the sha256 mismatch in the manifest should be detected
|
||||||
|
assert.equal(result.ok, false, "should fail on sha256 mismatch in manifest");
|
||||||
|
const sha256Error = result.errors.some(
|
||||||
|
(e) => e.includes("sha256") || e.includes("SKILL.md"),
|
||||||
|
);
|
||||||
|
assert.ok(sha256Error, `sha256 mismatch should appear in errors: ${JSON.stringify(result.errors)}`);
|
||||||
|
} finally {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("verifyGenerated: manifest entry with wrong mode is flagged even when paths match", async () => {
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), "vg-manifest-mode-"));
|
||||||
|
try {
|
||||||
|
const skillName = "test-skill";
|
||||||
|
const agentName = "cursor";
|
||||||
|
|
||||||
|
const agentDir = path.join(dir, "skills", skillName, agentName);
|
||||||
|
await mkdir(agentDir, { recursive: true });
|
||||||
|
|
||||||
|
const content = "# Test Skill\n";
|
||||||
|
await writeFile(path.join(agentDir, "SKILL.md"), content);
|
||||||
|
|
||||||
|
// Build a correct manifest first (with real sha256)
|
||||||
|
const { buildManifest } = await import(`${SCRIPTS_DIR}/generate-skills.mjs`);
|
||||||
|
const correctManifest = await buildManifest(agentDir, `skills/${skillName}/${agentName}`);
|
||||||
|
|
||||||
|
// Tamper: change the mode field for the SKILL.md entry
|
||||||
|
const tamperedManifest = {
|
||||||
|
...correctManifest,
|
||||||
|
files: correctManifest.files.map((f) =>
|
||||||
|
f.path === "SKILL.md" ? { ...f, mode: "777" } : f,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
await writeFile(
|
||||||
|
path.join(agentDir, ".generated-manifest.json"),
|
||||||
|
JSON.stringify(tamperedManifest, null, 2) + "\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await verifyGenerated(dir, {
|
||||||
|
generatedRootsOverride: [`skills/${skillName}/${agentName}`],
|
||||||
|
});
|
||||||
|
|
||||||
|
// The mode mismatch in the manifest should be detected by diffManifests
|
||||||
|
assert.equal(result.ok, false, "should fail on mode mismatch in manifest");
|
||||||
|
const modeError = result.errors.some(
|
||||||
|
(e) => e.includes("mode") || e.includes("SKILL.md"),
|
||||||
|
);
|
||||||
|
assert.ok(modeError, `mode mismatch should appear in errors: ${JSON.stringify(result.errors)}`);
|
||||||
|
} finally {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* verify-docs-flow.mjs — documentation reading-flow and consistency verifier (M2, S-206)
|
||||||
|
*
|
||||||
|
* Asserts:
|
||||||
|
* (i) every docs/*.md file is linked from docs/README.md
|
||||||
|
* (ii) the reviewer CLI matrix is consistent across the four canonical sources
|
||||||
|
* (iii) the Telegram agent list matches the set of agents with helpers on disk
|
||||||
|
* (iv) all repository-relative paths referenced in README.md and docs/ exist
|
||||||
|
*
|
||||||
|
* Exits 0 when all checks pass; exits 1 with a report when any check fails.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node scripts/verify-docs-flow.mjs
|
||||||
|
* pnpm run verify:docs (wired via package.json)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
export const REPO_ROOT = path.resolve(__dirname, "..");
|
||||||
|
|
||||||
|
// ── (i) docs/README.md coverage ───────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check that every *.md file under docs/ is linked from docs/README.md.
|
||||||
|
* Returns an array of error strings (empty = pass).
|
||||||
|
*
|
||||||
|
* @param {string} repoRoot
|
||||||
|
* @returns {Promise<string[]>}
|
||||||
|
*/
|
||||||
|
export async function checkDocsIndexCoverage(repoRoot) {
|
||||||
|
const docsDir = path.join(repoRoot, "docs");
|
||||||
|
const readmePath = path.join(docsDir, "README.md");
|
||||||
|
|
||||||
|
if (!fs.existsSync(readmePath)) {
|
||||||
|
return ["docs/README.md is missing"];
|
||||||
|
}
|
||||||
|
|
||||||
|
const readmeContent = fs.readFileSync(readmePath, "utf8");
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
// Collect all *.md files under docs/ except README.md itself
|
||||||
|
const mdFiles = fs
|
||||||
|
.readdirSync(docsDir)
|
||||||
|
.filter((f) => f.endsWith(".md") && f !== "README.md");
|
||||||
|
|
||||||
|
for (const file of mdFiles) {
|
||||||
|
// Accept both ./FILE.md and FILE.md link forms
|
||||||
|
const linked =
|
||||||
|
readmeContent.includes(`](${file})`) ||
|
||||||
|
readmeContent.includes(`](./${file})`);
|
||||||
|
if (!linked) {
|
||||||
|
errors.push(`docs/README.md does not link to docs/${file}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── (ii) Reviewer matrix consistency ──────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical reviewer CLI names expected in workflow docs.
|
||||||
|
* Order matters for the consistency check.
|
||||||
|
*/
|
||||||
|
const EXPECTED_REVIEWER_CLIS = ["codex", "claude", "cursor", "opencode", "pi"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check that the reviewer CLI matrix is consistent across the four canonical
|
||||||
|
* workflow docs. Returns an array of error strings (empty = pass).
|
||||||
|
*
|
||||||
|
* @param {string} repoRoot
|
||||||
|
* @returns {Promise<string[]>}
|
||||||
|
*/
|
||||||
|
export async function checkReviewerMatrixConsistency(repoRoot) {
|
||||||
|
const canonical = [
|
||||||
|
"docs/CREATE-PLAN.md",
|
||||||
|
"docs/IMPLEMENT-PLAN.md",
|
||||||
|
"docs/DO-TASK.md",
|
||||||
|
"docs/REVIEWERS.md",
|
||||||
|
];
|
||||||
|
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
for (const docPath of canonical) {
|
||||||
|
const fullPath = path.join(repoRoot, docPath);
|
||||||
|
if (!fs.existsSync(fullPath)) {
|
||||||
|
errors.push(`${docPath} is missing`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const content = fs.readFileSync(fullPath, "utf8");
|
||||||
|
// Each canonical doc must reference all five CLIs
|
||||||
|
for (const cli of EXPECTED_REVIEWER_CLIS) {
|
||||||
|
if (!content.includes(`\`${cli}\``)) {
|
||||||
|
errors.push(`${docPath}: does not mention reviewer CLI \`${cli}\``);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── (iii) Telegram agent coverage ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agents that must have helpers on disk in skills/reviewer-runtime/.
|
||||||
|
* Non-Pi agents use the top-level directory; Pi uses the pi/ sub-directory.
|
||||||
|
*/
|
||||||
|
const TELEGRAM_AGENTS = [
|
||||||
|
{
|
||||||
|
id: "codex",
|
||||||
|
helperPath: "skills/reviewer-runtime/notify-telegram.sh",
|
||||||
|
telegramSectionKeyword: "Codex",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "claude-code",
|
||||||
|
helperPath: "skills/reviewer-runtime/notify-telegram.sh",
|
||||||
|
telegramSectionKeyword: "Claude Code",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "opencode",
|
||||||
|
helperPath: "skills/reviewer-runtime/notify-telegram.sh",
|
||||||
|
telegramSectionKeyword: "OpenCode",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cursor",
|
||||||
|
helperPath: "skills/reviewer-runtime/notify-telegram.sh",
|
||||||
|
telegramSectionKeyword: "Cursor",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pi",
|
||||||
|
helperPath: "skills/reviewer-runtime/pi/notify-telegram.sh",
|
||||||
|
telegramSectionKeyword: "Pi",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check that docs/TELEGRAM-NOTIFICATIONS.md documents every agent that has a
|
||||||
|
* notify-telegram.sh helper on disk. Returns an array of error strings.
|
||||||
|
*
|
||||||
|
* @param {string} repoRoot
|
||||||
|
* @returns {Promise<string[]>}
|
||||||
|
*/
|
||||||
|
export async function checkTelegramAgentCoverage(repoRoot) {
|
||||||
|
const telegramDoc = path.join(repoRoot, "docs", "TELEGRAM-NOTIFICATIONS.md");
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
if (!fs.existsSync(telegramDoc)) {
|
||||||
|
return ["docs/TELEGRAM-NOTIFICATIONS.md is missing"];
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = fs.readFileSync(telegramDoc, "utf8");
|
||||||
|
|
||||||
|
for (const agent of TELEGRAM_AGENTS) {
|
||||||
|
const helperFullPath = path.join(repoRoot, agent.helperPath);
|
||||||
|
if (!fs.existsSync(helperFullPath)) {
|
||||||
|
errors.push(
|
||||||
|
`Telegram agent ${agent.id}: helper ${agent.helperPath} does not exist on disk`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// The Telegram doc must mention this agent somewhere
|
||||||
|
if (!content.includes(agent.telegramSectionKeyword)) {
|
||||||
|
errors.push(
|
||||||
|
`docs/TELEGRAM-NOTIFICATIONS.md does not document agent: ${agent.telegramSectionKeyword}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pi-specific: must link to PI-COMMON-REVIEWER.md
|
||||||
|
if (!content.includes("PI-COMMON-REVIEWER.md")) {
|
||||||
|
errors.push(
|
||||||
|
"docs/TELEGRAM-NOTIFICATIONS.md must link to docs/PI-COMMON-REVIEWER.md"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── (iv) Repo-relative path references ────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Patterns to skip when checking path references — these are URLs and
|
||||||
|
* non-filesystem references that look like repo paths but aren't.
|
||||||
|
*/
|
||||||
|
const PATH_SKIP_PATTERNS = [
|
||||||
|
/^https?:\/\//, // HTTP URLs
|
||||||
|
/^\/tmp\//, // /tmp paths (runtime artifacts)
|
||||||
|
/^\$\{/, // shell variable expansions
|
||||||
|
/^~\//, // home directory paths
|
||||||
|
/^\.\.?\//, // relative ./ or ../ — checked differently
|
||||||
|
/node_modules/, // node_modules
|
||||||
|
/^[A-Z_]+_[A-Z_]+$/, // environment variable names
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract candidate repository-relative paths from markdown text.
|
||||||
|
* Looks for:
|
||||||
|
* - markdown links: [text](path)
|
||||||
|
* - inline code: `path`
|
||||||
|
* - bare paths starting with known top-level directories
|
||||||
|
*
|
||||||
|
* @param {string} content
|
||||||
|
* @returns {string[]} - candidate relative paths
|
||||||
|
*/
|
||||||
|
function extractRepoPaths(content) {
|
||||||
|
const candidates = new Set();
|
||||||
|
|
||||||
|
// Markdown links: [text](path) where path does not start with http/https
|
||||||
|
const linkRe = /\[(?:[^\]]*)\]\(([^)]+)\)/g;
|
||||||
|
let m;
|
||||||
|
while ((m = linkRe.exec(content)) !== null) {
|
||||||
|
const href = m[1].split("#")[0].trim(); // strip anchors
|
||||||
|
if (!href) continue;
|
||||||
|
if (PATH_SKIP_PATTERNS.some((p) => p.test(href))) continue;
|
||||||
|
// Normalize ./ prefix
|
||||||
|
const normalized = href.startsWith("./") ? href.slice(2) : href;
|
||||||
|
if (normalized.startsWith("../")) continue; // skip upward traversals
|
||||||
|
candidates.add(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...candidates];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check that all repository-relative paths referenced in README.md and docs/
|
||||||
|
* actually exist in the repository. Returns an array of error strings.
|
||||||
|
*
|
||||||
|
* @param {string} repoRoot
|
||||||
|
* @returns {Promise<string[]>}
|
||||||
|
*/
|
||||||
|
export async function checkRepoPathsExist(repoRoot) {
|
||||||
|
const errors = [];
|
||||||
|
const filesToCheck = [
|
||||||
|
path.join(repoRoot, "README.md"),
|
||||||
|
...fs
|
||||||
|
.readdirSync(path.join(repoRoot, "docs"))
|
||||||
|
.filter((f) => f.endsWith(".md"))
|
||||||
|
.map((f) => path.join(repoRoot, "docs", f)),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const filePath of filesToCheck) {
|
||||||
|
if (!fs.existsSync(filePath)) continue;
|
||||||
|
const content = fs.readFileSync(filePath, "utf8");
|
||||||
|
const relDoc = path.relative(repoRoot, filePath);
|
||||||
|
const candidates = extractRepoPaths(content);
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
// Skip things that obviously aren't repo paths
|
||||||
|
if (!candidate || candidate.length < 3) continue;
|
||||||
|
// Skip paths that look like markdown-only links (heading anchors)
|
||||||
|
if (candidate.startsWith("#")) continue;
|
||||||
|
// For links relative to docs/, resolve relative to docs/
|
||||||
|
let candidatePath;
|
||||||
|
if (relDoc.startsWith("docs/")) {
|
||||||
|
candidatePath = path.join(repoRoot, "docs", candidate);
|
||||||
|
// If not found there, try relative to repo root
|
||||||
|
if (!fs.existsSync(candidatePath)) {
|
||||||
|
candidatePath = path.join(repoRoot, candidate);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
candidatePath = path.join(repoRoot, candidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(candidatePath)) {
|
||||||
|
errors.push(`${relDoc}: broken repo path reference → ${candidate}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const repoRoot = REPO_ROOT;
|
||||||
|
const allErrors = [];
|
||||||
|
const checks = [
|
||||||
|
["docs/README.md coverage", checkDocsIndexCoverage],
|
||||||
|
["reviewer matrix consistency", checkReviewerMatrixConsistency],
|
||||||
|
["Telegram agent coverage", checkTelegramAgentCoverage],
|
||||||
|
["repo-relative path existence", checkRepoPathsExist],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [label, fn] of checks) {
|
||||||
|
const errors = await fn(repoRoot);
|
||||||
|
if (errors.length > 0) {
|
||||||
|
console.error(`\n[FAIL] ${label}:`);
|
||||||
|
for (const e of errors) {
|
||||||
|
console.error(` - ${e}`);
|
||||||
|
}
|
||||||
|
allErrors.push(...errors);
|
||||||
|
} else {
|
||||||
|
console.log(`[pass] ${label}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allErrors.length > 0) {
|
||||||
|
console.error(`\ndocs-flow: ${allErrors.length} error(s) found.`);
|
||||||
|
process.exit(1);
|
||||||
|
} else {
|
||||||
|
console.log("\ndocs-flow: all checks passed.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run main only when executed directly (not when imported by tests)
|
||||||
|
const isMain = process.argv[1] === fileURLToPath(import.meta.url);
|
||||||
|
if (isMain) {
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* verify-generated.mjs — drift detector for generator-owned files (M3, S-306)
|
||||||
|
*
|
||||||
|
* Verifies that every declared generated root on disk matches the content that
|
||||||
|
* the generator would produce from the current canonical sources.
|
||||||
|
*
|
||||||
|
* Two verification modes:
|
||||||
|
*
|
||||||
|
* Production mode (default, no generatedRootsOverride):
|
||||||
|
* Calls generateSkills() into a temp directory and compares the freshly
|
||||||
|
* generated output file-by-file against the on-disk generated roots.
|
||||||
|
* Detects ALL forms of drift:
|
||||||
|
* (a) Direct edits to generated files
|
||||||
|
* (b) Canonical source changes without running `pnpm run sync:pi`
|
||||||
|
* (c) Stale files added to a generated root
|
||||||
|
* (d) Files deleted from a generated root
|
||||||
|
*
|
||||||
|
* Test mode (generatedRootsOverride set):
|
||||||
|
* Uses `.generated-manifest.json` as the oracle (manifest-based checks).
|
||||||
|
* Suitable for unit tests that use artificial generated-root fixtures
|
||||||
|
* without a full canonical source tree.
|
||||||
|
*
|
||||||
|
* Walk scope: only the declared generated roots returned by generate-skills.mjs.
|
||||||
|
* - `skills/<skill>/_source/` and `skills/<skill>/shared/` are NEVER walked.
|
||||||
|
* - `.generated-manifest.json` is excluded from content comparison.
|
||||||
|
* - `node_modules/` is always excluded.
|
||||||
|
*
|
||||||
|
* Exit codes:
|
||||||
|
* 0 — all generated roots match
|
||||||
|
* 1 — one or more mismatches detected
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node scripts/verify-generated.mjs
|
||||||
|
* pnpm run verify:generated
|
||||||
|
*
|
||||||
|
* Exported:
|
||||||
|
* verifyGenerated(repoRoot, options?) → Promise<{ok: boolean, errors: string[]}>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import { lstat, mkdtemp, readdir, readFile, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
|
||||||
|
import { buildManifest, generateSkills, getGeneratedRoots } from "./generate-skills.mjs";
|
||||||
|
|
||||||
|
const REPO_ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..");
|
||||||
|
const MANIFEST_FILENAME = ".generated-manifest.json";
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function sha256File(absPath) {
|
||||||
|
const buf = await readFile(absPath);
|
||||||
|
return crypto.createHash("sha256").update(buf).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pathExists(p) {
|
||||||
|
try {
|
||||||
|
await lstat(p);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk a directory and return all file relative paths (excluding node_modules
|
||||||
|
* and the manifest file itself, which is handled separately).
|
||||||
|
*/
|
||||||
|
async function walkRootFiles(rootDir) {
|
||||||
|
const results = [];
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = await readdir(rootDir, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.name === "node_modules") continue;
|
||||||
|
if (entry.name === MANIFEST_FILENAME) continue; // manifest handled separately
|
||||||
|
|
||||||
|
const full = path.join(rootDir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
const sub = await walkSubDir(full, entry.name);
|
||||||
|
results.push(...sub);
|
||||||
|
} else if (entry.isFile()) {
|
||||||
|
results.push(entry.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function walkSubDir(dir, prefix) {
|
||||||
|
const results = [];
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = await readdir(dir, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.name === "node_modules") continue;
|
||||||
|
const relPath = `${prefix}/${entry.name}`;
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
const sub = await walkSubDir(full, relPath);
|
||||||
|
results.push(...sub);
|
||||||
|
} else if (entry.isFile()) {
|
||||||
|
results.push(relPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load and parse the .generated-manifest.json from a generated root.
|
||||||
|
* Returns null if missing or malformed.
|
||||||
|
*/
|
||||||
|
async function loadManifest(rootDir) {
|
||||||
|
const manifestPath = path.join(rootDir, MANIFEST_FILENAME);
|
||||||
|
try {
|
||||||
|
const raw = await readFile(manifestPath, "utf8");
|
||||||
|
const obj = JSON.parse(raw);
|
||||||
|
// Structural validation: must have $schema and generator markers
|
||||||
|
if (!obj.$schema || !obj.generator || !Array.isArray(obj.files)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify structural equality of two manifests (ignoring ephemeral fields).
|
||||||
|
* Returns array of difference descriptions, or empty array if equal.
|
||||||
|
*/
|
||||||
|
function diffManifests(expected, actual, rootRel) {
|
||||||
|
const diffs = [];
|
||||||
|
|
||||||
|
if (expected.$schema !== actual.$schema) {
|
||||||
|
diffs.push(`${rootRel}/.generated-manifest.json: $schema mismatch`);
|
||||||
|
}
|
||||||
|
if (expected.generator !== actual.generator) {
|
||||||
|
diffs.push(`${rootRel}/.generated-manifest.json: generator mismatch`);
|
||||||
|
}
|
||||||
|
if (expected.generatedRoot !== actual.generatedRoot) {
|
||||||
|
diffs.push(
|
||||||
|
`${rootRel}/.generated-manifest.json: generatedRoot mismatch ` +
|
||||||
|
`(expected ${expected.generatedRoot}, got ${actual.generatedRoot})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare files arrays structurally — paths, kind, mode, and sha256
|
||||||
|
const expByPath = new Map(expected.files.map((f) => [f.path, f]));
|
||||||
|
const actByPath = new Map(actual.files.map((f) => [f.path, f]));
|
||||||
|
|
||||||
|
for (const p of expByPath.keys()) {
|
||||||
|
if (!actByPath.has(p)) {
|
||||||
|
diffs.push(`${rootRel}/.generated-manifest.json: expected file entry missing: ${p}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const p of actByPath.keys()) {
|
||||||
|
if (!expByPath.has(p)) {
|
||||||
|
diffs.push(`${rootRel}/.generated-manifest.json: unexpected file entry: ${p}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For entries present in both, compare kind, mode, and sha256
|
||||||
|
for (const [p, expEntry] of expByPath) {
|
||||||
|
const actEntry = actByPath.get(p);
|
||||||
|
if (!actEntry) continue; // missing already reported above
|
||||||
|
|
||||||
|
if (expEntry.kind !== actEntry.kind) {
|
||||||
|
diffs.push(
|
||||||
|
`${rootRel}/.generated-manifest.json: ${p}: kind mismatch ` +
|
||||||
|
`(expected ${expEntry.kind}, got ${actEntry.kind})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (expEntry.mode !== actEntry.mode) {
|
||||||
|
diffs.push(
|
||||||
|
`${rootRel}/.generated-manifest.json: ${p}: mode mismatch ` +
|
||||||
|
`(expected ${expEntry.mode}, got ${actEntry.mode})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (expEntry.sha256 !== actEntry.sha256) {
|
||||||
|
diffs.push(
|
||||||
|
`${rootRel}/.generated-manifest.json: ${p}: sha256 mismatch ` +
|
||||||
|
`(expected ${expEntry.sha256.slice(0, 8)}…, got ${actEntry.sha256.slice(0, 8)}…)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return diffs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Core verifier ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify all declared generated roots against freshly generated output.
|
||||||
|
*
|
||||||
|
* Calls generateSkills() into a temp directory and compares the resulting
|
||||||
|
* files to the on-disk roots. Detects all drift types:
|
||||||
|
* - Canonical source changed without running `pnpm run sync:pi`
|
||||||
|
* - Generated file edited directly
|
||||||
|
* - Stale file added to generated root
|
||||||
|
* - Generated file deleted from root
|
||||||
|
*
|
||||||
|
* Does NOT walk _source/, shared/, or node_modules/.
|
||||||
|
*
|
||||||
|
* @param {string} repoRoot - Absolute path to repo root.
|
||||||
|
* @param {string[]} rootsRelative - Generated root paths to verify.
|
||||||
|
* @param {string[]} errors - Error array to append to.
|
||||||
|
*/
|
||||||
|
async function verifyWithFreshGeneration(repoRoot, rootsRelative, errors) {
|
||||||
|
const tmpDir = await mkdtemp(path.join(tmpdir(), "verify-gen-"));
|
||||||
|
try {
|
||||||
|
await generateSkills(repoRoot, { targetRoot: tmpDir });
|
||||||
|
|
||||||
|
for (const rootRel of rootsRelative) {
|
||||||
|
const freshRootAbs = path.join(tmpDir, rootRel);
|
||||||
|
const onDiskRootAbs = path.join(repoRoot, rootRel);
|
||||||
|
|
||||||
|
if (!(await pathExists(onDiskRootAbs))) {
|
||||||
|
errors.push(`generated root missing: ${rootRel}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const freshFiles = await walkRootFiles(freshRootAbs);
|
||||||
|
const onDiskFiles = await walkRootFiles(onDiskRootAbs);
|
||||||
|
|
||||||
|
const freshSet = new Set(freshFiles);
|
||||||
|
const diskSet = new Set(onDiskFiles);
|
||||||
|
|
||||||
|
// Files the generator produces but are absent on disk
|
||||||
|
for (const f of freshFiles) {
|
||||||
|
if (!diskSet.has(f)) {
|
||||||
|
errors.push(`${rootRel}/${f}: missing (expected per canonical sources — run \`pnpm run sync:pi\`)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// On-disk files the generator would NOT produce (stale)
|
||||||
|
for (const f of onDiskFiles) {
|
||||||
|
if (!freshSet.has(f)) {
|
||||||
|
errors.push(`${rootRel}/${f}: stale (not produced from canonical sources)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content comparison for files present in both sets
|
||||||
|
for (const f of freshFiles) {
|
||||||
|
if (!diskSet.has(f)) continue; // missing already reported
|
||||||
|
const freshHash = await sha256File(path.join(freshRootAbs, f));
|
||||||
|
const diskHash = await sha256File(path.join(onDiskRootAbs, f));
|
||||||
|
if (freshHash !== diskHash) {
|
||||||
|
errors.push(
|
||||||
|
`${rootRel}/${f}: content drift ` +
|
||||||
|
`(on-disk sha256=${diskHash.slice(0, 8)}…, expected=${freshHash.slice(0, 8)}… — run \`pnpm run sync:pi\`)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate .generated-manifest.json on disk matches what the generator produced
|
||||||
|
const freshManifestPath = path.join(freshRootAbs, MANIFEST_FILENAME);
|
||||||
|
const diskManifestPath = path.join(onDiskRootAbs, MANIFEST_FILENAME);
|
||||||
|
if (await pathExists(freshManifestPath)) {
|
||||||
|
if (!(await pathExists(diskManifestPath))) {
|
||||||
|
errors.push(`${rootRel}/${MANIFEST_FILENAME}: missing`);
|
||||||
|
} else {
|
||||||
|
const freshManifest = await loadManifest(freshRootAbs);
|
||||||
|
const diskManifest = await loadManifest(onDiskRootAbs);
|
||||||
|
if (!diskManifest) {
|
||||||
|
errors.push(`${rootRel}/${MANIFEST_FILENAME}: missing or malformed`);
|
||||||
|
} else if (freshManifest) {
|
||||||
|
const manifestDiffs = diffManifests(freshManifest, diskManifest, rootRel);
|
||||||
|
errors.push(...manifestDiffs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await rm(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify using .generated-manifest.json as the oracle (test / no-canonical-sources mode).
|
||||||
|
*
|
||||||
|
* Used when generatedRootsOverride is set: the test fixture creates generated
|
||||||
|
* roots with manifests but does not provide a full canonical source tree.
|
||||||
|
*
|
||||||
|
* For each root:
|
||||||
|
* 1. Load the on-disk .generated-manifest.json (report missing manifest).
|
||||||
|
* 2. For every file listed in the manifest: verify existence, mode, sha256.
|
||||||
|
* 3. Walk the root for any files NOT listed in the manifest (stale files).
|
||||||
|
* 4. Verify the manifest's own structural fields match expected schema.
|
||||||
|
*
|
||||||
|
* @param {string} repoRoot - Absolute path to repo root.
|
||||||
|
* @param {string[]} rootsRelative - Generated root paths to verify.
|
||||||
|
* @param {string[]} errors - Error array to append to.
|
||||||
|
*/
|
||||||
|
async function verifyWithManifest(repoRoot, rootsRelative, errors) {
|
||||||
|
for (const rootRel of rootsRelative) {
|
||||||
|
const rootAbs = path.join(repoRoot, rootRel);
|
||||||
|
|
||||||
|
// Check the root directory exists
|
||||||
|
if (!(await pathExists(rootAbs))) {
|
||||||
|
errors.push(`generated root missing: ${rootRel}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load on-disk manifest
|
||||||
|
const manifest = await loadManifest(rootAbs);
|
||||||
|
if (!manifest) {
|
||||||
|
errors.push(`${rootRel}/.generated-manifest.json: missing or malformed`);
|
||||||
|
// Can't verify files without a manifest; report all on-disk files as stale
|
||||||
|
const onDisk = await walkRootFiles(rootAbs);
|
||||||
|
for (const f of onDisk) {
|
||||||
|
errors.push(`${rootRel}/${f}: stale (no valid manifest)`);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build expected manifest from current disk state (structural check)
|
||||||
|
const expectedManifest = await buildManifest(rootAbs, rootRel);
|
||||||
|
const manifestDiffs = diffManifests(expectedManifest, manifest, rootRel);
|
||||||
|
errors.push(...manifestDiffs);
|
||||||
|
|
||||||
|
// Build maps for file checks
|
||||||
|
const manifestByPath = new Map(manifest.files.map((f) => [f.path, f]));
|
||||||
|
|
||||||
|
// Check each file listed in manifest
|
||||||
|
for (const entry of manifest.files) {
|
||||||
|
const fileAbs = path.join(rootAbs, entry.path);
|
||||||
|
|
||||||
|
if (!(await pathExists(fileAbs))) {
|
||||||
|
errors.push(`${rootRel}/${entry.path}: missing (listed in manifest)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check content hash
|
||||||
|
const actualHash = await sha256File(fileAbs);
|
||||||
|
if (actualHash !== entry.sha256) {
|
||||||
|
errors.push(
|
||||||
|
`${rootRel}/${entry.path}: content mismatch ` +
|
||||||
|
`(manifest sha256=${entry.sha256.slice(0, 8)}…, actual=${actualHash.slice(0, 8)}…)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check mode
|
||||||
|
const st = await lstat(fileAbs);
|
||||||
|
const actualMode = (st.mode & 0o777).toString(8).padStart(3, "0");
|
||||||
|
if (actualMode !== entry.mode) {
|
||||||
|
errors.push(
|
||||||
|
`${rootRel}/${entry.path}: mode mismatch ` +
|
||||||
|
`(manifest=${entry.mode}, actual=${actualMode})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect stale files: on-disk files not listed in manifest
|
||||||
|
const onDiskFiles = await walkRootFiles(rootAbs);
|
||||||
|
for (const relPath of onDiskFiles) {
|
||||||
|
if (!manifestByPath.has(relPath)) {
|
||||||
|
errors.push(`${rootRel}/${relPath}: stale (not in manifest)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify all declared generated roots in a repo.
|
||||||
|
*
|
||||||
|
* In production mode (no generatedRootsOverride): generates into a temp
|
||||||
|
* directory and compares file-by-file against on-disk roots. This detects
|
||||||
|
* both direct edits to generated files AND changes to canonical sources that
|
||||||
|
* were not followed by `pnpm run sync:pi`.
|
||||||
|
*
|
||||||
|
* In test mode (generatedRootsOverride set): uses manifest-based checks.
|
||||||
|
* Suitable for unit tests that provide artificial generated-root fixtures
|
||||||
|
* without a full canonical source tree.
|
||||||
|
*
|
||||||
|
* @param {string} [repoRoot] - Absolute path to repo root.
|
||||||
|
* @param {object} [options]
|
||||||
|
* @param {string[]} [options.generatedRootsOverride] - Override root list (test mode only).
|
||||||
|
* @returns {Promise<{ok: boolean, errors: string[]}>}
|
||||||
|
*/
|
||||||
|
export async function verifyGenerated(repoRoot = REPO_ROOT, options = {}) {
|
||||||
|
const rootsRelative = options.generatedRootsOverride ?? getGeneratedRoots();
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
if (options.generatedRootsOverride) {
|
||||||
|
// Test mode: no canonical source tree available — use manifest oracle
|
||||||
|
await verifyWithManifest(repoRoot, rootsRelative, errors);
|
||||||
|
} else {
|
||||||
|
// Production mode: generate fresh from canonical sources and compare
|
||||||
|
await verifyWithFreshGeneration(repoRoot, rootsRelative, errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: errors.length === 0, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CLI entry point ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||||
|
const result = await verifyGenerated(REPO_ROOT);
|
||||||
|
|
||||||
|
if (result.ok) {
|
||||||
|
console.log("verify:generated: all generated roots are up to date.");
|
||||||
|
process.exit(0);
|
||||||
|
} else {
|
||||||
|
console.error("verify:generated: drift detected:\n");
|
||||||
|
for (const err of result.errors) {
|
||||||
|
console.error(` ✗ ${err}`);
|
||||||
|
}
|
||||||
|
console.error(`\n${result.errors.length} error(s). Run \`pnpm run sync:pi\` to regenerate.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+168
@@ -0,0 +1,168 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# shellcheck source=lib/portable.sh
|
||||||
|
source "$(dirname "${BASH_SOURCE[0]}")/lib/portable.sh"
|
||||||
|
|
||||||
|
REQUIRED_FILES=(
|
||||||
|
"docs/PI-RESEARCH.md"
|
||||||
|
"docs/PI.md"
|
||||||
|
"docs/PI-SUPERPOWERS.md"
|
||||||
|
"docs/PI-COMMON-REVIEWER.md"
|
||||||
|
"skills/atlassian/pi/SKILL.md"
|
||||||
|
"skills/create-plan/pi/SKILL.md"
|
||||||
|
"skills/create-plan/pi/templates/continuation-runbook.md"
|
||||||
|
"skills/create-plan/pi/templates/milestone-plan.md"
|
||||||
|
"skills/create-plan/pi/templates/story-tracker.md"
|
||||||
|
"skills/do-task/pi/SKILL.md"
|
||||||
|
"skills/do-task/pi/templates/task-plan.md"
|
||||||
|
"skills/implement-plan/pi/SKILL.md"
|
||||||
|
"skills/web-automation/pi/SKILL.md"
|
||||||
|
"skills/reviewer-runtime/pi/run-review.sh"
|
||||||
|
"skills/reviewer-runtime/pi/notify-telegram.sh"
|
||||||
|
"scripts/install-pi-package.sh"
|
||||||
|
"pi-package/skills/atlassian/SKILL.md"
|
||||||
|
"pi-package/skills/create-plan/SKILL.md"
|
||||||
|
"pi-package/skills/do-task/SKILL.md"
|
||||||
|
"pi-package/skills/implement-plan/SKILL.md"
|
||||||
|
"pi-package/skills/web-automation/SKILL.md"
|
||||||
|
"package.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
# These required-file checks are intentionally hard failures: removing any
|
||||||
|
# required artifact should make this script exit non-zero immediately.
|
||||||
|
for file in "${REQUIRED_FILES[@]}"; do
|
||||||
|
test -f "$file"
|
||||||
|
done
|
||||||
|
|
||||||
|
test -x skills/reviewer-runtime/pi/run-review.sh
|
||||||
|
test -x skills/reviewer-runtime/pi/notify-telegram.sh
|
||||||
|
test -x scripts/install-pi-package.sh
|
||||||
|
find skills/web-automation/pi/scripts -type f -print -quit | grep -q .
|
||||||
|
find skills/atlassian/pi/scripts -type f -print -quit | grep -q .
|
||||||
|
|
||||||
|
for file in skills/create-plan/pi/SKILL.md skills/do-task/pi/SKILL.md skills/implement-plan/pi/SKILL.md; do
|
||||||
|
grep -q 'docs/PI-SUPERPOWERS.md' "$file"
|
||||||
|
grep -q 'docs/PI-COMMON-REVIEWER.md' "$file"
|
||||||
|
# shellcheck disable=SC2016
|
||||||
|
grep -q 'Reviewer CLI: `codex`, `claude`, `cursor`, `opencode`, `pi`, or `skip`' "$file"
|
||||||
|
grep -q 'pi --no-session --no-skills --no-prompt-templates --no-extensions --no-context-files' "$file"
|
||||||
|
grep -q -- '--tools read,grep,find,ls -p' "$file"
|
||||||
|
grep -q 'pi --list-models \[search\]' "$file"
|
||||||
|
done
|
||||||
|
|
||||||
|
grep -q 'reviewer model is configured independently' docs/PI-COMMON-REVIEWER.md
|
||||||
|
grep -q 'provider-qualified model IDs' docs/PI-COMMON-REVIEWER.md
|
||||||
|
# shellcheck disable=SC2016
|
||||||
|
grep -q 'MUST NOT include `write`, `edit`, or `bash`' docs/PI-COMMON-REVIEWER.md
|
||||||
|
grep -q 'Reviewer CLI | codex \\| claude \\| cursor \\| opencode \\| pi' skills/do-task/pi/templates/task-plan.md
|
||||||
|
|
||||||
|
grep -q 'pi-package/skills/atlassian/scripts' skills/atlassian/pi/SKILL.md
|
||||||
|
grep -q 'pi-package/skills/web-automation/scripts' skills/web-automation/pi/SKILL.md
|
||||||
|
# shellcheck disable=SC2016
|
||||||
|
grep -q 'local checkout package install keeps the runtime in `pi-package/skills/<skill>/scripts`' docs/PI.md
|
||||||
|
|
||||||
|
grep -q 'install-pi-package.sh --global' docs/PI.md
|
||||||
|
grep -q 'install-pi-package.sh --local' docs/PI.md
|
||||||
|
grep -q 'install-pi-package.sh --global' README.md
|
||||||
|
grep -q 'install-pi-package.sh --local' README.md
|
||||||
|
|
||||||
|
for family in atlassian create-plan do-task implement-plan web-automation; do
|
||||||
|
source_dir="skills/${family}/pi"
|
||||||
|
source_skill_md="${source_dir}/SKILL.md"
|
||||||
|
skill_name=$(awk '/^name:/ { print $2; exit }' "$source_skill_md")
|
||||||
|
mirror_dir="pi-package/skills/${skill_name}"
|
||||||
|
mirror_skill_md="${mirror_dir}/SKILL.md"
|
||||||
|
|
||||||
|
test -d "$mirror_dir"
|
||||||
|
test -f "$mirror_skill_md"
|
||||||
|
test "$skill_name" = "$(basename "$mirror_dir")"
|
||||||
|
test "$skill_name" = "$(awk '/^name:/ { print $2; exit }' "$mirror_skill_md")"
|
||||||
|
|
||||||
|
diff -qr \
|
||||||
|
--exclude '.DS_Store' \
|
||||||
|
--exclude 'node_modules' \
|
||||||
|
--exclude '.generated-manifest.json' \
|
||||||
|
--exclude 'package.json' \
|
||||||
|
"$source_dir" "$mirror_dir" >/dev/null
|
||||||
|
|
||||||
|
while IFS= read -r -d '' source_path; do
|
||||||
|
rel_path=${source_path#"$source_dir"/}
|
||||||
|
mirror_path="${mirror_dir}/${rel_path}"
|
||||||
|
test -e "$mirror_path"
|
||||||
|
test "$(portable_stat_perms "$source_path")" = "$(portable_stat_perms "$mirror_path")"
|
||||||
|
done < <(find "$source_dir" \
|
||||||
|
\( -name '.DS_Store' -o -name 'node_modules' \) -prune -o \
|
||||||
|
-mindepth 1 -print0)
|
||||||
|
done
|
||||||
|
|
||||||
|
# SC2251: restructured to avoid ! outside condition
|
||||||
|
if grep -nE 'update_plan|plan mode|sub-agent|subagents' \
|
||||||
|
skills/create-plan/pi/SKILL.md \
|
||||||
|
skills/do-task/pi/SKILL.md \
|
||||||
|
skills/implement-plan/pi/SKILL.md; then
|
||||||
|
echo "Error: pi SKILL.md files must not contain Codex-specific terms" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
node <<'EOF'
|
||||||
|
const fs = require("fs");
|
||||||
|
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"));
|
||||||
|
|
||||||
|
if (!pkg.pi || !Array.isArray(pkg.pi.skills) || pkg.pi.skills.length !== 5) {
|
||||||
|
console.error("package.json must define pi.skills with exactly 5 entries");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedSkills = [
|
||||||
|
"./pi-package/skills/atlassian",
|
||||||
|
"./pi-package/skills/create-plan",
|
||||||
|
"./pi-package/skills/do-task",
|
||||||
|
"./pi-package/skills/implement-plan",
|
||||||
|
"./pi-package/skills/web-automation",
|
||||||
|
];
|
||||||
|
|
||||||
|
if (JSON.stringify(pkg.pi.skills) !== JSON.stringify(expectedSkills)) {
|
||||||
|
console.error("package.json must point pi.skills at the pi-package mirror");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(pkg.files) || pkg.files.length === 0) {
|
||||||
|
console.error("package.json must define a non-empty files allowlist");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const requiredFile of [
|
||||||
|
"pi-package/skills",
|
||||||
|
"docs/PI-COMMON-REVIEWER.md",
|
||||||
|
"scripts/install-pi-package.sh",
|
||||||
|
"scripts/generate-skills.mjs",
|
||||||
|
]) {
|
||||||
|
if (!pkg.files.includes(requiredFile)) {
|
||||||
|
console.error(`package.json files must include ${requiredFile}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const forbiddenFile of [
|
||||||
|
"skills/atlassian/pi",
|
||||||
|
"skills/create-plan/pi",
|
||||||
|
"skills/do-task/pi",
|
||||||
|
"skills/implement-plan/pi",
|
||||||
|
"skills/web-automation/pi",
|
||||||
|
]) {
|
||||||
|
if (pkg.files.includes(forbiddenFile)) {
|
||||||
|
console.error(`package.json files must not include ${forbiddenFile}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(pkg.keywords) || !pkg.keywords.includes("pi-package")) {
|
||||||
|
console.error("package.json must include the pi-package keyword");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("package metadata ok");
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "pi resources verified"
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user