@avasapp/agent-bridge 0.4.2 → 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/README.md +36 -14
- package/dist/adapters/ably.cjs +79 -3
- package/dist/adapters/ably.d.cts +2 -1
- package/dist/adapters/async-storage.cjs +158 -0
- package/dist/adapters/async-storage.d.cts +14 -0
- package/dist/adapters/device.cjs +1110 -0
- package/dist/adapters/device.d.cts +49 -0
- package/dist/adapters/expo-router.cjs +112 -6
- package/dist/adapters/expo-router.d.cts +1 -1
- package/dist/adapters/jotai.cjs +266 -0
- package/dist/adapters/jotai.d.cts +19 -0
- package/dist/adapters/react-native-mmkv.cjs +80 -0
- package/dist/adapters/react-native-mmkv.d.cts +3 -1
- package/dist/adapters/react-native-webview.d.cts +1 -1
- package/dist/adapters/react-navigation.cjs +293 -0
- package/dist/adapters/react-navigation.d.cts +55 -0
- package/dist/adapters/redux.cjs +322 -0
- package/dist/adapters/redux.d.cts +28 -0
- package/dist/adapters/secure-store.cjs +169 -0
- package/dist/adapters/secure-store.d.cts +28 -0
- package/dist/adapters/socket-io.cjs +77 -3
- package/dist/adapters/socket-io.d.cts +2 -1
- package/dist/adapters/tanstack-query.cjs +3 -0
- package/dist/adapters/tanstack-query.d.cts +13 -4
- package/dist/adapters/zustand.cjs +193 -22
- package/dist/adapters/zustand.d.cts +11 -15
- package/dist/{chunk-TA2OLPPT.js → chunk-Y6QFU5ON.js} +477 -165
- package/dist/cli.js +2370 -103
- package/dist/client/index.cjs +441 -136
- package/dist/client/index.d.ts +67 -4
- package/dist/client/index.js +5 -3
- package/dist/expo/index.d.cts +1 -1
- package/dist/network/index.cjs +320 -73
- package/dist/network/index.d.cts +18 -3
- package/dist/noop/async-storage.cjs +26 -0
- package/dist/noop/device.cjs +26 -0
- package/dist/noop/jotai.cjs +26 -0
- package/dist/noop/react-navigation.cjs +26 -0
- package/dist/noop/realtime.cjs +6 -1
- package/dist/noop/redux.cjs +28 -0
- package/dist/noop/secure-store.cjs +26 -0
- package/dist/realtime/index.cjs +71 -4
- package/dist/realtime/index.d.cts +21 -2
- package/dist/runtime/index.cjs +838 -128
- package/dist/runtime/index.d.cts +4 -2
- package/dist/store-common-j0cHHvBr.d.cts +23 -0
- package/dist/{types-BmpykP1P.d.cts → types-qlwdcGjt.d.cts} +22 -0
- package/entries/async-storage.cjs +9 -0
- package/entries/device.cjs +9 -0
- package/entries/jotai.cjs +9 -0
- package/entries/react-navigation.cjs +9 -0
- package/entries/redux.cjs +9 -0
- package/entries/secure-store.cjs +9 -0
- package/package.json +34 -2
- package/skills/agent-bridge/SKILL.md +70 -22
- package/skills/agent-bridge/references/device.md +21 -0
- package/skills/agent-bridge/references/flows.md +19 -8
- package/skills/agent-bridge/references/large-data.md +6 -6
- package/skills/agent-bridge/references/network.md +25 -7
- package/skills/agent-bridge/references/realtime.md +5 -5
- package/skills/agent-bridge/references/restore.md +25 -7
- package/skills/agent-bridge/references/screen.md +25 -11
- package/skills/agent-bridge/references/state.md +31 -11
- package/skills/agent-bridge/references/webview.md +9 -9
- package/skills/agent-bridge/references/writing-scenarios.md +3 -3
package/dist/cli.js
CHANGED
|
@@ -2,10 +2,14 @@
|
|
|
2
2
|
import {
|
|
3
3
|
AgentBridgeCallError,
|
|
4
4
|
RUNTIME_MARKER,
|
|
5
|
+
SessionLinkError,
|
|
6
|
+
assertListening,
|
|
7
|
+
callTimeout,
|
|
5
8
|
checkName,
|
|
6
9
|
connect,
|
|
7
10
|
connectSession,
|
|
8
11
|
encodeLine,
|
|
12
|
+
explainConnectError,
|
|
9
13
|
explicitMetro,
|
|
10
14
|
flowScenarios,
|
|
11
15
|
formatDuration,
|
|
@@ -14,6 +18,7 @@ import {
|
|
|
14
18
|
listSessions,
|
|
15
19
|
logLine,
|
|
16
20
|
metroHost,
|
|
21
|
+
noAppHint,
|
|
17
22
|
openConnection,
|
|
18
23
|
parseDuration,
|
|
19
24
|
parseScenarioFlag,
|
|
@@ -21,27 +26,201 @@ import {
|
|
|
21
26
|
projectRoot,
|
|
22
27
|
readSession,
|
|
23
28
|
removeSessionFiles,
|
|
24
|
-
|
|
29
|
+
runFlows,
|
|
25
30
|
sessionFiles,
|
|
26
31
|
sessionRequest,
|
|
27
32
|
stateDir,
|
|
28
33
|
writePrivate
|
|
29
|
-
} from "./chunk-
|
|
34
|
+
} from "./chunk-Y6QFU5ON.js";
|
|
30
35
|
|
|
31
36
|
// src/cli.ts
|
|
32
|
-
import {
|
|
33
|
-
import {
|
|
34
|
-
import {
|
|
37
|
+
import { createRequire } from "module";
|
|
38
|
+
import { mkdir as mkdir3, readFile } from "fs/promises";
|
|
39
|
+
import { dirname as dirname7 } from "path";
|
|
35
40
|
import { parseArgs } from "util";
|
|
36
41
|
|
|
42
|
+
// src/client/flow-cli.ts
|
|
43
|
+
import { mkdir, realpath as realpath2, rename, writeFile } from "fs/promises";
|
|
44
|
+
import { dirname, join as join2, relative as relative2 } from "path";
|
|
45
|
+
import { pathToFileURL } from "url";
|
|
46
|
+
|
|
47
|
+
// src/client/flow-files.ts
|
|
48
|
+
import { readdir, realpath, stat } from "fs/promises";
|
|
49
|
+
import { basename, join, relative, resolve } from "path";
|
|
50
|
+
var FLOW_SUFFIX = /\.flow\.mjs$/;
|
|
51
|
+
function globRegExp(glob) {
|
|
52
|
+
let re = "";
|
|
53
|
+
for (let i = 0; i < glob.length; i++) {
|
|
54
|
+
const c = glob[i];
|
|
55
|
+
if (c === "*" && glob[i + 1] === "*") {
|
|
56
|
+
i++;
|
|
57
|
+
if (glob[i + 1] === "/") {
|
|
58
|
+
i++;
|
|
59
|
+
re += "(?:.*/)?";
|
|
60
|
+
} else re += ".*";
|
|
61
|
+
} else if (c === "*") re += "[^/]*";
|
|
62
|
+
else if (c === "?") re += "[^/]";
|
|
63
|
+
else re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
64
|
+
}
|
|
65
|
+
return new RegExp(`^${re}$`);
|
|
66
|
+
}
|
|
67
|
+
var collator = new Intl.Collator("en", { numeric: true });
|
|
68
|
+
var posix = (p) => p.split("\\").join("/");
|
|
69
|
+
var byPath = (a, b) => collator.compare(posix(a), posix(b));
|
|
70
|
+
async function walk(dir, seen = /* @__PURE__ */ new Set()) {
|
|
71
|
+
const real = await realpath(dir);
|
|
72
|
+
if (seen.has(real)) return [];
|
|
73
|
+
seen.add(real);
|
|
74
|
+
const out = [];
|
|
75
|
+
for (const e of await readdir(dir, { withFileTypes: true })) {
|
|
76
|
+
if (e.name === "node_modules" || e.name.startsWith(".")) continue;
|
|
77
|
+
const full = join(dir, e.name);
|
|
78
|
+
const isDir = e.isDirectory() || e.isSymbolicLink() && (await stat(full).catch(() => null))?.isDirectory();
|
|
79
|
+
if (isDir) out.push(...await walk(full, seen));
|
|
80
|
+
else out.push(full);
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
async function resolveFlowFiles(args, cwd = process.cwd()) {
|
|
85
|
+
const files = [];
|
|
86
|
+
const seen = /* @__PURE__ */ new Set();
|
|
87
|
+
for (const given of args) {
|
|
88
|
+
const arg = process.platform === "win32" ? given.split("\\").join("/") : given;
|
|
89
|
+
const path = resolve(cwd, arg);
|
|
90
|
+
const info = await stat(path).catch(() => null);
|
|
91
|
+
let found;
|
|
92
|
+
if (info?.isDirectory())
|
|
93
|
+
found = (await walk(path)).filter((f) => FLOW_SUFFIX.test(f)).sort(byPath);
|
|
94
|
+
else if (info) found = [path];
|
|
95
|
+
else if (/[*?]/.test(arg)) {
|
|
96
|
+
const fixed2 = arg.split("/").findIndex((seg) => /[*?]/.test(seg));
|
|
97
|
+
const base = resolve(cwd, arg.split("/").slice(0, fixed2).join("/") || ".");
|
|
98
|
+
const re = globRegExp(posix(relative(cwd, resolve(cwd, arg))));
|
|
99
|
+
found = (await walk(base).catch(() => [])).filter((f) => FLOW_SUFFIX.test(f) && re.test(posix(relative(cwd, f)))).sort(byPath);
|
|
100
|
+
} else throw new Error(`No such flow file or directory: ${given}`);
|
|
101
|
+
if (!found.length) throw new Error(`No flow files match ${given} (a directory or glob only finds *.flow.mjs)`);
|
|
102
|
+
for (const f of found) {
|
|
103
|
+
const real = await realpath(f);
|
|
104
|
+
if (!seen.has(real)) {
|
|
105
|
+
seen.add(real);
|
|
106
|
+
files.push(real);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return files;
|
|
111
|
+
}
|
|
112
|
+
var flowName = (file, exported) => exported ?? basename(file).replace(FLOW_SUFFIX, "").replace(/\.[mc]?[jt]s$/, "");
|
|
113
|
+
|
|
114
|
+
// src/client/flow-cli.ts
|
|
115
|
+
var REPORTERS = ["text", "json", "junit"];
|
|
116
|
+
function checkRunFlags(flags) {
|
|
117
|
+
const reporter = flags.reporter ?? "text";
|
|
118
|
+
if (!REPORTERS.includes(reporter))
|
|
119
|
+
throw new Error(`--reporter is text, json or junit, not ${reporter}`);
|
|
120
|
+
if (flags.out && reporter === "text")
|
|
121
|
+
throw new Error("--out needs --reporter json or junit (run: --out is the report file)");
|
|
122
|
+
return reporter !== "text";
|
|
123
|
+
}
|
|
124
|
+
var failedRun = (file, name, error) => ({
|
|
125
|
+
file,
|
|
126
|
+
name,
|
|
127
|
+
status: "failed",
|
|
128
|
+
ms: 0,
|
|
129
|
+
steps: 0,
|
|
130
|
+
errors: 0,
|
|
131
|
+
error,
|
|
132
|
+
restoreErrors: [],
|
|
133
|
+
notices: []
|
|
134
|
+
});
|
|
135
|
+
var reason = (error) => error instanceof Error ? error.message : String(error);
|
|
136
|
+
async function loadFlows(files, cwd = process.cwd()) {
|
|
137
|
+
const root = await realpath2(cwd).catch(() => cwd);
|
|
138
|
+
const entries = [];
|
|
139
|
+
const failures = [];
|
|
140
|
+
for (const abs of files) {
|
|
141
|
+
const file = relative2(root, abs).split("\\").join("/");
|
|
142
|
+
try {
|
|
143
|
+
const module = await import(pathToFileURL(abs).href);
|
|
144
|
+
if (typeof module.default !== "function")
|
|
145
|
+
throw new Error(`${file} has no \`export default async ({ step }) => {}\``);
|
|
146
|
+
if (module.name !== void 0 && typeof module.name !== "string")
|
|
147
|
+
throw new Error(`${file}: \`export const name\` must be a string, not ${typeof module.name}`);
|
|
148
|
+
flowScenarios(module);
|
|
149
|
+
entries.push({ file, name: flowName(abs, module.name), module });
|
|
150
|
+
} catch (error) {
|
|
151
|
+
failures.push(failedRun(file, flowName(abs), reason(error)));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return { entries, failures };
|
|
155
|
+
}
|
|
156
|
+
function selectFlows(entries, grep) {
|
|
157
|
+
if (!grep) return entries;
|
|
158
|
+
const needle = grep.toLowerCase();
|
|
159
|
+
return entries.filter((e) => `${e.name}
|
|
160
|
+
${e.file}`.toLowerCase().includes(needle));
|
|
161
|
+
}
|
|
162
|
+
async function writeReport(out, doc) {
|
|
163
|
+
await mkdir(dirname(out), { recursive: true });
|
|
164
|
+
const tmp = join2(dirname(out), `.${process.pid}-${Date.now()}.tmp`);
|
|
165
|
+
await writeFile(tmp, doc.endsWith("\n") ? doc : `${doc}
|
|
166
|
+
`);
|
|
167
|
+
await rename(tmp, out);
|
|
168
|
+
}
|
|
169
|
+
async function logToStderr(fn) {
|
|
170
|
+
const log = console.log;
|
|
171
|
+
console.log = (...args) => console.error(...args);
|
|
172
|
+
try {
|
|
173
|
+
return await fn();
|
|
174
|
+
} finally {
|
|
175
|
+
console.log = log;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// src/client/flow-report.ts
|
|
180
|
+
var ANSI = /\u001b\[[0-?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)|\u001b[@-Z\\-_]/g;
|
|
181
|
+
var escape = (s) => s.replace(ANSI, "").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\ufffe\uffff]/g, "").replace(/[<>&"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
182
|
+
var classname = (file) => {
|
|
183
|
+
const at = file.lastIndexOf("/");
|
|
184
|
+
return at > 0 ? file.slice(0, at) : "agent-bridge";
|
|
185
|
+
};
|
|
186
|
+
function renderJson(runs) {
|
|
187
|
+
const count = (s) => runs.filter((r) => r.status === s).length;
|
|
188
|
+
return JSON.stringify(
|
|
189
|
+
{ passed: count("passed"), failed: count("failed"), skipped: count("skipped"), flows: runs },
|
|
190
|
+
null,
|
|
191
|
+
2
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
function renderJunit(runs) {
|
|
195
|
+
const failed = runs.filter((r) => r.status === "failed").length;
|
|
196
|
+
const skipped = runs.filter((r) => r.status === "skipped").length;
|
|
197
|
+
const time = runs.reduce((t, r) => t + r.ms, 0) / 1e3;
|
|
198
|
+
const cases = runs.map((r) => {
|
|
199
|
+
const open = `<testcase classname="${escape(classname(r.file))}" name="${escape(r.name)}" file="${escape(r.file)}" time="${(r.ms / 1e3).toFixed(3)}"`;
|
|
200
|
+
if (r.status === "skipped") return `${open}><skipped/></testcase>`;
|
|
201
|
+
if (r.status === "passed") return `${open}/>`;
|
|
202
|
+
const screen = r.state?.screen?.length ? `
|
|
203
|
+
on screen:
|
|
204
|
+
${r.state.screen.join("\n")}` : "";
|
|
205
|
+
const route = r.state?.route !== void 0 ? `
|
|
206
|
+
route: ${JSON.stringify(r.state.route)}` : "";
|
|
207
|
+
return `${open}><failure message="${escape(r.error ?? "failed")}">${escape((r.error ?? "") + route + screen)}</failure></testcase>`;
|
|
208
|
+
});
|
|
209
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
210
|
+
<testsuite name="agent-bridge" tests="${runs.length}" failures="${failed}" skipped="${skipped}" time="${time.toFixed(3)}">
|
|
211
|
+
${cases.join("\n")}
|
|
212
|
+
</testsuite>
|
|
213
|
+
`;
|
|
214
|
+
}
|
|
215
|
+
|
|
37
216
|
// src/client/batch.ts
|
|
38
217
|
import { readFileSync } from "fs";
|
|
39
|
-
import { mkdir, readdir, stat } from "fs/promises";
|
|
40
|
-
import { join } from "path";
|
|
218
|
+
import { mkdir as mkdir2, readdir as readdir2, stat as stat2 } from "fs/promises";
|
|
219
|
+
import { join as join3 } from "path";
|
|
41
220
|
import { createInterface } from "readline";
|
|
42
221
|
|
|
43
222
|
// src/client/output.ts
|
|
44
|
-
import { writeFile } from "fs/promises";
|
|
223
|
+
import { writeFile as writeFile2 } from "fs/promises";
|
|
45
224
|
var OUTPUT_LIMIT_BYTES = 32 * 1024;
|
|
46
225
|
var kb = (bytes) => bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
47
226
|
function shapeOf(value) {
|
|
@@ -73,7 +252,7 @@ async function shapedResult(value, options = {}) {
|
|
|
73
252
|
const json = JSON.stringify(value, null, 2) ?? "null";
|
|
74
253
|
const bytes = Buffer.byteLength(json);
|
|
75
254
|
if (options.out) {
|
|
76
|
-
await
|
|
255
|
+
await writeFile2(options.out, `${json}
|
|
77
256
|
`);
|
|
78
257
|
return fileSummary(options.out, bytes, value);
|
|
79
258
|
}
|
|
@@ -85,7 +264,7 @@ async function renderResult(value, options = {}) {
|
|
|
85
264
|
const json = JSON.stringify(value, null, 2) ?? "null";
|
|
86
265
|
const bytes = Buffer.byteLength(json);
|
|
87
266
|
if (options.out) {
|
|
88
|
-
await
|
|
267
|
+
await writeFile2(options.out, `${json}
|
|
89
268
|
`);
|
|
90
269
|
return JSON.stringify(fileSummary(options.out, bytes, value), null, 2);
|
|
91
270
|
}
|
|
@@ -93,6 +272,16 @@ async function renderResult(value, options = {}) {
|
|
|
93
272
|
return json;
|
|
94
273
|
return JSON.stringify(tooLarge(bytes, value), null, 2);
|
|
95
274
|
}
|
|
275
|
+
async function renderText(text2, options = {}) {
|
|
276
|
+
const bytes = Buffer.byteLength(text2);
|
|
277
|
+
if (options.out) {
|
|
278
|
+
await writeFile2(options.out, `${text2}
|
|
279
|
+
`);
|
|
280
|
+
return JSON.stringify(fileSummary(options.out, bytes, text2), null, 2);
|
|
281
|
+
}
|
|
282
|
+
if (options.full || bytes <= (options.limit ?? OUTPUT_LIMIT_BYTES)) return text2;
|
|
283
|
+
return JSON.stringify(tooLarge(bytes, text2), null, 2);
|
|
284
|
+
}
|
|
96
285
|
|
|
97
286
|
// src/client/batch.ts
|
|
98
287
|
function parseCallArgs(raw) {
|
|
@@ -120,9 +309,9 @@ function fileJson(ref, read) {
|
|
|
120
309
|
throw new Error(
|
|
121
310
|
`An argument that is just "@" needs a path (@file, or @- for stdin). To pass the text itself, quote it as JSON: '"@"'`
|
|
122
311
|
);
|
|
123
|
-
const
|
|
312
|
+
const text2 = read(path);
|
|
124
313
|
try {
|
|
125
|
-
return JSON.parse(
|
|
314
|
+
return JSON.parse(text2);
|
|
126
315
|
} catch (error) {
|
|
127
316
|
throw new Error(
|
|
128
317
|
`${path === "-" ? "stdin" : path} is not valid JSON (${error instanceof Error ? error.message : error})`
|
|
@@ -146,19 +335,19 @@ function parseCallArgv(argv, read = readArgFile) {
|
|
|
146
335
|
});
|
|
147
336
|
}
|
|
148
337
|
var BatchParseError = class extends Error {
|
|
149
|
-
constructor(tool,
|
|
150
|
-
super(
|
|
338
|
+
constructor(tool, message3) {
|
|
339
|
+
super(message3);
|
|
151
340
|
this.tool = tool;
|
|
152
341
|
}
|
|
153
342
|
tool;
|
|
154
343
|
};
|
|
155
344
|
function parseBatchLine(line) {
|
|
156
|
-
const
|
|
157
|
-
if (!
|
|
158
|
-
const at =
|
|
159
|
-
if (at < 0) return { tool:
|
|
160
|
-
const tool =
|
|
161
|
-
const raw =
|
|
345
|
+
const text2 = line.trim();
|
|
346
|
+
if (!text2 || text2.startsWith("#")) return null;
|
|
347
|
+
const at = text2.search(/\s/);
|
|
348
|
+
if (at < 0) return { tool: text2, args: [] };
|
|
349
|
+
const tool = text2.slice(0, at);
|
|
350
|
+
const raw = text2.slice(at).trim();
|
|
162
351
|
if (/^[[{"]/.test(raw)) {
|
|
163
352
|
try {
|
|
164
353
|
JSON.parse(raw);
|
|
@@ -289,11 +478,11 @@ async function dotLine(bridge, line) {
|
|
|
289
478
|
}
|
|
290
479
|
}
|
|
291
480
|
async function checkOutDir(dir) {
|
|
292
|
-
const info = await
|
|
293
|
-
if (!info) return
|
|
481
|
+
const info = await stat2(dir).catch(() => null);
|
|
482
|
+
if (!info) return mkdir2(dir, { recursive: true });
|
|
294
483
|
if (!info.isDirectory())
|
|
295
484
|
throw new Error(`--out ${dir} is a file; with --batch it must be a directory`);
|
|
296
|
-
if ((await
|
|
485
|
+
if ((await readdir2(dir)).length)
|
|
297
486
|
throw new Error(
|
|
298
487
|
`--out ${dir} is not empty; use a new or empty directory so old files aren't mistaken for this run's`
|
|
299
488
|
);
|
|
@@ -306,9 +495,9 @@ async function runBatch(bridge, input, options = {}) {
|
|
|
306
495
|
let stopped = false;
|
|
307
496
|
try {
|
|
308
497
|
for await (const line of rl) {
|
|
309
|
-
const
|
|
310
|
-
const outcome =
|
|
311
|
-
if (
|
|
498
|
+
const text2 = line.trim();
|
|
499
|
+
const outcome = text2.startsWith(".") ? await dotLine(bridge, text2) : await runLine(bridge, line);
|
|
500
|
+
if (text2.startsWith(".") && outcome === null) break;
|
|
312
501
|
if (!outcome) continue;
|
|
313
502
|
result.calls++;
|
|
314
503
|
const raw = outcome;
|
|
@@ -323,7 +512,7 @@ async function runBatch(bridge, input, options = {}) {
|
|
|
323
512
|
record.value = await shapedResult(raw.value, {
|
|
324
513
|
full: options.full,
|
|
325
514
|
outKind: "dir",
|
|
326
|
-
out: options.outDir ?
|
|
515
|
+
out: options.outDir ? join3(
|
|
327
516
|
options.outDir,
|
|
328
517
|
`${result.calls}-${String(raw.tool).replace(/[^\w.-]/g, "_")}.json`
|
|
329
518
|
) : void 0
|
|
@@ -352,13 +541,27 @@ async function runBatch(bridge, input, options = {}) {
|
|
|
352
541
|
return result;
|
|
353
542
|
}
|
|
354
543
|
|
|
544
|
+
// src/client/snapshot-format.ts
|
|
545
|
+
function withCompactSnapshot(tool, args, json = false) {
|
|
546
|
+
if (tool !== "screen.snapshot" || args.length > 1) return args;
|
|
547
|
+
const options = args[0];
|
|
548
|
+
if (options !== void 0 && (options === null || typeof options !== "object" || Array.isArray(options)))
|
|
549
|
+
return args;
|
|
550
|
+
const given = options ?? {};
|
|
551
|
+
if (given.format === "text" && json)
|
|
552
|
+
throw new Error('--json and {"format":"text"} ask for different formats; drop one');
|
|
553
|
+
if (given.format !== void 0) return args;
|
|
554
|
+
return [{ ...given, format: json ? "json" : "text" }];
|
|
555
|
+
}
|
|
556
|
+
var isSnapshotText = (tool, value) => tool === "screen.snapshot" && typeof value === "string";
|
|
557
|
+
|
|
355
558
|
// src/client/repl.ts
|
|
356
559
|
import { appendFileSync, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
|
|
357
560
|
import { homedir } from "os";
|
|
358
|
-
import { dirname, join as
|
|
561
|
+
import { dirname as dirname2, join as join4 } from "path";
|
|
359
562
|
import { createInterface as createInterface2 } from "readline";
|
|
360
563
|
var HISTORY_LIMIT = 500;
|
|
361
|
-
var DEFAULT_HISTORY_FILE =
|
|
564
|
+
var DEFAULT_HISTORY_FILE = join4(
|
|
362
565
|
homedir(),
|
|
363
566
|
".agent-bridge",
|
|
364
567
|
"repl_history"
|
|
@@ -410,7 +613,7 @@ function loadHistory(file) {
|
|
|
410
613
|
}
|
|
411
614
|
function saveHistory(file, lines) {
|
|
412
615
|
try {
|
|
413
|
-
mkdirSync(
|
|
616
|
+
mkdirSync(dirname2(file), { recursive: true });
|
|
414
617
|
writeFileSync(file, `${lines.slice(-HISTORY_LIMIT).join("\n")}
|
|
415
618
|
`, {
|
|
416
619
|
mode: 384
|
|
@@ -420,7 +623,7 @@ function saveHistory(file, lines) {
|
|
|
420
623
|
}
|
|
421
624
|
function appendHistory(file, line) {
|
|
422
625
|
try {
|
|
423
|
-
mkdirSync(
|
|
626
|
+
mkdirSync(dirname2(file), { recursive: true });
|
|
424
627
|
appendFileSync(file, `${line}
|
|
425
628
|
`, { mode: 384 });
|
|
426
629
|
} catch {
|
|
@@ -432,9 +635,9 @@ async function runRepl(bridge, options) {
|
|
|
432
635
|
const { output } = options;
|
|
433
636
|
const tty = !!options.tty;
|
|
434
637
|
const { historyFile } = options;
|
|
435
|
-
const paint = (code,
|
|
436
|
-
const print = (
|
|
437
|
-
output.write(`${
|
|
638
|
+
const paint = (code, text2) => tty && !process.env.NO_COLOR ? `\x1B[${code}m${text2}\x1B[0m` : text2;
|
|
639
|
+
const print = (text2 = "") => {
|
|
640
|
+
output.write(`${text2}
|
|
438
641
|
`);
|
|
439
642
|
};
|
|
440
643
|
const toolNames = () => bridge.tools().map((t) => t.name);
|
|
@@ -587,21 +790,201 @@ import { spawn } from "child_process";
|
|
|
587
790
|
import { existsSync, readFileSync as readFileSync3, rmSync as rmSync2 } from "fs";
|
|
588
791
|
|
|
589
792
|
// src/client/session/daemon.ts
|
|
793
|
+
import { randomBytes } from "crypto";
|
|
590
794
|
import { appendFileSync as appendFileSync2, chmodSync, rmSync } from "fs";
|
|
591
795
|
import { createServer } from "net";
|
|
592
796
|
import { createInterface as createInterface3 } from "readline";
|
|
797
|
+
|
|
798
|
+
// src/client/session/journal.ts
|
|
799
|
+
var MAX_TRIES = 3;
|
|
800
|
+
var lostConnection = (r) => !r.ok && (r.from === "" || /socket closed|closed the debugger|not open/i.test(r.error));
|
|
801
|
+
var plain = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
802
|
+
var isStore = (v) => plain(v.original) && plain(v.after);
|
|
803
|
+
function mergeStore(old, next) {
|
|
804
|
+
const known = /* @__PURE__ */ new Set([...Object.keys(old.original), ...Object.keys(old.after), ...old.skipped ?? []]);
|
|
805
|
+
const original = { ...old.original };
|
|
806
|
+
const after = { ...old.after };
|
|
807
|
+
const touched = /* @__PURE__ */ new Set([...Object.keys(next.original), ...Object.keys(next.after), ...next.skipped ?? []]);
|
|
808
|
+
for (const k of touched) {
|
|
809
|
+
if (!known.has(k) && k in next.original) original[k] = next.original[k];
|
|
810
|
+
if (k in next.after) after[k] = next.after[k];
|
|
811
|
+
else delete after[k];
|
|
812
|
+
}
|
|
813
|
+
const skipped = [.../* @__PURE__ */ new Set([...old.skipped ?? [], ...next.skipped ?? []])];
|
|
814
|
+
return { original, after, ...skipped.length && { skipped } };
|
|
815
|
+
}
|
|
816
|
+
function merge(carry, data) {
|
|
817
|
+
for (const [area, part] of Object.entries(data)) {
|
|
818
|
+
const old = carry[area];
|
|
819
|
+
if (!plain(old) || !plain(part)) {
|
|
820
|
+
carry[area] = part;
|
|
821
|
+
continue;
|
|
822
|
+
}
|
|
823
|
+
const next = { ...old };
|
|
824
|
+
for (const [name, value] of Object.entries(part)) {
|
|
825
|
+
const was = old[name];
|
|
826
|
+
next[name] = plain(was) && plain(value) ? isStore(was) && isStore(value) ? mergeStore(was, value) : { ...value, ...was } : value;
|
|
827
|
+
}
|
|
828
|
+
carry[area] = next;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
var names = (part) => plain(part) ? Object.keys(part) : [];
|
|
832
|
+
function createJournal(call, firstLoad, alive = async () => false) {
|
|
833
|
+
let data = {};
|
|
834
|
+
let tag = "";
|
|
835
|
+
let load = firstLoad;
|
|
836
|
+
const carry = {};
|
|
837
|
+
let tries = 0;
|
|
838
|
+
let lastWaiting = "";
|
|
839
|
+
const why = (area, part, reason2) => {
|
|
840
|
+
const keys = names(part);
|
|
841
|
+
return keys.length ? keys.map((n) => `${area}:${n} (${reason2(n)})`) : [`${area} (${reason2(area)})`];
|
|
842
|
+
};
|
|
843
|
+
const gaveUp = (reasons) => {
|
|
844
|
+
const left = Object.entries(carry).flatMap(
|
|
845
|
+
([area, part]) => why(area, part, () => reasons[area] ?? "not ready")
|
|
846
|
+
);
|
|
847
|
+
for (const k of Object.keys(carry)) delete carry[k];
|
|
848
|
+
return left.length ? `could not re-arm after the reload: ${left.join(", ")}` : "";
|
|
849
|
+
};
|
|
850
|
+
return {
|
|
851
|
+
/** The tag to send with every call. */
|
|
852
|
+
ack: () => tag,
|
|
853
|
+
/** Takes the journal a reply carries; one from another load is dropped. */
|
|
854
|
+
accept(r) {
|
|
855
|
+
if (r.journalData === void 0 || !r.loadId || r.loadId !== load) return;
|
|
856
|
+
data = r.journalData;
|
|
857
|
+
tag = `${r.loadId}:${r.journal ?? 0}`;
|
|
858
|
+
},
|
|
859
|
+
/** The app reloaded into `newLoad`: what the old load held moves to `carry`. Returns the pending areas that had nothing persistent. */
|
|
860
|
+
reloaded(lastPending, newLoad) {
|
|
861
|
+
merge(carry, data);
|
|
862
|
+
data = {};
|
|
863
|
+
tag = "";
|
|
864
|
+
load = newLoad;
|
|
865
|
+
tries = 0;
|
|
866
|
+
lastWaiting = "";
|
|
867
|
+
return lastPending.filter((area) => !(area in carry));
|
|
868
|
+
},
|
|
869
|
+
hasCarry: () => Object.keys(carry).length > 0,
|
|
870
|
+
/** The load the journal is for now. */
|
|
871
|
+
currentLoad: () => load,
|
|
872
|
+
/** Hands `carry` to the new load. Resolves to a note ('' when there was nothing to say). */
|
|
873
|
+
async adopt() {
|
|
874
|
+
if (!Object.keys(carry).length) return "";
|
|
875
|
+
const sent = structuredClone(carry);
|
|
876
|
+
const sentLoad = load;
|
|
877
|
+
let r;
|
|
878
|
+
try {
|
|
879
|
+
r = await call("bridge.adopt", [sent]);
|
|
880
|
+
} catch {
|
|
881
|
+
if (!await alive().catch(() => false)) return "";
|
|
882
|
+
r = { id: "", from: "app", ok: false, error: "timed out", ms: 0 };
|
|
883
|
+
}
|
|
884
|
+
if (lostConnection({ ...r })) return "";
|
|
885
|
+
if (load !== sentLoad || r.loadId && sentLoad && r.loadId !== sentLoad) return "";
|
|
886
|
+
this.accept(r);
|
|
887
|
+
tries += 1;
|
|
888
|
+
const bits = [];
|
|
889
|
+
const reasons = {};
|
|
890
|
+
if (!r.ok) {
|
|
891
|
+
for (const area of Object.keys(sent)) reasons[area] = r.error;
|
|
892
|
+
} else {
|
|
893
|
+
const reply = r.value;
|
|
894
|
+
const adopted = [];
|
|
895
|
+
const already = [];
|
|
896
|
+
const changed = [];
|
|
897
|
+
const invalid = [];
|
|
898
|
+
const skipped = [];
|
|
899
|
+
const notKept = [];
|
|
900
|
+
for (const area of Object.keys(sent)) {
|
|
901
|
+
const part = reply[area];
|
|
902
|
+
if (!part) continue;
|
|
903
|
+
adopted.push(...part.adopted ?? []);
|
|
904
|
+
already.push(...part.already ?? []);
|
|
905
|
+
changed.push(...part.changed ?? []);
|
|
906
|
+
invalid.push(...part.invalid ?? []);
|
|
907
|
+
skipped.push(...part.skipped ?? []);
|
|
908
|
+
notKept.push(...part.notKept ?? []);
|
|
909
|
+
if (part.retry !== void 0) {
|
|
910
|
+
carry[area] = part.retry;
|
|
911
|
+
reasons[area] = part.error ?? "not ready";
|
|
912
|
+
} else delete carry[area];
|
|
913
|
+
}
|
|
914
|
+
if (adopted.length)
|
|
915
|
+
bits.push(`kept ${adopted.length} undo${adopted.length === 1 ? "" : "s"} across the reload (${adopted.join(", ")}); bridge.restore still puts them back`);
|
|
916
|
+
if (already.length) bits.push(`already back to the original: ${already.join(", ")}`);
|
|
917
|
+
if (changed.length) bits.push(`left alone, the app changed them since: ${changed.join(", ")}`);
|
|
918
|
+
if (skipped.length) bits.push(`left alone, a different storage instance than the one you wrote: ${skipped.join(", ")}`);
|
|
919
|
+
if (notKept.length) bits.push(`not kept across reload (not plain JSON, renamed by partialize, or hidden by redact): ${notKept.join(", ")}`);
|
|
920
|
+
if (invalid.length) bits.push(`ignored, not a value this session saved: ${invalid.join(", ")}`);
|
|
921
|
+
}
|
|
922
|
+
if (Object.keys(carry).length) {
|
|
923
|
+
if (tries >= MAX_TRIES) {
|
|
924
|
+
bits.push(gaveUp(reasons));
|
|
925
|
+
lastWaiting = "";
|
|
926
|
+
} else {
|
|
927
|
+
const waiting = Object.entries(carry).flatMap(([area, part]) => why(area, part, () => reasons[area] ?? "not ready")).join(", ");
|
|
928
|
+
if (waiting !== lastWaiting) bits.push(`not ready yet, will retry: ${waiting}`);
|
|
929
|
+
lastWaiting = waiting;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
return bits.join("; ");
|
|
933
|
+
},
|
|
934
|
+
/** At session end: drops what is still waiting and says so. */
|
|
935
|
+
giveUp: () => gaveUp({})
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// src/client/session/daemon.ts
|
|
593
940
|
var RELOAD = "app.reload";
|
|
594
|
-
var
|
|
941
|
+
var REPEATABLE = /* @__PURE__ */ new Set([
|
|
942
|
+
"bridge.ping",
|
|
943
|
+
"bridge.pending",
|
|
944
|
+
"bridge.tools",
|
|
945
|
+
"screen.snapshot",
|
|
946
|
+
"screen.findText",
|
|
947
|
+
"screen.waitFor",
|
|
948
|
+
"screen.capture",
|
|
949
|
+
"store.get",
|
|
950
|
+
"store.list",
|
|
951
|
+
"query.get",
|
|
952
|
+
"query.list",
|
|
953
|
+
"mmkv.get",
|
|
954
|
+
"mmkv.keys",
|
|
955
|
+
"mmkv.list",
|
|
956
|
+
"net.entry",
|
|
957
|
+
"net.mocks",
|
|
958
|
+
"router.current",
|
|
959
|
+
"scenario.list",
|
|
960
|
+
"webview.list",
|
|
961
|
+
"webview.url",
|
|
962
|
+
"webview.messages",
|
|
963
|
+
"webview.message",
|
|
964
|
+
"webview.snapshot",
|
|
965
|
+
"webview.waitFor",
|
|
966
|
+
"realtime.channels"
|
|
967
|
+
]);
|
|
968
|
+
var LOG_TOOLS = /* @__PURE__ */ new Set(["net.log", "realtime.log", "bridge.logs"]);
|
|
969
|
+
var WAIT_TOOLS = /* @__PURE__ */ new Set(["screen.waitFor", "webview.waitFor"]);
|
|
970
|
+
var WAIT_DEFAULT_MS = 5e3;
|
|
971
|
+
var repeatable = (tool, args) => REPEATABLE.has(tool) || LOG_TOOLS.has(tool) && !args.some((a) => a?.clear);
|
|
972
|
+
var withTimeLeft = (args, leftMs) => {
|
|
973
|
+
const next = [...args];
|
|
974
|
+
const options = next[1] ?? {};
|
|
975
|
+
next[1] = { ...options, timeoutMs: Math.max(1, Math.round(leftMs)) };
|
|
976
|
+
return next;
|
|
977
|
+
};
|
|
978
|
+
var pause = (ms, signal) => new Promise((resolve4) => {
|
|
595
979
|
const done = () => {
|
|
596
980
|
clearTimeout(timer);
|
|
597
981
|
signal.removeEventListener("abort", done);
|
|
598
|
-
|
|
982
|
+
resolve4();
|
|
599
983
|
};
|
|
600
984
|
const timer = setTimeout(done, ms);
|
|
601
985
|
signal.addEventListener("abort", done, { once: true });
|
|
602
986
|
});
|
|
603
987
|
var message = (error) => error instanceof Error ? error.message : String(error);
|
|
604
|
-
var lostConnection = (r) => !r.ok && (r.from === "" || /socket closed|closed the debugger|not open/i.test(r.error));
|
|
605
988
|
async function runSessionDaemon(options) {
|
|
606
989
|
const name = checkName(options.name);
|
|
607
990
|
const dir = stateDir();
|
|
@@ -609,7 +992,7 @@ async function runSessionDaemon(options) {
|
|
|
609
992
|
const metro = metroHost(options.metro);
|
|
610
993
|
const timeoutMs = options.timeoutMs ?? 1e4;
|
|
611
994
|
const reloadWaitMs = options.reloadWaitMs ?? 2e4;
|
|
612
|
-
const log = (
|
|
995
|
+
const log = (text2) => appendFileSync2(files.log, `${(/* @__PURE__ */ new Date()).toISOString()} ${text2}
|
|
613
996
|
`, {
|
|
614
997
|
mode: 384
|
|
615
998
|
});
|
|
@@ -636,7 +1019,9 @@ async function runSessionDaemon(options) {
|
|
|
636
1019
|
`${c.device.name} already belongs to session "${owner.name}". Use --session ${owner.name}, or stop it first.`
|
|
637
1020
|
);
|
|
638
1021
|
};
|
|
639
|
-
let conn = await reach()
|
|
1022
|
+
let conn = await reach().catch(async (error) => {
|
|
1023
|
+
throw await explainConnectError(error, metro, options.device);
|
|
1024
|
+
});
|
|
640
1025
|
claim(conn);
|
|
641
1026
|
const pin = conn.pin;
|
|
642
1027
|
const deviceOf = (c) => ({
|
|
@@ -664,20 +1049,68 @@ async function runSessionDaemon(options) {
|
|
|
664
1049
|
let lastPending = [];
|
|
665
1050
|
const reloads = [];
|
|
666
1051
|
let unseen = [];
|
|
667
|
-
const
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
1052
|
+
const journalKey = randomBytes(16).toString("hex");
|
|
1053
|
+
const send = (tool, args, ms) => conn.call(tool, args, ms, { journalAck: journal.ack(), journalKey });
|
|
1054
|
+
const adoptTimeoutMs = options.adoptTimeoutMs ?? Math.max(timeoutMs, 3500);
|
|
1055
|
+
const journal = createJournal(
|
|
1056
|
+
(tool, args) => send(tool, args, adoptTimeoutMs),
|
|
1057
|
+
conn.device.loadId,
|
|
1058
|
+
() => ping()
|
|
1059
|
+
);
|
|
1060
|
+
const addNote = (note) => {
|
|
671
1061
|
reloads.push(note);
|
|
672
1062
|
unseen.push(note);
|
|
673
1063
|
log(`warning: ${note}`);
|
|
674
1064
|
};
|
|
1065
|
+
const adoptRetryMs = options.adoptRetryMs ?? 1e3;
|
|
1066
|
+
let adoptRun = null;
|
|
1067
|
+
let adoptLoad;
|
|
1068
|
+
let nextAdopt = 0;
|
|
1069
|
+
const adoptJournal = async (force = false) => {
|
|
1070
|
+
if (adoptRun) {
|
|
1071
|
+
const startedFor = adoptLoad;
|
|
1072
|
+
await adoptRun;
|
|
1073
|
+
if (!force || startedFor === journal.currentLoad()) return;
|
|
1074
|
+
if (adoptRun) return adoptRun;
|
|
1075
|
+
}
|
|
1076
|
+
if (!journal.hasCarry() || !force && Date.now() < nextAdopt) return;
|
|
1077
|
+
inflight++;
|
|
1078
|
+
adoptLoad = journal.currentLoad();
|
|
1079
|
+
adoptRun = journal.adopt().then(
|
|
1080
|
+
(note) => {
|
|
1081
|
+
if (note) addNote(`app reloaded; ${note}`);
|
|
1082
|
+
},
|
|
1083
|
+
() => {
|
|
1084
|
+
}
|
|
1085
|
+
).finally(() => {
|
|
1086
|
+
inflight--;
|
|
1087
|
+
nextAdopt = Date.now() + adoptRetryMs;
|
|
1088
|
+
adoptRun = null;
|
|
1089
|
+
});
|
|
1090
|
+
return adoptRun;
|
|
1091
|
+
};
|
|
1092
|
+
const noteReload = (newLoad) => {
|
|
1093
|
+
const lost = journal.reloaded(lastPending, newLoad);
|
|
1094
|
+
lastPending = [];
|
|
1095
|
+
if (lost.length || !journal.hasCarry())
|
|
1096
|
+
addNote(
|
|
1097
|
+
`app reloaded; ${lost.length} pending restore${lost.length === 1 ? "" : "s"} lost${lost.length ? `: ${lost.join(", ")}` : ""}`
|
|
1098
|
+
);
|
|
1099
|
+
nextAdopt = 0;
|
|
1100
|
+
void adoptJournal(true);
|
|
1101
|
+
};
|
|
675
1102
|
const observe = (r) => {
|
|
676
1103
|
if (!r.loadId) return;
|
|
677
|
-
if (knownLoad && r.loadId !== knownLoad) noteReload();
|
|
1104
|
+
if (knownLoad && r.loadId !== knownLoad) noteReload(r.loadId);
|
|
678
1105
|
knownLoad = r.loadId;
|
|
679
1106
|
lastPending = r.pending ?? [];
|
|
680
1107
|
};
|
|
1108
|
+
const seen = (r) => {
|
|
1109
|
+
observe(r);
|
|
1110
|
+
journal.accept(r);
|
|
1111
|
+
const { journalData: _, ...rest } = r;
|
|
1112
|
+
return rest;
|
|
1113
|
+
};
|
|
681
1114
|
const takeNotice = () => {
|
|
682
1115
|
const notice = unseen.join("; ");
|
|
683
1116
|
unseen = [];
|
|
@@ -686,9 +1119,9 @@ async function runSessionDaemon(options) {
|
|
|
686
1119
|
let reloadingUntil = 0;
|
|
687
1120
|
let stale = false;
|
|
688
1121
|
let reconnecting = null;
|
|
689
|
-
const reconnect = (
|
|
1122
|
+
const reconnect = (reason2) => {
|
|
690
1123
|
reconnecting ??= (async () => {
|
|
691
|
-
log(`reconnecting: ${
|
|
1124
|
+
log(`reconnecting: ${reason2}`);
|
|
692
1125
|
conn.close();
|
|
693
1126
|
const patient = Date.now() < reloadingUntil;
|
|
694
1127
|
const deadline = Date.now() + (patient ? reloadWaitMs : 0);
|
|
@@ -707,7 +1140,7 @@ async function runSessionDaemon(options) {
|
|
|
707
1140
|
stale = true;
|
|
708
1141
|
log(`reconnect failed: ${message(error)}`);
|
|
709
1142
|
throw new Error(
|
|
710
|
-
patient ? `The app is reloading; its bridge isn't ready yet (${message(error)})` : `The app is gone (${
|
|
1143
|
+
patient ? `The app is reloading; its bridge isn't ready yet (${message(error)})` : `The app is gone (${reason2}). Reconnecting failed: ${message(error)}`
|
|
711
1144
|
);
|
|
712
1145
|
}
|
|
713
1146
|
await pause(300, stopping.signal);
|
|
@@ -716,7 +1149,7 @@ async function runSessionDaemon(options) {
|
|
|
716
1149
|
stale = false;
|
|
717
1150
|
reloadingUntil = 0;
|
|
718
1151
|
if (conn.device.loadId) {
|
|
719
|
-
if (knownLoad && conn.device.loadId !== knownLoad) noteReload();
|
|
1152
|
+
if (knownLoad && conn.device.loadId !== knownLoad) noteReload(conn.device.loadId);
|
|
720
1153
|
knownLoad = conn.device.loadId;
|
|
721
1154
|
}
|
|
722
1155
|
state.device = deviceOf(conn);
|
|
@@ -728,8 +1161,8 @@ async function runSessionDaemon(options) {
|
|
|
728
1161
|
});
|
|
729
1162
|
return reconnecting;
|
|
730
1163
|
};
|
|
731
|
-
const connected = async (
|
|
732
|
-
const attempt2 = reconnecting ?? reconnect(
|
|
1164
|
+
const connected = async (reason2 = "app stopped answering") => {
|
|
1165
|
+
const attempt2 = reconnecting ?? reconnect(reason2);
|
|
733
1166
|
const wait = Date.now() < reloadingUntil ? reloadWaitMs + 2 * attemptMs + 500 : 2 * attemptMs + 500;
|
|
734
1167
|
let timer2;
|
|
735
1168
|
try {
|
|
@@ -750,16 +1183,16 @@ async function runSessionDaemon(options) {
|
|
|
750
1183
|
clearTimeout(timer2);
|
|
751
1184
|
}
|
|
752
1185
|
};
|
|
753
|
-
const ping = (ms = 1500) =>
|
|
1186
|
+
const ping = (ms = 1500) => send("bridge.ping", [], Math.min(ms, 1500)).then(
|
|
754
1187
|
(r) => {
|
|
755
|
-
if (r.ok)
|
|
1188
|
+
if (r.ok) seen(r);
|
|
756
1189
|
return r.ok;
|
|
757
1190
|
},
|
|
758
1191
|
() => false
|
|
759
1192
|
);
|
|
760
1193
|
const attempt = async (tool, args, ms) => {
|
|
761
1194
|
try {
|
|
762
|
-
const result = await
|
|
1195
|
+
const result = await send(tool, args, ms);
|
|
763
1196
|
return lostConnection(result) ? { lost: result.error } : { result };
|
|
764
1197
|
} catch (error) {
|
|
765
1198
|
if (await ping(ms)) throw error;
|
|
@@ -768,12 +1201,15 @@ async function runSessionDaemon(options) {
|
|
|
768
1201
|
};
|
|
769
1202
|
const callApp = async (tool, args, ms) => {
|
|
770
1203
|
if (stale || reconnecting) await connected();
|
|
1204
|
+
await adoptJournal(tool === "bridge.restore");
|
|
1205
|
+
if (tool === RELOAD) await ping();
|
|
771
1206
|
let t0 = performance.now();
|
|
1207
|
+
const start2 = t0;
|
|
772
1208
|
const first = await attempt(tool, args, ms);
|
|
773
1209
|
if (first.result) {
|
|
774
|
-
|
|
775
|
-
if (tool === RELOAD &&
|
|
776
|
-
return { result
|
|
1210
|
+
const result = seen(first.result);
|
|
1211
|
+
if (tool === RELOAD && result.ok) waitForReload();
|
|
1212
|
+
return { result, ms: performance.now() - t0 };
|
|
777
1213
|
}
|
|
778
1214
|
if (tool === RELOAD) {
|
|
779
1215
|
waitForReload();
|
|
@@ -786,20 +1222,48 @@ async function runSessionDaemon(options) {
|
|
|
786
1222
|
};
|
|
787
1223
|
return { result, ms: performance.now() - t0 };
|
|
788
1224
|
}
|
|
789
|
-
|
|
1225
|
+
const before = knownLoad;
|
|
1226
|
+
const goneWait = WAIT_TOOLS.has(tool) && args[1]?.gone;
|
|
1227
|
+
let reconnectFailure = "";
|
|
1228
|
+
try {
|
|
1229
|
+
await connected(first.lost);
|
|
1230
|
+
} catch (error) {
|
|
1231
|
+
reconnectFailure = message(error);
|
|
1232
|
+
}
|
|
1233
|
+
const reloaded = knownLoad !== before;
|
|
1234
|
+
if (!reconnectFailure) await sync();
|
|
1235
|
+
const again = repeatable(tool, args) && !(goneWait && reloaded);
|
|
1236
|
+
if (again && reconnectFailure) throw new Error(reconnectFailure);
|
|
1237
|
+
if (!again) {
|
|
1238
|
+
if ((reloaded || reconnectFailure) && /^(mmkv|store)\./.test(tool))
|
|
1239
|
+
addNote(
|
|
1240
|
+
`${tool} was cut off; if it wrote to MMKV or a persisted store, that undo may be lost`
|
|
1241
|
+
);
|
|
1242
|
+
const how = reloaded ? "the app reloaded" : before ? "the app disconnected" : "the connection dropped";
|
|
1243
|
+
throw new Error(
|
|
1244
|
+
`${how} during ${tool}; it may or may not have run (${first.lost})${reconnectFailure ? `; reconnecting failed: ${reconnectFailure}` : ""}`
|
|
1245
|
+
);
|
|
1246
|
+
}
|
|
790
1247
|
t0 = performance.now();
|
|
791
|
-
const
|
|
1248
|
+
const retryArgs = WAIT_TOOLS.has(tool) ? withTimeLeft(
|
|
1249
|
+
args,
|
|
1250
|
+
(args[1]?.timeoutMs ?? WAIT_DEFAULT_MS) - (t0 - start2)
|
|
1251
|
+
) : args;
|
|
1252
|
+
const second = await attempt(tool, retryArgs, ms);
|
|
792
1253
|
if (!second.result)
|
|
793
1254
|
throw new Error(
|
|
794
1255
|
`The app stopped answering after a reconnect: ${second.lost}`
|
|
795
1256
|
);
|
|
796
|
-
observe(second.result);
|
|
797
1257
|
return {
|
|
798
|
-
result: second.result,
|
|
1258
|
+
result: seen(second.result),
|
|
799
1259
|
ms: performance.now() - t0,
|
|
800
1260
|
reconnected: true
|
|
801
1261
|
};
|
|
802
1262
|
};
|
|
1263
|
+
const sync = async () => {
|
|
1264
|
+
if (journal.hasCarry()) await adoptJournal(true);
|
|
1265
|
+
else await ping();
|
|
1266
|
+
};
|
|
803
1267
|
const waitForReload = () => {
|
|
804
1268
|
reloadingUntil = Date.now() + reloadWaitMs;
|
|
805
1269
|
stale = true;
|
|
@@ -822,19 +1286,47 @@ async function runSessionDaemon(options) {
|
|
|
822
1286
|
};
|
|
823
1287
|
const health = options.healthMs === 0 ? void 0 : setInterval(async () => {
|
|
824
1288
|
if (inflight || reconnecting || finishing) return;
|
|
825
|
-
|
|
1289
|
+
const pinged = conn;
|
|
1290
|
+
const answered = await ping();
|
|
1291
|
+
if (inflight || reconnecting || finishing || conn !== pinged) return;
|
|
1292
|
+
if (stale || !answered)
|
|
826
1293
|
await reconnect("no answer to bridge.ping").catch(() => {
|
|
827
1294
|
});
|
|
828
1295
|
}, options.healthMs ?? 5e3);
|
|
829
1296
|
const timer = health;
|
|
830
1297
|
timer?.unref?.();
|
|
1298
|
+
const restoreOnce = async () => {
|
|
1299
|
+
const fail = (error) => ({ id: "restore", from: "", ok: false, error, ms: 0 });
|
|
1300
|
+
const run = (tool) => attempt(tool, [], timeoutMs);
|
|
1301
|
+
try {
|
|
1302
|
+
if (stale || reconnecting || !await ping()) await connected("no answer before restore");
|
|
1303
|
+
for (let i = 0; i < 3 && journal.hasCarry(); i++) {
|
|
1304
|
+
if (i) await new Promise((r) => setTimeout(r, Math.min(adoptRetryMs, 500)));
|
|
1305
|
+
await adoptJournal(true);
|
|
1306
|
+
}
|
|
1307
|
+
const first = await run("bridge.restore");
|
|
1308
|
+
if (first.result) return seen(first.result);
|
|
1309
|
+
await connected(first.lost);
|
|
1310
|
+
await adoptJournal(true);
|
|
1311
|
+
const check = await run("bridge.pending");
|
|
1312
|
+
if (!check.result) return fail(`restore may or may not have run (${first.lost})`);
|
|
1313
|
+
const left = seen(check.result);
|
|
1314
|
+
if (!left.ok) return fail(`restore may or may not have run (${first.lost}); ${left.error}`);
|
|
1315
|
+
if (!Object.keys(left.value).length)
|
|
1316
|
+
return { ...left, id: "restore", value: { note: "the reply was lost; nothing is pending now" } };
|
|
1317
|
+
const second = await run("bridge.restore");
|
|
1318
|
+
return second.result ? seen(second.result) : fail(`The app stopped answering after a reconnect: ${second.lost}`);
|
|
1319
|
+
} catch (error) {
|
|
1320
|
+
return fail(message(error));
|
|
1321
|
+
}
|
|
1322
|
+
};
|
|
831
1323
|
let finishing = null;
|
|
832
1324
|
let resolveDone = () => {
|
|
833
1325
|
};
|
|
834
1326
|
const done = new Promise((r) => {
|
|
835
1327
|
resolveDone = r;
|
|
836
1328
|
});
|
|
837
|
-
const teardown = (
|
|
1329
|
+
const teardown = (reason2, keep) => {
|
|
838
1330
|
finishing ??= (async () => {
|
|
839
1331
|
clearTimeout(idleTimer);
|
|
840
1332
|
clearInterval(health);
|
|
@@ -842,16 +1334,9 @@ async function runSessionDaemon(options) {
|
|
|
842
1334
|
const cut = setTimeout(() => cutOff.abort(), Math.min(attemptMs, 5e3));
|
|
843
1335
|
let restore = null;
|
|
844
1336
|
if (!keep) {
|
|
845
|
-
restore = await
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
id: "restore",
|
|
849
|
-
from: "",
|
|
850
|
-
ok: false,
|
|
851
|
-
error: message(error),
|
|
852
|
-
ms: 0
|
|
853
|
-
})
|
|
854
|
-
);
|
|
1337
|
+
restore = await restoreOnce();
|
|
1338
|
+
const left = journal.giveUp();
|
|
1339
|
+
if (left) addNote(`app reloaded; ${left}`);
|
|
855
1340
|
}
|
|
856
1341
|
clearTimeout(cut);
|
|
857
1342
|
cutOff.abort();
|
|
@@ -859,10 +1344,10 @@ async function runSessionDaemon(options) {
|
|
|
859
1344
|
server.close();
|
|
860
1345
|
removeSessionFiles(name, dir);
|
|
861
1346
|
const outcome = !restore ? "kept app state" : restore.ok ? `restore: ${JSON.stringify(restore.value)}` : `restore: ${restore.error}`;
|
|
862
|
-
log(`stopped (${
|
|
1347
|
+
log(`stopped (${reason2}); ${outcome}`);
|
|
863
1348
|
const deadline = Date.now() + 2e3;
|
|
864
1349
|
const settle = () => {
|
|
865
|
-
if (!clients.size || Date.now() > deadline) resolveDone(
|
|
1350
|
+
if (!clients.size || Date.now() > deadline) resolveDone(reason2);
|
|
866
1351
|
else setTimeout(settle, 10);
|
|
867
1352
|
};
|
|
868
1353
|
settle();
|
|
@@ -884,16 +1369,21 @@ async function runSessionDaemon(options) {
|
|
|
884
1369
|
switch (req.op) {
|
|
885
1370
|
case "call": {
|
|
886
1371
|
if (!req.tool) return { id: req.id, error: "call needs a tool" };
|
|
1372
|
+
if (req.tool === "bridge.adopt")
|
|
1373
|
+
return { id: req.id, error: "bridge.adopt is for a session's own use" };
|
|
887
1374
|
inflight++;
|
|
888
1375
|
touch();
|
|
889
1376
|
try {
|
|
1377
|
+
const args = req.args ?? [];
|
|
890
1378
|
const answer = await callApp(
|
|
891
1379
|
req.tool,
|
|
892
|
-
|
|
893
|
-
req.timeoutMs ?? timeoutMs
|
|
1380
|
+
args,
|
|
1381
|
+
callTimeout(req.timeoutMs ?? timeoutMs, req.tool, args)
|
|
894
1382
|
);
|
|
1383
|
+
await adoptRun;
|
|
895
1384
|
return { id: req.id, ...answer, ...takeNotice() };
|
|
896
1385
|
} catch (error) {
|
|
1386
|
+
await adoptRun;
|
|
897
1387
|
return { id: req.id, error: message(error), ...takeNotice() };
|
|
898
1388
|
} finally {
|
|
899
1389
|
inflight--;
|
|
@@ -936,7 +1426,10 @@ async function runSessionDaemon(options) {
|
|
|
936
1426
|
socket.on("close", () => clients.delete(socket));
|
|
937
1427
|
socket.on("error", () => {
|
|
938
1428
|
});
|
|
939
|
-
createInterface3({ input: socket })
|
|
1429
|
+
const lines = createInterface3({ input: socket });
|
|
1430
|
+
lines.on("error", () => {
|
|
1431
|
+
});
|
|
1432
|
+
lines.on("line", async (line) => {
|
|
940
1433
|
let req;
|
|
941
1434
|
try {
|
|
942
1435
|
req = JSON.parse(line);
|
|
@@ -953,11 +1446,11 @@ async function runSessionDaemon(options) {
|
|
|
953
1446
|
});
|
|
954
1447
|
if (process.platform !== "win32") rmSync(files.socket, { force: true });
|
|
955
1448
|
try {
|
|
956
|
-
await new Promise((
|
|
1449
|
+
await new Promise((resolve4, reject) => {
|
|
957
1450
|
server.once("error", reject);
|
|
958
1451
|
server.listen(files.socket, () => {
|
|
959
1452
|
server.off("error", reject);
|
|
960
|
-
|
|
1453
|
+
resolve4();
|
|
961
1454
|
});
|
|
962
1455
|
});
|
|
963
1456
|
} catch (error) {
|
|
@@ -984,13 +1477,14 @@ async function runSessionDaemon(options) {
|
|
|
984
1477
|
|
|
985
1478
|
// src/client/session/cli.ts
|
|
986
1479
|
var DAEMON_COMMAND = "__session-daemon";
|
|
987
|
-
function sessionFor(flags) {
|
|
1480
|
+
function sessionFor(flags, skip) {
|
|
988
1481
|
if (flags["no-session"]) return null;
|
|
989
1482
|
return pickSession({
|
|
990
1483
|
name: flags.session ?? process.env.AGENT_BRIDGE_SESSION,
|
|
991
1484
|
metro: explicitMetro(flags.metro),
|
|
992
1485
|
device: flags.device,
|
|
993
|
-
transport: flags.transport
|
|
1486
|
+
transport: flags.transport,
|
|
1487
|
+
skip
|
|
994
1488
|
});
|
|
995
1489
|
}
|
|
996
1490
|
var describeRestore = (restore) => {
|
|
@@ -1044,7 +1538,7 @@ async function start(flags) {
|
|
|
1044
1538
|
[...process.execArgv, process.argv[1], ...args],
|
|
1045
1539
|
{ detached: true, stdio: "ignore", windowsHide: true }
|
|
1046
1540
|
);
|
|
1047
|
-
const state = await new Promise((
|
|
1541
|
+
const state = await new Promise((resolve4, reject) => {
|
|
1048
1542
|
const deadline = Date.now() + 3e4;
|
|
1049
1543
|
let exited = false;
|
|
1050
1544
|
child.once("exit", () => {
|
|
@@ -1055,13 +1549,13 @@ async function start(flags) {
|
|
|
1055
1549
|
const failed = existsSync(files.error);
|
|
1056
1550
|
if (ready && ready.pid === child.pid) {
|
|
1057
1551
|
clearInterval(tick);
|
|
1058
|
-
|
|
1552
|
+
resolve4(ready);
|
|
1059
1553
|
} else if (failed || exited || Date.now() > deadline) {
|
|
1060
1554
|
clearInterval(tick);
|
|
1061
1555
|
if (!exited) child.kill();
|
|
1062
|
-
const
|
|
1556
|
+
const reason2 = failed ? readFileSync3(files.error, "utf8") : exited ? `The session exited early; see ${files.log}` : `The session didn't connect within 30 s; see ${files.log}`;
|
|
1063
1557
|
rmSync2(files.error, { force: true });
|
|
1064
|
-
reject(new Error(
|
|
1558
|
+
reject(new Error(reason2));
|
|
1065
1559
|
}
|
|
1066
1560
|
}, 50);
|
|
1067
1561
|
});
|
|
@@ -1177,10 +1671,1675 @@ async function daemonMain(flags) {
|
|
|
1177
1671
|
process.exit(0);
|
|
1178
1672
|
}
|
|
1179
1673
|
|
|
1674
|
+
// src/init/index.ts
|
|
1675
|
+
import { spawn as spawn2 } from "child_process";
|
|
1676
|
+
import {
|
|
1677
|
+
appendFileSync as appendFileSync3,
|
|
1678
|
+
existsSync as existsSync2,
|
|
1679
|
+
mkdirSync as mkdirSync2,
|
|
1680
|
+
readdirSync,
|
|
1681
|
+
readFileSync as readFileSync4,
|
|
1682
|
+
writeFileSync as writeFileSync2
|
|
1683
|
+
} from "fs";
|
|
1684
|
+
import { dirname as dirname6, relative as relative6, resolve as resolve2, sep as sep2 } from "path";
|
|
1685
|
+
import { createInterface as createInterface4 } from "readline/promises";
|
|
1686
|
+
|
|
1687
|
+
// package.json
|
|
1688
|
+
var version = "0.6.0";
|
|
1689
|
+
|
|
1690
|
+
// src/init/detect.ts
|
|
1691
|
+
import { dirname as dirname4, join as join6, relative as relative3 } from "path";
|
|
1692
|
+
|
|
1693
|
+
// src/init/package-manager.ts
|
|
1694
|
+
import { dirname as dirname3, join as join5 } from "path";
|
|
1695
|
+
var LOCKFILES = [
|
|
1696
|
+
["bun.lock", "bun"],
|
|
1697
|
+
["bun.lockb", "bun"],
|
|
1698
|
+
["pnpm-lock.yaml", "pnpm"],
|
|
1699
|
+
["yarn.lock", "yarn"],
|
|
1700
|
+
["package-lock.json", "npm"]
|
|
1701
|
+
];
|
|
1702
|
+
var fromName = (value) => {
|
|
1703
|
+
const name = typeof value === "string" ? value.split("@")[0] : void 0;
|
|
1704
|
+
return name === "bun" || name === "pnpm" || name === "yarn" || name === "npm" ? name : void 0;
|
|
1705
|
+
};
|
|
1706
|
+
function detectPackageManager(dir, root, fs, userAgent = "") {
|
|
1707
|
+
for (const d of [dir, root]) {
|
|
1708
|
+
const pm = fromName(fs.readJson(join5(d, "package.json"))?.packageManager);
|
|
1709
|
+
if (pm) return pm;
|
|
1710
|
+
}
|
|
1711
|
+
for (let d = dir; ; d = dirname3(d)) {
|
|
1712
|
+
for (const [file, pm] of LOCKFILES) if (fs.exists(join5(d, file))) return pm;
|
|
1713
|
+
if (d === root || dirname3(d) === d) break;
|
|
1714
|
+
}
|
|
1715
|
+
return fromName(userAgent.split("/")[0]) ?? "npm";
|
|
1716
|
+
}
|
|
1717
|
+
function installCommand(pm, spec) {
|
|
1718
|
+
switch (pm) {
|
|
1719
|
+
case "bun":
|
|
1720
|
+
return ["bun", "add", "-d", spec];
|
|
1721
|
+
case "pnpm":
|
|
1722
|
+
return ["pnpm", "add", "-D", spec];
|
|
1723
|
+
case "yarn":
|
|
1724
|
+
return ["yarn", "add", "-D", spec];
|
|
1725
|
+
case "npm":
|
|
1726
|
+
return ["npm", "install", "-D", spec];
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
function binFallback(pm) {
|
|
1730
|
+
switch (pm) {
|
|
1731
|
+
case "bun":
|
|
1732
|
+
return "bunx @avasapp/agent-bridge";
|
|
1733
|
+
case "pnpm":
|
|
1734
|
+
return "pnpm exec agent-bridge";
|
|
1735
|
+
case "yarn":
|
|
1736
|
+
return "yarn agent-bridge";
|
|
1737
|
+
case "npm":
|
|
1738
|
+
return "./node_modules/.bin/agent-bridge";
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
// src/init/detect.ts
|
|
1743
|
+
var PACKAGE = "@avasapp/agent-bridge";
|
|
1744
|
+
var LIBRARIES = {
|
|
1745
|
+
expo: "expo",
|
|
1746
|
+
expoRouter: "expo-router",
|
|
1747
|
+
reactQuery: "@tanstack/react-query",
|
|
1748
|
+
webview: "react-native-webview",
|
|
1749
|
+
zustand: "zustand",
|
|
1750
|
+
redux: "@reduxjs/toolkit",
|
|
1751
|
+
reduxCore: "redux",
|
|
1752
|
+
jotai: "jotai",
|
|
1753
|
+
mmkv: "react-native-mmkv",
|
|
1754
|
+
asyncStorage: "@react-native-async-storage/async-storage",
|
|
1755
|
+
secureStore: "expo-secure-store",
|
|
1756
|
+
ably: "ably",
|
|
1757
|
+
socketIo: "socket.io-client",
|
|
1758
|
+
reactNavigation: "@react-navigation/native",
|
|
1759
|
+
typescript: "typescript"
|
|
1760
|
+
};
|
|
1761
|
+
var SOURCE_EXTS = ["tsx", "ts", "jsx", "js"];
|
|
1762
|
+
var depsOf = (pkg) => ({
|
|
1763
|
+
...pkg?.peerDependencies,
|
|
1764
|
+
...pkg?.devDependencies,
|
|
1765
|
+
...pkg?.dependencies
|
|
1766
|
+
});
|
|
1767
|
+
var isApp = (dir, fs) => !!fs.readJson(join6(dir, "package.json"))?.dependencies?.["react-native"] || ["app.json", "app.config.js", "app.config.ts", "ios", "android"].some((f) => fs.exists(join6(dir, f)));
|
|
1768
|
+
var isWorkspaceRoot = (dir, fs) => !!fs.readJson(join6(dir, "package.json"))?.workspaces || fs.exists(join6(dir, "pnpm-workspace.yaml"));
|
|
1769
|
+
function findUp(dir, fs, test) {
|
|
1770
|
+
for (let d = dir; ; d = dirname4(d)) {
|
|
1771
|
+
if (test(d)) return d;
|
|
1772
|
+
if (fs.exists(join6(d, ".git")) || dirname4(d) === d) return void 0;
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
function workspacePatterns(root, fs) {
|
|
1776
|
+
const ws = fs.readJson(join6(root, "package.json"))?.workspaces;
|
|
1777
|
+
const fromPkg = Array.isArray(ws) ? ws : ws?.packages ?? [];
|
|
1778
|
+
const yaml = fs.read(join6(root, "pnpm-workspace.yaml")) ?? "";
|
|
1779
|
+
const block = yaml.match(/^packages:\s*\n((?:[ \t]*(?:-.*|#.*)?\n?)*)/m)?.[1] ?? "";
|
|
1780
|
+
const fromYaml = [...block.matchAll(/^[ \t]*-[ \t]*['"]?([^'"#\n]+?)['"]?[ \t]*$/gm)].map(
|
|
1781
|
+
(m) => m[1]
|
|
1782
|
+
);
|
|
1783
|
+
return [...fromPkg, ...fromYaml].filter(
|
|
1784
|
+
(p) => typeof p === "string" && !p.startsWith("!")
|
|
1785
|
+
);
|
|
1786
|
+
}
|
|
1787
|
+
function expandWorkspaces(root, patterns, fs) {
|
|
1788
|
+
const found = /* @__PURE__ */ new Set();
|
|
1789
|
+
const walk2 = (dir, segments, depth) => {
|
|
1790
|
+
if (!segments.length) {
|
|
1791
|
+
if (fs.exists(join6(dir, "package.json"))) found.add(dir);
|
|
1792
|
+
return;
|
|
1793
|
+
}
|
|
1794
|
+
const [head, ...rest] = segments;
|
|
1795
|
+
const children = () => fs.list(dir).filter((e) => e.dir && e.name !== "node_modules" && !e.name.startsWith("."));
|
|
1796
|
+
if (head === "**") {
|
|
1797
|
+
walk2(dir, rest, depth);
|
|
1798
|
+
if (depth < 4)
|
|
1799
|
+
for (const c of children()) walk2(join6(dir, c.name), segments, depth + 1);
|
|
1800
|
+
} else if (head.includes("*")) {
|
|
1801
|
+
const re = new RegExp(
|
|
1802
|
+
`^${head.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*")}$`
|
|
1803
|
+
);
|
|
1804
|
+
for (const c of children())
|
|
1805
|
+
if (re.test(c.name)) walk2(join6(dir, c.name), rest, depth);
|
|
1806
|
+
} else {
|
|
1807
|
+
walk2(join6(dir, head), rest, depth);
|
|
1808
|
+
}
|
|
1809
|
+
};
|
|
1810
|
+
for (const p of patterns)
|
|
1811
|
+
walk2(root, p.replace(/\/+$/, "").split("/").filter((s) => s && s !== "."), 0);
|
|
1812
|
+
return [...found].sort();
|
|
1813
|
+
}
|
|
1814
|
+
var firstExisting = (dir, fs, candidates) => candidates.find((c) => fs.exists(join6(dir, c)));
|
|
1815
|
+
var withExts = (base) => SOURCE_EXTS.map((e) => `${base}.${e}`);
|
|
1816
|
+
function detect(dir, fs, env = {}) {
|
|
1817
|
+
const pkg = fs.readJson(join6(dir, "package.json"));
|
|
1818
|
+
if (!pkg) return { error: `No package.json in ${dir}.` };
|
|
1819
|
+
const deps = depsOf(pkg);
|
|
1820
|
+
if (!deps["react-native"]) {
|
|
1821
|
+
if (isWorkspaceRoot(dir, fs)) {
|
|
1822
|
+
const apps = expandWorkspaces(dir, workspacePatterns(dir, fs), fs).filter((d) => isApp(d, fs)).map((d) => relative3(dir, d));
|
|
1823
|
+
return {
|
|
1824
|
+
error: apps.length ? `${dir} is a monorepo root. Rerun with --dir pointing at the app: ${apps.map((a) => `--dir ${a}`).join(", ")}` : `${dir} is a monorepo root and no workspace package depends on react-native.`,
|
|
1825
|
+
workspaces: apps
|
|
1826
|
+
};
|
|
1827
|
+
}
|
|
1828
|
+
return {
|
|
1829
|
+
error: `${dir} isn't a React Native app: its package.json has no react-native dependency. Pass --dir <app>.`
|
|
1830
|
+
};
|
|
1831
|
+
}
|
|
1832
|
+
const root = findUp(dir, fs, (d) => isWorkspaceRoot(d, fs)) ?? dir;
|
|
1833
|
+
const repoRoot = findUp(dir, fs, (d) => fs.exists(join6(d, ".git"))) ?? root;
|
|
1834
|
+
const has = Object.fromEntries(
|
|
1835
|
+
Object.entries(LIBRARIES).map(([k, dep]) => [k, !!deps[dep]])
|
|
1836
|
+
);
|
|
1837
|
+
const appJson = fs.readJson(join6(dir, "app.json"));
|
|
1838
|
+
const appName = [appJson?.expo?.name, appJson?.name, pkg.name].find(
|
|
1839
|
+
(n) => typeof n === "string" && !!n
|
|
1840
|
+
) ?? "app";
|
|
1841
|
+
const tsconfig = firstExisting(dir, fs, ["tsconfig.json"]);
|
|
1842
|
+
const rootFile = firstExisting(
|
|
1843
|
+
dir,
|
|
1844
|
+
fs,
|
|
1845
|
+
has.expoRouter ? [...withExts("app/_layout"), ...withExts("src/app/_layout")] : [...withExts("App"), ...withExts("src/App")]
|
|
1846
|
+
);
|
|
1847
|
+
const agentsFile = [.../* @__PURE__ */ new Set([dir, repoRoot])].flatMap((d) => [join6(d, "AGENTS.md"), join6(d, "CLAUDE.md")]).find((f) => fs.exists(f));
|
|
1848
|
+
let bin;
|
|
1849
|
+
for (let d = dir; ; d = dirname4(d)) {
|
|
1850
|
+
const candidate = join6(d, "node_modules", ".bin", "agent-bridge");
|
|
1851
|
+
if (fs.exists(candidate)) {
|
|
1852
|
+
bin = candidate;
|
|
1853
|
+
break;
|
|
1854
|
+
}
|
|
1855
|
+
if (d === repoRoot || dirname4(d) === d) break;
|
|
1856
|
+
}
|
|
1857
|
+
return {
|
|
1858
|
+
dir,
|
|
1859
|
+
root,
|
|
1860
|
+
repoRoot,
|
|
1861
|
+
appName,
|
|
1862
|
+
pm: detectPackageManager(dir, root, fs, env.userAgent),
|
|
1863
|
+
has,
|
|
1864
|
+
installed: !!deps[PACKAGE],
|
|
1865
|
+
typescript: !!tsconfig || has.typescript,
|
|
1866
|
+
srcDir: fs.exists(join6(dir, "src")),
|
|
1867
|
+
rootFile,
|
|
1868
|
+
tsconfig,
|
|
1869
|
+
oxlint: firstExisting(dir, fs, [".oxlintrc.json"]),
|
|
1870
|
+
eslint: firstExisting(dir, fs, [
|
|
1871
|
+
"eslint.config.js",
|
|
1872
|
+
"eslint.config.mjs",
|
|
1873
|
+
"eslint.config.cjs",
|
|
1874
|
+
"eslint.config.ts"
|
|
1875
|
+
]),
|
|
1876
|
+
agentsFile,
|
|
1877
|
+
bin
|
|
1878
|
+
};
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
// src/init/plan.ts
|
|
1882
|
+
import { dirname as dirname5, isAbsolute, join as join7, relative as relative4, sep } from "path";
|
|
1883
|
+
|
|
1884
|
+
// src/init/jsonc.ts
|
|
1885
|
+
function strip(text2) {
|
|
1886
|
+
let out = "";
|
|
1887
|
+
let i = 0;
|
|
1888
|
+
while (i < text2.length) {
|
|
1889
|
+
const c = text2[i];
|
|
1890
|
+
if (c === '"') {
|
|
1891
|
+
let j = i + 1;
|
|
1892
|
+
while (j < text2.length && text2[j] !== '"') j += text2[j] === "\\" ? 2 : 1;
|
|
1893
|
+
out += text2.slice(i, j + 1);
|
|
1894
|
+
i = j + 1;
|
|
1895
|
+
} else if (c === "/" && text2[i + 1] === "/") {
|
|
1896
|
+
const end = text2.indexOf("\n", i);
|
|
1897
|
+
const j = end === -1 ? text2.length : end;
|
|
1898
|
+
out += " ".repeat(j - i);
|
|
1899
|
+
i = j;
|
|
1900
|
+
} else if (c === "/" && text2[i + 1] === "*") {
|
|
1901
|
+
const end = text2.indexOf("*/", i + 2);
|
|
1902
|
+
const j = end === -1 ? text2.length : end + 2;
|
|
1903
|
+
out += text2.slice(i, j).replace(/[^\n]/g, " ");
|
|
1904
|
+
i = j;
|
|
1905
|
+
} else {
|
|
1906
|
+
out += c;
|
|
1907
|
+
i++;
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
return out.replace(/,(\s*[}\]])/g, " $1");
|
|
1911
|
+
}
|
|
1912
|
+
function parseJsonc(text2) {
|
|
1913
|
+
try {
|
|
1914
|
+
return JSON.parse(strip(text2));
|
|
1915
|
+
} catch {
|
|
1916
|
+
return void 0;
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
function addToArray(text2, key, value, initial = []) {
|
|
1920
|
+
const json = parseJsonc(text2);
|
|
1921
|
+
if (!json || typeof json !== "object" || Array.isArray(json)) return void 0;
|
|
1922
|
+
const current = json[key];
|
|
1923
|
+
const clean = strip(text2);
|
|
1924
|
+
const item = JSON.stringify(value);
|
|
1925
|
+
if (Array.isArray(current)) {
|
|
1926
|
+
const matches = [...clean.matchAll(new RegExp(`"${key}"\\s*:\\s*\\[`, "g"))];
|
|
1927
|
+
if (matches.length !== 1) return void 0;
|
|
1928
|
+
const at = matches[0].index + matches[0][0].length;
|
|
1929
|
+
const after = text2.slice(at);
|
|
1930
|
+
const multiline = after.match(/^[ \t]*\n([ \t]*)/);
|
|
1931
|
+
if (!current.length) return `${text2.slice(0, at)}${item}${after}`;
|
|
1932
|
+
if (multiline) return `${text2.slice(0, at)}
|
|
1933
|
+
${multiline[1]}${item},${after}`;
|
|
1934
|
+
return `${text2.slice(0, at)}${item}, ${after}`;
|
|
1935
|
+
}
|
|
1936
|
+
if (current !== void 0) return void 0;
|
|
1937
|
+
const end = clean.slice(0, clean.lastIndexOf("}")).trimEnd().length;
|
|
1938
|
+
const indent = text2.match(/^([ \t]+)"/m)?.[1] ?? " ";
|
|
1939
|
+
const comma = clean.slice(0, end).endsWith("{") ? "" : ",";
|
|
1940
|
+
const list2 = [...initial, value].map((v) => JSON.stringify(v)).join(", ");
|
|
1941
|
+
return `${text2.slice(0, end)}${comma}
|
|
1942
|
+
${indent}"${key}": [${list2}]${text2.slice(end)}`;
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
// src/init/render.ts
|
|
1946
|
+
import { createHash } from "crypto";
|
|
1947
|
+
var DOCS = "https://github.com/avas-app/agent-bridge/blob/main/docs";
|
|
1948
|
+
var FLOW_GLOB = "**/*.flow.*";
|
|
1949
|
+
var REALTIME = {
|
|
1950
|
+
"socket.io": { fn: "socketIoTools", from: "@avasapp/agent-bridge/socket.io", client: "socket" },
|
|
1951
|
+
ably: { fn: "ablyTools", from: "@avasapp/agent-bridge/ably", client: "ably" }
|
|
1952
|
+
};
|
|
1953
|
+
var STORES_WHY = "stores often hold tokens: add `redact` for those paths before wiring them";
|
|
1954
|
+
var MMKV_WHY = "MMKV often holds tokens and mmkv.get returns values as they are: wire only instances without secrets";
|
|
1955
|
+
var ASYNC_STORAGE_WHY = "AsyncStorage often holds tokens and asyncStorage.get returns values as they are: wire it only if it holds no secrets";
|
|
1956
|
+
var SECURE_STORE_WHY = "SecureStore holds secrets: secureStore.get hides values unless the agent asks for {full:true}, but secureStore.set can still overwrite them";
|
|
1957
|
+
var REDUX_WHY = "the Redux state often holds tokens: add `redact` for those slices and paths, and wrap the root reducer in bridgeReducer() (inside persistReducer) before wiring it";
|
|
1958
|
+
var JOTAI_WHY = "atoms often hold tokens: list only the atoms you want the agent to read and set, and add `redact` for secret ones";
|
|
1959
|
+
var REALTIME_WHY = "the adapter must wrap the client where it is created, before the app subscribes";
|
|
1960
|
+
var NETWORK_PROMPT = "Wire network tools? In dev builds they patch fetch and XMLHttpRequest so the agent can log and mock requests.";
|
|
1961
|
+
var NETWORK_WHY = "networkTools patches fetch and XMLHttpRequest in dev builds so the agent can log and mock requests (net.*); it also enables the offline scenario";
|
|
1962
|
+
var storeKey = (name) => {
|
|
1963
|
+
const bare = name.replace(/^use/, "").replace(/Store$/, "") || name;
|
|
1964
|
+
return bare[0].toLowerCase() + bare.slice(1);
|
|
1965
|
+
};
|
|
1966
|
+
function realtimeLine(todo) {
|
|
1967
|
+
const r = REALTIME[todo.kind];
|
|
1968
|
+
return `export const realtimeDevTools = ${r.fn}(${todo.name ?? r.client})`;
|
|
1969
|
+
}
|
|
1970
|
+
function realtimeTodoText(todo) {
|
|
1971
|
+
const r = REALTIME[todo.kind];
|
|
1972
|
+
const where = todo.file ? `In ${todo.file}, right after \`${todo.name}\` is created` : `Where your ${todo.kind} client is created`;
|
|
1973
|
+
return `${where}, add \`${realtimeLine(todo)}\` (import { ${r.fn} } from '${r.from}'), then spread realtimeDevTools into tools`;
|
|
1974
|
+
}
|
|
1975
|
+
function renderBridgeFile(o) {
|
|
1976
|
+
const L = [];
|
|
1977
|
+
const core = ["cdpTransport", ...o.ts ? ["type Scenarios"] : [], "useAgentBridge"];
|
|
1978
|
+
L.push(
|
|
1979
|
+
`// Generated by agent-bridge init ${o.version}. Yours to edit: once changed, init never overwrites it.`,
|
|
1980
|
+
`import { ${core.join(", ")} } from '@avasapp/agent-bridge'`
|
|
1981
|
+
);
|
|
1982
|
+
if (o.expo) L.push(`import { expoTransport } from '@avasapp/agent-bridge/expo'`);
|
|
1983
|
+
if (o.expoRouter) L.push(`import { routerTools } from '@avasapp/agent-bridge/expo-router'`);
|
|
1984
|
+
if (o.network)
|
|
1985
|
+
L.push(`import { networkTools, strictNetwork } from '@avasapp/agent-bridge/network'`);
|
|
1986
|
+
if (o.webview)
|
|
1987
|
+
L.push(`import { webviewTools } from '@avasapp/agent-bridge/react-native-webview'`);
|
|
1988
|
+
if (o.reactQuery) {
|
|
1989
|
+
L.push(
|
|
1990
|
+
`import { queryTools } from '@avasapp/agent-bridge/tanstack-query'`,
|
|
1991
|
+
`import { QueryClientContext } from '@tanstack/react-query'`
|
|
1992
|
+
);
|
|
1993
|
+
}
|
|
1994
|
+
if (o.expoRouter) L.push(`import { router, useNavigationContainerRef } from 'expo-router'`);
|
|
1995
|
+
if (o.reactQuery) L.push(`import { useContext } from 'react'`);
|
|
1996
|
+
if (o.reactNavigation)
|
|
1997
|
+
L.push(
|
|
1998
|
+
"",
|
|
1999
|
+
"// Pass the ref you give to <NavigationContainer ref={\u2026}>, then uncomment navTools below.",
|
|
2000
|
+
`// import { navTools } from '@avasapp/agent-bridge/react-navigation'`,
|
|
2001
|
+
`// import { navigationRef } from './path-to-your-navigation-ref'`
|
|
2002
|
+
);
|
|
2003
|
+
if (o.realtimeTools?.length)
|
|
2004
|
+
L.push("", ...o.realtimeTools.map((t) => `import { ${t.name} } from '${t.from}'`));
|
|
2005
|
+
if (o.stores) {
|
|
2006
|
+
const stores = o.stores.length ? o.stores : [{ name: "useAuthStore", from: "../stores/auth" }];
|
|
2007
|
+
L.push(
|
|
2008
|
+
"",
|
|
2009
|
+
"// Zustand stores are left out: they often hold tokens. Add `redact` for those",
|
|
2010
|
+
"// paths, then uncomment these and storeTools below. See",
|
|
2011
|
+
`// ${DOCS}/adapters.md`,
|
|
2012
|
+
`// import { storeTools } from '@avasapp/agent-bridge/zustand'`,
|
|
2013
|
+
...stores.map((s) => `// import { ${s.name} } from '${s.from}'`)
|
|
2014
|
+
);
|
|
2015
|
+
}
|
|
2016
|
+
if (o.mmkv) {
|
|
2017
|
+
const mmkv = o.mmkv.length ? o.mmkv : [{ name: "storage", from: "../storage" }];
|
|
2018
|
+
L.push(
|
|
2019
|
+
"",
|
|
2020
|
+
"// MMKV is left out: it often holds tokens, and mmkv.get returns values as they",
|
|
2021
|
+
"// are. Wire only instances without secrets: uncomment these and mmkvTools below.",
|
|
2022
|
+
`// See ${DOCS}/adapters.md`,
|
|
2023
|
+
`// import { mmkvTools } from '@avasapp/agent-bridge/react-native-mmkv'`,
|
|
2024
|
+
...mmkv.map((m) => `// import { ${m.name} } from '${m.from}'`)
|
|
2025
|
+
);
|
|
2026
|
+
}
|
|
2027
|
+
if (o.asyncStorage)
|
|
2028
|
+
L.push(
|
|
2029
|
+
"",
|
|
2030
|
+
"// AsyncStorage is left out: it often holds tokens, and asyncStorage.get returns",
|
|
2031
|
+
"// values as they are. Uncomment these and asyncStorageTools below if it holds none,",
|
|
2032
|
+
"// passing the storage the app uses (a createAsyncStorage(name) instance, if it has one).",
|
|
2033
|
+
`// See ${DOCS}/adapters.md`,
|
|
2034
|
+
`// import { asyncStorageTools } from '@avasapp/agent-bridge/async-storage'`,
|
|
2035
|
+
`// import AsyncStorage from '@react-native-async-storage/async-storage'`
|
|
2036
|
+
);
|
|
2037
|
+
if (o.secureStore)
|
|
2038
|
+
L.push(
|
|
2039
|
+
"",
|
|
2040
|
+
"// SecureStore is left out: it holds secrets. secureStore.get hides values unless",
|
|
2041
|
+
"// the agent asks for {full:true}. Uncomment these and secureStoreTools below,",
|
|
2042
|
+
"// passing the keys the app uses so secureStore.list can show them, and the same",
|
|
2043
|
+
"// options the app passes to SecureStore (keychainService, requireAuthentication).",
|
|
2044
|
+
`// See ${DOCS}/adapters.md`,
|
|
2045
|
+
`// import { secureStoreTools } from '@avasapp/agent-bridge/secure-store'`,
|
|
2046
|
+
`// import * as SecureStore from 'expo-secure-store'`
|
|
2047
|
+
);
|
|
2048
|
+
if (o.redux)
|
|
2049
|
+
L.push(
|
|
2050
|
+
"",
|
|
2051
|
+
"// Redux is left out: the state often holds tokens. Add `redact` for those slices,",
|
|
2052
|
+
"// wrap the root reducer in bridgeReducer() (inside persistReducer) where the store",
|
|
2053
|
+
"// is created, then uncomment these and reduxTools below.",
|
|
2054
|
+
`// See ${DOCS}/adapters.md`,
|
|
2055
|
+
`// import { reduxTools } from '@avasapp/agent-bridge/redux'`,
|
|
2056
|
+
`// import { store } from '../store'`
|
|
2057
|
+
);
|
|
2058
|
+
if (o.jotai)
|
|
2059
|
+
L.push(
|
|
2060
|
+
"",
|
|
2061
|
+
"// Jotai is left out: atoms often hold tokens. List the atoms the agent may read and",
|
|
2062
|
+
"// set, add `redact` for secret ones, then uncomment these and jotaiTools below.",
|
|
2063
|
+
"// Use the store your app uses: a <Provider> without a store prop has its own, out of reach here.",
|
|
2064
|
+
`// See ${DOCS}/adapters.md`,
|
|
2065
|
+
`// import { jotaiTools } from '@avasapp/agent-bridge/jotai'`,
|
|
2066
|
+
`// import { getDefaultStore } from 'jotai'`,
|
|
2067
|
+
`// import { userAtom } from '../atoms'`
|
|
2068
|
+
);
|
|
2069
|
+
for (const todo of o.realtime) {
|
|
2070
|
+
const r = REALTIME[todo.kind];
|
|
2071
|
+
L.push(
|
|
2072
|
+
"",
|
|
2073
|
+
`// TODO ${todo.kind}: the adapter must wrap the client where it is created,`,
|
|
2074
|
+
`// before the app subscribes (${DOCS}/realtime.md).`,
|
|
2075
|
+
todo.file ? `// In ${todo.file}, right after \`${todo.name}\` is created, add:` : `// Where your ${todo.kind} client is created, add:`,
|
|
2076
|
+
`// import { ${r.fn} } from '${r.from}'`,
|
|
2077
|
+
`// ${realtimeLine(todo)}`,
|
|
2078
|
+
`// then import realtimeDevTools here and uncomment it in tools.`
|
|
2079
|
+
);
|
|
2080
|
+
}
|
|
2081
|
+
L.push("");
|
|
2082
|
+
if (o.ts) L.push("const scenarios: Scenarios = {");
|
|
2083
|
+
else L.push(`/** @type {import('@avasapp/agent-bridge').Scenarios} */`, "const scenarios = {");
|
|
2084
|
+
if (o.network) {
|
|
2085
|
+
L.push(
|
|
2086
|
+
" offline: {",
|
|
2087
|
+
` description: 'Every request fails like a network failure; Metro still gets through.',`,
|
|
2088
|
+
" apply: ({ onUndo }) => {",
|
|
2089
|
+
" onUndo(strictNetwork({ offline: true }))",
|
|
2090
|
+
" },",
|
|
2091
|
+
" },"
|
|
2092
|
+
);
|
|
2093
|
+
}
|
|
2094
|
+
L.push(
|
|
2095
|
+
" // A user signed in locally with every request mocked: the state most flows",
|
|
2096
|
+
` // start from. See ${DOCS}/scenarios.md`,
|
|
2097
|
+
" // signedIn: {",
|
|
2098
|
+
` // description: 'Signed in locally; no request reaches a server.',`,
|
|
2099
|
+
" // apply: async ({ call, onUndo }) => {",
|
|
2100
|
+
` // onUndo(strictNetwork()) // and mockApi, both from '@avasapp/agent-bridge/network'`,
|
|
2101
|
+
` // onUndo(mockApi(API_URL, { 'GET /me': { json: user } }))`,
|
|
2102
|
+
` // await call('store.set', 'auth', { token: 'local', user })`,
|
|
2103
|
+
" // return { user }",
|
|
2104
|
+
" // },",
|
|
2105
|
+
" // },",
|
|
2106
|
+
"}",
|
|
2107
|
+
"",
|
|
2108
|
+
"export function AgentBridge() {"
|
|
2109
|
+
);
|
|
2110
|
+
if (o.reactQuery)
|
|
2111
|
+
L.push(
|
|
2112
|
+
" // Undefined outside a QueryClientProvider: mount this inside one for query.* tools.",
|
|
2113
|
+
" const queryClient = useContext(QueryClientContext)"
|
|
2114
|
+
);
|
|
2115
|
+
L.push(
|
|
2116
|
+
" useAgentBridge({",
|
|
2117
|
+
` name: '${o.appName.replace(/[\\']/g, "\\$&")}',`,
|
|
2118
|
+
` transports: [${o.expo ? "expoTransport(), " : ""}cdpTransport()],`,
|
|
2119
|
+
" tools: {"
|
|
2120
|
+
);
|
|
2121
|
+
if (o.reactQuery) L.push(" ...(queryClient ? queryTools(queryClient) : {}),");
|
|
2122
|
+
if (o.expoRouter)
|
|
2123
|
+
L.push(" ...routerTools(router, { navigation: useNavigationContainerRef() }),");
|
|
2124
|
+
if (o.webview)
|
|
2125
|
+
L.push(
|
|
2126
|
+
` // Each WebView also needs useWebViewTools (${DOCS}/webviews.md).`,
|
|
2127
|
+
" ...webviewTools(),"
|
|
2128
|
+
);
|
|
2129
|
+
if (o.network) L.push(" ...networkTools(),");
|
|
2130
|
+
for (const t of o.realtimeTools ?? []) L.push(` ...${t.name},`);
|
|
2131
|
+
if (o.stores) {
|
|
2132
|
+
const stores = o.stores.length ? o.stores : [{ name: "useAuthStore", from: "" }];
|
|
2133
|
+
const map = stores.map((s) => `${storeKey(s.name)}: ${s.name}`).join(", ");
|
|
2134
|
+
const first = storeKey(stores[0].name);
|
|
2135
|
+
L.push(` // ...storeTools({ ${map} }, { redact: { ${first}: ['token'] } }),`);
|
|
2136
|
+
}
|
|
2137
|
+
if (o.mmkv) {
|
|
2138
|
+
const mmkv = o.mmkv.length ? o.mmkv : [{ name: "storage", from: "" }];
|
|
2139
|
+
L.push(` // ...mmkvTools({ ${mmkv.map((m) => m.name).join(", ")} }),`);
|
|
2140
|
+
}
|
|
2141
|
+
if (o.asyncStorage) L.push(" // ...asyncStorageTools(AsyncStorage),");
|
|
2142
|
+
if (o.secureStore) L.push(` // ...secureStoreTools(SecureStore, ['authToken']),`);
|
|
2143
|
+
if (o.redux) L.push(` // ...reduxTools(store, { redact: { auth: ['token'] } }),`);
|
|
2144
|
+
if (o.jotai) L.push(" // ...jotaiTools(getDefaultStore(), { user: userAtom }),");
|
|
2145
|
+
if (o.realtime.length) L.push(" // ...realtimeDevTools,");
|
|
2146
|
+
if (o.reactNavigation)
|
|
2147
|
+
L.push(
|
|
2148
|
+
" // React Navigation: uncomment this and the imports above, with the ref you pass to",
|
|
2149
|
+
" // <NavigationContainer ref={\u2026}> (createNavigationContainerRef from @react-navigation/native).",
|
|
2150
|
+
" // ...navTools(navigationRef),"
|
|
2151
|
+
);
|
|
2152
|
+
L.push(
|
|
2153
|
+
` // Your own tools: any function, JSON in and out, e.g. 'cart.clear': () => clearCart(),`,
|
|
2154
|
+
" },",
|
|
2155
|
+
" scenarios,",
|
|
2156
|
+
" })",
|
|
2157
|
+
" return null",
|
|
2158
|
+
"}",
|
|
2159
|
+
""
|
|
2160
|
+
);
|
|
2161
|
+
return stamp(L.join("\n"));
|
|
2162
|
+
}
|
|
2163
|
+
function renderMountSnippet(importPath2) {
|
|
2164
|
+
return `import { AgentBridge } from '${importPath2}'
|
|
2165
|
+
{__DEV__ ? <AgentBridge /> : null}`;
|
|
2166
|
+
}
|
|
2167
|
+
function renderSmokeFlow(o) {
|
|
2168
|
+
return stamp([
|
|
2169
|
+
`// Generated by agent-bridge init ${o.version}. Run it with:`,
|
|
2170
|
+
`// ${o.bin} run flows/smoke.flow.mjs`,
|
|
2171
|
+
`/** @type {import('@avasapp/agent-bridge/client').FlowModule['default']} */`,
|
|
2172
|
+
"const smoke = async ({ step, expect }) => {",
|
|
2173
|
+
` await step('snapshot', 'screen.snapshot')`,
|
|
2174
|
+
...o.expoRouter ? [` await step('open home', 'router.navigate', '/')`] : [],
|
|
2175
|
+
" // TODO: check text your first screen always shows, for example:",
|
|
2176
|
+
` // await expect.text('Welcome')`,
|
|
2177
|
+
"}",
|
|
2178
|
+
"",
|
|
2179
|
+
"export default smoke",
|
|
2180
|
+
""
|
|
2181
|
+
].join("\n"));
|
|
2182
|
+
}
|
|
2183
|
+
function renderFlowsReadme(bin) {
|
|
2184
|
+
return `Flows for \`${bin} run flows/<name>.flow.mjs\`: plain .mjs files named \`*.flow.mjs\`, kept out of \`app/\` and test globs. See ${DOCS}/cli.md#flows-where-they-go.
|
|
2185
|
+
`;
|
|
2186
|
+
}
|
|
2187
|
+
function agentsLine(appDir, bin) {
|
|
2188
|
+
const where = appDir ? `${appDir}/flows` : "flows";
|
|
2189
|
+
const run = `\`${bin} run flows/smoke.flow.mjs\``;
|
|
2190
|
+
return `- Flows for agent-bridge live in \`${where}/<name>.flow.mjs\` (never under \`app/\`, never \`*.test.*\`); run one ${appDir ? `from \`${appDir}\` with ${run}` : `with ${run}`}.`;
|
|
2191
|
+
}
|
|
2192
|
+
var HEADER = /^(\/\/ Generated by agent-bridge init \d+\.\d+\.\d+(?:-[\w.]+)?)( \(#([0-9a-f]{12})\))?\./;
|
|
2193
|
+
var hash = (body) => createHash("sha256").update(body).digest("hex").slice(0, 12);
|
|
2194
|
+
function stamp(content) {
|
|
2195
|
+
const nl = content.indexOf("\n");
|
|
2196
|
+
return content.slice(0, nl).replace(HEADER, `$1 (#${hash(content.slice(nl))}).`) + content.slice(nl);
|
|
2197
|
+
}
|
|
2198
|
+
function isPristine(text2) {
|
|
2199
|
+
const nl = text2.indexOf("\n");
|
|
2200
|
+
const recorded = text2.slice(0, nl).match(HEADER)?.[3];
|
|
2201
|
+
return !!recorded && recorded === hash(text2.slice(nl));
|
|
2202
|
+
}
|
|
2203
|
+
var NETWORK_LINES = [
|
|
2204
|
+
`import { networkTools } from '@avasapp/agent-bridge/network'`,
|
|
2205
|
+
"...networkTools(), // in useAgentBridge({ tools })"
|
|
2206
|
+
];
|
|
2207
|
+
var indentOf = (text2) => text2.match(/^([ \t]+)"/m)?.[1] ?? " ";
|
|
2208
|
+
function patchJson(text2, edit, manual) {
|
|
2209
|
+
let json;
|
|
2210
|
+
try {
|
|
2211
|
+
json = JSON.parse(text2);
|
|
2212
|
+
} catch {
|
|
2213
|
+
return { manual };
|
|
2214
|
+
}
|
|
2215
|
+
if (!json || typeof json !== "object" || Array.isArray(json)) return { manual };
|
|
2216
|
+
if (!edit(json)) return { unchanged: true };
|
|
2217
|
+
return {
|
|
2218
|
+
text: JSON.stringify(json, null, indentOf(text2)) + (text2.endsWith("\n") ? "\n" : "")
|
|
2219
|
+
};
|
|
2220
|
+
}
|
|
2221
|
+
function patchPackageScripts(text2) {
|
|
2222
|
+
return patchJson(
|
|
2223
|
+
text2,
|
|
2224
|
+
(pkg) => {
|
|
2225
|
+
if (pkg.scripts?.flow !== void 0) return false;
|
|
2226
|
+
pkg.scripts = { ...pkg.scripts, flow: "agent-bridge run" };
|
|
2227
|
+
return true;
|
|
2228
|
+
},
|
|
2229
|
+
`Add to package.json scripts: "flow": "agent-bridge run"`
|
|
2230
|
+
);
|
|
2231
|
+
}
|
|
2232
|
+
var globRe = (glob) => new RegExp(
|
|
2233
|
+
`^${glob.replace(/^\.\//, "").replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*\//g, "\0").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]").replace(/\u0000/g, "(?:.*/)?")}$`
|
|
2234
|
+
);
|
|
2235
|
+
function includesFlows(pattern) {
|
|
2236
|
+
const last = pattern.split("/").pop() ?? "";
|
|
2237
|
+
const glob = last.includes(".") ? pattern : `${pattern.replace(/\/+$/, "")}${last === "*" ? "" : "/**/*"}`;
|
|
2238
|
+
return globRe(glob).test("flows/smoke.flow.mjs");
|
|
2239
|
+
}
|
|
2240
|
+
function patchTsconfig(text2, baseAllowJs) {
|
|
2241
|
+
const manual = `Add "${FLOW_GLOB}" to "exclude" in tsconfig.json`;
|
|
2242
|
+
const json = parseJsonc(text2);
|
|
2243
|
+
if (!json || typeof json !== "object") return { manual };
|
|
2244
|
+
if (Array.isArray(json.exclude) && json.exclude.includes(FLOW_GLOB)) return { unchanged: true };
|
|
2245
|
+
const allowJs = json.compilerOptions?.allowJs ?? baseAllowJs ?? true;
|
|
2246
|
+
const include = Array.isArray(json.include) ? json.include : json.files ? [] : ["**/*"];
|
|
2247
|
+
if (!allowJs || !include.some((p) => typeof p === "string" && includesFlows(p)))
|
|
2248
|
+
return { unchanged: true, note: "doesn't include .mjs files" };
|
|
2249
|
+
if (json.extends && !Array.isArray(json.exclude))
|
|
2250
|
+
return {
|
|
2251
|
+
manual: `${manual}, along with what it inherits from ${JSON.stringify(json.extends)}: an "exclude" here replaces the inherited one`
|
|
2252
|
+
};
|
|
2253
|
+
const patched = addToArray(text2, "exclude", FLOW_GLOB, ["node_modules"]);
|
|
2254
|
+
return patched ? { text: patched } : { manual };
|
|
2255
|
+
}
|
|
2256
|
+
function patchOxlint(text2) {
|
|
2257
|
+
const ignore = parseJsonc(text2)?.ignorePatterns;
|
|
2258
|
+
if (Array.isArray(ignore) && ignore.includes(FLOW_GLOB)) return { unchanged: true };
|
|
2259
|
+
const patched = addToArray(text2, "ignorePatterns", FLOW_GLOB);
|
|
2260
|
+
return patched ? { text: patched } : { manual: `Add "${FLOW_GLOB}" to "ignorePatterns" in .oxlintrc.json` };
|
|
2261
|
+
}
|
|
2262
|
+
var ESLINT_SNIPPET = `{ ignores: ['${FLOW_GLOB}'] },`;
|
|
2263
|
+
function patchEslint(text2) {
|
|
2264
|
+
if (text2.includes(FLOW_GLOB)) return { unchanged: true };
|
|
2265
|
+
const manual = `Add to the start of your ESLint flat config array: ${ESLINT_SNIPPET}`;
|
|
2266
|
+
const re = /^((?:module\.exports\s*=|export\s+default)\s*(?:defineConfig\(\s*)?\[)/gm;
|
|
2267
|
+
const matches = [...text2.matchAll(re)];
|
|
2268
|
+
if (matches.length !== 1) return { manual };
|
|
2269
|
+
const at = matches[0].index + matches[0][1].length;
|
|
2270
|
+
return { text: `${text2.slice(0, at)}
|
|
2271
|
+
${ESLINT_SNIPPET}${text2.slice(at)}` };
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
// src/init/plan.ts
|
|
2275
|
+
var SKILL_COMMAND = ["npx", "skills", "add", "avas-app/agent-bridge"];
|
|
2276
|
+
var SKILL_RUN = ["npx", "--yes", "skills", "add", "avas-app/agent-bridge"];
|
|
2277
|
+
var PLUGIN_LINES = [
|
|
2278
|
+
"/plugin marketplace add avas-app/agent-bridge",
|
|
2279
|
+
"/plugin install agent-bridge@agent-bridge"
|
|
2280
|
+
];
|
|
2281
|
+
var MARKERS = [
|
|
2282
|
+
["expo", /expoTransport/, "expo"],
|
|
2283
|
+
["expoRouter", /routerTools/, "expo-router"],
|
|
2284
|
+
["reactQuery", /queryTools/, "@tanstack/react-query"],
|
|
2285
|
+
["webview", /webviewTools/, "react-native-webview"],
|
|
2286
|
+
["zustand", /storeTools/, "zustand"],
|
|
2287
|
+
["redux", /reduxTools/, "@reduxjs/toolkit"],
|
|
2288
|
+
["reduxCore", /reduxTools/, "redux"],
|
|
2289
|
+
["jotai", /jotaiTools/, "jotai"],
|
|
2290
|
+
["mmkv", /mmkvTools/, "react-native-mmkv"],
|
|
2291
|
+
["asyncStorage", /asyncStorageTools/, "@react-native-async-storage/async-storage"],
|
|
2292
|
+
["secureStore", /secureStoreTools/, "expo-secure-store"],
|
|
2293
|
+
["socketIo", /realtimeDevTools|socketIoTools|realtime/i, "socket.io-client"],
|
|
2294
|
+
["ably", /realtimeDevTools|ablyTools|realtime/i, "ably"]
|
|
2295
|
+
];
|
|
2296
|
+
var posix2 = (p) => p.split(sep).join("/");
|
|
2297
|
+
function importPath(fromFile, toFile) {
|
|
2298
|
+
const rel = posix2(relative4(dirname5(fromFile), toFile)).replace(/\.(tsx?|jsx?|mjs|cjs)$/, "");
|
|
2299
|
+
return rel.startsWith(".") ? rel : `./${rel}`;
|
|
2300
|
+
}
|
|
2301
|
+
function binFor(d) {
|
|
2302
|
+
if (d.bin) {
|
|
2303
|
+
const rel = posix2(relative4(d.dir, d.bin));
|
|
2304
|
+
return rel.startsWith(".") ? rel : `./${rel}`;
|
|
2305
|
+
}
|
|
2306
|
+
if (d.pm === "npm" && d.root !== d.dir)
|
|
2307
|
+
return `${posix2(relative4(d.dir, d.root))}/node_modules/.bin/agent-bridge`;
|
|
2308
|
+
return binFallback(d.pm);
|
|
2309
|
+
}
|
|
2310
|
+
function bridgePath(d, out) {
|
|
2311
|
+
if (out) return isAbsolute(out) ? out : join7(d.dir, out);
|
|
2312
|
+
return join7(d.dir, d.srcDir ? "src/dev" : "dev", `agent-bridge.${d.typescript ? "tsx" : "js"}`);
|
|
2313
|
+
}
|
|
2314
|
+
function nextSteps(bin, o) {
|
|
2315
|
+
const { offline, runFrom } = o;
|
|
2316
|
+
const next = [];
|
|
2317
|
+
if (!o.mounted)
|
|
2318
|
+
next.push({
|
|
2319
|
+
step: o.elsewhere ? "Make sure the root component renders the component that calls useAgentBridge" : "Mount it: paste the snippet above"
|
|
2320
|
+
});
|
|
2321
|
+
next.push(
|
|
2322
|
+
{ step: "Reload the app (press r in Metro)" },
|
|
2323
|
+
{ step: "Start a session", command: `${bin} session start` },
|
|
2324
|
+
{ step: "List the tools", command: `${bin} tools` },
|
|
2325
|
+
{ step: "See what's on screen", command: `${bin} call screen.snapshot` }
|
|
2326
|
+
);
|
|
2327
|
+
if (offline)
|
|
2328
|
+
next.push({ step: "Try a scenario", command: `${bin} call scenario.apply '"offline"'` });
|
|
2329
|
+
next.push({ step: "Undo everything and disconnect", command: `${bin} session stop` });
|
|
2330
|
+
const from = runFrom === "." ? "" : `From \`${runFrom}\`: `;
|
|
2331
|
+
return {
|
|
2332
|
+
next,
|
|
2333
|
+
prompt: `This app has agent-bridge. ${from ? `${from}s` : "S"}tart a session with \`${bin} session start\`, see the screen with \`${bin} call screen.snapshot\`, and check changes through the bridge instead of tapping; stop the session when done.`
|
|
2334
|
+
};
|
|
2335
|
+
}
|
|
2336
|
+
var stripExt = (p) => p.replace(/\.(tsx?|jsx?|mjs|cjs)$/, "").replace(/\/index$/, "");
|
|
2337
|
+
function readTsconfig(d, fs) {
|
|
2338
|
+
if (!d.tsconfig) return { aliases: [] };
|
|
2339
|
+
const file = join7(d.dir, d.tsconfig);
|
|
2340
|
+
const json = parseJsonc(fs.read(file) ?? "");
|
|
2341
|
+
if (!json) return { aliases: [] };
|
|
2342
|
+
const findExtended = (from2, ext2) => {
|
|
2343
|
+
const names2 = ext2.endsWith(".json") ? [ext2] : [ext2, `${ext2}.json`];
|
|
2344
|
+
const roots = ext2.startsWith(".") ? [dirname5(from2)] : ancestors(dirname5(from2), d.repoRoot).map((a) => join7(a, "node_modules"));
|
|
2345
|
+
for (const r of roots) for (const n of names2) if (fs.exists(join7(r, n)) && !fs.list(join7(r, n)).length) return join7(r, n);
|
|
2346
|
+
return void 0;
|
|
2347
|
+
};
|
|
2348
|
+
let baseAllowJs;
|
|
2349
|
+
let ext = json.extends;
|
|
2350
|
+
let from = file;
|
|
2351
|
+
for (let depth = 0; typeof ext === "string" && depth < 4; depth++) {
|
|
2352
|
+
const path = findExtended(from, ext);
|
|
2353
|
+
const base = path ? parseJsonc(fs.read(path) ?? "") : void 0;
|
|
2354
|
+
if (!base) break;
|
|
2355
|
+
if (typeof base.compilerOptions?.allowJs === "boolean") {
|
|
2356
|
+
baseAllowJs = base.compilerOptions.allowJs;
|
|
2357
|
+
break;
|
|
2358
|
+
}
|
|
2359
|
+
ext = base.extends;
|
|
2360
|
+
from = path;
|
|
2361
|
+
}
|
|
2362
|
+
const baseDir = join7(d.dir, json.compilerOptions?.baseUrl ?? ".");
|
|
2363
|
+
const paths = json.compilerOptions?.paths ?? {};
|
|
2364
|
+
const aliases = Object.entries(paths).flatMap(
|
|
2365
|
+
([pattern, targets]) => Array.isArray(targets) && typeof targets[0] === "string" ? [[pattern.replace(/\*$/, ""), join7(baseDir, targets[0].replace(/\*$/, ""))]] : []
|
|
2366
|
+
);
|
|
2367
|
+
return { json, baseAllowJs, aliases };
|
|
2368
|
+
}
|
|
2369
|
+
function ancestors(dir, stop2) {
|
|
2370
|
+
const out = [];
|
|
2371
|
+
for (let d = dir; ; d = dirname5(d)) {
|
|
2372
|
+
out.push(d);
|
|
2373
|
+
if (d === stop2 || dirname5(d) === d) return out;
|
|
2374
|
+
}
|
|
2375
|
+
}
|
|
2376
|
+
function importedFiles(file, text2, aliases) {
|
|
2377
|
+
const specs = [...text2.matchAll(/(?:from\s+|import\s+|require\(\s*)['"]([^'"]+)['"]/g)].map((m) => m[1]);
|
|
2378
|
+
return specs.flatMap((spec) => {
|
|
2379
|
+
if (spec.startsWith(".")) return [stripExt(join7(dirname5(file), spec))];
|
|
2380
|
+
const alias = aliases.find(([prefix]) => prefix && spec.startsWith(prefix));
|
|
2381
|
+
return alias ? [stripExt(join7(alias[1], spec.slice(alias[0].length)))] : [];
|
|
2382
|
+
});
|
|
2383
|
+
}
|
|
2384
|
+
function plan(d, found, fs, o, cwd) {
|
|
2385
|
+
const rel = (p) => posix2(relative4(d.dir, p));
|
|
2386
|
+
const out = bridgePath(d, o.out);
|
|
2387
|
+
if (rel(out).startsWith("..") || isAbsolute(rel(out)))
|
|
2388
|
+
return { error: `--out ${o.out} is outside the app (${d.dir}).` };
|
|
2389
|
+
if (/^(src\/)?app\//.test(rel(out)))
|
|
2390
|
+
return {
|
|
2391
|
+
error: `${rel(out)} is under app/, where expo-router treats every file as a route. Pick another --out.`
|
|
2392
|
+
};
|
|
2393
|
+
const ts = /\.tsx?$/.test(out);
|
|
2394
|
+
const bin = binFor(d);
|
|
2395
|
+
const runFrom = posix2(relative4(cwd, d.dir)) || ".";
|
|
2396
|
+
const tsconfig = readTsconfig(d, fs);
|
|
2397
|
+
const actions = [];
|
|
2398
|
+
const files = [];
|
|
2399
|
+
const commented = [];
|
|
2400
|
+
const todos = [];
|
|
2401
|
+
const generate = (path, content) => {
|
|
2402
|
+
const current = fs.read(path);
|
|
2403
|
+
if (current === void 0) {
|
|
2404
|
+
actions.push({ kind: "write", path, content });
|
|
2405
|
+
files.push({ path: rel(path), status: "written" });
|
|
2406
|
+
return "written";
|
|
2407
|
+
}
|
|
2408
|
+
const pristine = isPristine(current);
|
|
2409
|
+
if (!o.force) {
|
|
2410
|
+
files.push({
|
|
2411
|
+
path: rel(path),
|
|
2412
|
+
status: "skipped",
|
|
2413
|
+
note: pristine ? "exists; --force regenerates it" : "exists, edited since init wrote it"
|
|
2414
|
+
});
|
|
2415
|
+
return pristine ? "kept" : "edited";
|
|
2416
|
+
}
|
|
2417
|
+
if (pristine) {
|
|
2418
|
+
const same = current === content;
|
|
2419
|
+
if (!same) actions.push({ kind: "write", path, content });
|
|
2420
|
+
files.push({ path: rel(path), status: same ? "unchanged" : "written" });
|
|
2421
|
+
return "written";
|
|
2422
|
+
}
|
|
2423
|
+
actions.push({ kind: "write", path: `${path}.new`, content });
|
|
2424
|
+
files.push({
|
|
2425
|
+
path: rel(path),
|
|
2426
|
+
status: "skipped",
|
|
2427
|
+
note: `edited since init wrote it, so --force left it; wrote ${rel(path)}.new to compare`
|
|
2428
|
+
});
|
|
2429
|
+
files.push({ path: `${rel(path)}.new`, status: "written" });
|
|
2430
|
+
return "edited";
|
|
2431
|
+
};
|
|
2432
|
+
const imported = (list2) => list2.map((f) => ({ name: f.name, from: importPath(out, join7(d.dir, f.file)) }));
|
|
2433
|
+
const realtimeTools = imported(found.realtimeTools);
|
|
2434
|
+
const realtime = realtimeTools.length ? [] : [
|
|
2435
|
+
...d.has.socketIo ? found.socketIo.length ? found.socketIo.map((f) => ({ kind: "socket.io", ...f })) : [{ kind: "socket.io" }] : [],
|
|
2436
|
+
...d.has.ably ? found.ably.length ? found.ably.map((f) => ({ kind: "ably", ...f })) : [{ kind: "ably" }] : []
|
|
2437
|
+
];
|
|
2438
|
+
const existingText = fs.read(out);
|
|
2439
|
+
const existing = found.bridgeUsers.filter((f) => stripExt(join7(d.dir, f)) !== stripExt(out));
|
|
2440
|
+
const elsewhere = existingText === void 0 && existing.length > 0 && !o.out;
|
|
2441
|
+
let state = "elsewhere";
|
|
2442
|
+
if (elsewhere) {
|
|
2443
|
+
files.push({
|
|
2444
|
+
path: rel(out),
|
|
2445
|
+
status: "skipped",
|
|
2446
|
+
note: `the app already uses the bridge in ${existing.join(", ")}; pass --out to write one anyway`
|
|
2447
|
+
});
|
|
2448
|
+
} else {
|
|
2449
|
+
state = generate(
|
|
2450
|
+
out,
|
|
2451
|
+
renderBridgeFile({
|
|
2452
|
+
version: o.version,
|
|
2453
|
+
appName: d.appName,
|
|
2454
|
+
ts,
|
|
2455
|
+
expo: d.has.expo,
|
|
2456
|
+
expoRouter: d.has.expoRouter,
|
|
2457
|
+
reactQuery: d.has.reactQuery,
|
|
2458
|
+
webview: d.has.webview,
|
|
2459
|
+
network: o.network.enabled,
|
|
2460
|
+
reactNavigation: d.has.reactNavigation && !d.has.expoRouter,
|
|
2461
|
+
stores: d.has.zustand ? imported(found.stores) : void 0,
|
|
2462
|
+
mmkv: d.has.mmkv ? imported(found.mmkv) : void 0,
|
|
2463
|
+
asyncStorage: d.has.asyncStorage,
|
|
2464
|
+
secureStore: d.has.secureStore,
|
|
2465
|
+
redux: d.has.redux || d.has.reduxCore,
|
|
2466
|
+
jotai: d.has.jotai,
|
|
2467
|
+
realtime,
|
|
2468
|
+
realtimeTools
|
|
2469
|
+
})
|
|
2470
|
+
);
|
|
2471
|
+
}
|
|
2472
|
+
const wrote = state === "written";
|
|
2473
|
+
const liveText = wrote ? void 0 : elsewhere ? existing.map((f) => fs.read(join7(d.dir, f)) ?? "").join("\n") : existingText;
|
|
2474
|
+
const network = wrote ? o.network.enabled : /networkTools\s*\(/.test(liveText ?? "");
|
|
2475
|
+
const offline = wrote ? o.network.enabled : /\boffline\s*:/.test(liveText ?? "");
|
|
2476
|
+
if (wrote) {
|
|
2477
|
+
if (d.has.zustand)
|
|
2478
|
+
commented.push({
|
|
2479
|
+
adapter: "zustand",
|
|
2480
|
+
items: found.stores.map((s) => `${s.name} (${s.file})`),
|
|
2481
|
+
why: `${STORES_WHY} (${DOCS}/adapters.md)`
|
|
2482
|
+
});
|
|
2483
|
+
if (d.has.mmkv)
|
|
2484
|
+
commented.push({
|
|
2485
|
+
adapter: "react-native-mmkv",
|
|
2486
|
+
items: found.mmkv.map((s) => `${s.name} (${s.file})`),
|
|
2487
|
+
why: `${MMKV_WHY} (${DOCS}/adapters.md)`
|
|
2488
|
+
});
|
|
2489
|
+
if (d.has.asyncStorage)
|
|
2490
|
+
commented.push({
|
|
2491
|
+
adapter: "async-storage",
|
|
2492
|
+
items: ["AsyncStorage"],
|
|
2493
|
+
why: `${ASYNC_STORAGE_WHY} (${DOCS}/adapters.md)`
|
|
2494
|
+
});
|
|
2495
|
+
if (d.has.secureStore)
|
|
2496
|
+
commented.push({
|
|
2497
|
+
adapter: "secure-store",
|
|
2498
|
+
items: ["SecureStore"],
|
|
2499
|
+
why: `${SECURE_STORE_WHY} (${DOCS}/adapters.md)`
|
|
2500
|
+
});
|
|
2501
|
+
if (d.has.redux || d.has.reduxCore)
|
|
2502
|
+
commented.push({
|
|
2503
|
+
adapter: "redux",
|
|
2504
|
+
items: ["reduxTools(store)"],
|
|
2505
|
+
why: `${REDUX_WHY} (${DOCS}/adapters.md)`
|
|
2506
|
+
});
|
|
2507
|
+
if (d.has.jotai)
|
|
2508
|
+
commented.push({
|
|
2509
|
+
adapter: "jotai",
|
|
2510
|
+
items: ["jotaiTools(store, atoms)"],
|
|
2511
|
+
why: `${JOTAI_WHY} (${DOCS}/adapters.md)`
|
|
2512
|
+
});
|
|
2513
|
+
if (d.has.reactNavigation && !d.has.expoRouter)
|
|
2514
|
+
commented.push({
|
|
2515
|
+
adapter: "@react-navigation/native",
|
|
2516
|
+
items: ["navTools(navigationRef)"],
|
|
2517
|
+
why: `navTools needs the container ref you pass to <NavigationContainer> (${DOCS}/adapters.md)`
|
|
2518
|
+
});
|
|
2519
|
+
for (const t of realtime) todos.push(`${realtimeTodoText(t)}: ${REALTIME_WHY} (${DOCS}/realtime.md).`);
|
|
2520
|
+
}
|
|
2521
|
+
const missing = !wrote && liveText !== void 0 ? MARKERS.filter(([lib, marker]) => d.has[lib] && !marker.test(liveText)).map(([, , dep]) => dep) : [];
|
|
2522
|
+
const flows = join7(d.dir, "flows");
|
|
2523
|
+
generate(
|
|
2524
|
+
join7(flows, "smoke.flow.mjs"),
|
|
2525
|
+
renderSmokeFlow({ version: o.version, expoRouter: d.has.expoRouter, bin })
|
|
2526
|
+
);
|
|
2527
|
+
const readme = join7(flows, "README.md");
|
|
2528
|
+
if (fs.exists(readme)) files.push({ path: rel(readme), status: "skipped", note: "exists" });
|
|
2529
|
+
else {
|
|
2530
|
+
actions.push({ kind: "write", path: readme, content: renderFlowsReadme(bin) });
|
|
2531
|
+
files.push({ path: rel(readme), status: "written" });
|
|
2532
|
+
}
|
|
2533
|
+
const patch = (path, fn) => {
|
|
2534
|
+
if (!path) return;
|
|
2535
|
+
const abs = join7(d.dir, path);
|
|
2536
|
+
const result = fn(fs.read(abs) ?? "");
|
|
2537
|
+
if ("text" in result) {
|
|
2538
|
+
actions.push({ kind: "patch", path: abs, patch: fn });
|
|
2539
|
+
files.push({ path, status: "patched" });
|
|
2540
|
+
} else if ("manual" in result) {
|
|
2541
|
+
files.push({ path, status: "manual", note: result.manual });
|
|
2542
|
+
} else {
|
|
2543
|
+
files.push({ path, status: "unchanged", ...result.note && { note: result.note } });
|
|
2544
|
+
}
|
|
2545
|
+
};
|
|
2546
|
+
patch("package.json", patchPackageScripts);
|
|
2547
|
+
patch(d.tsconfig, (text2) => patchTsconfig(text2, tsconfig.baseAllowJs));
|
|
2548
|
+
patch(d.oxlint, patchOxlint);
|
|
2549
|
+
patch(d.eslint, patchEslint);
|
|
2550
|
+
if (d.agentsFile) {
|
|
2551
|
+
const text2 = fs.read(d.agentsFile) ?? "";
|
|
2552
|
+
const path = posix2(relative4(d.dir, d.agentsFile));
|
|
2553
|
+
if (text2.includes(".flow.mjs")) files.push({ path, status: "unchanged" });
|
|
2554
|
+
else {
|
|
2555
|
+
const appDir = posix2(relative4(dirname5(d.agentsFile), d.dir));
|
|
2556
|
+
actions.push({ kind: "append", path: d.agentsFile, line: agentsLine(appDir, bin) });
|
|
2557
|
+
files.push({ path, status: "appended" });
|
|
2558
|
+
}
|
|
2559
|
+
}
|
|
2560
|
+
const spec = `${PACKAGE}@^${o.version}`;
|
|
2561
|
+
const install = installCommand(d.pm, spec);
|
|
2562
|
+
if (!d.installed && o.install) actions.unshift({ kind: "install", command: install, cwd: d.dir });
|
|
2563
|
+
const skill = o.skillYes ? [...SKILL_RUN, "-y"] : SKILL_RUN;
|
|
2564
|
+
if (o.skill) actions.push({ kind: "skill", command: skill, cwd: d.repoRoot });
|
|
2565
|
+
const rootFile = d.rootFile ? join7(d.dir, d.rootFile) : void 0;
|
|
2566
|
+
const rootImports = rootFile ? importedFiles(rootFile, fs.read(rootFile) ?? "", tsconfig.aliases) : [];
|
|
2567
|
+
const reachedFromRoot = (file) => stripExt(file) === stripExt(rootFile ?? "") || rootImports.includes(stripExt(file));
|
|
2568
|
+
const mounted = elsewhere ? existing.some((f) => reachedFromRoot(join7(d.dir, f))) : rootImports.includes(stripExt(out));
|
|
2569
|
+
const snippet = renderMountSnippet(importPath(rootFile ?? join7(d.dir, "App.tsx"), out));
|
|
2570
|
+
const where = elsewhere ? `The app already uses the bridge in ${existing.join(", ")}; make sure the root component renders it` : d.rootFile ? `Paste the import into ${d.rootFile} and the element into the root component's JSX` : `No root component found (looked for ${d.has.expoRouter ? "app/_layout or src/app/_layout" : "App or src/App"}): paste it into yours, fixing the import path`;
|
|
2571
|
+
const note = `${where}${d.has.reactQuery && !elsewhere ? ", inside QueryClientProvider so query.* tools work" : ""}.`;
|
|
2572
|
+
const regenerable = state === "written" || state === "kept";
|
|
2573
|
+
const ignored = o.network.chosen && !wrote && o.network.enabled !== network ? `${o.network.enabled ? "--network" : "--no-network"} had no effect: ${elsewhere ? existing.join(", ") : rel(out)} already exists${regenerable ? "; add --force to regenerate it" : "; edit it by hand"}.` : void 0;
|
|
2574
|
+
const choice = {
|
|
2575
|
+
enabled: network,
|
|
2576
|
+
open: !network && !(o.network.chosen && wrote),
|
|
2577
|
+
why: NETWORK_WHY,
|
|
2578
|
+
...regenerable && { flag: network ? "--no-network --force" : "--network --force" },
|
|
2579
|
+
...!network && { lines: NETWORK_LINES },
|
|
2580
|
+
...ignored && { note: ignored }
|
|
2581
|
+
};
|
|
2582
|
+
const detected = MARKERS.filter(([lib]) => d.has[lib]).map(([, , dep]) => dep);
|
|
2583
|
+
if (d.has.reactNavigation && !d.has.expoRouter) detected.push("@react-navigation/native");
|
|
2584
|
+
return {
|
|
2585
|
+
actions,
|
|
2586
|
+
report: {
|
|
2587
|
+
ok: true,
|
|
2588
|
+
version: o.version,
|
|
2589
|
+
dryRun: false,
|
|
2590
|
+
app: { dir: d.dir, name: d.appName, packageManager: d.pm, detected },
|
|
2591
|
+
files,
|
|
2592
|
+
install: {
|
|
2593
|
+
status: d.installed ? "present" : "not-run",
|
|
2594
|
+
command: install.join(" ")
|
|
2595
|
+
},
|
|
2596
|
+
skill: { status: "not-run", command: SKILL_COMMAND.join(" "), plugin: PLUGIN_LINES },
|
|
2597
|
+
mount: { file: d.rootFile ?? null, mounted, snippet, note },
|
|
2598
|
+
choices: { network: choice },
|
|
2599
|
+
commented,
|
|
2600
|
+
todos,
|
|
2601
|
+
missing,
|
|
2602
|
+
existing,
|
|
2603
|
+
runFrom,
|
|
2604
|
+
scenarios: offline ? ["offline"] : [],
|
|
2605
|
+
...nextSteps(bin, { mounted, offline, runFrom, elsewhere })
|
|
2606
|
+
}
|
|
2607
|
+
};
|
|
2608
|
+
}
|
|
2609
|
+
|
|
2610
|
+
// src/init/scan.ts
|
|
2611
|
+
import { join as join8, relative as relative5 } from "path";
|
|
2612
|
+
var CODE = /\.(tsx?|jsx?|mjs|cjs)$/;
|
|
2613
|
+
var TESTS = /\.(test|spec)\.|(^|\/)__tests__\//;
|
|
2614
|
+
var imports = (text2, module) => new RegExp(
|
|
2615
|
+
`(from\\s+|require\\(\\s*)['"]${module.replace(/[.]/g, "\\.")}(/[\\w./-]+)?['"]`
|
|
2616
|
+
).test(text2);
|
|
2617
|
+
var TYPED = "(?:\\s*:[^=]+)?";
|
|
2618
|
+
var PATTERNS = [
|
|
2619
|
+
["stores", "zustand", new RegExp(`export\\s+const\\s+(use\\w+)${TYPED}\\s*=\\s*create\\w*\\s*[<(]`, "g")],
|
|
2620
|
+
["mmkv", "react-native-mmkv", new RegExp(`export\\s+const\\s+(\\w+)${TYPED}\\s*=\\s*(?:new\\s+MMKV|createMMKV)\\s*[<(]`, "g")],
|
|
2621
|
+
["socketIo", "socket.io-client", new RegExp(`(?:const|let)\\s+(\\w+)${TYPED}\\s*=\\s*io\\s*\\(`, "g")],
|
|
2622
|
+
["ably", "ably", new RegExp(`(?:const|let)\\s+(\\w+)${TYPED}\\s*=\\s*new\\s+(?:Ably\\.)?Realtime(?:\\.Promise)?\\s*\\(`, "g")],
|
|
2623
|
+
["realtimeTools", "@avasapp/agent-bridge", /export\s+const\s+(\w+)\s*=\s*(?:socketIoTools|ablyTools)\s*\(/g]
|
|
2624
|
+
];
|
|
2625
|
+
function scan(dir, fs, { maxFiles = 2e3, maxBytes = 512 * 1024 } = {}) {
|
|
2626
|
+
const files = [];
|
|
2627
|
+
const walk2 = (d, deep) => {
|
|
2628
|
+
const entries = fs.list(d).sort((a, b) => a.name < b.name ? -1 : 1);
|
|
2629
|
+
for (const e of entries) {
|
|
2630
|
+
if (files.length >= maxFiles) return;
|
|
2631
|
+
if (e.name.startsWith(".") || e.name === "node_modules") continue;
|
|
2632
|
+
const path = join8(d, e.name);
|
|
2633
|
+
if (e.dir) {
|
|
2634
|
+
if (deep) walk2(path, true);
|
|
2635
|
+
} else if (CODE.test(e.name) && !TESTS.test(relative5(dir, path))) {
|
|
2636
|
+
files.push(path);
|
|
2637
|
+
}
|
|
2638
|
+
}
|
|
2639
|
+
};
|
|
2640
|
+
walk2(dir, false);
|
|
2641
|
+
for (const sub of ["src", "app", "lib"]) walk2(join8(dir, sub), true);
|
|
2642
|
+
const result = { stores: [], mmkv: [], socketIo: [], ably: [], realtimeTools: [], bridgeUsers: [] };
|
|
2643
|
+
for (const path of files) {
|
|
2644
|
+
const text2 = fs.read(path);
|
|
2645
|
+
if (!text2 || text2.length > maxBytes) continue;
|
|
2646
|
+
if (/\buseAgentBridge\s*\(|(from\s+|require\(\s*)['"]@avasapp\/agent-bridge['"]/.test(text2))
|
|
2647
|
+
result.bridgeUsers.push(relative5(dir, path));
|
|
2648
|
+
for (const [key, module, re] of PATTERNS) {
|
|
2649
|
+
if (!imports(text2, module)) continue;
|
|
2650
|
+
for (const m of text2.matchAll(re))
|
|
2651
|
+
result[key].push({ name: m[1], file: relative5(dir, path) });
|
|
2652
|
+
}
|
|
2653
|
+
}
|
|
2654
|
+
return result;
|
|
2655
|
+
}
|
|
2656
|
+
|
|
2657
|
+
// src/init/index.ts
|
|
2658
|
+
var INSTALL_TIMEOUT_MS = 5 * 6e4;
|
|
2659
|
+
var SKILL_TIMEOUT_MS = 2 * 6e4;
|
|
2660
|
+
var posix3 = (p) => p.split(sep2).join("/");
|
|
2661
|
+
var nodeFs = {
|
|
2662
|
+
exists: existsSync2,
|
|
2663
|
+
read: (path) => {
|
|
2664
|
+
try {
|
|
2665
|
+
return readFileSync4(path, "utf8");
|
|
2666
|
+
} catch {
|
|
2667
|
+
return void 0;
|
|
2668
|
+
}
|
|
2669
|
+
},
|
|
2670
|
+
readJson: (path) => {
|
|
2671
|
+
try {
|
|
2672
|
+
return JSON.parse(readFileSync4(path, "utf8"));
|
|
2673
|
+
} catch {
|
|
2674
|
+
return void 0;
|
|
2675
|
+
}
|
|
2676
|
+
},
|
|
2677
|
+
list: (dir) => {
|
|
2678
|
+
try {
|
|
2679
|
+
return readdirSync(dir, { withFileTypes: true }).map((e) => ({
|
|
2680
|
+
name: e.name,
|
|
2681
|
+
dir: e.isDirectory()
|
|
2682
|
+
}));
|
|
2683
|
+
} catch {
|
|
2684
|
+
return [];
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
};
|
|
2688
|
+
function nodeIo() {
|
|
2689
|
+
return {
|
|
2690
|
+
fs: nodeFs,
|
|
2691
|
+
write: (path, content) => {
|
|
2692
|
+
mkdirSync2(dirname6(path), { recursive: true });
|
|
2693
|
+
writeFileSync2(path, content);
|
|
2694
|
+
},
|
|
2695
|
+
append: (path, text2) => appendFileSync3(path, text2),
|
|
2696
|
+
exec: (command, cwd, interactive, timeoutMs) => new Promise((done) => {
|
|
2697
|
+
const [cmd, ...args] = command;
|
|
2698
|
+
const child = spawn2(cmd, args, {
|
|
2699
|
+
cwd,
|
|
2700
|
+
stdio: [interactive ? "inherit" : "ignore", 2, 2],
|
|
2701
|
+
shell: process.platform === "win32"
|
|
2702
|
+
});
|
|
2703
|
+
const timer = setTimeout(() => {
|
|
2704
|
+
child.kill("SIGTERM");
|
|
2705
|
+
done("timeout");
|
|
2706
|
+
}, timeoutMs);
|
|
2707
|
+
child.on("error", () => {
|
|
2708
|
+
clearTimeout(timer);
|
|
2709
|
+
done(127);
|
|
2710
|
+
});
|
|
2711
|
+
child.on("close", (code) => {
|
|
2712
|
+
clearTimeout(timer);
|
|
2713
|
+
done(code ?? 1);
|
|
2714
|
+
});
|
|
2715
|
+
}),
|
|
2716
|
+
prompt: async (question) => {
|
|
2717
|
+
const rl = createInterface4({ input: process.stdin, output: process.stderr });
|
|
2718
|
+
try {
|
|
2719
|
+
return await rl.question(question);
|
|
2720
|
+
} finally {
|
|
2721
|
+
rl.close();
|
|
2722
|
+
}
|
|
2723
|
+
},
|
|
2724
|
+
print: (text2) => process.stdout.write(`${text2}
|
|
2725
|
+
`),
|
|
2726
|
+
warn: (text2) => process.stderr.write(`${text2}
|
|
2727
|
+
`),
|
|
2728
|
+
interactive: !!process.stdin.isTTY && !!process.stdout.isTTY && !/^(1|true)$/i.test(process.env.CI ?? ""),
|
|
2729
|
+
cwd: process.cwd(),
|
|
2730
|
+
userAgent: process.env.npm_config_user_agent
|
|
2731
|
+
};
|
|
2732
|
+
}
|
|
2733
|
+
async function apply(actions, report, io, interactive) {
|
|
2734
|
+
const why = (code, ms) => code === "timeout" ? `timed out after ${ms / 6e4} min` : `exit code ${code}`;
|
|
2735
|
+
for (const a of actions) {
|
|
2736
|
+
switch (a.kind) {
|
|
2737
|
+
case "install": {
|
|
2738
|
+
const code = await io.exec(a.command, a.cwd, false, INSTALL_TIMEOUT_MS);
|
|
2739
|
+
report.install.status = code === 0 ? "installed" : "failed";
|
|
2740
|
+
if (code !== 0) {
|
|
2741
|
+
report.ok = false;
|
|
2742
|
+
report.install.error = why(code, INSTALL_TIMEOUT_MS);
|
|
2743
|
+
io.warn(`Install failed (${a.command.join(" ")}): ${report.install.error}`);
|
|
2744
|
+
}
|
|
2745
|
+
break;
|
|
2746
|
+
}
|
|
2747
|
+
case "skill": {
|
|
2748
|
+
const code = await io.exec(a.command, a.cwd, interactive, SKILL_TIMEOUT_MS);
|
|
2749
|
+
report.skill.status = code === 0 ? "added" : "failed";
|
|
2750
|
+
if (code !== 0) {
|
|
2751
|
+
report.skill.error = why(code, SKILL_TIMEOUT_MS);
|
|
2752
|
+
io.warn(`Adding the skill failed (${report.skill.error}); add it later with ${report.skill.command}`);
|
|
2753
|
+
}
|
|
2754
|
+
break;
|
|
2755
|
+
}
|
|
2756
|
+
case "write":
|
|
2757
|
+
io.write(a.path, a.content);
|
|
2758
|
+
break;
|
|
2759
|
+
case "patch": {
|
|
2760
|
+
const result = a.patch(io.fs.read(a.path) ?? "");
|
|
2761
|
+
if ("text" in result) io.write(a.path, result.text);
|
|
2762
|
+
break;
|
|
2763
|
+
}
|
|
2764
|
+
case "append": {
|
|
2765
|
+
const text2 = io.fs.read(a.path) ?? "";
|
|
2766
|
+
io.append(a.path, `${text2.endsWith("\n") || !text2 ? "" : "\n"}${a.line}
|
|
2767
|
+
`);
|
|
2768
|
+
break;
|
|
2769
|
+
}
|
|
2770
|
+
}
|
|
2771
|
+
}
|
|
2772
|
+
}
|
|
2773
|
+
var describe = (a, dir) => {
|
|
2774
|
+
const rel = (p) => relative6(dir, p) || ".";
|
|
2775
|
+
switch (a.kind) {
|
|
2776
|
+
case "install":
|
|
2777
|
+
return `install ${a.command.join(" ")}`;
|
|
2778
|
+
case "skill":
|
|
2779
|
+
return `skill ${a.command.join(" ")} (in ${a.cwd})`;
|
|
2780
|
+
case "write":
|
|
2781
|
+
return `write ${rel(a.path)}`;
|
|
2782
|
+
case "patch":
|
|
2783
|
+
return `patch ${rel(a.path)}`;
|
|
2784
|
+
case "append":
|
|
2785
|
+
return `append ${rel(a.path)}: ${a.line}`;
|
|
2786
|
+
}
|
|
2787
|
+
};
|
|
2788
|
+
function formatReport(r, actions) {
|
|
2789
|
+
const L = [];
|
|
2790
|
+
const detected = r.app.detected.length ? ` (${r.app.detected.join(", ")})` : "";
|
|
2791
|
+
L.push(
|
|
2792
|
+
`agent-bridge init ${r.version}${r.dryRun ? " --dry-run: nothing changed" : ""}`,
|
|
2793
|
+
`App "${r.app.name}"${detected}, ${r.app.packageManager}`,
|
|
2794
|
+
""
|
|
2795
|
+
);
|
|
2796
|
+
if (r.dryRun) {
|
|
2797
|
+
L.push("Would run:", ...actions.map((a) => ` ${describe(a, r.app.dir)}`), "");
|
|
2798
|
+
for (const a of actions)
|
|
2799
|
+
if (a.kind === "write")
|
|
2800
|
+
L.push(`--- ${relative6(r.app.dir, a.path)}`, a.content.trimEnd(), "");
|
|
2801
|
+
}
|
|
2802
|
+
L.push("Files");
|
|
2803
|
+
for (const f of r.files)
|
|
2804
|
+
L.push(` ${f.status.padEnd(9)} ${f.path}${f.note ? `: ${f.note}` : ""}`);
|
|
2805
|
+
L.push(
|
|
2806
|
+
"",
|
|
2807
|
+
`Install: ${r.install.status} (${r.install.command})`,
|
|
2808
|
+
`Skill: ${r.skill.status} (${r.skill.command})`,
|
|
2809
|
+
" In Claude Code, install it as a plugin instead:",
|
|
2810
|
+
...r.skill.plugin.map((l) => ` ${l}`)
|
|
2811
|
+
);
|
|
2812
|
+
if (r.existing.length)
|
|
2813
|
+
L.push("", `Already uses the bridge: ${r.existing.join(", ")}`);
|
|
2814
|
+
if (r.missing.length)
|
|
2815
|
+
L.push("", `Detected but not wired in the bridge file: ${r.missing.join(", ")} (see ${DOCS}/adapters.md)`);
|
|
2816
|
+
if (r.commented.length) {
|
|
2817
|
+
L.push("", "Left as comments in the bridge file:");
|
|
2818
|
+
for (const c of r.commented)
|
|
2819
|
+
L.push(` ${c.adapter}${c.items.length ? `: ${c.items.join(", ")}` : ""}. Why: ${c.why}`);
|
|
2820
|
+
}
|
|
2821
|
+
if (r.todos.length) L.push("", "TODO:", ...r.todos.map((t) => ` ${t}`));
|
|
2822
|
+
const n = r.choices.network;
|
|
2823
|
+
L.push("");
|
|
2824
|
+
if (n.enabled) L.push("Network tools: on (net.*).");
|
|
2825
|
+
else {
|
|
2826
|
+
const how = [n.flag && `rerun with ${n.flag}`, n.lines && "add these lines"].filter(Boolean).join(", or ");
|
|
2827
|
+
L.push(`Network tools: off. ${n.open ? "Open choice: " : ""}${n.why}. To wire them, ${how}:`);
|
|
2828
|
+
L.push(...(n.lines ?? []).map((l) => ` ${l}`));
|
|
2829
|
+
}
|
|
2830
|
+
if (n.note) L.push(n.note);
|
|
2831
|
+
L.push(
|
|
2832
|
+
"",
|
|
2833
|
+
r.mount.mounted ? `Mounted: yes (from ${r.mount.file}).` : `Mounted: no. ${r.mount.note}`
|
|
2834
|
+
);
|
|
2835
|
+
if (!r.mount.mounted && !r.existing.length)
|
|
2836
|
+
L.push(...r.mount.snippet.split("\n").map((l) => ` ${l}`));
|
|
2837
|
+
L.push("", `First run${r.runFrom === "." ? "" : ` (from ${r.runFrom})`}:`);
|
|
2838
|
+
r.next.forEach(
|
|
2839
|
+
(s, i) => L.push(` ${i + 1}. ${s.step}${s.command ? `
|
|
2840
|
+
${s.command}` : ""}`)
|
|
2841
|
+
);
|
|
2842
|
+
L.push("", "Prompt for your agent:", ` ${r.prompt}`);
|
|
2843
|
+
return L.join("\n");
|
|
2844
|
+
}
|
|
2845
|
+
async function runInit(args, io) {
|
|
2846
|
+
const dir = resolve2(io.cwd, args.dir ?? ".");
|
|
2847
|
+
const fail = (error) => {
|
|
2848
|
+
if (args.json) io.print(JSON.stringify({ ok: false, ...error }, null, 2));
|
|
2849
|
+
else io.warn(error.error);
|
|
2850
|
+
return 1;
|
|
2851
|
+
};
|
|
2852
|
+
let d = detect(dir, io.fs, { userAgent: io.userAgent });
|
|
2853
|
+
if ("error" in d) return fail(d);
|
|
2854
|
+
const interactive = io.interactive && !args.json && !args.yes;
|
|
2855
|
+
const options = (network2) => ({
|
|
2856
|
+
version,
|
|
2857
|
+
out: args.out === void 0 ? void 0 : resolve2(io.cwd, args.out),
|
|
2858
|
+
force: args.force,
|
|
2859
|
+
install: args.install !== false,
|
|
2860
|
+
skill: args.skill !== false,
|
|
2861
|
+
network: network2,
|
|
2862
|
+
skillYes: !interactive
|
|
2863
|
+
});
|
|
2864
|
+
const planFor = (det, network2, install2 = true) => plan(det, scan(dir, io.fs), io.fs, { ...options(network2), install: install2 && args.install !== false }, io.cwd);
|
|
2865
|
+
let network = { enabled: args.network ?? false, chosen: args.network !== void 0 };
|
|
2866
|
+
const first = planFor(d, network);
|
|
2867
|
+
if ("error" in first) return fail(first);
|
|
2868
|
+
if (args.dryRun) {
|
|
2869
|
+
const { actions: actions2, report: report2 } = first;
|
|
2870
|
+
report2.dryRun = true;
|
|
2871
|
+
if (args.json) {
|
|
2872
|
+
const contents = Object.fromEntries(
|
|
2873
|
+
actions2.flatMap((a) => a.kind === "write" ? [[relative6(dir, a.path), a.content]] : [])
|
|
2874
|
+
);
|
|
2875
|
+
io.print(JSON.stringify({ ...report2, contents }, null, 2));
|
|
2876
|
+
} else io.print(formatReport(report2, actions2));
|
|
2877
|
+
return 0;
|
|
2878
|
+
}
|
|
2879
|
+
const bridge = posix3(relative6(dir, bridgePath(d, options(network).out)));
|
|
2880
|
+
const writesBridge = first.report.files.some((f) => f.path === bridge && f.status === "written");
|
|
2881
|
+
if (!network.chosen && interactive && writesBridge) {
|
|
2882
|
+
const answer = await io.prompt(`${NETWORK_PROMPT} [y/N] `);
|
|
2883
|
+
network = { enabled: /^y(es)?$/i.test(answer.trim()), chosen: true };
|
|
2884
|
+
}
|
|
2885
|
+
const install = first.actions.find((a) => a.kind === "install");
|
|
2886
|
+
const installReport = first.report.install;
|
|
2887
|
+
if (install) {
|
|
2888
|
+
await apply([install], first.report, io, interactive);
|
|
2889
|
+
const after = detect(dir, io.fs, { userAgent: io.userAgent });
|
|
2890
|
+
if (!("error" in after)) d = after;
|
|
2891
|
+
}
|
|
2892
|
+
const p = planFor(d, network, false);
|
|
2893
|
+
if ("error" in p) return fail(p);
|
|
2894
|
+
const { actions, report } = p;
|
|
2895
|
+
if (install) {
|
|
2896
|
+
report.install = installReport;
|
|
2897
|
+
report.ok = first.report.ok;
|
|
2898
|
+
}
|
|
2899
|
+
await apply(actions, report, io, interactive);
|
|
2900
|
+
io.print(args.json ? JSON.stringify(report, null, 2) : formatReport(report, actions));
|
|
2901
|
+
return report.ok ? 0 : 1;
|
|
2902
|
+
}
|
|
2903
|
+
|
|
2904
|
+
// src/mcp/server.ts
|
|
2905
|
+
import { createInterface as createInterface5 } from "readline";
|
|
2906
|
+
var SUPPORTED = ["2025-06-18", "2024-11-05"];
|
|
2907
|
+
var STATUS_TOOL = "agent_bridge_status";
|
|
2908
|
+
var MAX_NAME = 64;
|
|
2909
|
+
function mcpNames(tools, reserved = []) {
|
|
2910
|
+
const out = /* @__PURE__ */ new Map();
|
|
2911
|
+
const taken = new Set(reserved);
|
|
2912
|
+
const sorted = [...tools].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
2913
|
+
for (const { name } of sorted) {
|
|
2914
|
+
const base = name.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, MAX_NAME - 4) || "_";
|
|
2915
|
+
let mcp = base;
|
|
2916
|
+
for (let n = 2; taken.has(mcp); n++) mcp = `${base}_${n}`;
|
|
2917
|
+
taken.add(mcp);
|
|
2918
|
+
out.set(mcp, name);
|
|
2919
|
+
}
|
|
2920
|
+
return out;
|
|
2921
|
+
}
|
|
2922
|
+
var INPUT_SCHEMA = {
|
|
2923
|
+
type: "object",
|
|
2924
|
+
properties: {
|
|
2925
|
+
args: {
|
|
2926
|
+
type: "array",
|
|
2927
|
+
items: {},
|
|
2928
|
+
description: "The tool's arguments, in order (each any JSON value). Leave out for none."
|
|
2929
|
+
},
|
|
2930
|
+
full: {
|
|
2931
|
+
type: "boolean",
|
|
2932
|
+
description: `Return a result over ${OUTPUT_LIMIT_BYTES / 1024} KB whole instead of a summary of its size and shape.`
|
|
2933
|
+
}
|
|
2934
|
+
},
|
|
2935
|
+
additionalProperties: false
|
|
2936
|
+
};
|
|
2937
|
+
var text = (t) => ({ type: "text", text: t });
|
|
2938
|
+
var logBlock = (logs) => logs.length ? [text(`App errors:
|
|
2939
|
+
${logs.map(logLine).join("\n")}`)] : [];
|
|
2940
|
+
var IMAGE_MAGIC = [
|
|
2941
|
+
["iVBORw0KGgo", "image/png"],
|
|
2942
|
+
["/9j/", "image/jpeg"],
|
|
2943
|
+
["UklGR", "image/webp"],
|
|
2944
|
+
["R0lGOD", "image/gif"]
|
|
2945
|
+
];
|
|
2946
|
+
function withImage(tool, value) {
|
|
2947
|
+
if (tool !== "screen.capture" || !value || typeof value !== "object") return null;
|
|
2948
|
+
const { base64, ...rest } = value;
|
|
2949
|
+
if (typeof base64 !== "string" || !base64) return null;
|
|
2950
|
+
const url = /^data:([^;,]*)[^,]*,/.exec(base64);
|
|
2951
|
+
const data = url ? base64.slice(url[0].length) : base64;
|
|
2952
|
+
const mimeType = IMAGE_MAGIC.find(([magic]) => data.startsWith(magic))?.[1];
|
|
2953
|
+
if (!mimeType) return null;
|
|
2954
|
+
const image = { type: "image", data, mimeType };
|
|
2955
|
+
return Object.keys(rest).length ? [image, text(JSON.stringify(rest, null, 2))] : [image];
|
|
2956
|
+
}
|
|
2957
|
+
async function resultContent(tool, value, full) {
|
|
2958
|
+
const image = withImage(tool, value);
|
|
2959
|
+
if (image) return image;
|
|
2960
|
+
if (isSnapshotText(tool, value) && (full || Buffer.byteLength(value) <= OUTPUT_LIMIT_BYTES)) return [text(value)];
|
|
2961
|
+
const shaped = await shapedResult(value, { full });
|
|
2962
|
+
const json = JSON.stringify(shaped, null, 2) ?? "null";
|
|
2963
|
+
if (!full && shaped && typeof shaped === "object" && shaped.resultTooLarge)
|
|
2964
|
+
return [
|
|
2965
|
+
text(
|
|
2966
|
+
JSON.stringify(
|
|
2967
|
+
{
|
|
2968
|
+
...shaped,
|
|
2969
|
+
hint: "Not returned: the result is large. Call again with full: true to get it whole, or narrow the call (a path, a filter, a smaller page)."
|
|
2970
|
+
},
|
|
2971
|
+
null,
|
|
2972
|
+
2
|
|
2973
|
+
)
|
|
2974
|
+
)
|
|
2975
|
+
];
|
|
2976
|
+
return [text(json)];
|
|
2977
|
+
}
|
|
2978
|
+
var message2 = (error) => error instanceof Error ? error.message : String(error);
|
|
2979
|
+
function startMcpServer(options) {
|
|
2980
|
+
const pollMs = options.pollMs ?? 5e3;
|
|
2981
|
+
let bridge = null;
|
|
2982
|
+
let opening = null;
|
|
2983
|
+
let tools = [];
|
|
2984
|
+
let names2 = /* @__PURE__ */ new Map();
|
|
2985
|
+
let signature = "";
|
|
2986
|
+
let listed = null;
|
|
2987
|
+
let announced = "";
|
|
2988
|
+
let initialized = false;
|
|
2989
|
+
let closed = false;
|
|
2990
|
+
let inflight = 0;
|
|
2991
|
+
let lastError;
|
|
2992
|
+
const send = (message3) => {
|
|
2993
|
+
if (!closed) options.output.write(encodeLine(message3));
|
|
2994
|
+
};
|
|
2995
|
+
const reply = (id, result) => send({ jsonrpc: "2.0", id, result });
|
|
2996
|
+
const fail = (id, code, error) => send({ jsonrpc: "2.0", id, error: { code, message: error } });
|
|
2997
|
+
const noteError = (error) => {
|
|
2998
|
+
const now = message2(error);
|
|
2999
|
+
if (now === lastError) return;
|
|
3000
|
+
lastError = now;
|
|
3001
|
+
options.log?.(`agent-bridge mcp: not connected: ${now}`);
|
|
3002
|
+
};
|
|
3003
|
+
const announce = () => {
|
|
3004
|
+
if (!initialized || listed === null || signature === listed || signature === announced)
|
|
3005
|
+
return;
|
|
3006
|
+
announced = signature;
|
|
3007
|
+
send({ jsonrpc: "2.0", method: "notifications/tools/list_changed" });
|
|
3008
|
+
};
|
|
3009
|
+
const adopt = (next) => {
|
|
3010
|
+
const list2 = next.tools();
|
|
3011
|
+
const sig = JSON.stringify([next.device.deviceId, list2]);
|
|
3012
|
+
bridge = next;
|
|
3013
|
+
lastError = void 0;
|
|
3014
|
+
if (sig !== signature) {
|
|
3015
|
+
signature = sig;
|
|
3016
|
+
names2 = mcpNames(list2, [STATUS_TOOL]);
|
|
3017
|
+
tools = [...names2].map(([mcp, name]) => ({
|
|
3018
|
+
name,
|
|
3019
|
+
mcp,
|
|
3020
|
+
description: list2.find((t) => t.name === name)?.description
|
|
3021
|
+
}));
|
|
3022
|
+
announce();
|
|
3023
|
+
}
|
|
3024
|
+
};
|
|
3025
|
+
const ensure = () => {
|
|
3026
|
+
if (bridge) return Promise.resolve(bridge);
|
|
3027
|
+
opening ??= options.connect().then((next) => {
|
|
3028
|
+
adopt(next);
|
|
3029
|
+
return next;
|
|
3030
|
+
}).catch((error) => {
|
|
3031
|
+
noteError(error);
|
|
3032
|
+
throw error;
|
|
3033
|
+
}).finally(() => {
|
|
3034
|
+
opening = null;
|
|
3035
|
+
});
|
|
3036
|
+
return opening;
|
|
3037
|
+
};
|
|
3038
|
+
const drop = () => {
|
|
3039
|
+
const old = bridge;
|
|
3040
|
+
bridge = null;
|
|
3041
|
+
try {
|
|
3042
|
+
old?.close();
|
|
3043
|
+
} catch {
|
|
3044
|
+
}
|
|
3045
|
+
};
|
|
3046
|
+
let polling = false;
|
|
3047
|
+
const poll = async () => {
|
|
3048
|
+
if (polling || inflight || closed) return;
|
|
3049
|
+
polling = true;
|
|
3050
|
+
try {
|
|
3051
|
+
if (!bridge) {
|
|
3052
|
+
await ensure();
|
|
3053
|
+
return;
|
|
3054
|
+
}
|
|
3055
|
+
const next = await options.connect();
|
|
3056
|
+
if (inflight || closed) return next.close();
|
|
3057
|
+
const old = bridge;
|
|
3058
|
+
adopt(next);
|
|
3059
|
+
if (old !== next) old.close();
|
|
3060
|
+
} catch (error) {
|
|
3061
|
+
noteError(error);
|
|
3062
|
+
} finally {
|
|
3063
|
+
polling = false;
|
|
3064
|
+
}
|
|
3065
|
+
};
|
|
3066
|
+
const timer = pollMs > 0 ? setInterval(() => void poll(), pollMs) : void 0;
|
|
3067
|
+
timer?.unref?.();
|
|
3068
|
+
const statusTool = {
|
|
3069
|
+
name: STATUS_TOOL,
|
|
3070
|
+
title: "agent-bridge status",
|
|
3071
|
+
description: "Whether this server is connected to the app, which app and session, how many tools it has, and why the last connect failed. Works with no app connected.",
|
|
3072
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false }
|
|
3073
|
+
};
|
|
3074
|
+
const listTools = () => {
|
|
3075
|
+
listed = signature;
|
|
3076
|
+
return {
|
|
3077
|
+
tools: [
|
|
3078
|
+
...tools.map((t) => ({
|
|
3079
|
+
name: t.mcp,
|
|
3080
|
+
title: t.name,
|
|
3081
|
+
description: t.description ?? t.name,
|
|
3082
|
+
inputSchema: INPUT_SCHEMA
|
|
3083
|
+
})),
|
|
3084
|
+
statusTool
|
|
3085
|
+
]
|
|
3086
|
+
};
|
|
3087
|
+
};
|
|
3088
|
+
const statusResult = async () => {
|
|
3089
|
+
await ensure().catch(() => {
|
|
3090
|
+
});
|
|
3091
|
+
const json = {
|
|
3092
|
+
connected: !!bridge,
|
|
3093
|
+
...bridge && {
|
|
3094
|
+
app: { name: bridge.device.name, deviceId: bridge.device.deviceId },
|
|
3095
|
+
tools: tools.length
|
|
3096
|
+
},
|
|
3097
|
+
...options.sessionName?.() && { session: options.sessionName() },
|
|
3098
|
+
...lastError && { lastError }
|
|
3099
|
+
};
|
|
3100
|
+
return { content: [text(JSON.stringify(json, null, 2))] };
|
|
3101
|
+
};
|
|
3102
|
+
const callTool = async (params) => {
|
|
3103
|
+
const failure = (...content) => ({ isError: true, content });
|
|
3104
|
+
if (params.name === STATUS_TOOL) return statusResult();
|
|
3105
|
+
const notice = options.takeNotice?.();
|
|
3106
|
+
if (notice) {
|
|
3107
|
+
drop();
|
|
3108
|
+
return failure(text(notice));
|
|
3109
|
+
}
|
|
3110
|
+
inflight++;
|
|
3111
|
+
try {
|
|
3112
|
+
let b;
|
|
3113
|
+
try {
|
|
3114
|
+
b = await ensure();
|
|
3115
|
+
} catch (error) {
|
|
3116
|
+
return failure(
|
|
3117
|
+
text(
|
|
3118
|
+
`Not connected to the app: ${message2(error)}
|
|
3119
|
+
Start the app and call again; the server reconnects on the next call. The ${STATUS_TOOL} tool says what state it is in.`
|
|
3120
|
+
)
|
|
3121
|
+
);
|
|
3122
|
+
}
|
|
3123
|
+
const tool = typeof params.name === "string" ? names2.get(params.name) : void 0;
|
|
3124
|
+
if (!tool) return null;
|
|
3125
|
+
const input = params.arguments ?? {};
|
|
3126
|
+
let args = input.args ?? [];
|
|
3127
|
+
if (typeof args === "string") {
|
|
3128
|
+
try {
|
|
3129
|
+
args = JSON.parse(args);
|
|
3130
|
+
} catch {
|
|
3131
|
+
}
|
|
3132
|
+
}
|
|
3133
|
+
if (!Array.isArray(args))
|
|
3134
|
+
return failure(text("`args` must be an array of the tool's arguments."));
|
|
3135
|
+
try {
|
|
3136
|
+
const timed = await b.timed(tool, ...withCompactSnapshot(tool, args));
|
|
3137
|
+
const content = await resultContent(tool, timed.value, input.full === true);
|
|
3138
|
+
content.push(...logBlock(timed.logs));
|
|
3139
|
+
if (timed.notice) content.push(text(`Warning: ${timed.notice}`));
|
|
3140
|
+
return { content };
|
|
3141
|
+
} catch (error) {
|
|
3142
|
+
if (error instanceof AgentBridgeCallError)
|
|
3143
|
+
return failure(text(error.message), ...logBlock(error.logs));
|
|
3144
|
+
if (error instanceof SessionLinkError) {
|
|
3145
|
+
drop();
|
|
3146
|
+
return failure(
|
|
3147
|
+
text(
|
|
3148
|
+
`${error.message}
|
|
3149
|
+
The call may or may not have run; check before repeating one that changes state. The server reconnects on the next call.`
|
|
3150
|
+
)
|
|
3151
|
+
);
|
|
3152
|
+
}
|
|
3153
|
+
if (/^No reply to "/.test(message2(error))) drop();
|
|
3154
|
+
return failure(text(message2(error)));
|
|
3155
|
+
}
|
|
3156
|
+
} finally {
|
|
3157
|
+
inflight--;
|
|
3158
|
+
}
|
|
3159
|
+
};
|
|
3160
|
+
const active = /* @__PURE__ */ new Map();
|
|
3161
|
+
const dispatch = async (method, params, id, isRequest) => {
|
|
3162
|
+
switch (method) {
|
|
3163
|
+
case "initialize": {
|
|
3164
|
+
if (!isRequest) return null;
|
|
3165
|
+
const asked = params?.protocolVersion;
|
|
3166
|
+
void ensure().catch(() => {
|
|
3167
|
+
});
|
|
3168
|
+
return {
|
|
3169
|
+
result: {
|
|
3170
|
+
protocolVersion: SUPPORTED.includes(asked) ? asked : SUPPORTED[0],
|
|
3171
|
+
capabilities: { tools: { listChanged: true } },
|
|
3172
|
+
serverInfo: { name: "agent-bridge", version: options.version ?? "0.0.0" },
|
|
3173
|
+
instructions: `Tools of the connected React Native app. Each takes \`args\`, an array of the tool's arguments. Results over 32 KB come back as a summary unless \`full\` is true. If the list is empty or calls fail, call ${STATUS_TOOL}.`
|
|
3174
|
+
}
|
|
3175
|
+
};
|
|
3176
|
+
}
|
|
3177
|
+
case "notifications/initialized":
|
|
3178
|
+
initialized = true;
|
|
3179
|
+
return null;
|
|
3180
|
+
case "ping":
|
|
3181
|
+
return isRequest ? { result: {} } : null;
|
|
3182
|
+
case "tools/list":
|
|
3183
|
+
if (!isRequest) return null;
|
|
3184
|
+
await Promise.race([
|
|
3185
|
+
ensure().catch(() => {
|
|
3186
|
+
}),
|
|
3187
|
+
new Promise((r) => {
|
|
3188
|
+
const t = setTimeout(r, 5e3);
|
|
3189
|
+
if (typeof t === "object") t.unref();
|
|
3190
|
+
})
|
|
3191
|
+
]);
|
|
3192
|
+
return { result: listTools() };
|
|
3193
|
+
case "tools/call": {
|
|
3194
|
+
if (!isRequest) return null;
|
|
3195
|
+
const result = await callTool(params ?? {});
|
|
3196
|
+
return result ? { result } : { code: -32602, error: `Unknown tool: ${String(params?.name)}` };
|
|
3197
|
+
}
|
|
3198
|
+
default:
|
|
3199
|
+
return isRequest ? { code: -32601, error: `Method not found: ${method}` } : null;
|
|
3200
|
+
}
|
|
3201
|
+
};
|
|
3202
|
+
const handle = async (raw) => {
|
|
3203
|
+
let req;
|
|
3204
|
+
try {
|
|
3205
|
+
req = JSON.parse(raw);
|
|
3206
|
+
} catch {
|
|
3207
|
+
return void fail(null, -32700, "Parse error");
|
|
3208
|
+
}
|
|
3209
|
+
if (!req || typeof req !== "object" || Array.isArray(req))
|
|
3210
|
+
return void fail(null, -32600, "Invalid request");
|
|
3211
|
+
if (req.method === void 0 && req.id !== void 0) return;
|
|
3212
|
+
if (typeof req.method !== "string")
|
|
3213
|
+
return void fail(req.id ?? null, -32600, "Invalid request");
|
|
3214
|
+
const { id, method, params } = req;
|
|
3215
|
+
const isRequest = id !== void 0 && id !== null;
|
|
3216
|
+
if (method === "notifications/cancelled") {
|
|
3217
|
+
active.get(JSON.stringify(params?.requestId))?.();
|
|
3218
|
+
return;
|
|
3219
|
+
}
|
|
3220
|
+
const key = JSON.stringify(id);
|
|
3221
|
+
let cancel = () => {
|
|
3222
|
+
};
|
|
3223
|
+
const cancelled = new Promise((r) => {
|
|
3224
|
+
cancel = () => r("cancelled");
|
|
3225
|
+
});
|
|
3226
|
+
if (isRequest) active.set(key, cancel);
|
|
3227
|
+
try {
|
|
3228
|
+
const outcome = await Promise.race([dispatch(method, params, id, isRequest), cancelled]);
|
|
3229
|
+
if (outcome === "cancelled" || !outcome) return;
|
|
3230
|
+
if ("result" in outcome) reply(id, outcome.result);
|
|
3231
|
+
else fail(id, outcome.code, outcome.error);
|
|
3232
|
+
} catch (error) {
|
|
3233
|
+
if (isRequest) fail(id, -32603, message2(error));
|
|
3234
|
+
} finally {
|
|
3235
|
+
if (isRequest) active.delete(key);
|
|
3236
|
+
}
|
|
3237
|
+
};
|
|
3238
|
+
const lines = createInterface5({ input: options.input });
|
|
3239
|
+
const pending = /* @__PURE__ */ new Set();
|
|
3240
|
+
lines.on("line", (line) => {
|
|
3241
|
+
if (!line.trim()) return;
|
|
3242
|
+
const p = handle(line).finally(() => pending.delete(p));
|
|
3243
|
+
pending.add(p);
|
|
3244
|
+
});
|
|
3245
|
+
const done = new Promise((resolve4) => {
|
|
3246
|
+
lines.once("close", () => {
|
|
3247
|
+
void Promise.allSettled([...pending]).then(() => {
|
|
3248
|
+
close();
|
|
3249
|
+
resolve4();
|
|
3250
|
+
});
|
|
3251
|
+
});
|
|
3252
|
+
});
|
|
3253
|
+
function close() {
|
|
3254
|
+
if (closed) return;
|
|
3255
|
+
closed = true;
|
|
3256
|
+
if (timer) clearInterval(timer);
|
|
3257
|
+
lines.close();
|
|
3258
|
+
drop();
|
|
3259
|
+
}
|
|
3260
|
+
return { done, close };
|
|
3261
|
+
}
|
|
3262
|
+
|
|
3263
|
+
// src/mcp/run.ts
|
|
3264
|
+
function createConnector(flags, options) {
|
|
3265
|
+
let daemon;
|
|
3266
|
+
let pinned;
|
|
3267
|
+
let ending = false;
|
|
3268
|
+
let notice;
|
|
3269
|
+
const ownName = `mcp-${process.pid}`;
|
|
3270
|
+
const stopped = (name) => {
|
|
3271
|
+
notice = `The session "${name}" this server was using stopped (session stop, or its idle time ran out). Stopping runs bridge.restore, so state changed through it was most likely undone. This call did not run; call again and the server uses a new session.`;
|
|
3272
|
+
};
|
|
3273
|
+
const connectBridge = async () => {
|
|
3274
|
+
if (flags["no-session"]) return connect(options);
|
|
3275
|
+
const timeoutMs = options.timeoutMs;
|
|
3276
|
+
if (daemon) return connectSession({ name: daemon.state.name, timeoutMs });
|
|
3277
|
+
if (pinned) {
|
|
3278
|
+
if (readSession(pinned)) return connectSession({ name: pinned, timeoutMs });
|
|
3279
|
+
stopped(pinned);
|
|
3280
|
+
pinned = void 0;
|
|
3281
|
+
}
|
|
3282
|
+
const existing = sessionFor(flags, (s) => s.name.startsWith("mcp-") && s.name !== ownName);
|
|
3283
|
+
if (existing) {
|
|
3284
|
+
pinned = existing.name;
|
|
3285
|
+
return connectSession({ name: pinned, timeoutMs });
|
|
3286
|
+
}
|
|
3287
|
+
const started = await runSessionDaemon({
|
|
3288
|
+
name: ownName,
|
|
3289
|
+
metro: options.metro,
|
|
3290
|
+
device: options.device,
|
|
3291
|
+
transport: options.transport,
|
|
3292
|
+
idleMs: 0,
|
|
3293
|
+
timeoutMs
|
|
3294
|
+
});
|
|
3295
|
+
daemon = started;
|
|
3296
|
+
void started.done.then(() => {
|
|
3297
|
+
if (daemon !== started) return;
|
|
3298
|
+
daemon = void 0;
|
|
3299
|
+
if (!ending) stopped(started.state.name);
|
|
3300
|
+
});
|
|
3301
|
+
return connectSession({ name: started.state.name, timeoutMs });
|
|
3302
|
+
};
|
|
3303
|
+
return {
|
|
3304
|
+
connect: connectBridge,
|
|
3305
|
+
takeNotice: () => {
|
|
3306
|
+
const n = notice;
|
|
3307
|
+
notice = void 0;
|
|
3308
|
+
return n;
|
|
3309
|
+
},
|
|
3310
|
+
stop: async () => {
|
|
3311
|
+
ending = true;
|
|
3312
|
+
return daemon?.stop(flags.keep).catch(() => null);
|
|
3313
|
+
},
|
|
3314
|
+
sessionName: () => daemon?.state.name ?? pinned
|
|
3315
|
+
};
|
|
3316
|
+
}
|
|
3317
|
+
async function mcpMain(flags, options, version2) {
|
|
3318
|
+
const connector = createConnector(flags, options);
|
|
3319
|
+
const server = startMcpServer({
|
|
3320
|
+
input: process.stdin,
|
|
3321
|
+
output: process.stdout,
|
|
3322
|
+
connect: connector.connect,
|
|
3323
|
+
takeNotice: connector.takeNotice,
|
|
3324
|
+
sessionName: connector.sessionName,
|
|
3325
|
+
log: (line) => console.error(line),
|
|
3326
|
+
pollMs: flags["no-session"] ? 0 : void 0,
|
|
3327
|
+
version: version2
|
|
3328
|
+
});
|
|
3329
|
+
const quit = () => server.close();
|
|
3330
|
+
for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"])
|
|
3331
|
+
process.on(signal, quit);
|
|
3332
|
+
await server.done;
|
|
3333
|
+
const restore = await connector.stop();
|
|
3334
|
+
if (restore && !restore.ok) console.error(`bridge.restore failed: ${restore.error}`);
|
|
3335
|
+
}
|
|
3336
|
+
|
|
1180
3337
|
// src/cli.ts
|
|
1181
3338
|
var HELP2 = `agent-bridge: drive a running React Native app from an agent
|
|
1182
3339
|
|
|
1183
3340
|
Usage
|
|
3341
|
+
agent-bridge init Set up this app: install, write the dev-only bridge
|
|
3342
|
+
file and a smoke flow, print the line to mount it
|
|
1184
3343
|
agent-bridge devices Apps connected to Metro
|
|
1185
3344
|
agent-bridge tools Tools the app exposes
|
|
1186
3345
|
agent-bridge call <tool> [args] Call a tool. args: a JSON array, or one JSON value
|
|
@@ -1191,10 +3350,15 @@ Usage
|
|
|
1191
3350
|
agent-bridge call --batch Read \`tool args\` lines from stdin (args may be @file); one JSON line per call, one connection
|
|
1192
3351
|
agent-bridge repl Interactive prompt (history, tab completion, .help). With
|
|
1193
3352
|
stdin not a terminal it behaves like call --batch
|
|
1194
|
-
agent-bridge run <flow
|
|
1195
|
-
|
|
1196
|
-
|
|
3353
|
+
agent-bridge run <flow|dir|glob>... Run flows in order on one connection: export default
|
|
3354
|
+
async ({ step, call, expect }) => {}. A directory means
|
|
3355
|
+
its *.flow.mjs. Each flow ends with bridge.restore, even
|
|
3356
|
+
on failure (--keep skips it); a scenario export is
|
|
3357
|
+
applied first. Exits 1 if any flow failed
|
|
1197
3358
|
agent-bridge scenarios Scenarios the app defines (scenario.apply applies one)
|
|
3359
|
+
agent-bridge mcp Serve the app's tools as an MCP server on stdio (add to your agent's MCP
|
|
3360
|
+
config). Uses this project's session, or starts one and
|
|
3361
|
+
runs bridge.restore on exit (--keep: don't)
|
|
1198
3362
|
agent-bridge assert-absent <files...> Fail if a release bundle contains the bridge
|
|
1199
3363
|
|
|
1200
3364
|
Sessions: one connection for all of an agent's calls
|
|
@@ -1210,15 +3374,33 @@ Options
|
|
|
1210
3374
|
--device <text> Pick an app when several are connected
|
|
1211
3375
|
--transport <name> auto (default), expo or cdp
|
|
1212
3376
|
--timeout <ms> Per-call timeout (default 10000)
|
|
1213
|
-
--out <file> call: write the result to a file
|
|
3377
|
+
--out <file> call: write the result to a file and print its size and shape
|
|
1214
3378
|
(--batch: a directory, one <n>-<tool>.json per call)
|
|
3379
|
+
run: the report file (with --reporter json|junit)
|
|
1215
3380
|
--stop-on-error call --batch: stop after the first failed call
|
|
1216
3381
|
--full call: print results over 32 KB instead of a summary
|
|
1217
|
-
--
|
|
3382
|
+
--json call screen.snapshot: print JSON with rects, not one line per element (call --batch and repl stay JSON)
|
|
3383
|
+
(--out also writes JSON)
|
|
3384
|
+
--strict run: fail a flow if the app logged an error
|
|
3385
|
+
--bail run: stop at the first failed flow
|
|
3386
|
+
--grep <text> run: only flows whose name or path (relative to here) contains text
|
|
3387
|
+
--keep run: no bridge.restore after each flow (session stop: keep the session)
|
|
3388
|
+
--reporter <name> run: text (default), json or junit; step lines and flow output go to stderr
|
|
1218
3389
|
--scenario <name> run: also apply this scenario (repeatable). Options as
|
|
1219
3390
|
JSON after "=": --scenario 'signedIn={"user":{"name":"Ada"}}'
|
|
1220
3391
|
--session <name> Use this session (env AGENT_BRIDGE_SESSION)
|
|
1221
3392
|
--no-session Connect directly even if a session is running
|
|
3393
|
+
|
|
3394
|
+
init options
|
|
3395
|
+
--dir <path> The app (default: the current directory)
|
|
3396
|
+
--out <file> Bridge file path, inside the app (default src/dev/agent-bridge.tsx, or dev/)
|
|
3397
|
+
--dry-run Print what it would do and the files; change nothing
|
|
3398
|
+
--force Regenerate files init wrote that are still unedited (edited ones get a .new copy)
|
|
3399
|
+
--no-install Print the install command instead of running it
|
|
3400
|
+
--no-skill Don't run \`npx skills add avas-app/agent-bridge\`
|
|
3401
|
+
--network, --no-network Wire networkTools into a new bridge file (asked in a terminal; off otherwise)
|
|
3402
|
+
--json Machine-readable report on stdout; never prompts
|
|
3403
|
+
--yes, -y Never prompt
|
|
1222
3404
|
`;
|
|
1223
3405
|
var failedLogs = (error) => error instanceof AgentBridgeCallError ? error.logs : [];
|
|
1224
3406
|
async function readStdin() {
|
|
@@ -1245,12 +3427,23 @@ async function main() {
|
|
|
1245
3427
|
batch: { type: "boolean" },
|
|
1246
3428
|
"stop-on-error": { type: "boolean" },
|
|
1247
3429
|
scenario: { type: "string", multiple: true },
|
|
3430
|
+
bail: { type: "boolean" },
|
|
3431
|
+
grep: { type: "string" },
|
|
3432
|
+
reporter: { type: "string" },
|
|
1248
3433
|
name: { type: "string" },
|
|
1249
3434
|
idle: { type: "string" },
|
|
1250
3435
|
keep: { type: "boolean" },
|
|
1251
3436
|
"dry-run": { type: "boolean" },
|
|
1252
3437
|
session: { type: "string" },
|
|
1253
3438
|
"no-session": { type: "boolean" },
|
|
3439
|
+
dir: { type: "string" },
|
|
3440
|
+
force: { type: "boolean" },
|
|
3441
|
+
"no-install": { type: "boolean" },
|
|
3442
|
+
"no-skill": { type: "boolean" },
|
|
3443
|
+
network: { type: "boolean" },
|
|
3444
|
+
"no-network": { type: "boolean" },
|
|
3445
|
+
json: { type: "boolean" },
|
|
3446
|
+
yes: { type: "boolean", short: "y" },
|
|
1254
3447
|
help: { type: "boolean", short: "h" }
|
|
1255
3448
|
}
|
|
1256
3449
|
});
|
|
@@ -1290,9 +3483,30 @@ async function main() {
|
|
|
1290
3483
|
if (failed) process.exitCode = 1;
|
|
1291
3484
|
};
|
|
1292
3485
|
switch (command) {
|
|
3486
|
+
case "init":
|
|
3487
|
+
process.exitCode = await runInit(
|
|
3488
|
+
{
|
|
3489
|
+
dir: values.dir,
|
|
3490
|
+
out: values.out,
|
|
3491
|
+
dryRun: values["dry-run"],
|
|
3492
|
+
force: values.force,
|
|
3493
|
+
install: !values["no-install"],
|
|
3494
|
+
skill: !values["no-skill"],
|
|
3495
|
+
network: values.network ? true : values["no-network"] ? false : void 0,
|
|
3496
|
+
json: values.json,
|
|
3497
|
+
yes: values.yes
|
|
3498
|
+
},
|
|
3499
|
+
nodeIo()
|
|
3500
|
+
);
|
|
3501
|
+
return;
|
|
1293
3502
|
case "devices": {
|
|
1294
3503
|
const devices = await listDevices(options);
|
|
1295
|
-
if (!devices.length)
|
|
3504
|
+
if (!devices.length) {
|
|
3505
|
+
const metro = metroHost(options.metro);
|
|
3506
|
+
await assertListening(metro);
|
|
3507
|
+
console.log("No apps connected.");
|
|
3508
|
+
console.error(await noAppHint(metro));
|
|
3509
|
+
}
|
|
1296
3510
|
for (const d of devices) {
|
|
1297
3511
|
const extra = d.tools !== void 0 ? ` ${d.tools} tools ${d.deviceId}` : "";
|
|
1298
3512
|
console.log(`${d.transport.padEnd(5)} ${d.name}${extra}`);
|
|
@@ -1321,12 +3535,12 @@ async function main() {
|
|
|
1321
3535
|
(path) => path === "-" ? stdin : readArgFile(path)
|
|
1322
3536
|
);
|
|
1323
3537
|
return withBridge(async (bridge) => {
|
|
1324
|
-
const { value, ms, appMs, logs, notice } = await bridge.timed(tool, ...args).catch((error) => {
|
|
3538
|
+
const { value, ms, appMs, logs, notice } = await bridge.timed(tool, ...withCompactSnapshot(tool, args, values.json || !!values.out)).catch((error) => {
|
|
1325
3539
|
for (const e of failedLogs(error)) console.error(logLine(e));
|
|
1326
3540
|
throw error;
|
|
1327
3541
|
});
|
|
1328
3542
|
console.log(
|
|
1329
|
-
await renderResult(value, { out: values.out, full: values.full })
|
|
3543
|
+
isSnapshotText(tool, value) ? await renderText(value, { out: values.out, full: values.full }) : await renderResult(value, { out: values.out, full: values.full })
|
|
1330
3544
|
);
|
|
1331
3545
|
for (const e of logs) console.error(logLine(e));
|
|
1332
3546
|
if (notice) console.error(`Warning: ${notice}`);
|
|
@@ -1351,18 +3565,65 @@ async function main() {
|
|
|
1351
3565
|
});
|
|
1352
3566
|
});
|
|
1353
3567
|
case "run": {
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
const
|
|
1357
|
-
|
|
3568
|
+
if (!rest.length)
|
|
3569
|
+
throw new Error("Usage: agent-bridge run <flow file | directory | glob>...");
|
|
3570
|
+
const machine = checkRunFlags({ reporter: values.reporter, out: values.out });
|
|
3571
|
+
const reporter = values.reporter ?? "text";
|
|
3572
|
+
const files = await resolveFlowFiles(rest);
|
|
3573
|
+
const { entries: loaded, failures } = await loadFlows(files);
|
|
3574
|
+
const entries = selectFlows(loaded, values.grep);
|
|
3575
|
+
if (!entries.length && !failures.length) {
|
|
3576
|
+
if (!machine) throw new Error(`--grep ${values.grep}: no flow matches`);
|
|
3577
|
+
failures.push(failedRun("", `--grep ${values.grep}`, "no flow matches"));
|
|
3578
|
+
}
|
|
3579
|
+
if (machine && values.out) await mkdir3(dirname7(values.out), { recursive: true });
|
|
1358
3580
|
const extra = (values.scenario ?? []).map(parseScenarioFlag);
|
|
1359
|
-
|
|
1360
|
-
const
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
3581
|
+
const finish = async (runs2) => {
|
|
3582
|
+
const failed = runs2.filter((r) => r.status === "failed");
|
|
3583
|
+
if (machine) {
|
|
3584
|
+
const doc = reporter === "json" ? renderJson(runs2) : renderJunit(runs2);
|
|
3585
|
+
if (values.out) await writeReport(values.out, doc);
|
|
3586
|
+
else console.log(doc);
|
|
3587
|
+
}
|
|
3588
|
+
const log = machine ? console.error : console.log;
|
|
3589
|
+
if (runs2.length > 1 || machine) {
|
|
3590
|
+
log("");
|
|
3591
|
+
for (const r of runs2)
|
|
3592
|
+
log(
|
|
3593
|
+
`${r.status === "passed" ? "PASS" : r.status === "failed" ? "FAIL" : "SKIP"} ${r.name}${r.status === "skipped" ? "" : ` (${(r.ms / 1e3).toFixed(1)} s)`}${r.error ? `: ${r.error}` : ""}`
|
|
3594
|
+
);
|
|
3595
|
+
const count = (st) => runs2.filter((r) => r.status === st).length;
|
|
3596
|
+
log(
|
|
3597
|
+
`${count("passed")} passed, ${failed.length} failed${count("skipped") ? `, ${count("skipped")} skipped` : ""}`
|
|
3598
|
+
);
|
|
3599
|
+
}
|
|
3600
|
+
if (failed.length) process.exitCode = 1;
|
|
3601
|
+
};
|
|
3602
|
+
const execute = (bridge) => runFlows(bridge, entries, {
|
|
3603
|
+
scenarios: extra,
|
|
3604
|
+
strict: values.strict,
|
|
3605
|
+
bail: values.bail,
|
|
3606
|
+
keep: values.keep,
|
|
3607
|
+
single: entries.length === 1 && !machine,
|
|
3608
|
+
print: machine ? (line) => console.error(line) : void 0
|
|
1365
3609
|
});
|
|
3610
|
+
let runs;
|
|
3611
|
+
if (!entries.length) runs = [];
|
|
3612
|
+
else {
|
|
3613
|
+
try {
|
|
3614
|
+
const run = (bridge) => machine ? logToStderr(() => execute(bridge)) : execute(bridge);
|
|
3615
|
+
let result = [];
|
|
3616
|
+
await withBridge(async (bridge) => {
|
|
3617
|
+
result = await run(bridge);
|
|
3618
|
+
});
|
|
3619
|
+
runs = result;
|
|
3620
|
+
} catch (error) {
|
|
3621
|
+
if (!machine) throw error;
|
|
3622
|
+
const why = error instanceof Error ? error.message : String(error);
|
|
3623
|
+
runs = entries.map((e) => failedRun(e.file, e.name, why));
|
|
3624
|
+
}
|
|
3625
|
+
}
|
|
3626
|
+
return finish([...failures, ...runs]);
|
|
1366
3627
|
}
|
|
1367
3628
|
case "scenarios":
|
|
1368
3629
|
return withBridge(async (bridge) => {
|
|
@@ -1382,6 +3643,12 @@ async function main() {
|
|
|
1382
3643
|
console.log(` ${"".padEnd(20)} options: ${JSON.stringify(s.options)}`);
|
|
1383
3644
|
}
|
|
1384
3645
|
});
|
|
3646
|
+
case "mcp":
|
|
3647
|
+
return mcpMain(
|
|
3648
|
+
values,
|
|
3649
|
+
options,
|
|
3650
|
+
createRequire(import.meta.url)("../package.json").version
|
|
3651
|
+
);
|
|
1385
3652
|
case "session":
|
|
1386
3653
|
return sessionCommand(rest[0], values);
|
|
1387
3654
|
case DAEMON_COMMAND:
|