feat(installer): improve cursor and opencode skill handling

This commit is contained in:
Stefano Fiorini
2026-04-24 02:20:06 -05:00
parent d62899308a
commit 193cd45db8
30 changed files with 3832 additions and 64 deletions
+2
View File
@@ -67,6 +67,7 @@ ai-coding-skills/
│ └── web-automation/ │ └── web-automation/
│ ├── codex/ │ ├── codex/
│ ├── claude-code/ │ ├── claude-code/
│ ├── cursor/
│ ├── opencode/ │ ├── opencode/
│ └── pi/ │ └── pi/
├── .codex/ ├── .codex/
@@ -104,6 +105,7 @@ ai-coding-skills/
| implement-plan | pi | Worktree-isolated plan execution with iterative cross-model milestone review | Ready | [IMPLEMENT-PLAN](docs/IMPLEMENT-PLAN.md) | | implement-plan | pi | Worktree-isolated plan execution with iterative cross-model milestone review | Ready | [IMPLEMENT-PLAN](docs/IMPLEMENT-PLAN.md) |
| web-automation | codex | CloakBrowser-backed browsing, scraping, auth, flow automation, and install validation | Ready | [WEB-AUTOMATION](docs/WEB-AUTOMATION.md) | | web-automation | codex | CloakBrowser-backed browsing, scraping, auth, flow automation, and install validation | Ready | [WEB-AUTOMATION](docs/WEB-AUTOMATION.md) |
| web-automation | claude-code | CloakBrowser-backed browsing, scraping, auth, flow automation, and install validation | Ready | [WEB-AUTOMATION](docs/WEB-AUTOMATION.md) | | web-automation | claude-code | CloakBrowser-backed browsing, scraping, auth, flow automation, and install validation | Ready | [WEB-AUTOMATION](docs/WEB-AUTOMATION.md) |
| web-automation | cursor | CloakBrowser-backed browsing, scraping, auth, flow automation, and install validation | Ready | [WEB-AUTOMATION](docs/WEB-AUTOMATION.md) |
| web-automation | opencode | CloakBrowser-backed browsing, scraping, auth, flow automation, and install validation | Ready | [WEB-AUTOMATION](docs/WEB-AUTOMATION.md) | | web-automation | opencode | CloakBrowser-backed browsing, scraping, auth, flow automation, and install validation | Ready | [WEB-AUTOMATION](docs/WEB-AUTOMATION.md) |
| web-automation | pi | CloakBrowser-backed browsing, scraping, auth, flow automation, and install validation | Ready | [WEB-AUTOMATION](docs/WEB-AUTOMATION.md) | | web-automation | pi | CloakBrowser-backed browsing, scraping, auth, flow automation, and install validation | Ready | [WEB-AUTOMATION](docs/WEB-AUTOMATION.md) |
+24 -5
View File
@@ -23,7 +23,21 @@ mkdir -p ~/.cursor/skills/create-plan
cp -R skills/create-plan/cursor/* ~/.cursor/skills/create-plan/ cp -R skills/create-plan/cursor/* ~/.cursor/skills/create-plan/
``` ```
Cursor variants exist for `atlassian`, `create-plan`, `do-task`, and `implement-plan`. `web-automation` currently has no Cursor variant. Cursor variants exist for `atlassian`, `create-plan`, `do-task`, `implement-plan`, and `web-automation`.
Web automation repo-local install:
```bash
mkdir -p .cursor/skills/web-automation
cp -R skills/web-automation/cursor/* .cursor/skills/web-automation/
cd .cursor/skills/web-automation/scripts
pnpm install
npx cloakbrowser install
pnpm approve-builds
pnpm rebuild better-sqlite3 esbuild
```
Global web automation installs use `~/.cursor/skills/web-automation/` instead.
## Reviewer Runtime ## Reviewer Runtime
@@ -40,14 +54,19 @@ Global uses `~/.cursor/skills/reviewer-runtime/` instead.
## Superpowers ## Superpowers
Cursor variants expect the nested Superpowers layout: Cursor can discover Superpowers from the Cursor plugin cache or from manual
repo-local/global skill roots. Prefer the plugin install when it is present;
do not also install a manual Superpowers copy, or Cursor may show each
Superpowers skill twice.
```bash ```bash
.cursor/plugins/cache/cursor-public/superpowers/<revision>/skills/<superpower>/SKILL.md
~/.cursor/plugins/cache/cursor-public/superpowers/<revision>/skills/<superpower>/SKILL.md
.cursor/skills/superpowers/skills/<superpower>/SKILL.md .cursor/skills/superpowers/skills/<superpower>/SKILL.md
~/.cursor/skills/superpowers/skills/<superpower>/SKILL.md ~/.cursor/skills/superpowers/skills/<superpower>/SKILL.md
``` ```
Recommended symlink: Manual symlink, only when the Cursor plugin is not installed:
```bash ```bash
mkdir -p .cursor/skills/superpowers mkdir -p .cursor/skills/superpowers
@@ -72,7 +91,7 @@ Repo-local scope:
```bash ```bash
cursor-agent --version cursor-agent --version
test -f .cursor/skills/create-plan/SKILL.md test -f .cursor/skills/create-plan/SKILL.md
test -f .cursor/skills/superpowers/skills/brainstorming/SKILL.md test -f .cursor/skills/superpowers/skills/brainstorming/SKILL.md || find ~/.cursor/plugins/cache/cursor-public/superpowers -path '*/skills/brainstorming/SKILL.md' -print -quit 2>/dev/null | grep -q .
``` ```
Global scope: Global scope:
@@ -80,5 +99,5 @@ Global scope:
```bash ```bash
cursor-agent --version cursor-agent --version
test -f ~/.cursor/skills/create-plan/SKILL.md test -f ~/.cursor/skills/create-plan/SKILL.md
test -f ~/.cursor/skills/superpowers/skills/brainstorming/SKILL.md test -f ~/.cursor/skills/superpowers/skills/brainstorming/SKILL.md || find ~/.cursor/plugins/cache/cursor-public/superpowers -path '*/skills/brainstorming/SKILL.md' -print -quit 2>/dev/null | grep -q .
``` ```
+1 -1
View File
@@ -50,7 +50,7 @@ The current skill matrix is:
| `create-plan` | yes | yes | yes | yes | yes | | `create-plan` | yes | yes | yes | yes | yes |
| `do-task` | yes | yes | yes | yes | yes | | `do-task` | yes | yes | yes | yes | yes |
| `implement-plan` | yes | yes | yes | yes | yes | | `implement-plan` | yes | yes | yes | yes | yes |
| `web-automation` | yes | yes | no | yes | yes | | `web-automation` | yes | yes | yes | yes | yes |
## Superpowers Handling ## Superpowers Handling
+12 -5
View File
@@ -28,13 +28,21 @@ chmod +x ~/.config/opencode/skills/reviewer-runtime/*.sh
## Superpowers ## Superpowers
OpenCode variants expect Superpowers at: OpenCode can discover Superpowers from the shared agents skill root or the
OpenCode-specific skills root:
```bash ```bash
~/.agents/skills/superpowers
~/.config/opencode/skills/superpowers ~/.config/opencode/skills/superpowers
``` ```
Example: OpenCode's native setup commonly exposes the shared agents root:
```bash
ln -s /absolute/path/to/obra/superpowers/skills ~/.agents/skills/superpowers
```
OpenCode-specific setup is also supported:
```bash ```bash
ln -s /absolute/path/to/obra/superpowers/skills ~/.config/opencode/skills/superpowers ln -s /absolute/path/to/obra/superpowers/skills ~/.config/opencode/skills/superpowers
@@ -43,9 +51,8 @@ ln -s /absolute/path/to/obra/superpowers/skills ~/.config/opencode/skills/superp
Verify: Verify:
```bash ```bash
ls -l ~/.config/opencode/skills/superpowers test -f ~/.agents/skills/superpowers/brainstorming/SKILL.md || test -f ~/.config/opencode/skills/superpowers/brainstorming/SKILL.md
test -f ~/.config/opencode/skills/superpowers/brainstorming/SKILL.md test -f ~/.agents/skills/superpowers/verification-before-completion/SKILL.md || test -f ~/.config/opencode/skills/superpowers/verification-before-completion/SKILL.md
test -f ~/.config/opencode/skills/superpowers/verification-before-completion/SKILL.md
``` ```
## OpenCode Reviewer Notes ## OpenCode Reviewer Notes
+16
View File
@@ -48,6 +48,22 @@ pnpm approve-builds
pnpm rebuild better-sqlite3 esbuild pnpm rebuild better-sqlite3 esbuild
``` ```
### Cursor
Repo-local install:
```bash
mkdir -p .cursor/skills/web-automation
cp -R skills/web-automation/cursor/* .cursor/skills/web-automation/
cd .cursor/skills/web-automation/scripts
pnpm install
npx cloakbrowser install
pnpm approve-builds
pnpm rebuild better-sqlite3 esbuild
```
Global installs use `~/.cursor/skills/web-automation/` instead.
### OpenCode ### OpenCode
```bash ```bash
+118 -13
View File
@@ -1,4 +1,4 @@
import { access, cp, lstat, mkdir, readFile, realpath, rm, stat, symlink, chmod, unlink } from "node:fs/promises"; import { access, cp, lstat, mkdir, readFile, realpath, rm, stat, symlink, chmod, unlink, readdir } from "node:fs/promises";
import { constants, existsSync } from "node:fs"; import { constants, existsSync } from "node:fs";
import { homedir } from "node:os"; import { homedir } from "node:os";
import path from "node:path"; import path from "node:path";
@@ -66,7 +66,7 @@ export const CLIENTS = {
scopes: { global: { skillsRoot: "~/.config/opencode/skills" } }, scopes: { global: { skillsRoot: "~/.config/opencode/skills" } },
variant: "opencode", variant: "opencode",
superpowers: { superpowers: {
roots: ["~/.config/opencode/skills/superpowers"], roots: ["~/.agents/skills/superpowers", "~/.config/opencode/skills/superpowers"],
layout: "flat", layout: "flat",
}, },
reviewerRuntime: { reviewerRuntime: {
@@ -130,7 +130,7 @@ export const SKILLS = {
}, },
"web-automation": { "web-automation": {
name: "web-automation", name: "web-automation",
variants: ["codex", "claude-code", "opencode", "pi"], variants: ["codex", "claude-code", "cursor", "opencode", "pi"],
requiresSuperpowers: [], requiresSuperpowers: [],
requiresReviewerRuntime: false, requiresReviewerRuntime: false,
bootstrap: "web-automation", bootstrap: "web-automation",
@@ -138,9 +138,9 @@ export const SKILLS = {
}, },
}; };
export function expandHome(value, cwd = process.cwd()) { export function expandHome(value, cwd = process.cwd(), homeDir = homedir()) {
if (!value) return value; if (!value) return value;
let expanded = value.replace(/^~(?=$|\/)/, homedir()); let expanded = value.replace(/^~(?=$|\/)/, homeDir);
if (!path.isAbsolute(expanded)) expanded = path.resolve(cwd, expanded); if (!path.isAbsolute(expanded)) expanded = path.resolve(cwd, expanded);
return expanded; return expanded;
} }
@@ -243,14 +243,57 @@ export async function detectInstalledSkills({ skillsRoot, clientId, repoRoot = p
return state; return state;
} }
export async function findInstalledSuperpowers(clientId, cwd = process.cwd()) { export async function findInstalledSuperpowers(clientId, cwd = process.cwd(), { homeDir = homedir() } = {}) {
const client = CLIENTS[clientId]; const client = CLIENTS[clientId];
const found = []; const found = new Set();
for (const root of client.superpowers.roots) { for (const root of client.superpowers.roots) {
const expanded = expandHome(root, cwd); const expanded = expandHome(root, cwd, homeDir);
if (await pathExists(expanded)) found.push(expanded); if (await pathExists(expanded)) found.add(expanded);
} }
return found; if (clientId === "claude-code") {
for (const root of await findClaudeCodeSuperpowersPluginRoots(homeDir)) found.add(root);
}
if (clientId === "cursor") {
for (const root of await findCursorSuperpowersPluginRoots(homeDir)) found.add(root);
}
return [...found];
}
async function findClaudeCodeSuperpowersPluginRoots(homeDir) {
const pluginId = "superpowers@claude-plugins-official";
const settings = await readJsonIfExists(path.join(homeDir, ".claude", "settings.json"));
if (settings?.enabledPlugins?.[pluginId] !== true) return [];
const installed = await readJsonIfExists(path.join(homeDir, ".claude", "plugins", "installed_plugins.json"));
const entries = installed?.plugins?.[pluginId];
if (!Array.isArray(entries)) return [];
const roots = [];
for (const entry of entries) {
if (!entry?.installPath) continue;
const skillsRoot = path.join(entry.installPath, "skills");
if (await pathExists(skillsRoot)) roots.push(skillsRoot);
}
return roots;
}
async function findCursorSuperpowersPluginRoots(homeDir) {
const pluginRoot = path.join(homeDir, ".cursor", "plugins", "cache", "cursor-public", "superpowers");
let entries = [];
try {
entries = await readdir(pluginRoot, { withFileTypes: true });
} catch (error) {
if (error?.code === "ENOENT") return [];
throw error;
}
const roots = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const skillsRoot = path.join(pluginRoot, entry.name, "skills");
if (await pathExists(skillsRoot)) roots.push(skillsRoot);
}
return roots.sort();
} }
function piPackageSettingsPath(scope, repoRoot) { function piPackageSettingsPath(scope, repoRoot) {
@@ -286,6 +329,19 @@ export function isBootstrapInstalled(action, target) {
return false; return false;
} }
export async function detectHelperInstallState({ source, target, files }) {
let differs = false;
for (const file of files) {
const sourceFile = path.join(source, file);
const targetFile = path.join(target, file);
if (!existsSync(targetFile)) return "not-installed";
if (!existsSync(sourceFile)) return "unknown";
const [sourceContents, targetContents] = await Promise.all([readFile(sourceFile), readFile(targetFile)]);
if (!sourceContents.equals(targetContents)) differs = true;
}
return differs ? "stale" : "installed";
}
export function piPackageCommand({ action, repoRoot, piInstallArg = "" }) { export function piPackageCommand({ action, repoRoot, piInstallArg = "" }) {
if (["install", "update", "reinstall"].includes(action)) { if (["install", "update", "reinstall"].includes(action)) {
const args = ["install"]; const args = ["install"];
@@ -311,10 +367,36 @@ export function requiredSuperpowersFor(actions) {
return [...required]; return [...required];
} }
async function buildReviewerRuntimeOperation({ clientId, scope, skillsRoot, repoRoot, action = null }) {
if (action === "skip") return null;
const helperTarget = reviewerRuntimeRoot(clientId, skillsRoot, repoRoot);
const helperSource = path.join(repoRoot, CLIENTS[clientId].reviewerRuntime.source);
const helperFiles = CLIENTS[clientId].reviewerRuntime.files;
const helperState = await detectHelperInstallState({ source: helperSource, target: helperTarget, files: helperFiles });
const effectiveAction = action || (helperState === "stale" || helperState === "unknown" ? "update" : "install");
const operation = {
kind: "helper",
helper: "reviewer-runtime",
clientId,
scope,
action: effectiveAction,
source: helperSource,
target: helperTarget,
files: helperFiles,
skillsRoot,
};
if (!action && helperState === "installed") {
operation.status = "skipped";
operation.details = "runtime helper already installed";
}
return operation;
}
export async function buildOperationPlan({ selections, repoRoot = process.cwd(), assumeYes = false, superpowersByClient = null }) { export async function buildOperationPlan({ selections, repoRoot = process.cwd(), assumeYes = false, superpowersByClient = null }) {
const operations = []; const operations = [];
const prompts = []; const prompts = [];
const reportRows = []; const reportRows = [];
const plannedHelpers = new Set();
for (const selection of selections || []) { for (const selection of selections || []) {
const { clientId, scope = "global" } = selection; const { clientId, scope = "global" } = selection;
@@ -380,6 +462,7 @@ export async function buildOperationPlan({ selections, repoRoot = process.cwd(),
} }
const installed = await detectInstalledSkills({ skillsRoot, clientId, repoRoot }); const installed = await detectInstalledSkills({ skillsRoot, clientId, repoRoot });
const actions = selection.actions || {}; const actions = selection.actions || {};
const helperActions = selection.helperActions || {};
const missingSuperpowers = requiredSuperpowersFor(actions); const missingSuperpowers = requiredSuperpowersFor(actions);
if (missingSuperpowers.length > 0) { if (missingSuperpowers.length > 0) {
@@ -391,6 +474,16 @@ export async function buildOperationPlan({ selections, repoRoot = process.cwd(),
} }
} }
for (const [helper, action] of Object.entries(helperActions)) {
if (helper !== "reviewer-runtime") continue;
const helperTarget = reviewerRuntimeRoot(clientId, skillsRoot, repoRoot);
const helperKey = `${clientId}\0${scope}\0${helperTarget}`;
if (plannedHelpers.has(helperKey)) continue;
plannedHelpers.add(helperKey);
const helperOperation = await buildReviewerRuntimeOperation({ clientId, scope, skillsRoot, repoRoot, action });
if (helperOperation) operations.push(helperOperation);
}
for (const [skillName, action] of Object.entries(actions)) { for (const [skillName, action] of Object.entries(actions)) {
const source = getSkillSource(skillName, clientId, repoRoot); const source = getSkillSource(skillName, clientId, repoRoot);
if (!source) { if (!source) {
@@ -405,10 +498,15 @@ export async function buildOperationPlan({ selections, repoRoot = process.cwd(),
operations.push({ kind: "skill", clientId, scope, skill: skillName, action, source, target, skillsRoot }); operations.push({ kind: "skill", clientId, scope, skill: skillName, action, source, target, skillsRoot });
if (["install", "update", "reinstall"].includes(action) && SKILLS[skillName].requiresReviewerRuntime) { if (["install", "update", "reinstall"].includes(action) && SKILLS[skillName].requiresReviewerRuntime) {
const helperTarget = reviewerRuntimeRoot(clientId, skillsRoot, repoRoot); const helperTarget = reviewerRuntimeRoot(clientId, skillsRoot, repoRoot);
operations.push({ kind: "helper", helper: "reviewer-runtime", clientId, scope, action: "install", source: path.join(repoRoot, CLIENTS[clientId].reviewerRuntime.source), target: helperTarget, files: CLIENTS[clientId].reviewerRuntime.files, skillsRoot }); const helperKey = `${clientId}\0${scope}\0${helperTarget}`;
if (!plannedHelpers.has(helperKey)) {
plannedHelpers.add(helperKey);
const helperOperation = await buildReviewerRuntimeOperation({ clientId, scope, skillsRoot, repoRoot });
if (helperOperation) operations.push(helperOperation);
}
} }
if (["install", "update", "reinstall"].includes(action) && SKILLS[skillName].bootstrap) { if (["install", "update", "reinstall"].includes(action) && SKILLS[skillName].bootstrap) {
operations.push({ kind: "bootstrap", clientId, scope, skill: skillName, action: SKILLS[skillName].bootstrap, target }); operations.push({ kind: "bootstrap", clientId, scope, skill: skillName, item: `${skillName} deps`, action: SKILLS[skillName].bootstrap, displayAction: "bootstrap-deps", target });
} }
} }
@@ -424,7 +522,7 @@ export async function buildOperationPlan({ selections, repoRoot = process.cwd(),
reportRows.push({ reportRows.push({
client: op.clientId, client: op.clientId,
scope: op.scope, scope: op.scope,
item: op.skill || op.helper || op.kind, item: op.item || op.skill || op.helper || op.kind,
action: op.displayAction || op.action, action: op.displayAction || op.action,
status: op.status || "planned", status: op.status || "planned",
details: op.details || op.target || "", details: op.details || op.target || "",
@@ -520,6 +618,13 @@ export async function executeOperation(op) {
return { ...op, status: "ok" }; return { ...op, status: "ok" };
} }
if (op.kind === "helper") { if (op.kind === "helper") {
if (op.action === "remove") {
await validateRemoveTarget(op.target, op.skillsRoot, { repoRoot: REPO_ROOT });
const info = existsSync(op.target) ? await lstat(op.target) : null;
if (info?.isSymbolicLink()) await unlink(op.target);
else await rm(op.target, { recursive: true, force: true });
return { ...op, status: "ok" };
}
await installHelperAllowlist(op); await installHelperAllowlist(op);
return { ...op, status: "ok" }; return { ...op, status: "ok" };
} }
+32 -3
View File
@@ -8,6 +8,7 @@ import {
CLIENTS, CLIENTS,
SKILLS, SKILLS,
buildOperationPlan, buildOperationPlan,
detectHelperInstallState,
detectInstalledClients, detectInstalledClients,
detectInstalledSkills, detectInstalledSkills,
executeOperation, executeOperation,
@@ -42,7 +43,8 @@ Answers JSON example:
{ {
"clientId": "codex", "clientId": "codex",
"scope": "global", "scope": "global",
"actions": { "create-plan": "install", "web-automation": "skip" } "actions": { "create-plan": "install", "web-automation": "skip" },
"helperActions": { "reviewer-runtime": "skip" }
} }
] ]
} }
@@ -168,7 +170,32 @@ async function interactiveAnswers({ dryRun = false } = {}) {
actions[skill] = chosen; actions[skill] = chosen;
} }
} }
selections.push({ clientId, scope, actions }); const helperActions = {};
const workflowNeedsReviewerRuntime = Object.entries(actions).some(([skill, action]) => (
["install", "update", "reinstall"].includes(action) && SKILLS[skill]?.requiresReviewerRuntime
));
const helperTarget = reviewerRuntimeRoot(clientId, scopeInfo.skillsRoot, process.cwd());
const helperState = await detectHelperInstallState({
source: path.join(process.cwd(), CLIENTS[clientId].reviewerRuntime.source),
target: helperTarget,
files: CLIENTS[clientId].reviewerRuntime.files,
});
if (workflowNeedsReviewerRuntime || helperState !== "not-installed") {
const choices = helperState === "not-installed" ? "install/skip" : "update/reinstall/remove/skip";
let defaultAction = "skip";
if (workflowNeedsReviewerRuntime && helperState === "not-installed") defaultAction = "install";
if (workflowNeedsReviewerRuntime && ["stale", "unknown"].includes(helperState)) defaultAction = "update";
const answer = await rl.question(`${clientId}/${scope}/reviewer-runtime is ${helperState}; action (${choices}) [${defaultAction}]: `);
const chosen = answer.trim() || defaultAction;
const allowed = choices.split("/");
if (!allowed.includes(chosen)) {
console.log(`Invalid action '${chosen}', using skip.`);
helperActions["reviewer-runtime"] = "skip";
} else {
helperActions["reviewer-runtime"] = chosen;
}
}
selections.push({ clientId, scope, actions, helperActions });
} }
return { selections }; return { selections };
} finally { } finally {
@@ -212,6 +239,8 @@ async function main() {
} }
} }
if (plan.operations.length === 0) return;
if (args.dryRun) { if (args.dryRun) {
console.log("\nDry-run mode: no filesystem changes performed."); console.log("\nDry-run mode: no filesystem changes performed.");
return; return;
@@ -275,7 +304,7 @@ async function main() {
const rows = results.map((op) => ({ const rows = results.map((op) => ({
client: op.clientId, client: op.clientId,
scope: op.scope, scope: op.scope,
item: op.skill || op.helper || op.kind, item: op.item || op.skill || op.helper || op.kind,
action: op.displayAction || op.action, action: op.displayAction || op.action,
status: op.status, status: op.status,
details: op.details || op.target || "", details: op.details || op.target || "",
+188 -3
View File
@@ -11,6 +11,7 @@ import {
SKILLS, SKILLS,
buildOperationPlan, buildOperationPlan,
detectInstalledSkills, detectInstalledSkills,
findInstalledSuperpowers,
getSkillSource, getSkillSource,
piPackageCommand, piPackageCommand,
parseReviewerShorthand, parseReviewerShorthand,
@@ -20,8 +21,7 @@ import {
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
test("manifest records supported variants and helper allowlists", () => { test("manifest records supported variants and helper allowlists", () => {
assert.deepEqual(SKILLS["web-automation"].variants, ["codex", "claude-code", "opencode", "pi"]); assert.deepEqual(SKILLS["web-automation"].variants, ["codex", "claude-code", "cursor", "opencode", "pi"]);
assert.equal(SKILLS["web-automation"].variants.includes("cursor"), false);
assert.deepEqual(CLIENTS.codex.reviewerRuntime.files, ["run-review.sh", "notify-telegram.sh"]); assert.deepEqual(CLIENTS.codex.reviewerRuntime.files, ["run-review.sh", "notify-telegram.sh"]);
assert.deepEqual(CLIENTS.pi.reviewerRuntime.files, ["run-review.sh", "notify-telegram.sh"]); assert.deepEqual(CLIENTS.pi.reviewerRuntime.files, ["run-review.sh", "notify-telegram.sh"]);
}); });
@@ -39,7 +39,7 @@ test("parseReviewerShorthand keeps provider-qualified model ids verbatim", () =>
}); });
test("unsupported skill variant is reported as unsupported", () => { test("unsupported skill variant is reported as unsupported", () => {
assert.equal(getSkillSource("web-automation", "cursor"), null); assert.ok(getSkillSource("web-automation", "cursor").endsWith("skills/web-automation/cursor"));
assert.ok(getSkillSource("web-automation", "pi").endsWith("pi-package/skills/web-automation")); assert.ok(getSkillSource("web-automation", "pi").endsWith("pi-package/skills/web-automation"));
}); });
@@ -75,6 +75,176 @@ test("plan install workflow skill includes reviewer-runtime and missing Superpow
} }
}); });
test("plan skips already current reviewer-runtime helper for workflow skill updates", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-helper-current-"));
try {
const repo = path.join(dir, "repo");
const install = path.join(dir, "install");
await mkdir(path.join(repo, "skills", "create-plan", "cursor"), { recursive: true });
await mkdir(path.join(repo, "skills", "do-task", "cursor"), { recursive: true });
await writeFile(path.join(repo, "skills", "create-plan", "cursor", "SKILL.md"), "---\nname: create-plan\n---\n");
await writeFile(path.join(repo, "skills", "do-task", "cursor", "SKILL.md"), "---\nname: do-task\n---\n");
await mkdir(path.join(repo, "skills", "reviewer-runtime"), { recursive: true });
await mkdir(path.join(install, "reviewer-runtime"), { recursive: true });
for (const file of CLIENTS.cursor.reviewerRuntime.files) {
await writeFile(path.join(repo, "skills", "reviewer-runtime", file), `${file}\n`);
await writeFile(path.join(install, "reviewer-runtime", file), `${file}\n`);
}
const plan = await buildOperationPlan({
selections: [{ clientId: "cursor", scope: "global", skillsRoot: install, actions: { "create-plan": "update", "do-task": "update" } }],
repoRoot: repo,
superpowersByClient: { cursor: [path.join(dir, "superpowers")] },
});
const helperRows = plan.reportRows.filter((row) => row.item === "reviewer-runtime");
assert.equal(helperRows.length, 1);
assert.equal(helperRows[0].action, "install");
assert.equal(helperRows[0].status, "skipped");
assert.match(helperRows[0].details, /already installed/);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("plan auto-updates stale reviewer-runtime helper for workflow skill updates", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-helper-stale-"));
try {
const repo = path.join(dir, "repo");
const install = path.join(dir, "install");
await mkdir(path.join(repo, "skills", "create-plan", "cursor"), { recursive: true });
await writeFile(path.join(repo, "skills", "create-plan", "cursor", "SKILL.md"), "---\nname: create-plan\n---\n");
await mkdir(path.join(repo, "skills", "reviewer-runtime"), { recursive: true });
await mkdir(path.join(install, "reviewer-runtime"), { recursive: true });
for (const file of CLIENTS.cursor.reviewerRuntime.files) {
await writeFile(path.join(repo, "skills", "reviewer-runtime", file), `${file}:new\n`);
await writeFile(path.join(install, "reviewer-runtime", file), `${file}:old\n`);
}
const plan = await buildOperationPlan({
selections: [{ clientId: "cursor", scope: "global", skillsRoot: install, actions: { "create-plan": "update" } }],
repoRoot: repo,
superpowersByClient: { cursor: [path.join(dir, "superpowers")] },
});
const helperRows = plan.reportRows.filter((row) => row.item === "reviewer-runtime");
assert.equal(helperRows.length, 1);
assert.equal(helperRows[0].action, "update");
assert.equal(helperRows[0].status, "planned");
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("plan honors explicit reviewer-runtime helper actions", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-helper-explicit-"));
try {
const repo = path.join(dir, "repo");
const install = path.join(dir, "install");
await mkdir(path.join(repo, "skills", "reviewer-runtime"), { recursive: true });
await mkdir(path.join(install, "reviewer-runtime"), { recursive: true });
for (const file of CLIENTS.cursor.reviewerRuntime.files) {
await writeFile(path.join(repo, "skills", "reviewer-runtime", file), `${file}\n`);
await writeFile(path.join(install, "reviewer-runtime", file), `${file}\n`);
}
const plan = await buildOperationPlan({
selections: [{ clientId: "cursor", scope: "global", skillsRoot: install, actions: {}, helperActions: { "reviewer-runtime": "reinstall" } }],
repoRoot: repo,
});
assert.deepEqual(plan.reportRows.map((row) => [row.item, row.action, row.status]), [
["reviewer-runtime", "reinstall", "planned"],
]);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("plan labels skill bootstrap rows as dependency rows", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-bootstrap-label-"));
try {
const repo = path.join(dir, "repo");
const install = path.join(dir, "install");
await mkdir(path.join(repo, "skills", "web-automation", "claude-code"), { recursive: true });
await writeFile(path.join(repo, "skills", "web-automation", "claude-code", "SKILL.md"), "---\nname: web-automation\n---\n");
const plan = await buildOperationPlan({
selections: [{ clientId: "claude-code", scope: "global", skillsRoot: install, actions: { "web-automation": "update" } }],
repoRoot: repo,
});
assert.deepEqual(plan.reportRows.map((row) => [row.item, row.action]), [
["web-automation", "update"],
["web-automation deps", "bootstrap-deps"],
]);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("findInstalledSuperpowers detects Claude Code Superpowers plugin installs", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-claude-superpowers-"));
try {
const installPath = path.join(dir, ".claude", "plugins", "cache", "claude-plugins-official", "superpowers", "4.2.0");
await mkdir(path.join(installPath, "skills", "brainstorming"), { recursive: true });
await writeFile(path.join(installPath, "skills", "brainstorming", "SKILL.md"), "---\nname: brainstorming\n---\n");
await mkdir(path.join(dir, ".claude", "plugins"), { recursive: true });
await writeFile(path.join(dir, ".claude", "settings.json"), JSON.stringify({
enabledPlugins: {
"superpowers@claude-plugins-official": true,
},
}));
await writeFile(path.join(dir, ".claude", "plugins", "installed_plugins.json"), JSON.stringify({
plugins: {
"superpowers@claude-plugins-official": [
{
scope: "user",
installPath,
version: "4.2.0",
},
],
},
}));
assert.deepEqual(await findInstalledSuperpowers("claude-code", process.cwd(), { homeDir: dir }), [
path.join(installPath, "skills"),
]);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("findInstalledSuperpowers detects OpenCode shared agents Superpowers installs", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-opencode-superpowers-"));
try {
const sharedRoot = path.join(dir, ".agents", "skills", "superpowers");
await mkdir(path.join(sharedRoot, "brainstorming"), { recursive: true });
await writeFile(path.join(sharedRoot, "brainstorming", "SKILL.md"), "---\nname: brainstorming\n---\n");
assert.deepEqual(await findInstalledSuperpowers("opencode", process.cwd(), { homeDir: dir }), [
sharedRoot,
]);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("findInstalledSuperpowers detects Cursor Superpowers plugin installs", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-cursor-superpowers-"));
try {
const pluginSkills = path.join(dir, ".cursor", "plugins", "cache", "cursor-public", "superpowers", "abc123", "skills");
await mkdir(path.join(pluginSkills, "brainstorming"), { recursive: true });
await writeFile(path.join(pluginSkills, "brainstorming", "SKILL.md"), "---\nname: brainstorming\n---\n");
assert.deepEqual(await findInstalledSuperpowers("cursor", process.cwd(), { homeDir: dir }), [
pluginSkills,
]);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("plan removing last workflow skill prompts for optional Superpowers removal", async () => { test("plan removing last workflow skill prompts for optional Superpowers removal", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-remove-")); const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-remove-"));
try { try {
@@ -230,6 +400,21 @@ test("cli package mode preserves package action and ignores skill narrowing", ()
assert.equal(plan.operations[0].action, "remove"); assert.equal(plan.operations[0].action, "remove");
}); });
test("cli exits without confirmation when no operations are planned", () => {
const output = execFileSync(process.execPath, [
path.join(REPO_ROOT, "scripts", "manage-skills.mjs"),
"--answers",
"/dev/stdin",
], {
cwd: REPO_ROOT,
encoding: "utf8",
input: JSON.stringify({ selections: [{ clientId: "pi", scope: "packageGlobal", action: "skip", actions: {} }] }),
});
assert.match(output, /No operations planned\./);
assert.doesNotMatch(output, /Proceed with these operations/);
assert.doesNotMatch(output, /Final report/);
});
test("validateRemoveTarget rejects paths outside the manifest root", async () => { test("validateRemoveTarget rejects paths outside the manifest root", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-safe-")); const dir = await mkdtemp(path.join(tmpdir(), "skill-manager-safe-"));
try { try {
+5 -5
View File
@@ -15,7 +15,7 @@ This skill wraps the current Superpowers flow for the Cursor Agent CLI (`cursor-
3. Review the plan iteratively with a second model/provider 3. Review the plan iteratively with a second model/provider
4. Persist a local execution package in `ai_plan/YYYY-MM-DD-<short-title>/` 4. Persist a local execution package in `ai_plan/YYYY-MM-DD-<short-title>/`
**Core principle:** Cursor Agent CLI discovers skills from `.cursor/skills/` (repo-local or `~/.cursor/skills/` global). It also reads `AGENTS.md` at the repo root for additional instructions. **Core principle:** Cursor Agent CLI discovers skills from `.cursor/skills/` (repo-local), `~/.cursor/skills/` (global), and installed Cursor plugin cache entries. It also reads `AGENTS.md` at the repo root for additional instructions.
## Prerequisite Check (MANDATORY) ## Prerequisite Check (MANDATORY)
@@ -23,7 +23,7 @@ Required:
- Cursor Agent CLI: `cursor-agent --version` (install via `curl https://cursor.com/install -fsS | bash`). The binary is `cursor-agent` (installed to `~/.local/bin/`). Some environments alias it as `cursor agent` (subcommand of the Cursor IDE CLI) — both forms work, but this skill uses `cursor-agent` throughout. - Cursor Agent CLI: `cursor-agent --version` (install via `curl https://cursor.com/install -fsS | bash`). The binary is `cursor-agent` (installed to `~/.local/bin/`). Some environments alias it as `cursor agent` (subcommand of the Cursor IDE CLI) — both forms work, but this skill uses `cursor-agent` throughout.
- `jq` (required only if using `cursor` as the reviewer CLI): `jq --version` (install via `brew install jq` or your package manager) - `jq` (required only if using `cursor` as the reviewer CLI): `jq --version` (install via `brew install jq` or your package manager)
- Superpowers repo: `https://github.com/obra/superpowers` - Superpowers repo: `https://github.com/obra/superpowers`
- Superpowers skills installed under `.cursor/skills/` (repo-local) or `~/.cursor/skills/` (global) - Superpowers skills available from the Cursor plugin cache, `.cursor/skills/` (repo-local), or `~/.cursor/skills/` (global). Do not install both the plugin and a manual Superpowers copy, or Cursor may show duplicate skill entries.
- `superpowers:brainstorming` - `superpowers:brainstorming`
- `superpowers:writing-plans` - `superpowers:writing-plans`
@@ -31,15 +31,15 @@ Verify before proceeding:
```bash ```bash
cursor-agent --version cursor-agent --version
test -f .cursor/skills/superpowers/skills/brainstorming/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/brainstorming/SKILL.md test -f .cursor/skills/superpowers/skills/brainstorming/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/brainstorming/SKILL.md || find ~/.cursor/plugins/cache/cursor-public/superpowers -path '*/skills/brainstorming/SKILL.md' -print -quit 2>/dev/null | grep -q .
test -f .cursor/skills/superpowers/skills/writing-plans/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/writing-plans/SKILL.md test -f .cursor/skills/superpowers/skills/writing-plans/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/writing-plans/SKILL.md || find ~/.cursor/plugins/cache/cursor-public/superpowers -path '*/skills/writing-plans/SKILL.md' -print -quit 2>/dev/null | grep -q .
# Only if using cursor as reviewer CLI: # Only if using cursor as reviewer CLI:
# jq --version # jq --version
``` ```
If any dependency is missing, stop and return: If any dependency is missing, stop and return:
`Missing dependency: Superpowers planning skills are required (superpowers:brainstorming, superpowers:writing-plans). Install from https://github.com/obra/superpowers and copy into .cursor/skills/ or ~/.cursor/skills/, then retry.` `Missing dependency: Superpowers planning skills are required (superpowers:brainstorming, superpowers:writing-plans). Install the Cursor Superpowers plugin or install Superpowers under .cursor/skills/ or ~/.cursor/skills/, then retry.`
## Required Skill Invocation Rules ## Required Skill Invocation Rules
+4 -3
View File
@@ -9,18 +9,19 @@ Create and maintain a local plan folder under `ai_plan/` at project root.
## Prerequisite Check (MANDATORY) ## Prerequisite Check (MANDATORY)
This OpenCode variant depends on Superpowers skills being installed via OpenCode's native skill system. This OpenCode variant depends on Superpowers skills being available through OpenCode's native skill system.
Required: Required:
- Superpowers repo: `https://github.com/obra/superpowers` - Superpowers repo: `https://github.com/obra/superpowers`
- OpenCode Superpowers skills symlink: `~/.config/opencode/skills/superpowers` - OpenCode Superpowers skills available at `~/.agents/skills/superpowers` or `~/.config/opencode/skills/superpowers`
- `superpowers/brainstorming` - `superpowers/brainstorming`
- `superpowers/writing-plans` - `superpowers/writing-plans`
Verify before proceeding: Verify before proceeding:
```bash ```bash
ls -l ~/.config/opencode/skills/superpowers test -f ~/.agents/skills/superpowers/brainstorming/SKILL.md || test -f ~/.config/opencode/skills/superpowers/brainstorming/SKILL.md
test -f ~/.agents/skills/superpowers/writing-plans/SKILL.md || test -f ~/.config/opencode/skills/superpowers/writing-plans/SKILL.md
``` ```
If dependencies are missing, stop immediately and return: If dependencies are missing, stop immediately and return:
+7 -7
View File
@@ -9,7 +9,7 @@ Execute an ad-hoc user prompt end-to-end: parse → clarify → plan (with revie
This is a single-artifact sibling of `create-plan` + `implement-plan`. Unlike `implement-plan`, `do-task` operates on one persistent `task-plan.md` (not a full milestone plan) and defaults to the **current branch** (not a worktree). This is a single-artifact sibling of `create-plan` + `implement-plan`. Unlike `implement-plan`, `do-task` operates on one persistent `task-plan.md` (not a full milestone plan) and defaults to the **current branch** (not a worktree).
**Core principle:** Cursor Agent CLI discovers skills from `.cursor/skills/` (repo-local) or `~/.cursor/skills/` (global). It also reads `AGENTS.md` at the repo root for additional instructions. **Core principle:** Cursor Agent CLI discovers skills from `.cursor/skills/` (repo-local), `~/.cursor/skills/` (global), and installed Cursor plugin cache entries. It also reads `AGENTS.md` at the repo root for additional instructions.
## Prerequisite Check (MANDATORY) ## Prerequisite Check (MANDATORY)
@@ -17,7 +17,7 @@ Required:
- Cursor Agent CLI: `cursor-agent --version` (install via `curl https://cursor.com/install -fsS | bash`). Binary is `cursor-agent`; the alias `cursor agent` also works. - Cursor Agent CLI: `cursor-agent --version` (install via `curl https://cursor.com/install -fsS | bash`). Binary is `cursor-agent`; the alias `cursor agent` also works.
- `jq` (**required** — `do-task` always parses JSON output from at least the cursor reviewer branch, and other reviewers may produce JSON). Install via `brew install jq` (macOS) or your package manager. Verify: `jq --version`. - `jq` (**required** — `do-task` always parses JSON output from at least the cursor reviewer branch, and other reviewers may produce JSON). Install via `brew install jq` (macOS) or your package manager. Verify: `jq --version`.
- Superpowers repo: `https://github.com/obra/superpowers` - Superpowers repo: `https://github.com/obra/superpowers`
- Superpowers skills installed under `.cursor/skills/` (repo-local) or `~/.cursor/skills/` (global) - Superpowers skills available from the Cursor plugin cache, `.cursor/skills/` (repo-local), or `~/.cursor/skills/` (global). Do not install both the plugin and a manual Superpowers copy, or Cursor may show duplicate skill entries.
- `superpowers:brainstorming` - `superpowers:brainstorming`
- `superpowers:test-driven-development` - `superpowers:test-driven-development`
- `superpowers:verification-before-completion` - `superpowers:verification-before-completion`
@@ -31,15 +31,15 @@ Verify before proceeding:
```bash ```bash
cursor-agent --version cursor-agent --version
jq --version jq --version
test -f .cursor/skills/superpowers/skills/brainstorming/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/brainstorming/SKILL.md test -f .cursor/skills/superpowers/skills/brainstorming/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/brainstorming/SKILL.md || find ~/.cursor/plugins/cache/cursor-public/superpowers -path '*/skills/brainstorming/SKILL.md' -print -quit 2>/dev/null | grep -q .
test -f .cursor/skills/superpowers/skills/test-driven-development/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/test-driven-development/SKILL.md test -f .cursor/skills/superpowers/skills/test-driven-development/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/test-driven-development/SKILL.md || find ~/.cursor/plugins/cache/cursor-public/superpowers -path '*/skills/test-driven-development/SKILL.md' -print -quit 2>/dev/null | grep -q .
test -f .cursor/skills/superpowers/skills/verification-before-completion/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/verification-before-completion/SKILL.md test -f .cursor/skills/superpowers/skills/verification-before-completion/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/verification-before-completion/SKILL.md || find ~/.cursor/plugins/cache/cursor-public/superpowers -path '*/skills/verification-before-completion/SKILL.md' -print -quit 2>/dev/null | grep -q .
test -f .cursor/skills/superpowers/skills/finishing-a-development-branch/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/finishing-a-development-branch/SKILL.md test -f .cursor/skills/superpowers/skills/finishing-a-development-branch/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/finishing-a-development-branch/SKILL.md || find ~/.cursor/plugins/cache/cursor-public/superpowers -path '*/skills/finishing-a-development-branch/SKILL.md' -print -quit 2>/dev/null | grep -q .
``` ```
If any required dependency is missing, stop immediately and return: If any required dependency is missing, stop immediately and return:
`Missing dependency: [specific missing item]. Install Cursor Agent CLI, jq, and Superpowers skills under .cursor/skills/ or ~/.cursor/skills/, then retry.` `Missing dependency: [specific missing item]. Install Cursor Agent CLI, jq, and the Cursor Superpowers plugin or Superpowers skills under .cursor/skills/ or ~/.cursor/skills/, then retry.`
## Required Skill Invocation Rules ## Required Skill Invocation Rules
+9 -10
View File
@@ -9,14 +9,14 @@ Execute an ad-hoc user prompt end-to-end: parse → clarify → plan (with revie
This is a single-artifact sibling of `create-plan` + `implement-plan`. Unlike `implement-plan`, `do-task` operates on one persistent `task-plan.md` (not a full milestone plan) and defaults to the **current branch** (not a worktree). This is a single-artifact sibling of `create-plan` + `implement-plan`. Unlike `implement-plan`, `do-task` operates on one persistent `task-plan.md` (not a full milestone plan) and defaults to the **current branch** (not a worktree).
**Core principle:** OpenCode loads skills through its native skill tool from `~/.config/opencode/skills/`. Sub-skill invocations use OpenCode's native mechanism — not Claude's `Skill` tool, not Codex's native-discovery patterns, not Cursor's workspace discovery. **Core principle:** OpenCode loads skills through its native skill tool. Local skills live under `~/.config/opencode/skills/`, and OpenCode can also expose shared agent skills from `~/.agents/skills/`. Sub-skill invocations use OpenCode's native mechanism — not Claude's `Skill` tool, not Cursor's workspace discovery.
## Prerequisite Check (MANDATORY) ## Prerequisite Check (MANDATORY)
Required: Required:
- OpenCode CLI: `opencode --version` (install via your package manager or `brew install opencode`). - OpenCode CLI: `opencode --version` (install via your package manager or `brew install opencode`).
- Superpowers repo: `https://github.com/obra/superpowers` - Superpowers repo: `https://github.com/obra/superpowers`
- OpenCode Superpowers skills symlink: `~/.config/opencode/skills/superpowers` - OpenCode Superpowers skills available at `~/.agents/skills/superpowers` or `~/.config/opencode/skills/superpowers`
- `superpowers/brainstorming` - `superpowers/brainstorming`
- `superpowers/test-driven-development` - `superpowers/test-driven-development`
- `superpowers/verification-before-completion` - `superpowers/verification-before-completion`
@@ -29,11 +29,10 @@ Verify before proceeding:
```bash ```bash
opencode --version opencode --version
ls -l ~/.config/opencode/skills/superpowers test -f ~/.agents/skills/superpowers/brainstorming/SKILL.md || test -f ~/.config/opencode/skills/superpowers/brainstorming/SKILL.md
test -f ~/.config/opencode/skills/superpowers/brainstorming/SKILL.md test -f ~/.agents/skills/superpowers/test-driven-development/SKILL.md || test -f ~/.config/opencode/skills/superpowers/test-driven-development/SKILL.md
test -f ~/.config/opencode/skills/superpowers/test-driven-development/SKILL.md test -f ~/.agents/skills/superpowers/verification-before-completion/SKILL.md || test -f ~/.config/opencode/skills/superpowers/verification-before-completion/SKILL.md
test -f ~/.config/opencode/skills/superpowers/verification-before-completion/SKILL.md test -f ~/.agents/skills/superpowers/finishing-a-development-branch/SKILL.md || test -f ~/.config/opencode/skills/superpowers/finishing-a-development-branch/SKILL.md
test -f ~/.config/opencode/skills/superpowers/finishing-a-development-branch/SKILL.md
``` ```
If any required dependency is missing, stop immediately and return: If any required dependency is missing, stop immediately and return:
@@ -46,7 +45,7 @@ If any required dependency is missing, stop immediately and return:
- Announce skill usage explicitly: - Announce skill usage explicitly:
- `I've read the [Skill Name] skill and I'm using it to [purpose].` - `I've read the [Skill Name] skill and I'm using it to [purpose].`
- For skills with checklists, track checklist items explicitly in conversation. - For skills with checklists, track checklist items explicitly in conversation.
- Do NOT use Claude's `Skill` tool syntax, Codex's `~/.agents/skills/` native-discovery paths, or Cursor's workspace discovery. OpenCode's skill system is independent. - Do NOT use Claude's `Skill` tool syntax or Cursor's workspace discovery. OpenCode's skill system may expose shared files from `~/.agents/skills/`, but invocation still goes through OpenCode's native skill mechanism.
## Trigger Phrase Detection ## Trigger Phrase Detection
@@ -795,7 +794,7 @@ Review History is append-only.
## Variant Hardening Notes — OpenCode ## Variant Hardening Notes — OpenCode
- Must use OpenCode's native skill tool for sub-skill invocation. Do NOT use Claude's `Skill` tool syntax or Codex's `~/.agents/skills/` paths. - Must use OpenCode's native skill tool for sub-skill invocation. Do NOT use Claude's `Skill` tool syntax. OpenCode may load shared skill files from `~/.agents/skills/`, but invocation is still OpenCode-native.
- Phase 1 includes a Bootstrap Superpowers Context step that lists installed skills and confirms `superpowers/brainstorming`, `superpowers/test-driven-development`, `superpowers/verification-before-completion`, and `superpowers/finishing-a-development-branch` are discoverable before any other phase runs. - Phase 1 includes a Bootstrap Superpowers Context step that lists installed skills and confirms `superpowers/brainstorming`, `superpowers/test-driven-development`, `superpowers/verification-before-completion`, and `superpowers/finishing-a-development-branch` are discoverable before any other phase runs.
- Helper paths are `~/.config/opencode/skills/reviewer-runtime/{run-review.sh,notify-telegram.sh}`. - Helper paths are `~/.config/opencode/skills/reviewer-runtime/{run-review.sh,notify-telegram.sh}`.
- OpenCode reviewer CLI branch (when `REVIEWER_CLI=opencode`): - OpenCode reviewer CLI branch (when `REVIEWER_CLI=opencode`):
@@ -809,7 +808,7 @@ Review History is append-only.
## Common Mistakes ## Common Mistakes
- Skipping the Bootstrap Superpowers Context step in Phase 1 (breaks native skill discovery). - Skipping the Bootstrap Superpowers Context step in Phase 1 (breaks native skill discovery).
- Using Claude `Skill` tool syntax or Codex `~/.agents/skills/` paths. - Using Claude `Skill` tool syntax, or treating shared `~/.agents/skills/` files as anything other than OpenCode-native skill entries.
- Forgetting to set `--agent plan` on opencode reviewer calls (would use the default `build` agent which can write files). - Forgetting to set `--agent plan` on opencode reviewer calls (would use the default `build` agent which can write files).
- Asking multiple clarifying questions in a single message. - Asking multiple clarifying questions in a single message.
- Skipping the per-payload secret scan because "the previous round was clean". - Skipping the per-payload secret scan because "the previous round was clean".
+7 -7
View File
@@ -16,7 +16,7 @@ This skill wraps the Superpowers execution flow for the Cursor Agent CLI (`curso
4. Review each milestone with a second model/provider 4. Review each milestone with a second model/provider
5. Commit approved milestones, merge to parent branch, and delete worktree 5. Commit approved milestones, merge to parent branch, and delete worktree
**Core principle:** Cursor Agent CLI discovers skills from `.cursor/skills/` (repo-local or `~/.cursor/skills/` global). It also reads `AGENTS.md` at the repo root for additional instructions. **Core principle:** Cursor Agent CLI discovers skills from `.cursor/skills/` (repo-local), `~/.cursor/skills/` (global), and installed Cursor plugin cache entries. It also reads `AGENTS.md` at the repo root for additional instructions.
## Prerequisite Check (MANDATORY) ## Prerequisite Check (MANDATORY)
@@ -28,7 +28,7 @@ Required:
- `milestone-plan.md` exists in plan folder - `milestone-plan.md` exists in plan folder
- `story-tracker.md` exists in plan folder - `story-tracker.md` exists in plan folder
- Git repo with worktree support: `git worktree list` - Git repo with worktree support: `git worktree list`
- Superpowers skills installed under `.cursor/skills/` (repo-local) or `~/.cursor/skills/` (global) - Superpowers skills available from the Cursor plugin cache, `.cursor/skills/` (repo-local), or `~/.cursor/skills/` (global). Do not install both the plugin and a manual Superpowers copy, or Cursor may show duplicate skill entries.
- Superpowers execution skills: - Superpowers execution skills:
- `superpowers:executing-plans` - `superpowers:executing-plans`
- `superpowers:using-git-worktrees` - `superpowers:using-git-worktrees`
@@ -39,17 +39,17 @@ Verify before proceeding:
```bash ```bash
cursor-agent --version cursor-agent --version
test -f .cursor/skills/superpowers/skills/executing-plans/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/executing-plans/SKILL.md test -f .cursor/skills/superpowers/skills/executing-plans/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/executing-plans/SKILL.md || find ~/.cursor/plugins/cache/cursor-public/superpowers -path '*/skills/executing-plans/SKILL.md' -print -quit 2>/dev/null | grep -q .
test -f .cursor/skills/superpowers/skills/using-git-worktrees/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/using-git-worktrees/SKILL.md test -f .cursor/skills/superpowers/skills/using-git-worktrees/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/using-git-worktrees/SKILL.md || find ~/.cursor/plugins/cache/cursor-public/superpowers -path '*/skills/using-git-worktrees/SKILL.md' -print -quit 2>/dev/null | grep -q .
test -f .cursor/skills/superpowers/skills/verification-before-completion/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/verification-before-completion/SKILL.md test -f .cursor/skills/superpowers/skills/verification-before-completion/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/verification-before-completion/SKILL.md || find ~/.cursor/plugins/cache/cursor-public/superpowers -path '*/skills/verification-before-completion/SKILL.md' -print -quit 2>/dev/null | grep -q .
test -f .cursor/skills/superpowers/skills/finishing-a-development-branch/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/finishing-a-development-branch/SKILL.md test -f .cursor/skills/superpowers/skills/finishing-a-development-branch/SKILL.md || test -f ~/.cursor/skills/superpowers/skills/finishing-a-development-branch/SKILL.md || find ~/.cursor/plugins/cache/cursor-public/superpowers -path '*/skills/finishing-a-development-branch/SKILL.md' -print -quit 2>/dev/null | grep -q .
# Only if using cursor as reviewer CLI: # Only if using cursor as reviewer CLI:
# jq --version # jq --version
``` ```
If any dependency is missing, stop and return: If any dependency is missing, stop and return:
`Missing dependency: [specific missing item]. Install from https://github.com/obra/superpowers and copy into .cursor/skills/ or ~/.cursor/skills/, then retry.` `Missing dependency: [specific missing item]. Install the Cursor Superpowers plugin or install Superpowers under .cursor/skills/ or ~/.cursor/skills/, then retry.`
If no plan folder exists: If no plan folder exists:
+5 -2
View File
@@ -17,7 +17,7 @@ Required:
- `milestone-plan.md` exists in plan folder - `milestone-plan.md` exists in plan folder
- `story-tracker.md` exists in plan folder - `story-tracker.md` exists in plan folder
- Git repo with worktree support: `git worktree list` - Git repo with worktree support: `git worktree list`
- OpenCode Superpowers skills symlink: `~/.config/opencode/skills/superpowers` - OpenCode Superpowers skills available at `~/.agents/skills/superpowers` or `~/.config/opencode/skills/superpowers`
- Superpowers execution skills: - Superpowers execution skills:
- `superpowers/executing-plans` - `superpowers/executing-plans`
- `superpowers/using-git-worktrees` - `superpowers/using-git-worktrees`
@@ -27,7 +27,10 @@ Required:
Verify before proceeding: Verify before proceeding:
```bash ```bash
ls -l ~/.config/opencode/skills/superpowers test -f ~/.agents/skills/superpowers/executing-plans/SKILL.md || test -f ~/.config/opencode/skills/superpowers/executing-plans/SKILL.md
test -f ~/.agents/skills/superpowers/using-git-worktrees/SKILL.md || test -f ~/.config/opencode/skills/superpowers/using-git-worktrees/SKILL.md
test -f ~/.agents/skills/superpowers/verification-before-completion/SKILL.md || test -f ~/.config/opencode/skills/superpowers/verification-before-completion/SKILL.md
test -f ~/.agents/skills/superpowers/finishing-a-development-branch/SKILL.md || test -f ~/.config/opencode/skills/superpowers/finishing-a-development-branch/SKILL.md
``` ```
If dependencies are missing, stop immediately and return: If dependencies are missing, stop immediately and return:
+112
View File
@@ -0,0 +1,112 @@
---
name: web-automation
description: Browse and scrape web pages using Playwright-compatible CloakBrowser. Use when automating web workflows, extracting rendered page content, handling authenticated sessions, or running multi-step browser flows.
---
# Web Automation with CloakBrowser (Cursor)
Automated web browsing and scraping using Playwright-compatible CloakBrowser with two execution paths:
- one-shot extraction via `extract.js`
- broader stateful automation via `auth.ts`, `browse.ts`, `flow.ts`, `scan-local-app.ts`, and `scrape.ts`
## Requirements
- Node.js 20+
- pnpm
- Network access to download the CloakBrowser binary on first use
## First-Time Setup
Repo-local install:
```bash
cd .cursor/skills/web-automation/scripts
pnpm install
npx cloakbrowser install
pnpm approve-builds
pnpm rebuild better-sqlite3 esbuild
```
Global install:
```bash
cd ~/.cursor/skills/web-automation/scripts
pnpm install
npx cloakbrowser install
pnpm approve-builds
pnpm rebuild better-sqlite3 esbuild
```
## Updating CloakBrowser
Run from the installed `scripts/` directory:
```bash
pnpm up cloakbrowser playwright-core
npx cloakbrowser install
pnpm approve-builds
pnpm rebuild better-sqlite3 esbuild
```
## Prerequisite Check (MANDATORY)
Before running automation, verify CloakBrowser and Playwright Core are installed and wired correctly.
```bash
cd .cursor/skills/web-automation/scripts || cd ~/.cursor/skills/web-automation/scripts
node check-install.js
```
If the check fails, stop and return:
"Missing dependency/config: web-automation requires `cloakbrowser` and `playwright-core` with CloakBrowser-based scripts. Run setup in this skill, then retry."
If runtime fails with missing native bindings for `better-sqlite3` or `esbuild`, run:
```bash
cd .cursor/skills/web-automation/scripts || cd ~/.cursor/skills/web-automation/scripts
pnpm approve-builds
pnpm rebuild better-sqlite3 esbuild
```
## When To Use Which Command
- Use `node extract.js "<URL>"` for a one-shot rendered fetch with JSON output.
- Use `npx tsx scrape.ts ...` when you need markdown extraction, Readability cleanup, or selector-based scraping.
- Use `npx tsx browse.ts ...`, `auth.ts`, or `flow.ts` when the task needs login handling, persistent sessions, clicks, typing, screenshots, or multi-step navigation.
- Use `npx tsx scan-local-app.ts` when you need a configurable local-app smoke pass driven by `SCAN_*` and `CLOAKBROWSER_*` environment variables.
## Quick Reference
- Install check: `node check-install.js`
- One-shot JSON extract: `node extract.js "https://example.com"`
- Browse page: `npx tsx browse.ts --url "https://example.com"`
- Scrape markdown: `npx tsx scrape.ts --url "https://example.com" --mode main --output page.md`
- Authenticate: `npx tsx auth.ts --url "https://example.com/login"`
- Natural-language flow: `npx tsx flow.ts --instruction 'go to https://example.com then click on "Login" then type "user@example.com" in #email then press enter'`
- Local app smoke scan: `SCAN_BASE_URL=http://localhost:3000 SCAN_ROUTES=/,/dashboard npx tsx scan-local-app.ts`
## Local App Smoke Scan
`scan-local-app.ts` is intentionally generic. Configure it with environment variables instead of editing the file:
- `SCAN_BASE_URL`
- `SCAN_LOGIN_PATH`
- `SCAN_USERNAME`
- `SCAN_PASSWORD`
- `SCAN_USERNAME_SELECTOR`
- `SCAN_PASSWORD_SELECTOR`
- `SCAN_SUBMIT_SELECTOR`
- `SCAN_ROUTES`
- `SCAN_REPORT_PATH`
- `SCAN_HEADLESS`
If `SCAN_USERNAME` or `SCAN_PASSWORD` are omitted, the script falls back to `CLOAKBROWSER_USERNAME` and `CLOAKBROWSER_PASSWORD`.
## Notes
- Sessions persist in CloakBrowser profile storage.
- Use `--wait` for dynamic pages.
- Use `--mode selector --selector "..."` for targeted extraction.
- `extract.js` keeps a bounded stealth/rendered fetch path without needing a long-lived automation session.
@@ -0,0 +1,575 @@
#!/usr/bin/env npx tsx
/**
* Authentication handler for web automation
* Supports generic form login and Microsoft SSO (MSAL)
*
* Usage:
* npx tsx auth.ts --url "https://example.com/login" --type form
* npx tsx auth.ts --url "https://example.com" --type msal
* npx tsx auth.ts --url "https://example.com" --type auto
*/
import { getPage, launchBrowser } from './browse.js';
import parseArgs from 'minimist';
import type { Page, BrowserContext } from 'playwright-core';
import { createInterface } from 'readline';
// Types
type AuthType = 'auto' | 'form' | 'msal';
interface AuthOptions {
url: string;
authType: AuthType;
credentials?: {
username: string;
password: string;
};
headless?: boolean;
timeout?: number;
}
interface AuthResult {
success: boolean;
finalUrl: string;
authType: AuthType;
message: string;
}
// Get credentials from environment or options
function getCredentials(options?: {
username?: string;
password?: string;
}): { username: string; password: string } | null {
const username = options?.username || process.env.CLOAKBROWSER_USERNAME;
const password = options?.password || process.env.CLOAKBROWSER_PASSWORD;
if (!username || !password) {
return null;
}
return { username, password };
}
// Prompt user for input (for MFA or credentials)
async function promptUser(question: string, hidden = false): Promise<string> {
const rl = createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
if (hidden) {
process.stdout.write(question);
// Note: This is a simple implementation. For production, use a proper hidden input library
}
rl.question(question, (answer) => {
rl.close();
resolve(answer);
});
});
}
// Detect authentication type from page
async function detectAuthType(page: Page): Promise<AuthType> {
const url = page.url();
// Check for Microsoft login
if (
url.includes('login.microsoftonline.com') ||
url.includes('login.live.com') ||
url.includes('login.windows.net')
) {
return 'msal';
}
// Check for common form login patterns
const hasLoginForm = await page.evaluate(() => {
const passwordField = document.querySelector(
'input[type="password"], input[name*="password"], input[id*="password"]'
);
const usernameField = document.querySelector(
'input[type="email"], input[type="text"][name*="user"], input[type="text"][name*="email"], input[id*="user"], input[id*="email"]'
);
return !!(passwordField && usernameField);
});
if (hasLoginForm) {
return 'form';
}
return 'auto';
}
// Handle generic form login
async function handleFormLogin(
page: Page,
credentials: { username: string; password: string },
timeout: number
): Promise<boolean> {
console.log('Attempting form login...');
// Find and fill username/email field
const usernameSelectors = [
'input[type="email"]',
'input[name*="user" i]',
'input[name*="email" i]',
'input[id*="user" i]',
'input[id*="email" i]',
'input[autocomplete="username"]',
'input[type="text"]:first-of-type',
];
let usernameField = null;
for (const selector of usernameSelectors) {
usernameField = await page.$(selector);
if (usernameField) break;
}
if (!usernameField) {
console.error('Could not find username/email field');
return false;
}
await usernameField.fill(credentials.username);
console.log('Filled username field');
// Find and fill password field
const passwordSelectors = [
'input[type="password"]',
'input[name*="password" i]',
'input[id*="password" i]',
'input[autocomplete="current-password"]',
];
let passwordField = null;
for (const selector of passwordSelectors) {
passwordField = await page.$(selector);
if (passwordField) break;
}
if (!passwordField) {
console.error('Could not find password field');
return false;
}
await passwordField.fill(credentials.password);
console.log('Filled password field');
// Check for "Remember me" checkbox and check it
const rememberCheckbox = await page.$(
'input[type="checkbox"][name*="remember" i], input[type="checkbox"][id*="remember" i]'
);
if (rememberCheckbox) {
await rememberCheckbox.check();
console.log('Checked "Remember me" checkbox');
}
// Find and click submit button
const submitSelectors = [
'button[type="submit"]',
'input[type="submit"]',
'button:has-text("Sign in")',
'button:has-text("Log in")',
'button:has-text("Login")',
'button:has-text("Submit")',
'[role="button"]:has-text("Sign in")',
];
let submitButton = null;
for (const selector of submitSelectors) {
submitButton = await page.$(selector);
if (submitButton) break;
}
if (!submitButton) {
// Try pressing Enter as fallback
await passwordField.press('Enter');
} else {
await submitButton.click();
}
console.log('Submitted login form');
// Wait for navigation or error
try {
await page.waitForNavigation({ timeout, waitUntil: 'domcontentloaded' });
return true;
} catch {
// Check if we're still on login page with error
const errorMessages = await page.$$eval(
'.error, .alert-danger, [role="alert"], .login-error',
(els) => els.map((el) => el.textContent?.trim()).filter(Boolean)
);
if (errorMessages.length > 0) {
console.error('Login error:', errorMessages.join(', '));
return false;
}
return true; // Might have succeeded without navigation
}
}
// Handle Microsoft SSO login
async function handleMsalLogin(
page: Page,
credentials: { username: string; password: string },
timeout: number
): Promise<boolean> {
console.log('Attempting Microsoft SSO login...');
const currentUrl = page.url();
// If not already on Microsoft login, wait for redirect
if (!currentUrl.includes('login.microsoftonline.com')) {
try {
await page.waitForURL('**/login.microsoftonline.com/**', { timeout: 10000 });
} catch {
console.log('Not redirected to Microsoft login');
return false;
}
}
// Wait for email input
const emailInput = await page.waitForSelector(
'input[type="email"], input[name="loginfmt"]',
{ timeout }
);
if (!emailInput) {
console.error('Could not find email input on Microsoft login');
return false;
}
// Fill email and submit
await emailInput.fill(credentials.username);
console.log('Filled email field');
const nextButton = await page.$('input[type="submit"], button[type="submit"]');
if (nextButton) {
await nextButton.click();
} else {
await emailInput.press('Enter');
}
// Wait for password page
try {
await page.waitForSelector(
'input[type="password"], input[name="passwd"]',
{ timeout }
);
} catch {
// Might be using passwordless auth or different flow
console.log('Password field not found - might be using different auth flow');
return false;
}
// Fill password
const passwordInput = await page.$('input[type="password"], input[name="passwd"]');
if (!passwordInput) {
console.error('Could not find password input');
return false;
}
await passwordInput.fill(credentials.password);
console.log('Filled password field');
// Submit
const signInButton = await page.$('input[type="submit"], button[type="submit"]');
if (signInButton) {
await signInButton.click();
} else {
await passwordInput.press('Enter');
}
// Handle "Stay signed in?" prompt
try {
const staySignedInButton = await page.waitForSelector(
'input[value="Yes"], button:has-text("Yes")',
{ timeout: 5000 }
);
if (staySignedInButton) {
await staySignedInButton.click();
console.log('Clicked "Stay signed in" button');
}
} catch {
// Prompt might not appear
}
// Check for Conditional Access Policy error
const caError = await page.$('text=Conditional Access policy');
if (caError) {
console.error('Blocked by Conditional Access Policy');
// Take screenshot for debugging
await page.screenshot({ path: 'ca-policy-error.png' });
console.log('Screenshot saved: ca-policy-error.png');
return false;
}
// Wait for redirect away from Microsoft login
try {
await page.waitForURL(
(url) => !url.href.includes('login.microsoftonline.com'),
{ timeout }
);
return true;
} catch {
return false;
}
}
// Check if user is already authenticated
async function isAuthenticated(page: Page, targetUrl: string): Promise<boolean> {
const currentUrl = page.url();
// If we're on the target URL (not a login page), we're likely authenticated
if (currentUrl.startsWith(targetUrl)) {
// Check for common login page indicators
const isLoginPage = await page.evaluate(() => {
const loginIndicators = [
'input[type="password"]',
'form[action*="login"]',
'form[action*="signin"]',
'.login-form',
'#login',
];
return loginIndicators.some((sel) => document.querySelector(sel) !== null);
});
return !isLoginPage;
}
return false;
}
// Main authentication function
export async function authenticate(options: AuthOptions): Promise<AuthResult> {
const browser = await launchBrowser({ headless: options.headless ?? true });
const page = await browser.newPage();
const timeout = options.timeout ?? 30000;
try {
// Navigate to URL
console.log(`Navigating to: ${options.url}`);
await page.goto(options.url, { timeout: 60000, waitUntil: 'domcontentloaded' });
// Check if already authenticated
if (await isAuthenticated(page, options.url)) {
return {
success: true,
finalUrl: page.url(),
authType: 'auto',
message: 'Already authenticated (session persisted from profile)',
};
}
// Get credentials
const credentials = options.credentials
? options.credentials
: getCredentials();
if (!credentials) {
// No credentials - open interactive browser
console.log('\nNo credentials provided. Opening browser for manual login...');
console.log('Please complete the login process manually.');
console.log('The session will be saved to your profile.');
// Switch to headed mode for manual login
await browser.close();
const interactiveBrowser = await launchBrowser({ headless: false });
const interactivePage = await interactiveBrowser.newPage();
await interactivePage.goto(options.url);
await promptUser('\nPress Enter when you have completed login...');
const finalUrl = interactivePage.url();
await interactiveBrowser.close();
return {
success: true,
finalUrl,
authType: 'auto',
message: 'Manual login completed - session saved to profile',
};
}
// Detect auth type if auto
let authType = options.authType;
if (authType === 'auto') {
authType = await detectAuthType(page);
console.log(`Detected auth type: ${authType}`);
}
// Handle authentication based on type
let success = false;
switch (authType) {
case 'msal':
success = await handleMsalLogin(page, credentials, timeout);
break;
case 'form':
default:
success = await handleFormLogin(page, credentials, timeout);
break;
}
const finalUrl = page.url();
return {
success,
finalUrl,
authType,
message: success
? `Authentication successful - session saved to profile`
: 'Authentication failed',
};
} finally {
await browser.close();
}
}
// Navigate to authenticated page (handles auth if needed)
export async function navigateAuthenticated(
url: string,
options?: {
credentials?: { username: string; password: string };
headless?: boolean;
}
): Promise<{ page: Page; browser: BrowserContext }> {
const { page, browser } = await getPage({ headless: options?.headless ?? true });
await page.goto(url, { timeout: 60000, waitUntil: 'domcontentloaded' });
// Check if we need to authenticate
if (!(await isAuthenticated(page, url))) {
console.log('Session expired or not authenticated. Attempting login...');
// Get credentials
const credentials = options?.credentials ?? getCredentials();
if (!credentials) {
throw new Error(
'Authentication required but no credentials provided. ' +
'Set CLOAKBROWSER_USERNAME and CLOAKBROWSER_PASSWORD environment variables.'
);
}
// Detect and handle auth
const authType = await detectAuthType(page);
let success = false;
if (authType === 'msal') {
success = await handleMsalLogin(page, credentials, 30000);
} else {
success = await handleFormLogin(page, credentials, 30000);
}
if (!success) {
await browser.close();
throw new Error('Authentication failed');
}
// Navigate back to original URL if we were redirected
if (!page.url().startsWith(url)) {
await page.goto(url, { timeout: 60000, waitUntil: 'domcontentloaded' });
}
}
return { page, browser };
}
// CLI entry point
async function main() {
const args = parseArgs(process.argv.slice(2), {
string: ['url', 'type', 'username', 'password'],
boolean: ['headless', 'help'],
default: {
type: 'auto',
headless: false, // Default to headed for auth so user can see/interact
},
alias: {
u: 'url',
t: 'type',
h: 'help',
},
});
if (args.help || !args.url) {
console.log(`
Web Authentication Handler
Usage:
npx tsx auth.ts --url <url> [options]
Options:
-u, --url <url> URL to authenticate (required)
-t, --type <type> Auth type: auto, form, or msal (default: auto)
--username <user> Username/email (or set CLOAKBROWSER_USERNAME env var)
--password <pass> Password (or set CLOAKBROWSER_PASSWORD env var)
--headless <bool> Run in headless mode (default: false for auth)
-h, --help Show this help message
Auth Types:
auto Auto-detect authentication type
form Generic username/password form
msal Microsoft SSO (login.microsoftonline.com)
Environment Variables:
CLOAKBROWSER_USERNAME Default username/email for authentication
CLOAKBROWSER_PASSWORD Default password for authentication
Examples:
# Interactive login (no credentials, opens browser)
npx tsx auth.ts --url "https://example.com/login"
# Form login with credentials
npx tsx auth.ts --url "https://example.com/login" --type form \\
--username "user@example.com" --password "secret"
# Microsoft SSO login
CLOAKBROWSER_USERNAME=user@company.com CLOAKBROWSER_PASSWORD=secret \\
npx tsx auth.ts --url "https://internal.company.com" --type msal
Notes:
- Session is saved to ~/.cloakbrowser-profile/ for persistence
- After successful auth, subsequent browses will be authenticated
- Use --headless false if you need to handle MFA manually
`);
process.exit(args.help ? 0 : 1);
}
const authType = args.type as AuthType;
if (!['auto', 'form', 'msal'].includes(authType)) {
console.error(`Invalid auth type: ${authType}. Must be auto, form, or msal.`);
process.exit(1);
}
try {
const result = await authenticate({
url: args.url,
authType,
credentials:
args.username && args.password
? { username: args.username, password: args.password }
: undefined,
headless: args.headless,
});
console.log(`\nAuthentication result:`);
console.log(` Success: ${result.success}`);
console.log(` Auth type: ${result.authType}`);
console.log(` Final URL: ${result.finalUrl}`);
console.log(` Message: ${result.message}`);
process.exit(result.success ? 0 : 1);
} catch (error) {
console.error('Error:', error instanceof Error ? error.message : error);
process.exit(1);
}
}
// Run if executed directly
const isMainModule = process.argv[1]?.includes('auth.ts');
if (isMainModule) {
main();
}
@@ -0,0 +1,188 @@
#!/usr/bin/env npx tsx
/**
* Browser launcher using CloakBrowser with persistent profile
*
* Usage:
* npx tsx browse.ts --url "https://example.com"
* npx tsx browse.ts --url "https://example.com" --screenshot --output page.png
* npx tsx browse.ts --url "https://example.com" --headless false --wait 5000
*/
import { launchPersistentContext } from 'cloakbrowser';
import { homedir } from 'os';
import { join } from 'path';
import { existsSync, mkdirSync } from 'fs';
import parseArgs from 'minimist';
import type { Page, BrowserContext } from 'playwright-core';
interface BrowseOptions {
url: string;
headless?: boolean;
screenshot?: boolean;
output?: string;
wait?: number;
timeout?: number;
interactive?: boolean;
}
interface BrowseResult {
title: string;
url: string;
screenshotPath?: string;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
const getProfilePath = (): string => {
const customPath = process.env.CLOAKBROWSER_PROFILE_PATH;
if (customPath) return customPath;
const profileDir = join(homedir(), '.cloakbrowser-profile');
if (!existsSync(profileDir)) {
mkdirSync(profileDir, { recursive: true });
}
return profileDir;
};
export async function launchBrowser(options: {
headless?: boolean;
}): Promise<BrowserContext> {
const profilePath = getProfilePath();
const envHeadless = process.env.CLOAKBROWSER_HEADLESS;
const headless = options.headless ?? (envHeadless ? envHeadless === 'true' : true);
console.log(`Using profile: ${profilePath}`);
console.log(`Headless mode: ${headless}`);
const context = await launchPersistentContext({
userDataDir: profilePath,
headless,
humanize: true,
});
return context;
}
export async function browse(options: BrowseOptions): Promise<BrowseResult> {
const browser = await launchBrowser({ headless: options.headless });
const page = browser.pages()[0] || await browser.newPage();
try {
console.log(`Navigating to: ${options.url}`);
await page.goto(options.url, {
timeout: options.timeout ?? 60000,
waitUntil: 'domcontentloaded',
});
if (options.wait) {
console.log(`Waiting ${options.wait}ms...`);
await sleep(options.wait);
}
const result: BrowseResult = {
title: await page.title(),
url: page.url(),
};
console.log(`Page title: ${result.title}`);
console.log(`Final URL: ${result.url}`);
if (options.screenshot) {
const outputPath = options.output ?? 'screenshot.png';
await page.screenshot({ path: outputPath, fullPage: true });
result.screenshotPath = outputPath;
console.log(`Screenshot saved: ${outputPath}`);
}
if (options.interactive) {
console.log('\nInteractive mode - browser will stay open.');
console.log('Press Ctrl+C to close.');
await new Promise(() => {});
}
return result;
} finally {
if (!options.interactive) {
await browser.close();
}
}
}
export async function getPage(options?: {
headless?: boolean;
}): Promise<{ page: Page; browser: BrowserContext }> {
const browser = await launchBrowser({ headless: options?.headless });
const page = browser.pages()[0] || await browser.newPage();
return { page, browser };
}
async function main() {
const args = parseArgs(process.argv.slice(2), {
string: ['url', 'output'],
boolean: ['screenshot', 'headless', 'interactive', 'help'],
default: {
headless: true,
screenshot: false,
interactive: false,
},
alias: {
u: 'url',
o: 'output',
s: 'screenshot',
h: 'help',
i: 'interactive',
},
});
if (args.help || !args.url) {
console.log(`
Web Browser with CloakBrowser
Usage:
npx tsx browse.ts --url <url> [options]
Options:
-u, --url <url> URL to navigate to (required)
-s, --screenshot Take a screenshot of the page
-o, --output <path> Output path for screenshot (default: screenshot.png)
--headless <bool> Run in headless mode (default: true)
--wait <ms> Wait time after page load in milliseconds
--timeout <ms> Navigation timeout (default: 60000)
-i, --interactive Keep browser open for manual interaction
-h, --help Show this help message
Examples:
npx tsx browse.ts --url "https://example.com"
npx tsx browse.ts --url "https://example.com" --screenshot --output page.png
npx tsx browse.ts --url "https://example.com" --headless false --interactive
Environment Variables:
CLOAKBROWSER_PROFILE_PATH Custom profile directory (default: ~/.cloakbrowser-profile/)
CLOAKBROWSER_HEADLESS Default headless mode (true/false)
`);
process.exit(args.help ? 0 : 1);
}
try {
await browse({
url: args.url,
headless: args.headless,
screenshot: args.screenshot,
output: args.output,
wait: args.wait ? parseInt(args.wait, 10) : undefined,
timeout: args.timeout ? parseInt(args.timeout, 10) : undefined,
interactive: args.interactive,
});
} catch (error) {
console.error('Error:', error instanceof Error ? error.message : error);
process.exit(1);
}
}
const isMainModule = process.argv[1]?.includes('browse.ts');
if (isMainModule) {
main();
}
@@ -0,0 +1,40 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
function fail(message, details) {
const payload = { error: message };
if (details) payload.details = details;
process.stderr.write(`${JSON.stringify(payload)}\n`);
process.exit(1);
}
async function main() {
try {
await import("cloakbrowser");
await import("playwright-core");
} catch (error) {
fail(
"Missing dependency/config: web-automation requires cloakbrowser and playwright-core.",
error instanceof Error ? error.message : String(error)
);
}
const browsePath = path.join(__dirname, "browse.ts");
const browseSource = fs.readFileSync(browsePath, "utf8");
if (!/launchPersistentContext/.test(browseSource) || !/from ['"]cloakbrowser['"]/.test(browseSource)) {
fail("browse.ts is not configured for CloakBrowser.");
}
process.stdout.write("OK: cloakbrowser + playwright-core installed\n");
process.stdout.write("OK: CloakBrowser integration detected in browse.ts\n");
}
main().catch((error) => {
fail("Install check failed.", error instanceof Error ? error.message : String(error));
});
+188
View File
@@ -0,0 +1,188 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const DEFAULT_WAIT_MS = 5000;
const MAX_WAIT_MS = 20000;
const NAV_TIMEOUT_MS = 30000;
const EXTRA_CHALLENGE_WAIT_MS = 8000;
const CONTENT_LIMIT = 12000;
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
function fail(message, details) {
const payload = { error: message };
if (details) payload.details = details;
process.stderr.write(`${JSON.stringify(payload)}\n`);
process.exit(1);
}
function parseWaitTime(raw) {
const value = Number.parseInt(raw || `${DEFAULT_WAIT_MS}`, 10);
if (!Number.isFinite(value) || value < 0) return DEFAULT_WAIT_MS;
return Math.min(value, MAX_WAIT_MS);
}
function parseTarget(rawUrl) {
if (!rawUrl) {
fail("Missing URL. Usage: node extract.js <URL>");
}
let parsed;
try {
parsed = new URL(rawUrl);
} catch (error) {
fail("Invalid URL.", error.message);
}
if (!["http:", "https:"].includes(parsed.protocol)) {
fail("Only http and https URLs are allowed.");
}
return parsed.toString();
}
function ensureParentDir(filePath) {
if (!filePath) return;
fs.mkdirSync(path.dirname(filePath), { recursive: true });
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function detectChallenge(page) {
try {
return await page.evaluate(() => {
const text = (document.body?.innerText || "").toLowerCase();
return (
text.includes("checking your browser") ||
text.includes("just a moment") ||
text.includes("verify you are human") ||
text.includes("press and hold") ||
document.querySelector('iframe[src*="challenge"]') !== null ||
document.querySelector('iframe[src*="cloudflare"]') !== null
);
});
} catch {
return false;
}
}
async function loadCloakBrowser() {
try {
return await import("cloakbrowser");
} catch (error) {
fail(
"CloakBrowser is not installed for this skill. Run pnpm install in this skill's scripts directory first.",
error.message
);
}
}
async function runWithStderrLogs(fn) {
const originalLog = console.log;
const originalError = console.error;
console.log = (...args) => process.stderr.write(`${args.join(" ")}\n`);
console.error = (...args) => process.stderr.write(`${args.join(" ")}\n`);
try {
return await fn();
} finally {
console.log = originalLog;
console.error = originalError;
}
}
async function main() {
const requestedUrl = parseTarget(process.argv[2]);
const waitTime = parseWaitTime(process.env.WAIT_TIME);
const screenshotPath = process.env.SCREENSHOT_PATH || "";
const saveHtml = process.env.SAVE_HTML === "true";
const headless = process.env.HEADLESS !== "false";
const userAgent = process.env.USER_AGENT || undefined;
const startedAt = Date.now();
const { ensureBinary, launchContext } = await loadCloakBrowser();
let context;
try {
await runWithStderrLogs(() => ensureBinary());
context = await runWithStderrLogs(() => launchContext({
headless,
userAgent,
locale: "en-US",
viewport: { width: 1440, height: 900 },
humanize: true,
}));
const page = await context.newPage();
const response = await page.goto(requestedUrl, {
waitUntil: "domcontentloaded",
timeout: NAV_TIMEOUT_MS
});
await sleep(waitTime);
let challengeDetected = await detectChallenge(page);
if (challengeDetected) {
await sleep(EXTRA_CHALLENGE_WAIT_MS);
challengeDetected = await detectChallenge(page);
}
const extracted = await page.evaluate((contentLimit) => {
const bodyText = document.body?.innerText || "";
return {
finalUrl: window.location.href,
title: document.title || "",
content: bodyText.slice(0, contentLimit),
metaDescription:
document.querySelector('meta[name="description"]')?.content ||
document.querySelector('meta[property="og:description"]')?.content ||
""
};
}, CONTENT_LIMIT);
const result = {
requestedUrl,
finalUrl: extracted.finalUrl,
title: extracted.title,
content: extracted.content,
metaDescription: extracted.metaDescription,
status: response ? response.status() : null,
challengeDetected,
elapsedSeconds: ((Date.now() - startedAt) / 1000).toFixed(2)
};
if (screenshotPath) {
ensureParentDir(screenshotPath);
await page.screenshot({ path: screenshotPath, fullPage: false, timeout: 10000 });
result.screenshot = screenshotPath;
}
if (saveHtml) {
const htmlTarget = screenshotPath
? screenshotPath.replace(/\.[^.]+$/, ".html")
: path.resolve(__dirname, `page-${Date.now()}.html`);
ensureParentDir(htmlTarget);
fs.writeFileSync(htmlTarget, await page.content());
result.htmlFile = htmlTarget;
}
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
await context.close();
} catch (error) {
if (context) {
try {
await context.close();
} catch {
// Ignore close errors after the primary failure.
}
}
fail("Scrape failed.", error.message);
}
}
main();
@@ -0,0 +1,329 @@
#!/usr/bin/env npx tsx
import parseArgs from 'minimist';
import type { Page } from 'playwright-core';
import { launchBrowser } from './browse';
type Step =
| { action: 'goto'; url: string }
| { action: 'click'; selector?: string; text?: string; role?: string; name?: string }
| { action: 'type'; selector?: string; text: string }
| { action: 'press'; key: string; selector?: string }
| { action: 'wait'; ms: number }
| { action: 'screenshot'; path: string }
| { action: 'extract'; selector: string; count?: number };
function normalizeNavigationUrl(rawUrl: string): string {
let parsed: URL;
try {
parsed = new URL(rawUrl);
} catch {
throw new Error(`Invalid navigation URL: ${rawUrl}`);
}
if (!['http:', 'https:'].includes(parsed.protocol)) {
throw new Error(`Only http and https URLs are allowed in flow steps: ${rawUrl}`);
}
return parsed.toString();
}
function normalizeKey(k: string): string {
if (!k) return 'Enter';
const lower = k.toLowerCase();
if (lower === 'enter' || lower === 'return') return 'Enter';
if (lower === 'tab') return 'Tab';
if (lower === 'escape' || lower === 'esc') return 'Escape';
return k;
}
function splitInstructions(instruction: string): string[] {
return instruction
.split(/\bthen\b|;/gi)
.map((s) => s.trim())
.filter(Boolean);
}
function parseInstruction(instruction: string): Step[] {
const parts = splitInstructions(instruction);
const steps: Step[] = [];
for (const p of parts) {
// go to https://...
const goto = p.match(/^(?:go to|open|navigate to)\s+(https?:\/\/\S+)/i);
if (goto) {
steps.push({ action: 'goto', url: normalizeNavigationUrl(goto[1]) });
continue;
}
// click on "text" or click #selector or click button "name"
const clickRole = p.match(/^click\s+(button|link|textbox|img|image|tab)\s+"([^"]+)"$/i);
if (clickRole) {
const role = clickRole[1].toLowerCase() === 'image' ? 'img' : clickRole[1].toLowerCase();
steps.push({ action: 'click', role, name: clickRole[2] });
continue;
}
const clickText = p.match(/^click(?: on)?\s+"([^"]+)"/i);
if (clickText) {
steps.push({ action: 'click', text: clickText[1] });
continue;
}
const clickSelector = p.match(/^click(?: on)?\s+(#[\w-]+|\.[\w-]+|[a-z]+\[[^\]]+\])/i);
if (clickSelector) {
steps.push({ action: 'click', selector: clickSelector[1] });
continue;
}
// type "text" [in selector]
const typeInto = p.match(/^type\s+"([^"]+)"\s+in\s+(.+)$/i);
if (typeInto) {
steps.push({ action: 'type', text: typeInto[1], selector: typeInto[2].trim() });
continue;
}
const typeOnly = p.match(/^type\s+"([^"]+)"$/i);
if (typeOnly) {
steps.push({ action: 'type', text: typeOnly[1] });
continue;
}
// press enter [in selector]
const pressIn = p.match(/^press\s+(\w+)\s+in\s+(.+)$/i);
if (pressIn) {
steps.push({ action: 'press', key: normalizeKey(pressIn[1]), selector: pressIn[2].trim() });
continue;
}
const pressOnly = p.match(/^press\s+(\w+)$/i);
if (pressOnly) {
steps.push({ action: 'press', key: normalizeKey(pressOnly[1]) });
continue;
}
// wait 2s / wait 500ms
const waitS = p.match(/^wait\s+(\d+)\s*s(?:ec(?:onds?)?)?$/i);
if (waitS) {
steps.push({ action: 'wait', ms: parseInt(waitS[1], 10) * 1000 });
continue;
}
const waitMs = p.match(/^wait\s+(\d+)\s*ms$/i);
if (waitMs) {
steps.push({ action: 'wait', ms: parseInt(waitMs[1], 10) });
continue;
}
// screenshot path
const shot = p.match(/^screenshot(?: to)?\s+(.+)$/i);
if (shot) {
steps.push({ action: 'screenshot', path: shot[1].trim() });
continue;
}
throw new Error(`Could not parse step: "${p}"`);
}
return steps;
}
function validateSteps(steps: Step[]): Step[] {
return steps.map((step) =>
step.action === 'goto'
? {
...step,
url: normalizeNavigationUrl(step.url),
}
: step
);
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function isLikelyLoginText(text: string): boolean {
return /(login|accedi|sign\s*in|entra)/i.test(text);
}
async function clickByText(page: Page, text: string): Promise<boolean> {
const patterns = [new RegExp(`^${escapeRegExp(text)}$`, 'i'), new RegExp(escapeRegExp(text), 'i')];
for (const pattern of patterns) {
const targets = [
page.getByRole('button', { name: pattern }).first(),
page.getByRole('link', { name: pattern }).first(),
page.getByText(pattern).first(),
];
for (const target of targets) {
if (await target.count()) {
try {
await target.click({ timeout: 8000 });
return true;
} catch {
// keep trying next candidate
}
}
}
}
return false;
}
async function fallbackLoginNavigation(page: Page, requestedText: string): Promise<boolean> {
if (!isLikelyLoginText(requestedText)) return false;
const current = new URL(page.url());
const candidateLinks = await page.evaluate(() => {
const loginTerms = ['login', 'accedi', 'sign in', 'entra'];
const anchors = Array.from(document.querySelectorAll('a[href], a[onclick], button[onclick]')) as Array<HTMLAnchorElement | HTMLButtonElement>;
return anchors
.map((el) => {
const text = (el.textContent || '').trim().toLowerCase();
const href = (el as HTMLAnchorElement).getAttribute('href') || '';
return { text, href };
})
.filter((x) => x.text && loginTerms.some((t) => x.text.includes(t)))
.map((x) => x.href)
.filter(Boolean);
});
// Prefer real URLs (not javascript:)
const realCandidate = candidateLinks.find((h) => /login|account\/login/i.test(h) && !h.startsWith('javascript:'));
if (realCandidate) {
const target = new URL(realCandidate, page.url()).toString();
await page.goto(target, { waitUntil: 'domcontentloaded', timeout: 60000 });
return true;
}
// Site-specific fallback for Corriere
if (/corriere\.it$/i.test(current.hostname) || /\.corriere\.it$/i.test(current.hostname)) {
await page.goto('https://www.corriere.it/account/login', {
waitUntil: 'domcontentloaded',
timeout: 60000,
});
return true;
}
return false;
}
async function typeInBestTarget(page: Page, text: string, selector?: string) {
if (selector) {
await page.locator(selector).first().click({ timeout: 10000 });
await page.locator(selector).first().fill(text);
return;
}
const loc = page.locator('input[name="q"], input[type="search"], input[type="text"], textarea').first();
await loc.click({ timeout: 10000 });
await loc.fill(text);
}
async function pressOnTarget(page: Page, key: string, selector?: string) {
if (selector) {
await page.locator(selector).first().press(key);
return;
}
await page.keyboard.press(key);
}
async function runSteps(page: Page, steps: Step[]) {
for (const step of steps) {
switch (step.action) {
case 'goto':
await page.goto(normalizeNavigationUrl(step.url), {
waitUntil: 'domcontentloaded',
timeout: 60000,
});
break;
case 'click':
if (step.selector) {
await page.locator(step.selector).first().click({ timeout: 15000 });
} else if (step.role && step.name) {
await page.getByRole(step.role as any, { name: new RegExp(escapeRegExp(step.name), 'i') }).first().click({ timeout: 15000 });
} else if (step.text) {
const clicked = await clickByText(page, step.text);
if (!clicked) {
const recovered = await fallbackLoginNavigation(page, step.text);
if (!recovered) {
throw new Error(`Could not click target text: ${step.text}`);
}
}
} else {
throw new Error('click step missing selector/text/role');
}
try {
await page.waitForLoadState('domcontentloaded', { timeout: 10000 });
} catch {
// no navigation is fine
}
break;
case 'type':
await typeInBestTarget(page, step.text, step.selector);
break;
case 'press':
await pressOnTarget(page, step.key, step.selector);
break;
case 'wait':
await page.waitForTimeout(step.ms);
break;
case 'screenshot':
await page.screenshot({ path: step.path, fullPage: true });
break;
case 'extract': {
const items = await page.locator(step.selector).allTextContents();
const out = items.slice(0, step.count ?? items.length).map((t) => t.trim()).filter(Boolean);
console.log(JSON.stringify(out, null, 2));
break;
}
default:
throw new Error('Unknown step');
}
}
}
async function main() {
const args = parseArgs(process.argv.slice(2), {
string: ['instruction', 'steps'],
boolean: ['headless', 'help'],
default: { headless: true },
alias: { i: 'instruction', s: 'steps', h: 'help' },
});
if (args.help || (!args.instruction && !args.steps)) {
console.log(`
General Web Flow Runner (CloakBrowser)
Usage:
npx tsx flow.ts --instruction "go to https://example.com then type \"hello\" then press enter"
npx tsx flow.ts --steps '[{"action":"goto","url":"https://example.com"}]'
Supported natural steps:
- go to/open/navigate to <url>
- click on "Text"
- click <css-selector>
- type "text"
- type "text" in <css-selector>
- press <key>
- press <key> in <css-selector>
- wait <N>s | wait <N>ms
- screenshot <path>
`);
process.exit(args.help ? 0 : 1);
}
const steps = validateSteps(args.steps ? JSON.parse(args.steps) : parseInstruction(args.instruction));
const browser = await launchBrowser({ headless: args.headless });
const page = await browser.newPage();
try {
await runSteps(page, steps);
console.log('Flow complete. Final URL:', page.url());
} finally {
await browser.close();
}
}
main().catch((e) => {
console.error('Error:', e instanceof Error ? e.message : e);
process.exit(1);
});
@@ -0,0 +1,36 @@
{
"name": "web-automation-scripts",
"version": "1.0.0",
"description": "Web browsing and scraping scripts using CloakBrowser",
"type": "module",
"scripts": {
"check-install": "node check-install.js",
"extract": "node extract.js",
"browse": "tsx browse.ts",
"auth": "tsx auth.ts",
"flow": "tsx flow.ts",
"scrape": "tsx scrape.ts",
"typecheck": "tsc --noEmit -p tsconfig.json",
"lint": "pnpm run typecheck && node --check check-install.js && node --check extract.js",
"fetch-browser": "npx cloakbrowser install"
},
"dependencies": {
"@mozilla/readability": "^0.5.0",
"better-sqlite3": "^12.6.2",
"cloakbrowser": "^0.3.22",
"jsdom": "^24.0.0",
"minimist": "^1.2.8",
"playwright-core": "^1.59.1",
"turndown": "^7.1.2",
"turndown-plugin-gfm": "^1.0.2"
},
"devDependencies": {
"@types/jsdom": "^21.1.6",
"@types/minimist": "^1.2.5",
"@types/turndown": "^5.0.4",
"esbuild": "0.27.0",
"tsx": "^4.7.0",
"typescript": "^5.3.0"
},
"packageManager": "pnpm@10.18.1+sha512.77a884a165cbba2d8d1c19e3b4880eee6d2fcabd0d879121e282196b80042351d5eb3ca0935fa599da1dc51265cc68816ad2bddd2a2de5ea9fdf92adbec7cd34"
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,174 @@
#!/usr/bin/env npx tsx
import { mkdirSync, writeFileSync } from 'fs';
import { dirname, resolve } from 'path';
import { getPage } from './browse.js';
type NavResult = {
requestedUrl: string;
url: string;
status: number | null;
title: string;
error?: string;
};
type RouteCheck = {
route: string;
result: NavResult;
heading: string | null;
};
const DEFAULT_BASE_URL = 'http://localhost:3000';
const DEFAULT_REPORT_PATH = resolve(process.cwd(), 'scan-local-app.md');
function env(name: string): string | undefined {
const value = process.env[name]?.trim();
return value ? value : undefined;
}
function getRoutes(baseUrl: string): string[] {
const routeList = env('SCAN_ROUTES');
if (routeList) {
return routeList
.split(',')
.map((route) => route.trim())
.filter(Boolean)
.map((route) => new URL(route, baseUrl).toString());
}
return [baseUrl];
}
async function gotoWithStatus(page: any, url: string): Promise<NavResult> {
const response = await page
.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 })
.catch((error: unknown) => ({ error }));
if (response?.error) {
return {
requestedUrl: url,
url: page.url(),
status: null,
title: await page.title().catch(() => ''),
error: String(response.error),
};
}
return {
requestedUrl: url,
url: page.url(),
status: response ? response.status() : null,
title: await page.title().catch(() => ''),
};
}
async function textOrNull(page: any, selector: string): Promise<string | null> {
const locator = page.locator(selector).first();
try {
if ((await locator.count()) === 0) return null;
const value = await locator.textContent();
return value ? value.trim().replace(/\s+/g, ' ') : null;
} catch {
return null;
}
}
async function loginIfConfigured(page: any, baseUrl: string, lines: string[]) {
const loginPath = env('SCAN_LOGIN_PATH');
const username = env('SCAN_USERNAME') ?? env('CLOAKBROWSER_USERNAME');
const password = env('SCAN_PASSWORD') ?? env('CLOAKBROWSER_PASSWORD');
const usernameSelector = env('SCAN_USERNAME_SELECTOR') ?? 'input[type="email"], input[name="email"]';
const passwordSelector = env('SCAN_PASSWORD_SELECTOR') ?? 'input[type="password"], input[name="password"]';
const submitSelector = env('SCAN_SUBMIT_SELECTOR') ?? 'button[type="submit"], input[type="submit"]';
if (!loginPath) {
lines.push('## Login');
lines.push('- Skipped: set `SCAN_LOGIN_PATH` to enable login smoke checks.');
lines.push('');
return;
}
const loginUrl = new URL(loginPath, baseUrl).toString();
lines.push('## Login');
lines.push(`- Login URL: ${loginUrl}`);
await gotoWithStatus(page, loginUrl);
if (!username || !password) {
lines.push('- Skipped: set `SCAN_USERNAME`/`SCAN_PASSWORD` or `CLOAKBROWSER_USERNAME`/`CLOAKBROWSER_PASSWORD`.');
lines.push('');
return;
}
await page.locator(usernameSelector).first().fill(username);
await page.locator(passwordSelector).first().fill(password);
await page.locator(submitSelector).first().click();
await page.waitForTimeout(2500);
lines.push(`- After submit URL: ${page.url()}`);
lines.push(`- Cookie count: ${(await page.context().cookies()).length}`);
lines.push('');
}
async function checkRoutes(page: any, baseUrl: string, lines: string[]) {
const routes = getRoutes(baseUrl);
const routeChecks: RouteCheck[] = [];
for (const url of routes) {
const result = await gotoWithStatus(page, url);
const heading = await textOrNull(page, 'h1');
routeChecks.push({
route: url,
result,
heading,
});
}
lines.push('## Route Checks');
for (const check of routeChecks) {
const relativeUrl = check.route.startsWith(baseUrl) ? check.route.slice(baseUrl.length) || '/' : check.route;
const finalPath = check.result.url.startsWith(baseUrl)
? check.result.url.slice(baseUrl.length) || '/'
: check.result.url;
const suffix = check.heading ? `, h1="${check.heading}"` : '';
const errorSuffix = check.result.error ? `, error="${check.result.error}"` : '';
lines.push(
`- ${relativeUrl} → status ${check.result.status ?? 'ERR'} (final ${finalPath})${suffix}${errorSuffix}`
);
}
lines.push('');
}
async function main() {
const baseUrl = env('SCAN_BASE_URL') ?? DEFAULT_BASE_URL;
const reportPath = resolve(env('SCAN_REPORT_PATH') ?? DEFAULT_REPORT_PATH);
const headless = (env('SCAN_HEADLESS') ?? env('CLOAKBROWSER_HEADLESS') ?? 'true') === 'true';
const { page, browser } = await getPage({ headless });
const lines: string[] = [];
lines.push('# Web Automation Scan (local)');
lines.push('');
lines.push(`- Base URL: ${baseUrl}`);
lines.push(`- Timestamp: ${new Date().toISOString()}`);
lines.push(`- Headless: ${headless}`);
lines.push(`- Report Path: ${reportPath}`);
lines.push('');
try {
await loginIfConfigured(page, baseUrl, lines);
await checkRoutes(page, baseUrl, lines);
lines.push('## Notes');
lines.push('- This generic smoke helper records route availability and top-level headings for a local app.');
lines.push('- Configure login and route coverage with `SCAN_*` environment variables.');
} finally {
await browser.close();
}
mkdirSync(dirname(reportPath), { recursive: true });
writeFileSync(reportPath, `${lines.join('\n')}\n`, 'utf-8');
console.log(`Report written to ${reportPath}`);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
@@ -0,0 +1,351 @@
#!/usr/bin/env npx tsx
/**
* Web scraper that extracts content to markdown
*
* Usage:
* npx tsx scrape.ts --url "https://example.com" --mode main
* npx tsx scrape.ts --url "https://example.com" --mode full --output page.md
* npx tsx scrape.ts --url "https://example.com" --mode selector --selector ".content"
*/
import TurndownService from 'turndown';
import * as turndownPluginGfm from 'turndown-plugin-gfm';
import { Readability } from '@mozilla/readability';
import { JSDOM } from 'jsdom';
import { writeFileSync } from 'fs';
import parseArgs from 'minimist';
import { getPage } from './browse.js';
// Types
type ScrapeMode = 'main' | 'full' | 'selector';
interface ScrapeOptions {
url: string;
mode: ScrapeMode;
selector?: string;
output?: string;
includeLinks?: boolean;
includeTables?: boolean;
includeImages?: boolean;
headless?: boolean;
wait?: number;
}
interface ScrapeResult {
title: string;
url: string;
markdown: string;
byline?: string;
excerpt?: string;
}
// Configure Turndown for markdown conversion
function createTurndownService(options: {
includeLinks?: boolean;
includeTables?: boolean;
includeImages?: boolean;
}): TurndownService {
const turndown = new TurndownService({
headingStyle: 'atx',
hr: '---',
bulletListMarker: '-',
codeBlockStyle: 'fenced',
fence: '```',
emDelimiter: '*',
strongDelimiter: '**',
linkStyle: 'inlined',
});
// Add GFM support (tables, strikethrough, task lists)
turndown.use(turndownPluginGfm.gfm);
// Custom rule for code blocks with language detection
turndown.addRule('codeBlockWithLanguage', {
filter: (node) => {
return (
node.nodeName === 'PRE' &&
node.firstChild?.nodeName === 'CODE'
);
},
replacement: (_content, node) => {
const codeNode = node.firstChild as HTMLElement;
const className = codeNode.getAttribute('class') || '';
const langMatch = className.match(/language-(\w+)/);
const lang = langMatch ? langMatch[1] : '';
const code = codeNode.textContent || '';
return `\n\n\`\`\`${lang}\n${code}\n\`\`\`\n\n`;
},
});
// Remove images if not included
if (!options.includeImages) {
turndown.addRule('removeImages', {
filter: 'img',
replacement: () => '',
});
}
// Remove links but keep text if not included
if (!options.includeLinks) {
turndown.addRule('removeLinks', {
filter: 'a',
replacement: (content) => content,
});
}
// Remove script, style, nav, footer, aside elements
turndown.remove(['script', 'style', 'nav', 'footer', 'aside', 'noscript']);
return turndown;
}
// Extract main content using Readability
function extractMainContent(html: string, url: string): {
content: string;
title: string;
byline?: string;
excerpt?: string;
} {
const dom = new JSDOM(html, { url });
const reader = new Readability(dom.window.document);
const article = reader.parse();
if (!article) {
throw new Error('Could not extract main content from page');
}
return {
content: article.content,
title: article.title,
byline: article.byline || undefined,
excerpt: article.excerpt || undefined,
};
}
// Scrape a URL and return markdown
export async function scrape(options: ScrapeOptions): Promise<ScrapeResult> {
const { page, browser } = await getPage({ headless: options.headless ?? true });
try {
// Navigate to URL
console.log(`Navigating to: ${options.url}`);
await page.goto(options.url, {
timeout: 60000,
waitUntil: 'domcontentloaded',
});
// Wait if specified
if (options.wait) {
console.log(`Waiting ${options.wait}ms for dynamic content...`);
await page.waitForTimeout(options.wait);
}
const pageTitle = await page.title();
const pageUrl = page.url();
let html: string;
let title = pageTitle;
let byline: string | undefined;
let excerpt: string | undefined;
// Get HTML based on mode
switch (options.mode) {
case 'main': {
// Get full page HTML and extract with Readability
const fullHtml = await page.content();
const extracted = extractMainContent(fullHtml, pageUrl);
html = extracted.content;
title = extracted.title || pageTitle;
byline = extracted.byline;
excerpt = extracted.excerpt;
break;
}
case 'selector': {
if (!options.selector) {
throw new Error('Selector mode requires --selector option');
}
const element = await page.$(options.selector);
if (!element) {
throw new Error(`Selector not found: ${options.selector}`);
}
html = await element.innerHTML();
break;
}
case 'full':
default: {
// Get body content, excluding common non-content elements
html = await page.evaluate(() => {
// Remove common non-content elements
const selectorsToRemove = [
'script', 'style', 'noscript', 'iframe',
'nav', 'header', 'footer', '.cookie-banner',
'.advertisement', '.ads', '#ads', '.social-share',
'.comments', '#comments', '.sidebar'
];
selectorsToRemove.forEach(selector => {
document.querySelectorAll(selector).forEach(el => el.remove());
});
return document.body.innerHTML;
});
break;
}
}
// Convert to markdown
const turndown = createTurndownService({
includeLinks: options.includeLinks ?? true,
includeTables: options.includeTables ?? true,
includeImages: options.includeImages ?? false,
});
let markdown = turndown.turndown(html);
// Add title as H1 if not already present
if (!markdown.startsWith('# ')) {
markdown = `# ${title}\n\n${markdown}`;
}
// Add metadata header
const metadataLines = [
`<!-- Scraped from: ${pageUrl} -->`,
byline ? `<!-- Author: ${byline} -->` : null,
excerpt ? `<!-- Excerpt: ${excerpt} -->` : null,
`<!-- Scraped at: ${new Date().toISOString()} -->`,
'',
].filter(Boolean);
markdown = metadataLines.join('\n') + '\n' + markdown;
// Clean up excessive whitespace
markdown = markdown
.replace(/\n{4,}/g, '\n\n\n')
.replace(/[ \t]+$/gm, '')
.trim();
const result: ScrapeResult = {
title,
url: pageUrl,
markdown,
byline,
excerpt,
};
// Save to file if output specified
if (options.output) {
writeFileSync(options.output, markdown, 'utf-8');
console.log(`Markdown saved to: ${options.output}`);
}
return result;
} finally {
await browser.close();
}
}
// CLI entry point
async function main() {
const args = parseArgs(process.argv.slice(2), {
string: ['url', 'mode', 'selector', 'output'],
boolean: ['headless', 'links', 'tables', 'images', 'help'],
default: {
mode: 'main',
headless: true,
links: true,
tables: true,
images: false,
},
alias: {
u: 'url',
m: 'mode',
s: 'selector',
o: 'output',
h: 'help',
},
});
if (args.help || !args.url) {
console.log(`
Web Scraper - Extract content to Markdown
Usage:
npx tsx scrape.ts --url <url> [options]
Options:
-u, --url <url> URL to scrape (required)
-m, --mode <mode> Scrape mode: main, full, or selector (default: main)
-s, --selector <sel> CSS selector for selector mode
-o, --output <path> Output file path for markdown
--headless <bool> Run in headless mode (default: true)
--wait <ms> Wait time for dynamic content
--links Include links in output (default: true)
--tables Include tables in output (default: true)
--images Include images in output (default: false)
-h, --help Show this help message
Scrape Modes:
main Extract main article content using Readability (best for articles)
full Full page content with common elements removed
selector Extract specific element by CSS selector
Examples:
npx tsx scrape.ts --url "https://docs.example.com/guide" --mode main
npx tsx scrape.ts --url "https://example.com" --mode full --output page.md
npx tsx scrape.ts --url "https://example.com" --mode selector --selector ".api-docs"
npx tsx scrape.ts --url "https://example.com" --mode main --no-links --output clean.md
Output Format:
- GitHub Flavored Markdown (tables, strikethrough, task lists)
- Proper heading hierarchy
- Code blocks with language detection
- Metadata comments at top (source URL, date)
`);
process.exit(args.help ? 0 : 1);
}
const mode = args.mode as ScrapeMode;
if (!['main', 'full', 'selector'].includes(mode)) {
console.error(`Invalid mode: ${mode}. Must be main, full, or selector.`);
process.exit(1);
}
try {
const result = await scrape({
url: args.url,
mode,
selector: args.selector,
output: args.output,
includeLinks: args.links,
includeTables: args.tables,
includeImages: args.images,
headless: args.headless,
wait: args.wait ? parseInt(args.wait, 10) : undefined,
});
// Print result summary
console.log(`\nScrape complete:`);
console.log(` Title: ${result.title}`);
console.log(` URL: ${result.url}`);
if (result.byline) console.log(` Author: ${result.byline}`);
console.log(` Markdown length: ${result.markdown.length} chars`);
// Print markdown if not saved to file
if (!args.output) {
console.log('\n--- Markdown Output ---\n');
console.log(result.markdown);
}
} catch (error) {
console.error('Error:', error instanceof Error ? error.message : error);
process.exit(1);
}
}
// Run if executed directly
const isMainModule = process.argv[1]?.includes('scrape.ts');
if (isMainModule) {
main();
}
@@ -0,0 +1,36 @@
import { launchPersistentContext } from 'cloakbrowser';
import { homedir } from 'os';
import { join } from 'path';
import { mkdirSync, existsSync } from 'fs';
async function test() {
const profilePath = join(homedir(), '.cloakbrowser-profile');
if (!existsSync(profilePath)) {
mkdirSync(profilePath, { recursive: true });
}
console.log('Profile path:', profilePath);
console.log('Launching CloakBrowser with full options...');
const browser = await launchPersistentContext({
headless: true,
userDataDir: profilePath,
humanize: true,
});
console.log('Browser launched');
const page = browser.pages()[0] || await browser.newPage();
console.log('Page created');
await page.goto('https://github.com', { timeout: 30000 });
console.log('Navigated to:', page.url());
console.log('Title:', await page.title());
await page.screenshot({ path: '/tmp/github-test.png' });
console.log('Screenshot saved');
await browser.close();
console.log('Done');
}
test().catch(console.error);
@@ -0,0 +1,23 @@
import { launch } from 'cloakbrowser';
async function test() {
console.log('Launching CloakBrowser with minimal config...');
const browser = await launch({
headless: true,
humanize: true,
});
console.log('Browser launched');
const page = await browser.newPage();
console.log('Page created');
await page.goto('https://example.com', { timeout: 30000 });
console.log('Navigated to:', page.url());
console.log('Title:', await page.title());
await browser.close();
console.log('Done');
}
test().catch(console.error);
@@ -0,0 +1,33 @@
import { launchPersistentContext } from 'cloakbrowser';
import { homedir } from 'os';
import { join } from 'path';
import { mkdirSync, existsSync } from 'fs';
async function test() {
const profilePath = join(homedir(), '.cloakbrowser-profile');
if (!existsSync(profilePath)) {
mkdirSync(profilePath, { recursive: true });
}
console.log('Profile path:', profilePath);
console.log('Launching with persistent userDataDir...');
const browser = await launchPersistentContext({
headless: true,
userDataDir: profilePath,
humanize: true,
});
console.log('Browser launched');
const page = browser.pages()[0] || await browser.newPage();
console.log('Page created');
await page.goto('https://example.com', { timeout: 30000 });
console.log('Navigated to:', page.url());
console.log('Title:', await page.title());
await browser.close();
console.log('Done');
}
test().catch(console.error);
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"outDir": "./dist",
"rootDir": "."
},
"include": ["*.ts"],
"exclude": ["node_modules", "dist"]
}
@@ -0,0 +1,8 @@
declare module 'turndown-plugin-gfm' {
import TurndownService from 'turndown';
export function gfm(turndownService: TurndownService): void;
export function strikethrough(turndownService: TurndownService): void;
export function tables(turndownService: TurndownService): void;
export function taskListItems(turndownService: TurndownService): void;
}
@@ -5,6 +5,7 @@ ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
SOURCE_DIR="${ROOT_DIR}/codex/scripts" SOURCE_DIR="${ROOT_DIR}/codex/scripts"
TARGETS=( TARGETS=(
"claude-code" "claude-code"
"cursor"
"opencode" "opencode"
"pi" "pi"
) )