Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 26 additions & 3 deletions .agents/skills/playwright-cdp/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Core principle: **do not scrape the DOM, and do not copy a browser profile.** At

For GitHub there is nothing to build: `gh pr view <n> --json body,comments,reviews` and `gh api repos/<repo>/pulls/<n>/comments` already return everything, with credentials `gh` holds. Use those directly.

Read-only. Every endpoint used fetches data or produces a download; nothing is created, edited or deleted.
Reading is the main job and touches nothing: those endpoints only fetch data or produce a download. **`slack-post.mjs` is the one exception — it writes.** See *Posting to Slack* below before using it.

## What comes out

Expand Down Expand Up @@ -48,7 +48,7 @@ Skipping either one wastes an hour. Both were verified by failure on macOS.
scripts/agent-browser.sh # headless; opens a window only on first run
```

It launches the Chrome for Testing that ships with Playwright against a profile under `~/.cache/playwright-notion-profile`, so **the everyday browser is never touched**. The first run shows a window: sign in to Notion and Slack there once. Every run after that is headless and the session persists.
It launches the Chrome for Testing that ships with Playwright against a profile under `~/.cache/playwright-cdp-profile`, so **the everyday browser is never touched**. The first run shows a window: sign in to Notion and Slack there once. Every run after that is headless and the session persists.

| Need | Command |
|---|---|
Expand All @@ -57,7 +57,7 @@ It launches the Chrome for Testing that ships with Playwright against a profile
| Throw the profile away (loses the login) | `scripts/agent-browser.sh --reset` |
| Check CDP is up | `curl -s http://127.0.0.1:9222/json/version` |

No automation browser on the machine → `npx playwright install chromium`, or point `NOTION_BROWSER_BIN` at a Chromium-family binary.
No automation browser on the machine → `npx playwright install chromium`, or point `CDP_BROWSER_BIN` at a Chromium-family binary.

`scripts/start-browser.sh [brave|chrome|edge]` remains for the one case the owned profile cannot cover: a page reachable only from the personal profile. It **closes the user's browser** (the profile lock blocks the debug port) — say so before running it. If it reports `remote debugging requires a non-default data directory`, that build refuses CDP on its default profile dir; Brave commonly works where Chrome refuses.

Expand Down Expand Up @@ -107,6 +107,26 @@ Replies are comments: the body keeps the top-level messages, `comments.md` holds

The web client's token lives only on the `app.slack.com` origin — an `/archives/` link is a stub page that redirects to the desktop app, so the script hops origins by itself. `no localConfig_v2` / `no token for team` means that profile is not signed in to Slack: `agent-browser.sh --headed`, sign in, retry.

## Posting to Slack

`slack-post.mjs` posts a message, or a reply in a thread, as the signed-in user.

```bash
node scripts/slack-post.mjs <url> '<text>' # shows what would be posted
node scripts/slack-post.mjs <url> '<text>' --send # actually posts
```

A URL pointing at a message or thread replies in that thread; a channel URL posts a new message. Long or multi-line text: `-` reads stdin, `@file.md` reads a file, which also avoids fighting the shell over quoting.

**Without `--send` it is a dry run**: it prints the workspace, the channel name and the thread it would land in, and posts nothing. That default exists because the destination is the easy thing to get wrong — a channel id in a URL says nothing about which channel it is, and a message cannot be unsent from the notifications people already got.

Rules for the agent, not just the script:

- **Never post without the user having seen the exact text and the exact destination.** Run the dry run, show its output, wait for a yes. A previous yes does not cover the next message.
- Never post on your own initiative — only when asked to post something specific.
- It posts **as the user**, under their name. Write what they would write, not a bot announcement.
- Report the permalink the script prints, so they can check or delete it.

## Step 4 — verify

