qiksy-mcp 1.53.0 → 1.63.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 +14 -0
- package/package.json +2 -1
- package/reports.mjs +78 -0
- package/server.mjs +46 -7
package/README.md
CHANGED
|
@@ -155,6 +155,20 @@ take twenty again. These three keep the flow on your machine, in field NAMES:
|
|
|
155
155
|
The recipe is a plain JSON file you own — editable, deletable, never uploaded.
|
|
156
156
|
`QIKSY_RECIPES_DIR` moves the folder (e.g. into the project, to share flows with a team).
|
|
157
157
|
|
|
158
|
+
### Reports land in the project, not in a download folder
|
|
159
|
+
|
|
160
|
+
This process is started **from the folder you work in**, so it knows which project a run is about
|
|
161
|
+
and files the paperwork there. `qa_report_build` with no `out` writes
|
|
162
|
+
`qiksy-reports/<date>-<subject>.html` under that folder, and the reply carries the path. The
|
|
163
|
+
panel's own **Report** button goes to the same place while the bridge is connected, and falls back
|
|
164
|
+
to an ordinary browser download when it is not — the toast says which of the two happened.
|
|
165
|
+
|
|
166
|
+
Why there and not `~/.qiksy`, where the recipes live: a recipe is a tool, and the same login flow
|
|
167
|
+
serves every project; a report is the work itself, attached to a ticket and read by somebody else,
|
|
168
|
+
so it belongs beside the code it is about. `QIKSY_REPORTS_DIR` moves the folder, and `off` means
|
|
169
|
+
write nothing unless a path is named. A folder with none of `.git` / `package.json` / `CLAUDE.md` /
|
|
170
|
+
`AGENTS.md` is not treated as a project, and neither is your home directory.
|
|
171
|
+
|
|
158
172
|
### Pictures, and a shelf both hands can reach
|
|
159
173
|
|
|
160
174
|
A screenshot usually leaves the tool the moment it is taken, and everything after that is
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "qiksy-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.63.0",
|
|
4
4
|
"description": "Browser MCP server for the Chrome tab you already have open — your session, your logins. Gives Claude Code, Cursor, Codex and VS Code the live page: findings, forms, failed requests with server bodies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"widgets.mjs",
|
|
34
34
|
"project.mjs",
|
|
35
35
|
"gaps.mjs",
|
|
36
|
+
"reports.mjs",
|
|
36
37
|
"gallery.mjs",
|
|
37
38
|
"svg-shot.mjs",
|
|
38
39
|
"connect.mjs",
|
package/reports.mjs
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHERE A REPORT GOES WHEN NOBODY SAID.
|
|
3
|
+
*
|
|
4
|
+
* Owner, 28.09.2026: «чтобы Bridge понимал, в контексте какого это проекта, и в этой папке
|
|
5
|
+
* создавал» — reports that pile up in the browser's download folder belong to nobody, and a week
|
|
6
|
+
* later nothing says which of them was about which product.
|
|
7
|
+
*
|
|
8
|
+
* The bridge already knew the answer and was not using it: this process is started BY the agent,
|
|
9
|
+
* FROM the folder the person is working in, and the docs installer already tests that folder for
|
|
10
|
+
* the marks of a project before writing its guide into it. The same test decides here.
|
|
11
|
+
*
|
|
12
|
+
* WHY NOT ~/.qiksy, WHERE THE RECIPES LIVE. A recipe is a tool — the same login flow serves every
|
|
13
|
+
* project, so it is filed by project INSIDE our folder. A report is the work itself: it is
|
|
14
|
+
* attached to a ticket, read by a client, and it belongs with the code it is about. Somebody who
|
|
15
|
+
* has never heard of this tool should find it by opening the project.
|
|
16
|
+
*
|
|
17
|
+
* Kept out of server.mjs so it can be held to account without a browser, a socket or a port —
|
|
18
|
+
* every decision here is a string and a file test, which is exactly the kind of thing that breaks
|
|
19
|
+
* quietly and is never noticed until somebody goes looking for a report that was never written.
|
|
20
|
+
*/
|
|
21
|
+
import { existsSync } from 'node:fs';
|
|
22
|
+
import { homedir } from 'node:os';
|
|
23
|
+
import { dirname, join, resolve } from 'node:path';
|
|
24
|
+
|
|
25
|
+
/** The marks that make a folder somebody's project rather than somebody's home directory. */
|
|
26
|
+
export const PROJECT_MARKS = ['.git', 'package.json', 'CLAUDE.md', 'AGENTS.md'];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The folder the agent was started in, if it looks like a project.
|
|
30
|
+
*
|
|
31
|
+
* Deliberately NOT a walk up the tree: the agent is started in the folder the person is working
|
|
32
|
+
* in, and climbing from a subdirectory would file a report about one package into the root of a
|
|
33
|
+
* monorepo — quietly, and in the one place nobody would look for it. `home` is refused for the
|
|
34
|
+
* same reason a stray `rm` refuses it: a home directory is not a project, and writing into it is
|
|
35
|
+
* never what somebody meant.
|
|
36
|
+
*/
|
|
37
|
+
export function projectRoot({ cwd, home = homedir(), exists = existsSync } = {}) {
|
|
38
|
+
if (!cwd) return null;
|
|
39
|
+
const dir = resolve(cwd);
|
|
40
|
+
if (dir === resolve(home) || dirname(dir) === dir) return null;
|
|
41
|
+
return PROJECT_MARKS.some((m) => exists(join(dir, m))) ? dir : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Where reports go when the caller names no path: the project's own folder, an explicit override,
|
|
46
|
+
* or nowhere at all.
|
|
47
|
+
*
|
|
48
|
+
* `off` exists because writing a file is an act on somebody's disk, and a person who wants to be
|
|
49
|
+
* asked every time must have a way to say so that does not involve remembering an argument.
|
|
50
|
+
*/
|
|
51
|
+
export function reportsDir({ cwd, env = process.env, home = homedir(), exists = existsSync } = {}) {
|
|
52
|
+
const set = env.QIKSY_REPORTS_DIR;
|
|
53
|
+
if (set === 'off') return null;
|
|
54
|
+
if (set) return resolve(set);
|
|
55
|
+
const root = projectRoot({ cwd, home, exists });
|
|
56
|
+
return root ? join(root, 'qiksy-reports') : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const slug = (s) =>
|
|
60
|
+
String(s ?? '')
|
|
61
|
+
.toLowerCase()
|
|
62
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
63
|
+
.replace(/^-+|-+$/g, '')
|
|
64
|
+
.slice(0, 60);
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* `2026-09-28-the-form-swallows-the-error.html` — sortable first, readable second.
|
|
68
|
+
*
|
|
69
|
+
* The subject comes from the document's own first block, because that is what the person called
|
|
70
|
+
* the thing; a name built from the host or the tool would file twenty reports about twenty
|
|
71
|
+
* different problems under one name and let the newest quietly replace the rest.
|
|
72
|
+
*/
|
|
73
|
+
export function reportFileName(blocks, kind, { now = new Date() } = {}) {
|
|
74
|
+
const first = (Array.isArray(blocks) ? blocks : []).find((b) => b && (b.title || b.text));
|
|
75
|
+
const subject = slug(first?.title || first?.text) || slug(kind) || 'report';
|
|
76
|
+
const day = now.toISOString().slice(0, 10);
|
|
77
|
+
return `${day}-${subject}.html`;
|
|
78
|
+
}
|
package/server.mjs
CHANGED
|
@@ -34,6 +34,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
34
34
|
import { LIBRARIES, datePlan, hintsFor, recipeFor, classifyNode, classifySnapshot, classifyBehaviour, diffDigest } from './widgets.mjs';
|
|
35
35
|
import { project } from './project.mjs';
|
|
36
36
|
import { recordGap, listGaps, gapInvite, GAPS_FILE } from './gaps.mjs';
|
|
37
|
+
import { reportsDir, reportFileName } from './reports.mjs';
|
|
37
38
|
import { STATES, STATE_KEYS, renderGallery } from './gallery.mjs';
|
|
38
39
|
import { renderSearchableSvg } from './svg-shot.mjs';
|
|
39
40
|
import { connectAgents, toClipboard, pairingInPlace } from './connect.mjs';
|
|
@@ -1264,6 +1265,29 @@ function onConnection(ws, req) {
|
|
|
1264
1265
|
ws.send(JSON.stringify({ t: 'pong', ts: msg.ts }));
|
|
1265
1266
|
return;
|
|
1266
1267
|
}
|
|
1268
|
+
/* THE ONE THING THE BROWSER SIDE MAY ASK FOR: file this document under the project.
|
|
1269
|
+
*
|
|
1270
|
+
* Deliberately narrow. The extension sends a name and the bytes; THIS side decides the
|
|
1271
|
+
* folder, because a directory chosen by the browser side is a directory chosen by a web page
|
|
1272
|
+
* one bug later. The name is reduced to a bare file name for the same reason — `..` and `/`
|
|
1273
|
+
* are how «a report» becomes «a file anywhere on this disk». Followers cannot use it: the
|
|
1274
|
+
* frame is only honoured on the extension's own connection. */
|
|
1275
|
+
if (msg.t === 'file' && msg.id && !isFollower) {
|
|
1276
|
+
const reply = (ok, extra) => safeSend(ws, { t: 'filed', id: msg.id, ok, ...extra });
|
|
1277
|
+
try {
|
|
1278
|
+
const dir = whereReportsGo();
|
|
1279
|
+
if (!dir) { reply(false, { error: 'no project folder here — the agent was started outside one, or QIKSY_REPORTS_DIR is off' }); return; }
|
|
1280
|
+
const name = basename(String(msg.name || '')).replace(/[^\w.@-]+/g, '-').slice(0, 120) || 'report.html';
|
|
1281
|
+
const full = join(dir, name);
|
|
1282
|
+
mkdirSync(dir, { recursive: true });
|
|
1283
|
+
writeFileSync(full, String(msg.content ?? ''), 'utf8');
|
|
1284
|
+
log(`filed ${name} → ${dir}`);
|
|
1285
|
+
reply(true, { path: full });
|
|
1286
|
+
} catch (e) {
|
|
1287
|
+
reply(false, { error: e?.message || String(e) });
|
|
1288
|
+
}
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1267
1291
|
// A follower's call: run it against the extension as if it were ours and hand the
|
|
1268
1292
|
// answer back under the follower's own id. Ours is a fresh randomUUID, so the two
|
|
1269
1293
|
// id spaces cannot collide.
|
|
@@ -1729,6 +1753,13 @@ function stepsFromJournal(url) {
|
|
|
1729
1753
|
|
|
1730
1754
|
// ── Recipes on disk ────────────────────────────────────────────────────────────
|
|
1731
1755
|
const RECIPES_DIR = process.env.QIKSY_RECIPES_DIR || join(homedir(), '.qiksy', 'recipes');
|
|
1756
|
+
|
|
1757
|
+
/* REPORTS LAND IN THE PROJECT, NOT IN «DOWNLOADS» — the rules, and why, live in mcp/reports.mjs
|
|
1758
|
+
so they can be held to account without a browser, a socket or a port. `cwd` is read here, at
|
|
1759
|
+
the moment of use, because the folder can be deleted under a long-running process. */
|
|
1760
|
+
function whereReportsGo() {
|
|
1761
|
+
try { return reportsDir({ cwd: process.cwd() }); } catch { return null; }
|
|
1762
|
+
}
|
|
1732
1763
|
const slug = (s) =>
|
|
1733
1764
|
String(s ?? '')
|
|
1734
1765
|
.toLowerCase()
|
|
@@ -5044,7 +5075,7 @@ server.registerTool(
|
|
|
5044
5075
|
'The reply is an INVENTORY — blocks, pictures embedded, findings, points, bytes, warnings — not the word «done».',
|
|
5045
5076
|
inputSchema: {
|
|
5046
5077
|
tabId: tabIdArg,
|
|
5047
|
-
out: z.string().optional().describe('Write the HTML here.
|
|
5078
|
+
out: z.string().optional().describe('Write the HTML here. WITHOUT IT THE REPORT IS FILED UNDER THE PROJECT you are working in — `qiksy-reports/<date>-<subject>.html` next to the code it is about, because a report belongs with the thing it describes rather than in a download folder. The reply says the path either way. Set QIKSY_REPORTS_DIR to file them elsewhere, or to `off` to write nothing unless asked.'),
|
|
5048
5079
|
save: z
|
|
5049
5080
|
.union([z.boolean(), z.string()])
|
|
5050
5081
|
.optional()
|
|
@@ -5066,13 +5097,20 @@ server.registerTool(
|
|
|
5066
5097
|
const kept = r.saved
|
|
5067
5098
|
? { saved: r.saved, savedNote: 'kept in the extension: the panel’s Shots tab lists it under Reports, and qa_reports reads it back by that name' }
|
|
5068
5099
|
: {};
|
|
5069
|
-
|
|
5070
|
-
|
|
5071
|
-
|
|
5072
|
-
|
|
5100
|
+
/* Named path wins; otherwise the project's own reports folder — see reportsDir(). */
|
|
5101
|
+
const home = out ? null : whereReportsGo();
|
|
5102
|
+
const target = out ? resolve(out) : (home ? join(home, reportFileName(blocks, kind)) : null);
|
|
5103
|
+
if (target) {
|
|
5104
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
5105
|
+
writeFileSync(target, r.html, 'utf8');
|
|
5073
5106
|
/* The funnel's question has been answered by the act itself; it must not ask again. */
|
|
5074
5107
|
for (const [host, v] of reportFunnel) reportFunnel.set(host, { ...v, built: true });
|
|
5075
|
-
return asText({
|
|
5108
|
+
return asText({
|
|
5109
|
+
ok: true, path: target, blocks: r.blocks, shotsEmbedded: r.shotsEmbedded, findings: r.findings,
|
|
5110
|
+
points: r.points, bytes: r.bytes, warnings: r.warnings, ...kept,
|
|
5111
|
+
...(out ? {} : { filedIn: home, filedNote: 'No path was given, so it was filed under the project you are working in. Name `out` to put it somewhere else, or set QIKSY_REPORTS_DIR (or `off`) to change where this goes.' }),
|
|
5112
|
+
note: 'Open it and print to PDF; the structure is the same on paper.',
|
|
5113
|
+
});
|
|
5076
5114
|
}
|
|
5077
5115
|
return asText({ ok: true, blocks: r.blocks, shotsEmbedded: r.shotsEmbedded, findings: r.findings, points: r.points, bytes: r.bytes, warnings: r.warnings, ...kept, html: r.html });
|
|
5078
5116
|
} finally {
|
|
@@ -6770,6 +6808,7 @@ server.registerTool(
|
|
|
6770
6808
|
'Use it after anything whose answer arrives later than a click settles: an order confirmation, a slow save, a list that repopulates, a button that ungreys when validation finishes. ' +
|
|
6771
6809
|
'A condition already true when you call returns immediately. ' +
|
|
6772
6810
|
'ON TIMEOUT IT SAYS WHAT IT SAW — how many matched, how many were visible, the nearest names on the page, and anything announced — so a wait that failed on a typo tells you so instead of just running out. ' +
|
|
6811
|
+
'A SIGN-IN IS WHAT THIS IS FOR, MORE THAN ANYTHING ELSE. When the site hands over to a password, a code from a phone or a hardware key, that step is the person\'s — do not drive it and do not call it a failure. Tell them to sign in, then `qa_wait_for { urlContains: "<the site you came from>", timeoutMs: 180000 }`: it is answered by the extension, so it survives the navigation, and if the tab never moves at all it says THAT instead of just running out. ' +
|
|
6773
6812
|
'Read-only: waiting is not acting, so this needs no Pro and no consent — the same footing as qa_wait_ready. ' +
|
|
6774
6813
|
'There is no "until quiet" (that is what every action already does) and no "until the network is idle" (the interceptor sees only the page\'s own fetch/XHR in the top frame, so the number would not mean what its name says).',
|
|
6775
6814
|
inputSchema: {
|
|
@@ -6784,7 +6823,7 @@ server.registerTool(
|
|
|
6784
6823
|
exactly: z.number().optional().describe('With countOf: wait until exactly this many match'),
|
|
6785
6824
|
announced: z.union([z.string(), z.boolean()]).optional().describe('Wait until a live region speaks — a string waits for one containing it, true for any'),
|
|
6786
6825
|
urlContains: z.string().optional().describe('Wait until the tab address contains this. Answered by the extension itself, so it survives the navigation it is waiting for.'),
|
|
6787
|
-
timeoutMs: z.number().optional().describe('Give up after this long (default 15000, max 60000)'),
|
|
6826
|
+
timeoutMs: z.number().optional().describe('Give up after this long (default 15000, max 60000 — or up to 300000 with urlContains, which is how you wait for a person to sign in)'),
|
|
6788
6827
|
},
|
|
6789
6828
|
},
|
|
6790
6829
|
async (args) => {
|