vibeview 0.22.0 → 0.24.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.
@@ -197,7 +197,7 @@ var ApiClient = class {
197
197
  async listApps() {
198
198
  return this.requestRaw("GET", "/apps/");
199
199
  }
200
- async uploadApp(filePath, name) {
200
+ async uploadApp(filePath, name, note) {
201
201
  const fileBuffer = readFileSync2(filePath);
202
202
  const blob = new Blob([fileBuffer]);
203
203
  const formData = new FormData();
@@ -205,8 +205,15 @@ var ApiClient = class {
205
205
  if (name) {
206
206
  formData.append("name", name);
207
207
  }
208
+ if (note) {
209
+ formData.append("note", note);
210
+ }
208
211
  return this.requestFormData("POST", "/apps/upload", formData);
209
212
  }
213
+ /** PATCH a build's note (null clears it). */
214
+ async updateBuildNote(appPublicId, buildId, note) {
215
+ return this.requestRaw("PATCH", `/apps/${appPublicId}/builds/${buildId}`, { note });
216
+ }
210
217
  async uploadBuild(appPublicId, filePath, version) {
211
218
  const fileBuffer = readFileSync2(filePath);
212
219
  const blob = new Blob([fileBuffer]);
@@ -238,6 +245,10 @@ var ApiClient = class {
238
245
  }
239
246
  return all;
240
247
  }
248
+ /** The startable device models (see `DeviceModelPool`). */
249
+ async listDeviceModels() {
250
+ return this.requestRaw("GET", "/devices/pools");
251
+ }
241
252
  async getDevice(deviceId) {
242
253
  return this.requestRaw("GET", `/devices/${deviceId}`);
243
254
  }
@@ -609,9 +620,15 @@ function sanitizeDownloadFilename(raw) {
609
620
 
610
621
  // src/lib/spinner.ts
611
622
  import ora from "ora";
612
- function createSpinner(text) {
623
+ function createSpinner(text, opts = {}) {
613
624
  const isCI = process.env.CI === "true" || process.env.CI === "1" || !process.stdout.isTTY;
614
- return ora({ text, isEnabled: !isCI, stream: process.stderr, discardStdin: false });
625
+ return ora({
626
+ text,
627
+ isEnabled: !isCI,
628
+ stream: process.stderr,
629
+ discardStdin: false,
630
+ isSilent: opts.silent === true
631
+ });
615
632
  }
616
633
 
617
634
  // src/lib/types.ts
@@ -632,16 +649,26 @@ async function uploadAppBuild(filePath, options) {
632
649
  }
633
650
  const { token, baseUrl } = resolveAuth(options);
634
651
  const client = new ApiClient(baseUrl, token);
635
- const result = await client.uploadApp(filePath, options.name);
652
+ const note = options.note?.trim() || void 0;
653
+ const result = await client.uploadApp(filePath, options.name, note);
654
+ if (note && result.deduplicated && result.build_id != null) {
655
+ await client.updateBuildNote(result.public_id, result.build_id, note);
656
+ }
636
657
  return { result, baseUrl };
637
658
  }
659
+ function describeBuild(result) {
660
+ if (result.build_id == null) return null;
661
+ const version = result.version ? `v${result.version}${result.build_number ? ` (${result.build_number})` : ""}` : "";
662
+ const dedup = result.deduplicated ? chalk.dim(" identical build already uploaded") : "";
663
+ return `Build ${chalk.cyan(String(result.build_id))}${version ? ` ${version}` : ""}${dedup}`;
664
+ }
638
665
  async function uploadApp(filePath, options) {
639
666
  if (!existsSync(filePath)) {
640
667
  console.error(chalk.red(`Error: File not found: ${filePath}`));
641
668
  process.exit(EXIT_INFRA_ERROR);
642
669
  }
643
670
  const { baseUrl } = resolveAuth(options);
644
- const spinner = createSpinner("Uploading app...");
671
+ const spinner = createSpinner("Uploading app...", { silent: options.output === "json" });
645
672
  spinner.start();
646
673
  try {
647
674
  const { result } = await uploadAppBuild(filePath, options);
@@ -658,6 +685,11 @@ async function uploadApp(filePath, options) {
658
685
  package_name: result.package_name,
659
686
  platform: result.platform,
660
687
  is_new: isNew,
688
+ build_id: result.build_id ?? null,
689
+ version: result.version ?? null,
690
+ build_number: result.build_number ?? null,
691
+ deduplicated: result.deduplicated === true,
692
+ note: options.note?.trim() || null,
661
693
  url: `${baseUrl}/apps/${result.public_id}`,
662
694
  warnings: result.warnings ?? []
663
695
  },
@@ -671,6 +703,8 @@ async function uploadApp(filePath, options) {
671
703
  ${chalk.cyan(result.public_id)} ${result.name} ${chalk.dim(`[${result.platform}]`)}`
672
704
  );
673
705
  console.log(` ${chalk.dim(result.package_name)}`);
706
+ const build = describeBuild(result);
707
+ if (build) console.log(` ${build}`);
674
708
  console.log(chalk.dim(` ${baseUrl}/apps/${result.public_id}
675
709
  `));
676
710
  }
@@ -679,7 +713,9 @@ async function uploadApp(filePath, options) {
679
713
  \u26A0 ${w}`));
680
714
  }
681
715
  } catch (err) {
682
- spinner.fail(err instanceof Error ? err.message : "Upload failed");
716
+ const message = err instanceof Error ? err.message : "Upload failed";
717
+ spinner.fail(message);
718
+ if (options.output === "json") console.error(chalk.red(`Error: ${message}`));
683
719
  process.exit(EXIT_INFRA_ERROR);
684
720
  }
685
721
  }
@@ -696,5 +732,6 @@ export {
696
732
  EXIT_INFRA_ERROR,
697
733
  UsageError,
698
734
  uploadAppBuild,
735
+ describeBuild,
699
736
  uploadApp
700
737
  };
@@ -6,7 +6,7 @@ import {
6
6
  loadConfig,
7
7
  resolveAuth,
8
8
  uploadAppBuild
9
- } from "./chunk-UGCGJ6IN.js";
9
+ } from "./chunk-2WNJYNDW.js";
10
10
 
11
11
  // src/lib/dev-state.ts
12
12
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
@@ -1646,6 +1646,26 @@ function decodeFrame(data) {
1646
1646
  return { type, streamId: data.readUInt32BE(1), payload: data.subarray(HEADER_LEN) };
1647
1647
  }
1648
1648
 
1649
+ // src/lib/redact.ts
1650
+ var SECRET_PARAMS = /* @__PURE__ */ new Set([
1651
+ "token",
1652
+ "access_token",
1653
+ "refresh_token",
1654
+ "api_key",
1655
+ "apikey",
1656
+ "key",
1657
+ "secret",
1658
+ "signature",
1659
+ "sig",
1660
+ "jwt"
1661
+ ]);
1662
+ function redactSecrets(text) {
1663
+ return text.replace(
1664
+ /\b([A-Za-z_]+)=([^&\s"'#]+)/g,
1665
+ (match, name) => SECRET_PARAMS.has(name.toLowerCase()) ? `${name}=[redacted]` : match
1666
+ );
1667
+ }
1668
+
1649
1669
  // src/lib/tunnel-client.ts
1650
1670
  var FATAL_CLOSE_CODES = /* @__PURE__ */ new Set([4008, 4009]);
1651
1671
  var MAX_BACKOFF_MS = 8e3;
@@ -1665,7 +1685,7 @@ var TunnelClient = class {
1665
1685
  url;
1666
1686
  connCount = 0;
1667
1687
  debug(msg) {
1668
- this.opts.onDebug?.(msg);
1688
+ this.opts.onDebug?.(redactSecrets(msg));
1669
1689
  }
1670
1690
  start() {
1671
1691
  this.connect();
@@ -2060,7 +2080,10 @@ async function startDevSession(cfg, onEvent = () => {
2060
2080
  }, onTunnelState) {
2061
2081
  const api = new ApiClient(cfg.baseUrl, cfg.token);
2062
2082
  const usesTunnel = PLATFORM_NEEDS_METRO[cfg.platform];
2063
- const created = await api.createSession(devSessionCreateBody(cfg));
2083
+ const pool = cfg.model ? resolveDeviceModel(await api.listDeviceModels(), cfg.platform, cfg.model) : null;
2084
+ const created = await api.createSession(
2085
+ devSessionCreateBody(pool ? { ...cfg, deviceCategory: pool.category, model: pool.model } : cfg)
2086
+ );
2064
2087
  const sessionId = created.session_id;
2065
2088
  let stopping = false;
2066
2089
  let tunnel = null;
@@ -2154,9 +2177,23 @@ function devSessionCreateBody(cfg) {
2154
2177
  device_category: cfg.deviceCategory,
2155
2178
  app_id: cfg.appId,
2156
2179
  // Roku has no JS bundle to serve, so there is nothing to tunnel.
2157
- tunnel: PLATFORM_NEEDS_METRO[cfg.platform]
2180
+ tunnel: PLATFORM_NEEDS_METRO[cfg.platform],
2181
+ ...cfg.model ? { model: cfg.model } : {}
2158
2182
  };
2159
2183
  }
2184
+ function resolveDeviceModel(pools, platform, model) {
2185
+ const { deviceType, tv } = PLATFORM_DEVICE[platform];
2186
+ const candidates = pools.filter(
2187
+ (p) => p.device_type === deviceType && p.category === "tv" === tv
2188
+ );
2189
+ const wanted = model.trim();
2190
+ const match = candidates.find((p) => p.model === wanted) ?? candidates.find((p) => p.model.toLowerCase() === wanted.toLowerCase());
2191
+ if (match) return match;
2192
+ const names = [...new Set(candidates.map((p) => p.model))].sort();
2193
+ throw new Error(
2194
+ names.length ? `no ${platform} device model named "${model}" \u2014 available: ${names.join(", ")} (see \`vibeview list-devices --models\`)` : `no ${platform} devices are available right now`
2195
+ );
2196
+ }
2160
2197
  var BLOCKING_WARNING_PREFIXES = ["No debug build", "App install failed", "App not found"];
2161
2198
  function findBlockingWarning(info) {
2162
2199
  return (info.warnings ?? []).find((w) => BLOCKING_WARNING_PREFIXES.some((p) => w.includes(p)));
@@ -2296,6 +2333,16 @@ async function ensureConfig(api, opts) {
2296
2333
  console.error(chalk3.red(`error: use --platform tvos or --platform androidtv for TV devices`));
2297
2334
  process.exit(2);
2298
2335
  }
2336
+ if (opts.model !== void 0 && opts.deviceCategory) {
2337
+ console.error(chalk3.red("error: pass --model or --device-category, not both"));
2338
+ process.exit(2);
2339
+ }
2340
+ if (opts.model !== void 0 && !opts.model.trim()) {
2341
+ console.error(
2342
+ chalk3.red("error: --model needs a model name (see `vibeview list-devices --models`)")
2343
+ );
2344
+ process.exit(2);
2345
+ }
2299
2346
  let firstRunBuild = null;
2300
2347
  let firstRunMetro = null;
2301
2348
  let isFirstRun = false;
@@ -2430,6 +2477,7 @@ Run \`vibeview dev\` without --app to pick from the list.`
2430
2477
  platform,
2431
2478
  appId,
2432
2479
  deviceCategory,
2480
+ model: opts.model,
2433
2481
  metroPort: finalMetro.port,
2434
2482
  metroCommand: finalMetro.command,
2435
2483
  platformHasMetroOverride: finalMetro.platformHasOverride,
@@ -2573,6 +2621,7 @@ async function runDevInProcess(opts) {
2573
2621
  platform: cfg.platform,
2574
2622
  appId: cfg.appId,
2575
2623
  deviceCategory: cfg.deviceCategory,
2624
+ model: cfg.model,
2576
2625
  metroPort: cfg.metroPort ?? 8081,
2577
2626
  // startDevSession's own browser-open happens before we've written the
2578
2627
  // state file / emitted session_ready — open it ourselves afterward
@@ -2820,6 +2869,55 @@ async function dev(opts) {
2820
2869
  if (process.exitCode === 1) return;
2821
2870
  }
2822
2871
 
2872
+ // src/lib/formatters/devices.ts
2873
+ import chalk4 from "chalk";
2874
+ var HEADERS = ["DEVICE_ID", "NAME", "TYPE", "CATEGORY", "OS", "STATUS"];
2875
+ function formatDevicesHuman(devices) {
2876
+ if (devices.length === 0) {
2877
+ return "No devices registered.\n";
2878
+ }
2879
+ const rows = devices.map((d) => [
2880
+ d.device_id,
2881
+ d.name,
2882
+ d.type,
2883
+ d.category,
2884
+ d.os_version,
2885
+ d.status
2886
+ ]);
2887
+ const widths = HEADERS.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
2888
+ const fmt = (cells) => cells.map((c, i) => c.padEnd(widths[i])).join(" ").trimEnd();
2889
+ const lines = [chalk4.bold(fmt(HEADERS))];
2890
+ for (const row of rows.sort(rowSort)) {
2891
+ const statusColored = row[5] === "available" ? chalk4.green(row[5]) : row[5] === "busy" ? chalk4.yellow(row[5]) : chalk4.dim(row[5]);
2892
+ lines.push(fmt([row[0], row[1], row[2], row[3], row[4], statusColored]));
2893
+ }
2894
+ return lines.join("\n") + "\n";
2895
+ }
2896
+ function rowSort(a, b) {
2897
+ return a[2].localeCompare(b[2]) || a[3].localeCompare(b[3]) || a[0].localeCompare(b[0]);
2898
+ }
2899
+ function filterDeviceModels(models, opts) {
2900
+ return models.filter((m) => !opts.type || m.device_type === opts.type).filter((m) => !opts.category || m.category === opts.category).map((m) => ({
2901
+ model: m.model,
2902
+ os_version: m.os_version,
2903
+ device_type: m.device_type,
2904
+ category: m.category,
2905
+ form_factor: m.form_factor ?? null
2906
+ }));
2907
+ }
2908
+ var MODEL_HEADERS = ["MODEL", "OS", "TYPE", "CATEGORY", "FORM FACTOR"];
2909
+ function formatDeviceModelsHuman(models) {
2910
+ if (models.length === 0) {
2911
+ return "No device models available right now.\n";
2912
+ }
2913
+ const rows = models.map((m) => [m.model, m.os_version, m.device_type, m.category, m.form_factor ?? "-"]).sort(
2914
+ (a, b) => a[2].localeCompare(b[2]) || a[3].localeCompare(b[3]) || a[0].localeCompare(b[0]) || a[1].localeCompare(b[1])
2915
+ );
2916
+ const widths = MODEL_HEADERS.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
2917
+ const fmt = (cells) => cells.map((c, i) => c.padEnd(widths[i])).join(" ").trimEnd();
2918
+ return [chalk4.bold(fmt(MODEL_HEADERS)), ...rows.map(fmt)].join("\n") + "\n";
2919
+ }
2920
+
2823
2921
  // src/agent/registry.ts
2824
2922
  var AGENT_COMMANDS = [
2825
2923
  {
@@ -3135,6 +3233,52 @@ var AGENT_COMMANDS = [
3135
3233
  }
3136
3234
  ]
3137
3235
  },
3236
+ {
3237
+ // set_posture: { posture: 'closed' | 'partial' | 'open' } OR { angle: 0..180 }
3238
+ // — exactly one (buildToolCall enforces it CLI-side, agent_control.py's
3239
+ // validate_tool_args at the gate). The angle is a settle-once fold, the
3240
+ // same as the dashboard slider's release, not its live drag stream.
3241
+ // Foldable devices only.
3242
+ verb: "set-posture",
3243
+ tool: "set_posture",
3244
+ summary: "Fold or unfold a foldable device (iPhone Duo): a preset (closed, partial, open) or an exact hinge angle in degrees (--angle 0-180) \u2014 pass exactly one. Reports the posture it settled in, the angle, and which screen is lit. Errors on a device without a hinge.",
3245
+ mutating: true,
3246
+ params: [
3247
+ {
3248
+ name: "posture",
3249
+ cli: "positional",
3250
+ type: "string",
3251
+ required: false,
3252
+ description: "One of 'closed', 'partial', 'open'. Omit when passing an angle."
3253
+ },
3254
+ {
3255
+ name: "angle",
3256
+ cli: "--angle",
3257
+ type: "number",
3258
+ required: false,
3259
+ description: "Exact hinge angle in whole degrees, 0 (shut) to 180 (flat open). Omit when passing a posture."
3260
+ }
3261
+ ]
3262
+ },
3263
+ {
3264
+ // rotate: { degrees?: 90 | 180 | 270 } (default 90). The same control as
3265
+ // the dashboard's Rotate button. A foldable turns its body by quarter
3266
+ // turns (90 clockwise, 270 back, 180 a half turn); a rigid phone toggles
3267
+ // portrait <-> landscape and accepts only 90. Refused on TV devices.
3268
+ verb: "rotate",
3269
+ tool: "rotate",
3270
+ summary: "Rotate the device. A phone toggles between portrait and landscape; a foldable (iPhone Duo) turns a quarter clockwise, or by --degrees 180/270. Reports the new orientation. Not available on TV devices.",
3271
+ mutating: true,
3272
+ params: [
3273
+ {
3274
+ name: "degrees",
3275
+ cli: "--degrees",
3276
+ type: "number",
3277
+ required: false,
3278
+ description: "Foldable only: 90 (default, a quarter turn clockwise), 180, or 270 (a quarter turn back). A phone accepts only 90."
3279
+ }
3280
+ ]
3281
+ },
3138
3282
  {
3139
3283
  // tools.py wait: { seconds?: number } (default: 2). Note the wire arg is
3140
3284
  // "seconds", NOT "ms" — tool_dispatch.py reads args.get("seconds", 2)
@@ -3226,18 +3370,18 @@ var AGENT_COMMANDS = [
3226
3370
  ];
3227
3371
 
3228
3372
  // src/agent/render.ts
3229
- import chalk4 from "chalk";
3373
+ import chalk5 from "chalk";
3230
3374
  function renderEnvelope(env) {
3231
3375
  const lines = [];
3232
3376
  if (env.ok) {
3233
- lines.push(chalk4.green(`ok`) + chalk4.dim(` (${env.tool})`));
3377
+ lines.push(chalk5.green(`ok`) + chalk5.dim(` (${env.tool})`));
3234
3378
  } else {
3235
- lines.push(chalk4.red(`error`) + chalk4.dim(` (${env.tool})`));
3379
+ lines.push(chalk5.red(`error`) + chalk5.dim(` (${env.tool})`));
3236
3380
  }
3237
3381
  lines.push(...resultLines(env.result));
3238
3382
  if (env.observation) {
3239
3383
  if (env.observation.kind === "unavailable") {
3240
- lines.push(chalk4.yellow(`warning: ${env.observation.text}`));
3384
+ lines.push(chalk5.yellow(`warning: ${env.observation.text}`));
3241
3385
  } else {
3242
3386
  lines.push(env.observation.text);
3243
3387
  }
@@ -3250,17 +3394,17 @@ function resultLines(result) {
3250
3394
  const lines = [];
3251
3395
  if (Array.isArray(r.entries) && typeof r.cursor === "number") {
3252
3396
  if (r.entries.length === 0) {
3253
- lines.push(chalk4.dim("(no log lines)"));
3397
+ lines.push(chalk5.dim("(no log lines)"));
3254
3398
  }
3255
3399
  for (const e of r.entries) {
3256
3400
  if (!e || typeof e !== "object") continue;
3257
3401
  const entry = e;
3258
3402
  const ts = typeof entry.timestamp === "number" ? new Date(entry.timestamp).toISOString().slice(11, 23) : "??:??:??.???";
3259
3403
  const level = typeof entry.level === "string" ? entry.level : "info";
3260
- const levelTag = level === "error" ? chalk4.red(level) : level === "warn" ? chalk4.yellow(level) : chalk4.dim(level);
3261
- lines.push(`${chalk4.dim(ts)} ${levelTag} ${String(entry.message ?? "")}`);
3404
+ const levelTag = level === "error" ? chalk5.red(level) : level === "warn" ? chalk5.yellow(level) : chalk5.dim(level);
3405
+ lines.push(`${chalk5.dim(ts)} ${levelTag} ${String(entry.message ?? "")}`);
3262
3406
  }
3263
- lines.push(chalk4.dim(`cursor: ${r.cursor}`));
3407
+ lines.push(chalk5.dim(`cursor: ${r.cursor}`));
3264
3408
  }
3265
3409
  if (r.found === true && typeof r.ref === "string" && r.ref) {
3266
3410
  let found = `found: @${r.ref}`;
@@ -3272,14 +3416,43 @@ function resultLines(result) {
3272
3416
  const text = typeof a.text === "string" && a.text ? ` ${JSON.stringify(a.text)}` : "";
3273
3417
  return `${ref}${text}`;
3274
3418
  });
3275
- if (alts.length > 0) lines.push(chalk4.dim(`alternatives: ${alts.join(", ")}`));
3419
+ if (alts.length > 0) lines.push(chalk5.dim(`alternatives: ${alts.join(", ")}`));
3276
3420
  }
3277
3421
  }
3278
- if (typeof r.note === "string" && r.note) lines.push(chalk4.dim(r.note));
3422
+ if (r.action === "posture_set" || r.action === "rotated") {
3423
+ const state = deviceStateLine(r);
3424
+ if (state) lines.push(state);
3425
+ }
3426
+ if (typeof r.note === "string" && r.note) lines.push(chalk5.dim(r.note));
3279
3427
  if (typeof r.message === "string" && r.message) lines.push(r.message);
3280
3428
  if (typeof r.warning === "string" && r.warning) lines.push(r.warning);
3281
3429
  return lines;
3282
3430
  }
3431
+ function deviceStateLine(r) {
3432
+ const parts = [];
3433
+ if (r.action === "rotated") {
3434
+ if (typeof r.device_orientation === "string") {
3435
+ let o = `device held ${r.device_orientation}`;
3436
+ if (typeof r.screen_orientation === "string" && r.screen_orientation !== r.device_orientation) {
3437
+ o += ` (picture on the lit screen: ${r.screen_orientation})`;
3438
+ }
3439
+ parts.push(o);
3440
+ }
3441
+ }
3442
+ if (typeof r.posture === "string") {
3443
+ let p = `posture: ${r.posture}`;
3444
+ if (typeof r.angle === "number") p += ` at ${r.angle}\xB0`;
3445
+ const extras = [];
3446
+ if (typeof r.screen === "string") extras.push(`${r.screen} screen`);
3447
+ const d = r.display;
3448
+ if (d && typeof d.width === "number" && typeof d.height === "number") {
3449
+ extras.push(`${d.width}x${d.height}`);
3450
+ }
3451
+ if (extras.length) p += ` (${extras.join(", ")})`;
3452
+ parts.push(p);
3453
+ }
3454
+ return parts.length ? parts.join("; ") : null;
3455
+ }
3283
3456
  function agentExitCode(env) {
3284
3457
  return env.ok ? 0 : 1;
3285
3458
  }
@@ -3401,6 +3574,15 @@ function buildToolCall(cmd, positionals, opts) {
3401
3574
  }
3402
3575
  return { tool: cmd.tool, args: args2 };
3403
3576
  }
3577
+ if (cmd.verb === "set-posture") {
3578
+ const hasPosture = positionals[0] !== void 0 && positionals[0] !== "";
3579
+ const hasAngle = opts.angle !== void 0 && opts.angle !== "";
3580
+ if (hasPosture === hasAngle) {
3581
+ throw new FlagError(
3582
+ hasPosture ? "set-posture takes a posture or --angle, not both" : "set-posture requires a posture (closed, partial, open) or --angle <0-180>"
3583
+ );
3584
+ }
3585
+ }
3404
3586
  const args = {};
3405
3587
  let positionalIndex = 0;
3406
3588
  for (const param of cmd.params) {
@@ -3526,6 +3708,9 @@ export {
3526
3708
  ROKU_LIVENESS_INTERVAL_MS,
3527
3709
  PLATFORM_DEVICE,
3528
3710
  dev,
3711
+ formatDevicesHuman,
3712
+ filterDeviceModels,
3713
+ formatDeviceModelsHuman,
3529
3714
  AGENT_COMMANDS,
3530
3715
  renderEnvelope,
3531
3716
  buildToolCall,
package/dist/cli.js CHANGED
@@ -7,6 +7,9 @@ import {
7
7
  createRokuPush,
8
8
  dev,
9
9
  fetchFinalSummary,
10
+ filterDeviceModels,
11
+ formatDeviceModelsHuman,
12
+ formatDevicesHuman,
10
13
  formatDuration,
11
14
  killOwnedMetro,
12
15
  loadDevConfig,
@@ -16,7 +19,7 @@ import {
16
19
  readDevState,
17
20
  readMetroState,
18
21
  runAgentVerb
19
- } from "./chunk-SHS5PCSS.js";
22
+ } from "./chunk-MVNMGB6I.js";
20
23
  import {
21
24
  ApiClient,
22
25
  EXIT_INFRA_ERROR,
@@ -29,7 +32,7 @@ import {
29
32
  resolveAuth,
30
33
  saveConfig,
31
34
  uploadApp
32
- } from "./chunk-UGCGJ6IN.js";
35
+ } from "./chunk-2WNJYNDW.js";
33
36
 
34
37
  // src/cli.ts
35
38
  import { createRequire } from "module";
@@ -1998,17 +2001,17 @@ function formatEnvVarsHuman(variables) {
1998
2001
  if (variables.length === 0) {
1999
2002
  return "No build variables found.\n";
2000
2003
  }
2001
- const HEADERS4 = ["NAME", "VISIBILITY", "SCOPE", "HINT"];
2004
+ const HEADERS3 = ["NAME", "VISIBILITY", "SCOPE", "HINT"];
2002
2005
  const rows = variables.map((v) => [
2003
2006
  v.name,
2004
2007
  v.is_file ? `${v.visibility} (file)` : v.visibility,
2005
2008
  v.app_public_id ?? "org-wide",
2006
2009
  v.value_hint ?? ""
2007
2010
  ]);
2008
- const widths = HEADERS4.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
2011
+ const widths = HEADERS3.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
2009
2012
  const padCells = (cells) => cells.map((c, i) => c.padEnd(widths[i]));
2010
2013
  const join3 = (cells) => cells.join(" ").trimEnd();
2011
- const lines = [chalk16.bold(join3(padCells(HEADERS4)))];
2014
+ const lines = [chalk16.bold(join3(padCells(HEADERS3)))];
2012
2015
  for (const row of rows) lines.push(join3(padCells(row)));
2013
2016
  return lines.join("\n") + "\n";
2014
2017
  }
@@ -2545,12 +2548,12 @@ function formatAscKeysHuman(keys) {
2545
2548
  if (keys.length === 0) {
2546
2549
  return "No App Store Connect keys found.\n";
2547
2550
  }
2548
- const HEADERS4 = ["NAME", "KEY ID", "ISSUER ID", "CREATED"];
2551
+ const HEADERS3 = ["NAME", "KEY ID", "ISSUER ID", "CREATED"];
2549
2552
  const rows = keys.map((k) => [k.name, k.key_id, k.issuer_id, k.created_at ?? ""]);
2550
- const widths = HEADERS4.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
2553
+ const widths = HEADERS3.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
2551
2554
  const padCells = (cells) => cells.map((c, i) => c.padEnd(widths[i]));
2552
2555
  const join3 = (cells) => cells.join(" ").trimEnd();
2553
- const lines = [chalk17.bold(join3(padCells(HEADERS4)))];
2556
+ const lines = [chalk17.bold(join3(padCells(HEADERS3)))];
2554
2557
  for (const row of rows) lines.push(join3(padCells(row)));
2555
2558
  return lines.join("\n") + "\n";
2556
2559
  }
@@ -2621,12 +2624,12 @@ function formatPlayKeysHuman(keys) {
2621
2624
  if (keys.length === 0) {
2622
2625
  return "No Google Play keys found.\n";
2623
2626
  }
2624
- const HEADERS4 = ["NAME", "CLIENT EMAIL", "CREATED"];
2627
+ const HEADERS3 = ["NAME", "CLIENT EMAIL", "CREATED"];
2625
2628
  const rows = keys.map((k) => [k.name, k.client_email, k.created_at ?? ""]);
2626
- const widths = HEADERS4.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
2629
+ const widths = HEADERS3.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
2627
2630
  const padCells = (cells) => cells.map((c, i) => c.padEnd(widths[i]));
2628
2631
  const join3 = (cells) => cells.join(" ").trimEnd();
2629
- const lines = [chalk17.bold(join3(padCells(HEADERS4)))];
2632
+ const lines = [chalk17.bold(join3(padCells(HEADERS3)))];
2630
2633
  for (const row of rows) lines.push(join3(padCells(row)));
2631
2634
  return lines.join("\n") + "\n";
2632
2635
  }
@@ -2695,7 +2698,7 @@ function formatCredentialsHuman(credentials2) {
2695
2698
  if (credentials2.length === 0) {
2696
2699
  return "No signing credentials found.\n";
2697
2700
  }
2698
- const HEADERS4 = ["NAME", "PLATFORM", "SCOPE", "IDENTITY", "EXPIRES"];
2701
+ const HEADERS3 = ["NAME", "PLATFORM", "SCOPE", "IDENTITY", "EXPIRES"];
2699
2702
  const rows = credentials2.map((c) => [
2700
2703
  c.name,
2701
2704
  c.platform,
@@ -2703,10 +2706,10 @@ function formatCredentialsHuman(credentials2) {
2703
2706
  formatIdentity(c),
2704
2707
  formatExpires(c)
2705
2708
  ]);
2706
- const widths = HEADERS4.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
2709
+ const widths = HEADERS3.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
2707
2710
  const padCells = (cells) => cells.map((c, i) => c.padEnd(widths[i]));
2708
2711
  const join3 = (cells) => cells.join(" ").trimEnd();
2709
- const lines = [chalk17.bold(join3(padCells(HEADERS4)))];
2712
+ const lines = [chalk17.bold(join3(padCells(HEADERS3)))];
2710
2713
  for (const row of rows) lines.push(join3(padCells(row)));
2711
2714
  return lines.join("\n") + "\n";
2712
2715
  }
@@ -2814,7 +2817,7 @@ function formatWebhooksHuman(webhooks2) {
2814
2817
  if (webhooks2.length === 0) {
2815
2818
  return "No webhooks found.\n";
2816
2819
  }
2817
- const HEADERS4 = ["ID", "URL", "ACTIVE", "LAST DELIVERY", "CREATED"];
2820
+ const HEADERS3 = ["ID", "URL", "ACTIVE", "LAST DELIVERY", "CREATED"];
2818
2821
  const rows = webhooks2.map((w) => [
2819
2822
  w.id,
2820
2823
  w.url,
@@ -2822,10 +2825,10 @@ function formatWebhooksHuman(webhooks2) {
2822
2825
  formatLastDelivery(w.last_delivery),
2823
2826
  w.created_at
2824
2827
  ]);
2825
- const widths = HEADERS4.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
2828
+ const widths = HEADERS3.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
2826
2829
  const padCells = (cells) => cells.map((c, i) => c.padEnd(widths[i]));
2827
2830
  const join3 = (cells) => cells.join(" ").trimEnd();
2828
- const lines = [chalk18.bold(join3(padCells(HEADERS4)))];
2831
+ const lines = [chalk18.bold(join3(padCells(HEADERS3)))];
2829
2832
  for (const row of rows) lines.push(join3(padCells(row)));
2830
2833
  return lines.join("\n") + "\n";
2831
2834
  }
@@ -2915,38 +2918,26 @@ function printError6(opts, err) {
2915
2918
  console.error(chalk18.red(`error: ${message}`));
2916
2919
  }
2917
2920
 
2918
- // src/lib/formatters/devices.ts
2919
- import chalk19 from "chalk";
2920
- var HEADERS3 = ["DEVICE_ID", "NAME", "TYPE", "CATEGORY", "OS", "STATUS"];
2921
- function formatDevicesHuman(devices) {
2922
- if (devices.length === 0) {
2923
- return "No devices registered.\n";
2924
- }
2925
- const rows = devices.map((d) => [
2926
- d.device_id,
2927
- d.name,
2928
- d.type,
2929
- d.category,
2930
- d.os_version,
2931
- d.status
2932
- ]);
2933
- const widths = HEADERS3.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
2934
- const fmt = (cells) => cells.map((c, i) => c.padEnd(widths[i])).join(" ").trimEnd();
2935
- const lines = [chalk19.bold(fmt(HEADERS3))];
2936
- for (const row of rows.sort(rowSort)) {
2937
- const statusColored = row[5] === "available" ? chalk19.green(row[5]) : row[5] === "busy" ? chalk19.yellow(row[5]) : chalk19.dim(row[5]);
2938
- lines.push(fmt([row[0], row[1], row[2], row[3], row[4], statusColored]));
2939
- }
2940
- return lines.join("\n") + "\n";
2941
- }
2942
- function rowSort(a, b) {
2943
- return a[2].localeCompare(b[2]) || a[3].localeCompare(b[3]) || a[0].localeCompare(b[0]);
2944
- }
2945
-
2946
2921
  // src/commands/list-devices.ts
2947
2922
  async function listDevicesCommand(options) {
2948
2923
  const { token, baseUrl } = resolveAuth(options);
2949
2924
  const client = new ApiClient(baseUrl, token);
2925
+ if (options.models) {
2926
+ try {
2927
+ const models = filterDeviceModels(await client.listDeviceModels(), options);
2928
+ if (options.output === "json") {
2929
+ process.stdout.write(JSON.stringify(models, null, 2) + "\n");
2930
+ } else {
2931
+ process.stdout.write(formatDeviceModelsHuman(models));
2932
+ }
2933
+ process.exit(EXIT_PASS);
2934
+ } catch (err) {
2935
+ process.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}
2936
+ `);
2937
+ process.exit(EXIT_INFRA_ERROR);
2938
+ }
2939
+ return;
2940
+ }
2950
2941
  try {
2951
2942
  const devices = await client.listDevices({
2952
2943
  type: options.type,
@@ -3024,7 +3015,7 @@ import { dirname, join as join2 } from "path";
3024
3015
  import { fileURLToPath } from "url";
3025
3016
  import { spawnSync } from "child_process";
3026
3017
  import { confirm } from "@inquirer/prompts";
3027
- import chalk20 from "chalk";
3018
+ import chalk19 from "chalk";
3028
3019
  function resolveShippedSkillPath(fromDir) {
3029
3020
  for (const rel of ["..", join2("..", "..")]) {
3030
3021
  const candidate = join2(fromDir, rel, "skills", "vibeview-agent", "SKILL.md");
@@ -3052,23 +3043,23 @@ function registerMcp() {
3052
3043
  encoding: "utf8"
3053
3044
  });
3054
3045
  if (res.status === 0) {
3055
- console.log(chalk20.green("\u2713 MCP server registered with Claude Code (claude mcp add vibeview)"));
3046
+ console.log(chalk19.green("\u2713 MCP server registered with Claude Code (claude mcp add vibeview)"));
3056
3047
  return;
3057
3048
  }
3058
3049
  const out = `${res.stderr ?? ""}${res.stdout ?? ""}`;
3059
3050
  if (out.includes("already exists")) {
3060
- console.log(chalk20.dim("MCP server already registered with Claude Code \u2014 nothing to do"));
3051
+ console.log(chalk19.dim("MCP server already registered with Claude Code \u2014 nothing to do"));
3061
3052
  return;
3062
3053
  }
3063
- console.error(chalk20.yellow(`\u26A0 could not register the MCP server: ${out.trim() || res.error}`));
3064
- console.error(chalk20.yellow(" register it manually: claude mcp add vibeview -- vibeview mcp"));
3054
+ console.error(chalk19.yellow(`\u26A0 could not register the MCP server: ${out.trim() || res.error}`));
3055
+ console.error(chalk19.yellow(" register it manually: claude mcp add vibeview -- vibeview mcp"));
3065
3056
  }
3066
3057
  async function agentSetup(opts) {
3067
3058
  const moduleDir = dirname(fileURLToPath(import.meta.url));
3068
3059
  const shipped = resolveShippedSkillPath(moduleDir);
3069
3060
  if (!shipped) {
3070
3061
  console.error(
3071
- chalk20.red(
3062
+ chalk19.red(
3072
3063
  "error: could not find the packaged skill (skills/vibeview-agent/SKILL.md) \u2014 is this a complete install of the vibeview package?"
3073
3064
  )
3074
3065
  );
@@ -3077,9 +3068,9 @@ async function agentSetup(opts) {
3077
3068
  }
3078
3069
  const targetDir = skillInstallDir(opts.project ?? false, process.cwd(), homedir());
3079
3070
  const result = installSkill(shipped, targetDir);
3080
- console.log(chalk20.green(`\u2713 vibeview-agent skill ${result}: ${join2(targetDir, "SKILL.md")}`));
3071
+ console.log(chalk19.green(`\u2713 vibeview-agent skill ${result}: ${join2(targetDir, "SKILL.md")}`));
3081
3072
  console.log(
3082
- chalk20.dim(
3073
+ chalk19.dim(
3083
3074
  opts.project ? " Claude Code discovers it automatically for anyone working in this project." : " Claude Code discovers it automatically in every project on this machine."
3084
3075
  )
3085
3076
  );
@@ -3087,7 +3078,7 @@ async function agentSetup(opts) {
3087
3078
  if (!claudeCliAvailable()) {
3088
3079
  if (opts.mcp === true) {
3089
3080
  console.error(
3090
- chalk20.yellow(
3081
+ chalk19.yellow(
3091
3082
  "\u26A0 claude CLI not found \u2014 install Claude Code, then run: claude mcp add vibeview -- vibeview mcp"
3092
3083
  )
3093
3084
  );
@@ -3101,7 +3092,7 @@ async function agentSetup(opts) {
3101
3092
  }
3102
3093
  console.log(
3103
3094
  `
3104
- Suggested line for your project's ${chalk20.bold("CLAUDE.md")} (or ${chalk20.bold(
3095
+ Suggested line for your project's ${chalk19.bold("CLAUDE.md")} (or ${chalk19.bold(
3105
3096
  "AGENTS.md"
3106
3097
  )} for other agents):
3107
3098
  `
@@ -3109,7 +3100,7 @@ Suggested line for your project's ${chalk20.bold("CLAUDE.md")} (or ${chalk20.bol
3109
3100
  console.log(` ${CLAUDE_MD_SUGGESTION}
3110
3101
  `);
3111
3102
  console.log(
3112
- chalk20.dim(
3103
+ chalk19.dim(
3113
3104
  "Other agents (Cursor, Copilot, Codex, ...): install the same skill from GitHub with\n npx skills add vibeview/skills\nor point them at the skill file above, or add the MCP server per client \u2014\nsee https://vibeview.io/docs/agent-control"
3114
3105
  )
3115
3106
  );
@@ -3231,10 +3222,14 @@ program.command("list-suites").description("List available test suites").option(
3231
3222
  []
3232
3223
  ).option("--match <mode>", "When multiple --tag flags: any (default) or all", "any").action((opts) => listSuites(opts));
3233
3224
  program.command("list-apps").description("List apps in your organization").option("--token <token>", "API token").option("--output <format>", "Output format: human, json", "human").action((opts) => listApps(opts));
3234
- program.command("list-devices").description("List devices registered with the platform (filterable by type and category)").option("--token <token>", "API token").option("--output <format>", "Output format: human, json", "human").option("--type <type>", "Filter by device type: ios, android, roku").option("--category <category>", "Filter by category: phone, tablet, tv").option("--status <status>", "Filter by status: available, busy, offline").action((opts) => listDevicesCommand(opts));
3225
+ program.command("list-devices").description("List devices registered with the platform (filterable by type and category)").option("--token <token>", "API token").option("--output <format>", "Output format: human, json", "human").option("--type <type>", "Filter by device type: ios, android, roku").option("--category <category>", "Filter by category: phone, tablet, tv").option("--status <status>", "Filter by status: available, busy, offline").option(
3226
+ "--models",
3227
+ "List the device models you can start instead (the names `dev --model` takes)",
3228
+ false
3229
+ ).action((opts) => listDevicesCommand(opts));
3235
3230
  program.command("upload-app <file>").description(
3236
3231
  "Upload an app build (zipped simulator .app for iOS/tvOS, .apk for Android/Android TV, or a Roku channel .zip)"
3237
- ).option("--token <token>", "API token").option("--output <format>", "Output format: human, json", "human").option("--name <name>", "App name (auto-detected if not provided)").action((file, opts) => uploadApp(file, opts));
3232
+ ).option("--token <token>", "API token").option("--output <format>", "Output format: human, json", "human").option("--name <name>", "App name (auto-detected if not provided)").option("--note <text>", "Note stored on the uploaded build (shown in the dashboard)").action((file, opts) => uploadApp(file, opts));
3238
3233
  program.command("create-test [name]").description("Create a standalone AI test case").option("--token <token>", "API token").option("--output <format>", "Output format: human, json", "human").option("--app <app>", "App ID (public_id) or package name (requires --platform)").option("--platform <platform>", "Platform (ios, android) when using package name for --app").option("--steps <json>", "Test steps as inline JSON array").option("--steps-file <path>", "Path to JSON file containing test steps").option("--context <text>", "Additional context for AI test execution").option("--test-name <name>", "Test case name (alternative to positional arg)").action((name, opts) => createTest({ ...opts, testName: name || opts.testName || "CLI Test" }));
3239
3234
  program.command("login").description("Log in to VibeView (interactive)").option("--base-url <url>", "VibeView server URL (defaults to https://vibeview.io)").action((opts) => login(opts));
3240
3235
  program.command("logout").description("Revoke the saved login token and remove it from ~/.vibeview/config.json").action(() => logout());
@@ -3247,6 +3242,9 @@ program.command("dev").description(
3247
3242
  "--device-category <category>",
3248
3243
  "phone or tablet (default: phone; TV platforms always use tv)",
3249
3244
  parseDeviceCategory
3245
+ ).option(
3246
+ "--model <name>",
3247
+ 'Run on this exact device model, e.g. "iPhone Duo" (see `vibeview list-devices --models`)'
3250
3248
  ).option("--no-open", "Do not open the browser automatically").option("--debug", "Log raw tunnel lifecycle events (close codes, reconnects) for diagnostics").option("--json", "Emit line-delimited JSON events on stdout instead of human output", false).option(
3251
3249
  "--detach",
3252
3250
  "Run the dev session in the background; prints session info and exits (see `dev-status`, `dev-stop`, and `dev-reload` for Roku)",
@@ -3395,7 +3393,7 @@ program.command("dev-reload").description(
3395
3393
  program.command("mcp").description(
3396
3394
  "Run an MCP stdio server exposing the agent verbs (tap, swipe, type, ...) plus dev_start/dev_stop/dev_reload as tools"
3397
3395
  ).option("--token <token>", "API token (or set VIBEVIEW_API_TOKEN)").action(async (opts) => {
3398
- const { runMcpServer } = await import("./server-6IJ44HAX.js");
3396
+ const { runMcpServer } = await import("./server-KIJCEN6T.js");
3399
3397
  await runMcpServer(opts);
3400
3398
  });
3401
3399
  program.command("agent-setup").description(
@@ -9,16 +9,18 @@ import {
9
9
  checkMetro,
10
10
  createRokuPush,
11
11
  decodeScreenshotResult,
12
+ filterDeviceModels,
13
+ formatDeviceModelsHuman,
12
14
  isDevPlatform,
13
15
  loadDevConfig,
14
16
  readDevState,
15
17
  renderEnvelope,
16
18
  startDevSession
17
- } from "./chunk-SHS5PCSS.js";
19
+ } from "./chunk-MVNMGB6I.js";
18
20
  import {
19
21
  ApiClient,
20
22
  resolveAuth
21
- } from "./chunk-UGCGJ6IN.js";
23
+ } from "./chunk-2WNJYNDW.js";
22
24
 
23
25
  // src/mcp/server.ts
24
26
  import { writeFileSync } from "fs";
@@ -78,10 +80,27 @@ var DEV_START_DEF = {
78
80
  metro_port: {
79
81
  type: "number",
80
82
  description: "Local Metro port (default: 8081, or vibeview.json)."
83
+ },
84
+ model: {
85
+ type: "string",
86
+ description: 'Run on this exact device model, e.g. "iPhone Duo" for the foldable. Names come from list_device_models. Omit for a default phone (or TV on TV platforms).'
81
87
  }
82
88
  },
83
89
  required: ["platform"]
84
90
  };
91
+ var LIST_DEVICE_MODELS_DEF = {
92
+ name: "list_device_models",
93
+ description: 'List the device models you can start right now (model, OS version, platform, category, and form_factor "foldable" for a device that folds, like the iPhone Duo). Pass a model name to dev_start to run on it.',
94
+ mutating: false,
95
+ properties: {
96
+ device_type: {
97
+ type: "string",
98
+ description: "Only models of this device type.",
99
+ enum: ["ios", "android", "roku"]
100
+ }
101
+ },
102
+ required: []
103
+ };
85
104
  var DEV_STOP_DEF = {
86
105
  name: "dev_stop",
87
106
  description: "Stop the dev-loop session started by dev_start.",
@@ -118,7 +137,8 @@ function buildToolDefinitions() {
118
137
  DEV_START_DEF,
119
138
  DEV_STOP_DEF,
120
139
  DEV_RELOAD_DEF,
121
- UPLOAD_APP_DEF
140
+ UPLOAD_APP_DEF,
141
+ LIST_DEVICE_MODELS_DEF
122
142
  ];
123
143
  }
124
144
  function textResult(text, structuredContent, isError = false) {
@@ -143,6 +163,9 @@ async function handleToolCall(toolName, args, deps) {
143
163
  if (toolName === "upload_app") {
144
164
  return handleUploadApp(args, deps);
145
165
  }
166
+ if (toolName === "list_device_models") {
167
+ return handleListDeviceModels(args, deps);
168
+ }
146
169
  const cmd = findCommand(toolName);
147
170
  if (!cmd) {
148
171
  return errorResult(`Unknown tool: ${toolName}`);
@@ -226,7 +249,8 @@ async function handleDevStart(args, deps) {
226
249
  const result = await deps.devSession.start({
227
250
  platform,
228
251
  app: typeof args.app === "string" ? args.app : void 0,
229
- metroPort: typeof args.metro_port === "number" ? args.metro_port : void 0
252
+ metroPort: typeof args.metro_port === "number" ? args.metro_port : void 0,
253
+ model: typeof args.model === "string" && args.model.trim() ? args.model : void 0
230
254
  });
231
255
  const payload = { session_id: result.sessionId, page_url: result.pageUrl };
232
256
  return textResult(`dev session started: ${payload.session_id}
@@ -235,6 +259,18 @@ ${payload.page_url}`, payload);
235
259
  return errorResult(`error: ${err instanceof Error ? err.message : String(err)}`);
236
260
  }
237
261
  }
262
+ async function handleListDeviceModels(args, deps) {
263
+ const list = deps.listDeviceModels ?? (() => new ApiClient(deps.auth.baseUrl, deps.auth.token).listDeviceModels());
264
+ let models;
265
+ try {
266
+ models = filterDeviceModels(await list(), {
267
+ type: typeof args.device_type === "string" ? args.device_type : void 0
268
+ });
269
+ } catch (err) {
270
+ return errorResult(`error: ${err instanceof Error ? err.message : String(err)}`);
271
+ }
272
+ return textResult(formatDeviceModelsHuman(models).trimEnd(), { models });
273
+ }
238
274
  async function handleDevReload(args, deps) {
239
275
  const requested = typeof args.session_id === "string" ? args.session_id : void 0;
240
276
  try {
@@ -265,7 +301,7 @@ async function handleUploadApp(args, deps) {
265
301
  }
266
302
  const name = typeof args.name === "string" && args.name ? args.name : void 0;
267
303
  const upload = deps.uploadApp ?? (async (f, n) => {
268
- const { uploadAppBuild } = await import("./upload-app-LJH3PETS.js");
304
+ const { uploadAppBuild } = await import("./upload-app-BXXKH65S.js");
269
305
  const { result: result2 } = await uploadAppBuild(f, { ...deps.auth, name: n });
270
306
  return result2;
271
307
  });
@@ -283,11 +319,17 @@ async function handleUploadApp(args, deps) {
283
319
  package_name: result.package_name,
284
320
  platform: result.platform,
285
321
  is_new: isNew,
322
+ build_id: result.build_id ?? null,
323
+ version: result.version ?? null,
324
+ build_number: result.build_number ?? null,
286
325
  warnings
287
326
  };
288
327
  const lines = [
289
328
  `${isNew ? "New app created" : "Build added to existing app"}: ${result.name}`,
290
329
  `app_id: ${result.public_id} [${result.platform}] ${result.package_name}`,
330
+ ...result.build_id != null ? [
331
+ `build_id: ${result.build_id}${result.version ? ` version ${result.version}${result.build_number ? ` (${result.build_number})` : ""}` : ""}`
332
+ ] : [],
291
333
  ...warnings.map((w) => `warning: ${w}`)
292
334
  ];
293
335
  return textResult(lines.join("\n"), payload);
@@ -348,6 +390,8 @@ function createDevSessionController(auth, webUrl) {
348
390
  platform: cfg.platform,
349
391
  appId,
350
392
  deviceCategory: isTv ? "tv" : entry?.deviceCategory ?? "phone",
393
+ // A named model decides the category itself (startDevSession).
394
+ model: cfg.model,
351
395
  metroPort,
352
396
  open: false
353
397
  });
@@ -1,9 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ describeBuild,
3
4
  uploadApp,
4
5
  uploadAppBuild
5
- } from "./chunk-UGCGJ6IN.js";
6
+ } from "./chunk-2WNJYNDW.js";
6
7
  export {
8
+ describeBuild,
7
9
  uploadApp,
8
10
  uploadAppBuild
9
11
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibeview",
3
- "version": "0.22.0",
3
+ "version": "0.24.0",
4
4
  "description": "VibeView CLI -- run mobile tests from CI/CD pipelines",
5
5
  "mcpName": "io.github.vibeview/vibeview",
6
6
  "type": "module",
@@ -156,6 +156,24 @@ it rather than letting it appear unexplained in their `git status`.
156
156
  (`dpad_up`/`dpad_down`/`dpad_left`/`dpad_right`/`dpad_center`), or move
157
157
  focus directly with `tap-focused` (moves focus AND activates) / `focus`
158
158
  (moves focus only, no activation).
159
+ - **Foldable (iPhone Duo)**: start on it by model name —
160
+ `vibeview dev --detach --json --model "iPhone Duo"` (MCP: `dev_start` with
161
+ `model`). Exact names come from `vibeview list-devices --models` (MCP:
162
+ `list_device_models`); a foldable is marked `foldable`. It starts closed, on
163
+ the cover screen. Fold with a preset or an exact hinge angle, and rotate:
164
+ ```bash
165
+ vibeview set-posture open --session <id> # closed | partial | open
166
+ vibeview set-posture --angle 75 --session <id> # 0 (shut) to 180 (flat)
167
+ vibeview rotate --session <id> # quarter turn clockwise
168
+ vibeview rotate --degrees 270 --session <id> # quarter turn back (180: half)
169
+ ```
170
+ Each response says where the device ended up — posture, angle and which
171
+ screen is lit (`cover` or `inner`; the device decides — a small angle can
172
+ keep the inner screen lit on the way down), or the new orientation. Folding
173
+ switches screens of different sizes and rotating turns the picture, so run
174
+ `ui-tree` afterwards — old refs are stale. On other phones and tablets
175
+ `rotate` toggles portrait/landscape (only 90 is accepted); TV devices don't
176
+ rotate.
159
177
  - **Roku channels (beta)**: start the session with
160
178
  `vibeview dev --platform roku --detach` (or `dev_start` with platform
161
179
  `roku` over MCP); after an edit run `vibeview dev-reload` (MCP:
@@ -220,6 +238,8 @@ human-readable text.
220
238
  | `clear-text` | — | Clear the text in the currently focused input field. |
221
239
  | `press` | `<button>` | Press a device button or perform a system gesture (home, back, d-pad, etc). |
222
240
  | `open-url` | `<url>` | Open a deep link or URL in the app under test. |
241
+ | `set-posture` | `<closed\|partial\|open>` or `--angle <0-180>` | Fold or unfold a foldable device (iPhone Duo) to a preset or an exact hinge angle — exactly one. Reports posture, angle and lit screen. Errors on a device without a hinge. |
242
+ | `rotate` | `[--degrees <90\|180\|270>]` | Rotate the device and report the new orientation. Phones/tablets toggle portrait/landscape (90 only); a foldable turns a quarter clockwise by default. Not on TV. |
223
243
  | `wait` | `[--ms <n>]` | Wait for a specified number of seconds before continuing (default: 2s). |
224
244
  | `find` | `<text>` `[--below <text>] [--above <text>] [--near <text>]` | Find an element by text with optional spatial constraints. |
225
245
  | `tap-focused` | `<ref>` | TV: move focus to an element and press SELECT in one step. |
@@ -229,7 +249,8 @@ Dev-loop lifecycle commands (not registry verbs, but needed for every run):
229
249
 
230
250
  | Command | Purpose |
231
251
  |---------|---------|
232
- | `dev --detach --json` | Start a device + Metro-tunnel session in the background, emitting `session_ready`/`warning`/`error` JSON events on stdout. |
252
+ | `dev --detach --json` | Start a device + Metro-tunnel session in the background, emitting `session_ready`/`warning`/`error` JSON events on stdout. Add `--model "<name>"` to pick an exact device model. |
253
+ | `list-devices --models` | List the device models you can start (the names `--model` takes); foldables are marked `foldable`. |
233
254
  | `dev-status` | Check on the current project's detached dev session. |
234
255
  | `dev-stop` | End the current project's detached dev session. |
235
256
  | `dev-reload` | Roku only: re-package, upload and restart the channel in the detached session (the `r` key of the foreground loop). |
@@ -244,13 +265,14 @@ hyphens replaced by underscores (`ui_tree`, `tap`, `scroll_to`, `long_press`,
244
265
  `drag {from: "@e5", to: "300,400"}`, `alert {action: "get"}`) plus an
245
266
  optional `session_id`.
246
267
 
247
- Three tools exist **only** over MCP, with no registry verb — they cover the
268
+ These tools exist **only** over MCP, with no registry verb — they cover the
248
269
  steps the CLI does with plain shell commands:
249
270
 
250
271
  | MCP tool | Args | Purpose |
251
272
  |----------|------|---------|
252
273
  | `upload_app` | `file` (required path), `name` | Upload a debug build. 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`. |
253
- | `dev_start` | `platform` (required: `ios`/`android`/`tvos`/`androidtv`), `app`, `metro_port` | Start a dev-loop session held open by the MCP server. The MCP equivalent of `vibeview dev --detach`. Returns `page_url` — relay it to the developer immediately, same as step 5 of the loop. |
274
+ | `dev_start` | `platform` (required: `ios`/`android`/`tvos`/`androidtv`/`roku`), `app`, `metro_port`, `model` | Start a dev-loop session held open by the MCP server. The MCP equivalent of `vibeview dev --detach`; `model` picks an exact device model (e.g. `"iPhone Duo"`). Returns `page_url` — relay it to the developer immediately, same as step 5 of the loop. |
275
+ | `list_device_models` | `device_type` | List the device models you can start (model, OS, platform, category, `foldable` marker). The MCP equivalent of `vibeview list-devices --models`. |
254
276
  | `dev_stop` | — | Stop the session `dev_start` started. The MCP equivalent of `vibeview dev-stop`. |
255
277
 
256
278
  **`dev_start` is stateful — it changes the default session for every later