vibeview 0.22.0 → 0.23.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.
@@ -6,7 +6,7 @@ import {
6
6
  loadConfig,
7
7
  resolveAuth,
8
8
  uploadAppBuild
9
- } from "./chunk-UGCGJ6IN.js";
9
+ } from "./chunk-PSFXVUHG.js";
10
10
 
11
11
  // src/lib/dev-state.ts
12
12
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
@@ -2060,7 +2060,10 @@ async function startDevSession(cfg, onEvent = () => {
2060
2060
  }, onTunnelState) {
2061
2061
  const api = new ApiClient(cfg.baseUrl, cfg.token);
2062
2062
  const usesTunnel = PLATFORM_NEEDS_METRO[cfg.platform];
2063
- const created = await api.createSession(devSessionCreateBody(cfg));
2063
+ const pool = cfg.model ? resolveDeviceModel(await api.listDeviceModels(), cfg.platform, cfg.model) : null;
2064
+ const created = await api.createSession(
2065
+ devSessionCreateBody(pool ? { ...cfg, deviceCategory: pool.category, model: pool.model } : cfg)
2066
+ );
2064
2067
  const sessionId = created.session_id;
2065
2068
  let stopping = false;
2066
2069
  let tunnel = null;
@@ -2154,9 +2157,23 @@ function devSessionCreateBody(cfg) {
2154
2157
  device_category: cfg.deviceCategory,
2155
2158
  app_id: cfg.appId,
2156
2159
  // Roku has no JS bundle to serve, so there is nothing to tunnel.
2157
- tunnel: PLATFORM_NEEDS_METRO[cfg.platform]
2160
+ tunnel: PLATFORM_NEEDS_METRO[cfg.platform],
2161
+ ...cfg.model ? { model: cfg.model } : {}
2158
2162
  };
2159
2163
  }
2164
+ function resolveDeviceModel(pools, platform, model) {
2165
+ const { deviceType, tv } = PLATFORM_DEVICE[platform];
2166
+ const candidates = pools.filter(
2167
+ (p) => p.device_type === deviceType && p.category === "tv" === tv
2168
+ );
2169
+ const wanted = model.trim();
2170
+ const match = candidates.find((p) => p.model === wanted) ?? candidates.find((p) => p.model.toLowerCase() === wanted.toLowerCase());
2171
+ if (match) return match;
2172
+ const names = [...new Set(candidates.map((p) => p.model))].sort();
2173
+ throw new Error(
2174
+ names.length ? `no ${platform} device model named "${model}" \u2014 available: ${names.join(", ")} (see \`vibeview list-devices --models\`)` : `no ${platform} devices are available right now`
2175
+ );
2176
+ }
2160
2177
  var BLOCKING_WARNING_PREFIXES = ["No debug build", "App install failed", "App not found"];
