fix(pi): package warning-free skill mirror and docs

This commit is contained in:
Stefano Fiorini
2026-04-23 17:32:26 -05:00
parent 0456c51291
commit 3429dac894
51 changed files with 6703 additions and 155 deletions
+4 -2
View File
@@ -116,21 +116,23 @@ Each skill should explicitly document agent compatibility and any prerequisites
The repo root now includes a pi package manifest that ships only the pi-specific surface:
- `skills/*/pi/` for the five supported skill families
- `pi-package/skills/*/` for the five packaged Pi skills
- `skills/reviewer-runtime/pi/`
- `docs/PI*.md`
- `scripts/sync-pi-package-skills.sh`
- `scripts/verify-pi-resources.sh`
Install it into project-local pi settings from this checkout with:
```bash
pi install -l /absolute/path/to/ai-coding-skills
pi install -l "$(pwd)"
pi list
```
Before publishing or sharing a tarball, run:
```bash
./scripts/sync-pi-package-skills.sh
./scripts/verify-pi-resources.sh
npm pack --dry-run --json
```
+69
View File
@@ -0,0 +1,69 @@
# 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.
## 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).
+3 -2
View File
@@ -32,7 +32,7 @@ Important discovery details from the skills docs:
- 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, both for local copies and for package-based installs.
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
@@ -46,7 +46,7 @@ Relevant package behaviors:
- 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.
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
@@ -84,6 +84,7 @@ Implication for this repo:
## 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.
+72 -46
View File
@@ -2,30 +2,76 @@
## Purpose
The pi workflow variants in this repo depend on two shared pieces of setup:
This document is only about making Obra Superpowers visible to Pi.
- Obra Superpowers skills
- reviewer-runtime helper scripts
If you need the shared reviewer helpers (`run-review.sh`, `notify-telegram.sh`), use [PI-COMMON-REVIEWER.md](./PI-COMMON-REVIEWER.md) instead.
This guide documents how to make both available to pi without inventing skill-specific setup instructions in three different workflow docs.
## What Pi Needs
## Superpowers Setup
The workflow-heavy Pi skills depend on Superpowers such as:
Pi can load skills from `~/.pi/agent/skills/`, `~/.agents/skills/`, `.pi/skills/`, `.agents/skills/`, package manifests, and settings-defined skill paths. Because of that, there are two supported ways to expose Obra Superpowers to pi.
- `brainstorming`
- `writing-plans`
- `executing-plans`
- `test-driven-development`
- `verification-before-completion`
- `finishing-a-development-branch`
- `using-git-worktrees`
### Option 1: Reuse `~/.agents/skills/`
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.
If you already expose Superpowers through a shared root such as:
## Verify An Existing Install
If you think Superpowers may already be installed, check the common shared-root setup first:
```bash
~/.agents/skills/superpowers -> ~/.codex/superpowers/skills
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
```
pi can discover those skill directories directly. This is only an example path, not a requirement; any shared skill root that pi can read is fine. It is simply the easiest path when Codex and pi share the same machine.
If those pass, Pi can usually reuse the same install directly.
### Option 2: Add An Explicit Pi Settings Entry
To verify a Pi-native install instead:
If you want pi to load a different path, add it in either `~/.pi/agent/settings.json` or `.pi/settings.json`:
```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
{
@@ -36,49 +82,29 @@ If you want pi to load a different path, add it in either `~/.pi/agent/settings.
}
```
Use this when the shared `~/.agents/skills/` root is missing or when you want a pi-specific override.
## Post-Install Verification
## Reviewer Runtime Setup
After any install path, verify the specific skills your workflow needs.
The workflow-heavy pi variants expect the helper scripts from `skills/reviewer-runtime/pi/` to be installed in one of these locations:
1. `.pi/skills/reviewer-runtime/pi/`
2. `~/.pi/agent/skills/reviewer-runtime/pi/`
Supported helper files:
- `run-review.sh`
- `notify-telegram.sh`
Example global install:
Planning-focused check:
```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
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
```
Example project-local install:
Execution-focused check:
```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
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
```
## Telegram Notifications
## What This Doc Does Not Cover
If you want pi workflow skills to send completion messages, configure:
- reviewer-runtime helper installation
- Telegram helper installation
- package layout or Pi package installation
- `TELEGRAM_BOT_TOKEN`
- `TELEGRAM_CHAT_ID`
The shared notification behavior matches the existing repo guidance in [TELEGRAM-NOTIFICATIONS.md](./TELEGRAM-NOTIFICATIONS.md).
## What This Does Not Assume
- pi does not automatically provide Codex `update_plan` semantics
- pi does not automatically provide plan mode or worktree workflows
- pi does not require extensions for the base workflow skills in this repo
The pi variants must therefore spell out their workflow steps directly and only rely on dependencies documented here.
Those belong in [PI-COMMON-REVIEWER.md](./PI-COMMON-REVIEWER.md) and [PI.md](./PI.md).
+70 -82
View File
@@ -2,9 +2,26 @@
## Purpose
This repo now treats pi as a first-class target alongside Codex, Claude Code, Cursor, and OpenCode.
This repo treats pi as a first-class target alongside Codex, Claude Code, Cursor, and OpenCode.
The pi support surface is built around the same family layout already used elsewhere in this repo:
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
Author Pi variants under:
- `skills/atlassian/pi/`
- `skills/create-plan/pi/`
@@ -12,108 +29,79 @@ The pi support surface is built around the same family layout already used elsew
- `skills/implement-plan/pi/`
- `skills/web-automation/pi/`
Shared pi guidance lives in:
These are the directories to edit by hand.
- [PI-RESEARCH.md](./PI-RESEARCH.md)
- [PI-SUPERPOWERS.md](./PI-SUPERPOWERS.md)
### Package Mirror
## Support Strategy
The package exposes:
### Variant Layout
- `pi-package/skills/atlassian/`
- `pi-package/skills/create-plan/`
- `pi-package/skills/do-task/`
- `pi-package/skills/implement-plan/`
- `pi-package/skills/web-automation/`
Each skill family gets a dedicated `pi` variant instead of a pointer-only shim. That keeps pi-specific wording, installation paths, and workflow constraints reviewable in-repo.
Those directories are generated from the source variants by [`scripts/sync-pi-package-skills.sh`](../scripts/sync-pi-package-skills.sh).
### Shared Workflow Guidance
### Shared Setup Docs
The workflow-heavy skills (`create-plan`, `do-task`, `implement-plan`) refer to [PI-SUPERPOWERS.md](./PI-SUPERPOWERS.md) for:
Workflow-heavy Pi skills split their shared setup across two docs:
- exposing Obra Superpowers to pi
- installing the shared reviewer runtime for pi
- Telegram notification setup expectations
- [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 Strategy
## Package Install
V1 uses one repo-level pi package rather than per-skill packages. The package surface is intentionally narrow:
- only pi skill variants
- shared pi docs
- pi reviewer-runtime helper sources
- a verification script
The package metadata and allowlist live at repo root so `pi install -l /absolute/path/to/ai-coding-skills` works without extra wrapping.
Current manifest shape:
- package name: `ai-coding-skills-pi`
- discovery keyword: `pi-package`
- `pi.skills`: the five `skills/<family>/pi` directories
- `files`: allowlisted to pi skill variants, pi docs, pi reviewer runtime helpers, and `scripts/verify-pi-resources.sh`
## Install Options
### Local Skill Copy
Copy a single variant into `~/.pi/agent/skills/<skill>/` or `.pi/skills/<skill>/` if you only want one skill.
### Shared Skill Roots
Pi can load from `~/.agents/skills/`, `.agents/skills/`, `.pi/skills/`, `~/.pi/agent/skills/`, package manifests, or settings-defined skill paths. That makes it possible to keep shared skill roots or repo-local installs.
### Package Install
The intended repo-level install flow is:
The repo-level package manifest lives at root so Pi can install directly from this checkout:
```bash
pi install -l /absolute/path/to/ai-coding-skills
pi install -l "$(pwd)"
pi list
```
Verify the package surface before packaging or publishing:
The package surface intentionally ships:
- `pi-package/skills/**`
- `skills/reviewer-runtime/pi/**`
- `docs/PI*.md`
- `scripts/verify-pi-resources.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
./scripts/sync-pi-package-skills.sh
./scripts/verify-pi-resources.sh
npm pack --dry-run --json
```
The verifier is responsible for catching:
- missing mirror directories
- source/mirror drift
- package metadata pointing at the wrong skill roots
- missing shared Pi docs
## Extension Decision
Pi extensions are powerful enough to add todo tracking, review orchestration helpers, or sub-agent-like flows, but they are not required for v1.
Pi extensions are still optional for this repo.
Current v1 decision:
- ship usable pi skill variants without extensions
- document the extension opportunities explicitly
- only add an extension later if it removes meaningful friction that documentation and helper scripts cannot address cleanly
See [Extension Assessment](#extension-assessment) for the final v1 decision.
## Extension Assessment
The upstream pi extension docs make three candidate areas realistic for this repo:
| Candidate | What It Could Add | V1 Decision | Rationale |
|-----------|-------------------|-------------|-----------|
| Todo tracking extension | A pi-native task list or command for story state transitions while running `create-plan`, `do-task`, or `implement-plan` | Defer | The current file-backed trackers already work across agents, so shipping an extension now would add maintenance cost without unlocking base usability. |
| Reviewer-loop extension | A pi-native wrapper around the reviewer-runtime flow, potentially exposing review runs as a command or tool instead of shell glue | Defer | The standalone `run-review.sh` helper is enough for v1, and deferring keeps the package surface smaller while usage patterns are still unknown. |
| Sub-agent orchestration extension | Higher-level automation for worktree setup, milestone stepping, or multi-run coordination | Defer | This is the highest-complexity option and most likely to overfit early assumptions before the pi variants have real usage feedback. |
### Final V1 Decision
All extension candidates are deferred for v1.
The v1 package should ship:
- pi skill variants
- shared pi docs
- pi reviewer-runtime helper scripts
- verification tooling
It should not require any pi extension to be usable.
The threshold for a follow-up extension should be concrete, repeated friction that cannot be addressed cleanly with documentation, templates, or helper scripts alone.
## Scope Notes
- Pi variants are written for pi-native behavior, not as thin copies of Codex instructions.
- The workflow-heavy skills assume pi can load referenced skills, but they do not assume pi has built-in equivalents for Codex-specific concepts like `update_plan`.
- Shared helper scripts are documented with deterministic install paths so workflow instructions stay reproducible.
- 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
+3 -2
View File
@@ -8,9 +8,10 @@ This directory contains user-facing docs for each skill.
- [CREATE-PLAN.md](./CREATE-PLAN.md) — Includes requirements, install, verification, and execution workflow for create-plan.
- [DO-TASK.md](./DO-TASK.md) — Single-prompt end-to-end execution with dual reviewer loops (plan + implementation), TDD-first, single task commit. Sibling of create-plan/implement-plan scoped to ad-hoc tasks.
- [IMPLEMENT-PLAN.md](./IMPLEMENT-PLAN.md) — Includes requirements, install, verification, and milestone review workflow for implement-plan.
- [PI.md](./PI.md) — Pi support overview, variant strategy, package direction, and extension decision.
- [PI.md](./PI.md) — Pi support overview, source-vs-package layout, install flow, and extension decision.
- [PI-RESEARCH.md](./PI-RESEARCH.md) — Source-backed pi findings that inform the repo's pi variants and packaging choices.
- [PI-SUPERPOWERS.md](./PI-SUPERPOWERS.md) — Shared Obra Superpowers and reviewer-runtime setup for pi workflow skills.
- [PI-SUPERPOWERS.md](./PI-SUPERPOWERS.md) — How to install or verify Obra Superpowers for Pi.
- [PI-COMMON-REVIEWER.md](./PI-COMMON-REVIEWER.md) — How to install or verify the shared reviewer-runtime helpers for Pi workflow skills.
- [TELEGRAM-NOTIFICATIONS.md](./TELEGRAM-NOTIFICATIONS.md) — Shared Telegram notification setup used by reviewer-driven skills.
- [WEB-AUTOMATION.md](./WEB-AUTOMATION.md) — Includes requirements, install, dependency verification, and usage examples for web-automation.
+8 -10
View File
@@ -20,22 +20,20 @@
"docs/PI.md",
"docs/PI-RESEARCH.md",
"docs/PI-SUPERPOWERS.md",
"docs/PI-COMMON-REVIEWER.md",
"docs/WEB-AUTOMATION.md",
"skills/atlassian/pi",
"skills/create-plan/pi",
"skills/do-task/pi",
"skills/implement-plan/pi",
"skills/web-automation/pi",
"pi-package/skills",
"skills/reviewer-runtime/pi",
"scripts/sync-pi-package-skills.sh",
"scripts/verify-pi-resources.sh"
],
"pi": {
"skills": [
"./skills/atlassian/pi",
"./skills/create-plan/pi",
"./skills/do-task/pi",
"./skills/implement-plan/pi",
"./skills/web-automation/pi"
"./pi-package/skills/atlassian",
"./pi-package/skills/create-plan",
"./pi-package/skills/do-task",
"./pi-package/skills/implement-plan",
"./pi-package/skills/web-automation"
]
}
}
+93
View File
@@ -0,0 +1,93 @@
---
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.
---
# 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).
## Prerequisite Check (MANDATORY)
Run inside the installed skill directory. The command block below uses the global install path; for a project-local install, replace `~/.pi/agent/skills/atlassian/` with `.pi/skills/atlassian/`.
```bash
cd ~/.pi/agent/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,20 @@
{
"name": "atlassian-skill-scripts",
"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"
}
+361
View File
@@ -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,92 @@
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,332 @@
import process from "node:process";
import { pathToFileURL } from "node:url";
import { Command } from "commander";
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, OutputFormat, Writer } from "./types.js";
type CliContext = {
cwd?: string;
env?: NodeJS.ProcessEnv;
fetchImpl?: FetchLike;
stdout?: Writer;
stderr?: Writer;
};
function resolveFormat(format: string | undefined): OutputFormat {
return format === "text" ? "text" : "json";
}
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,52 @@
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,292 @@
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 {
ok: true,
dryRun: true,
data: 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 {
ok: true,
dryRun: true,
data: 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 {
ok: true,
dryRun: true,
data: 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,13 @@
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,69 @@
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,86 @@
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,264 @@
import { markdownToAdf } from "./adf.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 {
ok: true,
dryRun: true,
data: 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 {
ok: true,
dryRun: true,
data: 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 {
ok: true,
dryRun: true,
data: 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 {
ok: true,
dryRun: true,
data: 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,44 @@
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,85 @@
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 {
ok: true,
dryRun: true,
data: 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,35 @@
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"]
}
+213
View File
@@ -0,0 +1,213 @@
---
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.
---
# 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:
1. Which CLI should review the plan?
- `codex`
- `claude`
- `cursor`
- `opencode`
- `skip`
2. Which model?
3. Max review rounds? Default: `10`
Store `REVIEWER_CLI`, `REVIEWER_MODEL`, and `MAX_ROUNDS` for the review loop.
### 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,135 @@
# 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,101 @@
# [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,66 @@
# 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.
+190
View File
@@ -0,0 +1,190 @@
---
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.
---
# 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:
1. Reviewer CLI: `codex`, `claude`, `cursor`, `opencode`, or `skip`
2. Reviewer model
3. Max rounds, default `10`
Store `REVIEWER_CLI`, `REVIEWER_MODEL`, and `MAX_ROUNDS`.
### 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,128 @@
# 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 |
| 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.
+221
View File
@@ -0,0 +1,221 @@
---
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.
---
# 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:
1. Reviewer CLI: `codex`, `claude`, `cursor`, `opencode`, or `skip`
2. Reviewer model
3. Max rounds, default `10`
Store `REVIEWER_CLI`, `REVIEWER_MODEL`, and `MAX_ROUNDS`.
### 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.
+116
View File
@@ -0,0 +1,116 @@
---
name: web-automation
description: Browse and scrape web pages using Playwright-compatible CloakBrowser. Use when automating web workflows, extracting rendered page content, handling authenticated sessions, or running multi-step browser flows.
---
# Web Automation with CloakBrowser (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).
## 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 CloakBrowser and Playwright Core are installed and wired correctly. The command block below uses the global install path; for a project-local install, replace `~/.pi/agent/skills/web-automation/` with `.pi/skills/web-automation/`.
```bash
cd ~/.pi/agent/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/agent/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,575 @@
#!/usr/bin/env npx tsx
/**
* Authentication handler for web automation
* Supports generic form login and Microsoft SSO (MSAL)
*
* Usage:
* npx tsx auth.ts --url "https://example.com/login" --type form
* npx tsx auth.ts --url "https://example.com" --type msal
* npx tsx auth.ts --url "https://example.com" --type auto
*/
import { getPage, launchBrowser } from './browse.js';
import parseArgs from 'minimist';
import type { Page, BrowserContext } from 'playwright-core';
import { createInterface } from 'readline';
// Types
type AuthType = 'auto' | 'form' | 'msal';
interface AuthOptions {
url: string;
authType: AuthType;
credentials?: {
username: string;
password: string;
};
headless?: boolean;
timeout?: number;
}
interface AuthResult {
success: boolean;
finalUrl: string;
authType: AuthType;
message: string;
}
// Get credentials from environment or options
function getCredentials(options?: {
username?: string;
password?: string;
}): { username: string; password: string } | null {
const username = options?.username || process.env.CLOAKBROWSER_USERNAME;
const password = options?.password || process.env.CLOAKBROWSER_PASSWORD;
if (!username || !password) {
return null;
}
return { username, password };
}
// Prompt user for input (for MFA or credentials)
async function promptUser(question: string, hidden = false): Promise<string> {
const rl = createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
if (hidden) {
process.stdout.write(question);
// Note: This is a simple implementation. For production, use a proper hidden input library
}
rl.question(question, (answer) => {
rl.close();
resolve(answer);
});
});
}
// Detect authentication type from page
async function detectAuthType(page: Page): Promise<AuthType> {
const url = page.url();
// Check for Microsoft login
if (
url.includes('login.microsoftonline.com') ||
url.includes('login.live.com') ||
url.includes('login.windows.net')
) {
return 'msal';
}
// Check for common form login patterns
const hasLoginForm = await page.evaluate(() => {
const passwordField = document.querySelector(
'input[type="password"], input[name*="password"], input[id*="password"]'
);
const usernameField = document.querySelector(
'input[type="email"], input[type="text"][name*="user"], input[type="text"][name*="email"], input[id*="user"], input[id*="email"]'
);
return !!(passwordField && usernameField);
});
if (hasLoginForm) {
return 'form';
}
return 'auto';
}
// Handle generic form login
async function handleFormLogin(
page: Page,
credentials: { username: string; password: string },
timeout: number
): Promise<boolean> {
console.log('Attempting form login...');
// Find and fill username/email field
const usernameSelectors = [
'input[type="email"]',
'input[name*="user" i]',
'input[name*="email" i]',
'input[id*="user" i]',
'input[id*="email" i]',
'input[autocomplete="username"]',
'input[type="text"]:first-of-type',
];
let usernameField = null;
for (const selector of usernameSelectors) {
usernameField = await page.$(selector);
if (usernameField) break;
}
if (!usernameField) {
console.error('Could not find username/email field');
return false;
}
await usernameField.fill(credentials.username);
console.log('Filled username field');
// Find and fill password field
const passwordSelectors = [
'input[type="password"]',
'input[name*="password" i]',
'input[id*="password" i]',
'input[autocomplete="current-password"]',
];
let passwordField = null;
for (const selector of passwordSelectors) {
passwordField = await page.$(selector);
if (passwordField) break;
}
if (!passwordField) {
console.error('Could not find password field');
return false;
}
await passwordField.fill(credentials.password);
console.log('Filled password field');
// Check for "Remember me" checkbox and check it
const rememberCheckbox = await page.$(
'input[type="checkbox"][name*="remember" i], input[type="checkbox"][id*="remember" i]'
);
if (rememberCheckbox) {
await rememberCheckbox.check();
console.log('Checked "Remember me" checkbox');
}
// Find and click submit button
const submitSelectors = [
'button[type="submit"]',
'input[type="submit"]',
'button:has-text("Sign in")',
'button:has-text("Log in")',
'button:has-text("Login")',
'button:has-text("Submit")',
'[role="button"]:has-text("Sign in")',
];
let submitButton = null;
for (const selector of submitSelectors) {
submitButton = await page.$(selector);
if (submitButton) break;
}
if (!submitButton) {
// Try pressing Enter as fallback
await passwordField.press('Enter');
} else {
await submitButton.click();
}
console.log('Submitted login form');
// Wait for navigation or error
try {
await page.waitForNavigation({ timeout, waitUntil: 'domcontentloaded' });
return true;
} catch {
// Check if we're still on login page with error
const errorMessages = await page.$$eval(
'.error, .alert-danger, [role="alert"], .login-error',
(els) => els.map((el) => el.textContent?.trim()).filter(Boolean)
);
if (errorMessages.length > 0) {
console.error('Login error:', errorMessages.join(', '));
return false;
}
return true; // Might have succeeded without navigation
}
}
// Handle Microsoft SSO login
async function handleMsalLogin(
page: Page,
credentials: { username: string; password: string },
timeout: number
): Promise<boolean> {
console.log('Attempting Microsoft SSO login...');
const currentUrl = page.url();
// If not already on Microsoft login, wait for redirect
if (!currentUrl.includes('login.microsoftonline.com')) {
try {
await page.waitForURL('**/login.microsoftonline.com/**', { timeout: 10000 });
} catch {
console.log('Not redirected to Microsoft login');
return false;
}
}
// Wait for email input
const emailInput = await page.waitForSelector(
'input[type="email"], input[name="loginfmt"]',
{ timeout }
);
if (!emailInput) {
console.error('Could not find email input on Microsoft login');
return false;
}
// Fill email and submit
await emailInput.fill(credentials.username);
console.log('Filled email field');
const nextButton = await page.$('input[type="submit"], button[type="submit"]');
if (nextButton) {
await nextButton.click();
} else {
await emailInput.press('Enter');
}
// Wait for password page
try {
await page.waitForSelector(
'input[type="password"], input[name="passwd"]',
{ timeout }
);
} catch {
// Might be using passwordless auth or different flow
console.log('Password field not found - might be using different auth flow');
return false;
}
// Fill password
const passwordInput = await page.$('input[type="password"], input[name="passwd"]');
if (!passwordInput) {
console.error('Could not find password input');
return false;
}
await passwordInput.fill(credentials.password);
console.log('Filled password field');
// Submit
const signInButton = await page.$('input[type="submit"], button[type="submit"]');
if (signInButton) {
await signInButton.click();
} else {
await passwordInput.press('Enter');
}
// Handle "Stay signed in?" prompt
try {
const staySignedInButton = await page.waitForSelector(
'input[value="Yes"], button:has-text("Yes")',
{ timeout: 5000 }
);
if (staySignedInButton) {
await staySignedInButton.click();
console.log('Clicked "Stay signed in" button');
}
} catch {
// Prompt might not appear
}
// Check for Conditional Access Policy error
const caError = await page.$('text=Conditional Access policy');
if (caError) {
console.error('Blocked by Conditional Access Policy');
// Take screenshot for debugging
await page.screenshot({ path: 'ca-policy-error.png' });
console.log('Screenshot saved: ca-policy-error.png');
return false;
}
// Wait for redirect away from Microsoft login
try {
await page.waitForURL(
(url) => !url.href.includes('login.microsoftonline.com'),
{ timeout }
);
return true;
} catch {
return false;
}
}
// Check if user is already authenticated
async function isAuthenticated(page: Page, targetUrl: string): Promise<boolean> {
const currentUrl = page.url();
// If we're on the target URL (not a login page), we're likely authenticated
if (currentUrl.startsWith(targetUrl)) {
// Check for common login page indicators
const isLoginPage = await page.evaluate(() => {
const loginIndicators = [
'input[type="password"]',
'form[action*="login"]',
'form[action*="signin"]',
'.login-form',
'#login',
];
return loginIndicators.some((sel) => document.querySelector(sel) !== null);
});
return !isLoginPage;
}
return false;
}
// Main authentication function
export async function authenticate(options: AuthOptions): Promise<AuthResult> {
const browser = await launchBrowser({ headless: options.headless ?? true });
const page = await browser.newPage();
const timeout = options.timeout ?? 30000;
try {
// Navigate to URL
console.log(`Navigating to: ${options.url}`);
await page.goto(options.url, { timeout: 60000, waitUntil: 'domcontentloaded' });
// Check if already authenticated
if (await isAuthenticated(page, options.url)) {
return {
success: true,
finalUrl: page.url(),
authType: 'auto',
message: 'Already authenticated (session persisted from profile)',
};
}
// Get credentials
const credentials = options.credentials
? options.credentials
: getCredentials();
if (!credentials) {
// No credentials - open interactive browser
console.log('\nNo credentials provided. Opening browser for manual login...');
console.log('Please complete the login process manually.');
console.log('The session will be saved to your profile.');
// Switch to headed mode for manual login
await browser.close();
const interactiveBrowser = await launchBrowser({ headless: false });
const interactivePage = await interactiveBrowser.newPage();
await interactivePage.goto(options.url);
await promptUser('\nPress Enter when you have completed login...');
const finalUrl = interactivePage.url();
await interactiveBrowser.close();
return {
success: true,
finalUrl,
authType: 'auto',
message: 'Manual login completed - session saved to profile',
};
}
// Detect auth type if auto
let authType = options.authType;
if (authType === 'auto') {
authType = await detectAuthType(page);
console.log(`Detected auth type: ${authType}`);
}
// Handle authentication based on type
let success = false;
switch (authType) {
case 'msal':
success = await handleMsalLogin(page, credentials, timeout);
break;
case 'form':
default:
success = await handleFormLogin(page, credentials, timeout);
break;
}
const finalUrl = page.url();
return {
success,
finalUrl,
authType,
message: success
? `Authentication successful - session saved to profile`
: 'Authentication failed',
};
} finally {
await browser.close();
}
}
// Navigate to authenticated page (handles auth if needed)
export async function navigateAuthenticated(
url: string,
options?: {
credentials?: { username: string; password: string };
headless?: boolean;
}
): Promise<{ page: Page; browser: BrowserContext }> {
const { page, browser } = await getPage({ headless: options?.headless ?? true });
await page.goto(url, { timeout: 60000, waitUntil: 'domcontentloaded' });
// Check if we need to authenticate
if (!(await isAuthenticated(page, url))) {
console.log('Session expired or not authenticated. Attempting login...');
// Get credentials
const credentials = options?.credentials ?? getCredentials();
if (!credentials) {
throw new Error(
'Authentication required but no credentials provided. ' +
'Set CLOAKBROWSER_USERNAME and CLOAKBROWSER_PASSWORD environment variables.'
);
}
// Detect and handle auth
const authType = await detectAuthType(page);
let success = false;
if (authType === 'msal') {
success = await handleMsalLogin(page, credentials, 30000);
} else {
success = await handleFormLogin(page, credentials, 30000);
}
if (!success) {
await browser.close();
throw new Error('Authentication failed');
}
// Navigate back to original URL if we were redirected
if (!page.url().startsWith(url)) {
await page.goto(url, { timeout: 60000, waitUntil: 'domcontentloaded' });
}
}
return { page, browser };
}
// CLI entry point
async function main() {
const args = parseArgs(process.argv.slice(2), {
string: ['url', 'type', 'username', 'password'],
boolean: ['headless', 'help'],
default: {
type: 'auto',
headless: false, // Default to headed for auth so user can see/interact
},
alias: {
u: 'url',
t: 'type',
h: 'help',
},
});
if (args.help || !args.url) {
console.log(`
Web Authentication Handler
Usage:
npx tsx auth.ts --url <url> [options]
Options:
-u, --url <url> URL to authenticate (required)
-t, --type <type> Auth type: auto, form, or msal (default: auto)
--username <user> Username/email (or set CLOAKBROWSER_USERNAME env var)
--password <pass> Password (or set CLOAKBROWSER_PASSWORD env var)
--headless <bool> Run in headless mode (default: false for auth)
-h, --help Show this help message
Auth Types:
auto Auto-detect authentication type
form Generic username/password form
msal Microsoft SSO (login.microsoftonline.com)
Environment Variables:
CLOAKBROWSER_USERNAME Default username/email for authentication
CLOAKBROWSER_PASSWORD Default password for authentication
Examples:
# Interactive login (no credentials, opens browser)
npx tsx auth.ts --url "https://example.com/login"
# Form login with credentials
npx tsx auth.ts --url "https://example.com/login" --type form \\
--username "user@example.com" --password "secret"
# Microsoft SSO login
CLOAKBROWSER_USERNAME=user@company.com CLOAKBROWSER_PASSWORD=secret \\
npx tsx auth.ts --url "https://internal.company.com" --type msal
Notes:
- Session is saved to ~/.cloakbrowser-profile/ for persistence
- After successful auth, subsequent browses will be authenticated
- Use --headless false if you need to handle MFA manually
`);
process.exit(args.help ? 0 : 1);
}
const authType = args.type as AuthType;
if (!['auto', 'form', 'msal'].includes(authType)) {
console.error(`Invalid auth type: ${authType}. Must be auto, form, or msal.`);
process.exit(1);
}
try {
const result = await authenticate({
url: args.url,
authType,
credentials:
args.username && args.password
? { username: args.username, password: args.password }
: undefined,
headless: args.headless,
});
console.log(`\nAuthentication result:`);
console.log(` Success: ${result.success}`);
console.log(` Auth type: ${result.authType}`);
console.log(` Final URL: ${result.finalUrl}`);
console.log(` Message: ${result.message}`);
process.exit(result.success ? 0 : 1);
} catch (error) {
console.error('Error:', error instanceof Error ? error.message : error);
process.exit(1);
}
}
// Run if executed directly
const isMainModule = process.argv[1]?.includes('auth.ts');
if (isMainModule) {
main();
}
@@ -0,0 +1,188 @@
#!/usr/bin/env npx tsx
/**
* Browser launcher using CloakBrowser with persistent profile
*
* Usage:
* npx tsx browse.ts --url "https://example.com"
* npx tsx browse.ts --url "https://example.com" --screenshot --output page.png
* npx tsx browse.ts --url "https://example.com" --headless false --wait 5000
*/
import { launchPersistentContext } from 'cloakbrowser';
import { homedir } from 'os';
import { join } from 'path';
import { existsSync, mkdirSync } from 'fs';
import parseArgs from 'minimist';
import type { Page, BrowserContext } from 'playwright-core';
interface BrowseOptions {
url: string;
headless?: boolean;
screenshot?: boolean;
output?: string;
wait?: number;
timeout?: number;
interactive?: boolean;
}
interface BrowseResult {
title: string;
url: string;
screenshotPath?: string;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
const getProfilePath = (): string => {
const customPath = process.env.CLOAKBROWSER_PROFILE_PATH;
if (customPath) return customPath;
const profileDir = join(homedir(), '.cloakbrowser-profile');
if (!existsSync(profileDir)) {
mkdirSync(profileDir, { recursive: true });
}
return profileDir;
};
export async function launchBrowser(options: {
headless?: boolean;
}): Promise<BrowserContext> {
const profilePath = getProfilePath();
const envHeadless = process.env.CLOAKBROWSER_HEADLESS;
const headless = options.headless ?? (envHeadless ? envHeadless === 'true' : true);
console.log(`Using profile: ${profilePath}`);
console.log(`Headless mode: ${headless}`);
const context = await launchPersistentContext({
userDataDir: profilePath,
headless,
humanize: true,
});
return context;
}
export async function browse(options: BrowseOptions): Promise<BrowseResult> {
const browser = await launchBrowser({ headless: options.headless });
const page = browser.pages()[0] || await browser.newPage();
try {
console.log(`Navigating to: ${options.url}`);
await page.goto(options.url, {
timeout: options.timeout ?? 60000,
waitUntil: 'domcontentloaded',
});
if (options.wait) {
console.log(`Waiting ${options.wait}ms...`);
await sleep(options.wait);
}
const result: BrowseResult = {
title: await page.title(),
url: page.url(),
};
console.log(`Page title: ${result.title}`);
console.log(`Final URL: ${result.url}`);
if (options.screenshot) {
const outputPath = options.output ?? 'screenshot.png';
await page.screenshot({ path: outputPath, fullPage: true });
result.screenshotPath = outputPath;
console.log(`Screenshot saved: ${outputPath}`);
}
if (options.interactive) {
console.log('\nInteractive mode - browser will stay open.');
console.log('Press Ctrl+C to close.');
await new Promise(() => {});
}
return result;
} finally {
if (!options.interactive) {
await browser.close();
}
}
}
export async function getPage(options?: {
headless?: boolean;
}): Promise<{ page: Page; browser: BrowserContext }> {
const browser = await launchBrowser({ headless: options?.headless });
const page = browser.pages()[0] || await browser.newPage();
return { page, browser };
}
async function main() {
const args = parseArgs(process.argv.slice(2), {
string: ['url', 'output'],
boolean: ['screenshot', 'headless', 'interactive', 'help'],
default: {
headless: true,
screenshot: false,
interactive: false,
},
alias: {
u: 'url',
o: 'output',
s: 'screenshot',
h: 'help',
i: 'interactive',
},
});
if (args.help || !args.url) {
console.log(`
Web Browser with CloakBrowser
Usage:
npx tsx browse.ts --url <url> [options]
Options:
-u, --url <url> URL to navigate to (required)
-s, --screenshot Take a screenshot of the page
-o, --output <path> Output path for screenshot (default: screenshot.png)
--headless <bool> Run in headless mode (default: true)
--wait <ms> Wait time after page load in milliseconds
--timeout <ms> Navigation timeout (default: 60000)
-i, --interactive Keep browser open for manual interaction
-h, --help Show this help message
Examples:
npx tsx browse.ts --url "https://example.com"
npx tsx browse.ts --url "https://example.com" --screenshot --output page.png
npx tsx browse.ts --url "https://example.com" --headless false --interactive
Environment Variables:
CLOAKBROWSER_PROFILE_PATH Custom profile directory (default: ~/.cloakbrowser-profile/)
CLOAKBROWSER_HEADLESS Default headless mode (true/false)
`);
process.exit(args.help ? 0 : 1);
}
try {
await browse({
url: args.url,
headless: args.headless,
screenshot: args.screenshot,
output: args.output,
wait: args.wait ? parseInt(args.wait, 10) : undefined,
timeout: args.timeout ? parseInt(args.timeout, 10) : undefined,
interactive: args.interactive,
});
} catch (error) {
console.error('Error:', error instanceof Error ? error.message : error);
process.exit(1);
}
}
const isMainModule = process.argv[1]?.includes('browse.ts');
if (isMainModule) {
main();
}
@@ -0,0 +1,40 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
function fail(message, details) {
const payload = { error: message };
if (details) payload.details = details;
process.stderr.write(`${JSON.stringify(payload)}\n`);
process.exit(1);
}
async function main() {
try {
await import("cloakbrowser");
await import("playwright-core");
} catch (error) {
fail(
"Missing dependency/config: web-automation requires cloakbrowser and playwright-core.",
error instanceof Error ? error.message : String(error)
);
}
const browsePath = path.join(__dirname, "browse.ts");
const browseSource = fs.readFileSync(browsePath, "utf8");
if (!/launchPersistentContext/.test(browseSource) || !/from ['"]cloakbrowser['"]/.test(browseSource)) {
fail("browse.ts is not configured for CloakBrowser.");
}
process.stdout.write("OK: cloakbrowser + playwright-core installed\n");
process.stdout.write("OK: CloakBrowser integration detected in browse.ts\n");
}
main().catch((error) => {
fail("Install check failed.", error instanceof Error ? error.message : String(error));
});
+188
View File
@@ -0,0 +1,188 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const DEFAULT_WAIT_MS = 5000;
const MAX_WAIT_MS = 20000;
const NAV_TIMEOUT_MS = 30000;
const EXTRA_CHALLENGE_WAIT_MS = 8000;
const CONTENT_LIMIT = 12000;
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
function fail(message, details) {
const payload = { error: message };
if (details) payload.details = details;
process.stderr.write(`${JSON.stringify(payload)}\n`);
process.exit(1);
}
function parseWaitTime(raw) {
const value = Number.parseInt(raw || `${DEFAULT_WAIT_MS}`, 10);
if (!Number.isFinite(value) || value < 0) return DEFAULT_WAIT_MS;
return Math.min(value, MAX_WAIT_MS);
}
function parseTarget(rawUrl) {
if (!rawUrl) {
fail("Missing URL. Usage: node extract.js <URL>");
}
let parsed;
try {
parsed = new URL(rawUrl);
} catch (error) {
fail("Invalid URL.", error.message);
}
if (!["http:", "https:"].includes(parsed.protocol)) {
fail("Only http and https URLs are allowed.");
}
return parsed.toString();
}
function ensureParentDir(filePath) {
if (!filePath) return;
fs.mkdirSync(path.dirname(filePath), { recursive: true });
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function detectChallenge(page) {
try {
return await page.evaluate(() => {
const text = (document.body?.innerText || "").toLowerCase();
return (
text.includes("checking your browser") ||
text.includes("just a moment") ||
text.includes("verify you are human") ||
text.includes("press and hold") ||
document.querySelector('iframe[src*="challenge"]') !== null ||
document.querySelector('iframe[src*="cloudflare"]') !== null
);
});
} catch {
return false;
}
}
async function loadCloakBrowser() {
try {
return await import("cloakbrowser");
} catch (error) {
fail(
"CloakBrowser is not installed for this skill. Run pnpm install in this skill's scripts directory first.",
error.message
);
}
}
async function runWithStderrLogs(fn) {
const originalLog = console.log;
const originalError = console.error;
console.log = (...args) => process.stderr.write(`${args.join(" ")}\n`);
console.error = (...args) => process.stderr.write(`${args.join(" ")}\n`);
try {
return await fn();
} finally {
console.log = originalLog;
console.error = originalError;
}
}
async function main() {
const requestedUrl = parseTarget(process.argv[2]);
const waitTime = parseWaitTime(process.env.WAIT_TIME);
const screenshotPath = process.env.SCREENSHOT_PATH || "";
const saveHtml = process.env.SAVE_HTML === "true";
const headless = process.env.HEADLESS !== "false";
const userAgent = process.env.USER_AGENT || undefined;
const startedAt = Date.now();
const { ensureBinary, launchContext } = await loadCloakBrowser();
let context;
try {
await runWithStderrLogs(() => ensureBinary());
context = await runWithStderrLogs(() => launchContext({
headless,
userAgent,
locale: "en-US",
viewport: { width: 1440, height: 900 },
humanize: true,
}));
const page = await context.newPage();
const response = await page.goto(requestedUrl, {
waitUntil: "domcontentloaded",
timeout: NAV_TIMEOUT_MS
});
await sleep(waitTime);
let challengeDetected = await detectChallenge(page);
if (challengeDetected) {
await sleep(EXTRA_CHALLENGE_WAIT_MS);
challengeDetected = await detectChallenge(page);
}
const extracted = await page.evaluate((contentLimit) => {
const bodyText = document.body?.innerText || "";
return {
finalUrl: window.location.href,
title: document.title || "",
content: bodyText.slice(0, contentLimit),
metaDescription:
document.querySelector('meta[name="description"]')?.content ||
document.querySelector('meta[property="og:description"]')?.content ||
""
};
}, CONTENT_LIMIT);
const result = {
requestedUrl,
finalUrl: extracted.finalUrl,
title: extracted.title,
content: extracted.content,
metaDescription: extracted.metaDescription,
status: response ? response.status() : null,
challengeDetected,
elapsedSeconds: ((Date.now() - startedAt) / 1000).toFixed(2)
};
if (screenshotPath) {
ensureParentDir(screenshotPath);
await page.screenshot({ path: screenshotPath, fullPage: false, timeout: 10000 });
result.screenshot = screenshotPath;
}
if (saveHtml) {
const htmlTarget = screenshotPath
? screenshotPath.replace(/\.[^.]+$/, ".html")
: path.resolve(__dirname, `page-${Date.now()}.html`);
ensureParentDir(htmlTarget);
fs.writeFileSync(htmlTarget, await page.content());
result.htmlFile = htmlTarget;
}
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
await context.close();
} catch (error) {
if (context) {
try {
await context.close();
} catch {
// Ignore close errors after the primary failure.
}
}
fail("Scrape failed.", error.message);
}
}
main();
@@ -0,0 +1,329 @@
#!/usr/bin/env npx tsx
import parseArgs from 'minimist';
import type { Page } from 'playwright-core';
import { launchBrowser } from './browse';
type Step =
| { action: 'goto'; url: string }
| { action: 'click'; selector?: string; text?: string; role?: string; name?: string }
| { action: 'type'; selector?: string; text: string }
| { action: 'press'; key: string; selector?: string }
| { action: 'wait'; ms: number }
| { action: 'screenshot'; path: string }
| { action: 'extract'; selector: string; count?: number };
function normalizeNavigationUrl(rawUrl: string): string {
let parsed: URL;
try {
parsed = new URL(rawUrl);
} catch {
throw new Error(`Invalid navigation URL: ${rawUrl}`);
}
if (!['http:', 'https:'].includes(parsed.protocol)) {
throw new Error(`Only http and https URLs are allowed in flow steps: ${rawUrl}`);
}
return parsed.toString();
}
function normalizeKey(k: string): string {
if (!k) return 'Enter';
const lower = k.toLowerCase();
if (lower === 'enter' || lower === 'return') return 'Enter';
if (lower === 'tab') return 'Tab';
if (lower === 'escape' || lower === 'esc') return 'Escape';
return k;
}
function splitInstructions(instruction: string): string[] {
return instruction
.split(/\bthen\b|;/gi)
.map((s) => s.trim())
.filter(Boolean);
}
function parseInstruction(instruction: string): Step[] {
const parts = splitInstructions(instruction);
const steps: Step[] = [];
for (const p of parts) {
// go to https://...
const goto = p.match(/^(?:go to|open|navigate to)\s+(https?:\/\/\S+)/i);
if (goto) {
steps.push({ action: 'goto', url: normalizeNavigationUrl(goto[1]) });
continue;
}
// click on "text" or click #selector or click button "name"
const clickRole = p.match(/^click\s+(button|link|textbox|img|image|tab)\s+"([^"]+)"$/i);
if (clickRole) {
const role = clickRole[1].toLowerCase() === 'image' ? 'img' : clickRole[1].toLowerCase();
steps.push({ action: 'click', role, name: clickRole[2] });
continue;
}
const clickText = p.match(/^click(?: on)?\s+"([^"]+)"/i);
if (clickText) {
steps.push({ action: 'click', text: clickText[1] });
continue;
}
const clickSelector = p.match(/^click(?: on)?\s+(#[\w-]+|\.[\w-]+|[a-z]+\[[^\]]+\])/i);
if (clickSelector) {
steps.push({ action: 'click', selector: clickSelector[1] });
continue;
}
// type "text" [in selector]
const typeInto = p.match(/^type\s+"([^"]+)"\s+in\s+(.+)$/i);
if (typeInto) {
steps.push({ action: 'type', text: typeInto[1], selector: typeInto[2].trim() });
continue;
}
const typeOnly = p.match(/^type\s+"([^"]+)"$/i);
if (typeOnly) {
steps.push({ action: 'type', text: typeOnly[1] });
continue;
}
// press enter [in selector]
const pressIn = p.match(/^press\s+(\w+)\s+in\s+(.+)$/i);
if (pressIn) {
steps.push({ action: 'press', key: normalizeKey(pressIn[1]), selector: pressIn[2].trim() });
continue;
}
const pressOnly = p.match(/^press\s+(\w+)$/i);
if (pressOnly) {
steps.push({ action: 'press', key: normalizeKey(pressOnly[1]) });
continue;
}
// wait 2s / wait 500ms
const waitS = p.match(/^wait\s+(\d+)\s*s(?:ec(?:onds?)?)?$/i);
if (waitS) {
steps.push({ action: 'wait', ms: parseInt(waitS[1], 10) * 1000 });
continue;
}
const waitMs = p.match(/^wait\s+(\d+)\s*ms$/i);
if (waitMs) {
steps.push({ action: 'wait', ms: parseInt(waitMs[1], 10) });
continue;
}
// screenshot path
const shot = p.match(/^screenshot(?: to)?\s+(.+)$/i);
if (shot) {
steps.push({ action: 'screenshot', path: shot[1].trim() });
continue;
}
throw new Error(`Could not parse step: "${p}"`);
}
return steps;
}
function validateSteps(steps: Step[]): Step[] {
return steps.map((step) =>
step.action === 'goto'
? {
...step,
url: normalizeNavigationUrl(step.url),
}
: step
);
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function isLikelyLoginText(text: string): boolean {
return /(login|accedi|sign\s*in|entra)/i.test(text);
}
async function clickByText(page: Page, text: string): Promise<boolean> {
const patterns = [new RegExp(`^${escapeRegExp(text)}$`, 'i'), new RegExp(escapeRegExp(text), 'i')];
for (const pattern of patterns) {
const targets = [
page.getByRole('button', { name: pattern }).first(),
page.getByRole('link', { name: pattern }).first(),
page.getByText(pattern).first(),
];
for (const target of targets) {
if (await target.count()) {
try {
await target.click({ timeout: 8000 });
return true;
} catch {
// keep trying next candidate
}
}
}
}
return false;
}
async function fallbackLoginNavigation(page: Page, requestedText: string): Promise<boolean> {
if (!isLikelyLoginText(requestedText)) return false;
const current = new URL(page.url());
const candidateLinks = await page.evaluate(() => {
const loginTerms = ['login', 'accedi', 'sign in', 'entra'];
const anchors = Array.from(document.querySelectorAll('a[href], a[onclick], button[onclick]')) as Array<HTMLAnchorElement | HTMLButtonElement>;
return anchors
.map((el) => {
const text = (el.textContent || '').trim().toLowerCase();
const href = (el as HTMLAnchorElement).getAttribute('href') || '';
return { text, href };
})
.filter((x) => x.text && loginTerms.some((t) => x.text.includes(t)))
.map((x) => x.href)
.filter(Boolean);
});
// Prefer real URLs (not javascript:)
const realCandidate = candidateLinks.find((h) => /login|account\/login/i.test(h) && !h.startsWith('javascript:'));
if (realCandidate) {
const target = new URL(realCandidate, page.url()).toString();
await page.goto(target, { waitUntil: 'domcontentloaded', timeout: 60000 });
return true;
}
// Site-specific fallback for Corriere
if (/corriere\.it$/i.test(current.hostname) || /\.corriere\.it$/i.test(current.hostname)) {
await page.goto('https://www.corriere.it/account/login', {
waitUntil: 'domcontentloaded',
timeout: 60000,
});
return true;
}
return false;
}
async function typeInBestTarget(page: Page, text: string, selector?: string) {
if (selector) {
await page.locator(selector).first().click({ timeout: 10000 });
await page.locator(selector).first().fill(text);
return;
}
const loc = page.locator('input[name="q"], input[type="search"], input[type="text"], textarea').first();
await loc.click({ timeout: 10000 });
await loc.fill(text);
}
async function pressOnTarget(page: Page, key: string, selector?: string) {
if (selector) {
await page.locator(selector).first().press(key);
return;
}
await page.keyboard.press(key);
}
async function runSteps(page: Page, steps: Step[]) {
for (const step of steps) {
switch (step.action) {
case 'goto':
await page.goto(normalizeNavigationUrl(step.url), {
waitUntil: 'domcontentloaded',
timeout: 60000,
});
break;
case 'click':
if (step.selector) {
await page.locator(step.selector).first().click({ timeout: 15000 });
} else if (step.role && step.name) {
await page.getByRole(step.role as any, { name: new RegExp(escapeRegExp(step.name), 'i') }).first().click({ timeout: 15000 });
} else if (step.text) {
const clicked = await clickByText(page, step.text);
if (!clicked) {
const recovered = await fallbackLoginNavigation(page, step.text);
if (!recovered) {
throw new Error(`Could not click target text: ${step.text}`);
}
}
} else {
throw new Error('click step missing selector/text/role');
}
try {
await page.waitForLoadState('domcontentloaded', { timeout: 10000 });
} catch {
// no navigation is fine
}
break;
case 'type':
await typeInBestTarget(page, step.text, step.selector);
break;
case 'press':
await pressOnTarget(page, step.key, step.selector);
break;
case 'wait':
await page.waitForTimeout(step.ms);
break;
case 'screenshot':
await page.screenshot({ path: step.path, fullPage: true });
break;
case 'extract': {
const items = await page.locator(step.selector).allTextContents();
const out = items.slice(0, step.count ?? items.length).map((t) => t.trim()).filter(Boolean);
console.log(JSON.stringify(out, null, 2));
break;
}
default:
throw new Error('Unknown step');
}
}
}
async function main() {
const args = parseArgs(process.argv.slice(2), {
string: ['instruction', 'steps'],
boolean: ['headless', 'help'],
default: { headless: true },
alias: { i: 'instruction', s: 'steps', h: 'help' },
});
if (args.help || (!args.instruction && !args.steps)) {
console.log(`
General Web Flow Runner (CloakBrowser)
Usage:
npx tsx flow.ts --instruction "go to https://example.com then type \"hello\" then press enter"
npx tsx flow.ts --steps '[{"action":"goto","url":"https://example.com"}]'
Supported natural steps:
- go to/open/navigate to <url>
- click on "Text"
- click <css-selector>
- type "text"
- type "text" in <css-selector>
- press <key>
- press <key> in <css-selector>
- wait <N>s | wait <N>ms
- screenshot <path>
`);
process.exit(args.help ? 0 : 1);
}
const steps = validateSteps(args.steps ? JSON.parse(args.steps) : parseInstruction(args.instruction));
const browser = await launchBrowser({ headless: args.headless });
const page = await browser.newPage();
try {
await runSteps(page, steps);
console.log('Flow complete. Final URL:', page.url());
} finally {
await browser.close();
}
}
main().catch((e) => {
console.error('Error:', e instanceof Error ? e.message : e);
process.exit(1);
});
@@ -0,0 +1,36 @@
{
"name": "web-automation-scripts",
"version": "1.0.0",
"description": "Web browsing and scraping scripts using CloakBrowser",
"type": "module",
"scripts": {
"check-install": "node check-install.js",
"extract": "node extract.js",
"browse": "tsx browse.ts",
"auth": "tsx auth.ts",
"flow": "tsx flow.ts",
"scrape": "tsx scrape.ts",
"typecheck": "tsc --noEmit -p tsconfig.json",
"lint": "pnpm run typecheck && node --check check-install.js && node --check extract.js",
"fetch-browser": "npx cloakbrowser install"
},
"dependencies": {
"@mozilla/readability": "^0.5.0",
"better-sqlite3": "^12.6.2",
"cloakbrowser": "^0.3.22",
"jsdom": "^24.0.0",
"minimist": "^1.2.8",
"playwright-core": "^1.59.1",
"turndown": "^7.1.2",
"turndown-plugin-gfm": "^1.0.2"
},
"devDependencies": {
"@types/jsdom": "^21.1.6",
"@types/minimist": "^1.2.5",
"@types/turndown": "^5.0.4",
"esbuild": "0.27.0",
"tsx": "^4.7.0",
"typescript": "^5.3.0"
},
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34"
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,174 @@
#!/usr/bin/env npx tsx
import { mkdirSync, writeFileSync } from 'fs';
import { dirname, resolve } from 'path';
import { getPage } from './browse.js';
type NavResult = {
requestedUrl: string;
url: string;
status: number | null;
title: string;
error?: string;
};
type RouteCheck = {
route: string;
result: NavResult;
heading: string | null;
};
const DEFAULT_BASE_URL = 'http://localhost:3000';
const DEFAULT_REPORT_PATH = resolve(process.cwd(), 'scan-local-app.md');
function env(name: string): string | undefined {
const value = process.env[name]?.trim();
return value ? value : undefined;
}
function getRoutes(baseUrl: string): string[] {
const routeList = env('SCAN_ROUTES');
if (routeList) {
return routeList
.split(',')
.map((route) => route.trim())
.filter(Boolean)
.map((route) => new URL(route, baseUrl).toString());
}
return [baseUrl];
}
async function gotoWithStatus(page: any, url: string): Promise<NavResult> {
const response = await page
.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 })
.catch((error: unknown) => ({ error }));
if (response?.error) {
return {
requestedUrl: url,
url: page.url(),
status: null,
title: await page.title().catch(() => ''),
error: String(response.error),
};
}
return {
requestedUrl: url,
url: page.url(),
status: response ? response.status() : null,
title: await page.title().catch(() => ''),
};
}
async function textOrNull(page: any, selector: string): Promise<string | null> {
const locator = page.locator(selector).first();
try {
if ((await locator.count()) === 0) return null;
const value = await locator.textContent();
return value ? value.trim().replace(/\s+/g, ' ') : null;
} catch {
return null;
}
}
async function loginIfConfigured(page: any, baseUrl: string, lines: string[]) {
const loginPath = env('SCAN_LOGIN_PATH');
const username = env('SCAN_USERNAME') ?? env('CLOAKBROWSER_USERNAME');
const password = env('SCAN_PASSWORD') ?? env('CLOAKBROWSER_PASSWORD');
const usernameSelector = env('SCAN_USERNAME_SELECTOR') ?? 'input[type="email"], input[name="email"]';
const passwordSelector = env('SCAN_PASSWORD_SELECTOR') ?? 'input[type="password"], input[name="password"]';
const submitSelector = env('SCAN_SUBMIT_SELECTOR') ?? 'button[type="submit"], input[type="submit"]';
if (!loginPath) {
lines.push('## Login');
lines.push('- Skipped: set `SCAN_LOGIN_PATH` to enable login smoke checks.');
lines.push('');
return;
}
const loginUrl = new URL(loginPath, baseUrl).toString();
lines.push('## Login');
lines.push(`- Login URL: ${loginUrl}`);
await gotoWithStatus(page, loginUrl);
if (!username || !password) {
lines.push('- Skipped: set `SCAN_USERNAME`/`SCAN_PASSWORD` or `CLOAKBROWSER_USERNAME`/`CLOAKBROWSER_PASSWORD`.');
lines.push('');
return;
}
await page.locator(usernameSelector).first().fill(username);
await page.locator(passwordSelector).first().fill(password);
await page.locator(submitSelector).first().click();
await page.waitForTimeout(2500);
lines.push(`- After submit URL: ${page.url()}`);
lines.push(`- Cookie count: ${(await page.context().cookies()).length}`);
lines.push('');
}
async function checkRoutes(page: any, baseUrl: string, lines: string[]) {
const routes = getRoutes(baseUrl);
const routeChecks: RouteCheck[] = [];
for (const url of routes) {
const result = await gotoWithStatus(page, url);
const heading = await textOrNull(page, 'h1');
routeChecks.push({
route: url,
result,
heading,
});
}
lines.push('## Route Checks');
for (const check of routeChecks) {
const relativeUrl = check.route.startsWith(baseUrl) ? check.route.slice(baseUrl.length) || '/' : check.route;
const finalPath = check.result.url.startsWith(baseUrl)
? check.result.url.slice(baseUrl.length) || '/'
: check.result.url;
const suffix = check.heading ? `, h1="${check.heading}"` : '';
const errorSuffix = check.result.error ? `, error="${check.result.error}"` : '';
lines.push(
`- ${relativeUrl} → status ${check.result.status ?? 'ERR'} (final ${finalPath})${suffix}${errorSuffix}`
);
}
lines.push('');
}
async function main() {
const baseUrl = env('SCAN_BASE_URL') ?? DEFAULT_BASE_URL;
const reportPath = resolve(env('SCAN_REPORT_PATH') ?? DEFAULT_REPORT_PATH);
const headless = (env('SCAN_HEADLESS') ?? env('CLOAKBROWSER_HEADLESS') ?? 'true') === 'true';
const { page, browser } = await getPage({ headless });
const lines: string[] = [];
lines.push('# Web Automation Scan (local)');
lines.push('');
lines.push(`- Base URL: ${baseUrl}`);
lines.push(`- Timestamp: ${new Date().toISOString()}`);
lines.push(`- Headless: ${headless}`);
lines.push(`- Report Path: ${reportPath}`);
lines.push('');
try {
await loginIfConfigured(page, baseUrl, lines);
await checkRoutes(page, baseUrl, lines);
lines.push('## Notes');
lines.push('- This generic smoke helper records route availability and top-level headings for a local app.');
lines.push('- Configure login and route coverage with `SCAN_*` environment variables.');
} finally {
await browser.close();
}
mkdirSync(dirname(reportPath), { recursive: true });
writeFileSync(reportPath, `${lines.join('\n')}\n`, 'utf-8');
console.log(`Report written to ${reportPath}`);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
@@ -0,0 +1,351 @@
#!/usr/bin/env npx tsx
/**
* Web scraper that extracts content to markdown
*
* Usage:
* npx tsx scrape.ts --url "https://example.com" --mode main
* npx tsx scrape.ts --url "https://example.com" --mode full --output page.md
* npx tsx scrape.ts --url "https://example.com" --mode selector --selector ".content"
*/
import TurndownService from 'turndown';
import * as turndownPluginGfm from 'turndown-plugin-gfm';
import { Readability } from '@mozilla/readability';
import { JSDOM } from 'jsdom';
import { writeFileSync } from 'fs';
import parseArgs from 'minimist';
import { getPage } from './browse.js';
// Types
type ScrapeMode = 'main' | 'full' | 'selector';
interface ScrapeOptions {
url: string;
mode: ScrapeMode;
selector?: string;
output?: string;
includeLinks?: boolean;
includeTables?: boolean;
includeImages?: boolean;
headless?: boolean;
wait?: number;
}
interface ScrapeResult {
title: string;
url: string;
markdown: string;
byline?: string;
excerpt?: string;
}
// Configure Turndown for markdown conversion
function createTurndownService(options: {
includeLinks?: boolean;
includeTables?: boolean;
includeImages?: boolean;
}): TurndownService {
const turndown = new TurndownService({
headingStyle: 'atx',
hr: '---',
bulletListMarker: '-',
codeBlockStyle: 'fenced',
fence: '```',
emDelimiter: '*',
strongDelimiter: '**',
linkStyle: 'inlined',
});
// Add GFM support (tables, strikethrough, task lists)
turndown.use(turndownPluginGfm.gfm);
// Custom rule for code blocks with language detection
turndown.addRule('codeBlockWithLanguage', {
filter: (node) => {
return (
node.nodeName === 'PRE' &&
node.firstChild?.nodeName === 'CODE'
);
},
replacement: (_content, node) => {
const codeNode = node.firstChild as HTMLElement;
const className = codeNode.getAttribute('class') || '';
const langMatch = className.match(/language-(\w+)/);
const lang = langMatch ? langMatch[1] : '';
const code = codeNode.textContent || '';
return `\n\n\`\`\`${lang}\n${code}\n\`\`\`\n\n`;
},
});
// Remove images if not included
if (!options.includeImages) {
turndown.addRule('removeImages', {
filter: 'img',
replacement: () => '',
});
}
// Remove links but keep text if not included
if (!options.includeLinks) {
turndown.addRule('removeLinks', {
filter: 'a',
replacement: (content) => content,
});
}
// Remove script, style, nav, footer, aside elements
turndown.remove(['script', 'style', 'nav', 'footer', 'aside', 'noscript']);
return turndown;
}
// Extract main content using Readability
function extractMainContent(html: string, url: string): {
content: string;
title: string;
byline?: string;
excerpt?: string;
} {
const dom = new JSDOM(html, { url });
const reader = new Readability(dom.window.document);
const article = reader.parse();
if (!article) {
throw new Error('Could not extract main content from page');
}
return {
content: article.content,
title: article.title,
byline: article.byline || undefined,
excerpt: article.excerpt || undefined,
};
}
// Scrape a URL and return markdown
export async function scrape(options: ScrapeOptions): Promise<ScrapeResult> {
const { page, browser } = await getPage({ headless: options.headless ?? true });
try {
// Navigate to URL
console.log(`Navigating to: ${options.url}`);
await page.goto(options.url, {
timeout: 60000,
waitUntil: 'domcontentloaded',
});
// Wait if specified
if (options.wait) {
console.log(`Waiting ${options.wait}ms for dynamic content...`);
await page.waitForTimeout(options.wait);
}
const pageTitle = await page.title();
const pageUrl = page.url();
let html: string;
let title = pageTitle;
let byline: string | undefined;
let excerpt: string | undefined;
// Get HTML based on mode
switch (options.mode) {
case 'main': {
// Get full page HTML and extract with Readability
const fullHtml = await page.content();
const extracted = extractMainContent(fullHtml, pageUrl);
html = extracted.content;
title = extracted.title || pageTitle;
byline = extracted.byline;
excerpt = extracted.excerpt;
break;
}
case 'selector': {
if (!options.selector) {
throw new Error('Selector mode requires --selector option');
}
const element = await page.$(options.selector);
if (!element) {
throw new Error(`Selector not found: ${options.selector}`);
}
html = await element.innerHTML();
break;
}
case 'full':
default: {
// Get body content, excluding common non-content elements
html = await page.evaluate(() => {
// Remove common non-content elements
const selectorsToRemove = [
'script', 'style', 'noscript', 'iframe',
'nav', 'header', 'footer', '.cookie-banner',
'.advertisement', '.ads', '#ads', '.social-share',
'.comments', '#comments', '.sidebar'
];
selectorsToRemove.forEach(selector => {
document.querySelectorAll(selector).forEach(el => el.remove());
});
return document.body.innerHTML;
});
break;
}
}
// Convert to markdown
const turndown = createTurndownService({
includeLinks: options.includeLinks ?? true,
includeTables: options.includeTables ?? true,
includeImages: options.includeImages ?? false,
});
let markdown = turndown.turndown(html);
// Add title as H1 if not already present
if (!markdown.startsWith('# ')) {
markdown = `# ${title}\n\n${markdown}`;
}
// Add metadata header
const metadataLines = [
`<!-- Scraped from: ${pageUrl} -->`,
byline ? `<!-- Author: ${byline} -->` : null,
excerpt ? `<!-- Excerpt: ${excerpt} -->` : null,
`<!-- Scraped at: ${new Date().toISOString()} -->`,
'',
].filter(Boolean);
markdown = metadataLines.join('\n') + '\n' + markdown;
// Clean up excessive whitespace
markdown = markdown
.replace(/\n{4,}/g, '\n\n\n')
.replace(/[ \t]+$/gm, '')
.trim();
const result: ScrapeResult = {
title,
url: pageUrl,
markdown,
byline,
excerpt,
};
// Save to file if output specified
if (options.output) {
writeFileSync(options.output, markdown, 'utf-8');
console.log(`Markdown saved to: ${options.output}`);
}
return result;
} finally {
await browser.close();
}
}
// CLI entry point
async function main() {
const args = parseArgs(process.argv.slice(2), {
string: ['url', 'mode', 'selector', 'output'],
boolean: ['headless', 'links', 'tables', 'images', 'help'],
default: {
mode: 'main',
headless: true,
links: true,
tables: true,
images: false,
},
alias: {
u: 'url',
m: 'mode',
s: 'selector',
o: 'output',
h: 'help',
},
});
if (args.help || !args.url) {
console.log(`
Web Scraper - Extract content to Markdown
Usage:
npx tsx scrape.ts --url <url> [options]
Options:
-u, --url <url> URL to scrape (required)
-m, --mode <mode> Scrape mode: main, full, or selector (default: main)
-s, --selector <sel> CSS selector for selector mode
-o, --output <path> Output file path for markdown
--headless <bool> Run in headless mode (default: true)
--wait <ms> Wait time for dynamic content
--links Include links in output (default: true)
--tables Include tables in output (default: true)
--images Include images in output (default: false)
-h, --help Show this help message
Scrape Modes:
main Extract main article content using Readability (best for articles)
full Full page content with common elements removed
selector Extract specific element by CSS selector
Examples:
npx tsx scrape.ts --url "https://docs.example.com/guide" --mode main
npx tsx scrape.ts --url "https://example.com" --mode full --output page.md
npx tsx scrape.ts --url "https://example.com" --mode selector --selector ".api-docs"
npx tsx scrape.ts --url "https://example.com" --mode main --no-links --output clean.md
Output Format:
- GitHub Flavored Markdown (tables, strikethrough, task lists)
- Proper heading hierarchy
- Code blocks with language detection
- Metadata comments at top (source URL, date)
`);
process.exit(args.help ? 0 : 1);
}
const mode = args.mode as ScrapeMode;
if (!['main', 'full', 'selector'].includes(mode)) {
console.error(`Invalid mode: ${mode}. Must be main, full, or selector.`);
process.exit(1);
}
try {
const result = await scrape({
url: args.url,
mode,
selector: args.selector,
output: args.output,
includeLinks: args.links,
includeTables: args.tables,
includeImages: args.images,
headless: args.headless,
wait: args.wait ? parseInt(args.wait, 10) : undefined,
});
// Print result summary
console.log(`\nScrape complete:`);
console.log(` Title: ${result.title}`);
console.log(` URL: ${result.url}`);
if (result.byline) console.log(` Author: ${result.byline}`);
console.log(` Markdown length: ${result.markdown.length} chars`);
// Print markdown if not saved to file
if (!args.output) {
console.log('\n--- Markdown Output ---\n');
console.log(result.markdown);
}
} catch (error) {
console.error('Error:', error instanceof Error ? error.message : error);
process.exit(1);
}
}
// Run if executed directly
const isMainModule = process.argv[1]?.includes('scrape.ts');
if (isMainModule) {
main();
}
@@ -0,0 +1,36 @@
import { launchPersistentContext } from 'cloakbrowser';
import { homedir } from 'os';
import { join } from 'path';
import { mkdirSync, existsSync } from 'fs';
async function test() {
const profilePath = join(homedir(), '.cloakbrowser-profile');
if (!existsSync(profilePath)) {
mkdirSync(profilePath, { recursive: true });
}
console.log('Profile path:', profilePath);
console.log('Launching CloakBrowser with full options...');
const browser = await launchPersistentContext({
headless: true,
userDataDir: profilePath,
humanize: true,
});
console.log('Browser launched');
const page = browser.pages()[0] || await browser.newPage();
console.log('Page created');
await page.goto('https://github.com', { timeout: 30000 });
console.log('Navigated to:', page.url());
console.log('Title:', await page.title());
await page.screenshot({ path: '/tmp/github-test.png' });
console.log('Screenshot saved');
await browser.close();
console.log('Done');
}
test().catch(console.error);
@@ -0,0 +1,23 @@
import { launch } from 'cloakbrowser';
async function test() {
console.log('Launching CloakBrowser with minimal config...');
const browser = await launch({
headless: true,
humanize: true,
});
console.log('Browser launched');
const page = await browser.newPage();
console.log('Page created');
await page.goto('https://example.com', { timeout: 30000 });
console.log('Navigated to:', page.url());
console.log('Title:', await page.title());
await browser.close();
console.log('Done');
}
test().catch(console.error);
@@ -0,0 +1,33 @@
import { launchPersistentContext } from 'cloakbrowser';
import { homedir } from 'os';
import { join } from 'path';
import { mkdirSync, existsSync } from 'fs';
async function test() {
const profilePath = join(homedir(), '.cloakbrowser-profile');
if (!existsSync(profilePath)) {
mkdirSync(profilePath, { recursive: true });
}
console.log('Profile path:', profilePath);
console.log('Launching with persistent userDataDir...');
const browser = await launchPersistentContext({
headless: true,
userDataDir: profilePath,
humanize: true,
});
console.log('Browser launched');
const page = browser.pages()[0] || await browser.newPage();
console.log('Page created');
await page.goto('https://example.com', { timeout: 30000 });
console.log('Navigated to:', page.url());
console.log('Title:', await page.title());
await browser.close();
console.log('Done');
}
test().catch(console.error);
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"outDir": "./dist",
"rootDir": "."
},
"include": ["*.ts"],
"exclude": ["node_modules", "dist"]
}
@@ -0,0 +1,8 @@
declare module 'turndown-plugin-gfm' {
import TurndownService from 'turndown';
export function gfm(turndownService: TurndownService): void;
export function strikethrough(turndownService: TurndownService): void;
export function tables(turndownService: TurndownService): void;
export function taskListItems(turndownService: TurndownService): void;
}
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
TARGET_ROOT="${ROOT_DIR}/pi-package/skills"
SKILL_FAMILIES=(
"atlassian"
"create-plan"
"do-task"
"implement-plan"
"web-automation"
)
extract_skill_name() {
local skill_md=$1
awk '/^name:/ { print $2; exit }' "$skill_md"
}
replace_dir() {
local source=$1
local target=$2
if [[ -z "$target" || "$target" == "/" || "$target" == "." || "$target" == ".." ]]; then
echo "Refusing to sync into unsafe target: $target" >&2
exit 1
fi
case "$target" in
"${ROOT_DIR}"/*) ;;
*)
echo "Refusing to remove target outside repo root: $target" >&2
exit 1
;;
esac
rm -rf "$target"
mkdir -p "$target"
cp -R "${source}/." "$target/"
}
rm -rf "$TARGET_ROOT"
mkdir -p "$TARGET_ROOT"
for family in "${SKILL_FAMILIES[@]}"; do
source_dir="${ROOT_DIR}/skills/${family}/pi"
skill_md="${source_dir}/SKILL.md"
if [[ ! -f "$skill_md" ]]; then
echo "Missing source SKILL.md: $skill_md" >&2
exit 1
fi
skill_name=$(extract_skill_name "$skill_md")
if [[ -z "$skill_name" ]]; then
echo "Could not derive skill name from $skill_md" >&2
exit 1
fi
replace_dir "$source_dir" "${TARGET_ROOT}/${skill_name}"
done
echo "Synced pi package skill mirror into ${TARGET_ROOT}."
+68
View File
@@ -5,6 +5,7 @@ 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"
@@ -16,6 +17,12 @@ REQUIRED_FILES=(
"skills/web-automation/pi/SKILL.md"
"skills/reviewer-runtime/pi/run-review.sh"
"skills/reviewer-runtime/pi/notify-telegram.sh"
"scripts/sync-pi-package-skills.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"
)
@@ -27,11 +34,35 @@ done
test -x skills/reviewer-runtime/pi/run-review.sh
test -x skills/reviewer-runtime/pi/notify-telegram.sh
test -x scripts/sync-pi-package-skills.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"
done
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' "$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 "$(stat -f '%Lp' "$source_path")" = "$(stat -f '%Lp' "$mirror_path")"
done < <(find "$source_dir" -mindepth 1 -print0)
done
! grep -nE 'update_plan|plan mode|sub-agent|subagents' \
@@ -48,11 +79,48 @@ if (!pkg.pi || !Array.isArray(pkg.pi.skills) || pkg.pi.skills.length !== 5) {
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/sync-pi-package-skills.sh",
]) {
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);
+1 -1
View File
@@ -90,4 +90,4 @@ If any check fails, stop and return:
## Notes
- Atlassian Cloud is the primary supported platform in v1.
- Pi validates skill naming leniently, so this repo keeps the shared `skills/<family>/pi/` layout even though the installed parent directory should be `atlassian`.
- Package installs use the repo's `pi-package/skills/atlassian/` mirror so the installed skill directory name matches `atlassian`.
+10 -3
View File
@@ -9,7 +9,12 @@ 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). The workflow depends on:
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
@@ -34,11 +39,13 @@ test -f ~/.agents/skills/superpowers/writing-plans/SKILL.md || test -f ~/.pi/age
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 instead of the common locations above, verify it matches [docs/PI-SUPERPOWERS.md](../../../docs/PI-SUPERPOWERS.md) before continuing.
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 pi reviewer runtime documented in docs/PI-SUPERPOWERS.md.`
`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
+10 -3
View File
@@ -11,7 +11,12 @@ This variant uses one persistent `task-plan.md` under `ai_plan/` and defaults to
## Shared Setup
Before using this skill, read [docs/PI-SUPERPOWERS.md](../../../docs/PI-SUPERPOWERS.md). This workflow depends on:
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
@@ -41,11 +46,13 @@ test -x .pi/skills/reviewer-runtime/pi/run-review.sh || test -x ~/.pi/agent/skil
test -x .pi/skills/reviewer-runtime/pi/notify-telegram.sh || test -x ~/.pi/agent/skills/reviewer-runtime/pi/notify-telegram.sh
```
If you use settings-defined paths instead of the common locations above, confirm they match [docs/PI-SUPERPOWERS.md](../../../docs/PI-SUPERPOWERS.md) before continuing.
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 pi reviewer-runtime setup documented in docs/PI-SUPERPOWERS.md.`
`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
+10 -3
View File
@@ -9,7 +9,12 @@ Execute an existing plan under `ai_plan/` milestone by milestone, using verifica
## Shared Setup
Before using this skill, read [docs/PI-SUPERPOWERS.md](../../../docs/PI-SUPERPOWERS.md). This workflow depends on:
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
@@ -44,11 +49,13 @@ test -x .pi/skills/reviewer-runtime/pi/run-review.sh || test -x ~/.pi/agent/skil
test -x .pi/skills/reviewer-runtime/pi/notify-telegram.sh || test -x ~/.pi/agent/skills/reviewer-runtime/pi/notify-telegram.sh
```
If you use settings-defined paths instead of the common locations above, confirm they match [docs/PI-SUPERPOWERS.md](../../../docs/PI-SUPERPOWERS.md) before continuing.
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-runtime setup documented in docs/PI-SUPERPOWERS.md.`
`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
+1 -1
View File
@@ -113,4 +113,4 @@ If `SCAN_USERNAME` or `SCAN_PASSWORD` are omitted, the script falls back to `CLO
- 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.
- Pi validates skill naming leniently, so this repo keeps the shared `skills/<family>/pi/` layout even though the installed parent directory should be `web-automation`.
- Package installs use the repo's `pi-package/skills/web-automation/` mirror so the installed skill directory name matches `web-automation`.