dsh-browser-playwright-codex 0.5.5 → 0.6.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/CHANGELOG.md +73 -0
- package/NOTICE.md +1 -1
- package/README.md +72 -11
- package/SECURITY.md +22 -0
- package/cordis.patch.yml +6 -0
- package/lib/compat.d.ts +9 -9
- package/lib/compat.js +0 -20
- package/lib/contract.d.ts +2 -2
- package/lib/contract.js +0 -4
- package/lib/login-sites.d.ts +136 -0
- package/lib/login-sites.js +303 -0
- package/lib/page-actions.d.ts +2 -2
- package/lib/page-actions.js +1 -1
- package/lib/provider.js +45 -1
- package/lib/runtime-state.d.ts +39 -0
- package/lib/runtime-state.js +20 -26
- package/lib/session.d.ts +40 -22
- package/lib/session.js +65 -1
- package/lib/site-aliases.d.ts +33 -0
- package/lib/site-aliases.js +132 -0
- package/lib/tool.js +95 -91
- package/lib/types.d.ts +13 -0
- package/package.json +3 -2
package/lib/tool.js
CHANGED
|
@@ -3,7 +3,8 @@ import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
|
3
3
|
import { BlockAssembler, createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
4
4
|
import { BrowserError } from "./errors.js";
|
|
5
5
|
import { registeredSuffixes } from "./contract.js";
|
|
6
|
-
import { getEnabled, publishGates } from "./runtime-state.js";
|
|
6
|
+
import { getEnabled, publishGates, publishLoginSites } from "./runtime-state.js";
|
|
7
|
+
import { summarizeLoginSites } from "./login-sites.js";
|
|
7
8
|
import { isCdpMethodAllowed, cdpDenialMessage } from "./cdp-policy.js";
|
|
8
9
|
import { SCREENSHOT_SCHEMA, SNAPSHOT_SCHEMA, TABS_SCHEMA } from "./tool-schemas.js";
|
|
9
10
|
import { parseJsonText, extractionPrompt } from "./extract.js";
|
|
@@ -68,11 +69,37 @@ export function apply(ctx, config) {
|
|
|
68
69
|
return id === undefined ? 'anonymous' : String(id);
|
|
69
70
|
};
|
|
70
71
|
/** Acquire the calling session's live browser session. */
|
|
72
|
+
/**
|
|
73
|
+
* Publish the "which sites look logged in" summary for the settings card. Throttled, and
|
|
74
|
+
* every failure is swallowed: this is a display nicety and must never slow down or break an
|
|
75
|
+
* action. Nothing here is model-visible — it travels only to the settings route.
|
|
76
|
+
*/
|
|
77
|
+
let lastLoginPublish = 0;
|
|
78
|
+
const publishLogins = (session) => {
|
|
79
|
+
const now = Date.now();
|
|
80
|
+
if (now - lastLoginPublish < 5000)
|
|
81
|
+
return;
|
|
82
|
+
lastLoginPublish = now;
|
|
83
|
+
void session
|
|
84
|
+
.cookieJar()
|
|
85
|
+
.then(async (rows) => {
|
|
86
|
+
publishLoginSites(summarizeLoginSites(rows, 'live', now, {
|
|
87
|
+
visited: session.visitedHosts(),
|
|
88
|
+
titles: await session.siteTitles(),
|
|
89
|
+
}));
|
|
90
|
+
})
|
|
91
|
+
.catch(() => {
|
|
92
|
+
/* display-only: a failed cookie read must not surface as a tool failure */
|
|
93
|
+
});
|
|
94
|
+
};
|
|
71
95
|
const acquire = (exec) => {
|
|
72
96
|
if (!getEnabled()) {
|
|
73
97
|
throw new BrowserError('DISABLED', 'dsh-browser-playwright-codex is disabled; open Settings → Plugins → 浏览器 and turn it on to use the browser');
|
|
74
98
|
}
|
|
75
|
-
return ctx.browser.acquire(ownerFor(exec), exec.signal)
|
|
99
|
+
return ctx.browser.acquire(ownerFor(exec), exec.signal).then((session) => {
|
|
100
|
+
publishLogins(session);
|
|
101
|
+
return session;
|
|
102
|
+
});
|
|
76
103
|
};
|
|
77
104
|
const services = () => {
|
|
78
105
|
const out = {};
|
|
@@ -137,46 +164,69 @@ export function apply(ctx, config) {
|
|
|
137
164
|
}));
|
|
138
165
|
ctx.tools.register(defineTool({
|
|
139
166
|
name: p('click'),
|
|
140
|
-
description: 'Click the
|
|
141
|
-
'Refs go stale after navigation or DOM changes
|
|
142
|
-
|
|
143
|
-
'
|
|
144
|
-
'
|
|
145
|
-
p('hover') +
|
|
146
|
-
' just revealed), pass text instead, copied exactly as rendered — a revealed item may need ' +
|
|
147
|
-
p('hover') +
|
|
148
|
-
' first to stay on screen. ' +
|
|
149
|
-
'IRON RULE: after the click, verify in the returned snapshot that the intended effect actually happened (URL, selection, toast, dialog, new content). ' +
|
|
150
|
-
'A click can also succeed without changing this page: when the snapshot shows no change, check ' +
|
|
167
|
+
description: 'Click the target from the latest snapshot — or reveal it without clicking (mode:"hover") — and return the new snapshot. ' +
|
|
168
|
+
'Best target first: `ref` when the snapshot has one; `text`, a visible label copied exactly as rendered, for a target shown WITHOUT a ref; `x`+`y` viewport CSS pixels only as a last resort (canvas-drawn hit areas). Refs go stale after navigation or DOM changes: take a fresh snapshot if a ref fails. ' +
|
|
169
|
+
'mode:"hover" only REVEALS content and never activates: use it when a control hides content until the pointer rests on it, especially when the snapshot lacks an element the page obviously should have. A hover returning only means the pointer moved: read the note comparing the actionable ref count before and after; new refs mean pre-hover refs are stale, so act from the NEW refs before an unrelated click closes the menu. If that note reports no new refs, do NOT repeat the hover and do NOT fall back to raw coordinates — hover the parent/container or the sibling that owns the menu, otherwise use a visible alternative or tell the user. ' +
|
|
170
|
+
'precise:true takes a ref click down the geometric path instead: the element is scrolled into view and clicked at its exact centre through native input, with no screenshot measuring, no devicePixelRatio guesswork. It is the FALLBACK, not a routine alternative — use it only after a ref click reports the element is not actionable, or that the click could not be delivered. ' +
|
|
171
|
+
'IRON RULE: verify the effect in the returned snapshot (URL, selection, toast, dialog, new content). No change may still be success: check ' +
|
|
151
172
|
p('tabs') +
|
|
152
|
-
' for a new tab (a target="_blank" link) before concluding
|
|
153
|
-
'The
|
|
154
|
-
'
|
|
155
|
-
p('click_at') +
|
|
156
|
-
' raw coordinates — inspect the visible state for the blocker or interception, resolve it, then retry the most direct semantic action. ' +
|
|
157
|
-
'If the click raises a native dialog, it stays PENDING and this call returns immediately with the dialog text instead of the page tree — answer it with ' +
|
|
173
|
+
' for a new tab (a target="_blank" link) before concluding, and treat a same-page overlay as success. ' +
|
|
174
|
+
'The landing note names what received the click: if that is not your target the click was intercepted — treat it as failed and retarget; do NOT blindly repeat it and do NOT throw raw coordinates at it, but inspect the visible state for the blocker or interception first, resolve that, then retry the most direct semantic action. ' +
|
|
175
|
+
'A native dialog stays PENDING and this returns immediately with the dialog text; this tool cannot accept it — ' +
|
|
158
176
|
p('dialog') +
|
|
159
|
-
'
|
|
177
|
+
' is a separate, deliberate call.',
|
|
160
178
|
parameters: {
|
|
161
179
|
ref: {
|
|
162
180
|
type: 'string',
|
|
163
|
-
description: 'Element ref (e.g. e12) from the latest snapshot.
|
|
181
|
+
description: 'Element ref (e.g. e12) from the latest snapshot. Preferred whenever the snapshot has one.',
|
|
164
182
|
},
|
|
165
183
|
text: {
|
|
166
184
|
type: 'string',
|
|
167
|
-
description: 'Visible label of a target shown WITHOUT a ref
|
|
185
|
+
description: 'Visible label of a target shown WITHOUT a ref, copied exactly as the snapshot renders it; the first visible element whose own text matches is used and the note names it.',
|
|
186
|
+
},
|
|
187
|
+
x: {
|
|
188
|
+
type: 'integer',
|
|
189
|
+
description: 'Viewport x in CSS pixels, with y: a raw click, mode "click" only, last resort.',
|
|
190
|
+
},
|
|
191
|
+
y: {
|
|
192
|
+
type: 'integer',
|
|
193
|
+
description: 'Viewport y in CSS pixels, with x: a raw click, mode "click" only, last resort.',
|
|
194
|
+
},
|
|
195
|
+
mode: {
|
|
196
|
+
type: 'string',
|
|
197
|
+
enum: ['click', 'hover'],
|
|
198
|
+
description: 'click (default) activates the target; hover only reveals it.',
|
|
199
|
+
},
|
|
200
|
+
precise: {
|
|
201
|
+
type: 'boolean',
|
|
202
|
+
description: 'Ref clicks only: click the geometric centre through native input instead of the standard click.',
|
|
168
203
|
},
|
|
169
204
|
},
|
|
170
205
|
output: { schema: SNAPSHOT_SCHEMA, render: renderSnapshotValue },
|
|
171
206
|
async execute(args, exec) {
|
|
172
207
|
const session = await acquire(exec);
|
|
173
|
-
|
|
174
|
-
|
|
208
|
+
const mode = args.mode ?? 'click';
|
|
209
|
+
if (mode !== 'click' && mode !== 'hover') {
|
|
210
|
+
throw new BrowserError('PARAM_MISSING', 'browser_click takes mode "click" or "hover", got: ' + String(args.mode));
|
|
211
|
+
}
|
|
212
|
+
const ref = args.ref !== undefined && args.ref !== '' ? args.ref : undefined;
|
|
213
|
+
const text = args.text !== undefined && args.text !== '' ? args.text : undefined;
|
|
214
|
+
const point = typeof args.x === 'number' && typeof args.y === 'number' ? { x: args.x, y: args.y } : undefined;
|
|
215
|
+
if (mode === 'hover') {
|
|
216
|
+
if (ref !== undefined)
|
|
217
|
+
return snapshotValue(await session.hover(ref, exec.signal));
|
|
218
|
+
if (text !== undefined)
|
|
219
|
+
return snapshotValue(await session.hoverText(text, exec.signal));
|
|
220
|
+
throw new BrowserError('PARAM_MISSING', 'browser_click with mode "hover" needs a ref from the latest snapshot, or the visible text label of a trigger that has no ref');
|
|
175
221
|
}
|
|
176
|
-
if (
|
|
177
|
-
return snapshotValue(await session.
|
|
222
|
+
if (ref !== undefined) {
|
|
223
|
+
return snapshotValue(args.precise === true ? await session.clickAtRef(ref, exec.signal) : await session.click(ref, exec.signal));
|
|
178
224
|
}
|
|
179
|
-
|
|
225
|
+
if (text !== undefined)
|
|
226
|
+
return snapshotValue(await session.clickText(text, exec.signal));
|
|
227
|
+
if (point !== undefined)
|
|
228
|
+
return snapshotValue(await session.clickAt(point.x, point.y, exec.signal));
|
|
229
|
+
throw new BrowserError('PARAM_MISSING', 'browser_click needs a target: a ref from the latest snapshot, the visible text label of a target that has no ref, or both x and y viewport coordinates');
|
|
180
230
|
},
|
|
181
231
|
}));
|
|
182
232
|
ctx.tools.register(defineTool({
|
|
@@ -207,71 +257,10 @@ export function apply(ctx, config) {
|
|
|
207
257
|
return snapshotValue(await session.resolveDialog(args.accept, args.promptText, exec.signal));
|
|
208
258
|
},
|
|
209
259
|
}));
|
|
210
|
-
ctx.tools.register(defineTool({
|
|
211
|
-
name: p('click_at'),
|
|
212
|
-
description: 'Precise native click, two modes. Ref mode (prefer it): pass ref — the element is scrolled into view, awaited for geometry stability, and clicked at its exact geometric centre through native input: no screenshot measuring, no devicePixelRatio guesswork. ' +
|
|
213
|
-
'Raw mode: pass x/y viewport CSS pixels to click a raw point with no element involvement. ' +
|
|
214
|
-
'This is the FALLBACK, not a routine alternative: try ' +
|
|
215
|
-
p('click') +
|
|
216
|
-
' (including its text mode) first, and use ref mode only after a click reports the element is not actionable or the click could not be delivered. Raw x/y is a last resort for targets no ref resolves (canvas-drawn hit areas): raw clicks still return a landing note saying what they hit, so re-snapshot and confirm. ' +
|
|
217
|
-
'IRON RULE: verify the effect in the returned snapshot; if nothing changed, do NOT repeat blindly and do NOT throw more raw coordinates at it — find the blocker or interception first, then retry the most direct semantic action. ' +
|
|
218
|
-
'The snapshot may open with a landing note naming what received the click, or — when an intercepted ref-mode target had to fall back to a bare click at its centre — a CAUTION note: if the click was intercepted, treat it as failed and retarget.',
|
|
219
|
-
parameters: {
|
|
220
|
-
ref: {
|
|
221
|
-
type: 'string',
|
|
222
|
-
description: 'Element ref (e.g. e12) from the latest snapshot. If given, its geometric centre is clicked; x/y are ignored.',
|
|
223
|
-
},
|
|
224
|
-
x: { type: 'integer', description: 'Raw-mode viewport x in CSS pixels (required only when ref is omitted).' },
|
|
225
|
-
y: { type: 'integer', description: 'Raw-mode viewport y in CSS pixels (required only when ref is omitted).' },
|
|
226
|
-
},
|
|
227
|
-
output: { schema: SNAPSHOT_SCHEMA, render: renderSnapshotValue },
|
|
228
|
-
async execute(args, exec) {
|
|
229
|
-
const session = await acquire(exec);
|
|
230
|
-
if (args.ref !== undefined && args.ref !== '') {
|
|
231
|
-
return snapshotValue(await session.clickAtRef(args.ref, exec.signal));
|
|
232
|
-
}
|
|
233
|
-
if (typeof args.x !== 'number' || typeof args.y !== 'number') {
|
|
234
|
-
throw new BrowserError('PARAM_MISSING', 'browser_click_at needs a ref, or both x and y viewport coordinates');
|
|
235
|
-
}
|
|
236
|
-
return snapshotValue(await session.clickAt(args.x, args.y, exec.signal));
|
|
237
|
-
},
|
|
238
|
-
}));
|
|
239
|
-
ctx.tools.register(defineTool({
|
|
240
|
-
name: p('hover'),
|
|
241
|
-
description: 'Hover the element with the given ref — or, for a trigger the snapshot gave no ref, the visible element whose own label equals text — and return the new snapshot. ' +
|
|
242
|
-
'Hover only REVEALS content and never activates anything: use ' +
|
|
243
|
-
p('click') +
|
|
244
|
-
' to open, select or submit. Use it when a control hides content until the pointer rests on it (submenus, dropdowns, account menus, tooltips, previews, chart values, player controls) — and especially when the snapshot lacks an element the page obviously should have. ' +
|
|
245
|
-
'Prefer ref when the snapshot has one; use text only for a label shown WITHOUT a ref, copied exactly as rendered. Do not hover plain links/buttons you mean to activate, and never hover "just in case". ' +
|
|
246
|
-
'A hover returning only means the pointer moved: the snapshot opens with a note comparing the actionable ref count before and after — read it. New refs mean the revealed content is in this snapshot and pre-hover refs are stale, so pick from the NEW refs and act before any unrelated click closes the menu. ' +
|
|
247
|
-
'IRON RULE: if the note reports no new refs and what you expected is still missing, do NOT repeat the hover and do NOT fall back to ' +
|
|
248
|
-
p('click_at') +
|
|
249
|
-
' raw coordinates — hover the parent/container or the sibling that owns the menu; if that also reveals nothing, hover is not implemented here, so use a visible alternative or tell the user.',
|
|
250
|
-
parameters: {
|
|
251
|
-
ref: {
|
|
252
|
-
type: 'string',
|
|
253
|
-
description: 'Element ref (e.g. e12) from the latest snapshot. Use this whenever the snapshot has a ref for the trigger.',
|
|
254
|
-
},
|
|
255
|
-
text: {
|
|
256
|
-
type: 'string',
|
|
257
|
-
description: 'Visible label of a trigger shown WITHOUT a ref (plain text, e.g. generic "设置"). Copied exactly as the snapshot renders it; the first visible element whose own text matches is hovered and the note names it.',
|
|
258
|
-
},
|
|
259
|
-
},
|
|
260
|
-
output: { schema: SNAPSHOT_SCHEMA, render: renderSnapshotValue },
|
|
261
|
-
async execute(args, exec) {
|
|
262
|
-
const session = await acquire(exec);
|
|
263
|
-
if (args.ref !== undefined && args.ref !== '') {
|
|
264
|
-
return snapshotValue(await session.hover(args.ref, exec.signal));
|
|
265
|
-
}
|
|
266
|
-
if (args.text !== undefined && args.text !== '') {
|
|
267
|
-
return snapshotValue(await session.hoverText(args.text, exec.signal));
|
|
268
|
-
}
|
|
269
|
-
throw new BrowserError('PARAM_MISSING', 'browser_hover needs either a ref from the latest snapshot or the visible text label of a trigger that has no ref');
|
|
270
|
-
},
|
|
271
|
-
}));
|
|
272
260
|
ctx.tools.register(defineTool({
|
|
273
261
|
name: p('fill'),
|
|
274
262
|
description: 'Replace the value of the input or select referenced by the latest snapshot. ' +
|
|
263
|
+
'`value` is accepted as an alias of `text`. ' +
|
|
275
264
|
'For <select> elements the text selects the matching option label. ' +
|
|
276
265
|
'IRON RULE: after filling, verify in the returned snapshot that the field actually holds the value. ' +
|
|
277
266
|
'Frameworks can silently reject or transform input (controlled components, contenteditable editors, ' +
|
|
@@ -284,12 +273,27 @@ export function apply(ctx, config) {
|
|
|
284
273
|
'say so) — understand why it was ignored, then act.',
|
|
285
274
|
parameters: {
|
|
286
275
|
ref: { type: 'string', required: true, description: 'Input or select ref (e.g. e7) from the latest snapshot.' },
|
|
287
|
-
text: {
|
|
276
|
+
text: {
|
|
277
|
+
type: 'string',
|
|
278
|
+
description: 'Exact value to fill, or option label for selects. (In browser_click, `text` is a label to click instead.)',
|
|
279
|
+
},
|
|
280
|
+
value: { type: 'string', description: 'Alias of `text`; if both are given, `text` wins.' },
|
|
288
281
|
},
|
|
289
282
|
output: { schema: SNAPSHOT_SCHEMA, render: renderSnapshotValue },
|
|
290
283
|
async execute(args, exec) {
|
|
284
|
+
// `text` deliberately is NOT `required: true` here, even though in practice you must give it
|
|
285
|
+
// (or `value`). The parameter DSL is a flat property map whose only requiredness is
|
|
286
|
+
// per-property, so it cannot express "exactly one of text/value" — the `oneOf` the compiled
|
|
287
|
+
// JSON Schema supports is value-level, not object-level. The requirement is therefore
|
|
288
|
+
// enforced below, before a session is acquired (a malformed call must not launch a browser),
|
|
289
|
+
// and the message names both spellings. 2026-10-16: a reviewing model passed `value` first
|
|
290
|
+
// and only got `invalid arguments: missing required property "text"` — no way forward.
|
|
291
|
+
const value = args.text ?? args.value;
|
|
292
|
+
if (value === undefined) {
|
|
293
|
+
throw new BrowserError('PARAM_MISSING', 'browser_fill needs the value to type: pass `text` (alias `value`). The `text` here is the value you type — for a visible label to click, use browser_click instead.');
|
|
294
|
+
}
|
|
291
295
|
const session = await acquire(exec);
|
|
292
|
-
return snapshotValue(await session.fill(args.ref,
|
|
296
|
+
return snapshotValue(await session.fill(args.ref, value, exec.signal));
|
|
293
297
|
},
|
|
294
298
|
}));
|
|
295
299
|
ctx.tools.register(defineTool({
|
package/lib/types.d.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Domain types for the browser capability: snapshots, sessions, providers.
|
|
3
3
|
* @module dsh-browser-playwright-codex/types
|
|
4
4
|
*/
|
|
5
|
+
import type { CookieRow } from './login-sites.ts';
|
|
5
6
|
/** Page load condition a navigation waits for. */
|
|
6
7
|
export type LoadState = 'load' | 'domcontentloaded' | 'networkidle';
|
|
7
8
|
/** One snapshot element. Refs identify actionable elements for later calls. */
|
|
@@ -191,6 +192,18 @@ export interface BrowserSession {
|
|
|
191
192
|
} | undefined, signal?: AbortSignal): Promise<ScreenshotCapture>;
|
|
192
193
|
/** Extract the page's bounded data: text, links, forms, frames. */
|
|
193
194
|
pageData(): Promise<unknown>;
|
|
195
|
+
/**
|
|
196
|
+
* Read this session's cookies for display-only login inference. Implementations must drop
|
|
197
|
+
* `value` before returning: the inference only needs names, domains and lifetime.
|
|
198
|
+
*/
|
|
199
|
+
cookieJar(): Promise<readonly CookieRow[]>;
|
|
200
|
+
/**
|
|
201
|
+
* Registrable domains of the pages this browser has open, used to attribute cookies to the site
|
|
202
|
+
* that was visited rather than to whoever set them.
|
|
203
|
+
*/
|
|
204
|
+
visitedHosts(): string[];
|
|
205
|
+
/** Page titles by registrable domain, for display only; empty when nothing is open. */
|
|
206
|
+
siteTitles(): Promise<Map<string, string>>;
|
|
194
207
|
/** Evaluate one JavaScript expression and return its JSON result. */
|
|
195
208
|
evaluate(expression: string, signal?: AbortSignal): Promise<unknown>;
|
|
196
209
|
/** Click the element whose visible text matches, by semantic locator. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-browser-playwright-codex",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Browser automation for DeepSeek Harness: fill in forms, scrape lists and work across tabs in real pages, using the logins you already have. Runs in a visible window that stays quiet when minimised, and stops for sign-ins, CAPTCHAs and native dialogs instead of confirming for you.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
],
|
|
43
43
|
"scripts": {
|
|
44
44
|
"build": "node node_modules/typescript/bin/tsc -p tsconfig.json",
|
|
45
|
+
"typecheck": "node node_modules/typescript/bin/tsc -p tsconfig.tests.json",
|
|
45
46
|
"test": "node --test \"tests/*.test.ts\"",
|
|
46
47
|
"test:scenarios": "node --test tests/scenarios.test.ts tests/tool-scenarios.test.ts",
|
|
47
48
|
"test:live": "node --test tests/live-smoke.test.ts",
|
|
@@ -51,7 +52,7 @@
|
|
|
51
52
|
"cost": "node node_modules/tsx/dist/cli.mjs scripts/measure-prompt-cost.mjs",
|
|
52
53
|
"format": "node node_modules/prettier/bin/prettier.cjs --write \"src/**/*.ts\" \"tests/**/*.ts\" \"scripts/**/*.mjs\"",
|
|
53
54
|
"format:check": "node node_modules/prettier/bin/prettier.cjs --check \"src/**/*.ts\" \"tests/**/*.ts\" \"scripts/**/*.mjs\"",
|
|
54
|
-
"prepublishOnly": "npm run build && npm test && npm run verify && npm run preflight",
|
|
55
|
+
"prepublishOnly": "npm run build && npm run typecheck && npm test && npm run verify && npm run preflight",
|
|
55
56
|
"preflight": "node scripts/preflight.mjs ."
|
|
56
57
|
},
|
|
57
58
|
"dependencies": {
|