arc-control-mcp 0.3.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 +255 -0
- package/LICENSE +21 -0
- package/README.md +490 -0
- package/package.json +52 -0
- package/src/index.js +123 -0
- package/src/jxa.js +243 -0
- package/src/page-lib.js +219 -0
- package/src/registry.js +99 -0
- package/src/state.js +216 -0
- package/src/tools/content.js +217 -0
- package/src/tools/interact.js +321 -0
- package/src/tools/navigation.js +302 -0
- package/src/tools/schema.js +48 -0
- package/src/tools/scripting.js +217 -0
- package/src/tools/shared.js +100 -0
- package/src/tools/spaces.js +66 -0
- package/src/tools/tabs.js +189 -0
package/src/jxa.js
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { execFile } from 'child_process';
|
|
2
|
+
import { promisify } from 'util';
|
|
3
|
+
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
|
|
6
|
+
const DEFAULT_TIMEOUT_MS = 30000;
|
|
7
|
+
const MAX_BUFFER_BYTES = 16 * 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
// Shared helpers injected ahead of every script body. Arc's scripting bridge
|
|
10
|
+
// refuses a bulk `w.tabs()` fetch but happily returns bulk property arrays
|
|
11
|
+
// (`w.tabs.id()`), which is ~10x fewer Apple Events than looping per tab.
|
|
12
|
+
// Exported so tests can parse-check it: node --check cannot see inside a
|
|
13
|
+
// template literal, so a syntax error here would only appear at runtime.
|
|
14
|
+
export const PREAMBLE = `
|
|
15
|
+
const Arc = Application("Arc");
|
|
16
|
+
|
|
17
|
+
// Arc keeps closed windows in its scripting collection as invisible phantoms
|
|
18
|
+
// whose activeTab is unreachable, so every lookup must filter on visible().
|
|
19
|
+
function liveWindows() {
|
|
20
|
+
const live = [];
|
|
21
|
+
for (let i = 0; i < Arc.windows.length; i++) {
|
|
22
|
+
const w = Arc.windows[i];
|
|
23
|
+
try {
|
|
24
|
+
if (w.visible()) live.push(w);
|
|
25
|
+
} catch (e) { /* phantom */ }
|
|
26
|
+
}
|
|
27
|
+
return live;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function mainWindow() {
|
|
31
|
+
const live = liveWindows();
|
|
32
|
+
if (live.length === 0) throw new Error("ARC_NO_WINDOW");
|
|
33
|
+
return live[0];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function requireArc() {
|
|
37
|
+
if (!Arc.running()) throw new Error("ARC_NOT_RUNNING");
|
|
38
|
+
if (liveWindows().length === 0) throw new Error("ARC_NO_WINDOW");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function snapshot() {
|
|
42
|
+
const rows = [];
|
|
43
|
+
const live = liveWindows();
|
|
44
|
+
for (let wi = 0; wi < live.length; wi++) {
|
|
45
|
+
const w = live[wi];
|
|
46
|
+
const ids = w.tabs.id();
|
|
47
|
+
const titles = w.tabs.title();
|
|
48
|
+
const urls = w.tabs.url();
|
|
49
|
+
const locations = w.tabs.location();
|
|
50
|
+
const windowId = w.id();
|
|
51
|
+
const activeId = w.activeTab.id();
|
|
52
|
+
|
|
53
|
+
// Which space each tab belongs to, so callers can tell agent tabs from the
|
|
54
|
+
// user's without opening anything.
|
|
55
|
+
const spaceOf = {};
|
|
56
|
+
try {
|
|
57
|
+
for (let si = 0; si < w.spaces.length; si++) {
|
|
58
|
+
const sp = w.spaces[si];
|
|
59
|
+
const spIds = sp.tabs.id();
|
|
60
|
+
const spTitle = sp.title();
|
|
61
|
+
for (let k = 0; k < spIds.length; k++) spaceOf[spIds[k]] = spTitle;
|
|
62
|
+
}
|
|
63
|
+
} catch (e) { /* spaces unavailable */ }
|
|
64
|
+
|
|
65
|
+
for (let ti = 0; ti < ids.length; ti++) {
|
|
66
|
+
rows.push({
|
|
67
|
+
id: ids[ti],
|
|
68
|
+
title: titles[ti],
|
|
69
|
+
url: urls[ti],
|
|
70
|
+
location: locations[ti],
|
|
71
|
+
space: spaceOf[ids[ti]] || null,
|
|
72
|
+
windowId: windowId,
|
|
73
|
+
windowIndex: wi + 1,
|
|
74
|
+
tabIndex: ti + 1,
|
|
75
|
+
isActive: ids[ti] === activeId
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return rows;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function locate(tabId) {
|
|
83
|
+
const live = liveWindows();
|
|
84
|
+
for (let wi = 0; wi < live.length; wi++) {
|
|
85
|
+
const w = live[wi];
|
|
86
|
+
const ids = w.tabs.id();
|
|
87
|
+
for (let ti = 0; ti < ids.length; ti++) {
|
|
88
|
+
if (ids[ti] === tabId) return w.tabs[ti];
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Prefers a tab this agent opened, then falls back to whatever is active, so
|
|
95
|
+
// the user can point it at their own tabs without looking up an id.
|
|
96
|
+
function target() {
|
|
97
|
+
requireArc();
|
|
98
|
+
if (P.tab_id) {
|
|
99
|
+
const tab = locate(P.tab_id);
|
|
100
|
+
if (!tab) throw new Error("TAB_NOT_FOUND:" + P.tab_id);
|
|
101
|
+
return tab;
|
|
102
|
+
}
|
|
103
|
+
if (P.default_tab_id) {
|
|
104
|
+
const tab = locate(P.default_tab_id);
|
|
105
|
+
if (tab) return tab;
|
|
106
|
+
}
|
|
107
|
+
// Falling through to the user's active tab is only safe for a tool that
|
|
108
|
+
// reads. Doing it for one that acts is how an agent ends up reloading or
|
|
109
|
+
// navigating the tab someone is working in, so refuse and say what to pass.
|
|
110
|
+
if (P.allow_active_tab) return mainWindow().activeTab;
|
|
111
|
+
throw new Error("NO_TARGET_TAB");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function agentSpace() {
|
|
115
|
+
return P.agent_space ? findSpace(P.agent_space) : null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Arc returns the JSON encoding of whatever the page expression evaluated to,
|
|
119
|
+
// so a string arrives wrapped in quotes and an object as JSON text.
|
|
120
|
+
function evalJs(tab, code) {
|
|
121
|
+
const raw = Arc.execute(tab, { javascript: code });
|
|
122
|
+
// Arc returns empty when the injected script fails to parse. That is
|
|
123
|
+
// indistinguishable from a page value of null unless we say so explicitly.
|
|
124
|
+
if (raw === undefined || raw === null || raw === "") {
|
|
125
|
+
return {
|
|
126
|
+
__arc: 1,
|
|
127
|
+
ok: false,
|
|
128
|
+
name: "ScriptError",
|
|
129
|
+
error: "Arc returned no result for the injected script. It most likely failed to parse, or Arc is blocking JavaScript from Apple Events (Arc > Settings > Advanced)."
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
try { return JSON.parse(raw); } catch (e) { return raw; }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function describe(tab) {
|
|
136
|
+
const id = tab.id();
|
|
137
|
+
const owned = P.owned_ids || [];
|
|
138
|
+
// The mine flag makes it obvious in every response whether this touched an
|
|
139
|
+
// agent tab or one of the user's own.
|
|
140
|
+
return { id: id, title: tab.title(), url: tab.url(), location: tab.location(), mine: owned.indexOf(id) >= 0 };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function findSpace(needle) {
|
|
144
|
+
const w = mainWindow();
|
|
145
|
+
const count = w.spaces.length;
|
|
146
|
+
for (let i = 0; i < count; i++) {
|
|
147
|
+
const space = w.spaces[i];
|
|
148
|
+
if (space.id() === needle || space.title() === needle) return space;
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
`;
|
|
153
|
+
|
|
154
|
+
// JSON is a JS-literal subset apart from the line separators, which older
|
|
155
|
+
// JavaScriptCore parsers reject inside string literals.
|
|
156
|
+
// Exported for unit tests; not part of the tool surface.
|
|
157
|
+
export function jsLiteral(value) {
|
|
158
|
+
return JSON.stringify(value === undefined ? null : value)
|
|
159
|
+
.replace(/\u2028/g, '\\u2028')
|
|
160
|
+
.replace(/\u2029/g, '\\u2029');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export class ArcError extends Error {}
|
|
164
|
+
|
|
165
|
+
// osascript appends its own " (-2700)" style code to the thrown message
|
|
166
|
+
function sentinel(message, name) {
|
|
167
|
+
const match = message.match(new RegExp(name + ':(.+?)(?:\\s*\\(-\\d+\\))?\\s*$', 'm'));
|
|
168
|
+
return match ? match[1].trim() : null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Exported for unit tests; not part of the tool surface.
|
|
172
|
+
export function friendly(message) {
|
|
173
|
+
const tabNotFound = sentinel(message, 'TAB_NOT_FOUND');
|
|
174
|
+
if (tabNotFound) {
|
|
175
|
+
return `No open Arc tab has id ${tabNotFound}. Run list_tabs to get current tab ids (they change when a tab is closed and reopened).`;
|
|
176
|
+
}
|
|
177
|
+
const spaceNotFound = sentinel(message, 'SPACE_NOT_FOUND');
|
|
178
|
+
if (spaceNotFound) {
|
|
179
|
+
return `No Arc space matches "${spaceNotFound}". Run list_spaces to see the available ids and titles.`;
|
|
180
|
+
}
|
|
181
|
+
const noMatch = sentinel(message, 'SELECTOR_NO_MATCH');
|
|
182
|
+
if (noMatch) {
|
|
183
|
+
return `No element on the page matches the selector "${noMatch}".`;
|
|
184
|
+
}
|
|
185
|
+
if (message.includes('NO_TARGET_TAB')) {
|
|
186
|
+
return [
|
|
187
|
+
'This tool changes a tab, and no tab was given. This agent has not opened one yet, and',
|
|
188
|
+
'it will not act on whatever tab the user happens to be looking at.',
|
|
189
|
+
'Pass an explicit tab_id from list_tabs, or call open_url first to get your own tab.'
|
|
190
|
+
].join('\n');
|
|
191
|
+
}
|
|
192
|
+
if (message.includes('ARC_NOT_RUNNING')) {
|
|
193
|
+
return 'Arc is not running. Launch Arc, or use open_url, which starts it.';
|
|
194
|
+
}
|
|
195
|
+
if (message.includes('ARC_NO_WINDOW')) {
|
|
196
|
+
return 'Arc is running but has no open windows. Open a window (Cmd-N) and try again.';
|
|
197
|
+
}
|
|
198
|
+
if (message.includes('-1743') || /not authoriz|assistive access/i.test(message)) {
|
|
199
|
+
return [
|
|
200
|
+
'Permission denied: controlling Arc needs automation access.',
|
|
201
|
+
'System Settings > Privacy & Security > Automation > enable "Arc" under this app,',
|
|
202
|
+
'then restart the app.'
|
|
203
|
+
].join('\n');
|
|
204
|
+
}
|
|
205
|
+
if (/javascript/i.test(message) && /(turned off|disabled|not allowed)/i.test(message)) {
|
|
206
|
+
return 'Arc is blocking JavaScript from Apple Events. Enable it in Arc > Settings > Advanced ("Allow JavaScript from Apple Events").';
|
|
207
|
+
}
|
|
208
|
+
if (message.includes('-600') || /isn't running|is not running/i.test(message)) {
|
|
209
|
+
return 'Arc is not running. Launch Arc, or use open_url, which starts it.';
|
|
210
|
+
}
|
|
211
|
+
return message;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Run a JXA body against Arc. `params` is exposed to the script as `P`, so
|
|
216
|
+
* arguments are never string-concatenated into the source.
|
|
217
|
+
* The body's final expression must be a JSON string.
|
|
218
|
+
*/
|
|
219
|
+
export async function runJxa(body, params = {}, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
220
|
+
const script = `const P = ${jsLiteral(params)};\n${PREAMBLE}\n${body}`;
|
|
221
|
+
let stdout;
|
|
222
|
+
try {
|
|
223
|
+
({ stdout } = await execFileAsync('osascript', ['-l', 'JavaScript', '-e', script], {
|
|
224
|
+
timeout: timeoutMs,
|
|
225
|
+
maxBuffer: MAX_BUFFER_BYTES
|
|
226
|
+
}));
|
|
227
|
+
} catch (error) {
|
|
228
|
+
if (error.killed || error.signal) {
|
|
229
|
+
throw new ArcError(`Arc did not respond within ${timeoutMs / 1000}s. It may be showing a dialog or busy loading.`);
|
|
230
|
+
}
|
|
231
|
+
// error.message embeds the whole `-e <script>` argv, which would false-match our sentinels
|
|
232
|
+
const detail = (error.stderr || '').trim() || error.message;
|
|
233
|
+
throw new ArcError(friendly(detail));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const text = stdout.trim();
|
|
237
|
+
if (!text) return null;
|
|
238
|
+
try {
|
|
239
|
+
return JSON.parse(text);
|
|
240
|
+
} catch {
|
|
241
|
+
throw new ArcError(`Unexpected output from Arc: ${text.slice(0, 400)}`);
|
|
242
|
+
}
|
|
243
|
+
}
|
package/src/page-lib.js
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers injected into the page ahead of every DOM expression. Composed in
|
|
3
|
+
* Node and shipped through Arc's `execute javascript`, so tool code stays
|
|
4
|
+
* ordinary JavaScript instead of nested string building inside JXA.
|
|
5
|
+
*/
|
|
6
|
+
export const PAGE_LIB = `
|
|
7
|
+
var A = (function () {
|
|
8
|
+
var api = {};
|
|
9
|
+
|
|
10
|
+
var TEXT_PREFIX = 'text=';
|
|
11
|
+
|
|
12
|
+
// Named keys get a legacy keyCode. Printable characters are handled
|
|
13
|
+
// separately, since keypress needs a charCode to look real to old handlers.
|
|
14
|
+
var KEY_CODES = {
|
|
15
|
+
Enter: 13, Tab: 9, Escape: 27, Backspace: 8, Delete: 46, Space: 32,
|
|
16
|
+
ArrowDown: 40, ArrowUp: 38, ArrowLeft: 37, ArrowRight: 39,
|
|
17
|
+
Home: 36, End: 35, PageUp: 33, PageDown: 34
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
var TEXT_CANDIDATES = 'a,button,[role=button],[role=link],[role=menuitem],[role=option],[role=tab],input,label,summary,li,td,th,h1,h2,h3,h4,h5,h6,span,div,p,legend,option';
|
|
21
|
+
|
|
22
|
+
function textOf(el) {
|
|
23
|
+
var t = el.innerText;
|
|
24
|
+
if (t === undefined || t === null || t === '') t = el.value;
|
|
25
|
+
if (t === undefined || t === null) t = '';
|
|
26
|
+
return String(t).trim();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// "text=Sign in" matches on visible text, anything else is a CSS selector.
|
|
30
|
+
// Exact matches come back before substring matches, so the obvious target
|
|
31
|
+
// wins on a page where the same word appears inside longer labels.
|
|
32
|
+
api.all = function (selector, root, opts) {
|
|
33
|
+
root = root || document;
|
|
34
|
+
opts = opts || {};
|
|
35
|
+
if (selector.indexOf(TEXT_PREFIX) !== 0) {
|
|
36
|
+
return Array.prototype.slice.call(root.querySelectorAll(selector));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
var needle = selector.slice(TEXT_PREFIX.length).trim().toLowerCase();
|
|
40
|
+
var nodes = root.querySelectorAll(TEXT_CANDIDATES);
|
|
41
|
+
var hits = [];
|
|
42
|
+
for (var i = 0; i < nodes.length; i++) {
|
|
43
|
+
var t = textOf(nodes[i]).toLowerCase();
|
|
44
|
+
if (!t || t.length > 300) continue;
|
|
45
|
+
var exact = t === needle;
|
|
46
|
+
if (!exact && (opts.exact || t.indexOf(needle) === -1)) continue;
|
|
47
|
+
// Keep the innermost match only: drop ancestors already collected, and
|
|
48
|
+
// skip this node when a descendant of it is already in the list.
|
|
49
|
+
var nested = false;
|
|
50
|
+
for (var j = 0; j < hits.length; j++) {
|
|
51
|
+
if (hits[j].el.contains(nodes[i])) { hits.splice(j, 1); j--; }
|
|
52
|
+
}
|
|
53
|
+
for (var k = 0; k < hits.length; k++) {
|
|
54
|
+
if (nodes[i].contains(hits[k].el)) { nested = true; break; }
|
|
55
|
+
}
|
|
56
|
+
if (!nested) hits.push({ el: nodes[i], exact: exact });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
var out = [];
|
|
60
|
+
for (var e = 0; e < hits.length; e++) if (hits[e].exact) out.push(hits[e].el);
|
|
61
|
+
for (var s = 0; s < hits.length; s++) if (!hits[s].exact) out.push(hits[s].el);
|
|
62
|
+
return out;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
api.one = function (selector, nth, opts) {
|
|
66
|
+
var list = api.all(selector, null, opts);
|
|
67
|
+
return list[nth || 0] || null;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
api.visible = function (el) {
|
|
71
|
+
if (!el || !el.getBoundingClientRect) return false;
|
|
72
|
+
var r = el.getBoundingClientRect();
|
|
73
|
+
// A 1x1 box is the standard screen-reader clipping trick, not something a
|
|
74
|
+
// user could click, so report it as hidden.
|
|
75
|
+
if (r.width < 2 || r.height < 2) return false;
|
|
76
|
+
var s = window.getComputedStyle(el);
|
|
77
|
+
if (s.visibility === 'hidden' || s.display === 'none' || s.opacity === '0') return false;
|
|
78
|
+
if (s.clipPath && /inset\\(\\s*(?:100%|50%)/.test(s.clipPath)) return false;
|
|
79
|
+
return true;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
api.describe = function (el, verbose) {
|
|
83
|
+
if (!el) return null;
|
|
84
|
+
var cap = verbose ? 200 : 120;
|
|
85
|
+
var attrs = {};
|
|
86
|
+
for (var i = 0; i < el.attributes.length; i++) {
|
|
87
|
+
var a = el.attributes[i];
|
|
88
|
+
if (a.name === 'style') continue;
|
|
89
|
+
attrs[a.name] = a.value.length > cap ? a.value.slice(0, cap) + '\\u2026' : a.value;
|
|
90
|
+
}
|
|
91
|
+
var out = {
|
|
92
|
+
tag: el.tagName.toLowerCase(),
|
|
93
|
+
text: textOf(el).slice(0, verbose ? 400 : 200) || null,
|
|
94
|
+
value: 'value' in el ? el.value : null,
|
|
95
|
+
href: el.href || null,
|
|
96
|
+
visible: api.visible(el),
|
|
97
|
+
disabled: !!el.disabled,
|
|
98
|
+
checked: 'checked' in el ? !!el.checked : null,
|
|
99
|
+
attrs: attrs
|
|
100
|
+
};
|
|
101
|
+
// rect is the bulkiest field and is rarely actionable, so it is opt-in.
|
|
102
|
+
if (verbose) {
|
|
103
|
+
var r = el.getBoundingClientRect();
|
|
104
|
+
out.rect = { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) };
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
api.center = function (el) {
|
|
110
|
+
var r = el.getBoundingClientRect();
|
|
111
|
+
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
api.click = function (el) {
|
|
115
|
+
el.scrollIntoView({ block: 'center', inline: 'center' });
|
|
116
|
+
var p = api.center(el);
|
|
117
|
+
var opts = { bubbles: true, cancelable: true, view: window, clientX: p.x, clientY: p.y, button: 0 };
|
|
118
|
+
['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click'].forEach(function (type) {
|
|
119
|
+
var Ctor = type.indexOf('pointer') === 0 && window.PointerEvent ? PointerEvent : MouseEvent;
|
|
120
|
+
el.dispatchEvent(new Ctor(type, opts));
|
|
121
|
+
});
|
|
122
|
+
return true;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// Uses the native setter so React and other frameworks see the change.
|
|
126
|
+
api.setValue = function (el, value) {
|
|
127
|
+
if (el.isContentEditable) {
|
|
128
|
+
el.focus();
|
|
129
|
+
el.textContent = value;
|
|
130
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
131
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
var tag = el.tagName.toLowerCase();
|
|
135
|
+
if (tag === 'select') {
|
|
136
|
+
throw new Error('A <select> cannot be filled. Use select_option instead.');
|
|
137
|
+
}
|
|
138
|
+
if (tag !== 'input' && tag !== 'textarea') {
|
|
139
|
+
throw new Error('<' + tag + '> is not an input, textarea or contenteditable element, so it cannot be filled.');
|
|
140
|
+
}
|
|
141
|
+
if (el.disabled) throw new Error('<' + tag + '> is disabled, so it cannot be filled.');
|
|
142
|
+
if (el.readOnly) throw new Error('<' + tag + '> is readonly, so it cannot be filled.');
|
|
143
|
+
var proto = tag === 'textarea' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
|
144
|
+
var setter = Object.getOwnPropertyDescriptor(proto, 'value');
|
|
145
|
+
el.focus();
|
|
146
|
+
if (setter && setter.set) setter.set.call(el, value); else el.value = value;
|
|
147
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
148
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
149
|
+
return true;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
api.key = function (el, key) {
|
|
153
|
+
var printable = key.length === 1;
|
|
154
|
+
var code = printable ? key.charCodeAt(0) : (KEY_CODES[key] || 0);
|
|
155
|
+
var init = { key: key, keyCode: code, which: code, bubbles: true, cancelable: true };
|
|
156
|
+
el.dispatchEvent(new KeyboardEvent('keydown', init));
|
|
157
|
+
if (code) {
|
|
158
|
+
el.dispatchEvent(new KeyboardEvent('keypress', {
|
|
159
|
+
key: key, keyCode: code, which: code, charCode: printable ? code : 0,
|
|
160
|
+
bubbles: true, cancelable: true
|
|
161
|
+
}));
|
|
162
|
+
}
|
|
163
|
+
el.dispatchEvent(new KeyboardEvent('keyup', init));
|
|
164
|
+
return true;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
api.miss = function (selector) {
|
|
168
|
+
return { error: 'no_match', selector: selector };
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
// Every injected script resolves to one of these two envelopes, so the Node
|
|
172
|
+
// side can tell "returned nothing" apart from "threw".
|
|
173
|
+
api.envelope = function (v) {
|
|
174
|
+
var out = { __arc: 1, ok: true };
|
|
175
|
+
var json;
|
|
176
|
+
try {
|
|
177
|
+
json = JSON.stringify(v);
|
|
178
|
+
} catch (e) {
|
|
179
|
+
return api.failure(new Error('Result is not JSON-serialisable: ' + (e && e.message ? e.message : e)));
|
|
180
|
+
}
|
|
181
|
+
if (json !== undefined) out.v = v;
|
|
182
|
+
if (json === '{}' && v && typeof v === 'object') {
|
|
183
|
+
var ctor = v.constructor && v.constructor.name;
|
|
184
|
+
if (ctor && ctor !== 'Object') {
|
|
185
|
+
out.note = ctor + ' has no JSON representation. Return specific properties, or A.describe(el) for an element.';
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
api.failure = function (e) {
|
|
192
|
+
return {
|
|
193
|
+
__arc: 1,
|
|
194
|
+
ok: false,
|
|
195
|
+
name: e && e.name ? String(e.name) : 'Error',
|
|
196
|
+
error: e && e.message ? String(e.message) : String(e)
|
|
197
|
+
};
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
return api;
|
|
201
|
+
})();
|
|
202
|
+
`;
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Wrap page code so it always resolves to an envelope: `{ __arc: 1, ok: true,
|
|
206
|
+
* v }` when it returned, `{ __arc: 1, ok: false, name, error }` when it threw.
|
|
207
|
+
* Arc hands back an empty string for a thrown script, which is otherwise
|
|
208
|
+
* indistinguishable from a page value of null.
|
|
209
|
+
*/
|
|
210
|
+
export function pageScript(body) {
|
|
211
|
+
return `${PAGE_LIB}
|
|
212
|
+
(function(){
|
|
213
|
+
try {
|
|
214
|
+
return A.envelope((function(){
|
|
215
|
+
${body}
|
|
216
|
+
})());
|
|
217
|
+
} catch (e) { return A.failure(e); }
|
|
218
|
+
})()`;
|
|
219
|
+
}
|
package/src/registry.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { toJSONSchema } from 'zod';
|
|
2
|
+
|
|
3
|
+
import { ArcError } from './jxa.js';
|
|
4
|
+
import * as tabs from './tools/tabs.js';
|
|
5
|
+
import * as navigation from './tools/navigation.js';
|
|
6
|
+
import * as content from './tools/content.js';
|
|
7
|
+
import * as interact from './tools/interact.js';
|
|
8
|
+
import * as spaces from './tools/spaces.js';
|
|
9
|
+
import * as scripting from './tools/scripting.js';
|
|
10
|
+
|
|
11
|
+
// Drop a module in here and its tools are exposed; nothing else needs changing.
|
|
12
|
+
const MODULES = { tabs, navigation, content, interact, spaces, scripting };
|
|
13
|
+
|
|
14
|
+
export const TOOLS = [];
|
|
15
|
+
export const HANDLERS = {};
|
|
16
|
+
|
|
17
|
+
// A malformed tool definition is invisible over MCP: the client just sees a
|
|
18
|
+
// tool that behaves oddly. Failing at import turns that into a startup crash
|
|
19
|
+
// with the offending module named.
|
|
20
|
+
/**
|
|
21
|
+
* The wire schema is generated, never hand-written. `io: 'input'` is what keeps
|
|
22
|
+
* a field with a default out of `required`, and drops the blanket
|
|
23
|
+
* additionalProperties so a tool can opt into it with z.strictObject.
|
|
24
|
+
*/
|
|
25
|
+
function schemaFrom(input) {
|
|
26
|
+
const json = toJSONSchema(input, { io: 'input' });
|
|
27
|
+
// $schema is meaningful for a standalone document, not for an inputSchema.
|
|
28
|
+
delete json.$schema;
|
|
29
|
+
return json;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readableIssues(error) {
|
|
33
|
+
return error.issues
|
|
34
|
+
.map((issue) => {
|
|
35
|
+
const path = issue.path.join('.');
|
|
36
|
+
return path ? `${path}: ${issue.message}` : issue.message;
|
|
37
|
+
})
|
|
38
|
+
.join('; ');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function validate(moduleName, tool) {
|
|
42
|
+
const where = `${moduleName}.${tool?.name ?? '<unnamed>'}`;
|
|
43
|
+
if (!tool?.name) throw new Error(`Module ${moduleName} has a tool with no name`);
|
|
44
|
+
if (typeof tool.description !== 'string' || !tool.description.trim()) {
|
|
45
|
+
throw new Error(`Tool ${where} has no description, so a model cannot tell when to call it`);
|
|
46
|
+
}
|
|
47
|
+
if (!tool.input || typeof tool.input.safeParse !== 'function') {
|
|
48
|
+
throw new Error(`Tool ${where} needs an "input" Zod schema (see src/tools/schema.js)`);
|
|
49
|
+
}
|
|
50
|
+
const json = schemaFrom(tool.input);
|
|
51
|
+
if (json.type !== 'object' || !json.properties) {
|
|
52
|
+
throw new Error(`Tool ${where}'s input must be a z.object, so its schema describes named arguments`);
|
|
53
|
+
}
|
|
54
|
+
if (!tool.annotations?.title) {
|
|
55
|
+
throw new Error(`Tool ${where} has no annotations.title`);
|
|
56
|
+
}
|
|
57
|
+
return json;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* One wrapper carries both cross-cutting concerns, so `batch` gets them for its
|
|
62
|
+
* steps too: arguments are validated against the tool's own schema, and the
|
|
63
|
+
* tool's read-only hint decides whether resolving to the user's active tab is
|
|
64
|
+
* acceptable.
|
|
65
|
+
*/
|
|
66
|
+
function wrap(tool, handler) {
|
|
67
|
+
const allowActiveTab = tool.annotations.readOnlyHint === true;
|
|
68
|
+
return async (args = {}, extra) => {
|
|
69
|
+
const parsed = tool.input.safeParse(args ?? {});
|
|
70
|
+
if (!parsed.success) {
|
|
71
|
+
// A bad argument is the tool failing, not the protocol failing, so this
|
|
72
|
+
// surfaces as an isError result the model can read and correct.
|
|
73
|
+
throw new ArcError(`Invalid arguments for ${tool.name}. ${readableIssues(parsed.error)}`);
|
|
74
|
+
}
|
|
75
|
+
return handler({ ...parsed.data, __allowActiveTab: allowActiveTab }, extra);
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
for (const [name, module] of Object.entries(MODULES)) {
|
|
80
|
+
for (const tool of module.tools) {
|
|
81
|
+
if (HANDLERS[tool.name]) throw new Error(`Duplicate tool name ${tool.name} in module ${name}`);
|
|
82
|
+
if (!module.handlers[tool.name]) throw new Error(`Module ${name} declares ${tool.name} with no handler`);
|
|
83
|
+
const inputSchema = validate(name, tool);
|
|
84
|
+
// Display precedence is top-level title, then annotations.title, then name.
|
|
85
|
+
// Deriving it here beats repeating the same string on 26 tool definitions.
|
|
86
|
+
const { input, ...rest } = tool;
|
|
87
|
+
TOOLS.push({ ...rest, title: tool.title ?? tool.annotations.title, inputSchema });
|
|
88
|
+
HANDLERS[tool.name] = wrap(tool, module.handlers[tool.name]);
|
|
89
|
+
}
|
|
90
|
+
for (const handlerName of Object.keys(module.handlers)) {
|
|
91
|
+
if (!module.tools.some((t) => t.name === handlerName)) {
|
|
92
|
+
throw new Error(`Module ${name} has handler ${handlerName} with no tool definition`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
scripting.bindRegistry(() => HANDLERS);
|
|
98
|
+
|
|
99
|
+
export const MODULE_NAMES = Object.keys(MODULES);
|