2161
2178
  function findBlockingWarning(info) {
2162
2179
  return (info.warnings ?? []).find((w) => BLOCKING_WARNING_PREFIXES.some((p) => w.includes(p)));
@@ -2296,6 +2313,16 @@ async function ensureConfig(api, opts) {
2296
2313
  console.error(chalk3.red(`error: use --platform tvos or --platform androidtv for TV devices`));
2297
2314
  process.exit(2);
2298
2315
  }
2316
+ if (opts.model !== void 0 && opts.deviceCategory) {
2317
+ console.error(chalk3.red("error: pass --model or --device-category, not both"));
2318
+ process.exit(2);
2319
+ }
2320
+ if (opts.model !== void 0 && !opts.model.trim()) {
2321
+ console.error(
2322
+ chalk3.red("error: --model needs a model name (see `vibeview list-devices --models`)")
2323
+ );
2324
+ process.exit(2);
2325
+ }
2299
2326
  let firstRunBuild = null;
2300
2327
  let firstRunMetro = null;
2301
2328
  let isFirstRun = false;
@@ -2430,6 +2457,7 @@ Run \`vibeview dev\` without --app to pick from the list.`
2430
2457
  platform,
2431
2458
  appId,
2432
2459
  deviceCategory,
2460
+ model: opts.model,
2433
2461
  metroPort: finalMetro.port,
2434
2462
  metroCommand: finalMetro.command,
2435
2463
  platformHasMetroOverride: finalMetro.platformHasOverride,
@@ -2573,6 +2601,7 @@ async function runDevInProcess(opts) {
2573
2601
  platform: cfg.platform,
2574
2602
  appId: cfg.appId,
2575
2603
  deviceCategory: cfg.deviceCategory,
2604
+ model: cfg.model,
2576
2605
  metroPort: cfg.metroPort ?? 8081,
2577
2606
  // startDevSession's own browser-open happens before we've written the
2578
2607
  // state file / emitted session_ready — open it ourselves afterward
@@ -2820,6 +2849,55 @@ async function dev(opts) {
2820
2849
  if (process.exitCode === 1) return;
2821
2850
  }
2822
2851
 
2852
+ // src/lib/formatters/devices.ts
2853
+ import chalk4 from "chalk";
2854
+ var HEADERS = ["DEVICE_ID", "NAME", "TYPE", "CATEGORY", "OS", "STATUS"];
2855
+ function formatDevicesHuman(devices) {
2856
+ if (devices.length === 0) {
2857
+ return "No devices registered.\n";
2858
+ }
2859
+ const rows = devices.map((d) => [
2860
+ d.device_id,
2861
+ d.name,
2862
+ d.type,
2863
+ d.category,
2864
+ d.os_version,
2865
+ d.status
2866
+ ]);
2867
+ const widths = HEADERS.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
2868
+ const fmt = (cells) => cells.map((c, i) => c.padEnd(widths[i])).join(" ").trimEnd();
2869
+ const lines = [chalk4.bold(fmt(HEADERS))];
2870
+ for (const row of rows.sort(rowSort)) {
2871
+ const statusColored = row[5] === "available" ? chalk4.green(row[5]) : row[5] === "busy" ? chalk4.yellow(row[5]) : chalk4.dim(row[5]);
2872
+ lines.push(fmt([row[0], row[1], row[2], row[3], row[4], statusColored]));
2873
+ }
2874
+ return lines.join("\n") + "\n";
2875
+ }
2876
+ function rowSort(a, b) {
2877
+ return a[2].localeCompare(b[2]) || a[3].localeCompare(b[3]) || a[0].localeCompare(b[0]);
2878
+ }
2879
+ function filterDeviceModels(models, opts) {
2880
+ return models.filter((m) => !opts.type || m.device_type === opts.type).filter((m) => !opts.category || m.category === opts.category).map((m) => ({
2881
+ model: m.model,
2882
+ os_version: m.os_version,
2883
+ device_type: m.device_type,
2884
+ category: m.category,
2885
+ form_factor: m.form_factor ?? null
2886
+ }));
2887
+ }
2888
+ var MODEL_HEADERS = ["MODEL", "OS", "TYPE", "CATEGORY", "FORM FACTOR"];
2889
+ function formatDeviceModelsHuman(models) {
2890
+ if (models.length === 0) {
2891
+ return "No device models available right now.\n";
2892
+ }
2893
+ const rows = models.map((m) => [m.model, m.os_version, m.device_type, m.category, m.form_factor ?? "-"]).sort(
2894
+ (a, b) => a[2].localeCompare(b[2]) || a[3].localeCompare(b[3]) || a[0].localeCompare(b[0]) || a[1].localeCompare(b[1])
2895
+ );
2896
+ const widths = MODEL_HEADERS.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
2897
+ const fmt = (cells) => cells.map((c, i) => c.padEnd(widths[i])).join(" ").trimEnd();
2898
+ return [chalk4.bold(fmt(MODEL_HEADERS)), ...rows.map(fmt)].join("\n") + "\n";
2899
+ }
2900
+
2823
2901
  // src/agent/registry.ts
2824
2902
  var AGENT_COMMANDS = [
2825
2903
  {
@@ -3135,6 +3213,52 @@ var AGENT_COMMANDS = [
3135
3213
  }
3136
3214
  ]
3137
3215
  },
3216
+ {
3217
+ // set_posture: { posture: 'closed' | 'partial' | 'open' } OR { angle: 0..180 }
3218
+ // — exactly one (buildToolCall enforces it CLI-side, agent_control.py's
3219
+ // validate_tool_args at the gate). The angle is a settle-once fold, the
3220
+ // same as the dashboard slider's release, not its live drag stream.
3221
+ // Foldable devices only.
3222
+ verb: "set-posture",
3223
+ tool: "set_posture",
3224
+ 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.",
3225
+ mutating: true,
3226
+ params: [
3227
+ {
3228
+ name: "posture",
3229
+ cli: "positional",
3230
+ type: "string",
3231
+ required: false,
3232
+ description: "One of 'closed', 'partial', 'open'. Omit when passing an angle."
3233
+ },
3234
+ {
3235
+ name: "angle",
3236
+ cli: "--angle",
3237
+ type: "number",
3238
+ required: false,
3239
+ description: "Exact hinge angle in whole degrees, 0 (shut) to 180 (flat open). Omit when passing a posture."
3240
+ }
3241
+ ]
3242
+ },
3243
+ {
3244
+ // rotate: { degrees?: 90 | 180 | 270 } (default 90). The same control as
3245
+ // the dashboard's Rotate button. A foldable turns its body by quarter
3246
+ // turns (90 clockwise, 270 back, 180 a half turn); a rigid phone toggles
3247
+ // portrait <-> landscape and accepts only 90. Refused on TV devices.
3248
+ verb: "rotate",
3249
+ tool: "rotate",
3250
+ 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.",
3251
+ mutating: true,
3252
+ params: [
3253
+ {
3254
+ name: "degrees",
3255
+ cli: "--degrees",
3256
+ type: "number",
3257
+ required: false,
3258
+ description: "Foldable only: 90 (default, a quarter turn clockwise), 180, or 270 (a quarter turn back). A phone accepts only 90."
3259
+ }
3260
+ ]
3261
+ },
3138
3262
  {
3139
3263
  // tools.py wait: { seconds?: number } (default: 2). Note the wire arg is
3140
3264
  // "seconds", NOT "ms" — tool_dispatch.py reads args.get("seconds", 2)
@@ -3226,18 +3350,18 @@ var AGENT_COMMANDS = [
3226
3350
  ];
3227
3351
 
3228
3352
  // src/agent/render.ts
3229
- import chalk4 from "chalk";
3353
+ import chalk5 from "chalk";
3230
3354
  function renderEnvelope(env) {
3231
3355
  const lines = [];
3232
3356
  if (env.ok) {
3233
- lines.push(chalk4.green(`ok`) + chalk4.dim(` (${env.tool})`));
3357
+ lines.push(chalk5.green(`ok`) + chalk5.dim(` (${env.tool})`));
3234
3358
  } else {
3235
- lines.push(chalk4.red(`error`) + chalk4.dim(` (${env.tool})`));
3359
+ lines.push(chalk5.red(`error`) + chalk5.dim(` (${env.tool})`));
3236
3360
  }
3237
3361
  lines.push(...resultLines(env.result));
3238
3362
  if (env.observation) {
3239
3363
  if (env.observation.kind === "unavailable") {
3240
- lines.push(chalk4.yellow(`warning: ${env.observation.text}`));
3364
+ lines.push(chalk5.yellow(`warning: ${env.observation.text}`));
3241
3365
  } else {
3242
3366
  lines.push(env.observation.text);
3243
3367
  }
@@ -3250,17 +3374,17 @@ function resultLines(result) {
3250
3374
  const lines = [];
3251
3375
  if (Array.isArray(r.entries) && typeof r.cursor === "number") {
3252
3376
  if (r.entries.length === 0) {
3253
- lines.push(chalk4.dim("(no log lines)"));
3377
+ lines.push(chalk5.dim("(no log lines)"));
3254
3378
  }
3255
3379
  for (const e of r.entries) {
3256
3380
  if (!e || typeof e !== "object") continue;
3257
3381
  const entry = e;
3258
3382
  const ts = typeof entry.timestamp === "number" ? new Date(entry.timestamp).toISOString().slice(11, 23) : "??:??:??.???";
3259
3383
  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 ?? "")}`);
3384
+ const levelTag = level === "error" ? chalk5.red(level) : level === "warn" ? chalk5.yellow(level) : chalk5.dim(level);
3385
+ lines.push(`${chalk5.dim(ts)} ${levelTag} ${String(entry.message ?? "")}`);
3262
3386
  }
3263
- lines.push(chalk4.dim(`cursor: ${r.cursor}`));
3387
+ lines.push(chalk5.dim(`cursor: ${r.cursor}`));
3264
3388
  }
3265
3389
  if (r.found === true && typeof r.ref === "string" && r.ref) {
3266
3390
  let found = `found: @${r.ref}`;
@@ -3272,14 +3396,43 @@ function resultLines(result) {
3272
3396
  const text = typeof a.text === "string" && a.text ? ` ${JSON.stringify(a.text)}` : "";
3273
3397
  return `${ref}${text}`;
3274
3398
  });
3275
- if (alts.length > 0) lines.push(chalk4.dim(`alternatives: ${alts.join(", ")}`));
3399
+ if (alts.length > 0) lines.push(chalk5.dim(`alternatives: ${alts.join(", ")}`));
3276
3400
  }
3277
3401
  }
3278
- if (typeof r.note === "string" && r.note) lines.push(chalk4.dim(r.note));
3402
+ if (r.action === "posture_set" || r.action === "rotated") {
3403
+ const state = deviceStateLine(r);
3404
+ if (state) lines.push(state);
3405
+ }
3406
+ if (typeof r.note === "string" && r.note) lines.push(chalk5.dim(r.note));
3279
3407
  if (typeof r.message === "string" && r.message) lines.push(r.message);
3280
3408
  if (typeof r.warning === "string" && r.warning) lines.push(r.warning);
3281
3409
  return lines;
3282
3410
  }
3411
+ function deviceStateLine(r) {
3412
+ const parts = [];
3413
+ if (r.action === "rotated") {
3414
+ if (typeof r.device_orientation === "string") {
3415
+ let o = `orientation: ${r.device_orientation}`;
3416
+ if (typeof r.screen_orientation === "string" && r.screen_orientation !== r.device_orientation) {
3417
+ o += ` (screen shows ${r.screen_orientation})`;
3418
+ }
3419
+ parts.push(o);
3420
+ }
3421
+ }
3422
+ if (typeof r.posture === "string") {
3423
+ let p = `posture: ${r.posture}`;
3424
+ if (typeof r.angle === "number") p += ` at ${r.angle}\xB0`;
3425
+ const extras = [];
3426
+ if (typeof r.screen === "string") extras.push(`${r.screen} screen`);
3427
+ const d = r.display;
3428
+ if (d && typeof d.width === "number" && typeof d.height === "number") {
3429
+ extras.push(`${d.width}x${d.height}`);
3430
+ }
3431
+ if (extras.length) p += ` (${extras.join(", ")})`;
3432
+ parts.push(p);
3433
+ }
3434
+ return parts.length ? parts.join("; ") : null;
3435
+ }
3283
3436
  function agentExitCode(env) {
3284
3437
  return env.ok ? 0 : 1;
3285
3438
  }
@@ -3401,6 +3554,15 @@ function buildToolCall(cmd, positionals, opts) {
3401
3554
  }
3402
3555
  return { tool: cmd.tool, args: args2 };
3403
3556
  }
3557
+ if (cmd.verb === "set-posture") {
3558
+ const hasPosture = positionals[0] !== void 0 && positionals[0] !== "";
3559
+ const hasAngle = opts.angle !== void 0 && opts.angle !== "";
3560
+ if (hasPosture === hasAngle) {
3561
+ throw new FlagError(
3562
+ hasPosture ? "set-posture takes a posture or --angle, not both" : "set-posture requires a posture (closed, partial, open) or --angle <0-180>"
3563
+ );
3564
+ }
3565
+ }
3404
3566
  const args = {};
3405
3567
  let positionalIndex = 0;
3406
3568
  for (const param of cmd.params) {
@@ -3526,6 +3688,9 @@ export {
3526
3688
  ROKU_LIVENESS_INTERVAL_MS,
3527
3689
  PLATFORM_DEVICE,
3528
3690
  dev,
3691
+ formatDevicesHuman,
3692
+ filterDeviceModels,
3693
+ formatDeviceModelsHuman,
3529
3694
  AGENT_COMMANDS,
3530
3695
  renderEnvelope,
3531
3696
  buildToolCall,
@@ -238,6 +238,10 @@ var ApiClient = class {
238
238
  }
239
239
  return all;
240
240
  }
241
+ /** The startable device models (see `DeviceModelPool`). */
242
+ async listDeviceModels() {
243
+ return this.requestRaw("GET", "/devices/pools");
244
+ }
241
245
  async getDevice(deviceId) {
242
246
  return this.requestRaw("GET", `/devices/${deviceId}`);
243
247
  }
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-E2LZSLRF.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-PSFXVUHG.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,7 +3222,11 @@ 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
3232
  ).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));
@@ -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-XMSBLIWY.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-E2LZSLRF.js";
18
20
  import {
19
21
  ApiClient,
20
22
  resolveAuth
21
- } from "./chunk-UGCGJ6IN.js";
23
+ } from "./chunk-PSFXVUHG.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-ZAKAOJKE.js");
269
305
  const { result: result2 } = await uploadAppBuild(f, { ...deps.auth, name: n });
270
306
  return result2;
271
307
  });
@@ -348,6 +384,8 @@ function createDevSessionController(auth, webUrl) {
348
384
  platform: cfg.platform,
349
385
  appId,
350
386
  deviceCategory: isTv ? "tv" : entry?.deviceCategory ?? "phone",
387
+ // A named model decides the category itself (startDevSession).
388
+ model: cfg.model,
351
389
  metroPort,
352
390
  open: false
353
391
  });
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  uploadApp,
4
4
  uploadAppBuild
5
- } from "./chunk-UGCGJ6IN.js";
5
+ } from "./chunk-PSFXVUHG.js";
6
6
  export {
7
7
  uploadApp,
8
8
  uploadAppBuild
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibeview",
3
- "version": "0.22.0",
3
+ "version": "0.23.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