Compare commits
8 Commits
c98f27f461
...
9e29c34c62
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e29c34c62 | |||
| 3429dac894 | |||
| 0456c51291 | |||
| f2c4d39abd | |||
| d7651ad57c | |||
| 3d868a852c | |||
| 51372eb420 | |||
| 7ba6f90e14 |
@@ -1,7 +1,9 @@
|
|||||||
/ai_plan/
|
/ai_plan/
|
||||||
|
/.pi/
|
||||||
/.worktrees/
|
/.worktrees/
|
||||||
/skills/atlassian/shared/scripts/.env
|
/skills/atlassian/shared/scripts/.env
|
||||||
/skills/atlassian/shared/scripts/node_modules/
|
/skills/atlassian/shared/scripts/node_modules/
|
||||||
/skills/atlassian/*/scripts/.env
|
/skills/atlassian/*/scripts/.env
|
||||||
/skills/atlassian/*/scripts/node_modules/
|
/skills/atlassian/*/scripts/node_modules/
|
||||||
/skills/web-automation/*/scripts/node_modules/
|
/skills/web-automation/*/scripts/node_modules/
|
||||||
|
/pi-package/skills/*/scripts/node_modules/
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# ai-coding-skills
|
# ai-coding-skills
|
||||||
|
|
||||||
Cross-agent skill collection for **Codex**, **Claude Code**, **OpenCode**, and **Cursor**.
|
Cross-agent skill collection for **Codex**, **Claude Code**, **OpenCode**, **Cursor**, and **pi**.
|
||||||
|
|
||||||
|
Pi package support is also included for the pi-native variants in this repo.
|
||||||
|
|
||||||
This repo is organized similarly to `obra/superpowers` and is designed to scale to many skills over time.
|
This repo is organized similarly to `obra/superpowers` and is designed to scale to many skills over time.
|
||||||
|
|
||||||
@@ -18,7 +20,12 @@ ai-coding-skills/
|
|||||||
│ ├── README.md
|
│ ├── README.md
|
||||||
│ ├── ATLASSIAN.md
|
│ ├── ATLASSIAN.md
|
||||||
│ ├── CREATE-PLAN.md
|
│ ├── CREATE-PLAN.md
|
||||||
|
│ ├── DO-TASK.md
|
||||||
│ ├── IMPLEMENT-PLAN.md
|
│ ├── IMPLEMENT-PLAN.md
|
||||||
|
│ ├── PI.md
|
||||||
|
│ ├── PI-RESEARCH.md
|
||||||
|
│ ├── PI-SUPERPOWERS.md
|
||||||
|
│ ├── TELEGRAM-NOTIFICATIONS.md
|
||||||
│ └── WEB-AUTOMATION.md
|
│ └── WEB-AUTOMATION.md
|
||||||
├── skills/
|
├── skills/
|
||||||
│ ├── _template/
|
│ ├── _template/
|
||||||
@@ -28,26 +35,34 @@ ai-coding-skills/
|
|||||||
│ │ ├── claude-code/
|
│ │ ├── claude-code/
|
||||||
│ │ ├── cursor/
|
│ │ ├── cursor/
|
||||||
│ │ ├── opencode/
|
│ │ ├── opencode/
|
||||||
|
│ │ ├── pi/
|
||||||
│ │ └── shared/
|
│ │ └── shared/
|
||||||
│ ├── create-plan/
|
│ ├── create-plan/
|
||||||
│ │ ├── codex/
|
│ │ ├── codex/
|
||||||
│ │ ├── claude-code/
|
│ │ ├── claude-code/
|
||||||
│ │ ├── opencode/
|
│ │ ├── opencode/
|
||||||
│ │ └── cursor/
|
│ │ ├── cursor/
|
||||||
|
│ │ └── pi/
|
||||||
│ ├── do-task/
|
│ ├── do-task/
|
||||||
│ │ ├── codex/
|
│ │ ├── codex/
|
||||||
│ │ ├── claude-code/
|
│ │ ├── claude-code/
|
||||||
│ │ ├── opencode/
|
│ │ ├── opencode/
|
||||||
│ │ └── cursor/
|
│ │ ├── cursor/
|
||||||
|
│ │ └── pi/
|
||||||
│ ├── implement-plan/
|
│ ├── implement-plan/
|
||||||
│ │ ├── codex/
|
│ │ ├── codex/
|
||||||
│ │ ├── claude-code/
|
│ │ ├── claude-code/
|
||||||
│ │ ├── opencode/
|
│ │ ├── opencode/
|
||||||
│ │ └── cursor/
|
│ │ ├── cursor/
|
||||||
|
│ │ └── pi/
|
||||||
|
│ ├── reviewer-runtime/
|
||||||
|
│ │ ├── pi/
|
||||||
|
│ │ └── tests/
|
||||||
│ └── web-automation/
|
│ └── web-automation/
|
||||||
│ ├── codex/
|
│ ├── codex/
|
||||||
│ ├── claude-code/
|
│ ├── claude-code/
|
||||||
│ └── opencode/
|
│ ├── opencode/
|
||||||
|
│ └── pi/
|
||||||
├── .codex/
|
├── .codex/
|
||||||
├── .claude-plugin/
|
├── .claude-plugin/
|
||||||
├── .opencode/
|
├── .opencode/
|
||||||
@@ -65,21 +80,26 @@ ai-coding-skills/
|
|||||||
| atlassian | claude-code | Portable Jira and Confluence workflows through a shared Cloud-first CLI | Ready | [ATLASSIAN](docs/ATLASSIAN.md) |
|
| atlassian | claude-code | Portable Jira and Confluence workflows through a shared Cloud-first CLI | Ready | [ATLASSIAN](docs/ATLASSIAN.md) |
|
||||||
| atlassian | opencode | Portable Jira and Confluence workflows through a shared Cloud-first CLI | Ready | [ATLASSIAN](docs/ATLASSIAN.md) |
|
| atlassian | opencode | Portable Jira and Confluence workflows through a shared Cloud-first CLI | Ready | [ATLASSIAN](docs/ATLASSIAN.md) |
|
||||||
| atlassian | cursor | Portable Jira and Confluence workflows through a shared Cloud-first CLI | Ready | [ATLASSIAN](docs/ATLASSIAN.md) |
|
| atlassian | cursor | Portable Jira and Confluence workflows through a shared Cloud-first CLI | Ready | [ATLASSIAN](docs/ATLASSIAN.md) |
|
||||||
|
| atlassian | pi | Portable Jira and Confluence workflows through a shared Cloud-first CLI | Ready | [ATLASSIAN](docs/ATLASSIAN.md) |
|
||||||
| create-plan | codex | Structured planning with milestones, iterative cross-model review, and runbook-first execution workflow | Ready | [CREATE-PLAN](docs/CREATE-PLAN.md) |
|
| create-plan | codex | Structured planning with milestones, iterative cross-model review, and runbook-first execution workflow | Ready | [CREATE-PLAN](docs/CREATE-PLAN.md) |
|
||||||
| create-plan | claude-code | Structured planning with milestones, iterative cross-model review, and runbook-first execution workflow | Ready | [CREATE-PLAN](docs/CREATE-PLAN.md) |
|
| create-plan | claude-code | Structured planning with milestones, iterative cross-model review, and runbook-first execution workflow | Ready | [CREATE-PLAN](docs/CREATE-PLAN.md) |
|
||||||
| create-plan | opencode | Structured planning with milestones, iterative cross-model review, and runbook-first execution workflow | Ready | [CREATE-PLAN](docs/CREATE-PLAN.md) |
|
| create-plan | opencode | Structured planning with milestones, iterative cross-model review, and runbook-first execution workflow | Ready | [CREATE-PLAN](docs/CREATE-PLAN.md) |
|
||||||
| create-plan | cursor | Structured planning with milestones, iterative cross-model review, and runbook-first execution workflow | Ready | [CREATE-PLAN](docs/CREATE-PLAN.md) |
|
| create-plan | cursor | Structured planning with milestones, iterative cross-model review, and runbook-first execution workflow | Ready | [CREATE-PLAN](docs/CREATE-PLAN.md) |
|
||||||
|
| create-plan | pi | Structured planning with milestones, iterative cross-model review, and runbook-first execution workflow | Ready | [CREATE-PLAN](docs/CREATE-PLAN.md) |
|
||||||
| do-task | codex | Single-prompt end-to-end execution with dual reviewer loops (plan + implementation), TDD-first, single task commit | Ready | [DO-TASK](docs/DO-TASK.md) |
|
| do-task | codex | Single-prompt end-to-end execution with dual reviewer loops (plan + implementation), TDD-first, single task commit | Ready | [DO-TASK](docs/DO-TASK.md) |
|
||||||
| do-task | claude-code | Single-prompt end-to-end execution with dual reviewer loops (plan + implementation), TDD-first, single task commit | Ready | [DO-TASK](docs/DO-TASK.md) |
|
| do-task | claude-code | Single-prompt end-to-end execution with dual reviewer loops (plan + implementation), TDD-first, single task commit | Ready | [DO-TASK](docs/DO-TASK.md) |
|
||||||
| do-task | opencode | Single-prompt end-to-end execution with dual reviewer loops (plan + implementation), TDD-first, single task commit | Ready | [DO-TASK](docs/DO-TASK.md) |
|
| do-task | opencode | Single-prompt end-to-end execution with dual reviewer loops (plan + implementation), TDD-first, single task commit | Ready | [DO-TASK](docs/DO-TASK.md) |
|
||||||
| do-task | cursor | Single-prompt end-to-end execution with dual reviewer loops (plan + implementation), TDD-first, single task commit | Ready | [DO-TASK](docs/DO-TASK.md) |
|
| do-task | cursor | Single-prompt end-to-end execution with dual reviewer loops (plan + implementation), TDD-first, single task commit | Ready | [DO-TASK](docs/DO-TASK.md) |
|
||||||
|
| do-task | pi | Single-prompt end-to-end execution with dual reviewer loops (plan + implementation), TDD-first, single task commit | Ready | [DO-TASK](docs/DO-TASK.md) |
|
||||||
| implement-plan | codex | Worktree-isolated plan execution with iterative cross-model milestone review | Ready | [IMPLEMENT-PLAN](docs/IMPLEMENT-PLAN.md) |
|
| implement-plan | codex | Worktree-isolated plan execution with iterative cross-model milestone review | Ready | [IMPLEMENT-PLAN](docs/IMPLEMENT-PLAN.md) |
|
||||||
| implement-plan | claude-code | Worktree-isolated plan execution with iterative cross-model milestone review | Ready | [IMPLEMENT-PLAN](docs/IMPLEMENT-PLAN.md) |
|
| implement-plan | claude-code | Worktree-isolated plan execution with iterative cross-model milestone review | Ready | [IMPLEMENT-PLAN](docs/IMPLEMENT-PLAN.md) |
|
||||||
| implement-plan | opencode | Worktree-isolated plan execution with iterative cross-model milestone review | Ready | [IMPLEMENT-PLAN](docs/IMPLEMENT-PLAN.md) |
|
| implement-plan | opencode | Worktree-isolated plan execution with iterative cross-model milestone review | Ready | [IMPLEMENT-PLAN](docs/IMPLEMENT-PLAN.md) |
|
||||||
| implement-plan | cursor | Worktree-isolated plan execution with iterative cross-model milestone review | Ready | [IMPLEMENT-PLAN](docs/IMPLEMENT-PLAN.md) |
|
| implement-plan | cursor | Worktree-isolated plan execution with iterative cross-model milestone review | Ready | [IMPLEMENT-PLAN](docs/IMPLEMENT-PLAN.md) |
|
||||||
|
| implement-plan | pi | Worktree-isolated plan execution with iterative cross-model milestone review | Ready | [IMPLEMENT-PLAN](docs/IMPLEMENT-PLAN.md) |
|
||||||
| web-automation | codex | CloakBrowser-backed browsing, scraping, auth, flow automation, and install validation | Ready | [WEB-AUTOMATION](docs/WEB-AUTOMATION.md) |
|
| web-automation | codex | CloakBrowser-backed browsing, scraping, auth, flow automation, and install validation | Ready | [WEB-AUTOMATION](docs/WEB-AUTOMATION.md) |
|
||||||
| web-automation | claude-code | CloakBrowser-backed browsing, scraping, auth, flow automation, and install validation | Ready | [WEB-AUTOMATION](docs/WEB-AUTOMATION.md) |
|
| web-automation | claude-code | CloakBrowser-backed browsing, scraping, auth, flow automation, and install validation | Ready | [WEB-AUTOMATION](docs/WEB-AUTOMATION.md) |
|
||||||
| web-automation | opencode | CloakBrowser-backed browsing, scraping, auth, flow automation, and install validation | Ready | [WEB-AUTOMATION](docs/WEB-AUTOMATION.md) |
|
| web-automation | opencode | CloakBrowser-backed browsing, scraping, auth, flow automation, and install validation | Ready | [WEB-AUTOMATION](docs/WEB-AUTOMATION.md) |
|
||||||
|
| web-automation | pi | CloakBrowser-backed browsing, scraping, auth, flow automation, and install validation | Ready | [WEB-AUTOMATION](docs/WEB-AUTOMATION.md) |
|
||||||
|
|
||||||
- Docs index: `docs/README.md`
|
- Docs index: `docs/README.md`
|
||||||
- Atlassian guide: `docs/ATLASSIAN.md`
|
- Atlassian guide: `docs/ATLASSIAN.md`
|
||||||
@@ -91,3 +111,43 @@ ai-coding-skills/
|
|||||||
## Compatibility Policy
|
## Compatibility Policy
|
||||||
|
|
||||||
Each skill should explicitly document agent compatibility and any prerequisites directly in its own `SKILL.md`.
|
Each skill should explicitly document agent compatibility and any prerequisites directly in its own `SKILL.md`.
|
||||||
|
|
||||||
|
## Pi Package
|
||||||
|
|
||||||
|
The repo root now includes a pi package manifest that ships only the pi-specific surface:
|
||||||
|
|
||||||
|
- `pi-package/skills/*/` for the five packaged Pi skills
|
||||||
|
- `skills/reviewer-runtime/pi/`
|
||||||
|
- `docs/PI*.md`
|
||||||
|
- `scripts/sync-pi-package-skills.sh`
|
||||||
|
- `scripts/verify-pi-resources.sh`
|
||||||
|
|
||||||
|
Install it from a cloned checkout with the repo-owned one-liner:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/install-pi-package.sh --global
|
||||||
|
```
|
||||||
|
|
||||||
|
For a project-local install instead:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/install-pi-package.sh --local
|
||||||
|
```
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
|
||||||
|
- Node.js 20+
|
||||||
|
- `pi`
|
||||||
|
- either `pnpm` on `PATH`, or `corepack` support from the Node install
|
||||||
|
|
||||||
|
The repo pins its pnpm version in `package.json` so Corepack-backed installs resolve consistently.
|
||||||
|
|
||||||
|
Before publishing or sharing a tarball, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/sync-pi-package-skills.sh
|
||||||
|
./scripts/verify-pi-resources.sh
|
||||||
|
npm pack --dry-run --json
|
||||||
|
```
|
||||||
|
|
||||||
|
Additional pi-specific guidance lives in [docs/PI.md](docs/PI.md).
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# PI RESEARCH
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This document records the pi-specific findings that drive the `pi` variants in this repo. It is intentionally source-backed so later skill work does not rely on memory or assumptions.
|
||||||
|
|
||||||
|
Checked on `2026-04-23`.
|
||||||
|
|
||||||
|
## Primary Sources
|
||||||
|
|
||||||
|
- [pi skills documentation](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/skills.md)
|
||||||
|
- [pi packages documentation](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/packages.md)
|
||||||
|
- [pi extensions documentation](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/extensions.md)
|
||||||
|
- [pi settings documentation](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/settings.md)
|
||||||
|
- [pi coding-agent README](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/README.md)
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
### Skill Discovery
|
||||||
|
|
||||||
|
Pi loads skills from multiple places:
|
||||||
|
|
||||||
|
- global skill roots: `~/.pi/agent/skills/` and `~/.agents/skills/`
|
||||||
|
- project skill roots: `.pi/skills/` and `.agents/skills/`
|
||||||
|
- package resources: `skills/` convention directories or `pi.skills` entries in `package.json`
|
||||||
|
- settings-provided skill paths
|
||||||
|
- explicit `--skill <path>` CLI arguments
|
||||||
|
|
||||||
|
Important discovery details from the skills docs:
|
||||||
|
|
||||||
|
- directories containing `SKILL.md` are discovered recursively
|
||||||
|
- top-level `.md` files are loaded as skills in pi-native roots like `.pi/skills/`
|
||||||
|
- per the upstream skills docs, top-level `.md` files are ignored in `.agents/skills/`
|
||||||
|
|
||||||
|
Implication for this repo: `skills/<skill>/pi/SKILL.md` fits pi's recursive discovery model cleanly for source authoring and manual copies, but it is not clean for package-discovered installs because Pi requires the immediate parent directory of `SKILL.md` to match the skill frontmatter `name`.
|
||||||
|
|
||||||
|
### Package Support
|
||||||
|
|
||||||
|
Pi packages are a first-class distribution path. The package docs say pi can load resources through conventional directories like `skills/`, or through explicit `pi` manifest entries in `package.json`.
|
||||||
|
|
||||||
|
Relevant package behaviors:
|
||||||
|
|
||||||
|
- `keywords: ["pi-package"]` makes packages show up in the gallery
|
||||||
|
- `pi.skills` can point at specific skill directories
|
||||||
|
- `files` allowlists should be used to keep tarballs tight
|
||||||
|
- installed pi packages can also ship `extensions/`, `prompts/`, and `themes/`
|
||||||
|
- when pi installs npm or git packages, it runs `npm install`
|
||||||
|
|
||||||
|
Implication for this repo: a single repo-level `package.json` is a viable v1 surface for shipping only the Pi resources, but the package-facing skill directories must be shaped like `<skill-name>/SKILL.md`. That means the repo should preserve `skills/<family>/pi/` for editing and expose a separate mirror such as `pi-package/skills/<skill-name>/` to Pi.
|
||||||
|
|
||||||
|
### Settings And Path Overrides
|
||||||
|
|
||||||
|
Pi settings support `packages`, `extensions`, `skills`, `prompts`, and `themes`. In both `~/.pi/agent/settings.json` and `.pi/settings.json`, the `skills` array can point to local files or directories, and the docs explicitly allow absolute paths and `~`.
|
||||||
|
|
||||||
|
Implication for this repo:
|
||||||
|
|
||||||
|
- pi can consume repo-local resources without publishing a package
|
||||||
|
- users can point pi at shared roots like `~/.agents/skills`
|
||||||
|
- workflow skills can document deterministic helper paths and fall back to settings-driven installs when needed
|
||||||
|
|
||||||
|
### Extensions
|
||||||
|
|
||||||
|
Pi extensions are TypeScript modules that can:
|
||||||
|
|
||||||
|
- register custom tools
|
||||||
|
- register commands
|
||||||
|
- intercept lifecycle events and tool calls
|
||||||
|
- add UI interactions and custom components
|
||||||
|
- persist session state
|
||||||
|
|
||||||
|
The coding-agent README explicitly lists advanced behaviors like sub-agents, plan mode, permission gates, MCP integration, and git checkpointing as extension-capable areas.
|
||||||
|
|
||||||
|
Implication for this repo: extensions are a real opportunity for workflow-heavy skills, but they are optional for the initial skill port. The base skill variants should remain usable without any extension dependency.
|
||||||
|
|
||||||
|
### Cross-Harness Compatibility
|
||||||
|
|
||||||
|
The pi skills docs explicitly call out `~/.agents/skills/` as a supported global skill root, and describe adding Codex or Claude skill directories through settings when needed.
|
||||||
|
|
||||||
|
Implication for this repo:
|
||||||
|
|
||||||
|
- existing Superpowers installs exposed through `~/.agents/skills/` can already be visible to pi
|
||||||
|
- pi-specific skill variants still need their own instructions because pi does not share Codex's `update_plan`, plan-mode, or worktree assumptions
|
||||||
|
|
||||||
|
## Decisions Derived From Research
|
||||||
|
|
||||||
|
- Use `skills/<skill>/pi/` for all five skill families.
|
||||||
|
- Package Pi skills through a separate mirror whose immediate directory names match the frontmatter `name` values.
|
||||||
|
- Publish shared pi guidance in repo docs instead of burying pi assumptions inside each skill.
|
||||||
|
- Use one repo-level pi package in v1.
|
||||||
|
- Assess extensions explicitly, but defer them unless they provide clear v1 value beyond documentation and helper scripts.
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# PI SUPERPOWERS
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This document is only about making Obra Superpowers visible to Pi.
|
||||||
|
|
||||||
|
If you need the shared reviewer helpers (`run-review.sh`, `notify-telegram.sh`), use [PI-COMMON-REVIEWER.md](./PI-COMMON-REVIEWER.md) instead.
|
||||||
|
|
||||||
|
## What Pi Needs
|
||||||
|
|
||||||
|
The workflow-heavy Pi skills depend on Superpowers such as:
|
||||||
|
|
||||||
|
- `brainstorming`
|
||||||
|
- `writing-plans`
|
||||||
|
- `executing-plans`
|
||||||
|
- `test-driven-development`
|
||||||
|
- `verification-before-completion`
|
||||||
|
- `finishing-a-development-branch`
|
||||||
|
- `using-git-worktrees`
|
||||||
|
|
||||||
|
Pi can discover them from shared roots like `~/.agents/skills/`, Pi-native roots like `~/.pi/agent/skills/` or `.pi/skills/`, or settings-defined skill directories.
|
||||||
|
|
||||||
|
## Verify An Existing Install
|
||||||
|
|
||||||
|
If you think Superpowers may already be installed, check the common shared-root setup first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
test -L ~/.agents/skills/superpowers
|
||||||
|
test -f ~/.agents/skills/superpowers/brainstorming/SKILL.md
|
||||||
|
test -f ~/.agents/skills/superpowers/test-driven-development/SKILL.md
|
||||||
|
test -f ~/.agents/skills/superpowers/verification-before-completion/SKILL.md
|
||||||
|
test -f ~/.agents/skills/superpowers/finishing-a-development-branch/SKILL.md
|
||||||
|
```
|
||||||
|
|
||||||
|
If those pass, Pi can usually reuse the same install directly.
|
||||||
|
|
||||||
|
To verify a Pi-native install instead:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
test -f ~/.pi/agent/skills/superpowers/brainstorming/SKILL.md || test -f .pi/skills/superpowers/brainstorming/SKILL.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Install Option 1: Reuse A Shared Skills Root
|
||||||
|
|
||||||
|
If you already have Superpowers available for another harness, the simplest Pi setup is to expose that same tree through `~/.agents/skills/`.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.agents/skills
|
||||||
|
ln -s ~/.codex/superpowers/skills ~/.agents/skills/superpowers
|
||||||
|
```
|
||||||
|
|
||||||
|
Re-run the verification checks above after creating the symlink.
|
||||||
|
|
||||||
|
## Install Option 2: Pi-Native Symlink Or Copy
|
||||||
|
|
||||||
|
If you do not want to use `~/.agents/skills/`, point Pi at a checked-out Superpowers tree directly.
|
||||||
|
|
||||||
|
Global Pi install:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.pi/agent/skills
|
||||||
|
ln -s /absolute/path/to/obra/superpowers/skills ~/.pi/agent/skills/superpowers
|
||||||
|
```
|
||||||
|
|
||||||
|
Project-local Pi install:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p .pi/skills
|
||||||
|
ln -s /absolute/path/to/obra/superpowers/skills .pi/skills/superpowers
|
||||||
|
```
|
||||||
|
|
||||||
|
If you prefer a settings-based path instead of a symlink, add it to either `~/.pi/agent/settings.json` or `.pi/settings.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"skills": [
|
||||||
|
"~/.agents/skills",
|
||||||
|
"/absolute/path/to/obra/superpowers/skills"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Post-Install Verification
|
||||||
|
|
||||||
|
After any install path, verify the specific skills your workflow needs.
|
||||||
|
|
||||||
|
Planning-focused check:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
test -f ~/.agents/skills/superpowers/brainstorming/SKILL.md || test -f ~/.pi/agent/skills/superpowers/brainstorming/SKILL.md || test -f .pi/skills/superpowers/brainstorming/SKILL.md
|
||||||
|
test -f ~/.agents/skills/superpowers/writing-plans/SKILL.md || test -f ~/.pi/agent/skills/superpowers/writing-plans/SKILL.md || test -f .pi/skills/superpowers/writing-plans/SKILL.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Execution-focused check:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
test -f ~/.agents/skills/superpowers/test-driven-development/SKILL.md || test -f ~/.pi/agent/skills/superpowers/test-driven-development/SKILL.md || test -f .pi/skills/superpowers/test-driven-development/SKILL.md
|
||||||
|
test -f ~/.agents/skills/superpowers/verification-before-completion/SKILL.md || test -f ~/.pi/agent/skills/superpowers/verification-before-completion/SKILL.md || test -f .pi/skills/superpowers/verification-before-completion/SKILL.md
|
||||||
|
test -f ~/.agents/skills/superpowers/finishing-a-development-branch/SKILL.md || test -f ~/.pi/agent/skills/superpowers/finishing-a-development-branch/SKILL.md || test -f .pi/skills/superpowers/finishing-a-development-branch/SKILL.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## What This Doc Does Not Cover
|
||||||
|
|
||||||
|
- reviewer-runtime helper installation
|
||||||
|
- Telegram helper installation
|
||||||
|
- package layout or Pi package installation
|
||||||
|
|
||||||
|
Those belong in [PI-COMMON-REVIEWER.md](./PI-COMMON-REVIEWER.md) and [PI.md](./PI.md).
|
||||||
+136
@@ -0,0 +1,136 @@
|
|||||||
|
# PI
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This repo treats pi as a first-class target alongside Codex, Claude Code, Cursor, and OpenCode.
|
||||||
|
|
||||||
|
The Pi support surface has two layers:
|
||||||
|
|
||||||
|
- editable source variants in `skills/<family>/pi/`
|
||||||
|
- a package-facing mirror in `pi-package/skills/<skill>/`
|
||||||
|
|
||||||
|
That split is intentional. Pi requires the immediate parent directory of `SKILL.md` to match the skill's frontmatter `name`, so the package cannot point directly at `skills/<family>/pi/`.
|
||||||
|
|
||||||
|
Related docs:
|
||||||
|
|
||||||
|
- [PI-RESEARCH.md](./PI-RESEARCH.md)
|
||||||
|
- [PI-SUPERPOWERS.md](./PI-SUPERPOWERS.md)
|
||||||
|
- [PI-COMMON-REVIEWER.md](./PI-COMMON-REVIEWER.md)
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
### Source Of Truth
|
||||||
|
|
||||||
|
Author Pi variants under:
|
||||||
|
|
||||||
|
- `skills/atlassian/pi/`
|
||||||
|
- `skills/create-plan/pi/`
|
||||||
|
- `skills/do-task/pi/`
|
||||||
|
- `skills/implement-plan/pi/`
|
||||||
|
- `skills/web-automation/pi/`
|
||||||
|
|
||||||
|
These are the directories to edit by hand.
|
||||||
|
|
||||||
|
### Package Mirror
|
||||||
|
|
||||||
|
The package exposes:
|
||||||
|
|
||||||
|
- `pi-package/skills/atlassian/`
|
||||||
|
- `pi-package/skills/create-plan/`
|
||||||
|
- `pi-package/skills/do-task/`
|
||||||
|
- `pi-package/skills/implement-plan/`
|
||||||
|
- `pi-package/skills/web-automation/`
|
||||||
|
|
||||||
|
Those directories are generated from the source variants by [`scripts/sync-pi-package-skills.sh`](../scripts/sync-pi-package-skills.sh).
|
||||||
|
|
||||||
|
### Shared Setup Docs
|
||||||
|
|
||||||
|
Workflow-heavy Pi skills split their shared setup across two docs:
|
||||||
|
|
||||||
|
- [PI-SUPERPOWERS.md](./PI-SUPERPOWERS.md) for installing and verifying Obra Superpowers in Pi
|
||||||
|
- [PI-COMMON-REVIEWER.md](./PI-COMMON-REVIEWER.md) for installing and verifying the shared reviewer-runtime helpers
|
||||||
|
|
||||||
|
## Package Install
|
||||||
|
|
||||||
|
The user-facing install flow is the repo-owned installer script, not a raw `pi install` command.
|
||||||
|
|
||||||
|
Global install from a cloned checkout:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/install-pi-package.sh --global
|
||||||
|
```
|
||||||
|
|
||||||
|
Project-local install from a cloned checkout:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/install-pi-package.sh --local
|
||||||
|
```
|
||||||
|
|
||||||
|
Prerequisites for that one-liner:
|
||||||
|
|
||||||
|
- Node.js 20+
|
||||||
|
- `pi`
|
||||||
|
- either `pnpm` on `PATH`, or Node's bundled `corepack` support
|
||||||
|
|
||||||
|
The installer uses `pnpm` directly when available and falls back to `corepack pnpm` otherwise.
|
||||||
|
The root `package.json` pins the pnpm version so Corepack-backed installs resolve consistently.
|
||||||
|
|
||||||
|
That script:
|
||||||
|
|
||||||
|
- runs `pi install` in the chosen scope
|
||||||
|
- installs the nested runtime dependencies for `atlassian`
|
||||||
|
- installs the nested runtime dependencies for `web-automation`
|
||||||
|
- fetches the CloakBrowser binary for `web-automation`
|
||||||
|
- prints `pi list` at the end so the active install is visible immediately
|
||||||
|
|
||||||
|
For this cloned-checkout flow, local checkout package install keeps the runtime in `pi-package/skills/<skill>/scripts`. Pi loads the skills from the package mirror in this repo; it does not copy them into `~/.pi/agent/skills/<skill>/` or `.pi/skills/<skill>/` unless you do a manual copy install.
|
||||||
|
|
||||||
|
The package surface intentionally ships:
|
||||||
|
|
||||||
|
- `pi-package/skills/**`
|
||||||
|
- `skills/reviewer-runtime/pi/**`
|
||||||
|
- `docs/PI*.md`
|
||||||
|
- `scripts/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 installer intentionally does not run sync. It assumes the checked-in `pi-package/skills/*` mirror is already current.
|
||||||
|
|
||||||
|
The verifier is responsible for catching:
|
||||||
|
|
||||||
|
- missing mirror directories
|
||||||
|
- source/mirror drift
|
||||||
|
- package metadata pointing at the wrong skill roots
|
||||||
|
- missing shared Pi docs
|
||||||
|
- missing user-facing installer wiring
|
||||||
|
|
||||||
|
## Extension Decision
|
||||||
|
|
||||||
|
Pi extensions are still optional for this repo.
|
||||||
|
|
||||||
|
Current v1 decision:
|
||||||
|
|
||||||
|
- ship usable Pi skills without extensions
|
||||||
|
- keep the package focused on skills, docs, and helpers
|
||||||
|
- revisit extensions only when documentation and helper scripts stop being enough
|
||||||
@@ -8,6 +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.
|
- [CREATE-PLAN.md](./CREATE-PLAN.md) — Includes requirements, install, verification, and execution workflow for create-plan.
|
||||||
- [DO-TASK.md](./DO-TASK.md) — Single-prompt end-to-end execution with dual reviewer loops (plan + implementation), TDD-first, single task commit. Sibling of create-plan/implement-plan scoped to ad-hoc tasks.
|
- [DO-TASK.md](./DO-TASK.md) — Single-prompt end-to-end execution with dual reviewer loops (plan + implementation), TDD-first, single task commit. Sibling of create-plan/implement-plan scoped to ad-hoc tasks.
|
||||||
- [IMPLEMENT-PLAN.md](./IMPLEMENT-PLAN.md) — Includes requirements, install, verification, and milestone review workflow for implement-plan.
|
- [IMPLEMENT-PLAN.md](./IMPLEMENT-PLAN.md) — Includes requirements, install, verification, and milestone review workflow for implement-plan.
|
||||||
|
- [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) — 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.
|
- [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.
|
- [WEB-AUTOMATION.md](./WEB-AUTOMATION.md) — Includes requirements, install, dependency verification, and usage examples for web-automation.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "ai-coding-skills-pi",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Pi variants and shared runtime helpers for ai-coding-skills.",
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"private": true,
|
||||||
|
"keywords": [
|
||||||
|
"pi-package",
|
||||||
|
"agent-skills",
|
||||||
|
"pi"
|
||||||
|
],
|
||||||
|
"files": [
|
||||||
|
"README.md",
|
||||||
|
"docs/ATLASSIAN.md",
|
||||||
|
"docs/CREATE-PLAN.md",
|
||||||
|
"docs/DO-TASK.md",
|
||||||
|
"docs/IMPLEMENT-PLAN.md",
|
||||||
|
"docs/README.md",
|
||||||
|
"docs/TELEGRAM-NOTIFICATIONS.md",
|
||||||
|
"docs/PI.md",
|
||||||
|
"docs/PI-RESEARCH.md",
|
||||||
|
"docs/PI-SUPERPOWERS.md",
|
||||||
|
"docs/PI-COMMON-REVIEWER.md",
|
||||||
|
"docs/WEB-AUTOMATION.md",
|
||||||
|
"pi-package/skills",
|
||||||
|
"skills/reviewer-runtime/pi",
|
||||||
|
"scripts/install-pi-package.sh",
|
||||||
|
"scripts/sync-pi-package-skills.sh",
|
||||||
|
"scripts/verify-pi-resources.sh"
|
||||||
|
],
|
||||||
|
"pi": {
|
||||||
|
"skills": [
|
||||||
|
"./pi-package/skills/atlassian",
|
||||||
|
"./pi-package/skills/create-plan",
|
||||||
|
"./pi-package/skills/do-task",
|
||||||
|
"./pi-package/skills/implement-plan",
|
||||||
|
"./pi-package/skills/web-automation"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34"
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
---
|
||||||
|
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).
|
||||||
|
|
||||||
|
If you installed this repo from a local checkout with `./scripts/install-pi-package.sh`, the runtime stays in the checkout mirror at `pi-package/skills/atlassian/scripts`.
|
||||||
|
|
||||||
|
## Prerequisite Check (MANDATORY)
|
||||||
|
|
||||||
|
Run inside the skill runtime directory that matches your install style:
|
||||||
|
|
||||||
|
- local checkout package install: `pi-package/skills/atlassian/scripts`
|
||||||
|
- project-local copied install: `.pi/skills/atlassian/scripts`
|
||||||
|
- global copied install: `~/.pi/agent/skills/atlassian/scripts`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd pi-package/skills/atlassian/scripts
|
||||||
|
node -e "require.resolve('commander');require.resolve('dotenv');console.log('OK: runtime dependencies installed')"
|
||||||
|
node -e 'require("dotenv").config({ path: ".env" }); const required = ["ATLASSIAN_BASE_URL", "ATLASSIAN_EMAIL", "ATLASSIAN_API_TOKEN"]; const missing = required.filter((key) => !(process.env[key] || "").trim()); if (missing.length) { console.error("Missing required Atlassian config: " + missing.join(", ")); process.exit(1); } console.log("OK: Atlassian config present")'
|
||||||
|
pnpm atlassian health
|
||||||
|
```
|
||||||
|
|
||||||
|
If any check fails, stop and return:
|
||||||
|
|
||||||
|
`Missing dependency/config: atlassian requires installed CLI dependencies and valid Atlassian Cloud credentials. Configure ATLASSIAN_* in the shell environment or scripts/.env, then retry.`
|
||||||
|
|
||||||
|
## Supported Commands
|
||||||
|
|
||||||
|
- `pnpm atlassian health`
|
||||||
|
- `pnpm atlassian jira-search --jql "..."`
|
||||||
|
- `pnpm atlassian jira-get --issue ABC-123`
|
||||||
|
- `pnpm atlassian jira-create ... [--dry-run]`
|
||||||
|
- `pnpm atlassian jira-update ... [--dry-run]`
|
||||||
|
- `pnpm atlassian jira-comment ... [--dry-run]`
|
||||||
|
- `pnpm atlassian jira-transitions --issue ABC-123`
|
||||||
|
- `pnpm atlassian jira-transition ... [--dry-run]`
|
||||||
|
- `pnpm atlassian conf-search --query "..."`
|
||||||
|
- `pnpm atlassian conf-get --page 12345`
|
||||||
|
- `pnpm atlassian conf-create ... [--dry-run]`
|
||||||
|
- `pnpm atlassian conf-update ... [--dry-run]`
|
||||||
|
- `pnpm atlassian conf-comment ... [--dry-run]`
|
||||||
|
- `pnpm atlassian conf-children --page 12345`
|
||||||
|
- `pnpm atlassian raw --product jira|confluence --method GET|POST|PUT --path ...`
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
- `pnpm atlassian jira-search --jql "project = ENG ORDER BY updated DESC" --max-results 10`
|
||||||
|
- `pnpm atlassian conf-comment --page 12345 --body-file comment.storage.html --dry-run`
|
||||||
|
- `pnpm atlassian raw --product jira --method GET --path "/rest/api/3/issue/ENG-123"`
|
||||||
|
|
||||||
|
## Safety Rules
|
||||||
|
|
||||||
|
- Default output is JSON; prefer that for agent workflows.
|
||||||
|
- Use `--dry-run` before any mutating command unless the user clearly wants the write to happen immediately.
|
||||||
|
- `raw` is for explicit edge cases only and does not allow `DELETE`.
|
||||||
|
- `--body-file` must stay inside the current workspace.
|
||||||
|
- Confluence write bodies should be storage-format inputs in v1.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Atlassian Cloud is the primary supported platform in v1.
|
||||||
|
- Package installs use the repo's `pi-package/skills/atlassian/` mirror so the installed skill directory name matches `atlassian`.
|
||||||
@@ -0,0 +1,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
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
---
|
||||||
|
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).
|
||||||
|
|
||||||
|
If you installed this repo from a local checkout with `./scripts/install-pi-package.sh`, the runtime stays in the checkout mirror at `pi-package/skills/web-automation/scripts`.
|
||||||
|
|
||||||
|
## Updating CloakBrowser
|
||||||
|
|
||||||
|
Run inside the installed `scripts/` directory for the pi skill. The commands below work for both global and project-local installs as long as you run them from the installed `scripts/` directory.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm up cloakbrowser playwright-core
|
||||||
|
npx cloakbrowser install
|
||||||
|
pnpm approve-builds
|
||||||
|
pnpm rebuild better-sqlite3 esbuild
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prerequisite Check (MANDATORY)
|
||||||
|
|
||||||
|
Before running automation, verify the runtime from the location that matches your install style:
|
||||||
|
|
||||||
|
- local checkout package install: `pi-package/skills/web-automation/scripts`
|
||||||
|
- project-local copied install: `.pi/skills/web-automation/scripts`
|
||||||
|
- global copied install: `~/.pi/agent/skills/web-automation/scripts`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd pi-package/skills/web-automation/scripts
|
||||||
|
node check-install.js
|
||||||
|
```
|
||||||
|
|
||||||
|
If the check fails, stop and return:
|
||||||
|
|
||||||
|
`Missing dependency/config: web-automation requires cloakbrowser and playwright-core with CloakBrowser-based scripts. Run setup in this skill, then retry.`
|
||||||
|
|
||||||
|
If runtime fails with missing native bindings for `better-sqlite3` or `esbuild`, run the same commands from your installed `scripts/` directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd pi-package/skills/web-automation/scripts
|
||||||
|
pnpm approve-builds
|
||||||
|
pnpm rebuild better-sqlite3 esbuild
|
||||||
|
```
|
||||||
|
|
||||||
|
## When To Use Which Command
|
||||||
|
|
||||||
|
- Use `node extract.js "<URL>"` for a one-shot rendered fetch with JSON output.
|
||||||
|
- Use `npx tsx scrape.ts ...` when you need markdown extraction, Readability cleanup, or selector-based scraping.
|
||||||
|
- Use `npx tsx browse.ts ...`, `auth.ts`, or `flow.ts` when the task needs login handling, persistent sessions, clicks, typing, screenshots, or multi-step navigation.
|
||||||
|
- Use `npx tsx scan-local-app.ts` when you need a configurable local-app smoke pass driven by `SCAN_*` and `CLOAKBROWSER_*` environment variables.
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
- Install check: `node check-install.js`
|
||||||
|
- One-shot JSON extract: `node extract.js "https://example.com"`
|
||||||
|
- Browse page: `npx tsx browse.ts --url "https://example.com"`
|
||||||
|
- Scrape markdown: `npx tsx scrape.ts --url "https://example.com" --mode main --output page.md`
|
||||||
|
- Authenticate: `npx tsx auth.ts --url "https://example.com/login"`
|
||||||
|
- Natural-language flow: `npx tsx flow.ts --instruction 'go to https://example.com then click on "Login" then type "user@example.com" in #email then press enter'`
|
||||||
|
- Local app smoke scan: `SCAN_BASE_URL=http://localhost:3000 SCAN_ROUTES=/,/dashboard npx tsx scan-local-app.ts`
|
||||||
|
|
||||||
|
## Local App Smoke Scan
|
||||||
|
|
||||||
|
`scan-local-app.ts` is intentionally generic. Configure it with environment variables instead of editing the file:
|
||||||
|
|
||||||
|
- `SCAN_BASE_URL`
|
||||||
|
- `SCAN_LOGIN_PATH`
|
||||||
|
- `SCAN_USERNAME`
|
||||||
|
- `SCAN_PASSWORD`
|
||||||
|
- `SCAN_USERNAME_SELECTOR`
|
||||||
|
- `SCAN_PASSWORD_SELECTOR`
|
||||||
|
- `SCAN_SUBMIT_SELECTOR`
|
||||||
|
- `SCAN_ROUTES`
|
||||||
|
- `SCAN_REPORT_PATH`
|
||||||
|
- `SCAN_HEADLESS`
|
||||||
|
|
||||||
|
If `SCAN_USERNAME` or `SCAN_PASSWORD` are omitted, the script falls back to `CLOAKBROWSER_USERNAME` and `CLOAKBROWSER_PASSWORD`.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Sessions persist in CloakBrowser profile storage.
|
||||||
|
- Use `--wait` for dynamic pages.
|
||||||
|
- Use `--mode selector --selector "..."` for targeted extraction.
|
||||||
|
- `extract.js` keeps a bounded stealth/rendered fetch path without needing a long-lived automation session.
|
||||||
|
- Package installs use the repo's `pi-package/skills/web-automation/` mirror so the installed skill directory name matches `web-automation`.
|
||||||
@@ -0,0 +1,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,43 @@
|
|||||||
|
#!/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");
|
||||||
|
await import("better-sqlite3");
|
||||||
|
await import("esbuild");
|
||||||
|
} catch (error) {
|
||||||
|
fail(
|
||||||
|
"Missing dependency/config: web-automation requires cloakbrowser, playwright-core, better-sqlite3, and esbuild.",
|
||||||
|
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: better-sqlite3 + esbuild installed\n");
|
||||||
|
process.stdout.write("OK: CloakBrowser integration detected in browse.ts\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
fail("Install check failed.", error instanceof Error ? error.message : String(error));
|
||||||
|
});
|
||||||
+188
@@ -0,0 +1,188 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const DEFAULT_WAIT_MS = 5000;
|
||||||
|
const MAX_WAIT_MS = 20000;
|
||||||
|
const NAV_TIMEOUT_MS = 30000;
|
||||||
|
const EXTRA_CHALLENGE_WAIT_MS = 8000;
|
||||||
|
const CONTENT_LIMIT = 12000;
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
function fail(message, details) {
|
||||||
|
const payload = { error: message };
|
||||||
|
if (details) payload.details = details;
|
||||||
|
process.stderr.write(`${JSON.stringify(payload)}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWaitTime(raw) {
|
||||||
|
const value = Number.parseInt(raw || `${DEFAULT_WAIT_MS}`, 10);
|
||||||
|
if (!Number.isFinite(value) || value < 0) return DEFAULT_WAIT_MS;
|
||||||
|
return Math.min(value, MAX_WAIT_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTarget(rawUrl) {
|
||||||
|
if (!rawUrl) {
|
||||||
|
fail("Missing URL. Usage: node extract.js <URL>");
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = new URL(rawUrl);
|
||||||
|
} catch (error) {
|
||||||
|
fail("Invalid URL.", error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!["http:", "https:"].includes(parsed.protocol)) {
|
||||||
|
fail("Only http and https URLs are allowed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureParentDir(filePath) {
|
||||||
|
if (!filePath) return;
|
||||||
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detectChallenge(page) {
|
||||||
|
try {
|
||||||
|
return await page.evaluate(() => {
|
||||||
|
const text = (document.body?.innerText || "").toLowerCase();
|
||||||
|
return (
|
||||||
|
text.includes("checking your browser") ||
|
||||||
|
text.includes("just a moment") ||
|
||||||
|
text.includes("verify you are human") ||
|
||||||
|
text.includes("press and hold") ||
|
||||||
|
document.querySelector('iframe[src*="challenge"]') !== null ||
|
||||||
|
document.querySelector('iframe[src*="cloudflare"]') !== null
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCloakBrowser() {
|
||||||
|
try {
|
||||||
|
return await import("cloakbrowser");
|
||||||
|
} catch (error) {
|
||||||
|
fail(
|
||||||
|
"CloakBrowser is not installed for this skill. Run pnpm install in this skill's scripts directory first.",
|
||||||
|
error.message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runWithStderrLogs(fn) {
|
||||||
|
const originalLog = console.log;
|
||||||
|
const originalError = console.error;
|
||||||
|
console.log = (...args) => process.stderr.write(`${args.join(" ")}\n`);
|
||||||
|
console.error = (...args) => process.stderr.write(`${args.join(" ")}\n`);
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
console.log = originalLog;
|
||||||
|
console.error = originalError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const requestedUrl = parseTarget(process.argv[2]);
|
||||||
|
const waitTime = parseWaitTime(process.env.WAIT_TIME);
|
||||||
|
const screenshotPath = process.env.SCREENSHOT_PATH || "";
|
||||||
|
const saveHtml = process.env.SAVE_HTML === "true";
|
||||||
|
const headless = process.env.HEADLESS !== "false";
|
||||||
|
const userAgent = process.env.USER_AGENT || undefined;
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const { ensureBinary, launchContext } = await loadCloakBrowser();
|
||||||
|
|
||||||
|
let context;
|
||||||
|
try {
|
||||||
|
await runWithStderrLogs(() => ensureBinary());
|
||||||
|
|
||||||
|
context = await runWithStderrLogs(() => launchContext({
|
||||||
|
headless,
|
||||||
|
userAgent,
|
||||||
|
locale: "en-US",
|
||||||
|
viewport: { width: 1440, height: 900 },
|
||||||
|
humanize: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const page = await context.newPage();
|
||||||
|
const response = await page.goto(requestedUrl, {
|
||||||
|
waitUntil: "domcontentloaded",
|
||||||
|
timeout: NAV_TIMEOUT_MS
|
||||||
|
});
|
||||||
|
|
||||||
|
await sleep(waitTime);
|
||||||
|
|
||||||
|
let challengeDetected = await detectChallenge(page);
|
||||||
|
if (challengeDetected) {
|
||||||
|
await sleep(EXTRA_CHALLENGE_WAIT_MS);
|
||||||
|
challengeDetected = await detectChallenge(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
const extracted = await page.evaluate((contentLimit) => {
|
||||||
|
const bodyText = document.body?.innerText || "";
|
||||||
|
return {
|
||||||
|
finalUrl: window.location.href,
|
||||||
|
title: document.title || "",
|
||||||
|
content: bodyText.slice(0, contentLimit),
|
||||||
|
metaDescription:
|
||||||
|
document.querySelector('meta[name="description"]')?.content ||
|
||||||
|
document.querySelector('meta[property="og:description"]')?.content ||
|
||||||
|
""
|
||||||
|
};
|
||||||
|
}, CONTENT_LIMIT);
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
requestedUrl,
|
||||||
|
finalUrl: extracted.finalUrl,
|
||||||
|
title: extracted.title,
|
||||||
|
content: extracted.content,
|
||||||
|
metaDescription: extracted.metaDescription,
|
||||||
|
status: response ? response.status() : null,
|
||||||
|
challengeDetected,
|
||||||
|
elapsedSeconds: ((Date.now() - startedAt) / 1000).toFixed(2)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (screenshotPath) {
|
||||||
|
ensureParentDir(screenshotPath);
|
||||||
|
await page.screenshot({ path: screenshotPath, fullPage: false, timeout: 10000 });
|
||||||
|
result.screenshot = screenshotPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (saveHtml) {
|
||||||
|
const htmlTarget = screenshotPath
|
||||||
|
? screenshotPath.replace(/\.[^.]+$/, ".html")
|
||||||
|
: path.resolve(__dirname, `page-${Date.now()}.html`);
|
||||||
|
ensureParentDir(htmlTarget);
|
||||||
|
fs.writeFileSync(htmlTarget, await page.content());
|
||||||
|
result.htmlFile = htmlTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||||
|
await context.close();
|
||||||
|
} catch (error) {
|
||||||
|
if (context) {
|
||||||
|
try {
|
||||||
|
await context.close();
|
||||||
|
} catch {
|
||||||
|
// Ignore close errors after the primary failure.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fail("Scrape failed.", error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
#!/usr/bin/env npx tsx
|
||||||
|
|
||||||
|
import parseArgs from 'minimist';
|
||||||
|
import type { Page } from 'playwright-core';
|
||||||
|
import { launchBrowser } from './browse';
|
||||||
|
|
||||||
|
type Step =
|
||||||
|
| { action: 'goto'; url: string }
|
||||||
|
| { action: 'click'; selector?: string; text?: string; role?: string; name?: string }
|
||||||
|
| { action: 'type'; selector?: string; text: string }
|
||||||
|
| { action: 'press'; key: string; selector?: string }
|
||||||
|
| { action: 'wait'; ms: number }
|
||||||
|
| { action: 'screenshot'; path: string }
|
||||||
|
| { action: 'extract'; selector: string; count?: number };
|
||||||
|
|
||||||
|
function normalizeNavigationUrl(rawUrl: string): string {
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(rawUrl);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Invalid navigation URL: ${rawUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||||
|
throw new Error(`Only http and https URLs are allowed in flow steps: ${rawUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeKey(k: string): string {
|
||||||
|
if (!k) return 'Enter';
|
||||||
|
const lower = k.toLowerCase();
|
||||||
|
if (lower === 'enter' || lower === 'return') return 'Enter';
|
||||||
|
if (lower === 'tab') return 'Tab';
|
||||||
|
if (lower === 'escape' || lower === 'esc') return 'Escape';
|
||||||
|
return k;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitInstructions(instruction: string): string[] {
|
||||||
|
return instruction
|
||||||
|
.split(/\bthen\b|;/gi)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseInstruction(instruction: string): Step[] {
|
||||||
|
const parts = splitInstructions(instruction);
|
||||||
|
const steps: Step[] = [];
|
||||||
|
|
||||||
|
for (const p of parts) {
|
||||||
|
// go to https://...
|
||||||
|
const goto = p.match(/^(?:go to|open|navigate to)\s+(https?:\/\/\S+)/i);
|
||||||
|
if (goto) {
|
||||||
|
steps.push({ action: 'goto', url: normalizeNavigationUrl(goto[1]) });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// click on "text" or click #selector or click button "name"
|
||||||
|
const clickRole = p.match(/^click\s+(button|link|textbox|img|image|tab)\s+"([^"]+)"$/i);
|
||||||
|
if (clickRole) {
|
||||||
|
const role = clickRole[1].toLowerCase() === 'image' ? 'img' : clickRole[1].toLowerCase();
|
||||||
|
steps.push({ action: 'click', role, name: clickRole[2] });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const clickText = p.match(/^click(?: on)?\s+"([^"]+)"/i);
|
||||||
|
if (clickText) {
|
||||||
|
steps.push({ action: 'click', text: clickText[1] });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const clickSelector = p.match(/^click(?: on)?\s+(#[\w-]+|\.[\w-]+|[a-z]+\[[^\]]+\])/i);
|
||||||
|
if (clickSelector) {
|
||||||
|
steps.push({ action: 'click', selector: clickSelector[1] });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// type "text" [in selector]
|
||||||
|
const typeInto = p.match(/^type\s+"([^"]+)"\s+in\s+(.+)$/i);
|
||||||
|
if (typeInto) {
|
||||||
|
steps.push({ action: 'type', text: typeInto[1], selector: typeInto[2].trim() });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const typeOnly = p.match(/^type\s+"([^"]+)"$/i);
|
||||||
|
if (typeOnly) {
|
||||||
|
steps.push({ action: 'type', text: typeOnly[1] });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// press enter [in selector]
|
||||||
|
const pressIn = p.match(/^press\s+(\w+)\s+in\s+(.+)$/i);
|
||||||
|
if (pressIn) {
|
||||||
|
steps.push({ action: 'press', key: normalizeKey(pressIn[1]), selector: pressIn[2].trim() });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const pressOnly = p.match(/^press\s+(\w+)$/i);
|
||||||
|
if (pressOnly) {
|
||||||
|
steps.push({ action: 'press', key: normalizeKey(pressOnly[1]) });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// wait 2s / wait 500ms
|
||||||
|
const waitS = p.match(/^wait\s+(\d+)\s*s(?:ec(?:onds?)?)?$/i);
|
||||||
|
if (waitS) {
|
||||||
|
steps.push({ action: 'wait', ms: parseInt(waitS[1], 10) * 1000 });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const waitMs = p.match(/^wait\s+(\d+)\s*ms$/i);
|
||||||
|
if (waitMs) {
|
||||||
|
steps.push({ action: 'wait', ms: parseInt(waitMs[1], 10) });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// screenshot path
|
||||||
|
const shot = p.match(/^screenshot(?: to)?\s+(.+)$/i);
|
||||||
|
if (shot) {
|
||||||
|
steps.push({ action: 'screenshot', path: shot[1].trim() });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Could not parse step: "${p}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return steps;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSteps(steps: Step[]): Step[] {
|
||||||
|
return steps.map((step) =>
|
||||||
|
step.action === 'goto'
|
||||||
|
? {
|
||||||
|
...step,
|
||||||
|
url: normalizeNavigationUrl(step.url),
|
||||||
|
}
|
||||||
|
: step
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegExp(value: string): string {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLikelyLoginText(text: string): boolean {
|
||||||
|
return /(login|accedi|sign\s*in|entra)/i.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clickByText(page: Page, text: string): Promise<boolean> {
|
||||||
|
const patterns = [new RegExp(`^${escapeRegExp(text)}$`, 'i'), new RegExp(escapeRegExp(text), 'i')];
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const targets = [
|
||||||
|
page.getByRole('button', { name: pattern }).first(),
|
||||||
|
page.getByRole('link', { name: pattern }).first(),
|
||||||
|
page.getByText(pattern).first(),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const target of targets) {
|
||||||
|
if (await target.count()) {
|
||||||
|
try {
|
||||||
|
await target.click({ timeout: 8000 });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
// keep trying next candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fallbackLoginNavigation(page: Page, requestedText: string): Promise<boolean> {
|
||||||
|
if (!isLikelyLoginText(requestedText)) return false;
|
||||||
|
|
||||||
|
const current = new URL(page.url());
|
||||||
|
|
||||||
|
const candidateLinks = await page.evaluate(() => {
|
||||||
|
const loginTerms = ['login', 'accedi', 'sign in', 'entra'];
|
||||||
|
const anchors = Array.from(document.querySelectorAll('a[href], a[onclick], button[onclick]')) as Array<HTMLAnchorElement | HTMLButtonElement>;
|
||||||
|
|
||||||
|
return anchors
|
||||||
|
.map((el) => {
|
||||||
|
const text = (el.textContent || '').trim().toLowerCase();
|
||||||
|
const href = (el as HTMLAnchorElement).getAttribute('href') || '';
|
||||||
|
return { text, href };
|
||||||
|
})
|
||||||
|
.filter((x) => x.text && loginTerms.some((t) => x.text.includes(t)))
|
||||||
|
.map((x) => x.href)
|
||||||
|
.filter(Boolean);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prefer real URLs (not javascript:)
|
||||||
|
const realCandidate = candidateLinks.find((h) => /login|account\/login/i.test(h) && !h.startsWith('javascript:'));
|
||||||
|
if (realCandidate) {
|
||||||
|
const target = new URL(realCandidate, page.url()).toString();
|
||||||
|
await page.goto(target, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Site-specific fallback for Corriere
|
||||||
|
if (/corriere\.it$/i.test(current.hostname) || /\.corriere\.it$/i.test(current.hostname)) {
|
||||||
|
await page.goto('https://www.corriere.it/account/login', {
|
||||||
|
waitUntil: 'domcontentloaded',
|
||||||
|
timeout: 60000,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function typeInBestTarget(page: Page, text: string, selector?: string) {
|
||||||
|
if (selector) {
|
||||||
|
await page.locator(selector).first().click({ timeout: 10000 });
|
||||||
|
await page.locator(selector).first().fill(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const loc = page.locator('input[name="q"], input[type="search"], input[type="text"], textarea').first();
|
||||||
|
await loc.click({ timeout: 10000 });
|
||||||
|
await loc.fill(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pressOnTarget(page: Page, key: string, selector?: string) {
|
||||||
|
if (selector) {
|
||||||
|
await page.locator(selector).first().press(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await page.keyboard.press(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSteps(page: Page, steps: Step[]) {
|
||||||
|
for (const step of steps) {
|
||||||
|
switch (step.action) {
|
||||||
|
case 'goto':
|
||||||
|
await page.goto(normalizeNavigationUrl(step.url), {
|
||||||
|
waitUntil: 'domcontentloaded',
|
||||||
|
timeout: 60000,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'click':
|
||||||
|
if (step.selector) {
|
||||||
|
await page.locator(step.selector).first().click({ timeout: 15000 });
|
||||||
|
} else if (step.role && step.name) {
|
||||||
|
await page.getByRole(step.role as any, { name: new RegExp(escapeRegExp(step.name), 'i') }).first().click({ timeout: 15000 });
|
||||||
|
} else if (step.text) {
|
||||||
|
const clicked = await clickByText(page, step.text);
|
||||||
|
if (!clicked) {
|
||||||
|
const recovered = await fallbackLoginNavigation(page, step.text);
|
||||||
|
if (!recovered) {
|
||||||
|
throw new Error(`Could not click target text: ${step.text}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error('click step missing selector/text/role');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await page.waitForLoadState('domcontentloaded', { timeout: 10000 });
|
||||||
|
} catch {
|
||||||
|
// no navigation is fine
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'type':
|
||||||
|
await typeInBestTarget(page, step.text, step.selector);
|
||||||
|
break;
|
||||||
|
case 'press':
|
||||||
|
await pressOnTarget(page, step.key, step.selector);
|
||||||
|
break;
|
||||||
|
case 'wait':
|
||||||
|
await page.waitForTimeout(step.ms);
|
||||||
|
break;
|
||||||
|
case 'screenshot':
|
||||||
|
await page.screenshot({ path: step.path, fullPage: true });
|
||||||
|
break;
|
||||||
|
case 'extract': {
|
||||||
|
const items = await page.locator(step.selector).allTextContents();
|
||||||
|
const out = items.slice(0, step.count ?? items.length).map((t) => t.trim()).filter(Boolean);
|
||||||
|
console.log(JSON.stringify(out, null, 2));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new Error('Unknown step');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = parseArgs(process.argv.slice(2), {
|
||||||
|
string: ['instruction', 'steps'],
|
||||||
|
boolean: ['headless', 'help'],
|
||||||
|
default: { headless: true },
|
||||||
|
alias: { i: 'instruction', s: 'steps', h: 'help' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (args.help || (!args.instruction && !args.steps)) {
|
||||||
|
console.log(`
|
||||||
|
General Web Flow Runner (CloakBrowser)
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
npx tsx flow.ts --instruction "go to https://example.com then type \"hello\" then press enter"
|
||||||
|
npx tsx flow.ts --steps '[{"action":"goto","url":"https://example.com"}]'
|
||||||
|
|
||||||
|
Supported natural steps:
|
||||||
|
- go to/open/navigate to <url>
|
||||||
|
- click on "Text"
|
||||||
|
- click <css-selector>
|
||||||
|
- type "text"
|
||||||
|
- type "text" in <css-selector>
|
||||||
|
- press <key>
|
||||||
|
- press <key> in <css-selector>
|
||||||
|
- wait <N>s | wait <N>ms
|
||||||
|
- screenshot <path>
|
||||||
|
`);
|
||||||
|
process.exit(args.help ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const steps = validateSteps(args.steps ? JSON.parse(args.steps) : parseInstruction(args.instruction));
|
||||||
|
const browser = await launchBrowser({ headless: args.headless });
|
||||||
|
const page = await browser.newPage();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runSteps(page, steps);
|
||||||
|
console.log('Flow complete. Final URL:', page.url());
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error('Error:', e instanceof Error ? e.message : e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "web-automation-scripts",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Web browsing and scraping scripts using CloakBrowser",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"check-install": "node check-install.js",
|
||||||
|
"extract": "node extract.js",
|
||||||
|
"browse": "tsx browse.ts",
|
||||||
|
"auth": "tsx auth.ts",
|
||||||
|
"flow": "tsx flow.ts",
|
||||||
|
"scrape": "tsx scrape.ts",
|
||||||
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||||
|
"lint": "pnpm run typecheck && node --check check-install.js && node --check extract.js",
|
||||||
|
"fetch-browser": "npx cloakbrowser install"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@mozilla/readability": "^0.5.0",
|
||||||
|
"better-sqlite3": "^12.6.2",
|
||||||
|
"cloakbrowser": "^0.3.22",
|
||||||
|
"jsdom": "^24.0.0",
|
||||||
|
"minimist": "^1.2.8",
|
||||||
|
"playwright-core": "^1.59.1",
|
||||||
|
"turndown": "^7.1.2",
|
||||||
|
"turndown-plugin-gfm": "^1.0.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/jsdom": "^21.1.6",
|
||||||
|
"@types/minimist": "^1.2.5",
|
||||||
|
"@types/turndown": "^5.0.4",
|
||||||
|
"esbuild": "0.27.0",
|
||||||
|
"tsx": "^4.7.0",
|
||||||
|
"typescript": "^5.3.0"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34"
|
||||||
|
}
|
||||||
+1292
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,174 @@
|
|||||||
|
#!/usr/bin/env npx tsx
|
||||||
|
|
||||||
|
import { mkdirSync, writeFileSync } from 'fs';
|
||||||
|
import { dirname, resolve } from 'path';
|
||||||
|
import { getPage } from './browse.js';
|
||||||
|
|
||||||
|
type NavResult = {
|
||||||
|
requestedUrl: string;
|
||||||
|
url: string;
|
||||||
|
status: number | null;
|
||||||
|
title: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RouteCheck = {
|
||||||
|
route: string;
|
||||||
|
result: NavResult;
|
||||||
|
heading: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_BASE_URL = 'http://localhost:3000';
|
||||||
|
const DEFAULT_REPORT_PATH = resolve(process.cwd(), 'scan-local-app.md');
|
||||||
|
|
||||||
|
function env(name: string): string | undefined {
|
||||||
|
const value = process.env[name]?.trim();
|
||||||
|
return value ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRoutes(baseUrl: string): string[] {
|
||||||
|
const routeList = env('SCAN_ROUTES');
|
||||||
|
if (routeList) {
|
||||||
|
return routeList
|
||||||
|
.split(',')
|
||||||
|
.map((route) => route.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((route) => new URL(route, baseUrl).toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
return [baseUrl];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gotoWithStatus(page: any, url: string): Promise<NavResult> {
|
||||||
|
const response = await page
|
||||||
|
.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 })
|
||||||
|
.catch((error: unknown) => ({ error }));
|
||||||
|
|
||||||
|
if (response?.error) {
|
||||||
|
return {
|
||||||
|
requestedUrl: url,
|
||||||
|
url: page.url(),
|
||||||
|
status: null,
|
||||||
|
title: await page.title().catch(() => ''),
|
||||||
|
error: String(response.error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
requestedUrl: url,
|
||||||
|
url: page.url(),
|
||||||
|
status: response ? response.status() : null,
|
||||||
|
title: await page.title().catch(() => ''),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function textOrNull(page: any, selector: string): Promise<string | null> {
|
||||||
|
const locator = page.locator(selector).first();
|
||||||
|
try {
|
||||||
|
if ((await locator.count()) === 0) return null;
|
||||||
|
const value = await locator.textContent();
|
||||||
|
return value ? value.trim().replace(/\s+/g, ' ') : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginIfConfigured(page: any, baseUrl: string, lines: string[]) {
|
||||||
|
const loginPath = env('SCAN_LOGIN_PATH');
|
||||||
|
const username = env('SCAN_USERNAME') ?? env('CLOAKBROWSER_USERNAME');
|
||||||
|
const password = env('SCAN_PASSWORD') ?? env('CLOAKBROWSER_PASSWORD');
|
||||||
|
const usernameSelector = env('SCAN_USERNAME_SELECTOR') ?? 'input[type="email"], input[name="email"]';
|
||||||
|
const passwordSelector = env('SCAN_PASSWORD_SELECTOR') ?? 'input[type="password"], input[name="password"]';
|
||||||
|
const submitSelector = env('SCAN_SUBMIT_SELECTOR') ?? 'button[type="submit"], input[type="submit"]';
|
||||||
|
|
||||||
|
if (!loginPath) {
|
||||||
|
lines.push('## Login');
|
||||||
|
lines.push('- Skipped: set `SCAN_LOGIN_PATH` to enable login smoke checks.');
|
||||||
|
lines.push('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginUrl = new URL(loginPath, baseUrl).toString();
|
||||||
|
lines.push('## Login');
|
||||||
|
lines.push(`- Login URL: ${loginUrl}`);
|
||||||
|
await gotoWithStatus(page, loginUrl);
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
lines.push('- Skipped: set `SCAN_USERNAME`/`SCAN_PASSWORD` or `CLOAKBROWSER_USERNAME`/`CLOAKBROWSER_PASSWORD`.');
|
||||||
|
lines.push('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.locator(usernameSelector).first().fill(username);
|
||||||
|
await page.locator(passwordSelector).first().fill(password);
|
||||||
|
await page.locator(submitSelector).first().click();
|
||||||
|
await page.waitForTimeout(2500);
|
||||||
|
|
||||||
|
lines.push(`- After submit URL: ${page.url()}`);
|
||||||
|
lines.push(`- Cookie count: ${(await page.context().cookies()).length}`);
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkRoutes(page: any, baseUrl: string, lines: string[]) {
|
||||||
|
const routes = getRoutes(baseUrl);
|
||||||
|
const routeChecks: RouteCheck[] = [];
|
||||||
|
|
||||||
|
for (const url of routes) {
|
||||||
|
const result = await gotoWithStatus(page, url);
|
||||||
|
const heading = await textOrNull(page, 'h1');
|
||||||
|
routeChecks.push({
|
||||||
|
route: url,
|
||||||
|
result,
|
||||||
|
heading,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('## Route Checks');
|
||||||
|
for (const check of routeChecks) {
|
||||||
|
const relativeUrl = check.route.startsWith(baseUrl) ? check.route.slice(baseUrl.length) || '/' : check.route;
|
||||||
|
const finalPath = check.result.url.startsWith(baseUrl)
|
||||||
|
? check.result.url.slice(baseUrl.length) || '/'
|
||||||
|
: check.result.url;
|
||||||
|
const suffix = check.heading ? `, h1="${check.heading}"` : '';
|
||||||
|
const errorSuffix = check.result.error ? `, error="${check.result.error}"` : '';
|
||||||
|
lines.push(
|
||||||
|
`- ${relativeUrl} → status ${check.result.status ?? 'ERR'} (final ${finalPath})${suffix}${errorSuffix}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const baseUrl = env('SCAN_BASE_URL') ?? DEFAULT_BASE_URL;
|
||||||
|
const reportPath = resolve(env('SCAN_REPORT_PATH') ?? DEFAULT_REPORT_PATH);
|
||||||
|
const headless = (env('SCAN_HEADLESS') ?? env('CLOAKBROWSER_HEADLESS') ?? 'true') === 'true';
|
||||||
|
const { page, browser } = await getPage({ headless });
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
lines.push('# Web Automation Scan (local)');
|
||||||
|
lines.push('');
|
||||||
|
lines.push(`- Base URL: ${baseUrl}`);
|
||||||
|
lines.push(`- Timestamp: ${new Date().toISOString()}`);
|
||||||
|
lines.push(`- Headless: ${headless}`);
|
||||||
|
lines.push(`- Report Path: ${reportPath}`);
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loginIfConfigured(page, baseUrl, lines);
|
||||||
|
await checkRoutes(page, baseUrl, lines);
|
||||||
|
lines.push('## Notes');
|
||||||
|
lines.push('- This generic smoke helper records route availability and top-level headings for a local app.');
|
||||||
|
lines.push('- Configure login and route coverage with `SCAN_*` environment variables.');
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdirSync(dirname(reportPath), { recursive: true });
|
||||||
|
writeFileSync(reportPath, `${lines.join('\n')}\n`, 'utf-8');
|
||||||
|
console.log(`Report written to ${reportPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
#!/usr/bin/env npx tsx
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Web scraper that extracts content to markdown
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* npx tsx scrape.ts --url "https://example.com" --mode main
|
||||||
|
* npx tsx scrape.ts --url "https://example.com" --mode full --output page.md
|
||||||
|
* npx tsx scrape.ts --url "https://example.com" --mode selector --selector ".content"
|
||||||
|
*/
|
||||||
|
|
||||||
|
import TurndownService from 'turndown';
|
||||||
|
import * as turndownPluginGfm from 'turndown-plugin-gfm';
|
||||||
|
import { Readability } from '@mozilla/readability';
|
||||||
|
import { JSDOM } from 'jsdom';
|
||||||
|
import { writeFileSync } from 'fs';
|
||||||
|
import parseArgs from 'minimist';
|
||||||
|
import { getPage } from './browse.js';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
type ScrapeMode = 'main' | 'full' | 'selector';
|
||||||
|
|
||||||
|
interface ScrapeOptions {
|
||||||
|
url: string;
|
||||||
|
mode: ScrapeMode;
|
||||||
|
selector?: string;
|
||||||
|
output?: string;
|
||||||
|
includeLinks?: boolean;
|
||||||
|
includeTables?: boolean;
|
||||||
|
includeImages?: boolean;
|
||||||
|
headless?: boolean;
|
||||||
|
wait?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScrapeResult {
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
markdown: string;
|
||||||
|
byline?: string;
|
||||||
|
excerpt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure Turndown for markdown conversion
|
||||||
|
function createTurndownService(options: {
|
||||||
|
includeLinks?: boolean;
|
||||||
|
includeTables?: boolean;
|
||||||
|
includeImages?: boolean;
|
||||||
|
}): TurndownService {
|
||||||
|
const turndown = new TurndownService({
|
||||||
|
headingStyle: 'atx',
|
||||||
|
hr: '---',
|
||||||
|
bulletListMarker: '-',
|
||||||
|
codeBlockStyle: 'fenced',
|
||||||
|
fence: '```',
|
||||||
|
emDelimiter: '*',
|
||||||
|
strongDelimiter: '**',
|
||||||
|
linkStyle: 'inlined',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add GFM support (tables, strikethrough, task lists)
|
||||||
|
turndown.use(turndownPluginGfm.gfm);
|
||||||
|
|
||||||
|
// Custom rule for code blocks with language detection
|
||||||
|
turndown.addRule('codeBlockWithLanguage', {
|
||||||
|
filter: (node) => {
|
||||||
|
return (
|
||||||
|
node.nodeName === 'PRE' &&
|
||||||
|
node.firstChild?.nodeName === 'CODE'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
replacement: (_content, node) => {
|
||||||
|
const codeNode = node.firstChild as HTMLElement;
|
||||||
|
const className = codeNode.getAttribute('class') || '';
|
||||||
|
const langMatch = className.match(/language-(\w+)/);
|
||||||
|
const lang = langMatch ? langMatch[1] : '';
|
||||||
|
const code = codeNode.textContent || '';
|
||||||
|
return `\n\n\`\`\`${lang}\n${code}\n\`\`\`\n\n`;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove images if not included
|
||||||
|
if (!options.includeImages) {
|
||||||
|
turndown.addRule('removeImages', {
|
||||||
|
filter: 'img',
|
||||||
|
replacement: () => '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove links but keep text if not included
|
||||||
|
if (!options.includeLinks) {
|
||||||
|
turndown.addRule('removeLinks', {
|
||||||
|
filter: 'a',
|
||||||
|
replacement: (content) => content,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove script, style, nav, footer, aside elements
|
||||||
|
turndown.remove(['script', 'style', 'nav', 'footer', 'aside', 'noscript']);
|
||||||
|
|
||||||
|
return turndown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract main content using Readability
|
||||||
|
function extractMainContent(html: string, url: string): {
|
||||||
|
content: string;
|
||||||
|
title: string;
|
||||||
|
byline?: string;
|
||||||
|
excerpt?: string;
|
||||||
|
} {
|
||||||
|
const dom = new JSDOM(html, { url });
|
||||||
|
const reader = new Readability(dom.window.document);
|
||||||
|
const article = reader.parse();
|
||||||
|
|
||||||
|
if (!article) {
|
||||||
|
throw new Error('Could not extract main content from page');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: article.content,
|
||||||
|
title: article.title,
|
||||||
|
byline: article.byline || undefined,
|
||||||
|
excerpt: article.excerpt || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scrape a URL and return markdown
|
||||||
|
export async function scrape(options: ScrapeOptions): Promise<ScrapeResult> {
|
||||||
|
const { page, browser } = await getPage({ headless: options.headless ?? true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Navigate to URL
|
||||||
|
console.log(`Navigating to: ${options.url}`);
|
||||||
|
await page.goto(options.url, {
|
||||||
|
timeout: 60000,
|
||||||
|
waitUntil: 'domcontentloaded',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait if specified
|
||||||
|
if (options.wait) {
|
||||||
|
console.log(`Waiting ${options.wait}ms for dynamic content...`);
|
||||||
|
await page.waitForTimeout(options.wait);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageTitle = await page.title();
|
||||||
|
const pageUrl = page.url();
|
||||||
|
|
||||||
|
let html: string;
|
||||||
|
let title = pageTitle;
|
||||||
|
let byline: string | undefined;
|
||||||
|
let excerpt: string | undefined;
|
||||||
|
|
||||||
|
// Get HTML based on mode
|
||||||
|
switch (options.mode) {
|
||||||
|
case 'main': {
|
||||||
|
// Get full page HTML and extract with Readability
|
||||||
|
const fullHtml = await page.content();
|
||||||
|
const extracted = extractMainContent(fullHtml, pageUrl);
|
||||||
|
html = extracted.content;
|
||||||
|
title = extracted.title || pageTitle;
|
||||||
|
byline = extracted.byline;
|
||||||
|
excerpt = extracted.excerpt;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'selector': {
|
||||||
|
if (!options.selector) {
|
||||||
|
throw new Error('Selector mode requires --selector option');
|
||||||
|
}
|
||||||
|
const element = await page.$(options.selector);
|
||||||
|
if (!element) {
|
||||||
|
throw new Error(`Selector not found: ${options.selector}`);
|
||||||
|
}
|
||||||
|
html = await element.innerHTML();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'full':
|
||||||
|
default: {
|
||||||
|
// Get body content, excluding common non-content elements
|
||||||
|
html = await page.evaluate(() => {
|
||||||
|
// Remove common non-content elements
|
||||||
|
const selectorsToRemove = [
|
||||||
|
'script', 'style', 'noscript', 'iframe',
|
||||||
|
'nav', 'header', 'footer', '.cookie-banner',
|
||||||
|
'.advertisement', '.ads', '#ads', '.social-share',
|
||||||
|
'.comments', '#comments', '.sidebar'
|
||||||
|
];
|
||||||
|
|
||||||
|
selectorsToRemove.forEach(selector => {
|
||||||
|
document.querySelectorAll(selector).forEach(el => el.remove());
|
||||||
|
});
|
||||||
|
|
||||||
|
return document.body.innerHTML;
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to markdown
|
||||||
|
const turndown = createTurndownService({
|
||||||
|
includeLinks: options.includeLinks ?? true,
|
||||||
|
includeTables: options.includeTables ?? true,
|
||||||
|
includeImages: options.includeImages ?? false,
|
||||||
|
});
|
||||||
|
|
||||||
|
let markdown = turndown.turndown(html);
|
||||||
|
|
||||||
|
// Add title as H1 if not already present
|
||||||
|
if (!markdown.startsWith('# ')) {
|
||||||
|
markdown = `# ${title}\n\n${markdown}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add metadata header
|
||||||
|
const metadataLines = [
|
||||||
|
`<!-- Scraped from: ${pageUrl} -->`,
|
||||||
|
byline ? `<!-- Author: ${byline} -->` : null,
|
||||||
|
excerpt ? `<!-- Excerpt: ${excerpt} -->` : null,
|
||||||
|
`<!-- Scraped at: ${new Date().toISOString()} -->`,
|
||||||
|
'',
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
markdown = metadataLines.join('\n') + '\n' + markdown;
|
||||||
|
|
||||||
|
// Clean up excessive whitespace
|
||||||
|
markdown = markdown
|
||||||
|
.replace(/\n{4,}/g, '\n\n\n')
|
||||||
|
.replace(/[ \t]+$/gm, '')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
const result: ScrapeResult = {
|
||||||
|
title,
|
||||||
|
url: pageUrl,
|
||||||
|
markdown,
|
||||||
|
byline,
|
||||||
|
excerpt,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save to file if output specified
|
||||||
|
if (options.output) {
|
||||||
|
writeFileSync(options.output, markdown, 'utf-8');
|
||||||
|
console.log(`Markdown saved to: ${options.output}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLI entry point
|
||||||
|
async function main() {
|
||||||
|
const args = parseArgs(process.argv.slice(2), {
|
||||||
|
string: ['url', 'mode', 'selector', 'output'],
|
||||||
|
boolean: ['headless', 'links', 'tables', 'images', 'help'],
|
||||||
|
default: {
|
||||||
|
mode: 'main',
|
||||||
|
headless: true,
|
||||||
|
links: true,
|
||||||
|
tables: true,
|
||||||
|
images: false,
|
||||||
|
},
|
||||||
|
alias: {
|
||||||
|
u: 'url',
|
||||||
|
m: 'mode',
|
||||||
|
s: 'selector',
|
||||||
|
o: 'output',
|
||||||
|
h: 'help',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (args.help || !args.url) {
|
||||||
|
console.log(`
|
||||||
|
Web Scraper - Extract content to Markdown
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
npx tsx scrape.ts --url <url> [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-u, --url <url> URL to scrape (required)
|
||||||
|
-m, --mode <mode> Scrape mode: main, full, or selector (default: main)
|
||||||
|
-s, --selector <sel> CSS selector for selector mode
|
||||||
|
-o, --output <path> Output file path for markdown
|
||||||
|
--headless <bool> Run in headless mode (default: true)
|
||||||
|
--wait <ms> Wait time for dynamic content
|
||||||
|
--links Include links in output (default: true)
|
||||||
|
--tables Include tables in output (default: true)
|
||||||
|
--images Include images in output (default: false)
|
||||||
|
-h, --help Show this help message
|
||||||
|
|
||||||
|
Scrape Modes:
|
||||||
|
main Extract main article content using Readability (best for articles)
|
||||||
|
full Full page content with common elements removed
|
||||||
|
selector Extract specific element by CSS selector
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
npx tsx scrape.ts --url "https://docs.example.com/guide" --mode main
|
||||||
|
npx tsx scrape.ts --url "https://example.com" --mode full --output page.md
|
||||||
|
npx tsx scrape.ts --url "https://example.com" --mode selector --selector ".api-docs"
|
||||||
|
npx tsx scrape.ts --url "https://example.com" --mode main --no-links --output clean.md
|
||||||
|
|
||||||
|
Output Format:
|
||||||
|
- GitHub Flavored Markdown (tables, strikethrough, task lists)
|
||||||
|
- Proper heading hierarchy
|
||||||
|
- Code blocks with language detection
|
||||||
|
- Metadata comments at top (source URL, date)
|
||||||
|
`);
|
||||||
|
process.exit(args.help ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mode = args.mode as ScrapeMode;
|
||||||
|
if (!['main', 'full', 'selector'].includes(mode)) {
|
||||||
|
console.error(`Invalid mode: ${mode}. Must be main, full, or selector.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await scrape({
|
||||||
|
url: args.url,
|
||||||
|
mode,
|
||||||
|
selector: args.selector,
|
||||||
|
output: args.output,
|
||||||
|
includeLinks: args.links,
|
||||||
|
includeTables: args.tables,
|
||||||
|
includeImages: args.images,
|
||||||
|
headless: args.headless,
|
||||||
|
wait: args.wait ? parseInt(args.wait, 10) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Print result summary
|
||||||
|
console.log(`\nScrape complete:`);
|
||||||
|
console.log(` Title: ${result.title}`);
|
||||||
|
console.log(` URL: ${result.url}`);
|
||||||
|
if (result.byline) console.log(` Author: ${result.byline}`);
|
||||||
|
console.log(` Markdown length: ${result.markdown.length} chars`);
|
||||||
|
|
||||||
|
// Print markdown if not saved to file
|
||||||
|
if (!args.output) {
|
||||||
|
console.log('\n--- Markdown Output ---\n');
|
||||||
|
console.log(result.markdown);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error instanceof Error ? error.message : error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run if executed directly
|
||||||
|
const isMainModule = process.argv[1]?.includes('scrape.ts');
|
||||||
|
if (isMainModule) {
|
||||||
|
main();
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { launchPersistentContext } from 'cloakbrowser';
|
||||||
|
import { homedir } from 'os';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { mkdirSync, existsSync } from 'fs';
|
||||||
|
|
||||||
|
async function test() {
|
||||||
|
const profilePath = join(homedir(), '.cloakbrowser-profile');
|
||||||
|
if (!existsSync(profilePath)) {
|
||||||
|
mkdirSync(profilePath, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Profile path:', profilePath);
|
||||||
|
console.log('Launching CloakBrowser with full options...');
|
||||||
|
|
||||||
|
const browser = await launchPersistentContext({
|
||||||
|
headless: true,
|
||||||
|
userDataDir: profilePath,
|
||||||
|
humanize: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Browser launched');
|
||||||
|
const page = browser.pages()[0] || await browser.newPage();
|
||||||
|
console.log('Page created');
|
||||||
|
|
||||||
|
await page.goto('https://github.com', { timeout: 30000 });
|
||||||
|
console.log('Navigated to:', page.url());
|
||||||
|
console.log('Title:', await page.title());
|
||||||
|
|
||||||
|
await page.screenshot({ path: '/tmp/github-test.png' });
|
||||||
|
console.log('Screenshot saved');
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
console.log('Done');
|
||||||
|
}
|
||||||
|
|
||||||
|
test().catch(console.error);
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { launch } from 'cloakbrowser';
|
||||||
|
|
||||||
|
async function test() {
|
||||||
|
console.log('Launching CloakBrowser with minimal config...');
|
||||||
|
|
||||||
|
const browser = await launch({
|
||||||
|
headless: true,
|
||||||
|
humanize: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Browser launched');
|
||||||
|
const page = await browser.newPage();
|
||||||
|
console.log('Page created');
|
||||||
|
|
||||||
|
await page.goto('https://example.com', { timeout: 30000 });
|
||||||
|
console.log('Navigated to:', page.url());
|
||||||
|
console.log('Title:', await page.title());
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
console.log('Done');
|
||||||
|
}
|
||||||
|
|
||||||
|
test().catch(console.error);
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { launchPersistentContext } from 'cloakbrowser';
|
||||||
|
import { homedir } from 'os';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { mkdirSync, existsSync } from 'fs';
|
||||||
|
|
||||||
|
async function test() {
|
||||||
|
const profilePath = join(homedir(), '.cloakbrowser-profile');
|
||||||
|
if (!existsSync(profilePath)) {
|
||||||
|
mkdirSync(profilePath, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Profile path:', profilePath);
|
||||||
|
console.log('Launching with persistent userDataDir...');
|
||||||
|
|
||||||
|
const browser = await launchPersistentContext({
|
||||||
|
headless: true,
|
||||||
|
userDataDir: profilePath,
|
||||||
|
humanize: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Browser launched');
|
||||||
|
const page = browser.pages()[0] || await browser.newPage();
|
||||||
|
console.log('Page created');
|
||||||
|
|
||||||
|
await page.goto('https://example.com', { timeout: 30000 });
|
||||||
|
console.log('Navigated to:', page.url());
|
||||||
|
console.log('Title:', await page.title());
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
console.log('Done');
|
||||||
|
}
|
||||||
|
|
||||||
|
test().catch(console.error);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "."
|
||||||
|
},
|
||||||
|
"include": ["*.ts"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
declare module 'turndown-plugin-gfm' {
|
||||||
|
import TurndownService from 'turndown';
|
||||||
|
|
||||||
|
export function gfm(turndownService: TurndownService): void;
|
||||||
|
export function strikethrough(turndownService: TurndownService): void;
|
||||||
|
export function tables(turndownService: TurndownService): void;
|
||||||
|
export function taskListItems(turndownService: TurndownService): void;
|
||||||
|
}
|
||||||
Executable
+105
@@ -0,0 +1,105 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage:
|
||||||
|
./scripts/install-pi-package.sh --global
|
||||||
|
./scripts/install-pi-package.sh --local
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--global Install the repo path into ~/.pi/agent/settings.json
|
||||||
|
--local Install the repo path into .pi/settings.json for the current project
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
require_command() {
|
||||||
|
local command_name=$1
|
||||||
|
if ! command -v "$command_name" >/dev/null 2>&1; then
|
||||||
|
echo "Missing required command: $command_name" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
pnpm_cmd=()
|
||||||
|
|
||||||
|
resolve_pnpm() {
|
||||||
|
if command -v pnpm >/dev/null 2>&1; then
|
||||||
|
pnpm_cmd=(pnpm)
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v corepack >/dev/null 2>&1; then
|
||||||
|
pnpm_cmd=(corepack pnpm)
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Missing required command: pnpm (or corepack)" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
run_pnpm() {
|
||||||
|
"${pnpm_cmd[@]}" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
require_node_20() {
|
||||||
|
local node_major
|
||||||
|
node_major=$(node -p "process.versions.node.split('.')[0]")
|
||||||
|
if (( node_major < 20 )); then
|
||||||
|
echo "Node.js 20+ is required. Found Node.js $(node -v)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
install_scope=
|
||||||
|
if [[ $# -ne 1 ]]; then
|
||||||
|
usage >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
--global)
|
||||||
|
install_scope="global"
|
||||||
|
;;
|
||||||
|
--local)
|
||||||
|
install_scope="local"
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
usage >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
require_command pi
|
||||||
|
require_command node
|
||||||
|
require_node_20
|
||||||
|
resolve_pnpm
|
||||||
|
|
||||||
|
case "$install_scope" in
|
||||||
|
global)
|
||||||
|
pi install "$ROOT_DIR"
|
||||||
|
;;
|
||||||
|
local)
|
||||||
|
pi install -l "$ROOT_DIR"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "Bootstrapping Atlassian runtime dependencies..."
|
||||||
|
run_pnpm install --frozen-lockfile --dir "${ROOT_DIR}/pi-package/skills/atlassian/scripts"
|
||||||
|
|
||||||
|
echo "Bootstrapping web-automation runtime dependencies..."
|
||||||
|
run_pnpm install --frozen-lockfile --dir "${ROOT_DIR}/pi-package/skills/web-automation/scripts"
|
||||||
|
run_pnpm --dir "${ROOT_DIR}/pi-package/skills/web-automation/scripts" exec cloakbrowser install
|
||||||
|
echo "Rebuilding native web-automation dependencies..."
|
||||||
|
run_pnpm rebuild --dir "${ROOT_DIR}/pi-package/skills/web-automation/scripts" better-sqlite3 esbuild
|
||||||
|
|
||||||
|
echo "Installed Pi packages now visible to this scope:"
|
||||||
|
pi list
|
||||||
|
|
||||||
|
echo "Pi package installed (${install_scope}) and runtime dependencies bootstrapped."
|
||||||
Executable
+62
@@ -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}."
|
||||||
Executable
+149
@@ -0,0 +1,149 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REQUIRED_FILES=(
|
||||||
|
"docs/PI-RESEARCH.md"
|
||||||
|
"docs/PI.md"
|
||||||
|
"docs/PI-SUPERPOWERS.md"
|
||||||
|
"docs/PI-COMMON-REVIEWER.md"
|
||||||
|
"skills/atlassian/pi/SKILL.md"
|
||||||
|
"skills/create-plan/pi/SKILL.md"
|
||||||
|
"skills/create-plan/pi/templates/continuation-runbook.md"
|
||||||
|
"skills/create-plan/pi/templates/milestone-plan.md"
|
||||||
|
"skills/create-plan/pi/templates/story-tracker.md"
|
||||||
|
"skills/do-task/pi/SKILL.md"
|
||||||
|
"skills/do-task/pi/templates/task-plan.md"
|
||||||
|
"skills/implement-plan/pi/SKILL.md"
|
||||||
|
"skills/web-automation/pi/SKILL.md"
|
||||||
|
"skills/reviewer-runtime/pi/run-review.sh"
|
||||||
|
"skills/reviewer-runtime/pi/notify-telegram.sh"
|
||||||
|
"scripts/install-pi-package.sh"
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
# These required-file checks are intentionally hard failures: removing any
|
||||||
|
# required artifact should make this script exit non-zero immediately.
|
||||||
|
for file in "${REQUIRED_FILES[@]}"; do
|
||||||
|
test -f "$file"
|
||||||
|
done
|
||||||
|
|
||||||
|
test -x skills/reviewer-runtime/pi/run-review.sh
|
||||||
|
test -x skills/reviewer-runtime/pi/notify-telegram.sh
|
||||||
|
test -x scripts/install-pi-package.sh
|
||||||
|
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
|
||||||
|
|
||||||
|
grep -q 'pi-package/skills/atlassian/scripts' skills/atlassian/pi/SKILL.md
|
||||||
|
grep -q 'pi-package/skills/web-automation/scripts' skills/web-automation/pi/SKILL.md
|
||||||
|
grep -q 'local checkout package install keeps the runtime in `pi-package/skills/<skill>/scripts`' docs/PI.md
|
||||||
|
|
||||||
|
grep -q 'install-pi-package.sh --global' docs/PI.md
|
||||||
|
grep -q 'install-pi-package.sh --local' docs/PI.md
|
||||||
|
grep -q 'install-pi-package.sh --global' README.md
|
||||||
|
grep -q 'install-pi-package.sh --local' README.md
|
||||||
|
|
||||||
|
for family in atlassian create-plan do-task implement-plan web-automation; do
|
||||||
|
source_dir="skills/${family}/pi"
|
||||||
|
source_skill_md="${source_dir}/SKILL.md"
|
||||||
|
skill_name=$(awk '/^name:/ { print $2; exit }' "$source_skill_md")
|
||||||
|
mirror_dir="pi-package/skills/${skill_name}"
|
||||||
|
mirror_skill_md="${mirror_dir}/SKILL.md"
|
||||||
|
|
||||||
|
test -d "$mirror_dir"
|
||||||
|
test -f "$mirror_skill_md"
|
||||||
|
test "$skill_name" = "$(basename "$mirror_dir")"
|
||||||
|
test "$skill_name" = "$(awk '/^name:/ { print $2; exit }' "$mirror_skill_md")"
|
||||||
|
|
||||||
|
diff -qr \
|
||||||
|
--exclude '.DS_Store' \
|
||||||
|
--exclude 'node_modules' \
|
||||||
|
"$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" \
|
||||||
|
\( -name '.DS_Store' -o -name 'node_modules' \) -prune -o \
|
||||||
|
-mindepth 1 -print0)
|
||||||
|
done
|
||||||
|
|
||||||
|
! grep -nE 'update_plan|plan mode|sub-agent|subagents' \
|
||||||
|
skills/create-plan/pi/SKILL.md \
|
||||||
|
skills/do-task/pi/SKILL.md \
|
||||||
|
skills/implement-plan/pi/SKILL.md
|
||||||
|
|
||||||
|
node <<'EOF'
|
||||||
|
const fs = require("fs");
|
||||||
|
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"));
|
||||||
|
|
||||||
|
if (!pkg.pi || !Array.isArray(pkg.pi.skills) || pkg.pi.skills.length !== 5) {
|
||||||
|
console.error("package.json must define pi.skills with exactly 5 entries");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedSkills = [
|
||||||
|
"./pi-package/skills/atlassian",
|
||||||
|
"./pi-package/skills/create-plan",
|
||||||
|
"./pi-package/skills/do-task",
|
||||||
|
"./pi-package/skills/implement-plan",
|
||||||
|
"./pi-package/skills/web-automation",
|
||||||
|
];
|
||||||
|
|
||||||
|
if (JSON.stringify(pkg.pi.skills) !== JSON.stringify(expectedSkills)) {
|
||||||
|
console.error("package.json must point pi.skills at the pi-package mirror");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(pkg.files) || pkg.files.length === 0) {
|
||||||
|
console.error("package.json must define a non-empty files allowlist");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const requiredFile of [
|
||||||
|
"pi-package/skills",
|
||||||
|
"docs/PI-COMMON-REVIEWER.md",
|
||||||
|
"scripts/install-pi-package.sh",
|
||||||
|
"scripts/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);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("package metadata ok");
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "pi resources verified"
|
||||||
Executable
+27
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
WORKFLOW_FILES=(
|
||||||
|
"skills/create-plan/pi/SKILL.md"
|
||||||
|
"skills/do-task/pi/SKILL.md"
|
||||||
|
"skills/implement-plan/pi/SKILL.md"
|
||||||
|
)
|
||||||
|
|
||||||
|
# These terms cover the Codex-only constructs explicitly called out by the plan.
|
||||||
|
# Expand this deny-list if later pi workflow ports introduce new harness-specific tokens.
|
||||||
|
|
||||||
|
for file in "${WORKFLOW_FILES[@]}"; do
|
||||||
|
test -f "$file"
|
||||||
|
grep -q 'docs/PI-SUPERPOWERS.md' "$file"
|
||||||
|
done
|
||||||
|
|
||||||
|
test -f skills/create-plan/pi/templates/continuation-runbook.md
|
||||||
|
test -f skills/create-plan/pi/templates/milestone-plan.md
|
||||||
|
test -f skills/create-plan/pi/templates/story-tracker.md
|
||||||
|
test -f skills/do-task/pi/templates/task-plan.md
|
||||||
|
test -x skills/reviewer-runtime/pi/run-review.sh
|
||||||
|
test -x skills/reviewer-runtime/pi/notify-telegram.sh
|
||||||
|
|
||||||
|
! rg -n 'update_plan|plan mode|sub-agent|subagents' "${WORKFLOW_FILES[@]}"
|
||||||
|
|
||||||
|
echo "pi workflow skill docs verified"
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
---
|
||||||
|
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).
|
||||||
|
|
||||||
|
If you installed this repo from a local checkout with `./scripts/install-pi-package.sh`, the runtime stays in the checkout mirror at `pi-package/skills/atlassian/scripts`.
|
||||||
|
|
||||||
|
## Prerequisite Check (MANDATORY)
|
||||||
|
|
||||||
|
Run inside the skill runtime directory that matches your install style:
|
||||||
|
|
||||||
|
- local checkout package install: `pi-package/skills/atlassian/scripts`
|
||||||
|
- project-local copied install: `.pi/skills/atlassian/scripts`
|
||||||
|
- global copied install: `~/.pi/agent/skills/atlassian/scripts`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd pi-package/skills/atlassian/scripts
|
||||||
|
node -e "require.resolve('commander');require.resolve('dotenv');console.log('OK: runtime dependencies installed')"
|
||||||
|
node -e 'require("dotenv").config({ path: ".env" }); const required = ["ATLASSIAN_BASE_URL", "ATLASSIAN_EMAIL", "ATLASSIAN_API_TOKEN"]; const missing = required.filter((key) => !(process.env[key] || "").trim()); if (missing.length) { console.error("Missing required Atlassian config: " + missing.join(", ")); process.exit(1); } console.log("OK: Atlassian config present")'
|
||||||
|
pnpm atlassian health
|
||||||
|
```
|
||||||
|
|
||||||
|
If any check fails, stop and return:
|
||||||
|
|
||||||
|
`Missing dependency/config: atlassian requires installed CLI dependencies and valid Atlassian Cloud credentials. Configure ATLASSIAN_* in the shell environment or scripts/.env, then retry.`
|
||||||
|
|
||||||
|
## Supported Commands
|
||||||
|
|
||||||
|
- `pnpm atlassian health`
|
||||||
|
- `pnpm atlassian jira-search --jql "..."`
|
||||||
|
- `pnpm atlassian jira-get --issue ABC-123`
|
||||||
|
- `pnpm atlassian jira-create ... [--dry-run]`
|
||||||
|
- `pnpm atlassian jira-update ... [--dry-run]`
|
||||||
|
- `pnpm atlassian jira-comment ... [--dry-run]`
|
||||||
|
- `pnpm atlassian jira-transitions --issue ABC-123`
|
||||||
|
- `pnpm atlassian jira-transition ... [--dry-run]`
|
||||||
|
- `pnpm atlassian conf-search --query "..."`
|
||||||
|
- `pnpm atlassian conf-get --page 12345`
|
||||||
|
- `pnpm atlassian conf-create ... [--dry-run]`
|
||||||
|
- `pnpm atlassian conf-update ... [--dry-run]`
|
||||||
|
- `pnpm atlassian conf-comment ... [--dry-run]`
|
||||||
|
- `pnpm atlassian conf-children --page 12345`
|
||||||
|
- `pnpm atlassian raw --product jira|confluence --method GET|POST|PUT --path ...`
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
- `pnpm atlassian jira-search --jql "project = ENG ORDER BY updated DESC" --max-results 10`
|
||||||
|
- `pnpm atlassian conf-comment --page 12345 --body-file comment.storage.html --dry-run`
|
||||||
|
- `pnpm atlassian raw --product jira --method GET --path "/rest/api/3/issue/ENG-123"`
|
||||||
|
|
||||||
|
## Safety Rules
|
||||||
|
|
||||||
|
- Default output is JSON; prefer that for agent workflows.
|
||||||
|
- Use `--dry-run` before any mutating command unless the user clearly wants the write to happen immediately.
|
||||||
|
- `raw` is for explicit edge cases only and does not allow `DELETE`.
|
||||||
|
- `--body-file` must stay inside the current workspace.
|
||||||
|
- Confluence write bodies should be storage-format inputs in v1.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Atlassian Cloud is the primary supported platform in v1.
|
||||||
|
- Package installs use the repo's `pi-package/skills/atlassian/` mirror so the installed skill directory name matches `atlassian`.
|
||||||
@@ -0,0 +1,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
@@ -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"]
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ const __dirname = path.dirname(__filename);
|
|||||||
|
|
||||||
const SHARED_SCRIPTS_DIR = path.resolve(__dirname, "..");
|
const SHARED_SCRIPTS_DIR = path.resolve(__dirname, "..");
|
||||||
const ATLASSIAN_SKILL_DIR = path.resolve(SHARED_SCRIPTS_DIR, "..", "..");
|
const ATLASSIAN_SKILL_DIR = path.resolve(SHARED_SCRIPTS_DIR, "..", "..");
|
||||||
const AGENTS = ["codex", "claude-code", "cursor", "opencode"] as const;
|
const AGENTS = ["codex", "claude-code", "cursor", "opencode", "pi"] as const;
|
||||||
const ENTRIES_TO_COPY = ["pnpm-lock.yaml", "tsconfig.json", "src"] as const;
|
const ENTRIES_TO_COPY = ["pnpm-lock.yaml", "tsconfig.json", "src"] as const;
|
||||||
|
|
||||||
async function replaceEntry(source: string, target: string) {
|
async function replaceEntry(source: string, target: string) {
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Keep this file in sync with skills/reviewer-runtime/notify-telegram.sh.
|
||||||
|
|
||||||
|
DEFAULT_API_BASE_URL="https://api.telegram.org"
|
||||||
|
DEFAULT_PARSE_MODE="HTML"
|
||||||
|
MAX_MESSAGE_LENGTH=4096
|
||||||
|
|
||||||
|
BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-}
|
||||||
|
CHAT_ID=${TELEGRAM_CHAT_ID:-}
|
||||||
|
API_BASE_URL=${TELEGRAM_API_BASE_URL:-$DEFAULT_API_BASE_URL}
|
||||||
|
PARSE_MODE=${TELEGRAM_PARSE_MODE:-$DEFAULT_PARSE_MODE}
|
||||||
|
MESSAGE=""
|
||||||
|
MESSAGE_FILE=""
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage:
|
||||||
|
notify-telegram.sh --message <text> [--bot-token <token>] [--chat-id <id>] [--api-base-url <url>]
|
||||||
|
notify-telegram.sh --message-file <path> [--bot-token <token>] [--chat-id <id>] [--api-base-url <url>]
|
||||||
|
|
||||||
|
Environment fallbacks:
|
||||||
|
TELEGRAM_BOT_TOKEN
|
||||||
|
TELEGRAM_CHAT_ID
|
||||||
|
TELEGRAM_API_BASE_URL
|
||||||
|
TELEGRAM_PARSE_MODE
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
fail_usage() {
|
||||||
|
echo "Error: $*" >&2
|
||||||
|
usage >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_args() {
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--bot-token)
|
||||||
|
BOT_TOKEN=${2:-}
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--chat-id)
|
||||||
|
CHAT_ID=${2:-}
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--api-base-url)
|
||||||
|
API_BASE_URL=${2:-}
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--message)
|
||||||
|
MESSAGE=${2:-}
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--message-file)
|
||||||
|
MESSAGE_FILE=${2:-}
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--help|-h)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
fail_usage "unknown argument: $1"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -n "$MESSAGE" && -n "$MESSAGE_FILE" ]]; then
|
||||||
|
fail_usage "use either --message or --message-file, not both"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$MESSAGE_FILE" ]]; then
|
||||||
|
[[ -r "$MESSAGE_FILE" ]] || fail_usage "message file is not readable: $MESSAGE_FILE"
|
||||||
|
MESSAGE=$(<"$MESSAGE_FILE")
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ -n "$MESSAGE" ]] || fail_usage "message is required"
|
||||||
|
[[ -n "$BOT_TOKEN" ]] || fail_usage "bot token is required (use --bot-token or TELEGRAM_BOT_TOKEN)"
|
||||||
|
[[ -n "$CHAT_ID" ]] || fail_usage "chat id is required (use --chat-id or TELEGRAM_CHAT_ID)"
|
||||||
|
command -v curl >/dev/null 2>&1 || fail_usage "curl is required"
|
||||||
|
|
||||||
|
if [[ ${#MESSAGE} -gt "$MAX_MESSAGE_LENGTH" ]]; then
|
||||||
|
MESSAGE=${MESSAGE:0:$MAX_MESSAGE_LENGTH}
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
parse_args "$@"
|
||||||
|
|
||||||
|
curl -fsS -X POST \
|
||||||
|
"${API_BASE_URL%/}/bot${BOT_TOKEN}/sendMessage" \
|
||||||
|
--data-urlencode "chat_id=${CHAT_ID}" \
|
||||||
|
--data-urlencode "text=${MESSAGE}" \
|
||||||
|
--data-urlencode "parse_mode=${PARSE_MODE}" \
|
||||||
|
--data-urlencode "disable_web_page_preview=true" \
|
||||||
|
>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
Executable
+360
@@ -0,0 +1,360 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Keep this file in sync with skills/reviewer-runtime/run-review.sh.
|
||||||
|
|
||||||
|
DEFAULT_POLL_SECONDS=10
|
||||||
|
DEFAULT_HEARTBEAT_SECONDS=60
|
||||||
|
DEFAULT_SOFT_TIMEOUT_SECONDS=600
|
||||||
|
DEFAULT_STALL_WARNING_SECONDS=300
|
||||||
|
DEFAULT_HARD_TIMEOUT_SECONDS=1800
|
||||||
|
EXIT_COMPLETED_EMPTY_OUTPUT=80
|
||||||
|
EXIT_NEEDS_OPERATOR_DECISION=81
|
||||||
|
|
||||||
|
COMMAND_FILE=""
|
||||||
|
STDOUT_FILE=""
|
||||||
|
STDERR_FILE=""
|
||||||
|
STATUS_FILE=""
|
||||||
|
SUCCESS_FILES=()
|
||||||
|
POLL_SECONDS=$DEFAULT_POLL_SECONDS
|
||||||
|
HEARTBEAT_SECONDS=$DEFAULT_HEARTBEAT_SECONDS
|
||||||
|
SOFT_TIMEOUT_SECONDS=$DEFAULT_SOFT_TIMEOUT_SECONDS
|
||||||
|
STALL_WARNING_SECONDS=$DEFAULT_STALL_WARNING_SECONDS
|
||||||
|
HARD_TIMEOUT_SECONDS=$DEFAULT_HARD_TIMEOUT_SECONDS
|
||||||
|
|
||||||
|
CHILD_PID=""
|
||||||
|
USE_GROUP_KILL=0
|
||||||
|
INTERRUPTED=0
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage:
|
||||||
|
run-review.sh \
|
||||||
|
--command-file <path> \
|
||||||
|
--stdout-file <path> \
|
||||||
|
--stderr-file <path> \
|
||||||
|
--status-file <path> \
|
||||||
|
[--success-file <path>] \
|
||||||
|
[--poll-seconds <int>] \
|
||||||
|
[--heartbeat-seconds <int>] \
|
||||||
|
[--soft-timeout-seconds <int>] \
|
||||||
|
[--stall-warning-seconds <int>] \
|
||||||
|
[--hard-timeout-seconds <int>]
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
fail_usage() {
|
||||||
|
echo "Error: $*" >&2
|
||||||
|
usage >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
require_integer() {
|
||||||
|
local name=$1
|
||||||
|
local value=$2
|
||||||
|
[[ "$value" =~ ^[0-9]+$ ]] || fail_usage "$name must be an integer"
|
||||||
|
}
|
||||||
|
|
||||||
|
escape_note() {
|
||||||
|
local note=$1
|
||||||
|
note=${note//$'\n'/ }
|
||||||
|
note=${note//\"/\'}
|
||||||
|
printf '%s' "$note"
|
||||||
|
}
|
||||||
|
|
||||||
|
join_success_files() {
|
||||||
|
if [[ ${#SUCCESS_FILES[@]} -eq 0 ]]; then
|
||||||
|
printf ''
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local joined=""
|
||||||
|
local path
|
||||||
|
|
||||||
|
for path in "${SUCCESS_FILES[@]}"; do
|
||||||
|
if [[ -n "$joined" ]]; then
|
||||||
|
joined+=", "
|
||||||
|
fi
|
||||||
|
joined+="$path"
|
||||||
|
done
|
||||||
|
|
||||||
|
printf '%s' "$joined"
|
||||||
|
}
|
||||||
|
|
||||||
|
iso_timestamp() {
|
||||||
|
date -u +"%Y-%m-%dT%H:%M:%SZ"
|
||||||
|
}
|
||||||
|
|
||||||
|
elapsed_seconds() {
|
||||||
|
local now
|
||||||
|
now=$(date +%s)
|
||||||
|
printf '%s' $((now - START_TIME))
|
||||||
|
}
|
||||||
|
|
||||||
|
file_bytes() {
|
||||||
|
local path=$1
|
||||||
|
if [[ -f "$path" ]]; then
|
||||||
|
wc -c <"$path" | tr -d '[:space:]'
|
||||||
|
else
|
||||||
|
printf '0'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
append_status() {
|
||||||
|
local level=$1
|
||||||
|
local state=$2
|
||||||
|
local note=$3
|
||||||
|
local elapsed pid stdout_bytes stderr_bytes line
|
||||||
|
|
||||||
|
elapsed=$(elapsed_seconds)
|
||||||
|
pid=${CHILD_PID:-0}
|
||||||
|
stdout_bytes=$(file_bytes "$STDOUT_FILE")
|
||||||
|
stderr_bytes=$(file_bytes "$STDERR_FILE")
|
||||||
|
line="ts=$(iso_timestamp) level=$level state=$state elapsed_s=$elapsed pid=$pid stdout_bytes=$stdout_bytes stderr_bytes=$stderr_bytes note=\"$(escape_note "$note")\""
|
||||||
|
|
||||||
|
printf '%s\n' "$line" | tee -a "$STATUS_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_parent_dir() {
|
||||||
|
local path=$1
|
||||||
|
mkdir -p "$(dirname "$path")"
|
||||||
|
}
|
||||||
|
|
||||||
|
kill_child_process_group() {
|
||||||
|
if [[ -z "$CHILD_PID" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! kill -0 "$CHILD_PID" 2>/dev/null; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$USE_GROUP_KILL" -eq 1 ]]; then
|
||||||
|
kill -TERM -- "-$CHILD_PID" 2>/dev/null || kill -TERM "$CHILD_PID" 2>/dev/null || true
|
||||||
|
else
|
||||||
|
kill -TERM "$CHILD_PID" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
if kill -0 "$CHILD_PID" 2>/dev/null; then
|
||||||
|
if [[ "$USE_GROUP_KILL" -eq 1 ]]; then
|
||||||
|
kill -KILL -- "-$CHILD_PID" 2>/dev/null || kill -KILL "$CHILD_PID" 2>/dev/null || true
|
||||||
|
else
|
||||||
|
kill -KILL "$CHILD_PID" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
handle_signal() {
|
||||||
|
local signal_name=$1
|
||||||
|
INTERRUPTED=1
|
||||||
|
append_status error failed "received SIG${signal_name}; terminating reviewer child"
|
||||||
|
kill_child_process_group
|
||||||
|
exit 130
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_args() {
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--command-file)
|
||||||
|
COMMAND_FILE=${2:-}
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--stdout-file)
|
||||||
|
STDOUT_FILE=${2:-}
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--stderr-file)
|
||||||
|
STDERR_FILE=${2:-}
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--status-file)
|
||||||
|
STATUS_FILE=${2:-}
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--success-file)
|
||||||
|
SUCCESS_FILES+=("${2:-}")
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--poll-seconds)
|
||||||
|
POLL_SECONDS=${2:-}
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--heartbeat-seconds)
|
||||||
|
HEARTBEAT_SECONDS=${2:-}
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--soft-timeout-seconds)
|
||||||
|
SOFT_TIMEOUT_SECONDS=${2:-}
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--stall-warning-seconds)
|
||||||
|
STALL_WARNING_SECONDS=${2:-}
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--hard-timeout-seconds)
|
||||||
|
HARD_TIMEOUT_SECONDS=${2:-}
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--help|-h)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
fail_usage "unknown argument: $1"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[[ -n "$COMMAND_FILE" ]] || fail_usage "--command-file is required"
|
||||||
|
[[ -n "$STDOUT_FILE" ]] || fail_usage "--stdout-file is required"
|
||||||
|
[[ -n "$STDERR_FILE" ]] || fail_usage "--stderr-file is required"
|
||||||
|
[[ -n "$STATUS_FILE" ]] || fail_usage "--status-file is required"
|
||||||
|
|
||||||
|
require_integer "poll-seconds" "$POLL_SECONDS"
|
||||||
|
require_integer "heartbeat-seconds" "$HEARTBEAT_SECONDS"
|
||||||
|
require_integer "soft-timeout-seconds" "$SOFT_TIMEOUT_SECONDS"
|
||||||
|
require_integer "stall-warning-seconds" "$STALL_WARNING_SECONDS"
|
||||||
|
require_integer "hard-timeout-seconds" "$HARD_TIMEOUT_SECONDS"
|
||||||
|
|
||||||
|
[[ "$POLL_SECONDS" -gt 0 ]] || fail_usage "poll-seconds must be > 0"
|
||||||
|
[[ "$HEARTBEAT_SECONDS" -gt 0 ]] || fail_usage "heartbeat-seconds must be > 0"
|
||||||
|
[[ "$SOFT_TIMEOUT_SECONDS" -gt 0 ]] || fail_usage "soft-timeout-seconds must be > 0"
|
||||||
|
[[ "$STALL_WARNING_SECONDS" -gt 0 ]] || fail_usage "stall-warning-seconds must be > 0"
|
||||||
|
[[ "$HARD_TIMEOUT_SECONDS" -gt 0 ]] || fail_usage "hard-timeout-seconds must be > 0"
|
||||||
|
[[ "$SOFT_TIMEOUT_SECONDS" -le "$HARD_TIMEOUT_SECONDS" ]] || fail_usage "soft-timeout-seconds must be <= hard-timeout-seconds"
|
||||||
|
[[ "$STALL_WARNING_SECONDS" -le "$HARD_TIMEOUT_SECONDS" ]] || fail_usage "stall-warning-seconds must be <= hard-timeout-seconds"
|
||||||
|
|
||||||
|
[[ -r "$COMMAND_FILE" ]] || fail_usage "command file is not readable: $COMMAND_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
launch_child() {
|
||||||
|
if command -v setsid >/dev/null 2>&1; then
|
||||||
|
setsid bash "$COMMAND_FILE" >"$STDOUT_FILE" 2>"$STDERR_FILE" &
|
||||||
|
USE_GROUP_KILL=1
|
||||||
|
else
|
||||||
|
bash "$COMMAND_FILE" >"$STDOUT_FILE" 2>"$STDERR_FILE" &
|
||||||
|
USE_GROUP_KILL=0
|
||||||
|
fi
|
||||||
|
CHILD_PID=$!
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
parse_args "$@"
|
||||||
|
|
||||||
|
ensure_parent_dir "$STDOUT_FILE"
|
||||||
|
ensure_parent_dir "$STDERR_FILE"
|
||||||
|
ensure_parent_dir "$STATUS_FILE"
|
||||||
|
: >"$STDOUT_FILE"
|
||||||
|
: >"$STDERR_FILE"
|
||||||
|
: >"$STATUS_FILE"
|
||||||
|
|
||||||
|
START_TIME=$(date +%s)
|
||||||
|
export START_TIME
|
||||||
|
|
||||||
|
trap 'handle_signal INT' INT
|
||||||
|
trap 'handle_signal TERM' TERM
|
||||||
|
trap 'if [[ "$INTERRUPTED" -eq 0 ]]; then kill_child_process_group; fi' EXIT
|
||||||
|
|
||||||
|
launch_child
|
||||||
|
append_status info running-silent "reviewer child launched"
|
||||||
|
|
||||||
|
local last_stdout_bytes=0
|
||||||
|
local last_stderr_bytes=0
|
||||||
|
local last_output_change_time=$START_TIME
|
||||||
|
local last_heartbeat_time=$START_TIME
|
||||||
|
local soft_timeout_logged=0
|
||||||
|
local stall_warning_logged=0
|
||||||
|
local heartbeat_count=0
|
||||||
|
|
||||||
|
while kill -0 "$CHILD_PID" 2>/dev/null; do
|
||||||
|
sleep "$POLL_SECONDS"
|
||||||
|
|
||||||
|
local now elapsed stdout_bytes stderr_bytes note state level
|
||||||
|
now=$(date +%s)
|
||||||
|
elapsed=$((now - START_TIME))
|
||||||
|
stdout_bytes=$(file_bytes "$STDOUT_FILE")
|
||||||
|
stderr_bytes=$(file_bytes "$STDERR_FILE")
|
||||||
|
|
||||||
|
if [[ $((now - last_heartbeat_time)) -ge "$HEARTBEAT_SECONDS" ]]; then
|
||||||
|
heartbeat_count=$((heartbeat_count + 1))
|
||||||
|
append_status info in-progress "In progress ${heartbeat_count}"
|
||||||
|
last_heartbeat_time=$now
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$stdout_bytes" -ne "$last_stdout_bytes" || "$stderr_bytes" -ne "$last_stderr_bytes" ]]; then
|
||||||
|
last_output_change_time=$now
|
||||||
|
stall_warning_logged=0
|
||||||
|
state=running-active
|
||||||
|
level=info
|
||||||
|
note="reviewer output changed"
|
||||||
|
else
|
||||||
|
local silent_for
|
||||||
|
silent_for=$((now - last_output_change_time))
|
||||||
|
if [[ "$silent_for" -ge "$STALL_WARNING_SECONDS" ]]; then
|
||||||
|
state=stall-warning
|
||||||
|
level=warn
|
||||||
|
note="no output growth for ${silent_for}s; process still alive"
|
||||||
|
stall_warning_logged=1
|
||||||
|
else
|
||||||
|
state=running-silent
|
||||||
|
level=info
|
||||||
|
note="reviewer process alive; waiting for output"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$elapsed" -ge "$SOFT_TIMEOUT_SECONDS" && "$soft_timeout_logged" -eq 0 ]]; then
|
||||||
|
note="$note; soft timeout reached, continuing while reviewer is alive"
|
||||||
|
soft_timeout_logged=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
append_status "$level" "$state" "$note"
|
||||||
|
last_stdout_bytes=$stdout_bytes
|
||||||
|
last_stderr_bytes=$stderr_bytes
|
||||||
|
|
||||||
|
if [[ "$elapsed" -ge "$HARD_TIMEOUT_SECONDS" ]]; then
|
||||||
|
append_status error needs-operator-decision "hard timeout reached; terminating reviewer child for operator intervention"
|
||||||
|
kill_child_process_group
|
||||||
|
trap - EXIT
|
||||||
|
exit "$EXIT_NEEDS_OPERATOR_DECISION"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
local child_exit_code=0
|
||||||
|
set +e
|
||||||
|
wait "$CHILD_PID"
|
||||||
|
child_exit_code=$?
|
||||||
|
set -e
|
||||||
|
trap - EXIT
|
||||||
|
|
||||||
|
local final_stdout_bytes final_stderr_bytes
|
||||||
|
local success_file success_bytes
|
||||||
|
final_stdout_bytes=$(file_bytes "$STDOUT_FILE")
|
||||||
|
final_stderr_bytes=$(file_bytes "$STDERR_FILE")
|
||||||
|
|
||||||
|
if [[ "$child_exit_code" -eq 0 ]]; then
|
||||||
|
if [[ "$final_stdout_bytes" -gt 0 ]]; then
|
||||||
|
append_status info completed "reviewer completed successfully"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ${#SUCCESS_FILES[@]} -gt 0 ]]; then
|
||||||
|
for success_file in "${SUCCESS_FILES[@]}"; do
|
||||||
|
success_bytes=$(file_bytes "$success_file")
|
||||||
|
if [[ "$success_bytes" -gt 0 ]]; then
|
||||||
|
append_status info completed "reviewer completed successfully via success file $(join_success_files)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
append_status error completed-empty-output "reviewer exited successfully with empty stdout"
|
||||||
|
exit "$EXIT_COMPLETED_EMPTY_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
append_status error failed "reviewer exited with code $child_exit_code"
|
||||||
|
exit "$child_exit_code"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
---
|
||||||
|
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).
|
||||||
|
|
||||||
|
If you installed this repo from a local checkout with `./scripts/install-pi-package.sh`, the runtime stays in the checkout mirror at `pi-package/skills/web-automation/scripts`.
|
||||||
|
|
||||||
|
## Updating CloakBrowser
|
||||||
|
|
||||||
|
Run inside the installed `scripts/` directory for the pi skill. The commands below work for both global and project-local installs as long as you run them from the installed `scripts/` directory.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm up cloakbrowser playwright-core
|
||||||
|
npx cloakbrowser install
|
||||||
|
pnpm approve-builds
|
||||||
|
pnpm rebuild better-sqlite3 esbuild
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prerequisite Check (MANDATORY)
|
||||||
|
|
||||||
|
Before running automation, verify the runtime from the location that matches your install style:
|
||||||
|
|
||||||
|
- local checkout package install: `pi-package/skills/web-automation/scripts`
|
||||||
|
- project-local copied install: `.pi/skills/web-automation/scripts`
|
||||||
|
- global copied install: `~/.pi/agent/skills/web-automation/scripts`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd pi-package/skills/web-automation/scripts
|
||||||
|
node check-install.js
|
||||||
|
```
|
||||||
|
|
||||||
|
If the check fails, stop and return:
|
||||||
|
|
||||||
|
`Missing dependency/config: web-automation requires cloakbrowser and playwright-core with CloakBrowser-based scripts. Run setup in this skill, then retry.`
|
||||||
|
|
||||||
|
If runtime fails with missing native bindings for `better-sqlite3` or `esbuild`, run the same commands from your installed `scripts/` directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd pi-package/skills/web-automation/scripts
|
||||||
|
pnpm approve-builds
|
||||||
|
pnpm rebuild better-sqlite3 esbuild
|
||||||
|
```
|
||||||
|
|
||||||
|
## When To Use Which Command
|
||||||
|
|
||||||
|
- Use `node extract.js "<URL>"` for a one-shot rendered fetch with JSON output.
|
||||||
|
- Use `npx tsx scrape.ts ...` when you need markdown extraction, Readability cleanup, or selector-based scraping.
|
||||||
|
- Use `npx tsx browse.ts ...`, `auth.ts`, or `flow.ts` when the task needs login handling, persistent sessions, clicks, typing, screenshots, or multi-step navigation.
|
||||||
|
- Use `npx tsx scan-local-app.ts` when you need a configurable local-app smoke pass driven by `SCAN_*` and `CLOAKBROWSER_*` environment variables.
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
- Install check: `node check-install.js`
|
||||||
|
- One-shot JSON extract: `node extract.js "https://example.com"`
|
||||||
|
- Browse page: `npx tsx browse.ts --url "https://example.com"`
|
||||||
|
- Scrape markdown: `npx tsx scrape.ts --url "https://example.com" --mode main --output page.md`
|
||||||
|
- Authenticate: `npx tsx auth.ts --url "https://example.com/login"`
|
||||||
|
- Natural-language flow: `npx tsx flow.ts --instruction 'go to https://example.com then click on "Login" then type "user@example.com" in #email then press enter'`
|
||||||
|
- Local app smoke scan: `SCAN_BASE_URL=http://localhost:3000 SCAN_ROUTES=/,/dashboard npx tsx scan-local-app.ts`
|
||||||
|
|
||||||
|
## Local App Smoke Scan
|
||||||
|
|
||||||
|
`scan-local-app.ts` is intentionally generic. Configure it with environment variables instead of editing the file:
|
||||||
|
|
||||||
|
- `SCAN_BASE_URL`
|
||||||
|
- `SCAN_LOGIN_PATH`
|
||||||
|
- `SCAN_USERNAME`
|
||||||
|
- `SCAN_PASSWORD`
|
||||||
|
- `SCAN_USERNAME_SELECTOR`
|
||||||
|
- `SCAN_PASSWORD_SELECTOR`
|
||||||
|
- `SCAN_SUBMIT_SELECTOR`
|
||||||
|
- `SCAN_ROUTES`
|
||||||
|
- `SCAN_REPORT_PATH`
|
||||||
|
- `SCAN_HEADLESS`
|
||||||
|
|
||||||
|
If `SCAN_USERNAME` or `SCAN_PASSWORD` are omitted, the script falls back to `CLOAKBROWSER_USERNAME` and `CLOAKBROWSER_PASSWORD`.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Sessions persist in CloakBrowser profile storage.
|
||||||
|
- Use `--wait` for dynamic pages.
|
||||||
|
- Use `--mode selector --selector "..."` for targeted extraction.
|
||||||
|
- `extract.js` keeps a bounded stealth/rendered fetch path without needing a long-lived automation session.
|
||||||
|
- Package installs use the repo's `pi-package/skills/web-automation/` mirror so the installed skill directory name matches `web-automation`.
|
||||||
@@ -0,0 +1,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,43 @@
|
|||||||
|
#!/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");
|
||||||
|
await import("better-sqlite3");
|
||||||
|
await import("esbuild");
|
||||||
|
} catch (error) {
|
||||||
|
fail(
|
||||||
|
"Missing dependency/config: web-automation requires cloakbrowser, playwright-core, better-sqlite3, and esbuild.",
|
||||||
|
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: better-sqlite3 + esbuild 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));
|
||||||
|
});
|
||||||
Executable
+188
@@ -0,0 +1,188 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const DEFAULT_WAIT_MS = 5000;
|
||||||
|
const MAX_WAIT_MS = 20000;
|
||||||
|
const NAV_TIMEOUT_MS = 30000;
|
||||||
|
const EXTRA_CHALLENGE_WAIT_MS = 8000;
|
||||||
|
const CONTENT_LIMIT = 12000;
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
function fail(message, details) {
|
||||||
|
const payload = { error: message };
|
||||||
|
if (details) payload.details = details;
|
||||||
|
process.stderr.write(`${JSON.stringify(payload)}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWaitTime(raw) {
|
||||||
|
const value = Number.parseInt(raw || `${DEFAULT_WAIT_MS}`, 10);
|
||||||
|
if (!Number.isFinite(value) || value < 0) return DEFAULT_WAIT_MS;
|
||||||
|
return Math.min(value, MAX_WAIT_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTarget(rawUrl) {
|
||||||
|
if (!rawUrl) {
|
||||||
|
fail("Missing URL. Usage: node extract.js <URL>");
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = new URL(rawUrl);
|
||||||
|
} catch (error) {
|
||||||
|
fail("Invalid URL.", error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!["http:", "https:"].includes(parsed.protocol)) {
|
||||||
|
fail("Only http and https URLs are allowed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureParentDir(filePath) {
|
||||||
|
if (!filePath) return;
|
||||||
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detectChallenge(page) {
|
||||||
|
try {
|
||||||
|
return await page.evaluate(() => {
|
||||||
|
const text = (document.body?.innerText || "").toLowerCase();
|
||||||
|
return (
|
||||||
|
text.includes("checking your browser") ||
|
||||||
|
text.includes("just a moment") ||
|
||||||
|
text.includes("verify you are human") ||
|
||||||
|
text.includes("press and hold") ||
|
||||||
|
document.querySelector('iframe[src*="challenge"]') !== null ||
|
||||||
|
document.querySelector('iframe[src*="cloudflare"]') !== null
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCloakBrowser() {
|
||||||
|
try {
|
||||||
|
return await import("cloakbrowser");
|
||||||
|
} catch (error) {
|
||||||
|
fail(
|
||||||
|
"CloakBrowser is not installed for this skill. Run pnpm install in this skill's scripts directory first.",
|
||||||
|
error.message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runWithStderrLogs(fn) {
|
||||||
|
const originalLog = console.log;
|
||||||
|
const originalError = console.error;
|
||||||
|
console.log = (...args) => process.stderr.write(`${args.join(" ")}\n`);
|
||||||
|
console.error = (...args) => process.stderr.write(`${args.join(" ")}\n`);
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
console.log = originalLog;
|
||||||
|
console.error = originalError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const requestedUrl = parseTarget(process.argv[2]);
|
||||||
|
const waitTime = parseWaitTime(process.env.WAIT_TIME);
|
||||||
|
const screenshotPath = process.env.SCREENSHOT_PATH || "";
|
||||||
|
const saveHtml = process.env.SAVE_HTML === "true";
|
||||||
|
const headless = process.env.HEADLESS !== "false";
|
||||||
|
const userAgent = process.env.USER_AGENT || undefined;
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const { ensureBinary, launchContext } = await loadCloakBrowser();
|
||||||
|
|
||||||
|
let context;
|
||||||
|
try {
|
||||||
|
await runWithStderrLogs(() => ensureBinary());
|
||||||
|
|
||||||
|
context = await runWithStderrLogs(() => launchContext({
|
||||||
|
headless,
|
||||||
|
userAgent,
|
||||||
|
locale: "en-US",
|
||||||
|
viewport: { width: 1440, height: 900 },
|
||||||
|
humanize: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const page = await context.newPage();
|
||||||
|
const response = await page.goto(requestedUrl, {
|
||||||
|
waitUntil: "domcontentloaded",
|
||||||
|
timeout: NAV_TIMEOUT_MS
|
||||||
|
});
|
||||||
|
|
||||||
|
await sleep(waitTime);
|
||||||
|
|
||||||
|
let challengeDetected = await detectChallenge(page);
|
||||||
|
if (challengeDetected) {
|
||||||
|
await sleep(EXTRA_CHALLENGE_WAIT_MS);
|
||||||
|
challengeDetected = await detectChallenge(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
const extracted = await page.evaluate((contentLimit) => {
|
||||||
|
const bodyText = document.body?.innerText || "";
|
||||||
|
return {
|
||||||
|
finalUrl: window.location.href,
|
||||||
|
title: document.title || "",
|
||||||
|
content: bodyText.slice(0, contentLimit),
|
||||||
|
metaDescription:
|
||||||
|
document.querySelector('meta[name="description"]')?.content ||
|
||||||
|
document.querySelector('meta[property="og:description"]')?.content ||
|
||||||
|
""
|
||||||
|
};
|
||||||
|
}, CONTENT_LIMIT);
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
requestedUrl,
|
||||||
|
finalUrl: extracted.finalUrl,
|
||||||
|
title: extracted.title,
|
||||||
|
content: extracted.content,
|
||||||
|
metaDescription: extracted.metaDescription,
|
||||||
|
status: response ? response.status() : null,
|
||||||
|
challengeDetected,
|
||||||
|
elapsedSeconds: ((Date.now() - startedAt) / 1000).toFixed(2)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (screenshotPath) {
|
||||||
|
ensureParentDir(screenshotPath);
|
||||||
|
await page.screenshot({ path: screenshotPath, fullPage: false, timeout: 10000 });
|
||||||
|
result.screenshot = screenshotPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (saveHtml) {
|
||||||
|
const htmlTarget = screenshotPath
|
||||||
|
? screenshotPath.replace(/\.[^.]+$/, ".html")
|
||||||
|
: path.resolve(__dirname, `page-${Date.now()}.html`);
|
||||||
|
ensureParentDir(htmlTarget);
|
||||||
|
fs.writeFileSync(htmlTarget, await page.content());
|
||||||
|
result.htmlFile = htmlTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||||
|
await context.close();
|
||||||
|
} catch (error) {
|
||||||
|
if (context) {
|
||||||
|
try {
|
||||||
|
await context.close();
|
||||||
|
} catch {
|
||||||
|
// Ignore close errors after the primary failure.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fail("Scrape failed.", error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
#!/usr/bin/env npx tsx
|
||||||
|
|
||||||
|
import parseArgs from 'minimist';
|
||||||
|
import type { Page } from 'playwright-core';
|
||||||
|
import { launchBrowser } from './browse';
|
||||||
|
|
||||||
|
type Step =
|
||||||
|
| { action: 'goto'; url: string }
|
||||||
|
| { action: 'click'; selector?: string; text?: string; role?: string; name?: string }
|
||||||
|
| { action: 'type'; selector?: string; text: string }
|
||||||
|
| { action: 'press'; key: string; selector?: string }
|
||||||
|
| { action: 'wait'; ms: number }
|
||||||
|
| { action: 'screenshot'; path: string }
|
||||||
|
| { action: 'extract'; selector: string; count?: number };
|
||||||
|
|
||||||
|
function normalizeNavigationUrl(rawUrl: string): string {
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(rawUrl);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Invalid navigation URL: ${rawUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||||
|
throw new Error(`Only http and https URLs are allowed in flow steps: ${rawUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeKey(k: string): string {
|
||||||
|
if (!k) return 'Enter';
|
||||||
|
const lower = k.toLowerCase();
|
||||||
|
if (lower === 'enter' || lower === 'return') return 'Enter';
|
||||||
|
if (lower === 'tab') return 'Tab';
|
||||||
|
if (lower === 'escape' || lower === 'esc') return 'Escape';
|
||||||
|
return k;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitInstructions(instruction: string): string[] {
|
||||||
|
return instruction
|
||||||
|
.split(/\bthen\b|;/gi)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseInstruction(instruction: string): Step[] {
|
||||||
|
const parts = splitInstructions(instruction);
|
||||||
|
const steps: Step[] = [];
|
||||||
|
|
||||||
|
for (const p of parts) {
|
||||||
|
// go to https://...
|
||||||
|
const goto = p.match(/^(?:go to|open|navigate to)\s+(https?:\/\/\S+)/i);
|
||||||
|
if (goto) {
|
||||||
|
steps.push({ action: 'goto', url: normalizeNavigationUrl(goto[1]) });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// click on "text" or click #selector or click button "name"
|
||||||
|
const clickRole = p.match(/^click\s+(button|link|textbox|img|image|tab)\s+"([^"]+)"$/i);
|
||||||
|
if (clickRole) {
|
||||||
|
const role = clickRole[1].toLowerCase() === 'image' ? 'img' : clickRole[1].toLowerCase();
|
||||||
|
steps.push({ action: 'click', role, name: clickRole[2] });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const clickText = p.match(/^click(?: on)?\s+"([^"]+)"/i);
|
||||||
|
if (clickText) {
|
||||||
|
steps.push({ action: 'click', text: clickText[1] });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const clickSelector = p.match(/^click(?: on)?\s+(#[\w-]+|\.[\w-]+|[a-z]+\[[^\]]+\])/i);
|
||||||
|
if (clickSelector) {
|
||||||
|
steps.push({ action: 'click', selector: clickSelector[1] });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// type "text" [in selector]
|
||||||
|
const typeInto = p.match(/^type\s+"([^"]+)"\s+in\s+(.+)$/i);
|
||||||
|
if (typeInto) {
|
||||||
|
steps.push({ action: 'type', text: typeInto[1], selector: typeInto[2].trim() });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const typeOnly = p.match(/^type\s+"([^"]+)"$/i);
|
||||||
|
if (typeOnly) {
|
||||||
|
steps.push({ action: 'type', text: typeOnly[1] });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// press enter [in selector]
|
||||||
|
const pressIn = p.match(/^press\s+(\w+)\s+in\s+(.+)$/i);
|
||||||
|
if (pressIn) {
|
||||||
|
steps.push({ action: 'press', key: normalizeKey(pressIn[1]), selector: pressIn[2].trim() });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const pressOnly = p.match(/^press\s+(\w+)$/i);
|
||||||
|
if (pressOnly) {
|
||||||
|
steps.push({ action: 'press', key: normalizeKey(pressOnly[1]) });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// wait 2s / wait 500ms
|
||||||
|
const waitS = p.match(/^wait\s+(\d+)\s*s(?:ec(?:onds?)?)?$/i);
|
||||||
|
if (waitS) {
|
||||||
|
steps.push({ action: 'wait', ms: parseInt(waitS[1], 10) * 1000 });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const waitMs = p.match(/^wait\s+(\d+)\s*ms$/i);
|
||||||
|
if (waitMs) {
|
||||||
|
steps.push({ action: 'wait', ms: parseInt(waitMs[1], 10) });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// screenshot path
|
||||||
|
const shot = p.match(/^screenshot(?: to)?\s+(.+)$/i);
|
||||||
|
if (shot) {
|
||||||
|
steps.push({ action: 'screenshot', path: shot[1].trim() });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Could not parse step: "${p}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return steps;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSteps(steps: Step[]): Step[] {
|
||||||
|
return steps.map((step) =>
|
||||||
|
step.action === 'goto'
|
||||||
|
? {
|
||||||
|
...step,
|
||||||
|
url: normalizeNavigationUrl(step.url),
|
||||||
|
}
|
||||||
|
: step
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegExp(value: string): string {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLikelyLoginText(text: string): boolean {
|
||||||
|
return /(login|accedi|sign\s*in|entra)/i.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clickByText(page: Page, text: string): Promise<boolean> {
|
||||||
|
const patterns = [new RegExp(`^${escapeRegExp(text)}$`, 'i'), new RegExp(escapeRegExp(text), 'i')];
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const targets = [
|
||||||
|
page.getByRole('button', { name: pattern }).first(),
|
||||||
|
page.getByRole('link', { name: pattern }).first(),
|
||||||
|
page.getByText(pattern).first(),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const target of targets) {
|
||||||
|
if (await target.count()) {
|
||||||
|
try {
|
||||||
|
await target.click({ timeout: 8000 });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
// keep trying next candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fallbackLoginNavigation(page: Page, requestedText: string): Promise<boolean> {
|
||||||
|
if (!isLikelyLoginText(requestedText)) return false;
|
||||||
|
|
||||||
|
const current = new URL(page.url());
|
||||||
|
|
||||||
|
const candidateLinks = await page.evaluate(() => {
|
||||||
|
const loginTerms = ['login', 'accedi', 'sign in', 'entra'];
|
||||||
|
const anchors = Array.from(document.querySelectorAll('a[href], a[onclick], button[onclick]')) as Array<HTMLAnchorElement | HTMLButtonElement>;
|
||||||
|
|
||||||
|
return anchors
|
||||||
|
.map((el) => {
|
||||||
|
const text = (el.textContent || '').trim().toLowerCase();
|
||||||
|
const href = (el as HTMLAnchorElement).getAttribute('href') || '';
|
||||||
|
return { text, href };
|
||||||
|
})
|
||||||
|
.filter((x) => x.text && loginTerms.some((t) => x.text.includes(t)))
|
||||||
|
.map((x) => x.href)
|
||||||
|
.filter(Boolean);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prefer real URLs (not javascript:)
|
||||||
|
const realCandidate = candidateLinks.find((h) => /login|account\/login/i.test(h) && !h.startsWith('javascript:'));
|
||||||
|
if (realCandidate) {
|
||||||
|
const target = new URL(realCandidate, page.url()).toString();
|
||||||
|
await page.goto(target, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Site-specific fallback for Corriere
|
||||||
|
if (/corriere\.it$/i.test(current.hostname) || /\.corriere\.it$/i.test(current.hostname)) {
|
||||||
|
await page.goto('https://www.corriere.it/account/login', {
|
||||||
|
waitUntil: 'domcontentloaded',
|
||||||
|
timeout: 60000,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function typeInBestTarget(page: Page, text: string, selector?: string) {
|
||||||
|
if (selector) {
|
||||||
|
await page.locator(selector).first().click({ timeout: 10000 });
|
||||||
|
await page.locator(selector).first().fill(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const loc = page.locator('input[name="q"], input[type="search"], input[type="text"], textarea').first();
|
||||||
|
await loc.click({ timeout: 10000 });
|
||||||
|
await loc.fill(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pressOnTarget(page: Page, key: string, selector?: string) {
|
||||||
|
if (selector) {
|
||||||
|
await page.locator(selector).first().press(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await page.keyboard.press(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSteps(page: Page, steps: Step[]) {
|
||||||
|
for (const step of steps) {
|
||||||
|
switch (step.action) {
|
||||||
|
case 'goto':
|
||||||
|
await page.goto(normalizeNavigationUrl(step.url), {
|
||||||
|
waitUntil: 'domcontentloaded',
|
||||||
|
timeout: 60000,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'click':
|
||||||
|
if (step.selector) {
|
||||||
|
await page.locator(step.selector).first().click({ timeout: 15000 });
|
||||||
|
} else if (step.role && step.name) {
|
||||||
|
await page.getByRole(step.role as any, { name: new RegExp(escapeRegExp(step.name), 'i') }).first().click({ timeout: 15000 });
|
||||||
|
} else if (step.text) {
|
||||||
|
const clicked = await clickByText(page, step.text);
|
||||||
|
if (!clicked) {
|
||||||
|
const recovered = await fallbackLoginNavigation(page, step.text);
|
||||||
|
if (!recovered) {
|
||||||
|
throw new Error(`Could not click target text: ${step.text}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error('click step missing selector/text/role');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await page.waitForLoadState('domcontentloaded', { timeout: 10000 });
|
||||||
|
} catch {
|
||||||
|
// no navigation is fine
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'type':
|
||||||
|
await typeInBestTarget(page, step.text, step.selector);
|
||||||
|
break;
|
||||||
|
case 'press':
|
||||||
|
await pressOnTarget(page, step.key, step.selector);
|
||||||
|
break;
|
||||||
|
case 'wait':
|
||||||
|
await page.waitForTimeout(step.ms);
|
||||||
|
break;
|
||||||
|
case 'screenshot':
|
||||||
|
await page.screenshot({ path: step.path, fullPage: true });
|
||||||
|
break;
|
||||||
|
case 'extract': {
|
||||||
|
const items = await page.locator(step.selector).allTextContents();
|
||||||
|
const out = items.slice(0, step.count ?? items.length).map((t) => t.trim()).filter(Boolean);
|
||||||
|
console.log(JSON.stringify(out, null, 2));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new Error('Unknown step');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = parseArgs(process.argv.slice(2), {
|
||||||
|
string: ['instruction', 'steps'],
|
||||||
|
boolean: ['headless', 'help'],
|
||||||
|
default: { headless: true },
|
||||||
|
alias: { i: 'instruction', s: 'steps', h: 'help' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (args.help || (!args.instruction && !args.steps)) {
|
||||||
|
console.log(`
|
||||||
|
General Web Flow Runner (CloakBrowser)
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
npx tsx flow.ts --instruction "go to https://example.com then type \"hello\" then press enter"
|
||||||
|
npx tsx flow.ts --steps '[{"action":"goto","url":"https://example.com"}]'
|
||||||
|
|
||||||
|
Supported natural steps:
|
||||||
|
- go to/open/navigate to <url>
|
||||||
|
- click on "Text"
|
||||||
|
- click <css-selector>
|
||||||
|
- type "text"
|
||||||
|
- type "text" in <css-selector>
|
||||||
|
- press <key>
|
||||||
|
- press <key> in <css-selector>
|
||||||
|
- wait <N>s | wait <N>ms
|
||||||
|
- screenshot <path>
|
||||||
|
`);
|
||||||
|
process.exit(args.help ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const steps = validateSteps(args.steps ? JSON.parse(args.steps) : parseInstruction(args.instruction));
|
||||||
|
const browser = await launchBrowser({ headless: args.headless });
|
||||||
|
const page = await browser.newPage();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runSteps(page, steps);
|
||||||
|
console.log('Flow complete. Final URL:', page.url());
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error('Error:', e instanceof Error ? e.message : e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "web-automation-scripts",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Web browsing and scraping scripts using CloakBrowser",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"check-install": "node check-install.js",
|
||||||
|
"extract": "node extract.js",
|
||||||
|
"browse": "tsx browse.ts",
|
||||||
|
"auth": "tsx auth.ts",
|
||||||
|
"flow": "tsx flow.ts",
|
||||||
|
"scrape": "tsx scrape.ts",
|
||||||
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||||
|
"lint": "pnpm run typecheck && node --check check-install.js && node --check extract.js",
|
||||||
|
"fetch-browser": "npx cloakbrowser install"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@mozilla/readability": "^0.5.0",
|
||||||
|
"better-sqlite3": "^12.6.2",
|
||||||
|
"cloakbrowser": "^0.3.22",
|
||||||
|
"jsdom": "^24.0.0",
|
||||||
|
"minimist": "^1.2.8",
|
||||||
|
"playwright-core": "^1.59.1",
|
||||||
|
"turndown": "^7.1.2",
|
||||||
|
"turndown-plugin-gfm": "^1.0.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/jsdom": "^21.1.6",
|
||||||
|
"@types/minimist": "^1.2.5",
|
||||||
|
"@types/turndown": "^5.0.4",
|
||||||
|
"esbuild": "0.27.0",
|
||||||
|
"tsx": "^4.7.0",
|
||||||
|
"typescript": "^5.3.0"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34"
|
||||||
|
}
|
||||||
+1292
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,174 @@
|
|||||||
|
#!/usr/bin/env npx tsx
|
||||||
|
|
||||||
|
import { mkdirSync, writeFileSync } from 'fs';
|
||||||
|
import { dirname, resolve } from 'path';
|
||||||
|
import { getPage } from './browse.js';
|
||||||
|
|
||||||
|
type NavResult = {
|
||||||
|
requestedUrl: string;
|
||||||
|
url: string;
|
||||||
|
status: number | null;
|
||||||
|
title: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RouteCheck = {
|
||||||
|
route: string;
|
||||||
|
result: NavResult;
|
||||||
|
heading: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_BASE_URL = 'http://localhost:3000';
|
||||||
|
const DEFAULT_REPORT_PATH = resolve(process.cwd(), 'scan-local-app.md');
|
||||||
|
|
||||||
|
function env(name: string): string | undefined {
|
||||||
|
const value = process.env[name]?.trim();
|
||||||
|
return value ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRoutes(baseUrl: string): string[] {
|
||||||
|
const routeList = env('SCAN_ROUTES');
|
||||||
|
if (routeList) {
|
||||||
|
return routeList
|
||||||
|
.split(',')
|
||||||
|
.map((route) => route.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((route) => new URL(route, baseUrl).toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
return [baseUrl];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gotoWithStatus(page: any, url: string): Promise<NavResult> {
|
||||||
|
const response = await page
|
||||||
|
.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 })
|
||||||
|
.catch((error: unknown) => ({ error }));
|
||||||
|
|
||||||
|
if (response?.error) {
|
||||||
|
return {
|
||||||
|
requestedUrl: url,
|
||||||
|
url: page.url(),
|
||||||
|
status: null,
|
||||||
|
title: await page.title().catch(() => ''),
|
||||||
|
error: String(response.error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
requestedUrl: url,
|
||||||
|
url: page.url(),
|
||||||
|
status: response ? response.status() : null,
|
||||||
|
title: await page.title().catch(() => ''),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function textOrNull(page: any, selector: string): Promise<string | null> {
|
||||||
|
const locator = page.locator(selector).first();
|
||||||
|
try {
|
||||||
|
if ((await locator.count()) === 0) return null;
|
||||||
|
const value = await locator.textContent();
|
||||||
|
return value ? value.trim().replace(/\s+/g, ' ') : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginIfConfigured(page: any, baseUrl: string, lines: string[]) {
|
||||||
|
const loginPath = env('SCAN_LOGIN_PATH');
|
||||||
|
const username = env('SCAN_USERNAME') ?? env('CLOAKBROWSER_USERNAME');
|
||||||
|
const password = env('SCAN_PASSWORD') ?? env('CLOAKBROWSER_PASSWORD');
|
||||||
|
const usernameSelector = env('SCAN_USERNAME_SELECTOR') ?? 'input[type="email"], input[name="email"]';
|
||||||
|
const passwordSelector = env('SCAN_PASSWORD_SELECTOR') ?? 'input[type="password"], input[name="password"]';
|
||||||
|
const submitSelector = env('SCAN_SUBMIT_SELECTOR') ?? 'button[type="submit"], input[type="submit"]';
|
||||||
|
|
||||||
|
if (!loginPath) {
|
||||||
|
lines.push('## Login');
|
||||||
|
lines.push('- Skipped: set `SCAN_LOGIN_PATH` to enable login smoke checks.');
|
||||||
|
lines.push('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginUrl = new URL(loginPath, baseUrl).toString();
|
||||||
|
lines.push('## Login');
|
||||||
|
lines.push(`- Login URL: ${loginUrl}`);
|
||||||
|
await gotoWithStatus(page, loginUrl);
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
lines.push('- Skipped: set `SCAN_USERNAME`/`SCAN_PASSWORD` or `CLOAKBROWSER_USERNAME`/`CLOAKBROWSER_PASSWORD`.');
|
||||||
|
lines.push('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.locator(usernameSelector).first().fill(username);
|
||||||
|
await page.locator(passwordSelector).first().fill(password);
|
||||||
|
await page.locator(submitSelector).first().click();
|
||||||
|
await page.waitForTimeout(2500);
|
||||||
|
|
||||||
|
lines.push(`- After submit URL: ${page.url()}`);
|
||||||
|
lines.push(`- Cookie count: ${(await page.context().cookies()).length}`);
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkRoutes(page: any, baseUrl: string, lines: string[]) {
|
||||||
|
const routes = getRoutes(baseUrl);
|
||||||
|
const routeChecks: RouteCheck[] = [];
|
||||||
|
|
||||||
|
for (const url of routes) {
|
||||||
|
const result = await gotoWithStatus(page, url);
|
||||||
|
const heading = await textOrNull(page, 'h1');
|
||||||
|
routeChecks.push({
|
||||||
|
route: url,
|
||||||
|
result,
|
||||||
|
heading,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('## Route Checks');
|
||||||
|
for (const check of routeChecks) {
|
||||||
|
const relativeUrl = check.route.startsWith(baseUrl) ? check.route.slice(baseUrl.length) || '/' : check.route;
|
||||||
|
const finalPath = check.result.url.startsWith(baseUrl)
|
||||||
|
? check.result.url.slice(baseUrl.length) || '/'
|
||||||
|
: check.result.url;
|
||||||
|
const suffix = check.heading ? `, h1="${check.heading}"` : '';
|
||||||
|
const errorSuffix = check.result.error ? `, error="${check.result.error}"` : '';
|
||||||
|
lines.push(
|
||||||
|
`- ${relativeUrl} → status ${check.result.status ?? 'ERR'} (final ${finalPath})${suffix}${errorSuffix}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const baseUrl = env('SCAN_BASE_URL') ?? DEFAULT_BASE_URL;
|
||||||
|
const reportPath = resolve(env('SCAN_REPORT_PATH') ?? DEFAULT_REPORT_PATH);
|
||||||
|
const headless = (env('SCAN_HEADLESS') ?? env('CLOAKBROWSER_HEADLESS') ?? 'true') === 'true';
|
||||||
|
const { page, browser } = await getPage({ headless });
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
lines.push('# Web Automation Scan (local)');
|
||||||
|
lines.push('');
|
||||||
|
lines.push(`- Base URL: ${baseUrl}`);
|
||||||
|
lines.push(`- Timestamp: ${new Date().toISOString()}`);
|
||||||
|
lines.push(`- Headless: ${headless}`);
|
||||||
|
lines.push(`- Report Path: ${reportPath}`);
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loginIfConfigured(page, baseUrl, lines);
|
||||||
|
await checkRoutes(page, baseUrl, lines);
|
||||||
|
lines.push('## Notes');
|
||||||
|
lines.push('- This generic smoke helper records route availability and top-level headings for a local app.');
|
||||||
|
lines.push('- Configure login and route coverage with `SCAN_*` environment variables.');
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdirSync(dirname(reportPath), { recursive: true });
|
||||||
|
writeFileSync(reportPath, `${lines.join('\n')}\n`, 'utf-8');
|
||||||
|
console.log(`Report written to ${reportPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
#!/usr/bin/env npx tsx
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Web scraper that extracts content to markdown
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* npx tsx scrape.ts --url "https://example.com" --mode main
|
||||||
|
* npx tsx scrape.ts --url "https://example.com" --mode full --output page.md
|
||||||
|
* npx tsx scrape.ts --url "https://example.com" --mode selector --selector ".content"
|
||||||
|
*/
|
||||||
|
|
||||||
|
import TurndownService from 'turndown';
|
||||||
|
import * as turndownPluginGfm from 'turndown-plugin-gfm';
|
||||||
|
import { Readability } from '@mozilla/readability';
|
||||||
|
import { JSDOM } from 'jsdom';
|
||||||
|
import { writeFileSync } from 'fs';
|
||||||
|
import parseArgs from 'minimist';
|
||||||
|
import { getPage } from './browse.js';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
type ScrapeMode = 'main' | 'full' | 'selector';
|
||||||
|
|
||||||
|
interface ScrapeOptions {
|
||||||
|
url: string;
|
||||||
|
mode: ScrapeMode;
|
||||||
|
selector?: string;
|
||||||
|
output?: string;
|
||||||
|
includeLinks?: boolean;
|
||||||
|
includeTables?: boolean;
|
||||||
|
includeImages?: boolean;
|
||||||
|
headless?: boolean;
|
||||||
|
wait?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScrapeResult {
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
markdown: string;
|
||||||
|
byline?: string;
|
||||||
|
excerpt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure Turndown for markdown conversion
|
||||||
|
function createTurndownService(options: {
|
||||||
|
includeLinks?: boolean;
|
||||||
|
includeTables?: boolean;
|
||||||
|
includeImages?: boolean;
|
||||||
|
}): TurndownService {
|
||||||
|
const turndown = new TurndownService({
|
||||||
|
headingStyle: 'atx',
|
||||||
|
hr: '---',
|
||||||
|
bulletListMarker: '-',
|
||||||
|
codeBlockStyle: 'fenced',
|
||||||
|
fence: '```',
|
||||||
|
emDelimiter: '*',
|
||||||
|
strongDelimiter: '**',
|
||||||
|
linkStyle: 'inlined',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add GFM support (tables, strikethrough, task lists)
|
||||||
|
turndown.use(turndownPluginGfm.gfm);
|
||||||
|
|
||||||
|
// Custom rule for code blocks with language detection
|
||||||
|
turndown.addRule('codeBlockWithLanguage', {
|
||||||
|
filter: (node) => {
|
||||||
|
return (
|
||||||
|
node.nodeName === 'PRE' &&
|
||||||
|
node.firstChild?.nodeName === 'CODE'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
replacement: (_content, node) => {
|
||||||
|
const codeNode = node.firstChild as HTMLElement;
|
||||||
|
const className = codeNode.getAttribute('class') || '';
|
||||||
|
const langMatch = className.match(/language-(\w+)/);
|
||||||
|
const lang = langMatch ? langMatch[1] : '';
|
||||||
|
const code = codeNode.textContent || '';
|
||||||
|
return `\n\n\`\`\`${lang}\n${code}\n\`\`\`\n\n`;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove images if not included
|
||||||
|
if (!options.includeImages) {
|
||||||
|
turndown.addRule('removeImages', {
|
||||||
|
filter: 'img',
|
||||||
|
replacement: () => '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove links but keep text if not included
|
||||||
|
if (!options.includeLinks) {
|
||||||
|
turndown.addRule('removeLinks', {
|
||||||
|
filter: 'a',
|
||||||
|
replacement: (content) => content,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove script, style, nav, footer, aside elements
|
||||||
|
turndown.remove(['script', 'style', 'nav', 'footer', 'aside', 'noscript']);
|
||||||
|
|
||||||
|
return turndown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract main content using Readability
|
||||||
|
function extractMainContent(html: string, url: string): {
|
||||||
|
content: string;
|
||||||
|
title: string;
|
||||||
|
byline?: string;
|
||||||
|
excerpt?: string;
|
||||||
|
} {
|
||||||
|
const dom = new JSDOM(html, { url });
|
||||||
|
const reader = new Readability(dom.window.document);
|
||||||
|
const article = reader.parse();
|
||||||
|
|
||||||
|
if (!article) {
|
||||||
|
throw new Error('Could not extract main content from page');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: article.content,
|
||||||
|
title: article.title,
|
||||||
|
byline: article.byline || undefined,
|
||||||
|
excerpt: article.excerpt || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scrape a URL and return markdown
|
||||||
|
export async function scrape(options: ScrapeOptions): Promise<ScrapeResult> {
|
||||||
|
const { page, browser } = await getPage({ headless: options.headless ?? true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Navigate to URL
|
||||||
|
console.log(`Navigating to: ${options.url}`);
|
||||||
|
await page.goto(options.url, {
|
||||||
|
timeout: 60000,
|
||||||
|
waitUntil: 'domcontentloaded',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait if specified
|
||||||
|
if (options.wait) {
|
||||||
|
console.log(`Waiting ${options.wait}ms for dynamic content...`);
|
||||||
|
await page.waitForTimeout(options.wait);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageTitle = await page.title();
|
||||||
|
const pageUrl = page.url();
|
||||||
|
|
||||||
|
let html: string;
|
||||||
|
let title = pageTitle;
|
||||||
|
let byline: string | undefined;
|
||||||
|
let excerpt: string | undefined;
|
||||||
|
|
||||||
|
// Get HTML based on mode
|
||||||
|
switch (options.mode) {
|
||||||
|
case 'main': {
|
||||||
|
// Get full page HTML and extract with Readability
|
||||||
|
const fullHtml = await page.content();
|
||||||
|
const extracted = extractMainContent(fullHtml, pageUrl);
|
||||||
|
html = extracted.content;
|
||||||
|
title = extracted.title || pageTitle;
|
||||||
|
byline = extracted.byline;
|
||||||
|
excerpt = extracted.excerpt;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'selector': {
|
||||||
|
if (!options.selector) {
|
||||||
|
throw new Error('Selector mode requires --selector option');
|
||||||
|
}
|
||||||
|
const element = await page.$(options.selector);
|
||||||
|
if (!element) {
|
||||||
|
throw new Error(`Selector not found: ${options.selector}`);
|
||||||
|
}
|
||||||
|
html = await element.innerHTML();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'full':
|
||||||
|
default: {
|
||||||
|
// Get body content, excluding common non-content elements
|
||||||
|
html = await page.evaluate(() => {
|
||||||
|
// Remove common non-content elements
|
||||||
|
const selectorsToRemove = [
|
||||||
|
'script', 'style', 'noscript', 'iframe',
|
||||||
|
'nav', 'header', 'footer', '.cookie-banner',
|
||||||
|
'.advertisement', '.ads', '#ads', '.social-share',
|
||||||
|
'.comments', '#comments', '.sidebar'
|
||||||
|
];
|
||||||
|
|
||||||
|
selectorsToRemove.forEach(selector => {
|
||||||
|
document.querySelectorAll(selector).forEach(el => el.remove());
|
||||||
|
});
|
||||||
|
|
||||||
|
return document.body.innerHTML;
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to markdown
|
||||||
|
const turndown = createTurndownService({
|
||||||
|
includeLinks: options.includeLinks ?? true,
|
||||||
|
includeTables: options.includeTables ?? true,
|
||||||
|
includeImages: options.includeImages ?? false,
|
||||||
|
});
|
||||||
|
|
||||||
|
let markdown = turndown.turndown(html);
|
||||||
|
|
||||||
|
// Add title as H1 if not already present
|
||||||
|
if (!markdown.startsWith('# ')) {
|
||||||
|
markdown = `# ${title}\n\n${markdown}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add metadata header
|
||||||
|
const metadataLines = [
|
||||||
|
`<!-- Scraped from: ${pageUrl} -->`,
|
||||||
|
byline ? `<!-- Author: ${byline} -->` : null,
|
||||||
|
excerpt ? `<!-- Excerpt: ${excerpt} -->` : null,
|
||||||
|
`<!-- Scraped at: ${new Date().toISOString()} -->`,
|
||||||
|
'',
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
markdown = metadataLines.join('\n') + '\n' + markdown;
|
||||||
|
|
||||||
|
// Clean up excessive whitespace
|
||||||
|
markdown = markdown
|
||||||
|
.replace(/\n{4,}/g, '\n\n\n')
|
||||||
|
.replace(/[ \t]+$/gm, '')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
const result: ScrapeResult = {
|
||||||
|
title,
|
||||||
|
url: pageUrl,
|
||||||
|
markdown,
|
||||||
|
byline,
|
||||||
|
excerpt,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save to file if output specified
|
||||||
|
if (options.output) {
|
||||||
|
writeFileSync(options.output, markdown, 'utf-8');
|
||||||
|
console.log(`Markdown saved to: ${options.output}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLI entry point
|
||||||
|
async function main() {
|
||||||
|
const args = parseArgs(process.argv.slice(2), {
|
||||||
|
string: ['url', 'mode', 'selector', 'output'],
|
||||||
|
boolean: ['headless', 'links', 'tables', 'images', 'help'],
|
||||||
|
default: {
|
||||||
|
mode: 'main',
|
||||||
|
headless: true,
|
||||||
|
links: true,
|
||||||
|
tables: true,
|
||||||
|
images: false,
|
||||||
|
},
|
||||||
|
alias: {
|
||||||
|
u: 'url',
|
||||||
|
m: 'mode',
|
||||||
|
s: 'selector',
|
||||||
|
o: 'output',
|
||||||
|
h: 'help',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (args.help || !args.url) {
|
||||||
|
console.log(`
|
||||||
|
Web Scraper - Extract content to Markdown
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
npx tsx scrape.ts --url <url> [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-u, --url <url> URL to scrape (required)
|
||||||
|
-m, --mode <mode> Scrape mode: main, full, or selector (default: main)
|
||||||
|
-s, --selector <sel> CSS selector for selector mode
|
||||||
|
-o, --output <path> Output file path for markdown
|
||||||
|
--headless <bool> Run in headless mode (default: true)
|
||||||
|
--wait <ms> Wait time for dynamic content
|
||||||
|
--links Include links in output (default: true)
|
||||||
|
--tables Include tables in output (default: true)
|
||||||
|
--images Include images in output (default: false)
|
||||||
|
-h, --help Show this help message
|
||||||
|
|
||||||
|
Scrape Modes:
|
||||||
|
main Extract main article content using Readability (best for articles)
|
||||||
|
full Full page content with common elements removed
|
||||||
|
selector Extract specific element by CSS selector
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
npx tsx scrape.ts --url "https://docs.example.com/guide" --mode main
|
||||||
|
npx tsx scrape.ts --url "https://example.com" --mode full --output page.md
|
||||||
|
npx tsx scrape.ts --url "https://example.com" --mode selector --selector ".api-docs"
|
||||||
|
npx tsx scrape.ts --url "https://example.com" --mode main --no-links --output clean.md
|
||||||
|
|
||||||
|
Output Format:
|
||||||
|
- GitHub Flavored Markdown (tables, strikethrough, task lists)
|
||||||
|
- Proper heading hierarchy
|
||||||
|
- Code blocks with language detection
|
||||||
|
- Metadata comments at top (source URL, date)
|
||||||
|
`);
|
||||||
|
process.exit(args.help ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mode = args.mode as ScrapeMode;
|
||||||
|
if (!['main', 'full', 'selector'].includes(mode)) {
|
||||||
|
console.error(`Invalid mode: ${mode}. Must be main, full, or selector.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await scrape({
|
||||||
|
url: args.url,
|
||||||
|
mode,
|
||||||
|
selector: args.selector,
|
||||||
|
output: args.output,
|
||||||
|
includeLinks: args.links,
|
||||||
|
includeTables: args.tables,
|
||||||
|
includeImages: args.images,
|
||||||
|
headless: args.headless,
|
||||||
|
wait: args.wait ? parseInt(args.wait, 10) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Print result summary
|
||||||
|
console.log(`\nScrape complete:`);
|
||||||
|
console.log(` Title: ${result.title}`);
|
||||||
|
console.log(` URL: ${result.url}`);
|
||||||
|
if (result.byline) console.log(` Author: ${result.byline}`);
|
||||||
|
console.log(` Markdown length: ${result.markdown.length} chars`);
|
||||||
|
|
||||||
|
// Print markdown if not saved to file
|
||||||
|
if (!args.output) {
|
||||||
|
console.log('\n--- Markdown Output ---\n');
|
||||||
|
console.log(result.markdown);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error instanceof Error ? error.message : error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run if executed directly
|
||||||
|
const isMainModule = process.argv[1]?.includes('scrape.ts');
|
||||||
|
if (isMainModule) {
|
||||||
|
main();
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { launchPersistentContext } from 'cloakbrowser';
|
||||||
|
import { homedir } from 'os';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { mkdirSync, existsSync } from 'fs';
|
||||||
|
|
||||||
|
async function test() {
|
||||||
|
const profilePath = join(homedir(), '.cloakbrowser-profile');
|
||||||
|
if (!existsSync(profilePath)) {
|
||||||
|
mkdirSync(profilePath, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Profile path:', profilePath);
|
||||||
|
console.log('Launching CloakBrowser with full options...');
|
||||||
|
|
||||||
|
const browser = await launchPersistentContext({
|
||||||
|
headless: true,
|
||||||
|
userDataDir: profilePath,
|
||||||
|
humanize: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Browser launched');
|
||||||
|
const page = browser.pages()[0] || await browser.newPage();
|
||||||
|
console.log('Page created');
|
||||||
|
|
||||||
|
await page.goto('https://github.com', { timeout: 30000 });
|
||||||
|
console.log('Navigated to:', page.url());
|
||||||
|
console.log('Title:', await page.title());
|
||||||
|
|
||||||
|
await page.screenshot({ path: '/tmp/github-test.png' });
|
||||||
|
console.log('Screenshot saved');
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
console.log('Done');
|
||||||
|
}
|
||||||
|
|
||||||
|
test().catch(console.error);
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { launch } from 'cloakbrowser';
|
||||||
|
|
||||||
|
async function test() {
|
||||||
|
console.log('Launching CloakBrowser with minimal config...');
|
||||||
|
|
||||||
|
const browser = await launch({
|
||||||
|
headless: true,
|
||||||
|
humanize: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Browser launched');
|
||||||
|
const page = await browser.newPage();
|
||||||
|
console.log('Page created');
|
||||||
|
|
||||||
|
await page.goto('https://example.com', { timeout: 30000 });
|
||||||
|
console.log('Navigated to:', page.url());
|
||||||
|
console.log('Title:', await page.title());
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
console.log('Done');
|
||||||
|
}
|
||||||
|
|
||||||
|
test().catch(console.error);
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { launchPersistentContext } from 'cloakbrowser';
|
||||||
|
import { homedir } from 'os';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { mkdirSync, existsSync } from 'fs';
|
||||||
|
|
||||||
|
async function test() {
|
||||||
|
const profilePath = join(homedir(), '.cloakbrowser-profile');
|
||||||
|
if (!existsSync(profilePath)) {
|
||||||
|
mkdirSync(profilePath, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Profile path:', profilePath);
|
||||||
|
console.log('Launching with persistent userDataDir...');
|
||||||
|
|
||||||
|
const browser = await launchPersistentContext({
|
||||||
|
headless: true,
|
||||||
|
userDataDir: profilePath,
|
||||||
|
humanize: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Browser launched');
|
||||||
|
const page = browser.pages()[0] || await browser.newPage();
|
||||||
|
console.log('Page created');
|
||||||
|
|
||||||
|
await page.goto('https://example.com', { timeout: 30000 });
|
||||||
|
console.log('Navigated to:', page.url());
|
||||||
|
console.log('Title:', await page.title());
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
console.log('Done');
|
||||||
|
}
|
||||||
|
|
||||||
|
test().catch(console.error);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "."
|
||||||
|
},
|
||||||
|
"include": ["*.ts"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
declare module 'turndown-plugin-gfm' {
|
||||||
|
import TurndownService from 'turndown';
|
||||||
|
|
||||||
|
export function gfm(turndownService: TurndownService): void;
|
||||||
|
export function strikethrough(turndownService: TurndownService): void;
|
||||||
|
export function tables(turndownService: TurndownService): void;
|
||||||
|
export function taskListItems(turndownService: TurndownService): void;
|
||||||
|
}
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||||
|
SOURCE_DIR="${ROOT_DIR}/codex/scripts"
|
||||||
|
TARGETS=(
|
||||||
|
"claude-code"
|
||||||
|
"opencode"
|
||||||
|
"pi"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 root: $target" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
rm -rf "$target"
|
||||||
|
mkdir -p "$target"
|
||||||
|
cp -R "${source}/." "$target/"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ ! -d "$SOURCE_DIR" ]]; then
|
||||||
|
echo "Missing canonical source directory: $SOURCE_DIR" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
for variant in "${TARGETS[@]}"; do
|
||||||
|
replace_dir "$SOURCE_DIR" "${ROOT_DIR}/${variant}/scripts"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Synced web-automation scripts from codex into ${#TARGETS[@]} variant directories."
|
||||||
Reference in New Issue
Block a user