@newly/cli 0.1.2 → 0.1.91

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.
@@ -1,47 +1,45 @@
1
- import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
2
- import { basename, join, resolve } from 'node:path';
1
+ import { Option } from 'commander';
2
+ import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
+ import { basename, dirname, join, resolve } from 'node:path';
3
4
  import { homedir } from 'node:os';
5
+ import { fileURLToPath, pathToFileURL } from 'node:url';
6
+ import { createHash } from 'node:crypto';
4
7
  import { ApiClient, friendlyApiError } from '../api.js';
5
8
  import { requireCredentials } from '../config.js';
6
- const RUN_CLOUD_SKILL = `---
7
- name: run-cloud-ios-simulator
8
- description: Build, install, and inspect iOS apps with run.cloud remote simulator sessions.
9
- version: 0.1.0
10
- ---
11
-
12
- # run.cloud iOS Simulator
13
-
14
- Use this skill when a user asks an agent to run, inspect, smoke test, or debug an iOS app in a remote simulator through run.cloud.
15
-
16
- ## Requirements
17
-
18
- - The user must be authenticated with \`runcloud login\`.
19
- - The account must have run.cloud access and a positive run.cloud balance.
20
- - App artifacts should be simulator-compatible builds. Device App Store IPAs may fail to install on iOS Simulator.
21
-
22
- ## Workflow
23
-
24
- 1. Check access with \`runcloud account --json\`.
25
- 2. Create a simulator with \`runcloud ios create --install ./build/MyApp.tar.gz --json\`.
26
- 3. Save the returned session id and URL.
27
- 4. Use \`runcloud ios open-url <url> --id <session-id>\` when a URL or deep link must be opened.
28
- 5. Release the session with \`runcloud ios delete <session-id>\`.
29
-
30
- ## Embedded Iframes
31
-
32
- - Use \`runcloud ios create --inactivity-timeout 60s --json\` when the embed should auto-close after user inactivity.
33
- - Omit \`--inactivity-timeout\` or pass \`none\` when the user needs a metered session without idle auto-close.
34
- - Iframes post \`ios-simulator:status\`, \`ios-simulator:auth-error\`, \`ios-simulator:session-ended\`, and \`ios-simulator:session-restart-requested\` to the parent window. Verify \`event.source\` before acting.
35
- - If \`ios-simulator:session-restart-requested\` arrives, create a fresh run.cloud session; do not reuse the ended iframe URL.
36
-
37
- ## Rules
38
-
39
- - Prefer \`--json\` for parsed output.
40
- - Always release sessions you create unless the user asks to keep them open.
41
- - If installation fails, verify that the artifact is a simulator build before attempting code changes.
42
- - Do not assume a local tunnel is installed on the user's machine.
43
- - Do not expose iOS simulator tokens in logs or screenshots.
44
- `;
9
+ export const RUN_CLOUD_SKILL_NAMES = [
10
+ 'run-cloud',
11
+ 'run-cloud-ios-simulator',
12
+ 'run-cloud-sandboxes',
13
+ ];
14
+ const RUN_CLOUD_SKILL_FILE_NAMES = ['SKILL.md', 'agents/openai.yaml'];
15
+ function loadRunCloudSkillFiles(skillName) {
16
+ const embedded = globalThis.__RUN_CLOUD_EMBEDDED_SKILLS__?.[skillName];
17
+ if (embedded)
18
+ return embedded;
19
+ const commandDirectory = dirname(fileURLToPath(import.meta.url));
20
+ const candidates = [
21
+ resolve(commandDirectory, '../../skills', skillName),
22
+ resolve(commandDirectory, '../../../.claude/skills', skillName),
23
+ ];
24
+ for (const candidate of candidates) {
25
+ if (RUN_CLOUD_SKILL_FILE_NAMES.every((filename) => existsSync(join(candidate, filename)))) {
26
+ return Object.fromEntries(RUN_CLOUD_SKILL_FILE_NAMES.map((filename) => [
27
+ filename,
28
+ readFileSync(join(candidate, filename), 'utf8'),
29
+ ]));
30
+ }
31
+ }
32
+ throw new Error(`run.cloud agent skill ${skillName} is missing from the package; reinstall runcloud or use npx skills add newly-app/run-cloud-examples`);
33
+ }
34
+ const RUN_CLOUD_SKILL_FILES = Object.fromEntries(RUN_CLOUD_SKILL_NAMES.map((skillName) => [
35
+ skillName,
36
+ loadRunCloudSkillFiles(skillName),
37
+ ]));
38
+ export const RUN_CLOUD_SKILLS = Object.fromEntries(RUN_CLOUD_SKILL_NAMES.map((skillName) => [
39
+ skillName,
40
+ RUN_CLOUD_SKILL_FILES[skillName]['SKILL.md'],
41
+ ]));
42
+ export const RUN_CLOUD_SKILL = RUN_CLOUD_SKILLS['run-cloud-ios-simulator'];
45
43
  function client() {
46
44
  const creds = requireCredentials();
47
45
  return new ApiClient(creds.apiUrl, creds.token);
@@ -106,117 +104,329 @@ function skillBase(agent, scope) {
106
104
  return join(homedir(), '.agents', 'skills');
107
105
  throw new Error(`Unsupported agent: ${agent}`);
108
106
  }
109
- function installSkill(opts) {
107
+ function installSkills(opts) {
110
108
  const agents = opts.agents?.length ? opts.agents : ['claude', 'codex', 'cursor'];
111
109
  const scope = opts.scope ?? 'project';
110
+ const targets = agents.flatMap((agent) => RUN_CLOUD_SKILL_NAMES.map((skill) => ({
111
+ agent,
112
+ skill,
113
+ path: join(skillBase(agent, scope), skill),
114
+ })));
115
+ if (!opts.force) {
116
+ const existing = targets.find((target) => existsSync(target.path));
117
+ if (existing) {
118
+ throw new Error(`${existing.path} already exists. Pass --force to overwrite.`);
119
+ }
120
+ }
112
121
  const results = [];
113
- for (const agent of agents) {
114
- const target = join(skillBase(agent, scope), 'run-cloud-ios-simulator');
115
- if (existsSync(target)) {
116
- if (!opts.force)
117
- throw new Error(`${target} already exists. Pass --force to overwrite.`);
122
+ for (const { agent, skill, path: target } of targets) {
123
+ if (existsSync(target))
118
124
  rmSync(target, { recursive: true, force: true });
125
+ for (const filename of RUN_CLOUD_SKILL_FILE_NAMES) {
126
+ const destination = join(target, filename);
127
+ mkdirSync(dirname(destination), { recursive: true });
128
+ writeFileSync(destination, RUN_CLOUD_SKILL_FILES[skill][filename]);
119
129
  }
120
- mkdirSync(target, { recursive: true });
121
- writeFileSync(join(target, 'SKILL.md'), RUN_CLOUD_SKILL);
122
- results.push({ agent, path: target, status: 'installed' });
130
+ results.push({ agent, skill, path: target, status: 'installed' });
123
131
  }
124
132
  return results;
125
133
  }
134
+ const RUN_CLOUD_DEMOS = [
135
+ 'eight-device-mosaic',
136
+ 'live-camera-relay',
137
+ ];
138
+ export const RUN_CLOUD_SAMPLES = {
139
+ ios: {
140
+ filename: 'run-cloud-sample-ios.app.tar.gz',
141
+ url: 'https://run.cloud/downloads/run-cloud-sample-ios.app.tar.gz',
142
+ sha256: '09c437b1f299d41f79a8453611d99b8b1e4a3f5198dd871f4dad183362f944b3',
143
+ byteSize: 19_516,
144
+ },
145
+ android: {
146
+ filename: 'run-cloud-sample-android.apk',
147
+ url: 'https://run.cloud/downloads/run-cloud-sample-android.apk',
148
+ sha256: '892d4441a24757fc88852d29abe87d42d14f04921cbe89802e369dad6e46edba',
149
+ byteSize: 6_521_225,
150
+ },
151
+ };
152
+ function samplePlatform(value) {
153
+ if (value === 'ios' || value === 'android')
154
+ return value;
155
+ throw new Error(`Unknown sample platform: ${value}. Choose ios or android.`);
156
+ }
157
+ function shellQuote(value) {
158
+ return `'${value.replaceAll("'", "'\\''")}'`;
159
+ }
160
+ export async function downloadRunCloudSample(rawPlatform, opts = {}, fetchSample = fetch, samples = RUN_CLOUD_SAMPLES) {
161
+ const platform = samplePlatform(rawPlatform);
162
+ const sample = samples[platform];
163
+ const target = resolve(opts.output ?? sample.filename);
164
+ if (existsSync(target) && !opts.force) {
165
+ throw new Error(`${target} already exists. Pass --force to overwrite it.`);
166
+ }
167
+ const response = await fetchSample(sample.url);
168
+ if (!response.ok) {
169
+ throw new Error(`Could not download the ${platform} sample: HTTP ${response.status}`);
170
+ }
171
+ const body = Buffer.from(await response.arrayBuffer());
172
+ const sha256 = createHash('sha256').update(body).digest('hex');
173
+ if (body.byteLength !== sample.byteSize || sha256 !== sample.sha256) {
174
+ throw new Error(`The ${platform} sample failed its checksum verification.`);
175
+ }
176
+ mkdirSync(dirname(target), { recursive: true });
177
+ writeFileSync(target, body);
178
+ return {
179
+ platform,
180
+ path: target,
181
+ url: sample.url,
182
+ sha256,
183
+ byteSize: body.byteLength,
184
+ next: `runcloud ${platform} create --install ${shellQuote(target)}`,
185
+ };
186
+ }
187
+ function experimentalWebRtcEnabled() {
188
+ return ['1', 'true', 'yes', 'on'].includes((process.env.RUN_CLOUD_EXPERIMENTAL_WEBRTC ?? '').trim().toLowerCase());
189
+ }
190
+ function assertDevHostAllowed(devHost) {
191
+ if (devHost && process.env.RUN_CLOUD_DEV_FEATURES !== '1') {
192
+ throw new Error('--dev-host is dev-only; set RUN_CLOUD_DEV_FEATURES=1 to use it');
193
+ }
194
+ }
195
+ function requestedCodec(codec) {
196
+ const value = codec?.trim().toLowerCase() || 'auto';
197
+ if (value === 'webrtc' && !experimentalWebRtcEnabled()) {
198
+ throw new Error('--codec webrtc is experimental; set RUN_CLOUD_EXPERIMENTAL_WEBRTC=1 to use it');
199
+ }
200
+ return value;
201
+ }
202
+ function demoDirectory(name) {
203
+ if (!RUN_CLOUD_DEMOS.some((demoName) => demoName === name)) {
204
+ throw new Error(`Unknown demo: ${name}. Available demos: ${RUN_CLOUD_DEMOS.join(', ')}`);
205
+ }
206
+ const source = resolve(dirname(fileURLToPath(import.meta.url)), '../../examples', name);
207
+ if (!existsSync(source))
208
+ throw new Error(`The ${name} demo is missing from this runcloud package.`);
209
+ return source;
210
+ }
211
+ export function initRunCloudDemo(name = 'eight-device-mosaic', directory, opts = {}) {
212
+ const source = demoDirectory(name);
213
+ const target = resolve(directory ?? `run-cloud-${name}`);
214
+ if (existsSync(target)) {
215
+ if (!opts.force)
216
+ throw new Error(`${target} already exists. Pass --force to overwrite it.`);
217
+ rmSync(target, { recursive: true, force: true });
218
+ }
219
+ cpSync(source, target, { recursive: true });
220
+ return { example: name, path: target, status: 'created' };
221
+ }
222
+ export async function runCloudDemo(name = 'eight-device-mosaic', opts = {}, loadDemo = async (url) => (await import(url))) {
223
+ if (name === 'eight-device-mosaic' && opts.duration) {
224
+ throw new Error('The eight-device mosaic uses a fixed 10-minute lease and does not accept --duration.');
225
+ }
226
+ const args = [];
227
+ if (opts.duration)
228
+ args.push('--duration', opts.duration);
229
+ if (opts.open)
230
+ args.push('--open');
231
+ const source = join(demoDirectory(name), 'demo.mjs');
232
+ const demo = await loadDemo(pathToFileURL(source).href);
233
+ await demo.runDemo(args);
234
+ }
126
235
  async function pushAsset(path, opts = {}) {
127
236
  const { blob, name } = fileBlob(path);
128
- const form = new FormData();
129
- form.set('file', blob, name);
130
- if (opts.name)
131
- form.set('name', opts.name);
132
- return (await client().uploadForm('/run-cloud/assets', form));
133
- }
134
- async function action(fn) {
237
+ const api = client();
238
+ const prepared = await api.post('/run-cloud/assets/uploads', {
239
+ filename: name,
240
+ ...(opts.name ? { name: opts.name } : {}),
241
+ contentType: blob.type || 'application/octet-stream',
242
+ byteSize: blob.size,
243
+ });
135
244
  try {
136
- await fn();
245
+ const uploaded = await fetch(prepared.upload.url, {
246
+ method: 'PUT',
247
+ headers: prepared.upload.headers,
248
+ body: blob,
249
+ });
250
+ if (!uploaded.ok)
251
+ throw new Error(`asset storage upload failed with HTTP ${uploaded.status}`);
137
252
  }
138
- catch (err) {
139
- console.error(friendlyApiError(err));
140
- process.exitCode = 1;
253
+ catch (error) {
254
+ if (prepared.asset.id) {
255
+ await api.delete(`/run-cloud/assets/${encodeURIComponent(prepared.asset.id)}`).catch(() => undefined);
256
+ }
257
+ throw error;
141
258
  }
259
+ return prepared.asset;
142
260
  }
143
- export function registerRunCloud(program) {
144
- program
145
- .command('account')
146
- .description('Show run.cloud entitlement and metered balance')
147
- .option('--json', 'output JSON', false)
148
- .action((opts) => action(async () => {
149
- print(await client().get('/run-cloud/account'), opts);
150
- }));
151
- const ios = program.command('ios').description('Create, list, inspect, and delete iOS simulator sessions');
152
- ios
261
+ async function createSimulatorSession(platform, opts) {
262
+ assertDevHostAllowed(opts.devHost);
263
+ const uploadedAssets = [];
264
+ for (const file of opts.install ?? [])
265
+ uploadedAssets.push(await pushAsset(file));
266
+ const installAssets = [
267
+ ...(opts.installAsset ?? []),
268
+ ...uploadedAssets
269
+ .map((asset) => asset.id)
270
+ .filter((id) => typeof id === 'string' && id.length > 0),
271
+ ];
272
+ return await client().post(`/run-cloud/${platform}`, {
273
+ model: opts.model,
274
+ region: opts.region,
275
+ displayName: opts.displayName,
276
+ labels: parseLabels(opts.label),
277
+ installAssets,
278
+ inactivityTimeout: opts.inactivityTimeout,
279
+ hardTimeout: opts.hardTimeout,
280
+ autoDelete: opts.rm,
281
+ codec: requestedCodec(opts.codec),
282
+ ...(opts.devHost ? { devHost: opts.devHost } : {}),
283
+ });
284
+ }
285
+ function registerSimulatorCommands(program, platform) {
286
+ const label = platform === 'android' ? 'Android emulator' : 'iOS simulator';
287
+ const article = platform === 'android' ? 'an' : 'an';
288
+ const simulator = program.command(platform).description(`Create, list, inspect, and delete ${label} sessions`);
289
+ simulator
153
290
  .command('create')
154
- .description('Create a remote iOS simulator session')
155
- .option('--model <iphone|ipad>', 'simulator model', 'iphone')
291
+ .description(`Create a remote ${label} session`)
292
+ .option('--model <model>', platform === 'ios' ? 'simulator model' : 'emulator model', platform === 'ios' ? 'iphone' : 'pixel')
156
293
  .option('--region <region>', 'fleet region')
157
294
  .option('--display-name <name>', 'human-readable name')
158
295
  .option('--label <key=value>', 'label to attach to the session', (v, p) => [...p, v], [])
159
- .option('--install <file>', 'local .ipa, .zip, .tar.gz, or simulator .app archive to upload and install', (v, p) => [...p, v], [])
296
+ .option('--install <file>', 'local app artifact to upload and install', (v, p) => [...p, v], [])
160
297
  .option('--install-asset <name-or-id>', 'previously uploaded asset to install', (v, p) => [...p, v], [])
161
298
  .option('--inactivity-timeout <duration>', 'auto-close idle timeout, e.g. 60s, 3m, or none')
162
299
  .option('--hard-timeout <duration>', 'maximum lifetime, e.g. 10m or 1h')
300
+ .option('--codec <auto|mjpeg|webrtc>', 'force the simulator viewer stream codec', 'auto')
163
301
  .option('--rm', 'release the session when this command exits', false)
164
302
  .option('--json', 'output JSON', false)
303
+ .addOption(new Option('--dev-host <host>', 'dev-only: target a specific simulator fleet host').hideHelp())
165
304
  .action((opts) => action(async () => {
166
- const uploadedAssets = [];
167
- for (const file of opts.install ?? [])
168
- uploadedAssets.push(await pushAsset(file));
169
- const installAssets = [
170
- ...(opts.installAsset ?? []),
171
- ...uploadedAssets
172
- .map((asset) => asset.id)
173
- .filter((id) => typeof id === 'string' && id.length > 0),
174
- ];
175
- const session = await client().post('/run-cloud/ios', {
176
- model: opts.model,
177
- region: opts.region,
178
- displayName: opts.displayName,
179
- labels: parseLabels(opts.label),
180
- installAssets,
181
- inactivityTimeout: opts.inactivityTimeout,
182
- hardTimeout: opts.hardTimeout,
183
- autoDelete: opts.rm,
184
- });
185
- print(session, opts);
305
+ print(await createSimulatorSession(platform, opts), opts);
186
306
  }));
187
- ios
307
+ simulator
188
308
  .command('list')
189
- .description('List active iOS simulator sessions')
309
+ .description(`List active ${label} sessions`)
190
310
  .option('--all', 'include released sessions', false)
191
311
  .option('--json', 'output JSON', false)
192
312
  .action((opts) => action(async () => {
193
313
  const qs = opts.all ? '?all=1' : '';
194
- print(await client().get(`/run-cloud/ios${qs}`), opts);
314
+ print(await client().get(`/run-cloud/${platform}${qs}`), opts);
195
315
  }));
196
- ios
316
+ simulator
197
317
  .command('get')
198
- .description('Show an iOS simulator session')
318
+ .description(`Show ${article} ${label} session`)
199
319
  .argument('<id>')
200
320
  .option('--json', 'output JSON', false)
201
321
  .action((id, opts) => action(async () => {
202
- print(await client().get(`/run-cloud/ios/${encodeURIComponent(id)}`), opts);
322
+ print(await client().get(`/run-cloud/${platform}/${encodeURIComponent(id)}`), opts);
203
323
  }));
204
- ios
324
+ simulator
205
325
  .command('delete')
206
- .description('Release an iOS simulator session')
326
+ .description(`Release ${article} ${label} session`)
207
327
  .argument('<id>')
208
328
  .option('--json', 'output JSON', false)
209
329
  .action((id, opts) => action(async () => {
210
- print(await client().delete(`/run-cloud/ios/${encodeURIComponent(id)}`), opts);
330
+ print(await client().delete(`/run-cloud/${platform}/${encodeURIComponent(id)}`), opts);
211
331
  }));
212
- ios
332
+ simulator
213
333
  .command('open-url')
214
- .description('Open a URL or deep link in an iOS simulator session')
334
+ .description(`Open a URL or deep link in ${article} ${label} session`)
215
335
  .argument('<url>')
216
336
  .requiredOption('--id <id>', 'session id')
217
337
  .option('--json', 'output JSON', false)
218
338
  .action((url, opts) => action(async () => {
219
- print(await client().post(`/run-cloud/ios/${encodeURIComponent(opts.id)}/open-url`, { url }), opts);
339
+ print(await client().post(`/run-cloud/${platform}/${encodeURIComponent(opts.id)}/open-url`, { url }), opts);
340
+ }));
341
+ return simulator;
342
+ }
343
+ async function action(fn) {
344
+ try {
345
+ await fn();
346
+ }
347
+ catch (err) {
348
+ console.error(friendlyApiError(err));
349
+ process.exitCode = 1;
350
+ }
351
+ }
352
+ export function registerRunCloud(program) {
353
+ const sample = program
354
+ .command('sample')
355
+ .alias('samples')
356
+ .description('Download installable onboarding sample apps');
357
+ sample
358
+ .command('download')
359
+ .description('Download a checksum-verified iOS or Android sample app')
360
+ .argument('<ios|android>', 'sample platform')
361
+ .option('-o, --output <path>', 'output file')
362
+ .option('--force', 'overwrite an existing output file', false)
363
+ .option('--json', 'output JSON', false)
364
+ .action((platform, opts) => action(async () => {
365
+ print(await downloadRunCloudSample(platform, opts), opts);
366
+ }));
367
+ const demo = program.command('demo').description('Create and run run.cloud demo projects');
368
+ demo
369
+ .command('init')
370
+ .description('Scaffold a standalone demo project')
371
+ .argument('[name]', 'demo to create', 'eight-device-mosaic')
372
+ .argument('[directory]', 'output directory')
373
+ .option('--force', 'overwrite an existing output directory', false)
374
+ .option('--json', 'output JSON', false)
375
+ .action((name, directory, opts) => action(async () => {
376
+ print(initRunCloudDemo(name, directory, opts), opts);
377
+ }));
378
+ demo
379
+ .command('run')
380
+ .description('Run a bundled demo without scaffolding a project')
381
+ .argument('[name]', 'demo to run', 'eight-device-mosaic')
382
+ .option('--duration <seconds>', 'duration for live-camera-relay')
383
+ .option('--open', 'open the demo viewer or live sessions in the browser', false)
384
+ .action((name, opts) => action(async () => {
385
+ await runCloudDemo(name, opts);
386
+ }));
387
+ program
388
+ .command('account')
389
+ .description('Show run.cloud account and organization access')
390
+ .option('--json', 'output JSON', false)
391
+ .action((opts) => action(async () => {
392
+ print(await client().get('/run-cloud/account'), opts);
393
+ }));
394
+ const ios = registerSimulatorCommands(program, 'ios');
395
+ registerSimulatorCommands(program, 'android');
396
+ ios
397
+ .command('tunnel')
398
+ .description('Connect a local service to a remote iOS simulator session')
399
+ .argument('<id>')
400
+ .requiredOption('--local-port <port>', 'local service port, for example Metro on 8081')
401
+ .option('--service <name>', 'service name shown in status', 'metro')
402
+ .option('--local-host <host>', 'loopback service host', '127.0.0.1')
403
+ .option('--sidecar-url <url>', 'Newly sidecar URL', 'http://127.0.0.1:4500')
404
+ .option('--json', 'output JSON', false)
405
+ .action((id, opts) => action(async () => {
406
+ const localPort = Number(opts.localPort);
407
+ if (!Number.isInteger(localPort) || localPort < 1 || localPort > 65535)
408
+ throw new Error('--local-port must be a valid TCP port');
409
+ const allocation = await client().post(`/sessions/${encodeURIComponent(id)}/tunnel`, {
410
+ localService: opts.service, localHost: opts.localHost, localPort,
411
+ });
412
+ if (!allocation.tunnel)
413
+ throw new Error('Control plane did not return tunnel credentials');
414
+ const sidecarResponse = await fetch(`${opts.sidecarUrl.replace(/\/$/, '')}/run-cloud/tunnel`, {
415
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(allocation.tunnel),
416
+ });
417
+ const status = await sidecarResponse.json();
418
+ if (!sidecarResponse.ok)
419
+ throw new Error(`Local tunnel failed: ${status.error ?? sidecarResponse.statusText}`);
420
+ print(status, opts);
421
+ }));
422
+ ios
423
+ .command('tunnel-status')
424
+ .description('Show the local tunnel sidecar status')
425
+ .option('--sidecar-url <url>', 'Newly sidecar URL', 'http://127.0.0.1:4500')
426
+ .option('--json', 'output JSON', false)
427
+ .action((opts) => action(async () => {
428
+ const response = await fetch(`${opts.sidecarUrl.replace(/\/$/, '')}/run-cloud/tunnel`);
429
+ print(await response.json(), opts);
220
430
  }));
221
431
  const asset = program
222
432
  .command('asset')
@@ -260,13 +470,13 @@ export function registerRunCloud(program) {
260
470
  const skills = program.command('skills').description('Install run.cloud skills for AI coding agents');
261
471
  skills
262
472
  .command('install')
263
- .description('Install the run.cloud iOS simulator skill')
473
+ .description('Install the run.cloud router, mobile-session, and sandbox skills')
264
474
  .option('--agents <agent>', 'agent to install for: claude, codex, cursor', (v, p) => [...p, v], [])
265
475
  .option('--scope <project|global>', 'install scope', 'project')
266
476
  .option('--force', 'overwrite existing skill', false)
267
477
  .option('--json', 'output JSON', false)
268
478
  .action((opts) => action(async () => {
269
479
  const scope = opts.scope === 'global' ? 'global' : 'project';
270
- print(installSkill({ ...opts, scope }), opts);
480
+ print(installSkills({ ...opts, scope }), opts);
271
481
  }));
272
482
  }