Never report success from an exit code. Each script prints its counts per document:
Expand All @@ -123,6 +143,8 @@ OK 9 comments 27 files <out>/<Name>.md
node scripts/test-doc.mjs # self-check for the renderers, needs no browser
```

A `slack-post.mjs` run is verified by its own output: a dry run ends in `DRY RUN — nothing was posted`, a real one in `POSTED <permalink>`. Never claim something was posted without that line.

## Common mistakes

| Mistake | What happens | Fix |
Expand Down Expand Up @@ -158,6 +180,7 @@ Keep the output contract: import `commentBook`, `writeDoc` and `saveAsset` from

## Warn the user before starting

- Posting to Slack is visible to everyone in the channel and cannot be unsent from their notifications.
- These are internal company documents being copied to a local disk. Whether that fits their company policy is their call, not something to assume.
- While a run is active the browser listens on a local debug port, so any local process can drive it. `agent-browser.sh --stop` when done.
- `start-browser.sh` (the fallback path only) closes their everyday browser; unsaved work in it is at risk.
6 changes: 3 additions & 3 deletions .agents/skills/playwright-cdp/scripts/agent-browser.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
# usage: ./agent-browser.sh [port] | --headed | --stop | --reset
set -uo pipefail

PROFILE="${NOTION_PROFILE_DIR:-$HOME/.cache/playwright-notion-profile}"
PROFILE="${CDP_PROFILE_DIR:-$HOME/.cache/playwright-cdp-profile}"
PORT="9222"
HEADED="${NOTION_HEADED:-0}"

Expand All @@ -27,15 +27,15 @@ esac

# Chrome for Testing ships with Playwright and is built for automation, so it
# takes CDP on a custom profile dir without the refusals a branded build makes.
BIN="${NOTION_BROWSER_BIN:-}"
BIN="${CDP_BROWSER_BIN:-}"
if [ -z "$BIN" ]; then
BIN=$(find "$HOME/Library/Caches/ms-playwright" -maxdepth 6 -type f \
-path "*Chrome for Testing.app/Contents/MacOS/*" 2>/dev/null | sort | tail -1)
fi
[ -x "$BIN" ] || {
echo "no automation browser found. Install one with:" >&2
echo " npx playwright install chromium" >&2
echo "or point NOTION_BROWSER_BIN at a Chromium-family binary." >&2
echo "or point CDP_BROWSER_BIN at a Chromium-family binary." >&2
exit 1
}

Expand Down
4 changes: 2 additions & 2 deletions .agents/skills/playwright-cdp/scripts/package.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"name": "playwright-notion-scripts",
"name": "playwright-cdp-scripts",
"private": true,
"type": "module",
"description": "Dependencies for the playwright-notion skill scripts. Run `npm install` here once. playwright-core only: the scripts attach to a running browser over CDP, so there is nothing to download.",
"description": "Dependencies for the playwright-cdp skill scripts. Run `npm install` here once. playwright-core only: the scripts attach to a running browser over CDP, so there is nothing to download.",
"dependencies": {
"playwright-core": "^1.40.0"
}
Expand Down
56 changes: 56 additions & 0 deletions .agents/skills/playwright-cdp/scripts/slack-api.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Slack access shared by the read and write scripts: which conversation a link
// points at, and the web client's own token, which every call needs alongside
// the session cookie.
// channel id and, when the link points at one message, its timestamp
export function parseUrl(u) {
const url = new URL(u);
let channel = null, ts = null;
const arch = url.pathname.match(/\/archives\/([A-Z0-9]+)(?:\/p(\d{10})(\d{6}))?/i);
if (arch) { channel = arch[1]; if (arch[2]) ts = `${arch[2]}.${arch[3]}`; }
const thread = url.pathname.match(/\/client\/[A-Z0-9]+\/([A-Z0-9]+)(?:\/thread\/[A-Z0-9]+-(\d+\.\d+))?/i);
if (thread) { channel = channel || thread[1]; ts = ts || thread[2] || null; }
ts = url.searchParams.get('thread_ts') || ts;
channel = url.searchParams.get('cid') || channel;
if (!channel) throw new Error('no channel id in URL: ' + u);
return { channel, ts };
}

export const when = (ts) => new Date(Number(String(ts).split('.')[0]) * 1000).toISOString().replace('T', ' ').slice(0, 16);

// The web client's own token, which the in-page API calls need alongside the
// session cookie. It only exists on the app.slack.com origin - a /archives/
// link is a stub page that redirects to the desktop app.
export async function readToken(page) {
const read = () => page.evaluate(() => {
const raw = localStorage.getItem('localConfig_v2');
if (!raw) return { error: 'no localConfig_v2 (not signed in to Slack in this profile?)' };
const cfg = JSON.parse(raw);
const teams = cfg.teams || {};
const fromUrl = (location.pathname.match(/\/client\/(T[A-Z0-9]+)/i) || [])[1];
const id = (fromUrl && teams[fromUrl] && fromUrl) || cfg.lastActiveTeamId || Object.keys(teams)[0];
const team = teams[id];
if (!team?.token) return { error: 'no token for team ' + id };
return { token: team.token, domain: team.domain, name: team.name };
});
let cfg = await read();
if (cfg.error && !page.url().startsWith('https://app.slack.com/')) {
await page.goto('https://app.slack.com/client', { waitUntil: 'domcontentloaded', timeout: 90000 });
await page.waitForTimeout(10000);
cfg = await read();
}
return cfg;
}

// One authenticated call, made from inside the page so the session cookie rides
// along. Slack's web API takes the token as a form field, not a header.
export async function slackApi(page, token, method, params) {
return page.evaluate(async ({ token, method, params }) => {
const fd = new FormData();
fd.append('token', token);
for (const [k, v] of Object.entries(params)) fd.append(k, String(v));
const r = await fetch('/api/' + method, { method: 'POST', body: fd, credentials: 'include' });
const j = await r.json();
if (!j.ok) throw new Error(method + ' -> ' + (j.error || 'unknown'));
return j;
}, { token, method, params });
}
69 changes: 69 additions & 0 deletions .agents/skills/playwright-cdp/scripts/slack-post.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import fs from 'fs';
import { connectCdp } from './doc.mjs';
import { parseUrl, readToken, slackApi, when } from './slack-api.mjs';

// usage: node slack-post.mjs <url> <text | - | @file> [--send] [cdp-port]
// url pointing at a message or thread -> replies in that thread
// url pointing at a channel -> posts a new message in the channel
// Without --send this only shows what would be posted and where. Posting is
// not undoable for the people who get the notification, so the default is to
// look before sending.
const args = process.argv.slice(2);
const SEND = args.includes('--send');
const rest = args.filter((a) => a !== '--send');
const [URL_ARG, TEXT_ARG, PORT = '9222'] = rest;

if (!URL_ARG || !TEXT_ARG) {
console.error('usage: node slack-post.mjs <url> <text | - | @file> [--send] [cdp-port]');
process.exit(1);
}

const text = TEXT_ARG === '-' ? fs.readFileSync(0, 'utf8')
: TEXT_ARG.startsWith('@') ? fs.readFileSync(TEXT_ARG.slice(1), 'utf8')
: TEXT_ARG;
if (!text.trim()) { console.error('refusing to post an empty message'); process.exit(1); }

const { channel, ts } = parseUrl(URL_ARG);
const browser = await connectCdp(PORT);
const page = await browser.contexts()[0].newPage();
try {
await page.goto(URL_ARG, { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.waitForTimeout(5000);
const cfg = await readToken(page);
if (cfg.error) throw new Error(cfg.error + ' — run agent-browser.sh --headed and sign in to Slack');

const host = `https://${cfg.domain}.slack.com`;
if (!page.url().startsWith(host)) {
await page.goto(`${host}/archives/${channel}`, { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.waitForTimeout(4000);
}

// name the destination in full, so a wrong channel is caught before sending
const info = await slackApi(page, cfg.token, 'conversations.info', { channel }).catch(() => null);
const c = info?.channel;
// a DM has no name, and a raw D0... id tells the reader nothing
let where = c?.name ? `#${c.name}` : channel;
if (c?.is_im) {
const u = await slackApi(page, cfg.token, 'users.info', { user: c.user }).catch(() => null);
const who = u?.user?.profile?.display_name || u?.user?.profile?.real_name || u?.user?.name || c.user;
where = `DM with @${who}`;
}
where += ts ? ` · reply in thread ${when(ts)}` : ' · new message';
console.log(`to: ${cfg.name} ${where}`);
console.log(`text: ${text.trim().split('\n').map((l) => ' ' + l).join('\n').trim()}`);

if (!SEND) {
console.log('\nDRY RUN — nothing was posted. Add --send to actually post.');
} else {
const res = await slackApi(page, cfg.token, 'chat.postMessage', {
channel, text, ...(ts ? { thread_ts: ts } : {})
});
console.log(`\nPOSTED ${host}/archives/${channel}/p${String(res.ts).replace('.', '')}`);
}
} catch (e) {
console.error('FAIL:', e.message);
process.exitCode = 1;
} finally {
await page.close().catch(() => {});
process.exit(process.exitCode || 0);
}
41 changes: 1 addition & 40 deletions .agents/skills/playwright-cdp/scripts/slack.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import path from 'path';
import { commentBook, writeDoc, saveAsset, docName, readUrls, connectCdp } from './doc.mjs';
import { parseUrl, readToken, when } from './slack-api.mjs';

// usage: node slack.mjs <url|urls-file> <out-dir> [cdp-port]
// Accepts a channel link, a message permalink, or a thread link.
Expand All @@ -10,46 +11,6 @@ const LIMIT = Number(process.env.SLACK_LIMIT || 200);
const MAX_MB = Number(process.env.SLACK_MAX_MB || 30);
const WITH_MEDIA = process.env.SLACK_MEDIA === '1';

// channel id and, when the link points at one message, its timestamp
function parseUrl(u) {
const url = new URL(u);
let channel = null, ts = null;
const arch = url.pathname.match(/\/archives\/([A-Z0-9]+)(?:\/p(\d{10})(\d{6}))?/i);
if (arch) { channel = arch[1]; if (arch[2]) ts = `${arch[2]}.${arch[3]}`; }
const thread = url.pathname.match(/\/client\/[A-Z0-9]+\/([A-Z0-9]+)(?:\/thread\/[A-Z0-9]+-(\d+\.\d+))?/i);
if (thread) { channel = channel || thread[1]; ts = ts || thread[2] || null; }
ts = url.searchParams.get('thread_ts') || ts;
channel = url.searchParams.get('cid') || channel;
if (!channel) throw new Error('no channel id in URL: ' + u);
return { channel, ts };
}

const when = (ts) => new Date(Number(String(ts).split('.')[0]) * 1000).toISOString().replace('T', ' ').slice(0, 16);

// The web client's own token, which the in-page API calls need alongside the
// session cookie. It only exists on the app.slack.com origin - a /archives/
// link is a stub page that redirects to the desktop app.
async function readToken(page) {
const read = () => page.evaluate(() => {
const raw = localStorage.getItem('localConfig_v2');
if (!raw) return { error: 'no localConfig_v2 (not signed in to Slack in this profile?)' };
const cfg = JSON.parse(raw);
const teams = cfg.teams || {};
const fromUrl = (location.pathname.match(/\/client\/(T[A-Z0-9]+)/i) || [])[1];
const id = (fromUrl && teams[fromUrl] && fromUrl) || cfg.lastActiveTeamId || Object.keys(teams)[0];
const team = teams[id];
if (!team?.token) return { error: 'no token for team ' + id };
return { token: team.token, domain: team.domain, name: team.name };
});
let cfg = await read();
if (cfg.error && !page.url().startsWith('https://app.slack.com/')) {
await page.goto('https://app.slack.com/client', { waitUntil: 'domcontentloaded', timeout: 90000 });
await page.waitForTimeout(10000);
cfg = await read();
}
return cfg;
}

// Slack virtual-scrolls, so the DOM only ever holds a few dozen messages.
// These calls run in the page: same origin, session cookie attached.
async function fetchConversation(page, { token, channel, ts, limit }) {
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ Four diagrams of that pipeline, and of how `goalrun` decides a row and proves a

| Skill | Does | Trigger |
| ----- | ---- | ------- |
| 📥 [`playwright-notion`](.agents/skills/playwright-notion/SKILL.md) | Notion → markdown through a logged-in browser, when API token and Export are both unavailable | "download these Notion pages" |
| 📥 [`playwright-cdp`](.agents/skills/playwright-cdp/SKILL.md) | Notion pages and Slack threads → markdown through a logged-in browser: body, every comment, and the attachments downloaded | "read this Notion page", "pull this Slack thread" |

### Authoring

Expand All @@ -90,7 +90,7 @@ Conventions every skill here follows, so a new one is predictable before you ope
Setup beyond cloning, where a skill needs it:

```bash
cd .agents/skills/playwright-notion/scripts && npm install # once
cd .agents/skills/playwright-cdp/scripts && npm install # once
```

## Adding a skill
Expand Down
4 changes: 2 additions & 2 deletions README.vi.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ Bốn sơ đồ về chuỗi đó, và về cách `goalrun` quyết định mộ

| Skill | Làm gì | Kích hoạt |
| ----- | ------ | --------- |
| 📥 [`playwright-notion`](.agents/skills/playwright-notion/SKILL.md) | Tải trang Notion về markdown qua browser đang đăng nhập, khi không có API token lẫn nút Export | "tải các trang Notion này về" |
| 📥 [`playwright-cdp`](.agents/skills/playwright-cdp/SKILL.md) | Trang Notion và thread Slack → markdown qua browser đang đăng nhập: nội dung, toàn bộ comment, và file đính kèm tải về | "đọc trang Notion này", "lấy thread Slack này" |

### Soạn prompt

Expand All @@ -90,7 +90,7 @@ Quy ước mà mọi skill ở đây tuân theo, để đoán được một ski
Bước cài thêm, với skill nào cần:

```bash
cd .agents/skills/playwright-notion/scripts && npm install # một lần
cd .agents/skills/playwright-cdp/scripts && npm install # một lần
```

## Thêm skill mới
Expand Down
Loading