@magnetoagents/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +190 -0
- package/dist/cli.d.ts +22 -0
- package/dist/cli.js +671 -0
- package/dist/client.d.ts +90 -0
- package/dist/client.js +245 -0
- package/dist/credentials.d.ts +14 -0
- package/dist/credentials.js +63 -0
- package/dist/format.d.ts +31 -0
- package/dist/format.js +175 -0
- package/dist/open-url.d.ts +1 -0
- package/dist/open-url.js +15 -0
- package/dist/package-version.d.ts +2 -0
- package/dist/package-version.js +10 -0
- package/package.json +39 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { createClient, MagnetoApiError, normalizeApiBase } from './client.js';
|
|
7
|
+
import { resolveApiKey, writeCredentials } from './credentials.js';
|
|
8
|
+
import { ACCOUNT_SPEC, COMPUTERS_SPEC, FILES_SPEC, InvalidOutputFormatError, parseOutputFlag, RUN_GET_SPEC, RUNS_SPEC, SKILLS_SPEC, TEMPLATES_SPEC, UPTIME_SPEC, formatOutput, resolveOutputFormat, } from './format.js';
|
|
9
|
+
import { openUrl as defaultOpenUrl } from './open-url.js';
|
|
10
|
+
import { readPackageVersion } from './package-version.js';
|
|
11
|
+
/** Thrown by injected `io.exit` in tests so action `catch` blocks do not remap the code to 1. */
|
|
12
|
+
export class CliExitError extends Error {
|
|
13
|
+
exitCode;
|
|
14
|
+
constructor(exitCode) {
|
|
15
|
+
super(`exit ${exitCode}`);
|
|
16
|
+
this.name = 'CliExitError';
|
|
17
|
+
this.exitCode = exitCode;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const GATEWAY_WARNING = 'Warning: this URL is a 60-second credential. Do not write it to shell history or logs.';
|
|
21
|
+
function resolveIo(io) {
|
|
22
|
+
return {
|
|
23
|
+
fetchImpl: io?.fetchImpl,
|
|
24
|
+
stdout: io?.stdout ?? process.stdout,
|
|
25
|
+
stderr: io?.stderr ?? process.stderr,
|
|
26
|
+
exit: io?.exit ?? ((code) => process.exit(code)),
|
|
27
|
+
openUrl: io?.openUrl ?? defaultOpenUrl,
|
|
28
|
+
env: io?.env ?? process.env,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export function handleError(err, io) {
|
|
32
|
+
if (err instanceof CliExitError) {
|
|
33
|
+
throw err;
|
|
34
|
+
}
|
|
35
|
+
const { stderr, exit } = resolveIo(io);
|
|
36
|
+
if (err instanceof MagnetoApiError) {
|
|
37
|
+
const bits = [`error: ${err.detail} (HTTP ${err.status})`];
|
|
38
|
+
if (err.requestId)
|
|
39
|
+
bits.push(`request-id: ${err.requestId}`);
|
|
40
|
+
if (err.retryAfter)
|
|
41
|
+
bits.push(`Retry-After: ${err.retryAfter}`);
|
|
42
|
+
stderr.write(`${bits.join(' · ')}\n`);
|
|
43
|
+
return exit(err.status === 401 || err.status === 403 ? 2 : 1);
|
|
44
|
+
}
|
|
45
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
46
|
+
stderr.write(`${message}\n`);
|
|
47
|
+
return exit(1);
|
|
48
|
+
}
|
|
49
|
+
function addOutputOption(cmd, io) {
|
|
50
|
+
return cmd.option('--output <fmt>', 'table | json | quiet', (raw) => {
|
|
51
|
+
try {
|
|
52
|
+
return parseOutputFlag(raw);
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
if (err instanceof InvalidOutputFormatError) {
|
|
56
|
+
io.stderr.write(`${err.message}\n`);
|
|
57
|
+
io.exit(1);
|
|
58
|
+
}
|
|
59
|
+
throw err;
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
function formatAccountHuman(data) {
|
|
64
|
+
const workspace = (data.workspace ?? {});
|
|
65
|
+
const subscription = data.subscription;
|
|
66
|
+
const usage = (data.usage ?? {});
|
|
67
|
+
const key = (data.key ?? {});
|
|
68
|
+
const rate = (data.rate_limit ?? {});
|
|
69
|
+
const wsName = workspace.name?.trim() ? workspace.name : '—';
|
|
70
|
+
const wsId = workspace.id ?? '';
|
|
71
|
+
let planLine;
|
|
72
|
+
if (subscription == null) {
|
|
73
|
+
planLine = '(unavailable)';
|
|
74
|
+
}
|
|
75
|
+
else if (!subscription.plans || subscription.plans.length === 0) {
|
|
76
|
+
planLine = '(none)';
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
planLine = subscription.plans
|
|
80
|
+
.map((p) => `${p.plan ?? '?'} (${p.status ?? '?'})`)
|
|
81
|
+
.join(', ');
|
|
82
|
+
}
|
|
83
|
+
const cu = formatUsageBlob(usage.cu);
|
|
84
|
+
const llm = formatUsageBlob(usage.llm);
|
|
85
|
+
const reqToday = usage.requests_today == null ? 'n/a' : String(usage.requests_today);
|
|
86
|
+
const scopes = Array.isArray(key.scopes) ? key.scopes.join(', ') : '';
|
|
87
|
+
const expires = key.expires_at ? key.expires_at : 'never';
|
|
88
|
+
const label = (s) => s.padEnd(12);
|
|
89
|
+
return [
|
|
90
|
+
`${label('Workspace')}${wsName} (${wsId})`,
|
|
91
|
+
`${label('Plan')}${planLine}`,
|
|
92
|
+
`${label('Usage')}CU ${cu} · LLM ${llm} · requests today ${reqToday}`,
|
|
93
|
+
`${label('Key')}${key.prefix ?? ''} scopes: ${scopes} expires: ${expires}`,
|
|
94
|
+
`${label('Rate limit')}${rate.limit ?? '?'} / ${rate.window_s ?? '?'}s`,
|
|
95
|
+
'',
|
|
96
|
+
].join('\n');
|
|
97
|
+
}
|
|
98
|
+
function formatUsageBlob(value) {
|
|
99
|
+
if (value == null)
|
|
100
|
+
return 'n/a';
|
|
101
|
+
if (typeof value === 'number' || typeof value === 'string')
|
|
102
|
+
return String(value);
|
|
103
|
+
if (typeof value === 'object') {
|
|
104
|
+
const o = value;
|
|
105
|
+
if (o.total_cost_usd != null)
|
|
106
|
+
return `$${o.total_cost_usd}`;
|
|
107
|
+
if (o.cost_usd != null)
|
|
108
|
+
return `$${o.cost_usd}`;
|
|
109
|
+
}
|
|
110
|
+
return JSON.stringify(value);
|
|
111
|
+
}
|
|
112
|
+
export function buildProgram(io) {
|
|
113
|
+
const resolved = resolveIo(io);
|
|
114
|
+
const { stdout, stderr, exit, env, openUrl } = resolved;
|
|
115
|
+
function requireClient() {
|
|
116
|
+
const apiKey = resolveApiKey(env);
|
|
117
|
+
if (!apiKey) {
|
|
118
|
+
stderr.write('No API key. Run `magneto login <key>` or set MAGNETO_API_KEY. Mint keys in the dashboard.\n');
|
|
119
|
+
return exit(1);
|
|
120
|
+
}
|
|
121
|
+
const timeoutRaw = env.MAGNETO_TIMEOUT_MS;
|
|
122
|
+
const parsedTimeout = timeoutRaw != null ? Number(timeoutRaw) : NaN;
|
|
123
|
+
return createClient({
|
|
124
|
+
apiKey,
|
|
125
|
+
baseUrl: env.MAGNETO_API_BASE ?? env.MAGNETO_BASE_URL,
|
|
126
|
+
fetchImpl: resolved.fetchImpl,
|
|
127
|
+
...(Number.isFinite(parsedTimeout) && parsedTimeout > 0 ? { timeoutMs: parsedTimeout } : {}),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
function printJson(data) {
|
|
131
|
+
stdout.write(`${JSON.stringify(data, null, 2)}\n`);
|
|
132
|
+
}
|
|
133
|
+
function fail(err) {
|
|
134
|
+
if (err instanceof CliExitError)
|
|
135
|
+
throw err;
|
|
136
|
+
handleError(err, resolved);
|
|
137
|
+
}
|
|
138
|
+
function writeFormatted(data, explicit, spec) {
|
|
139
|
+
let format;
|
|
140
|
+
try {
|
|
141
|
+
format = resolveOutputFormat({ explicit, isTty: Boolean(stdout.isTTY) });
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
if (err instanceof InvalidOutputFormatError) {
|
|
145
|
+
stderr.write(`${err.message}\n`);
|
|
146
|
+
exit(1);
|
|
147
|
+
}
|
|
148
|
+
throw err;
|
|
149
|
+
}
|
|
150
|
+
stdout.write(formatOutput(data, format, spec));
|
|
151
|
+
}
|
|
152
|
+
const program = new Command();
|
|
153
|
+
program
|
|
154
|
+
.name('magneto')
|
|
155
|
+
.description('Magneto CLI — public /api/v1 with sk_live_* keys')
|
|
156
|
+
.version(readPackageVersion());
|
|
157
|
+
program.exitOverride((err) => {
|
|
158
|
+
exit(typeof err.exitCode === 'number' ? err.exitCode : 1);
|
|
159
|
+
});
|
|
160
|
+
program.configureOutput({
|
|
161
|
+
writeOut: (s) => {
|
|
162
|
+
stdout.write(s);
|
|
163
|
+
},
|
|
164
|
+
writeErr: (s) => {
|
|
165
|
+
stderr.write(s);
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
program
|
|
169
|
+
.command('login')
|
|
170
|
+
.description('Save an sk_live_* key to ~/.magneto/credentials.json (mode 0600)')
|
|
171
|
+
.argument('<key>', 'Workspace API key (sk_live_…)')
|
|
172
|
+
.action((key) => {
|
|
173
|
+
if (!key.startsWith('sk_live_')) {
|
|
174
|
+
stderr.write('Key must start with sk_live_\n');
|
|
175
|
+
exit(1);
|
|
176
|
+
}
|
|
177
|
+
const credPath = writeCredentials(key);
|
|
178
|
+
stdout.write(`Credentials written to ${credPath} (mode 0600)\n`);
|
|
179
|
+
stdout.write(`API base: ${normalizeApiBase(env.MAGNETO_API_BASE)}\n`);
|
|
180
|
+
});
|
|
181
|
+
const computers = program.command('computers').description('Manage computers');
|
|
182
|
+
addOutputOption(computers
|
|
183
|
+
.command('list')
|
|
184
|
+
.description('List computers for the key minting user'), resolved).action(async (opts) => {
|
|
185
|
+
try {
|
|
186
|
+
const client = requireClient();
|
|
187
|
+
writeFormatted(await client.listComputers(), opts.output, COMPUTERS_SPEC);
|
|
188
|
+
}
|
|
189
|
+
catch (err) {
|
|
190
|
+
fail(err);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
computers
|
|
194
|
+
.command('create')
|
|
195
|
+
.description('Create a computer (workspace forced from key)')
|
|
196
|
+
.option('--name <name>', 'Display name')
|
|
197
|
+
.option('--flavor <flavor>', 'agent | desktop', 'agent')
|
|
198
|
+
.option('--template-id <id>', 'Agent template id', (v) => Number(v))
|
|
199
|
+
.option('--vcpu <n>', 'Desktop vCPU', (v) => Number(v))
|
|
200
|
+
.option('--ram-gb <n>', 'Desktop RAM GB', (v) => Number(v))
|
|
201
|
+
.option('--disk-gb <n>', 'Desktop disk GB', (v) => Number(v))
|
|
202
|
+
.action(async (opts) => {
|
|
203
|
+
try {
|
|
204
|
+
const client = requireClient();
|
|
205
|
+
const body = {};
|
|
206
|
+
if (opts.name)
|
|
207
|
+
body.name = opts.name;
|
|
208
|
+
if (opts.flavor)
|
|
209
|
+
body.flavor = opts.flavor;
|
|
210
|
+
if (opts.templateId != null && !Number.isNaN(opts.templateId)) {
|
|
211
|
+
body.template_id = opts.templateId;
|
|
212
|
+
}
|
|
213
|
+
if (opts.vcpu != null && !Number.isNaN(opts.vcpu))
|
|
214
|
+
body.vcpu = opts.vcpu;
|
|
215
|
+
if (opts.ramGb != null && !Number.isNaN(opts.ramGb))
|
|
216
|
+
body.ram_gb = opts.ramGb;
|
|
217
|
+
if (opts.diskGb != null && !Number.isNaN(opts.diskGb))
|
|
218
|
+
body.disk_gb = opts.diskGb;
|
|
219
|
+
printJson(await client.createComputer(body));
|
|
220
|
+
}
|
|
221
|
+
catch (err) {
|
|
222
|
+
fail(err);
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
addOutputOption(computers.command('get').description('Get a computer').argument('<id>', 'Computer id'), resolved).action(async (id, opts) => {
|
|
226
|
+
try {
|
|
227
|
+
const client = requireClient();
|
|
228
|
+
writeFormatted(await client.getComputer(id), opts.output, COMPUTERS_SPEC);
|
|
229
|
+
}
|
|
230
|
+
catch (err) {
|
|
231
|
+
fail(err);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
computers
|
|
235
|
+
.command('start')
|
|
236
|
+
.description('Start a stopped computer')
|
|
237
|
+
.argument('<id>', 'Computer id')
|
|
238
|
+
.action(async (id) => {
|
|
239
|
+
try {
|
|
240
|
+
const client = requireClient();
|
|
241
|
+
printJson(await client.startComputer(id));
|
|
242
|
+
}
|
|
243
|
+
catch (err) {
|
|
244
|
+
fail(err);
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
computers
|
|
248
|
+
.command('restart')
|
|
249
|
+
.description('Restart a computer')
|
|
250
|
+
.argument('<id>', 'Computer id')
|
|
251
|
+
.action(async (id) => {
|
|
252
|
+
try {
|
|
253
|
+
const client = requireClient();
|
|
254
|
+
printJson(await client.restartComputer(id));
|
|
255
|
+
}
|
|
256
|
+
catch (err) {
|
|
257
|
+
fail(err);
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
computers
|
|
261
|
+
.command('stop')
|
|
262
|
+
.description('Stop a computer')
|
|
263
|
+
.argument('<id>', 'Computer id')
|
|
264
|
+
.action(async (id) => {
|
|
265
|
+
try {
|
|
266
|
+
const client = requireClient();
|
|
267
|
+
printJson(await client.stopComputer(id));
|
|
268
|
+
}
|
|
269
|
+
catch (err) {
|
|
270
|
+
fail(err);
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
computers
|
|
274
|
+
.command('resize')
|
|
275
|
+
.description('Resize a desktop computer (all three flags required)')
|
|
276
|
+
.argument('<id>', 'Computer id')
|
|
277
|
+
.requiredOption('--vcpu <n>', 'Desktop vCPU', (v) => Number(v))
|
|
278
|
+
.requiredOption('--ram-gb <n>', 'Desktop RAM GB', (v) => Number(v))
|
|
279
|
+
.requiredOption('--disk-gb <n>', 'Desktop disk GB', (v) => Number(v))
|
|
280
|
+
.action(async (id, opts) => {
|
|
281
|
+
try {
|
|
282
|
+
if ([opts.vcpu, opts.ramGb, opts.diskGb].some((n) => Number.isNaN(n))) {
|
|
283
|
+
stderr.write('--vcpu, --ram-gb, and --disk-gb must be numbers\n');
|
|
284
|
+
exit(1);
|
|
285
|
+
}
|
|
286
|
+
const client = requireClient();
|
|
287
|
+
printJson(await client.resizeComputer(id, {
|
|
288
|
+
vcpu: opts.vcpu,
|
|
289
|
+
ram_gb: opts.ramGb,
|
|
290
|
+
disk_gb: opts.diskGb,
|
|
291
|
+
}));
|
|
292
|
+
}
|
|
293
|
+
catch (err) {
|
|
294
|
+
fail(err);
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
computers
|
|
298
|
+
.command('delete')
|
|
299
|
+
.description('Irreversible deprovision. --confirm must equal the computer name (forwarded as ?confirm=). No prompt.')
|
|
300
|
+
.argument('<id>', 'Computer id')
|
|
301
|
+
.requiredOption('--confirm <name>', 'Computer name (must match)')
|
|
302
|
+
.action(async (id, opts) => {
|
|
303
|
+
try {
|
|
304
|
+
const client = requireClient();
|
|
305
|
+
const result = await client.deleteComputer(id, opts.confirm);
|
|
306
|
+
if (result === undefined) {
|
|
307
|
+
stdout.write('ok\n');
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
printJson(result);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
catch (err) {
|
|
314
|
+
fail(err);
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
addOutputOption(computers
|
|
318
|
+
.command('uptime')
|
|
319
|
+
.description('Uptime for a computer')
|
|
320
|
+
.argument('<id>', 'Computer id')
|
|
321
|
+
.option('--window-seconds <n>', 'Lookback window in seconds', (v) => Number(v)), resolved).action(async (id, opts) => {
|
|
322
|
+
try {
|
|
323
|
+
const client = requireClient();
|
|
324
|
+
const windowSeconds = opts.windowSeconds != null && !Number.isNaN(opts.windowSeconds)
|
|
325
|
+
? opts.windowSeconds
|
|
326
|
+
: undefined;
|
|
327
|
+
writeFormatted(await client.getComputerUptime(id, windowSeconds), opts.output, UPTIME_SPEC);
|
|
328
|
+
}
|
|
329
|
+
catch (err) {
|
|
330
|
+
fail(err);
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
program
|
|
334
|
+
.command('gateway')
|
|
335
|
+
.description('Mint a 60-second signed desktop/terminal URL. The URL is a credential — do not write it to shell history or logs.')
|
|
336
|
+
.argument('<id>', 'Computer id')
|
|
337
|
+
.requiredOption('--target <target>', 'desktop | terminal')
|
|
338
|
+
.option('--open', 'Open the URL in the default browser')
|
|
339
|
+
.action(async (id, opts) => {
|
|
340
|
+
try {
|
|
341
|
+
const target = opts.target;
|
|
342
|
+
if (target !== 'desktop' && target !== 'terminal') {
|
|
343
|
+
stderr.write('--target must be desktop or terminal\n');
|
|
344
|
+
return exit(1);
|
|
345
|
+
}
|
|
346
|
+
const client = requireClient();
|
|
347
|
+
const result = await client.mintGatewayUrl(id, target);
|
|
348
|
+
stdout.write(`${result.url}\n`);
|
|
349
|
+
stderr.write(`${GATEWAY_WARNING}\n`);
|
|
350
|
+
if (opts.open)
|
|
351
|
+
await openUrl(result.url);
|
|
352
|
+
}
|
|
353
|
+
catch (err) {
|
|
354
|
+
fail(err);
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
program
|
|
358
|
+
.command('run')
|
|
359
|
+
.description('Run a computer-use instruction (desktop only; streams to stdout)')
|
|
360
|
+
.argument('<instruction>', 'Natural-language instruction')
|
|
361
|
+
.requiredOption('--computer <id>', 'Desktop computer id')
|
|
362
|
+
.option('--model <model>', 'Model id (default server-side)')
|
|
363
|
+
.option('--max-steps <n>', 'Max CU steps', (v) => Number(v))
|
|
364
|
+
.action(async (instruction, opts) => {
|
|
365
|
+
try {
|
|
366
|
+
const client = requireClient();
|
|
367
|
+
await client.runComputerUse(opts.computer, {
|
|
368
|
+
instruction,
|
|
369
|
+
...(opts.model ? { model: opts.model } : {}),
|
|
370
|
+
...(opts.maxSteps != null && !Number.isNaN(opts.maxSteps)
|
|
371
|
+
? { max_steps: opts.maxSteps }
|
|
372
|
+
: {}),
|
|
373
|
+
}, (chunk) => {
|
|
374
|
+
stdout.write(chunk);
|
|
375
|
+
});
|
|
376
|
+
if (stdout.isTTY)
|
|
377
|
+
stdout.write('\n');
|
|
378
|
+
}
|
|
379
|
+
catch (err) {
|
|
380
|
+
fail(err);
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
const runs = program.command('runs').description('Computer-use runs');
|
|
384
|
+
addOutputOption(runs
|
|
385
|
+
.command('list')
|
|
386
|
+
.description('List runs for a computer')
|
|
387
|
+
.requiredOption('--computer <id>', 'Computer id')
|
|
388
|
+
.option('--limit <n>', 'Page size', (v) => Number(v))
|
|
389
|
+
.option('--offset <n>', 'Page offset', (v) => Number(v)), resolved).action(async (opts) => {
|
|
390
|
+
try {
|
|
391
|
+
const client = requireClient();
|
|
392
|
+
const page = {};
|
|
393
|
+
if (opts.limit != null && !Number.isNaN(opts.limit))
|
|
394
|
+
page.limit = opts.limit;
|
|
395
|
+
if (opts.offset != null && !Number.isNaN(opts.offset))
|
|
396
|
+
page.offset = opts.offset;
|
|
397
|
+
writeFormatted(await client.listRuns(opts.computer, page), opts.output, RUNS_SPEC);
|
|
398
|
+
}
|
|
399
|
+
catch (err) {
|
|
400
|
+
fail(err);
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
addOutputOption(runs
|
|
404
|
+
.command('get')
|
|
405
|
+
.description('Get a run (table/quiet hide events; json includes them)')
|
|
406
|
+
.argument('<run_id>', 'Run id')
|
|
407
|
+
.requiredOption('--computer <id>', 'Computer id'), resolved).action(async (runId, opts) => {
|
|
408
|
+
try {
|
|
409
|
+
const client = requireClient();
|
|
410
|
+
writeFormatted(await client.getRun(opts.computer, runId), opts.output, RUN_GET_SPEC);
|
|
411
|
+
}
|
|
412
|
+
catch (err) {
|
|
413
|
+
fail(err);
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
runs
|
|
417
|
+
.command('export')
|
|
418
|
+
.description('Export a run replay (bytes to -o or stdout). Timeout exempt.')
|
|
419
|
+
.argument('<run_id>', 'Run id')
|
|
420
|
+
.requiredOption('--computer <id>', 'Computer id')
|
|
421
|
+
.option('-o, --output-file <file>', 'Write bytes to this path')
|
|
422
|
+
.action(async (runId, opts) => {
|
|
423
|
+
try {
|
|
424
|
+
const client = requireClient();
|
|
425
|
+
const res = await client.exportRun(opts.computer, runId);
|
|
426
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
427
|
+
if (opts.outputFile) {
|
|
428
|
+
fs.mkdirSync(path.dirname(opts.outputFile), { recursive: true });
|
|
429
|
+
fs.writeFileSync(opts.outputFile, buf);
|
|
430
|
+
}
|
|
431
|
+
else {
|
|
432
|
+
stdout.write(buf);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
catch (err) {
|
|
436
|
+
fail(err);
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
runs
|
|
440
|
+
.command('stop')
|
|
441
|
+
.description('Stop a computer-use run')
|
|
442
|
+
.argument('<run_id>', 'Run id')
|
|
443
|
+
.requiredOption('--computer <id>', 'Computer id')
|
|
444
|
+
.action(async (runId, opts) => {
|
|
445
|
+
try {
|
|
446
|
+
const client = requireClient();
|
|
447
|
+
printJson(await client.stopRun(opts.computer, runId));
|
|
448
|
+
}
|
|
449
|
+
catch (err) {
|
|
450
|
+
fail(err);
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
program
|
|
454
|
+
.command('bash')
|
|
455
|
+
.description('Run a shell command on a computer. Requires key scope `exec` (not in the default set). ' +
|
|
456
|
+
'On HTTP 200 the process exit code is the remote exit_code (not 1/2). ' +
|
|
457
|
+
'HTTP 401/403 still exit 2; other HTTP errors still exit 1. ' +
|
|
458
|
+
'Quote commands with spaces: magneto bash <id> -- "cmd with spaces".')
|
|
459
|
+
.argument('<id>', 'Computer id')
|
|
460
|
+
.argument('[command]', 'Remote command (use -- before commands that start with -)')
|
|
461
|
+
.option('--timeout <seconds>', 'Remote timeout in seconds (server clamps 1–300)', (v) => Number(v))
|
|
462
|
+
.action(async (id, command, opts) => {
|
|
463
|
+
try {
|
|
464
|
+
if (!command) {
|
|
465
|
+
stderr.write('command is required (e.g. magneto bash <id> -- "echo ok")\n');
|
|
466
|
+
return exit(1);
|
|
467
|
+
}
|
|
468
|
+
const timeout = opts.timeout != null && !Number.isNaN(opts.timeout) ? opts.timeout : 60;
|
|
469
|
+
const client = requireClient();
|
|
470
|
+
const result = await client.bash(id, { command, timeout });
|
|
471
|
+
stdout.write(result.output ?? '');
|
|
472
|
+
if (result.timed_out)
|
|
473
|
+
stderr.write('timed out\n');
|
|
474
|
+
if (result.truncated)
|
|
475
|
+
stderr.write('output truncated\n');
|
|
476
|
+
exit((result.exit_code ?? 0) & 255);
|
|
477
|
+
}
|
|
478
|
+
catch (err) {
|
|
479
|
+
fail(err);
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
const files = program.command('files').description('Account files');
|
|
483
|
+
addOutputOption(files.command('list').description('List uploaded files'), resolved).action(async (opts) => {
|
|
484
|
+
try {
|
|
485
|
+
const client = requireClient();
|
|
486
|
+
writeFormatted(await client.listFiles(), opts.output, FILES_SPEC);
|
|
487
|
+
}
|
|
488
|
+
catch (err) {
|
|
489
|
+
fail(err);
|
|
490
|
+
}
|
|
491
|
+
});
|
|
492
|
+
files
|
|
493
|
+
.command('upload')
|
|
494
|
+
.description('Upload a local file (multipart field `file`, 10 MiB server limit)')
|
|
495
|
+
.argument('<path>', 'Local file path')
|
|
496
|
+
.action(async (filePath) => {
|
|
497
|
+
try {
|
|
498
|
+
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
|
|
499
|
+
stderr.write(`file not found: ${filePath}\n`);
|
|
500
|
+
exit(1);
|
|
501
|
+
}
|
|
502
|
+
const stat = fs.statSync(filePath);
|
|
503
|
+
if (stat.size === 0) {
|
|
504
|
+
stderr.write('file is empty\n');
|
|
505
|
+
exit(1);
|
|
506
|
+
}
|
|
507
|
+
if (stat.size > 10 * 1024 * 1024) {
|
|
508
|
+
stderr.write('warning: file exceeds 10 MiB; server will reject it\n');
|
|
509
|
+
}
|
|
510
|
+
const client = requireClient();
|
|
511
|
+
printJson(await client.uploadFile(filePath));
|
|
512
|
+
}
|
|
513
|
+
catch (err) {
|
|
514
|
+
fail(err);
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
files
|
|
518
|
+
.command('download')
|
|
519
|
+
.description('Download a file via its signed URL (URL is never printed)')
|
|
520
|
+
.argument('<id>', 'File id')
|
|
521
|
+
.option('-o, --output <dir>', 'Destination file or directory (trailing / = directory)')
|
|
522
|
+
.action(async (id, opts) => {
|
|
523
|
+
try {
|
|
524
|
+
const client = requireClient();
|
|
525
|
+
const info = await client.getFileDownloadUrl(id);
|
|
526
|
+
const filename = typeof info.filename === 'string' && info.filename ? info.filename : id;
|
|
527
|
+
let dest;
|
|
528
|
+
if (opts.output) {
|
|
529
|
+
const asDir = opts.output.endsWith('/') ||
|
|
530
|
+
opts.output.endsWith(path.sep) ||
|
|
531
|
+
(fs.existsSync(opts.output) && fs.statSync(opts.output).isDirectory());
|
|
532
|
+
dest = asDir ? path.join(opts.output, filename) : opts.output;
|
|
533
|
+
}
|
|
534
|
+
else {
|
|
535
|
+
dest = path.join('.', filename);
|
|
536
|
+
}
|
|
537
|
+
await client.downloadSignedUrl(info.url, dest);
|
|
538
|
+
stderr.write(`${dest}\n`);
|
|
539
|
+
}
|
|
540
|
+
catch (err) {
|
|
541
|
+
fail(err);
|
|
542
|
+
}
|
|
543
|
+
});
|
|
544
|
+
const templates = program.command('templates').description('Agent template catalog');
|
|
545
|
+
addOutputOption(templates
|
|
546
|
+
.command('list')
|
|
547
|
+
.description('List templates')
|
|
548
|
+
.option('--skip <n>', 'Offset', (v) => Number(v), 0)
|
|
549
|
+
.option('--limit <n>', 'Page size', (v) => Number(v), 100), resolved).action(async (opts) => {
|
|
550
|
+
try {
|
|
551
|
+
const client = requireClient();
|
|
552
|
+
const skip = opts.skip != null && !Number.isNaN(opts.skip) ? opts.skip : 0;
|
|
553
|
+
const limit = opts.limit != null && !Number.isNaN(opts.limit) ? opts.limit : 100;
|
|
554
|
+
writeFormatted(await client.listTemplates({ skip, limit }), opts.output, TEMPLATES_SPEC);
|
|
555
|
+
}
|
|
556
|
+
catch (err) {
|
|
557
|
+
fail(err);
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
const skills = program.command('skills').description('Skill install helpers');
|
|
561
|
+
skills
|
|
562
|
+
.command('install')
|
|
563
|
+
.description('Install a marketplace skill onto a computer')
|
|
564
|
+
.argument('<skill_id>', 'Numeric skill id', (v) => Number(v))
|
|
565
|
+
.requiredOption('--computer <id>', 'Computer id')
|
|
566
|
+
.action(async (skillId, opts) => {
|
|
567
|
+
try {
|
|
568
|
+
if (typeof skillId !== 'number' || Number.isNaN(skillId)) {
|
|
569
|
+
stderr.write('skill_id must be a number\n');
|
|
570
|
+
exit(1);
|
|
571
|
+
}
|
|
572
|
+
const client = requireClient();
|
|
573
|
+
printJson(await client.installSkill(opts.computer, skillId));
|
|
574
|
+
}
|
|
575
|
+
catch (err) {
|
|
576
|
+
fail(err);
|
|
577
|
+
}
|
|
578
|
+
});
|
|
579
|
+
addOutputOption(skills
|
|
580
|
+
.command('search')
|
|
581
|
+
.description('List marketplace skills. Optional [query] filters the fetched page client-side (name + description); v1 has no ?q=.')
|
|
582
|
+
.argument('[query]', 'Client-side substring filter')
|
|
583
|
+
.option('--skip <n>', 'Offset', (v) => Number(v), 0)
|
|
584
|
+
.option('--limit <n>', 'Page size', (v) => Number(v), 100), resolved).action(async (query, opts) => {
|
|
585
|
+
try {
|
|
586
|
+
const client = requireClient();
|
|
587
|
+
const skip = opts.skip != null && !Number.isNaN(opts.skip) ? opts.skip : 0;
|
|
588
|
+
const limit = opts.limit != null && !Number.isNaN(opts.limit) ? opts.limit : 100;
|
|
589
|
+
const rows = (await client.listSkills({ skip, limit }));
|
|
590
|
+
const filtered = query && query.trim()
|
|
591
|
+
? rows.filter((row) => {
|
|
592
|
+
const q = query.toLowerCase();
|
|
593
|
+
const name = String(row.name ?? '').toLowerCase();
|
|
594
|
+
const desc = String(row.description ?? '').toLowerCase();
|
|
595
|
+
return name.includes(q) || desc.includes(q);
|
|
596
|
+
})
|
|
597
|
+
: rows;
|
|
598
|
+
writeFormatted(filtered, opts.output, SKILLS_SPEC);
|
|
599
|
+
}
|
|
600
|
+
catch (err) {
|
|
601
|
+
fail(err);
|
|
602
|
+
}
|
|
603
|
+
});
|
|
604
|
+
skills
|
|
605
|
+
.command('uninstall')
|
|
606
|
+
.description('Uninstall a marketplace skill from a computer (204 → silent)')
|
|
607
|
+
.argument('<skill_id>', 'Skill id')
|
|
608
|
+
.requiredOption('--computer <id>', 'Computer id')
|
|
609
|
+
.action(async (skillId, opts) => {
|
|
610
|
+
try {
|
|
611
|
+
const client = requireClient();
|
|
612
|
+
await client.uninstallSkill(opts.computer, skillId);
|
|
613
|
+
}
|
|
614
|
+
catch (err) {
|
|
615
|
+
fail(err);
|
|
616
|
+
}
|
|
617
|
+
});
|
|
618
|
+
addOutputOption(program
|
|
619
|
+
.command('account')
|
|
620
|
+
.description('Workspace plan, usage, key scopes, and rate limit'), resolved).action(async (opts) => {
|
|
621
|
+
try {
|
|
622
|
+
const client = requireClient();
|
|
623
|
+
const data = (await client.getAccount());
|
|
624
|
+
const warnings = data.warnings;
|
|
625
|
+
if (Array.isArray(warnings)) {
|
|
626
|
+
for (const w of warnings)
|
|
627
|
+
stderr.write(`${String(w)}\n`);
|
|
628
|
+
}
|
|
629
|
+
let format;
|
|
630
|
+
try {
|
|
631
|
+
format = resolveOutputFormat({ explicit: opts.output, isTty: Boolean(stdout.isTTY) });
|
|
632
|
+
}
|
|
633
|
+
catch (err) {
|
|
634
|
+
if (err instanceof InvalidOutputFormatError) {
|
|
635
|
+
stderr.write(`${err.message}\n`);
|
|
636
|
+
exit(1);
|
|
637
|
+
}
|
|
638
|
+
throw err;
|
|
639
|
+
}
|
|
640
|
+
if (format === 'json') {
|
|
641
|
+
stdout.write(formatOutput(data, 'json', ACCOUNT_SPEC));
|
|
642
|
+
}
|
|
643
|
+
else if (format === 'quiet') {
|
|
644
|
+
stdout.write(formatOutput(data, 'quiet', ACCOUNT_SPEC));
|
|
645
|
+
}
|
|
646
|
+
else {
|
|
647
|
+
stdout.write(formatAccountHuman(data));
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
catch (err) {
|
|
651
|
+
fail(err);
|
|
652
|
+
}
|
|
653
|
+
});
|
|
654
|
+
return program;
|
|
655
|
+
}
|
|
656
|
+
function isEntrypoint() {
|
|
657
|
+
const invoked = process.argv[1];
|
|
658
|
+
if (!invoked)
|
|
659
|
+
return false;
|
|
660
|
+
try {
|
|
661
|
+
return path.resolve(invoked) === fileURLToPath(import.meta.url);
|
|
662
|
+
}
|
|
663
|
+
catch {
|
|
664
|
+
return false;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
if (isEntrypoint()) {
|
|
668
|
+
buildProgram()
|
|
669
|
+
.parseAsync(process.argv)
|
|
670
|
+
.catch((err) => handleError(err));
|
|
671
|
+
}
|