vibeview 0.27.0 → 0.28.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/dist/{chunk-U7FCGMWG.js → chunk-2JLTC67A.js} +159 -11
- package/dist/{chunk-46X7UN6Y.js → chunk-OKOAWGWV.js} +226 -161
- package/dist/cli.js +409 -95
- package/dist/{server-XJREB7KU.js → server-6SRFOQ6B.js} +298 -53
- package/dist/{upload-app-QUUXKNR2.js → upload-app-WROTRZCA.js} +1 -1
- package/package.json +2 -2
- package/skills/vibeview-agent/SKILL.md +10 -5
|
@@ -21,11 +21,12 @@ import {
|
|
|
21
21
|
readDevState,
|
|
22
22
|
renderEnvelope,
|
|
23
23
|
startDevSession
|
|
24
|
-
} from "./chunk-
|
|
24
|
+
} from "./chunk-OKOAWGWV.js";
|
|
25
25
|
import {
|
|
26
26
|
ApiClient,
|
|
27
|
+
NotFoundError,
|
|
27
28
|
resolveAuth
|
|
28
|
-
} from "./chunk-
|
|
29
|
+
} from "./chunk-2JLTC67A.js";
|
|
29
30
|
|
|
30
31
|
// src/mcp/server.ts
|
|
31
32
|
import { writeFileSync } from "fs";
|
|
@@ -40,8 +41,14 @@ var MCP_DEV_PLATFORMS = DEV_PLATFORMS;
|
|
|
40
41
|
function isMcpDevPlatform(p) {
|
|
41
42
|
return isDevPlatform(p) && MCP_DEV_PLATFORMS.includes(p);
|
|
42
43
|
}
|
|
43
|
-
|
|
44
|
-
|
|
44
|
+
var MCP_PARAM_DESCRIPTIONS = {
|
|
45
|
+
wait: { seconds: "How long to wait, in seconds. Default: 2." }
|
|
46
|
+
};
|
|
47
|
+
function paramToProperty(cmd, param) {
|
|
48
|
+
return {
|
|
49
|
+
type: param.type,
|
|
50
|
+
description: MCP_PARAM_DESCRIPTIONS[cmd.verb]?.[param.name] ?? param.description
|
|
51
|
+
};
|
|
45
52
|
}
|
|
46
53
|
function toolNameFor(verb) {
|
|
47
54
|
return verb.replace(/-/g, "_");
|
|
@@ -50,7 +57,7 @@ function toolDefFromCommand(cmd) {
|
|
|
50
57
|
const properties = { session_id: SESSION_ID_PROPERTY };
|
|
51
58
|
const required = [];
|
|
52
59
|
for (const param of cmd.params) {
|
|
53
|
-
properties[param.name] = paramToProperty(param);
|
|
60
|
+
properties[param.name] = paramToProperty(cmd, param);
|
|
54
61
|
if (param.required) required.push(param.name);
|
|
55
62
|
}
|
|
56
63
|
if (cmd.verb === "screenshot") {
|
|
@@ -69,7 +76,7 @@ function toolDefFromCommand(cmd) {
|
|
|
69
76
|
}
|
|
70
77
|
var DEV_START_DEF = {
|
|
71
78
|
name: "dev_start",
|
|
72
|
-
description: "Start a VibeView
|
|
79
|
+
description: "Start a VibeView session held by this MCP server, and use it as the default session for every other tool call until dev_stop. By default it is a React Native dev loop: a debug build that loads its JS from your local Metro. Pass standalone: true to run any build as it is (a release, CI or cloud build) with no Metro. Returns once the session accepts commands (device up, app installed), with the device it got (model, OS, form factor, posture) \u2014 call ui_tree next.",
|
|
73
80
|
mutating: true,
|
|
74
81
|
properties: {
|
|
75
82
|
session_id: SESSION_ID_PROPERTY,
|
|
@@ -80,11 +87,11 @@ var DEV_START_DEF = {
|
|
|
80
87
|
},
|
|
81
88
|
app: {
|
|
82
89
|
type: "string",
|
|
83
|
-
description: "
|
|
90
|
+
description: "App id to run (from list_apps or upload_app). Must be an app for the chosen platform. Defaults to vibeview.json config."
|
|
84
91
|
},
|
|
85
92
|
metro_port: {
|
|
86
93
|
type: "number",
|
|
87
|
-
description: "Local Metro port (default: 8081, or vibeview.json)."
|
|
94
|
+
description: "Local Metro port (default: 8081, or vibeview.json). Ignored with standalone."
|
|
88
95
|
},
|
|
89
96
|
model: {
|
|
90
97
|
type: "string",
|
|
@@ -92,7 +99,11 @@ var DEV_START_DEF = {
|
|
|
92
99
|
},
|
|
93
100
|
build_id: {
|
|
94
101
|
type: "number",
|
|
95
|
-
description: 'Run this uploaded build of the app
|
|
102
|
+
description: 'Run this uploaded build of the app (an id from list_builds). Without standalone it must be a debug build (kind "debug"); with standalone any build of the app works. Omitted: the newest debug build, or with standalone the newest release build. Not for roku.'
|
|
103
|
+
},
|
|
104
|
+
standalone: {
|
|
105
|
+
type: "boolean",
|
|
106
|
+
description: "Run the build as it is, with no Metro and no tunnel: for release builds, builds with an embedded JS bundle, CI and cloud builds. Code changes do not hot-reload. Default false."
|
|
96
107
|
}
|
|
97
108
|
},
|
|
98
109
|
required: ["platform"]
|
|
@@ -102,7 +113,7 @@ var LIST_BUILDS_DEF = {
|
|
|
102
113
|
description: "List an app's uploaded builds, newest first: id, version, build number, platform, kind (debug/release/unknown), upload time and note. Pass a debug build's id to dev_start as build_id.",
|
|
103
114
|
mutating: false,
|
|
104
115
|
properties: {
|
|
105
|
-
app: { type: "string", description: "App id (
|
|
116
|
+
app: { type: "string", description: "App id (from list_apps or upload_app)." }
|
|
106
117
|
},
|
|
107
118
|
required: ["app"]
|
|
108
119
|
};
|
|
@@ -117,7 +128,7 @@ var STOP_SESSION_DEF = {
|
|
|
117
128
|
};
|
|
118
129
|
var LIST_DEVICE_MODELS_DEF = {
|
|
119
130
|
name: "list_device_models",
|
|
120
|
-
description: 'List the device models you can start right now (model, OS version, platform, category,
|
|
131
|
+
description: 'List the device models you can start right now (model, OS version, platform, category, form_factor "foldable" for any device with a hinge \u2014 the iPhone Duo, Android foldables such as the Pixel 9 Pro Fold \u2014 and how many of each are free and busy). Pass a model name to dev_start to run on it; a model with none free queues until one is.',
|
|
121
132
|
mutating: false,
|
|
122
133
|
properties: {
|
|
123
134
|
device_type: {
|
|
@@ -128,6 +139,19 @@ var LIST_DEVICE_MODELS_DEF = {
|
|
|
128
139
|
},
|
|
129
140
|
required: []
|
|
130
141
|
};
|
|
142
|
+
var LIST_APPS_DEF = {
|
|
143
|
+
name: "list_apps",
|
|
144
|
+
description: "List your organization's uploaded apps: app id, name, platform and bundle id / package name. Pass an app id to dev_start (app) or list_builds.",
|
|
145
|
+
mutating: false,
|
|
146
|
+
properties: {
|
|
147
|
+
platform: {
|
|
148
|
+
type: "string",
|
|
149
|
+
description: "Only apps for this platform.",
|
|
150
|
+
enum: [...DEV_PLATFORMS]
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
required: []
|
|
154
|
+
};
|
|
131
155
|
var DEV_STOP_DEF = {
|
|
132
156
|
name: "dev_stop",
|
|
133
157
|
description: "Stop the dev-loop session started by dev_start.",
|
|
@@ -149,7 +173,7 @@ var UPLOAD_APP_DEF = {
|
|
|
149
173
|
properties: {
|
|
150
174
|
file: {
|
|
151
175
|
type: "string",
|
|
152
|
-
description: "Path to the build file on this machine
|
|
176
|
+
description: "Path to the build file on this machine: a zipped iOS/tvOS simulator .app, an Android/Android TV .apk, or a Roku channel .zip."
|
|
153
177
|
},
|
|
154
178
|
name: {
|
|
155
179
|
type: "string",
|
|
@@ -166,6 +190,7 @@ function buildToolDefinitions() {
|
|
|
166
190
|
DEV_RELOAD_DEF,
|
|
167
191
|
UPLOAD_APP_DEF,
|
|
168
192
|
LIST_DEVICE_MODELS_DEF,
|
|
193
|
+
LIST_APPS_DEF,
|
|
169
194
|
LIST_BUILDS_DEF,
|
|
170
195
|
STOP_SESSION_DEF
|
|
171
196
|
];
|
|
@@ -174,7 +199,44 @@ function textResult(text, structuredContent, isError = false) {
|
|
|
174
199
|
return { content: [{ type: "text", text }], structuredContent, isError };
|
|
175
200
|
}
|
|
176
201
|
function errorResult(message) {
|
|
177
|
-
return { content: [{ type: "text", text: message }], isError: true };
|
|
202
|
+
return { content: [{ type: "text", text: forMcp(message) }], isError: true };
|
|
203
|
+
}
|
|
204
|
+
function forMcp(message) {
|
|
205
|
+
let text = message.replace(/`vibeview list-devices --models`/g, "list_device_models").replace(/`vibeview list-builds <app>`/g, "list_builds").replace(/`vibeview list-builds ([^`\s]+)`/g, 'list_builds (app "$1")').replace(/`vibeview list-apps`/g, "list_apps").replace(/\blist-apps\b/g, "list_apps").replace(/`vibeview upload-app`/g, "upload_app").replace(/`vibeview dev` needs/g, "dev_start needs");
|
|
206
|
+
if (/No debug build|is not a debug build/.test(text) && !/standalone: true/.test(text)) {
|
|
207
|
+
text += " To run a release build as it is, with no Metro, call dev_start with standalone: true.";
|
|
208
|
+
}
|
|
209
|
+
return text;
|
|
210
|
+
}
|
|
211
|
+
async function readSession(deps, sessionId) {
|
|
212
|
+
const get = deps.getSession ?? ((id) => new ApiClient(deps.auth.baseUrl, deps.auth.token).getSession(id));
|
|
213
|
+
return get(sessionId);
|
|
214
|
+
}
|
|
215
|
+
function isEndedStatus(status) {
|
|
216
|
+
return status === "stopped" || status === "failed";
|
|
217
|
+
}
|
|
218
|
+
function endedLine(ended) {
|
|
219
|
+
const why = ended.reason ? `: ${ended.reason}` : "";
|
|
220
|
+
return `session ${ended.sessionId} has ended (${ended.status}${why})`;
|
|
221
|
+
}
|
|
222
|
+
async function explainInactive(sessionId, deps) {
|
|
223
|
+
let info;
|
|
224
|
+
try {
|
|
225
|
+
info = await readSession(deps, sessionId);
|
|
226
|
+
} catch {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
if (!isEndedStatus(info.status)) return null;
|
|
230
|
+
const ended = {
|
|
231
|
+
sessionId,
|
|
232
|
+
status: info.status,
|
|
233
|
+
reason: info.failure_reason ?? null
|
|
234
|
+
};
|
|
235
|
+
const held = deps.devSession.current()?.sessionId === sessionId;
|
|
236
|
+
if (held) deps.devSession.markEnded(ended);
|
|
237
|
+
return errorResult(
|
|
238
|
+
`${endedLine(ended)} \u2014 ${held ? "call dev_start to start a new one" : "start a new session"}`
|
|
239
|
+
);
|
|
178
240
|
}
|
|
179
241
|
function findCommand(toolName) {
|
|
180
242
|
return AGENT_COMMANDS.find((c) => toolNameFor(c.verb) === toolName);
|
|
@@ -198,6 +260,9 @@ async function handleToolCall(toolName, args, deps) {
|
|
|
198
260
|
if (toolName === "list_builds") {
|
|
199
261
|
return handleListBuilds(args, deps);
|
|
200
262
|
}
|
|
263
|
+
if (toolName === "list_apps") {
|
|
264
|
+
return handleListApps(args, deps);
|
|
265
|
+
}
|
|
201
266
|
if (toolName === "stop_session") {
|
|
202
267
|
return handleStopSession(args, deps);
|
|
203
268
|
}
|
|
@@ -207,6 +272,8 @@ async function handleToolCall(toolName, args, deps) {
|
|
|
207
272
|
}
|
|
208
273
|
const sessionId = typeof args.session_id === "string" ? args.session_id : deps.resolveSessionId();
|
|
209
274
|
if (!sessionId) {
|
|
275
|
+
const ended = deps.devSession.lastEnded();
|
|
276
|
+
if (ended) return errorResult(`${endedLine(ended)} \u2014 call dev_start to start a new one`);
|
|
210
277
|
return errorResult(
|
|
211
278
|
"No session to target. Call dev_start first, or pass session_id (e.g. from a running `vibeview dev --detach`)."
|
|
212
279
|
);
|
|
@@ -224,7 +291,12 @@ async function handleToolCall(toolName, args, deps) {
|
|
|
224
291
|
try {
|
|
225
292
|
envelope = await deps.agentTool(sessionId, call.tool, call.args);
|
|
226
293
|
} catch (err) {
|
|
227
|
-
|
|
294
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
295
|
+
if (/Session is not active/i.test(message)) {
|
|
296
|
+
const explained = await explainInactive(sessionId, deps);
|
|
297
|
+
if (explained) return explained;
|
|
298
|
+
}
|
|
299
|
+
return errorResult(`error: ${message}`);
|
|
228
300
|
}
|
|
229
301
|
if (cmd.verb === "screenshot" && envelope.ok) {
|
|
230
302
|
return screenshotToolResult(envelope, args);
|
|
@@ -290,32 +362,94 @@ async function handleDevStart(args, deps) {
|
|
|
290
362
|
);
|
|
291
363
|
}
|
|
292
364
|
}
|
|
365
|
+
const standalone = args.standalone === true;
|
|
293
366
|
try {
|
|
294
367
|
const result = await deps.devSession.start({
|
|
295
368
|
platform,
|
|
296
369
|
app: typeof args.app === "string" ? args.app : void 0,
|
|
297
370
|
metroPort: typeof args.metro_port === "number" ? args.metro_port : void 0,
|
|
298
371
|
model: typeof args.model === "string" && args.model.trim() ? args.model : void 0,
|
|
299
|
-
...buildId !== void 0 ? { buildId } : {}
|
|
372
|
+
...buildId !== void 0 ? { buildId } : {},
|
|
373
|
+
...standalone ? { standalone } : {}
|
|
300
374
|
});
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
375
|
+
const warnings = result.warnings ?? [];
|
|
376
|
+
const payload = {
|
|
377
|
+
session_id: result.sessionId,
|
|
378
|
+
page_url: result.pageUrl,
|
|
379
|
+
standalone,
|
|
380
|
+
device: result.device ?? null,
|
|
381
|
+
warnings
|
|
382
|
+
};
|
|
383
|
+
const lines = [`${standalone ? "session" : "dev session"} started: ${payload.session_id}`];
|
|
384
|
+
const device = result.device ? deviceLine(result.device) : null;
|
|
385
|
+
if (device) lines.push(device);
|
|
386
|
+
lines.push(payload.page_url, ...warnings.map((w) => `warning: ${w}`));
|
|
387
|
+
return textResult(lines.join("\n"), payload);
|
|
304
388
|
} catch (err) {
|
|
305
389
|
return errorResult(`error: ${err instanceof Error ? err.message : String(err)}`);
|
|
306
390
|
}
|
|
307
391
|
}
|
|
392
|
+
function deviceLine(d) {
|
|
393
|
+
const name = d.model || d.name;
|
|
394
|
+
if (!name) return null;
|
|
395
|
+
const parts = [name];
|
|
396
|
+
if (d.os_version) parts.push(`OS ${d.os_version}`);
|
|
397
|
+
if (d.category) parts.push(d.category);
|
|
398
|
+
if (d.form_factor) {
|
|
399
|
+
const fold = [d.posture ? `posture ${d.posture}` : null, d.screen ? `${d.screen} screen` : null].filter(Boolean).join(", ");
|
|
400
|
+
parts.push(fold ? `${d.form_factor} (${fold})` : d.form_factor);
|
|
401
|
+
}
|
|
402
|
+
return `device: ${parts.join(", ")}`;
|
|
403
|
+
}
|
|
404
|
+
function startedDevice(info) {
|
|
405
|
+
if (!info) return null;
|
|
406
|
+
const posture = info.posture ?? null;
|
|
407
|
+
return {
|
|
408
|
+
name: info.device_name ?? null,
|
|
409
|
+
model: info.device_model ?? null,
|
|
410
|
+
os_version: info.os_version ?? null,
|
|
411
|
+
category: info.device_category ?? null,
|
|
412
|
+
form_factor: info.form_factor ?? null,
|
|
413
|
+
posture,
|
|
414
|
+
screen: info.form_factor === "foldable" && posture ? posture === "closed" ? "cover" : "inner" : null
|
|
415
|
+
};
|
|
416
|
+
}
|
|
308
417
|
async function handleListDeviceModels(args, deps) {
|
|
309
418
|
const list = deps.listDeviceModels ?? (() => new ApiClient(deps.auth.baseUrl, deps.auth.token).listDeviceModels());
|
|
310
419
|
let models;
|
|
311
420
|
try {
|
|
312
421
|
models = filterDeviceModels(await list(), {
|
|
313
|
-
type: typeof args.device_type === "string" ? args.device_type : void 0
|
|
422
|
+
type: typeof args.device_type === "string" ? args.device_type : void 0,
|
|
423
|
+
availability: true
|
|
314
424
|
});
|
|
315
425
|
} catch (err) {
|
|
316
426
|
return errorResult(`error: ${err instanceof Error ? err.message : String(err)}`);
|
|
317
427
|
}
|
|
318
|
-
return textResult(formatDeviceModelsHuman(models).trimEnd(), {
|
|
428
|
+
return textResult(formatDeviceModelsHuman(models, { availability: true }).trimEnd(), {
|
|
429
|
+
models: models.map(({ free, total, ...m }) => ({
|
|
430
|
+
...m,
|
|
431
|
+
available: free ?? null,
|
|
432
|
+
busy: typeof free === "number" && typeof total === "number" ? total - free : null
|
|
433
|
+
}))
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
async function handleListApps(args, deps) {
|
|
437
|
+
const list = deps.listApps ?? (() => new ApiClient(deps.auth.baseUrl, deps.auth.token).listApps());
|
|
438
|
+
const platform = typeof args.platform === "string" && args.platform ? args.platform : void 0;
|
|
439
|
+
let apps;
|
|
440
|
+
try {
|
|
441
|
+
apps = (await list()).filter((a) => !platform || a.platform === platform);
|
|
442
|
+
} catch (err) {
|
|
443
|
+
return errorResult(`error: ${err instanceof Error ? err.message : String(err)}`);
|
|
444
|
+
}
|
|
445
|
+
const rows = apps.map((a) => ({
|
|
446
|
+
app_id: a.public_id,
|
|
447
|
+
name: a.name,
|
|
448
|
+
platform: a.platform,
|
|
449
|
+
package_name: a.package_name
|
|
450
|
+
}));
|
|
451
|
+
const text = rows.length ? rows.map((a) => `${a.app_id} ${a.name} [${a.platform}] ${a.package_name}`).join("\n") : platform ? `No ${platform} apps yet \u2014 upload a build with upload_app.` : "No apps yet \u2014 upload a build with upload_app.";
|
|
452
|
+
return textResult(text, { apps: rows });
|
|
319
453
|
}
|
|
320
454
|
async function handleListBuilds(args, deps) {
|
|
321
455
|
const appId = typeof args.app === "string" ? args.app.trim() : "";
|
|
@@ -336,8 +470,28 @@ async function handleStopSession(args, deps) {
|
|
|
336
470
|
const sessionId = typeof args.session_id === "string" ? args.session_id.trim() : "";
|
|
337
471
|
if (!sessionId) return errorResult("stop_session: `session_id` is required");
|
|
338
472
|
const stop = deps.stopSession ?? ((id) => new ApiClient(deps.auth.baseUrl, deps.auth.token).stopSession(id));
|
|
473
|
+
const held = deps.devSession.current()?.sessionId === sessionId;
|
|
474
|
+
let before = null;
|
|
475
|
+
try {
|
|
476
|
+
before = await readSession(deps, sessionId);
|
|
477
|
+
} catch (err) {
|
|
478
|
+
return errorResult(`error: ${err instanceof Error ? err.message : String(err)}`);
|
|
479
|
+
}
|
|
480
|
+
if (before && isEndedStatus(before.status)) {
|
|
481
|
+
if (held) {
|
|
482
|
+
deps.devSession.markEnded({
|
|
483
|
+
sessionId,
|
|
484
|
+
status: before.status,
|
|
485
|
+
reason: before.failure_reason ?? null
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
return textResult(
|
|
489
|
+
`session ${sessionId} was already ${before.status === "failed" ? "over (it failed)" : "stopped"}`,
|
|
490
|
+
{ session_id: sessionId, stopped: false, already_ended: true, status: before.status }
|
|
491
|
+
);
|
|
492
|
+
}
|
|
339
493
|
try {
|
|
340
|
-
if (
|
|
494
|
+
if (held) {
|
|
341
495
|
await deps.devSession.stop();
|
|
342
496
|
} else {
|
|
343
497
|
await stop(sessionId);
|
|
@@ -361,13 +515,21 @@ async function handleDevReload(args, deps) {
|
|
|
361
515
|
}
|
|
362
516
|
async function handleDevStop(deps) {
|
|
363
517
|
const current = deps.devSession.current();
|
|
518
|
+
const ended = deps.devSession.lastEnded();
|
|
364
519
|
try {
|
|
365
520
|
await deps.devSession.stop();
|
|
366
521
|
} catch (err) {
|
|
367
522
|
return errorResult(`error: ${err instanceof Error ? err.message : String(err)}`);
|
|
368
523
|
}
|
|
369
|
-
|
|
370
|
-
|
|
524
|
+
if (!current) {
|
|
525
|
+
return textResult(
|
|
526
|
+
ended ? `no dev session was running \u2014 ${endedLine(ended)} already` : "no dev session was running",
|
|
527
|
+
{ session_id: null, stopped: false }
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
return textResult(`dev session stopped: ${current.sessionId}`, {
|
|
531
|
+
session_id: current.sessionId,
|
|
532
|
+
stopped: true
|
|
371
533
|
});
|
|
372
534
|
}
|
|
373
535
|
async function handleUploadApp(args, deps) {
|
|
@@ -377,7 +539,7 @@ async function handleUploadApp(args, deps) {
|
|
|
377
539
|
}
|
|
378
540
|
const name = typeof args.name === "string" && args.name ? args.name : void 0;
|
|
379
541
|
const upload = deps.uploadApp ?? (async (f, n) => {
|
|
380
|
-
const { uploadAppBuild } = await import("./upload-app-
|
|
542
|
+
const { uploadAppBuild } = await import("./upload-app-WROTRZCA.js");
|
|
381
543
|
const { result: result2 } = await uploadAppBuild(f, { ...deps.auth, name: n });
|
|
382
544
|
return result2;
|
|
383
545
|
});
|
|
@@ -413,6 +575,7 @@ async function handleUploadApp(args, deps) {
|
|
|
413
575
|
function createDevSessionController(auth, webUrl) {
|
|
414
576
|
let handle = null;
|
|
415
577
|
let platform = null;
|
|
578
|
+
let ended = null;
|
|
416
579
|
let presence = null;
|
|
417
580
|
let reloading = null;
|
|
418
581
|
const stopPresence = () => {
|
|
@@ -428,12 +591,32 @@ function createDevSessionController(auth, webUrl) {
|
|
|
428
591
|
);
|
|
429
592
|
}
|
|
430
593
|
};
|
|
594
|
+
const markEnded = (e) => {
|
|
595
|
+
if (!handle || handle.sessionId !== e.sessionId) return;
|
|
596
|
+
const dead = handle;
|
|
597
|
+
stopPresence();
|
|
598
|
+
handle = null;
|
|
599
|
+
platform = null;
|
|
600
|
+
ended = e;
|
|
601
|
+
void dead.stop().catch(() => {
|
|
602
|
+
});
|
|
603
|
+
};
|
|
604
|
+
const api = () => new ApiClient(auth.baseUrl, auth.token);
|
|
431
605
|
return {
|
|
432
606
|
async start(cfg) {
|
|
433
607
|
if (handle) {
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
608
|
+
const info = await api().getSession(handle.sessionId).catch(() => null);
|
|
609
|
+
if (info && isEndedStatus(info.status)) {
|
|
610
|
+
markEnded({
|
|
611
|
+
sessionId: handle.sessionId,
|
|
612
|
+
status: info.status,
|
|
613
|
+
reason: info.failure_reason ?? null
|
|
614
|
+
});
|
|
615
|
+
} else {
|
|
616
|
+
throw new Error(
|
|
617
|
+
`a dev session is already running (${handle.sessionId}) \u2014 call dev_stop first`
|
|
618
|
+
);
|
|
619
|
+
}
|
|
437
620
|
}
|
|
438
621
|
let devConfig = null;
|
|
439
622
|
try {
|
|
@@ -448,8 +631,26 @@ function createDevSessionController(auth, webUrl) {
|
|
|
448
631
|
`dev_start: no app to run for platform "${cfg.platform}" \u2014 pass the app arg (an app id from list-apps) or run \`vibeview dev\` once in this project to write vibeview.json`
|
|
449
632
|
);
|
|
450
633
|
}
|
|
634
|
+
let app;
|
|
635
|
+
try {
|
|
636
|
+
app = await api().getApp(appId);
|
|
637
|
+
} catch (err) {
|
|
638
|
+
if (err instanceof NotFoundError) {
|
|
639
|
+
throw new Error(
|
|
640
|
+
`app "${appId}" not found \u2014 it may have been deleted; see list_apps for your app ids`
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
throw err;
|
|
644
|
+
}
|
|
645
|
+
if (app.platform !== cfg.platform) {
|
|
646
|
+
throw new Error(
|
|
647
|
+
`app "${appId}" is ${platformWithArticle(app.platform)} app, not ${platformWithArticle(cfg.platform)} app \u2014 call dev_start with platform "${app.platform}", or pick ${platformWithArticle(cfg.platform)} app from list_apps`
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
const standalone = cfg.standalone === true;
|
|
651
|
+
const usesMetro = PLATFORM_NEEDS_METRO[cfg.platform] && !standalone;
|
|
451
652
|
const metroPort = cfg.metroPort ?? devConfig?.metroPort ?? 8081;
|
|
452
|
-
if (
|
|
653
|
+
if (usesMetro) {
|
|
453
654
|
const metro = await checkMetro(metroPort);
|
|
454
655
|
if (metro === "down") {
|
|
455
656
|
throw new Error(
|
|
@@ -458,47 +659,77 @@ function createDevSessionController(auth, webUrl) {
|
|
|
458
659
|
}
|
|
459
660
|
}
|
|
460
661
|
if (cfg.buildId !== void 0) {
|
|
461
|
-
checkDevBuild(
|
|
462
|
-
await new ApiClient(auth.baseUrl, auth.token).getApp(appId),
|
|
463
|
-
cfg.buildId,
|
|
464
|
-
cfg.platform
|
|
465
|
-
);
|
|
662
|
+
checkDevBuild(app, cfg.buildId, cfg.platform, { standalone });
|
|
466
663
|
}
|
|
467
664
|
const isTv = PLATFORM_DEVICE[cfg.platform].tv;
|
|
665
|
+
const warnings = [];
|
|
666
|
+
let started = null;
|
|
667
|
+
started = await startDevSession(
|
|
668
|
+
{
|
|
669
|
+
token: auth.token,
|
|
670
|
+
baseUrl: auth.baseUrl,
|
|
671
|
+
webUrl,
|
|
672
|
+
platform: cfg.platform,
|
|
673
|
+
appId,
|
|
674
|
+
deviceCategory: isTv ? "tv" : entry?.deviceCategory ?? "phone",
|
|
675
|
+
// A named model decides the category itself (startDevSession).
|
|
676
|
+
model: cfg.model,
|
|
677
|
+
buildId: cfg.buildId,
|
|
678
|
+
...standalone ? { standalone } : {},
|
|
679
|
+
metroPort,
|
|
680
|
+
open: false
|
|
681
|
+
},
|
|
682
|
+
(evt) => {
|
|
683
|
+
if (evt.event === "warning") warnings.push(evt.message);
|
|
684
|
+
if (evt.event === "session_ended" && evt.code !== 0 && started) {
|
|
685
|
+
const id = evt.session_id;
|
|
686
|
+
void api().getSession(id).then(
|
|
687
|
+
(info) => ({ status: info.status, reason: info.failure_reason ?? null }),
|
|
688
|
+
() => ({ status: "stopped", reason: null })
|
|
689
|
+
).then(({ status, reason }) => markEnded({ sessionId: id, status, reason }));
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
);
|
|
693
|
+
handle = started;
|
|
468
694
|
platform = cfg.platform;
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
webUrl,
|
|
473
|
-
platform: cfg.platform,
|
|
474
|
-
appId,
|
|
475
|
-
deviceCategory: isTv ? "tv" : entry?.deviceCategory ?? "phone",
|
|
476
|
-
// A named model decides the category itself (startDevSession).
|
|
477
|
-
model: cfg.model,
|
|
478
|
-
buildId: cfg.buildId,
|
|
479
|
-
metroPort,
|
|
480
|
-
open: false
|
|
481
|
-
});
|
|
482
|
-
if (cfg.platform === "roku") {
|
|
483
|
-
const api = new ApiClient(auth.baseUrl, auth.token);
|
|
695
|
+
ended = null;
|
|
696
|
+
if (!usesMetro) {
|
|
697
|
+
const client = api();
|
|
484
698
|
const sessionId = handle.sessionId;
|
|
485
699
|
presence = setInterval(() => {
|
|
486
|
-
|
|
487
|
-
|
|
700
|
+
client.touchSession(sessionId).then(
|
|
701
|
+
(info) => {
|
|
702
|
+
if (info && isEndedStatus(info.status)) {
|
|
703
|
+
markEnded({ sessionId, status: info.status, reason: info.failure_reason ?? null });
|
|
704
|
+
}
|
|
705
|
+
},
|
|
706
|
+
() => {
|
|
707
|
+
}
|
|
708
|
+
);
|
|
488
709
|
}, ROKU_LIVENESS_INTERVAL_MS);
|
|
489
710
|
presence.unref?.();
|
|
490
711
|
}
|
|
491
|
-
return
|
|
712
|
+
return {
|
|
713
|
+
sessionId: handle.sessionId,
|
|
714
|
+
pageUrl: handle.pageUrl,
|
|
715
|
+
device: startedDevice(handle.session),
|
|
716
|
+
warnings
|
|
717
|
+
};
|
|
492
718
|
},
|
|
493
719
|
async stop() {
|
|
494
720
|
stopPresence();
|
|
495
721
|
await handle?.stop();
|
|
496
722
|
handle = null;
|
|
497
723
|
platform = null;
|
|
724
|
+
ended = null;
|
|
498
725
|
},
|
|
499
726
|
current() {
|
|
500
727
|
return handle ? { sessionId: handle.sessionId, pageUrl: handle.pageUrl } : null;
|
|
501
728
|
},
|
|
729
|
+
markEnded,
|
|
730
|
+
lastEnded() {
|
|
731
|
+
return ended;
|
|
732
|
+
},
|
|
502
733
|
async reload(requested) {
|
|
503
734
|
if (reloading) {
|
|
504
735
|
throw new Error(
|
|
@@ -564,6 +795,13 @@ function createDevSessionController(auth, webUrl) {
|
|
|
564
795
|
function makeSessionResolver(devSession) {
|
|
565
796
|
return () => devSession.current()?.sessionId ?? readDevState(process.cwd())?.session_id;
|
|
566
797
|
}
|
|
798
|
+
var MCP_INSTRUCTIONS = [
|
|
799
|
+
"VibeView runs your app on a cloud iOS simulator, Android emulator, Apple TV, Android TV or Roku (beta) device you control with these tools.",
|
|
800
|
+
"Loop: dev_start (platform, app from list_apps; add standalone: true for a release or CI build that needs no Metro) -> ui_tree -> act with refs (tap @e5, type, swipe, press) and read the change each action reports -> dev_stop when done.",
|
|
801
|
+
"Foldables: pick one by model in dev_start (list_device_models marks them foldable, with free/busy counts), then set_posture and rotate.",
|
|
802
|
+
"screenshot writes a PNG and returns its path; pass inline: true to see the image. logs reads the app output after a crash or blank screen.",
|
|
803
|
+
"A session stopped elsewhere is reported as ended; call dev_start again for a new one."
|
|
804
|
+
].join("\n");
|
|
567
805
|
async function createMcpServer(opts) {
|
|
568
806
|
const [{ McpServer }, { z }] = await Promise.all([
|
|
569
807
|
import("@modelcontextprotocol/sdk/server/mcp.js"),
|
|
@@ -578,7 +816,10 @@ async function createMcpServer(opts) {
|
|
|
578
816
|
agentTool: client.agentTool.bind(client),
|
|
579
817
|
devSession
|
|
580
818
|
};
|
|
581
|
-
const server = new McpServer(
|
|
819
|
+
const server = new McpServer(
|
|
820
|
+
{ name: "vibeview", version: opts.version ?? "0.0.0" },
|
|
821
|
+
{ instructions: MCP_INSTRUCTIONS }
|
|
822
|
+
);
|
|
582
823
|
function zodShape(def) {
|
|
583
824
|
const shape = {};
|
|
584
825
|
for (const [key, prop] of Object.entries(def.properties)) {
|
|
@@ -614,8 +855,12 @@ async function runMcpServer(opts) {
|
|
|
614
855
|
await server.connect(transport);
|
|
615
856
|
}
|
|
616
857
|
export {
|
|
858
|
+
MCP_INSTRUCTIONS,
|
|
617
859
|
buildToolDefinitions,
|
|
618
860
|
createDevSessionController,
|
|
861
|
+
deviceLine,
|
|
862
|
+
forMcp,
|
|
619
863
|
handleToolCall,
|
|
620
|
-
runMcpServer
|
|
864
|
+
runMcpServer,
|
|
865
|
+
startedDevice
|
|
621
866
|
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vibeview",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "VibeView CLI -- run
|
|
3
|
+
"version": "0.28.0",
|
|
4
|
+
"description": "VibeView CLI -- run your app on cloud iOS simulators, Android emulators, Apple TV, Android TV and Roku (beta) devices: a React Native dev loop, device control for you or your coding agent (CLI or MCP), and tests from CI",
|
|
5
5
|
"mcpName": "io.github.vibeview/vibeview",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -297,12 +297,17 @@ steps the CLI does with plain shell commands:
|
|
|
297
297
|
|
|
298
298
|
| MCP tool | Args | Purpose |
|
|
299
299
|
|----------|------|---------|
|
|
300
|
-
| `upload_app` | `file` (required path), `name` | Upload a
|
|
301
|
-
| `
|
|
302
|
-
| `
|
|
300
|
+
| `upload_app` | `file` (required path), `name` | Upload a build (zipped iOS/tvOS simulator `.app`, Android/Android TV `.apk`, Roku channel `.zip`). The MCP equivalent of `vibeview upload-app`, so step 1 of the loop needs no shell. Returns the `app_id` to pass to `dev_start`. |
|
|
301
|
+
| `list_apps` | `platform` | List the org's apps (app id, name, platform, bundle id). The MCP equivalent of `vibeview list-apps` — use it to find an app someone else uploaded. |
|
|
302
|
+
| `dev_start` | `platform` (required: `ios`/`android`/`tvos`/`androidtv`/`roku`), `app`, `metro_port`, `model`, `build_id`, `standalone` | Start a session held open by the MCP server. The MCP equivalent of `vibeview dev --detach`; `model` picks an exact device model (e.g. `"iPhone Duo"`); `build_id` runs that build instead of the newest debug one (see `list_builds`). `standalone: true` runs any build as it is with no Metro — a release, CI or cloud build (without `build_id`: the newest release build). An app for another platform is refused before a device is taken. Returns `page_url` — relay it to the developer immediately, same as step 5 of the loop — and the device it got (model, OS, category; posture and lit screen on a foldable). |
|
|
303
|
+
| `list_device_models` | `device_type` | List the device models you can start (model, OS, platform, category, `foldable` marker, and how many are free/busy). The MCP equivalent of `vibeview list-devices --models`. |
|
|
303
304
|
| `list_builds` | `app` (required) | List an app's uploaded builds with their kind (`debug`/`release`/`unknown`). The MCP equivalent of `vibeview list-builds`. |
|
|
304
|
-
| `dev_stop` | — | Stop the session `dev_start` started. The MCP equivalent of `vibeview dev-stop`. |
|
|
305
|
-
| `stop_session` | `session_id` (required) | Stop any session by id. The MCP equivalent of `vibeview stop <session-id>`. |
|
|
305
|
+
| `dev_stop` | — | Stop the session `dev_start` started. The MCP equivalent of `vibeview dev-stop`. Says "no dev session was running" when there was none. |
|
|
306
|
+
| `stop_session` | `session_id` (required) | Stop any session by id. The MCP equivalent of `vibeview stop <session-id>`. Says so when the session had already ended. |
|
|
307
|
+
|
|
308
|
+
If the held session ends elsewhere (dashboard, idle timeout, failure), the
|
|
309
|
+
next tool call answers "session … has ended (…) — call dev_start"; the
|
|
310
|
+
server has already let it go, so `dev_start` starts a fresh one.
|
|
306
311
|
|
|
307
312
|
**`dev_start` is stateful — it changes the default session for every later
|
|
308
313
|
call.** Once it succeeds, any tool called without an explicit `session_id`
|