pursr 0.6.0 → 0.7.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/LICENSE +20 -20
- package/README.md +9 -9
- package/assets/icon.svg +20 -20
- package/assets/logo.svg +28 -28
- package/assets/social-preview.svg +76 -76
- package/bin/pursr-mcp.mjs +10 -9
- package/bin/pursr.mjs +11 -11
- package/package.json +4 -4
- package/plans/m5.4-polish.json +21 -21
- package/plugins/plugin-audit.js +57 -57
- package/plugins/plugin-demo.js +63 -63
- package/src/ai-diff.js +7 -6
- package/src/auth.js +92 -91
- package/src/baseline.js +126 -125
- package/src/ci-output.js +156 -156
- package/src/dom-snapshot.js +192 -192
- package/src/eval.js +17 -17
- package/src/every-viewport.js +51 -51
- package/src/frames.js +33 -33
- package/src/har.js +158 -158
- package/src/hover.js +25 -25
- package/src/index.js +6 -6
- package/src/interact.js +137 -137
- package/src/mcp-resources.js +111 -110
- package/src/mcp.js +436 -435
- package/src/overlays.js +169 -169
- package/src/plugin-audit.js +260 -260
- package/src/plugin.js +120 -120
- package/src/probe.js +19 -19
- package/src/report.js +175 -175
- package/src/runway.js +65 -65
- package/src/selector-heal.js +85 -85
- package/src/selector.js +38 -38
- package/src/shoot.js +73 -73
- package/src/shot.js +17 -17
- package/src/snap.js +128 -128
- package/src/sweep-schema.js +69 -69
- package/src/sweep.js +1 -1
- package/src/util.js +204 -188
- package/src/viewport.js +38 -38
- package/src/watch.js +134 -134
package/src/interact.js
CHANGED
|
@@ -1,138 +1,138 @@
|
|
|
1
|
-
// click, type, wait, seq — interaction primitives.
|
|
2
|
-
|
|
3
|
-
import { launch, newPage } from "./runway.js";
|
|
4
|
-
import { DEFAULT_VIEWPORT } from "./viewport.js";
|
|
5
|
-
import { gotoOrThrow, settle, CLICK_TIMEOUT_MS, WAIT_DEFAULT_TIMEOUT_MS } from "./overlays.js";
|
|
6
|
-
import { resolveLocator } from "./selector.js";
|
|
7
|
-
import { requireArg } from "./util.js";
|
|
8
|
-
|
|
9
|
-
export async function runClick(url, selector, out) {
|
|
10
|
-
requireArg("url", url, "string");
|
|
11
|
-
requireArg("selector", selector, "string");
|
|
12
|
-
const browser = await launch();
|
|
13
|
-
try {
|
|
14
|
-
const page = await newPage(browser, DEFAULT_VIEWPORT);
|
|
15
|
-
const r = await gotoOrThrow(page, url); await settle(page);
|
|
16
|
-
const loc = await resolveLocator(page, selector);
|
|
17
|
-
await loc.first().waitFor({ state: "visible", timeout: CLICK_TIMEOUT_MS });
|
|
18
|
-
await loc.first().click({ timeout: CLICK_TIMEOUT_MS });
|
|
19
|
-
await settle(page);
|
|
20
|
-
if (out) await page.screenshot({ path: out, fullPage: false });
|
|
21
|
-
return { ...r, url, out, selector, clicked: true };
|
|
22
|
-
} finally { try { await browser.close(); } catch {} }
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export async function runType(url, selector, text, out) {
|
|
26
|
-
requireArg("url", url, "string");
|
|
27
|
-
requireArg("selector", selector, "string");
|
|
28
|
-
const browser = await launch();
|
|
29
|
-
try {
|
|
30
|
-
const page = await newPage(browser, DEFAULT_VIEWPORT);
|
|
31
|
-
const r = await gotoOrThrow(page, url); await settle(page);
|
|
32
|
-
const loc = await resolveLocator(page, selector);
|
|
33
|
-
await loc.first().waitFor({ state: "visible", timeout: CLICK_TIMEOUT_MS });
|
|
34
|
-
await loc.first().click({ timeout: CLICK_TIMEOUT_MS });
|
|
35
|
-
await page.keyboard.type(String(text ?? ""), { delay: 10 });
|
|
36
|
-
await settle(page);
|
|
37
|
-
if (out) await page.screenshot({ path: out, fullPage: false });
|
|
38
|
-
return { ...r, url, out, selector, text, typed: true };
|
|
39
|
-
} finally { try { await browser.close(); } catch {} }
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export async function runWait(url, selector, timeoutMs) {
|
|
43
|
-
requireArg("url", url, "string");
|
|
44
|
-
requireArg("selector", selector, "string");
|
|
45
|
-
const browser = await launch();
|
|
46
|
-
try {
|
|
47
|
-
const page = await newPage(browser, DEFAULT_VIEWPORT);
|
|
48
|
-
const r = await gotoOrThrow(page, url);
|
|
49
|
-
const loc = await resolveLocator(page, selector);
|
|
50
|
-
const t = timeoutMs || WAIT_DEFAULT_TIMEOUT_MS;
|
|
51
|
-
try {
|
|
52
|
-
await loc.first().waitFor({ state: "visible", timeout: t });
|
|
53
|
-
return { ...r, url, selector, found: true, timeoutMs: t };
|
|
54
|
-
} catch {
|
|
55
|
-
return { ...r, url, selector, found: false, timeoutMs: t };
|
|
56
|
-
}
|
|
57
|
-
} finally { try { await browser.close(); } catch {} }
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export async function runSeq(url, actionsJson, out) {
|
|
61
|
-
requireArg("url", url, "string");
|
|
62
|
-
let actions;
|
|
63
|
-
try { actions = JSON.parse(actionsJson); }
|
|
64
|
-
catch (e) { throw new Error(`invalid actions JSON: ${e.message}`, { cause: e }); }
|
|
65
|
-
if (!Array.isArray(actions)) throw new Error("actions must be a JSON array");
|
|
66
|
-
if (!actions.length) throw new Error("actions array is empty");
|
|
67
|
-
const browser = await launch();
|
|
68
|
-
try {
|
|
69
|
-
const page = await newPage(browser, DEFAULT_VIEWPORT);
|
|
70
|
-
const r = await gotoOrThrow(page, url); await settle(page);
|
|
71
|
-
const trace = [];
|
|
72
|
-
let failed = false;
|
|
73
|
-
for (let i = 0; i < actions.length; i++) {
|
|
74
|
-
const a = actions[i] || {};
|
|
75
|
-
const step = { i, op: a.op };
|
|
76
|
-
try {
|
|
77
|
-
switch (a.op) {
|
|
78
|
-
case "click": {
|
|
79
|
-
const loc = await resolveLocator(page, a.selector);
|
|
80
|
-
await loc.first().waitFor({ state: "visible", timeout: CLICK_TIMEOUT_MS });
|
|
81
|
-
await loc.first().click({ timeout: CLICK_TIMEOUT_MS });
|
|
82
|
-
step.selector = a.selector; break;
|
|
83
|
-
}
|
|
84
|
-
case "type": {
|
|
85
|
-
const loc = await resolveLocator(page, a.selector);
|
|
86
|
-
await loc.first().waitFor({ state: "visible", timeout: CLICK_TIMEOUT_MS });
|
|
87
|
-
await loc.first().click({ timeout: CLICK_TIMEOUT_MS });
|
|
88
|
-
await page.keyboard.type(String(a.text ?? ""), { delay: 10 });
|
|
89
|
-
step.selector = a.selector; step.text = a.text; break;
|
|
90
|
-
}
|
|
91
|
-
case "wait": {
|
|
92
|
-
const t = a.timeoutMs ? Number(a.timeoutMs) : WAIT_DEFAULT_TIMEOUT_MS;
|
|
93
|
-
const loc = await resolveLocator(page, a.selector);
|
|
94
|
-
await loc.first().waitFor({ state: "visible", timeout: t });
|
|
95
|
-
step.selector = a.selector; step.timeoutMs = t; break;
|
|
96
|
-
}
|
|
97
|
-
case "eval": { step.result = await page.evaluate(String(a.js ?? "")); break; }
|
|
98
|
-
case "shot": {
|
|
99
|
-
await page.screenshot({ path: a.out, fullPage: !!a.fullPage });
|
|
100
|
-
step.out = a.out; step.fullPage = !!a.fullPage; break;
|
|
101
|
-
}
|
|
102
|
-
case "scroll": {
|
|
103
|
-
const vp = page.viewportSize();
|
|
104
|
-
await page.mouse.move((vp?.width || 640) / 2, (vp?.height || 400) / 2);
|
|
105
|
-
await page.mouse.wheel(a.deltaX || 0, a.deltaY || 0);
|
|
106
|
-
step.deltaX = a.deltaX; step.deltaY = a.deltaY; break;
|
|
107
|
-
}
|
|
108
|
-
case "navigate": { await gotoOrThrow(page, a.url); step.url = a.url; break; }
|
|
109
|
-
case "press": {
|
|
110
|
-
// a.key can be a single key ("Escape") or comma-separated ("Tab,Enter")
|
|
111
|
-
const raw = String(a.key ?? "").trim();
|
|
112
|
-
if (!raw) throw new Error("press: missing key");
|
|
113
|
-
const keys = raw.split(",").map(k => k.trim()).filter(Boolean);
|
|
114
|
-
for (const k of keys) await page.keyboard.press(k);
|
|
115
|
-
step.key = raw; step.count = keys.length; break;
|
|
116
|
-
}
|
|
117
|
-
case "sleep": { await page.waitForTimeout(Number(a.ms ?? 1000)); step.ms = a.ms; break; }
|
|
118
|
-
case "hover": {
|
|
119
|
-
const loc = await resolveLocator(page, a.selector);
|
|
120
|
-
await loc.first().waitFor({ state: "visible", timeout: CLICK_TIMEOUT_MS });
|
|
121
|
-
await loc.first().hover({ timeout: CLICK_TIMEOUT_MS });
|
|
122
|
-
step.selector = a.selector; break;
|
|
123
|
-
}
|
|
124
|
-
default: throw new Error(`unknown op: ${a.op}`);
|
|
125
|
-
}
|
|
126
|
-
if (a.settleMs !== undefined) await page.waitForTimeout(Number(a.settleMs));
|
|
127
|
-
else await settle(page);
|
|
128
|
-
step.ok = true;
|
|
129
|
-
} catch (e) {
|
|
130
|
-
step.ok = false; step.error = e.message; failed = true;
|
|
131
|
-
}
|
|
132
|
-
trace.push(step);
|
|
133
|
-
if (failed) break;
|
|
134
|
-
}
|
|
135
|
-
if (out) await page.screenshot({ path: out, fullPage: false });
|
|
136
|
-
return { ...r, url, out, steps: trace, failed };
|
|
137
|
-
} finally { try { await browser.close(); } catch {} }
|
|
1
|
+
// click, type, wait, seq — interaction primitives.
|
|
2
|
+
|
|
3
|
+
import { launch, newPage } from "./runway.js";
|
|
4
|
+
import { DEFAULT_VIEWPORT } from "./viewport.js";
|
|
5
|
+
import { gotoOrThrow, settle, CLICK_TIMEOUT_MS, WAIT_DEFAULT_TIMEOUT_MS } from "./overlays.js";
|
|
6
|
+
import { resolveLocator } from "./selector.js";
|
|
7
|
+
import { requireArg } from "./util.js";
|
|
8
|
+
|
|
9
|
+
export async function runClick(url, selector, out) {
|
|
10
|
+
requireArg("url", url, "string");
|
|
11
|
+
requireArg("selector", selector, "string");
|
|
12
|
+
const browser = await launch();
|
|
13
|
+
try {
|
|
14
|
+
const page = await newPage(browser, DEFAULT_VIEWPORT);
|
|
15
|
+
const r = await gotoOrThrow(page, url); await settle(page);
|
|
16
|
+
const loc = await resolveLocator(page, selector);
|
|
17
|
+
await loc.first().waitFor({ state: "visible", timeout: CLICK_TIMEOUT_MS });
|
|
18
|
+
await loc.first().click({ timeout: CLICK_TIMEOUT_MS });
|
|
19
|
+
await settle(page);
|
|
20
|
+
if (out) await page.screenshot({ path: out, fullPage: false });
|
|
21
|
+
return { ...r, url, out, selector, clicked: true };
|
|
22
|
+
} finally { try { await browser.close(); } catch {} }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function runType(url, selector, text, out) {
|
|
26
|
+
requireArg("url", url, "string");
|
|
27
|
+
requireArg("selector", selector, "string");
|
|
28
|
+
const browser = await launch();
|
|
29
|
+
try {
|
|
30
|
+
const page = await newPage(browser, DEFAULT_VIEWPORT);
|
|
31
|
+
const r = await gotoOrThrow(page, url); await settle(page);
|
|
32
|
+
const loc = await resolveLocator(page, selector);
|
|
33
|
+
await loc.first().waitFor({ state: "visible", timeout: CLICK_TIMEOUT_MS });
|
|
34
|
+
await loc.first().click({ timeout: CLICK_TIMEOUT_MS });
|
|
35
|
+
await page.keyboard.type(String(text ?? ""), { delay: 10 });
|
|
36
|
+
await settle(page);
|
|
37
|
+
if (out) await page.screenshot({ path: out, fullPage: false });
|
|
38
|
+
return { ...r, url, out, selector, text, typed: true };
|
|
39
|
+
} finally { try { await browser.close(); } catch {} }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function runWait(url, selector, timeoutMs) {
|
|
43
|
+
requireArg("url", url, "string");
|
|
44
|
+
requireArg("selector", selector, "string");
|
|
45
|
+
const browser = await launch();
|
|
46
|
+
try {
|
|
47
|
+
const page = await newPage(browser, DEFAULT_VIEWPORT);
|
|
48
|
+
const r = await gotoOrThrow(page, url);
|
|
49
|
+
const loc = await resolveLocator(page, selector);
|
|
50
|
+
const t = timeoutMs || WAIT_DEFAULT_TIMEOUT_MS;
|
|
51
|
+
try {
|
|
52
|
+
await loc.first().waitFor({ state: "visible", timeout: t });
|
|
53
|
+
return { ...r, url, selector, found: true, timeoutMs: t };
|
|
54
|
+
} catch {
|
|
55
|
+
return { ...r, url, selector, found: false, timeoutMs: t };
|
|
56
|
+
}
|
|
57
|
+
} finally { try { await browser.close(); } catch {} }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function runSeq(url, actionsJson, out) {
|
|
61
|
+
requireArg("url", url, "string");
|
|
62
|
+
let actions;
|
|
63
|
+
try { actions = JSON.parse(actionsJson); }
|
|
64
|
+
catch (e) { throw new Error(`invalid actions JSON: ${e.message}`, { cause: e }); }
|
|
65
|
+
if (!Array.isArray(actions)) throw new Error("actions must be a JSON array");
|
|
66
|
+
if (!actions.length) throw new Error("actions array is empty");
|
|
67
|
+
const browser = await launch();
|
|
68
|
+
try {
|
|
69
|
+
const page = await newPage(browser, DEFAULT_VIEWPORT);
|
|
70
|
+
const r = await gotoOrThrow(page, url); await settle(page);
|
|
71
|
+
const trace = [];
|
|
72
|
+
let failed = false;
|
|
73
|
+
for (let i = 0; i < actions.length; i++) {
|
|
74
|
+
const a = actions[i] || {};
|
|
75
|
+
const step = { i, op: a.op };
|
|
76
|
+
try {
|
|
77
|
+
switch (a.op) {
|
|
78
|
+
case "click": {
|
|
79
|
+
const loc = await resolveLocator(page, a.selector);
|
|
80
|
+
await loc.first().waitFor({ state: "visible", timeout: CLICK_TIMEOUT_MS });
|
|
81
|
+
await loc.first().click({ timeout: CLICK_TIMEOUT_MS });
|
|
82
|
+
step.selector = a.selector; break;
|
|
83
|
+
}
|
|
84
|
+
case "type": {
|
|
85
|
+
const loc = await resolveLocator(page, a.selector);
|
|
86
|
+
await loc.first().waitFor({ state: "visible", timeout: CLICK_TIMEOUT_MS });
|
|
87
|
+
await loc.first().click({ timeout: CLICK_TIMEOUT_MS });
|
|
88
|
+
await page.keyboard.type(String(a.text ?? ""), { delay: 10 });
|
|
89
|
+
step.selector = a.selector; step.text = a.text; break;
|
|
90
|
+
}
|
|
91
|
+
case "wait": {
|
|
92
|
+
const t = a.timeoutMs ? Number(a.timeoutMs) : WAIT_DEFAULT_TIMEOUT_MS;
|
|
93
|
+
const loc = await resolveLocator(page, a.selector);
|
|
94
|
+
await loc.first().waitFor({ state: "visible", timeout: t });
|
|
95
|
+
step.selector = a.selector; step.timeoutMs = t; break;
|
|
96
|
+
}
|
|
97
|
+
case "eval": { step.result = await page.evaluate(String(a.js ?? "")); break; }
|
|
98
|
+
case "shot": {
|
|
99
|
+
await page.screenshot({ path: a.out, fullPage: !!a.fullPage });
|
|
100
|
+
step.out = a.out; step.fullPage = !!a.fullPage; break;
|
|
101
|
+
}
|
|
102
|
+
case "scroll": {
|
|
103
|
+
const vp = page.viewportSize();
|
|
104
|
+
await page.mouse.move((vp?.width || 640) / 2, (vp?.height || 400) / 2);
|
|
105
|
+
await page.mouse.wheel(a.deltaX || 0, a.deltaY || 0);
|
|
106
|
+
step.deltaX = a.deltaX; step.deltaY = a.deltaY; break;
|
|
107
|
+
}
|
|
108
|
+
case "navigate": { await gotoOrThrow(page, a.url); step.url = a.url; break; }
|
|
109
|
+
case "press": {
|
|
110
|
+
// a.key can be a single key ("Escape") or comma-separated ("Tab,Enter")
|
|
111
|
+
const raw = String(a.key ?? "").trim();
|
|
112
|
+
if (!raw) throw new Error("press: missing key");
|
|
113
|
+
const keys = raw.split(",").map(k => k.trim()).filter(Boolean);
|
|
114
|
+
for (const k of keys) await page.keyboard.press(k);
|
|
115
|
+
step.key = raw; step.count = keys.length; break;
|
|
116
|
+
}
|
|
117
|
+
case "sleep": { await page.waitForTimeout(Number(a.ms ?? 1000)); step.ms = a.ms; break; }
|
|
118
|
+
case "hover": {
|
|
119
|
+
const loc = await resolveLocator(page, a.selector);
|
|
120
|
+
await loc.first().waitFor({ state: "visible", timeout: CLICK_TIMEOUT_MS });
|
|
121
|
+
await loc.first().hover({ timeout: CLICK_TIMEOUT_MS });
|
|
122
|
+
step.selector = a.selector; break;
|
|
123
|
+
}
|
|
124
|
+
default: throw new Error(`unknown op: ${a.op}`);
|
|
125
|
+
}
|
|
126
|
+
if (a.settleMs !== undefined) await page.waitForTimeout(Number(a.settleMs));
|
|
127
|
+
else await settle(page);
|
|
128
|
+
step.ok = true;
|
|
129
|
+
} catch (e) {
|
|
130
|
+
step.ok = false; step.error = e.message; failed = true;
|
|
131
|
+
}
|
|
132
|
+
trace.push(step);
|
|
133
|
+
if (failed) break;
|
|
134
|
+
}
|
|
135
|
+
if (out) await page.screenshot({ path: out, fullPage: false });
|
|
136
|
+
return { ...r, url, out, steps: trace, failed };
|
|
137
|
+
} finally { try { await browser.close(); } catch {} }
|
|
138
138
|
}
|
package/src/mcp-resources.js
CHANGED
|
@@ -1,111 +1,112 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// Exposes run results as MCP resources so hosts (Claude Code, Cursor, etc.)
|
|
4
|
-
// can browse, preview, and re-read captures without re-running captures.
|
|
5
|
-
//
|
|
6
|
-
// Resource shape (per MCP spec):
|
|
7
|
-
// uri:
|
|
8
|
-
// name: <human label>
|
|
9
|
-
// description: <what it is>
|
|
10
|
-
// mimeType: image/png | application/json | text/html
|
|
11
|
-
//
|
|
12
|
-
// We track "recent" sweep outputs in-memory + persist an index at
|
|
13
|
-
// $
|
|
14
|
-
|
|
15
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from "node:fs";
|
|
16
|
-
import { join, basename, dirname } from "node:path";
|
|
17
|
-
import { homedir } from "node:os";
|
|
18
|
-
import { nowIso } from "./util.js";
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
idx.resources.
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if (
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
const
|
|
86
|
-
const
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
1
|
+
// pursr — MCP resources adapter.
|
|
2
|
+
//
|
|
3
|
+
// Exposes run results as MCP resources so hosts (Claude Code, Cursor, etc.)
|
|
4
|
+
// can browse, preview, and re-read captures without re-running captures.
|
|
5
|
+
//
|
|
6
|
+
// Resource shape (per MCP spec):
|
|
7
|
+
// uri: pursr://<kind>/<id>
|
|
8
|
+
// name: <human label>
|
|
9
|
+
// description: <what it is>
|
|
10
|
+
// mimeType: image/png | application/json | text/html
|
|
11
|
+
//
|
|
12
|
+
// We track "recent" sweep outputs in-memory + persist an index at
|
|
13
|
+
// $PURSR_MCP_STATE/mcp-index.json so resources survive restarts.
|
|
14
|
+
|
|
15
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from "node:fs";
|
|
16
|
+
import { join, basename, dirname } from "node:path";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { nowIso } from "./util.js";
|
|
19
|
+
import { __PURSR_GET } from "./util.js";
|
|
20
|
+
|
|
21
|
+
function stateDir() {
|
|
22
|
+
const root = __PURSR_GET("PURSR_MCP_STATE") || join(homedir(), ".pursr", "mcp");
|
|
23
|
+
mkdirSync(root, { recursive: true });
|
|
24
|
+
return root;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function indexPath() { return join(stateDir(), "mcp-index.json"); }
|
|
28
|
+
|
|
29
|
+
function loadIndex() {
|
|
30
|
+
const p = indexPath();
|
|
31
|
+
if (!existsSync(p)) return { resources: [] };
|
|
32
|
+
try { return JSON.parse(readFileSync(p, "utf8")); } catch { return { resources: [] }; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function saveIndex(idx) {
|
|
36
|
+
writeFileSync(indexPath(), JSON.stringify(idx, null, 2), "utf8");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function recordResource({ kind, id, name, description, uri, mimeType, file, meta }) {
|
|
40
|
+
const idx = loadIndex();
|
|
41
|
+
// De-dup by uri
|
|
42
|
+
idx.resources = idx.resources.filter(r => r.uri !== uri);
|
|
43
|
+
idx.resources.unshift({
|
|
44
|
+
kind, id, name, description, uri, mimeType, file, meta: meta || null, ts: nowIso(),
|
|
45
|
+
});
|
|
46
|
+
// Cap index size
|
|
47
|
+
if (idx.resources.length > 200) idx.resources = idx.resources.slice(0, 200);
|
|
48
|
+
saveIndex(idx);
|
|
49
|
+
return idx.resources[0];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function listResources() {
|
|
53
|
+
// Combine persisted index + any in-memory scan of recent sweep dirs
|
|
54
|
+
const idx = loadIndex();
|
|
55
|
+
// Also include sidecars sitting next to a sweep.json under cwd
|
|
56
|
+
try {
|
|
57
|
+
const cwd = process.cwd();
|
|
58
|
+
for (const f of readdirSync(cwd)) {
|
|
59
|
+
if (f === "sweep.json") {
|
|
60
|
+
const sweepPath = join(cwd, f);
|
|
61
|
+
try {
|
|
62
|
+
const s = JSON.parse(readFileSync(sweepPath, "utf8"));
|
|
63
|
+
const dirUri = `pursr://sweep/${encodeURIComponent(s.name || basename(cwd))}`;
|
|
64
|
+
if (!idx.resources.some(r => r.uri === dirUri)) {
|
|
65
|
+
idx.resources.push({
|
|
66
|
+
kind: "sweep", id: s.name || basename(cwd),
|
|
67
|
+
name: `sweep: ${s.name || basename(cwd)}`,
|
|
68
|
+
description: `Sweep summary: ${(s.steps || []).length} steps`,
|
|
69
|
+
uri: dirUri, mimeType: "application/json",
|
|
70
|
+
file: sweepPath, meta: { steps: (s.steps || []).length, ts: s.ts }, ts: s.ts || nowIso(),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
} catch {}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
} catch {}
|
|
77
|
+
return idx.resources;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function readResource(uri) {
|
|
81
|
+
if (typeof uri !== "string") return null;
|
|
82
|
+
if (!uri.startsWith("pursr://")) return null;
|
|
83
|
+
// Parse kind/id
|
|
84
|
+
const rest = uri.slice("pursr://".length);
|
|
85
|
+
const [kind, ...restParts] = rest.split("/");
|
|
86
|
+
const id = restParts.join("/");
|
|
87
|
+
const idx = loadIndex();
|
|
88
|
+
const r = idx.resources.find(x => x.uri === uri);
|
|
89
|
+
if (r) {
|
|
90
|
+
return readResourceFile(r);
|
|
91
|
+
}
|
|
92
|
+
// Resolve by kind/id from filesystem fallback
|
|
93
|
+
if (kind === "sweep") {
|
|
94
|
+
const file = join(process.cwd(), decodeURIComponent(id), "sweep.json");
|
|
95
|
+
if (existsSync(file)) {
|
|
96
|
+
return { uri, mimeType: "application/json", text: readFileSync(file, "utf8") };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function readResourceFile(r) {
|
|
103
|
+
if (!r.file || !existsSync(r.file)) return { uri: r.uri, mimeType: r.mimeType, error: "file not found" };
|
|
104
|
+
const data = readFileSync(r.file);
|
|
105
|
+
if (r.mimeType && r.mimeType.startsWith("image/")) {
|
|
106
|
+
return { uri: r.uri, mimeType: r.mimeType, blob: data.toString("base64") };
|
|
107
|
+
}
|
|
108
|
+
if (r.mimeType === "application/json" || r.mimeType === "text/html") {
|
|
109
|
+
return { uri: r.uri, mimeType: r.mimeType, text: data.toString("utf8") };
|
|
110
|
+
}
|
|
111
|
+
return { uri: r.uri, mimeType: r.mimeType || "application/octet-stream", blob: data.toString("base64") };
|
|
111
112
|
}
|