pi-web-toolkit 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +89 -43
- package/docs/agents/domain.md +51 -0
- package/docs/agents/issue-tracker.md +22 -0
- package/docs/agents/triage-labels.md +15 -0
- package/docs/guide.md +1 -1
- package/docs/tools.md +6 -2
- package/extensions/utils/agent-browser.ts +179 -0
- package/extensions/utils/cli-runner.ts +108 -0
- package/extensions/utils/content-preview.ts +493 -0
- package/extensions/utils/output-sink.ts +67 -0
- package/extensions/utils/render-helpers.ts +77 -0
- package/extensions/utils/scrapling.ts +39 -24
- package/extensions/utils/tool-factory.ts +79 -0
- package/extensions/web_batch_fetch.ts +155 -47
- package/extensions/web_browse.ts +158 -256
- package/extensions/web_fetch.ts +83 -42
- package/extensions/web_search.ts +140 -56
- package/package.json +9 -1
package/README.md
CHANGED
|
@@ -1,79 +1,125 @@
|
|
|
1
1
|
# pi-web-toolkit
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/pi-web-toolkit)
|
|
4
|
+
[](https://github.com/Wade11s/pi-web-toolkit/actions)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+

|
|
7
|
+
|
|
8
|
+
**100% open-source. Zero API keys. Zero fees.**
|
|
9
|
+
|
|
10
|
+
Web research toolkit for [pi](https://pi.dev) agents. Search via SearXNG, fetch static pages with scrapling, browse interactively via agent-browser, and batch-read sources in parallel. All self-hosted, all local, all free — with built-in truncation safety and LLM-optimized prompt guidelines.
|
|
4
11
|
|
|
5
12
|
## Features
|
|
6
13
|
|
|
7
|
-
| Tool | Purpose |
|
|
8
|
-
|
|
9
|
-
| **`web_search`** | Search the web
|
|
10
|
-
| **`web_fetch`** | Fetch a single static page as clean markdown |
|
|
11
|
-
| **`
|
|
12
|
-
| **`
|
|
14
|
+
| Tool | Backend | Purpose | Current Limit |
|
|
15
|
+
|------|---------|---------|---------------|
|
|
16
|
+
| **`web_search`** | [SearXNG](https://github.com/searxng/searxng) | Search the web with scored, ranked results from multiple engines — always the first step in web research | 20 results (max 60, auto-pages up to 3 pages) |
|
|
17
|
+
| **`web_fetch`** | [scrapling](https://github.com/D4Vinci/Scrapling) | Fetch a single static page as clean markdown | — |
|
|
18
|
+
| **`web_batch_fetch`** | [scrapling](https://github.com/D4Vinci/Scrapling) | Fetch 2–15 pages in parallel for research synthesis | 3 concurrent (max 5) |
|
|
19
|
+
| **`web_browse`** | [agent-browser](https://github.com/vercel-labs/agent-browser) | Interact with a page (click, scroll, fill) then extract content | 25 actions |
|
|
13
20
|
|
|
14
|
-
##
|
|
21
|
+
## Quick Start
|
|
15
22
|
|
|
16
|
-
###
|
|
23
|
+
### 1. Install external dependencies
|
|
17
24
|
|
|
18
25
|
```bash
|
|
19
|
-
|
|
26
|
+
# SearXNG (for search)
|
|
27
|
+
docker run -d --name searxng -p 8080:8080 -v searxng:/etc/searxng searxng/searxng
|
|
28
|
+
export SEARXNG_URL="http://localhost:8080"
|
|
29
|
+
|
|
30
|
+
# scrapling (for fetch & batch fetch)
|
|
31
|
+
uv tool install "scrapling[all]"
|
|
32
|
+
scrapling install
|
|
33
|
+
|
|
34
|
+
# agent-browser (for browse)
|
|
35
|
+
npm i -g agent-browser && agent-browser install
|
|
20
36
|
```
|
|
21
37
|
|
|
22
|
-
|
|
38
|
+
**Verify dependencies:**
|
|
39
|
+
```bash
|
|
40
|
+
# SearXNG
|
|
41
|
+
curl -s "$SEARXNG_URL" | head
|
|
42
|
+
|
|
43
|
+
# scrapling
|
|
44
|
+
scrapling --help
|
|
23
45
|
|
|
46
|
+
# agent-browser
|
|
47
|
+
agent-browser doctor
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### 2. Install the extension
|
|
51
|
+
#### From npm
|
|
52
|
+
```bash
|
|
53
|
+
pi install npm:pi-web-toolkit
|
|
54
|
+
```
|
|
55
|
+
#### From GitHub
|
|
24
56
|
```bash
|
|
25
57
|
pi install git:github.com/Wade11s/pi-web-toolkit
|
|
26
58
|
```
|
|
27
59
|
|
|
28
|
-
##
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
# recommended: install scrapling via uv
|
|
42
|
-
uv tool install "scrapling[all]"
|
|
43
|
-
scrapling install
|
|
44
|
-
```
|
|
45
|
-
- **agent-browser** — for `web_browse`
|
|
46
|
-
```bash
|
|
47
|
-
npm i -g agent-browser && agent-browser install
|
|
48
|
-
```
|
|
49
|
-
Verify installation:
|
|
50
|
-
```bash
|
|
51
|
-
agent-browser doctor
|
|
52
|
-
```
|
|
60
|
+
## Configuration
|
|
61
|
+
|
|
62
|
+
All tools are configured via **environment variables** at runtime — no rebuild or restart required.
|
|
63
|
+
|
|
64
|
+
| Variable | Default | Used By | Description |
|
|
65
|
+
|----------|---------|---------|-------------|
|
|
66
|
+
| `SEARXNG_URL` | `http://localhost:8080` | `web_search` | Your SearXNG instance endpoint |
|
|
67
|
+
|
|
68
|
+
Set before starting pi:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
export SEARXNG_URL="https://searxng.example.com"
|
|
72
|
+
```
|
|
53
73
|
|
|
54
74
|
## Project Structure
|
|
55
75
|
|
|
56
76
|
```
|
|
57
77
|
pi-web-toolkit/
|
|
58
78
|
├── extensions/
|
|
79
|
+
│ ├── index.ts # Unified entry point — registers all 4 tools
|
|
59
80
|
│ ├── utils/
|
|
60
|
-
│ │
|
|
61
|
-
│
|
|
62
|
-
│ ├──
|
|
63
|
-
│ ├──
|
|
64
|
-
│
|
|
81
|
+
│ │ ├── scrapling.ts # Reusable scrapling CLI wrapper (shared by fetch + batch)
|
|
82
|
+
│ │ └── agent-browser.ts # agent-browser CLI wrapper (shared by web_browse)
|
|
83
|
+
│ ├── web_search.ts # SearXNG search tool
|
|
84
|
+
│ ├── web_fetch.ts # Single-page scrapling fetcher
|
|
85
|
+
│ ├── web_batch_fetch.ts # Parallel scrapling fetcher
|
|
86
|
+
│ └── web_browse.ts # Interactive browser automation (agent-browser)
|
|
65
87
|
├── docs/
|
|
66
|
-
│ ├── tools.md
|
|
67
|
-
│ └── guide.md
|
|
88
|
+
│ ├── tools.md # Full parameter specs
|
|
89
|
+
│ └── guide.md # Decision tree & tool comparison
|
|
90
|
+
├── CHANGELOG.md
|
|
68
91
|
├── package.json
|
|
69
92
|
├── README.md
|
|
70
93
|
└── LICENSE
|
|
71
94
|
```
|
|
72
95
|
|
|
96
|
+
**Design principles:**
|
|
97
|
+
- **Unified registration** — `index.ts` is the single source of truth for what pi loads.
|
|
98
|
+
- **Shared utilities** — `utils/scrapling.ts` and `utils/agent-browser.ts` encapsulate the CLI wrappers and fallback logic; tool files import only from `utils/`, never from each other.
|
|
99
|
+
- **Per-tool isolation** — each tool owns its own schema, execute logic, and TUI renderer; no cross-imports except via `utils/`.
|
|
100
|
+
- **Runtime config** — environment variables are read at execute time, not build time.
|
|
101
|
+
|
|
73
102
|
## Reference
|
|
74
103
|
|
|
75
104
|
- [Tool Reference](docs/tools.md) — Full parameter specs and usage examples for each tool.
|
|
76
105
|
- [Usage Guide](docs/guide.md) — Decision tree and tool comparison.
|
|
106
|
+
- [Changelog](CHANGELOG.md) — Release history and migration notes.
|
|
107
|
+
|
|
108
|
+
## Contributing
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
# Local development
|
|
112
|
+
pi install ./
|
|
113
|
+
|
|
114
|
+
# Type-check (no build step; pi loads TypeScript directly)
|
|
115
|
+
npx tsc --noEmit
|
|
116
|
+
|
|
117
|
+
# Verify external CLI dependencies
|
|
118
|
+
scrapling --help
|
|
119
|
+
agent-browser doctor
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Pull requests welcome. Please keep changes scoped to a single tool or concern and follow [Conventional Commits](https://www.conventionalcommits.org/).
|
|
77
123
|
|
|
78
124
|
## License
|
|
79
125
|
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Domain Docs
|
|
2
|
+
|
|
3
|
+
How the engineering skills should consume this repo's domain documentation when exploring the codebase.
|
|
4
|
+
|
|
5
|
+
## Before exploring, read these
|
|
6
|
+
|
|
7
|
+
- **`CONTEXT.md`** at the repo root, or
|
|
8
|
+
- **`CONTEXT-MAP.md`** at the repo root if it exists — it points at one `CONTEXT.md` per context. Read each one relevant to the topic.
|
|
9
|
+
- **`docs/adr/`** — read ADRs that touch the area you're about to work in. In multi-context repos, also check `src/<context>/docs/adr/` for context-scoped decisions.
|
|
10
|
+
|
|
11
|
+
If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The producer skill (`/grill-with-docs`) creates them lazily when terms or decisions actually get resolved.
|
|
12
|
+
|
|
13
|
+
## File structure
|
|
14
|
+
|
|
15
|
+
Single-context repo (most repos):
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
/
|
|
19
|
+
├── CONTEXT.md
|
|
20
|
+
├── docs/adr/
|
|
21
|
+
│ ├── 0001-event-sourced-orders.md
|
|
22
|
+
│ └── 0002-postgres-for-write-model.md
|
|
23
|
+
└── src/
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Multi-context repo (presence of `CONTEXT-MAP.md` at the root):
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
/
|
|
30
|
+
├── CONTEXT-MAP.md
|
|
31
|
+
├── docs/adr/ ← system-wide decisions
|
|
32
|
+
└── src/
|
|
33
|
+
├── ordering/
|
|
34
|
+
│ ├── CONTEXT.md
|
|
35
|
+
│ └── docs/adr/ ← context-specific decisions
|
|
36
|
+
└── billing/
|
|
37
|
+
├── CONTEXT.md
|
|
38
|
+
└── docs/adr/
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Use the glossary's vocabulary
|
|
42
|
+
|
|
43
|
+
When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids.
|
|
44
|
+
|
|
45
|
+
If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/grill-with-docs`).
|
|
46
|
+
|
|
47
|
+
## Flag ADR conflicts
|
|
48
|
+
|
|
49
|
+
If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:
|
|
50
|
+
|
|
51
|
+
> _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Issue tracker: GitHub
|
|
2
|
+
|
|
3
|
+
Issues and PRDs for this repo live as GitHub issues. Use the `gh` CLI for all operations.
|
|
4
|
+
|
|
5
|
+
## Conventions
|
|
6
|
+
|
|
7
|
+
- **Create an issue**: `gh issue create --title "..." --body "..."`. Use a heredoc for multi-line bodies.
|
|
8
|
+
- **Read an issue**: `gh issue view <number> --comments`, filtering comments by `jq` and also fetching labels.
|
|
9
|
+
- **List issues**: `gh issue list --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'` with appropriate `--label` and `--state` filters.
|
|
10
|
+
- **Comment on an issue**: `gh issue comment <number> --body "..."`
|
|
11
|
+
- **Apply / remove labels**: `gh issue edit <number> --add-label "..."` / `--remove-label "..."`
|
|
12
|
+
- **Close**: `gh issue close <number> --comment "..."`
|
|
13
|
+
|
|
14
|
+
Infer the repo from `git remote -v` — `gh` does this automatically when run inside a clone.
|
|
15
|
+
|
|
16
|
+
## When a skill says "publish to the issue tracker"
|
|
17
|
+
|
|
18
|
+
Create a GitHub issue.
|
|
19
|
+
|
|
20
|
+
## When a skill says "fetch the relevant ticket"
|
|
21
|
+
|
|
22
|
+
Run `gh issue view <number> --comments`.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Triage Labels
|
|
2
|
+
|
|
3
|
+
The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker.
|
|
4
|
+
|
|
5
|
+
| Label in mattpocock/skills | Label in our tracker | Meaning |
|
|
6
|
+
| -------------------------- | -------------------- | ---------------------------------------- |
|
|
7
|
+
| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue |
|
|
8
|
+
| `needs-info` | `needs-info` | Waiting on reporter for more information |
|
|
9
|
+
| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent |
|
|
10
|
+
| `ready-for-human` | `ready-for-human` | Requires human implementation |
|
|
11
|
+
| `wontfix` | `wontfix` | Will not be actioned |
|
|
12
|
+
|
|
13
|
+
When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table.
|
|
14
|
+
|
|
15
|
+
Edit the right-hand column to match whatever vocabulary you actually use.
|
package/docs/guide.md
CHANGED
|
@@ -32,7 +32,7 @@ User asks about something external / current
|
|
|
32
32
|
|
|
33
33
|
| | `web_fetch` | `web_browse` | `web_batch_fetch` |
|
|
34
34
|
|--|-------------|--------------|-------------------|
|
|
35
|
-
| **Pages** | 1 | 1 | 2–
|
|
35
|
+
| **Pages** | 1 | 1 | 2–15 |
|
|
36
36
|
| **Browser** | Yes (scrapling) | Yes (agent-browser) | Yes (scrapling) |
|
|
37
37
|
| **Interaction** | ❌ No | ✅ Click, fill, scroll, wait | ❌ No |
|
|
38
38
|
| **Selector** | ✅ Per-URL | ✅ Final state | ✅ Applied to all |
|
package/docs/tools.md
CHANGED
|
@@ -2,18 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
## `web_search`
|
|
4
4
|
|
|
5
|
-
Search the web via SearXNG. Returns ranked results with title, URL, and snippet.
|
|
5
|
+
Search the web via SearXNG. Returns ranked results with title, URL, and snippet. Automatically aggregates up to 3 pages of SearXNG results when more than ~20 are needed.
|
|
6
6
|
|
|
7
7
|
```typescript
|
|
8
8
|
{
|
|
9
9
|
query: string, // Search query
|
|
10
10
|
language?: string, // Language code (en, de, fr...). Default: "auto"
|
|
11
|
-
results?: number, // Max results (1–
|
|
11
|
+
results?: number, // Max results (1–60). Default: 20. Automatically pages through SearXNG (up to 3 pages) if needed.
|
|
12
12
|
}
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
**When to use:** The user asks about current events, facts, or anything requiring up-to-date information. This is always the **first step** of web research.
|
|
16
16
|
|
|
17
|
+
**Empty results behavior:** When no results are found, `web_search` returns a list of **suggestions** — alternative queries that SearXNG believes may yield better results. The agent can use these suggestions to automatically refine and retry the search.
|
|
18
|
+
|
|
19
|
+
**Pagination:** `web_search` automatically fetches up to 3 pages from SearXNG and deduplicates by URL. You do not need to call it multiple times for deeper results.
|
|
20
|
+
|
|
17
21
|
---
|
|
18
22
|
|
|
19
23
|
## `web_fetch`
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-browser CLI wrapper
|
|
3
|
+
*
|
|
4
|
+
* Encapsulates all low-level interaction with the agent-browser command:
|
|
5
|
+
* command building, process spawning, JSON parsing, and session cleanup.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { runCLI } from "./cli-runner";
|
|
9
|
+
|
|
10
|
+
export interface BrowseAction {
|
|
11
|
+
type: "click" | "fill" | "type" | "press" | "wait" | "wait_selector" | "scroll";
|
|
12
|
+
selector?: string;
|
|
13
|
+
value?: string;
|
|
14
|
+
key?: string;
|
|
15
|
+
ms?: number;
|
|
16
|
+
direction?: "down" | "up" | "bottom" | "top";
|
|
17
|
+
amount?: number;
|
|
18
|
+
state?: "attached" | "visible" | "hidden";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface AgentBrowserBatchItem {
|
|
22
|
+
success: boolean;
|
|
23
|
+
command: string[];
|
|
24
|
+
result?: any;
|
|
25
|
+
error?: string | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function requireString(action: BrowseAction, field: "selector" | "value" | "key"): string {
|
|
29
|
+
const value = action[field] as string | undefined;
|
|
30
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
31
|
+
throw new Error(`Action "${action.type}" requires non-empty ${field}`);
|
|
32
|
+
}
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function requireInteger(action: BrowseAction, field: "ms" | "amount"): number {
|
|
37
|
+
const value = action[field] as number | undefined;
|
|
38
|
+
if (!Number.isInteger(value) || (value as number) < 0) {
|
|
39
|
+
throw new Error(`Action "${action.type}" requires non-negative integer ${field}`);
|
|
40
|
+
}
|
|
41
|
+
return value as number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function waitForSelectorScript(selector: string, state: "attached" | "visible" | "hidden"): string {
|
|
45
|
+
const selectorLiteral = JSON.stringify(selector);
|
|
46
|
+
const stateLiteral = JSON.stringify(state);
|
|
47
|
+
return `await new Promise((resolve, reject) => {
|
|
48
|
+
const selector = ${selectorLiteral};
|
|
49
|
+
const state = ${stateLiteral};
|
|
50
|
+
const deadline = Date.now() + 30000;
|
|
51
|
+
const isVisible = (el) => !!(el && (el.offsetWidth || el.offsetHeight || el.getClientRects().length));
|
|
52
|
+
const check = () => {
|
|
53
|
+
const el = document.querySelector(selector);
|
|
54
|
+
const ok = state === "attached" ? !!el : state === "hidden" ? !isVisible(el) : isVisible(el);
|
|
55
|
+
if (ok) return resolve(true);
|
|
56
|
+
if (Date.now() > deadline) return reject(new Error(\`Timed out waiting for ${state} selector: ${selector}\`));
|
|
57
|
+
setTimeout(check, 100);
|
|
58
|
+
};
|
|
59
|
+
check();
|
|
60
|
+
})`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function buildBatchCommands(
|
|
64
|
+
url: string,
|
|
65
|
+
actions: BrowseAction[],
|
|
66
|
+
selector?: string,
|
|
67
|
+
): string[][] {
|
|
68
|
+
const commands: string[][] = [["open", url]];
|
|
69
|
+
|
|
70
|
+
for (const action of actions) {
|
|
71
|
+
switch (action.type) {
|
|
72
|
+
case "click":
|
|
73
|
+
commands.push(["click", requireString(action, "selector")]);
|
|
74
|
+
break;
|
|
75
|
+
case "fill":
|
|
76
|
+
commands.push(["fill", requireString(action, "selector"), requireString(action, "value")]);
|
|
77
|
+
break;
|
|
78
|
+
case "type":
|
|
79
|
+
commands.push(["type", requireString(action, "selector"), requireString(action, "value")]);
|
|
80
|
+
break;
|
|
81
|
+
case "press": {
|
|
82
|
+
if (action.selector) {
|
|
83
|
+
commands.push(["focus", action.selector]);
|
|
84
|
+
}
|
|
85
|
+
commands.push(["press", requireString(action, "key")]);
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
case "wait":
|
|
89
|
+
commands.push(["wait", String(requireInteger(action, "ms"))]);
|
|
90
|
+
break;
|
|
91
|
+
case "wait_selector": {
|
|
92
|
+
const state = action.state ?? "visible";
|
|
93
|
+
const waitSelector = requireString(action, "selector");
|
|
94
|
+
if (state === "visible") {
|
|
95
|
+
commands.push(["wait", waitSelector]);
|
|
96
|
+
} else {
|
|
97
|
+
commands.push(["eval", waitForSelectorScript(waitSelector, state)]);
|
|
98
|
+
}
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
case "scroll": {
|
|
102
|
+
const dir = action.direction ?? "down";
|
|
103
|
+
if (dir === "top") {
|
|
104
|
+
commands.push(["eval", "window.scrollTo(0, 0)"]);
|
|
105
|
+
} else if (dir === "bottom") {
|
|
106
|
+
commands.push(["eval", "window.scrollTo(0, document.body.scrollHeight)"]);
|
|
107
|
+
} else {
|
|
108
|
+
commands.push(["scroll", dir, String(action.amount ?? 500)]);
|
|
109
|
+
}
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
default:
|
|
113
|
+
throw new Error(`Unsupported browser action: ${(action as BrowseAction).type}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Extract content
|
|
118
|
+
if (selector) {
|
|
119
|
+
commands.push(["get", "text", selector, "--json"]);
|
|
120
|
+
} else {
|
|
121
|
+
commands.push(["snapshot", "-i", "--json"]);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Metadata
|
|
125
|
+
commands.push(["get", "title", "--json"]);
|
|
126
|
+
commands.push(["get", "url", "--json"]);
|
|
127
|
+
|
|
128
|
+
return commands;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function runAgentBrowserBatch(
|
|
132
|
+
commands: string[][],
|
|
133
|
+
options: { session: string; headless: boolean; signal?: AbortSignal; timeout?: number },
|
|
134
|
+
): Promise<AgentBrowserBatchItem[]> {
|
|
135
|
+
const args = ["--session", options.session];
|
|
136
|
+
if (!options.headless) args.push("--headed");
|
|
137
|
+
args.push("batch", "--bail", "--json");
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const result = await runCLI({
|
|
141
|
+
command: "agent-browser",
|
|
142
|
+
args,
|
|
143
|
+
stdin: JSON.stringify(commands),
|
|
144
|
+
timeout: options.timeout,
|
|
145
|
+
signal: options.signal,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
if (result.exitCode !== 0 && !result.stdout.trim()) {
|
|
149
|
+
throw new Error(`agent-browser failed (exit ${result.exitCode}):\n${result.stderr || "unknown error"}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
return JSON.parse(result.stdout) as AgentBrowserBatchItem[];
|
|
154
|
+
} catch (err: any) {
|
|
155
|
+
throw new Error(
|
|
156
|
+
`Failed to parse agent-browser output: ${err.message}\nstdout: ${result.stdout}\nstderr: ${result.stderr}`
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
} catch (err: any) {
|
|
160
|
+
if (err.message === "agent-browser is not installed") {
|
|
161
|
+
throw new Error(
|
|
162
|
+
"agent-browser is not installed.\n\nInstall it with:\n npm i -g agent-browser && agent-browser install\n\nThen run: agent-browser doctor"
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
throw err;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function closeAgentBrowserSession(session: string, signal?: AbortSignal): Promise<void> {
|
|
170
|
+
try {
|
|
171
|
+
await runCLI({
|
|
172
|
+
command: "agent-browser",
|
|
173
|
+
args: ["--session", session, "close"],
|
|
174
|
+
signal,
|
|
175
|
+
});
|
|
176
|
+
} catch {
|
|
177
|
+
// Best-effort cleanup — ignore errors
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI runner — abstracted process spawning
|
|
3
|
+
*
|
|
4
|
+
* Provides a single interface for running external CLI commands
|
|
5
|
+
* with consistent signal handling, timeout support, and stdout/stderr
|
|
6
|
+
* collection. Enables testability by allowing the runner to be swapped.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
10
|
+
|
|
11
|
+
export interface CLIRunOptions {
|
|
12
|
+
command: string;
|
|
13
|
+
args: string[];
|
|
14
|
+
/** Data to write to stdin. If omitted, stdin is ignored. */
|
|
15
|
+
stdin?: string;
|
|
16
|
+
/** Timeout in milliseconds. If exceeded, the process is killed. */
|
|
17
|
+
timeout?: number;
|
|
18
|
+
/** AbortSignal for cancellation. */
|
|
19
|
+
signal?: AbortSignal;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface CLIRunResult {
|
|
23
|
+
stdout: string;
|
|
24
|
+
stderr: string;
|
|
25
|
+
exitCode: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Run an external CLI command and capture its output.
|
|
30
|
+
*
|
|
31
|
+
* Handles:
|
|
32
|
+
* - stdout/stderr collection
|
|
33
|
+
* - optional stdin feeding
|
|
34
|
+
* - optional timeout (SIGTERM)
|
|
35
|
+
* - AbortSignal cancellation (SIGTERM)
|
|
36
|
+
* - process spawn errors (e.g. ENOENT)
|
|
37
|
+
*/
|
|
38
|
+
export function runCLI(options: CLIRunOptions): Promise<CLIRunResult> {
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
const stdio = options.stdin
|
|
41
|
+
? ["pipe", "pipe", "pipe"]
|
|
42
|
+
: ["ignore", "pipe", "pipe"];
|
|
43
|
+
|
|
44
|
+
const proc = spawn(options.command, options.args, {
|
|
45
|
+
shell: false,
|
|
46
|
+
stdio: stdio as any,
|
|
47
|
+
}) as ChildProcess;
|
|
48
|
+
|
|
49
|
+
let stdout = "";
|
|
50
|
+
let stderr = "";
|
|
51
|
+
let timeoutId: NodeJS.Timeout | undefined;
|
|
52
|
+
let settled = false;
|
|
53
|
+
|
|
54
|
+
const cleanup = () => {
|
|
55
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
56
|
+
if (options.signal) options.signal.removeEventListener("abort", kill);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const settleReject = (err: Error) => {
|
|
60
|
+
if (settled) return;
|
|
61
|
+
settled = true;
|
|
62
|
+
cleanup();
|
|
63
|
+
reject(err);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const kill = () => proc.kill("SIGTERM");
|
|
67
|
+
|
|
68
|
+
proc.stdout?.on("data", (data: Buffer) => {
|
|
69
|
+
stdout += data.toString();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
proc.stderr?.on("data", (data: Buffer) => {
|
|
73
|
+
stderr += data.toString();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
if (options.timeout) {
|
|
77
|
+
timeoutId = setTimeout(() => {
|
|
78
|
+
proc.kill("SIGTERM");
|
|
79
|
+
settleReject(new Error(`${options.command} timed out after ${options.timeout}ms`));
|
|
80
|
+
}, options.timeout);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
proc.on("close", (code) => {
|
|
84
|
+
if (settled) return;
|
|
85
|
+
settled = true;
|
|
86
|
+
cleanup();
|
|
87
|
+
resolve({ stdout, stderr, exitCode: code ?? 1 });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
proc.on("error", (err: any) => {
|
|
91
|
+
if (err.code === "ENOENT") {
|
|
92
|
+
settleReject(new Error(`${options.command} is not installed`));
|
|
93
|
+
} else {
|
|
94
|
+
settleReject(err);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
if (options.signal) {
|
|
99
|
+
if (options.signal.aborted) kill();
|
|
100
|
+
else options.signal.addEventListener("abort", kill, { once: true });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (options.stdin && proc.stdin) {
|
|
104
|
+
proc.stdin.write(options.stdin);
|
|
105
|
+
proc.stdin.end();
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
}
|