@aubron/ankerts-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 +56 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1109 -0
- package/dist/index.js.map +1 -0
- package/package.json +48 -0
- package/skills/ankerts/SKILL.md +81 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1109 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { AnkerClient as AnkerClient2, toAnkerError } from "@aubron/ankerts";
|
|
5
|
+
|
|
6
|
+
// src/globals.ts
|
|
7
|
+
import { parseArgs } from "util";
|
|
8
|
+
var GLOBAL_FLAGS = [
|
|
9
|
+
{
|
|
10
|
+
name: "output",
|
|
11
|
+
type: "string",
|
|
12
|
+
description: "Output format: json | ndjson | text. Default: json when piped, text on a TTY."
|
|
13
|
+
},
|
|
14
|
+
{ name: "json", type: "boolean", description: "Alias for --output json." },
|
|
15
|
+
{
|
|
16
|
+
name: "quiet",
|
|
17
|
+
type: "boolean",
|
|
18
|
+
short: "q",
|
|
19
|
+
description: "Bare values, one per line \u2014 for $() capture and xargs."
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
name: "fields",
|
|
23
|
+
type: "string",
|
|
24
|
+
description: "Comma-separated field mask (dotted paths) to trim large objects."
|
|
25
|
+
},
|
|
26
|
+
{ name: "dry-run", type: "boolean", description: "Validate and report; mutate nothing." },
|
|
27
|
+
{ name: "yes", type: "boolean", description: "Bypass confirmations / safety warnings." },
|
|
28
|
+
{
|
|
29
|
+
name: "printer",
|
|
30
|
+
type: "string",
|
|
31
|
+
description: "Target printer by DUID, serial, name, or index. Default: selected printer."
|
|
32
|
+
},
|
|
33
|
+
{ name: "timeout", type: "string", description: "Override the command timeout, in seconds." },
|
|
34
|
+
{ name: "no-input", type: "boolean", description: "Never prompt; fail fast instead." },
|
|
35
|
+
{ name: "insecure", type: "boolean", description: "Disable TLS verification (testing only)." },
|
|
36
|
+
{ name: "help", type: "boolean", short: "h", description: "Show help for this command." }
|
|
37
|
+
];
|
|
38
|
+
var EXIT_CODES = {
|
|
39
|
+
0: "success",
|
|
40
|
+
1: "generic / unexpected failure",
|
|
41
|
+
2: "usage error (bad/missing args)",
|
|
42
|
+
3: "auth error (login required/expired/captcha)",
|
|
43
|
+
4: "printer not found / not selected",
|
|
44
|
+
5: "connectivity/timeout \u2014 RETRIABLE",
|
|
45
|
+
6: "transport unavailable for this op (e.g. upload needs LAN)",
|
|
46
|
+
7: "printer-side error (gcode rejected, job refused)"
|
|
47
|
+
};
|
|
48
|
+
function buildParseOptions(flags) {
|
|
49
|
+
const options = {};
|
|
50
|
+
for (const f of flags) {
|
|
51
|
+
options[f.name] = {
|
|
52
|
+
type: f.type === "boolean" ? "boolean" : "string",
|
|
53
|
+
...f.short ? { short: f.short } : {},
|
|
54
|
+
...f.multiple ? { multiple: true } : {}
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return options;
|
|
58
|
+
}
|
|
59
|
+
function parseCommandArgs(argv, flags) {
|
|
60
|
+
const all = [
|
|
61
|
+
...GLOBAL_FLAGS,
|
|
62
|
+
...flags.filter((f) => !GLOBAL_FLAGS.some((g) => g.name === f.name))
|
|
63
|
+
];
|
|
64
|
+
const numberFlags = new Set(all.filter((f) => f.type === "number").map((f) => f.name));
|
|
65
|
+
const { values, positionals } = parseArgs({
|
|
66
|
+
args: argv,
|
|
67
|
+
options: buildParseOptions(all),
|
|
68
|
+
allowPositionals: true,
|
|
69
|
+
strict: true
|
|
70
|
+
});
|
|
71
|
+
const out = {};
|
|
72
|
+
for (const [k, v] of Object.entries(values)) {
|
|
73
|
+
out[k] = numberFlags.has(k) && typeof v === "string" ? Number(v) : v;
|
|
74
|
+
}
|
|
75
|
+
return { values: out, positionals };
|
|
76
|
+
}
|
|
77
|
+
function extractGlobals(values) {
|
|
78
|
+
const fields = typeof values.fields === "string" ? values.fields.split(",").map((s) => s.trim()) : void 0;
|
|
79
|
+
return {
|
|
80
|
+
output: typeof values.output === "string" ? values.output : void 0,
|
|
81
|
+
json: values.json === true,
|
|
82
|
+
quiet: values.quiet === true,
|
|
83
|
+
fields,
|
|
84
|
+
dryRun: values["dry-run"] === true,
|
|
85
|
+
yes: values.yes === true,
|
|
86
|
+
printer: typeof values.printer === "string" ? values.printer : void 0,
|
|
87
|
+
timeout: typeof values.timeout === "string" ? Number(values.timeout) : void 0,
|
|
88
|
+
noInput: values["no-input"] === true,
|
|
89
|
+
reveal: values.reveal === true,
|
|
90
|
+
insecure: values.insecure === true,
|
|
91
|
+
help: values.help === true
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// src/describe.ts
|
|
96
|
+
function buildDescribeTree(specs) {
|
|
97
|
+
return {
|
|
98
|
+
name: "ankerts",
|
|
99
|
+
summary: "Agent-first CLI for AnkerMake / eufyMake M5 printers.",
|
|
100
|
+
transports: {
|
|
101
|
+
mqtt: { use: "gcode, status, control", reachability: "anywhere (internet + creds)" },
|
|
102
|
+
pppp: { use: "file upload, camera", reachability: "LAN only (needs discovered IP)" },
|
|
103
|
+
https: { use: "login, account, printer list", reachability: "anywhere" }
|
|
104
|
+
},
|
|
105
|
+
exitCodes: Object.fromEntries(Object.entries(EXIT_CODES).map(([k, v]) => [Number(k), v])),
|
|
106
|
+
globalFlags: GLOBAL_FLAGS,
|
|
107
|
+
commands: specs.map((s) => ({
|
|
108
|
+
path: s.path,
|
|
109
|
+
command: s.path.join(" "),
|
|
110
|
+
summary: s.summary,
|
|
111
|
+
description: s.description,
|
|
112
|
+
transport: s.transport,
|
|
113
|
+
args: s.args,
|
|
114
|
+
flags: s.flags,
|
|
115
|
+
exitCodes: s.exitCodes,
|
|
116
|
+
examples: s.examples
|
|
117
|
+
}))
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// src/spec.ts
|
|
122
|
+
function defineCommand(input) {
|
|
123
|
+
return {
|
|
124
|
+
description: input.summary,
|
|
125
|
+
transport: "none",
|
|
126
|
+
flags: [],
|
|
127
|
+
args: [],
|
|
128
|
+
exitCodes: [0, 1, 2],
|
|
129
|
+
examples: [],
|
|
130
|
+
...input
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// src/commands/auth.ts
|
|
135
|
+
import { AnkerClient, ConfigStore, redactConfig, UsageError as UsageError2 } from "@aubron/ankerts";
|
|
136
|
+
|
|
137
|
+
// src/runtime.ts
|
|
138
|
+
import { UsageError } from "@aubron/ankerts";
|
|
139
|
+
function str(v) {
|
|
140
|
+
return typeof v === "string" ? v : "";
|
|
141
|
+
}
|
|
142
|
+
function requirePositional(ctx, index, name) {
|
|
143
|
+
const v = ctx.args.positionals[index];
|
|
144
|
+
if (!v) {
|
|
145
|
+
throw new UsageError({
|
|
146
|
+
message: `missing required argument <${name}>`,
|
|
147
|
+
hint: `See \`ankerts ${ctx.args.positionals.slice(0, index).join(" ")} --help\`.`,
|
|
148
|
+
input: { argument: name }
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
return v;
|
|
152
|
+
}
|
|
153
|
+
function timeoutMs(ctx, fallbackMs) {
|
|
154
|
+
const t = ctx.globals.timeout;
|
|
155
|
+
return typeof t === "number" && !Number.isNaN(t) ? t * 1e3 : fallbackMs;
|
|
156
|
+
}
|
|
157
|
+
function flagBool(args, name) {
|
|
158
|
+
return args.values[name] === true;
|
|
159
|
+
}
|
|
160
|
+
function flagStr(args, name) {
|
|
161
|
+
const v = args.values[name];
|
|
162
|
+
return typeof v === "string" ? v : void 0;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// src/commands/auth.ts
|
|
166
|
+
var login = defineCommand({
|
|
167
|
+
path: ["login"],
|
|
168
|
+
summary: "Authenticate to an AnkerMake/eufyMake account and store credentials.",
|
|
169
|
+
description: "Logs in over the cloud HTTPS API (email/password \u2192 token; the password is ECDH-encrypted in transit), then fetches the account's printer list and keys. If the API returns a captcha challenge, you get a structured, actionable error (captcha_id + image URL) \u2014 never a hang. Re-run with --captcha-answer to solve it.",
|
|
170
|
+
transport: "https",
|
|
171
|
+
flags: [
|
|
172
|
+
{ name: "email", type: "string", description: "Account email." },
|
|
173
|
+
{
|
|
174
|
+
name: "password",
|
|
175
|
+
type: "string",
|
|
176
|
+
description: "Account password. Use `-` to read from stdin, or set ANKER_PASSWORD."
|
|
177
|
+
},
|
|
178
|
+
{ name: "country", type: "string", description: "2-letter country code selecting the region." },
|
|
179
|
+
{ name: "save", type: "boolean", description: "Persist credentials to the config store." },
|
|
180
|
+
{ name: "captcha-id", type: "string", description: "Captcha id from a prior captcha error." },
|
|
181
|
+
{ name: "captcha-answer", type: "string", description: "Captcha answer text." }
|
|
182
|
+
],
|
|
183
|
+
exitCodes: [0, 2, 3, 5],
|
|
184
|
+
examples: [
|
|
185
|
+
{
|
|
186
|
+
description: "Log in and store credentials",
|
|
187
|
+
cmd: "ankerts login --email me@example.com --password hunter2 --country US --save",
|
|
188
|
+
output: '{ "account": { "email": "me@example.com", "region": "us" }, "printers": 1 }'
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
description: "Read the password from stdin (keeps it out of argv/history)",
|
|
192
|
+
cmd: "echo $PW | ankerts login --email me@example.com --password - --country US --save"
|
|
193
|
+
}
|
|
194
|
+
],
|
|
195
|
+
async run(ctx) {
|
|
196
|
+
const email = str(ctx.args.values.email);
|
|
197
|
+
const country = str(ctx.args.values.country);
|
|
198
|
+
let password = flagStr(ctx.args, "password") ?? process.env.ANKER_PASSWORD ?? "";
|
|
199
|
+
if (password === "-") password = (await ctx.readStdin()).trim();
|
|
200
|
+
if (!email || !country) {
|
|
201
|
+
throw new UsageError2({
|
|
202
|
+
message: "--email and --country are required",
|
|
203
|
+
input: { email: email || null, country: country || null }
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
if (!password) {
|
|
207
|
+
throw new UsageError2({
|
|
208
|
+
message: "no password provided",
|
|
209
|
+
hint: "Pass --password <pw>, set ANKER_PASSWORD, or use --password - to read stdin."
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
if (ctx.globals.dryRun) {
|
|
213
|
+
ctx.out.emit({ dryRun: true, action: "login", email, country });
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const client = await AnkerClient.login({
|
|
217
|
+
email,
|
|
218
|
+
password,
|
|
219
|
+
country,
|
|
220
|
+
captchaId: flagStr(ctx.args, "captcha-id"),
|
|
221
|
+
captchaAnswer: flagStr(ctx.args, "captcha-answer"),
|
|
222
|
+
save: flagBool(ctx.args, "save")
|
|
223
|
+
});
|
|
224
|
+
const cfg = client.getConfig();
|
|
225
|
+
if (!flagBool(ctx.args, "save")) {
|
|
226
|
+
ctx.out.log("note: credentials were NOT saved (pass --save to persist).");
|
|
227
|
+
} else {
|
|
228
|
+
ctx.out.log("login ok \u2014 credentials stored.");
|
|
229
|
+
}
|
|
230
|
+
ctx.out.emit({ account: redactConfig(cfg).account, printers: cfg.printers.length });
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
var logout = defineCommand({
|
|
234
|
+
path: ["logout"],
|
|
235
|
+
summary: "Remove stored credentials and printer config.",
|
|
236
|
+
description: "Clears the account token from the local config store. Printers are forgotten.",
|
|
237
|
+
transport: "none",
|
|
238
|
+
exitCodes: [0, 1],
|
|
239
|
+
examples: [{ cmd: "ankerts logout" }],
|
|
240
|
+
run(ctx) {
|
|
241
|
+
const store = new ConfigStore();
|
|
242
|
+
if (ctx.globals.dryRun) {
|
|
243
|
+
ctx.out.emit({ dryRun: true, action: "logout", path: store.path });
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
store.save({ account: null, printers: [] });
|
|
247
|
+
ctx.out.emit({ ok: true, message: "logged out" });
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
var configShow = defineCommand({
|
|
251
|
+
path: ["config", "show"],
|
|
252
|
+
summary: "Show the current stored config (secrets redacted by default).",
|
|
253
|
+
description: "Prints the account and per-printer records from the local config store. Secrets (auth token, MQTT/PPPP keys) are redacted unless you pass --reveal.",
|
|
254
|
+
transport: "none",
|
|
255
|
+
flags: [{ name: "reveal", type: "boolean", description: "Show secret values in clear text." }],
|
|
256
|
+
exitCodes: [0, 1],
|
|
257
|
+
examples: [
|
|
258
|
+
{ description: "Inspect config safely", cmd: "ankerts config show" },
|
|
259
|
+
{
|
|
260
|
+
description: "Get the first printer's DUID",
|
|
261
|
+
cmd: "ankerts config show --json | jq -r '.printers[0].duid'"
|
|
262
|
+
}
|
|
263
|
+
],
|
|
264
|
+
run(ctx) {
|
|
265
|
+
const store = new ConfigStore();
|
|
266
|
+
ctx.out.emit(redactConfig(store.load(), flagBool(ctx.args, "reveal")));
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
var authCommands = [login, logout, configShow];
|
|
270
|
+
|
|
271
|
+
// src/commands/gcode.ts
|
|
272
|
+
import { readFile } from "fs/promises";
|
|
273
|
+
import { inspectGcode } from "@aubron/ankerts";
|
|
274
|
+
function warnIfMutating(ctx, command) {
|
|
275
|
+
if (ctx.globals.yes || ctx.globals.dryRun) return;
|
|
276
|
+
const info = inspectGcode(command);
|
|
277
|
+
if (info.mutatesState && info.note) ctx.out.log(`warning: ${info.note}`);
|
|
278
|
+
}
|
|
279
|
+
var gcode = defineCommand({
|
|
280
|
+
path: ["gcode"],
|
|
281
|
+
summary: "Send gcode and return the parsed response (truncation-aware).",
|
|
282
|
+
description: "Publishes gcode over MQTT and parses the reply into raw text, fields/reports, ok, recognized, and timedOut (distinct from recognized:false \u2014 a timeout is not a rejection). The firmware returns each reply as a single ~512-byte serial-buffer snapshot, so long replies (or one caught mid-write, e.g. M900) come back partial: those are flagged `truncated:true` (with a stderr warning) instead of being passed off as complete like the reference's `echo:Ad` bug. Accepts one command as args, many via --batch <file>, or `-` to read stdin (one per line, NDJSON result per line). State-mutating commands print a volatility warning on stderr unless --yes. Default timeouts are latency-class aware (M109/M190/G29/M303 get minutes); --wait-motion appends M400 so the call returns on true motion completion, not queue-accept.",
|
|
283
|
+
transport: "mqtt",
|
|
284
|
+
args: [
|
|
285
|
+
{
|
|
286
|
+
name: "CMD",
|
|
287
|
+
description: "Gcode to send (or `-` to read commands from stdin).",
|
|
288
|
+
variadic: true
|
|
289
|
+
}
|
|
290
|
+
],
|
|
291
|
+
flags: [
|
|
292
|
+
{ name: "no-wait", type: "boolean", description: "Fire-and-forget; don't collect a response." },
|
|
293
|
+
{
|
|
294
|
+
name: "wait-motion",
|
|
295
|
+
type: "boolean",
|
|
296
|
+
description: "Append M400 \u2014 return on motion complete."
|
|
297
|
+
},
|
|
298
|
+
{ name: "batch", type: "string", description: "Read commands from a file (one per line)." }
|
|
299
|
+
],
|
|
300
|
+
exitCodes: [0, 1, 3, 4, 5, 7],
|
|
301
|
+
examples: [
|
|
302
|
+
{
|
|
303
|
+
description: "Read the firmware name (long reply \u2192 truncated:true, name still present)",
|
|
304
|
+
cmd: "ankerts gcode M115 --json | jq '{firmware:.fields.FIRMWARE_NAME, truncated}'",
|
|
305
|
+
output: '{ "firmware": "Marlin V8111_V3.2.2 (...)", "truncated": true }'
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
description: "A short reply comes back whole",
|
|
309
|
+
cmd: "ankerts gcode M105 --json | jq -r '.raw'",
|
|
310
|
+
output: "ok T:29.12 /0.00 B:30.31 /0.00"
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
description: "Detect an unknown command (not a truncation)",
|
|
314
|
+
cmd: "ankerts gcode M9998 --json | jq '{recognized, truncated}'",
|
|
315
|
+
output: '{ "recognized": false, "truncated": false }'
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
description: "Batch from stdin, one NDJSON result per line",
|
|
319
|
+
cmd: "printf 'M104 S200\\nM140 S60\\n' | ankerts gcode -"
|
|
320
|
+
}
|
|
321
|
+
],
|
|
322
|
+
async run(ctx) {
|
|
323
|
+
const client = ctx.client();
|
|
324
|
+
const opts = {
|
|
325
|
+
timeoutMs: timeoutMs(ctx, 0) || void 0,
|
|
326
|
+
wait: !flagBool(ctx.args, "no-wait"),
|
|
327
|
+
waitMotion: flagBool(ctx.args, "wait-motion")
|
|
328
|
+
};
|
|
329
|
+
const batchFile = flagStr(ctx.args, "batch");
|
|
330
|
+
let commands;
|
|
331
|
+
if (batchFile) {
|
|
332
|
+
commands = (await readFile(batchFile, "utf8")).split("\n").map((l) => l.trim()).filter(Boolean);
|
|
333
|
+
} else if (ctx.args.positionals[0] === "-") {
|
|
334
|
+
commands = (await ctx.readStdin()).split("\n").map((l) => l.trim()).filter(Boolean);
|
|
335
|
+
} else {
|
|
336
|
+
const single = ctx.args.positionals.join(" ").trim();
|
|
337
|
+
if (!single) {
|
|
338
|
+
requirePositional(ctx, 0, "CMD");
|
|
339
|
+
}
|
|
340
|
+
commands = [single];
|
|
341
|
+
}
|
|
342
|
+
if (ctx.globals.dryRun) {
|
|
343
|
+
ctx.out.emit(
|
|
344
|
+
commands.map((command) => ({ dryRun: true, command, inspect: inspectGcode(command) }))
|
|
345
|
+
);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
for (const command of commands) warnIfMutating(ctx, command);
|
|
349
|
+
if (commands.length === 1) {
|
|
350
|
+
const result = await client.gcode(commands[0], opts);
|
|
351
|
+
await client.close();
|
|
352
|
+
if (result.truncated) {
|
|
353
|
+
ctx.out.log(
|
|
354
|
+
"warning: reply was truncated by the firmware's ~512-byte serial buffer (truncated=true) \u2014 output may be incomplete."
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
ctx.out.emit(result);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
for await (const result of client.gcodeBatch(commands, opts)) {
|
|
361
|
+
ctx.out.ndjsonLine(result);
|
|
362
|
+
}
|
|
363
|
+
await client.close();
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
var stateSnapshot = defineCommand({
|
|
367
|
+
path: ["state", "snapshot"],
|
|
368
|
+
summary: "Capture machine settings (M503) as a parsed object.",
|
|
369
|
+
description: "Sends M503 over MQTT and parses the settings dump into a structured object (linear advance K, hotend PID, steps/mm, probe Z offset, and the report map). NOTE: M503's output usually exceeds the firmware's ~512-byte reply window, so the snapshot can be partial \u2014 check `result.truncated`. (A piecewise reader that stitches the full set from short per-setting queries is tracked as a follow-up.) Pair with `state restore` to undo volatile changes.",
|
|
370
|
+
transport: "mqtt",
|
|
371
|
+
exitCodes: [0, 1, 3, 4, 5],
|
|
372
|
+
examples: [
|
|
373
|
+
{
|
|
374
|
+
description: "Snapshot before tuning",
|
|
375
|
+
cmd: "ankerts state snapshot --json | jq '.hotendPid'"
|
|
376
|
+
}
|
|
377
|
+
],
|
|
378
|
+
async run(ctx) {
|
|
379
|
+
const client = ctx.client();
|
|
380
|
+
const settings = await client.snapshotState();
|
|
381
|
+
await client.close();
|
|
382
|
+
ctx.out.emit(settings);
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
var stateRestore = defineCommand({
|
|
386
|
+
path: ["state", "restore"],
|
|
387
|
+
summary: "Reload settings from EEPROM (M501), discarding volatile changes.",
|
|
388
|
+
description: "Sends M501 to reload the saved EEPROM settings, reverting any volatile (RAM) changes made this power cycle (e.g. an `M900 K` that would otherwise contaminate the next print).",
|
|
389
|
+
transport: "mqtt",
|
|
390
|
+
exitCodes: [0, 1, 3, 4, 5, 7],
|
|
391
|
+
examples: [{ cmd: "ankerts state restore" }],
|
|
392
|
+
async run(ctx) {
|
|
393
|
+
const client = ctx.client();
|
|
394
|
+
const result = await client.restoreState();
|
|
395
|
+
await client.close();
|
|
396
|
+
ctx.out.emit(result);
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
var gcodeCommands = [gcode, stateSnapshot, stateRestore];
|
|
400
|
+
|
|
401
|
+
// src/commands/jobs.ts
|
|
402
|
+
import { AnkerError } from "@aubron/ankerts";
|
|
403
|
+
var discover = defineCommand({
|
|
404
|
+
path: ["discover"],
|
|
405
|
+
summary: "Find printers on the local network (PPPP LAN search).",
|
|
406
|
+
description: "Broadcasts a PPPP LAN_SEARCH over UDP and collects replies. Discovery is flaky (UDP broadcast), so it retries. With --store, the discovered IPs are written into config \u2014 the prerequisite for LAN file upload. `print` runs this automatically when a printer's IP is missing.",
|
|
407
|
+
transport: "pppp",
|
|
408
|
+
flags: [
|
|
409
|
+
{ name: "store", type: "boolean", description: "Write discovered IPs into config." },
|
|
410
|
+
{ name: "retries", type: "string", description: "Discovery attempts (default 3)." }
|
|
411
|
+
],
|
|
412
|
+
exitCodes: [0, 1, 5],
|
|
413
|
+
examples: [
|
|
414
|
+
{ description: "Discover and store IPs", cmd: "ankerts discover --store" },
|
|
415
|
+
{
|
|
416
|
+
description: "Bare DUIDs of printers on the LAN",
|
|
417
|
+
cmd: "ankerts discover -q | xargs -n1 echo found:"
|
|
418
|
+
}
|
|
419
|
+
],
|
|
420
|
+
async run(ctx) {
|
|
421
|
+
const client = ctx.client();
|
|
422
|
+
const retries = Number(ctx.args.values.retries ?? 3) || 3;
|
|
423
|
+
const found = await client.discoverLan({
|
|
424
|
+
store: flagBool(ctx.args, "store"),
|
|
425
|
+
retries,
|
|
426
|
+
timeoutMs: timeoutMs(ctx, 1e3)
|
|
427
|
+
});
|
|
428
|
+
ctx.out.emit(found, { quietProjection: ["duid"] });
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
var print = defineCommand({
|
|
432
|
+
path: ["print"],
|
|
433
|
+
summary: "Upload a gcode file over the LAN and start the print.",
|
|
434
|
+
description: "Uploads via PPPP (LAN only) and, by default, starts the job. Auto-runs discovery to find/store the printer IP if it is unknown. Off-LAN this exits 6 (transport unavailable) with a structured error naming PPPP and the `discover --store` fix. Upload progress streams as NDJSON on stderr. `print` returns a job handle by default (it does NOT hold the process for the whole print) \u2014 use --wait-start to block until the job actually starts, or detach and poll with `printer wait --until complete`. By DEFAULT it auto-fixes the LCD ETA: a third-party slicer's embedded time/filament estimate is transcoded into the Anker `;TIME:` header (auto-detected \u2014 a no-op on natively-sliced files; always on a copy, never mutating your file). Pass --no-fix-metadata to upload the file byte-for-byte untouched.",
|
|
435
|
+
transport: "pppp",
|
|
436
|
+
args: [{ name: "file.gcode", description: "Path to the gcode file to upload.", required: true }],
|
|
437
|
+
flags: [
|
|
438
|
+
{ name: "no-start", type: "boolean", description: "Upload only; don't start the print." },
|
|
439
|
+
{ name: "transport", type: "string", description: "lan | auto (default auto; LAN only here)." },
|
|
440
|
+
{
|
|
441
|
+
name: "no-fix-metadata",
|
|
442
|
+
type: "boolean",
|
|
443
|
+
description: "Upload the file untouched (skip the automatic slicer-metadata ETA fix)."
|
|
444
|
+
},
|
|
445
|
+
{
|
|
446
|
+
name: "wait-start",
|
|
447
|
+
type: "boolean",
|
|
448
|
+
description: "Block until the job state flips to printing."
|
|
449
|
+
},
|
|
450
|
+
{
|
|
451
|
+
name: "wait-complete",
|
|
452
|
+
type: "boolean",
|
|
453
|
+
description: "Block until the print completes (long-running, resumable)."
|
|
454
|
+
}
|
|
455
|
+
],
|
|
456
|
+
exitCodes: [0, 1, 4, 5, 6, 7],
|
|
457
|
+
examples: [
|
|
458
|
+
{
|
|
459
|
+
description: "Upload + start (auto-discovers IP if needed)",
|
|
460
|
+
cmd: "ankerts print tower.gcode",
|
|
461
|
+
output: '{ "name": "tower.gcode", "started": true, "transport": "lan" }'
|
|
462
|
+
},
|
|
463
|
+
{
|
|
464
|
+
description: "Off-LAN failure is structured and actionable (exit 6)",
|
|
465
|
+
cmd: "ankerts print tower.gcode --json | jq -r '.error.hint'",
|
|
466
|
+
output: "Run `ankerts discover --store` while on the same LAN as the printer, then retry."
|
|
467
|
+
},
|
|
468
|
+
{
|
|
469
|
+
description: "Upload an OrcaSlicer file untouched (no automatic ETA fix)",
|
|
470
|
+
cmd: "ankerts print tower.gcode --no-fix-metadata"
|
|
471
|
+
}
|
|
472
|
+
],
|
|
473
|
+
async run(ctx) {
|
|
474
|
+
const file = requirePositional(ctx, 0, "file.gcode");
|
|
475
|
+
const client = ctx.client();
|
|
476
|
+
if (ctx.globals.dryRun) {
|
|
477
|
+
ctx.out.emit({
|
|
478
|
+
dryRun: true,
|
|
479
|
+
action: "print",
|
|
480
|
+
file,
|
|
481
|
+
start: !flagBool(ctx.args, "no-start"),
|
|
482
|
+
fixMetadata: !flagBool(ctx.args, "no-fix-metadata")
|
|
483
|
+
});
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
const result = await client.uploadAndPrint(file, {
|
|
487
|
+
start: !flagBool(ctx.args, "no-start"),
|
|
488
|
+
fixMetadata: flagBool(ctx.args, "fix-metadata"),
|
|
489
|
+
transport: ctx.args.values.transport ?? "auto",
|
|
490
|
+
onProgress: (p) => ctx.out.log(JSON.stringify({ event: "upload", ...p }))
|
|
491
|
+
});
|
|
492
|
+
if (flagBool(ctx.args, "wait-start") || flagBool(ctx.args, "wait-complete")) {
|
|
493
|
+
const { parseWaitCondition: parseWaitCondition2 } = await import("@aubron/ankerts");
|
|
494
|
+
const cond = flagBool(ctx.args, "wait-complete") ? "complete" : "printing";
|
|
495
|
+
ctx.out.log(`waiting for job to reach "${cond}"\u2026`);
|
|
496
|
+
await client.waitFor(parseWaitCondition2(cond), {
|
|
497
|
+
timeoutMs: timeoutMs(ctx, flagBool(ctx.args, "wait-complete") ? 864e5 : 12e4),
|
|
498
|
+
onTick: (s) => ctx.out.log(JSON.stringify(s))
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
await client.close();
|
|
502
|
+
ctx.out.emit(result);
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
function jobControl(verb) {
|
|
506
|
+
return defineCommand({
|
|
507
|
+
path: ["job", verb],
|
|
508
|
+
summary: `${verb[0].toUpperCase()}${verb.slice(1)} the current print job.`,
|
|
509
|
+
description: `Sends the ${verb} control command to the printer over MQTT (PRINT_CONTROL). The control values were reverse-engineered and confirmed live on an M5: cancel returns the printer to idle with the heaters off; pause/resume toggle the print.`,
|
|
510
|
+
transport: "mqtt",
|
|
511
|
+
exitCodes: [0, 1, 3, 4, 5, 7],
|
|
512
|
+
examples: [{ cmd: `ankerts job ${verb}` }],
|
|
513
|
+
async run(ctx) {
|
|
514
|
+
const client = ctx.client();
|
|
515
|
+
if (ctx.globals.dryRun) {
|
|
516
|
+
ctx.out.emit({ dryRun: true, action: `job ${verb}` });
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
if (verb === "cancel") await client.cancelJob();
|
|
520
|
+
else if (verb === "pause") await client.pauseJob();
|
|
521
|
+
else await client.resumeJob();
|
|
522
|
+
await client.close();
|
|
523
|
+
ctx.out.emit({ ok: true, action: verb });
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
var camera = defineCommand({
|
|
528
|
+
path: ["camera", "capture"],
|
|
529
|
+
summary: "Capture the printer's video stream (PPPP, optional).",
|
|
530
|
+
description: "Captures the H.264 video stream over PPPP. This is lower-priority and not yet wired in this version; it returns a structured not-implemented error so agents can branch cleanly rather than hang.",
|
|
531
|
+
transport: "pppp",
|
|
532
|
+
args: [
|
|
533
|
+
{ name: "out.h264", description: "Output file for the raw H.264 stream.", required: true }
|
|
534
|
+
],
|
|
535
|
+
flags: [{ name: "max-size", type: "string", description: "Maximum bytes to capture." }],
|
|
536
|
+
exitCodes: [0, 1, 4, 5],
|
|
537
|
+
examples: [{ cmd: "ankerts camera capture out.h264 --max-size 5000000" }],
|
|
538
|
+
run(ctx) {
|
|
539
|
+
requirePositional(ctx, 0, "out.h264");
|
|
540
|
+
throw new AnkerError({
|
|
541
|
+
code: "not_implemented",
|
|
542
|
+
message: "Camera capture is not yet implemented in this version.",
|
|
543
|
+
transport: "pppp",
|
|
544
|
+
hint: "Use `ankerts printer status` for telemetry; camera streaming is planned."
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
var jobCommands = [
|
|
549
|
+
discover,
|
|
550
|
+
print,
|
|
551
|
+
jobControl("cancel"),
|
|
552
|
+
jobControl("pause"),
|
|
553
|
+
jobControl("resume"),
|
|
554
|
+
camera
|
|
555
|
+
];
|
|
556
|
+
|
|
557
|
+
// src/commands/printer.ts
|
|
558
|
+
import { parseWaitCondition } from "@aubron/ankerts";
|
|
559
|
+
var list = defineCommand({
|
|
560
|
+
path: ["printer", "list"],
|
|
561
|
+
summary: "List the printers on the account.",
|
|
562
|
+
description: "Reads the printer list from stored config (populated at login). No network call. Each entry includes DUID, serial, model, and the discovered LAN IP (empty until you run `ankerts discover --store`).",
|
|
563
|
+
transport: "none",
|
|
564
|
+
exitCodes: [0, 1, 4],
|
|
565
|
+
examples: [
|
|
566
|
+
{
|
|
567
|
+
description: "Get the first printer's DUID, clean stdout",
|
|
568
|
+
cmd: "ankerts printer list --json | jq -r '.[0].duid'",
|
|
569
|
+
output: "USPRAKM-000994-YYLLG"
|
|
570
|
+
},
|
|
571
|
+
{ description: "Bare DUIDs for scripting", cmd: "ankerts printer list -q" }
|
|
572
|
+
],
|
|
573
|
+
run(ctx) {
|
|
574
|
+
const printers = ctx.client().listPrinters();
|
|
575
|
+
ctx.out.emit(printers, { quietProjection: ["duid"] });
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
var status = defineCommand({
|
|
579
|
+
path: ["printer", "status"],
|
|
580
|
+
summary: "Show temperatures, job state, and progress.",
|
|
581
|
+
description: "Queries server-authoritative status over MQTT and normalizes it: temperatures in \xB0C (converted from 1/100 \xB0C), progress 0\u2013100. For third-party-sliced gcode the firmware's headline ETA is unreliable, so etaReliable is false and the bogus ETA is omitted. With --watch, streams status objects as NDJSON (one per line) until interrupted.",
|
|
582
|
+
transport: "mqtt",
|
|
583
|
+
flags: [
|
|
584
|
+
{ name: "watch", type: "boolean", description: "Stream status as NDJSON until interrupted." },
|
|
585
|
+
{ name: "poll", type: "string", description: "Watch poll interval in seconds (default 2)." }
|
|
586
|
+
],
|
|
587
|
+
exitCodes: [0, 1, 3, 4, 5],
|
|
588
|
+
examples: [
|
|
589
|
+
{ description: "One-shot status", cmd: "ankerts printer status --json | jq '.nozzle'" },
|
|
590
|
+
{
|
|
591
|
+
description: "Watch a print (NDJSON stream)",
|
|
592
|
+
cmd: "ankerts printer status --watch | jq -r '.job.progressPct'"
|
|
593
|
+
}
|
|
594
|
+
],
|
|
595
|
+
async run(ctx) {
|
|
596
|
+
const client = ctx.client();
|
|
597
|
+
if (!flagBool(ctx.args, "watch")) {
|
|
598
|
+
const s = await client.getStatus();
|
|
599
|
+
await client.close();
|
|
600
|
+
ctx.out.emit(s);
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
const pollSec = Number(ctx.args.values.poll ?? 2) || 2;
|
|
604
|
+
ctx.out.log("watching status (NDJSON on stdout); Ctrl-C to stop.");
|
|
605
|
+
await client.subscribeEvents(() => {
|
|
606
|
+
});
|
|
607
|
+
for (; ; ) {
|
|
608
|
+
const s = await client.getStatus();
|
|
609
|
+
ctx.out.ndjsonLine(s);
|
|
610
|
+
await new Promise((r) => setTimeout(r, pollSec * 1e3));
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
var select = defineCommand({
|
|
615
|
+
path: ["printer", "select"],
|
|
616
|
+
summary: "Set the default printer in config.",
|
|
617
|
+
description: "Selects a printer (by DUID, serial, name, or index) as the default target for subsequent commands. Persisted to the config store.",
|
|
618
|
+
transport: "none",
|
|
619
|
+
args: [
|
|
620
|
+
{
|
|
621
|
+
name: "duid|index",
|
|
622
|
+
description: "Printer reference (DUID, serial, name, or index).",
|
|
623
|
+
required: true
|
|
624
|
+
}
|
|
625
|
+
],
|
|
626
|
+
exitCodes: [0, 1, 2, 4],
|
|
627
|
+
examples: [
|
|
628
|
+
{ description: "Select by index", cmd: "ankerts printer select 0" },
|
|
629
|
+
{ description: "Select by DUID", cmd: "ankerts printer select USPRAKM-000994-YYLLG" }
|
|
630
|
+
],
|
|
631
|
+
run(ctx) {
|
|
632
|
+
const ref = requirePositional(ctx, 0, "duid|index");
|
|
633
|
+
const printer = ctx.client().selectPrinter(ref);
|
|
634
|
+
ctx.out.emit({ selected: printer.duid, name: printer.name });
|
|
635
|
+
}
|
|
636
|
+
});
|
|
637
|
+
var wait = defineCommand({
|
|
638
|
+
path: ["printer", "wait"],
|
|
639
|
+
summary: "Block until a printer condition holds (re-attachable).",
|
|
640
|
+
description: "Watches server-authoritative state and resolves once the condition holds \u2014 so a wait killed mid-flight can simply be re-issued and re-derives current state. Streams NDJSON status ticks on stderr while waiting; on success prints the final status to stdout (exit 0); on timeout exits 5 (retriable). Conditions: connected | lan | nozzle>=C | bed>=C | temp-stable | printing | idle | progress>=pct | layer>=n | complete | failed | cancelled | runout.",
|
|
641
|
+
transport: "mqtt",
|
|
642
|
+
flags: [
|
|
643
|
+
{ name: "until", type: "string", description: "The condition to wait for (required)." },
|
|
644
|
+
{ name: "poll", type: "string", description: "Poll interval in seconds (default 2)." }
|
|
645
|
+
],
|
|
646
|
+
exitCodes: [0, 1, 3, 4, 5],
|
|
647
|
+
examples: [
|
|
648
|
+
{
|
|
649
|
+
description: "Wait for the job to actually start (not just upload-accepted)",
|
|
650
|
+
cmd: "ankerts printer wait --until printing --timeout 120"
|
|
651
|
+
},
|
|
652
|
+
{
|
|
653
|
+
description: "Cool down after the print completes",
|
|
654
|
+
cmd: 'ankerts printer wait --until complete && ankerts gcode "M104 S0"'
|
|
655
|
+
}
|
|
656
|
+
],
|
|
657
|
+
async run(ctx) {
|
|
658
|
+
const until = ctx.args.values.until;
|
|
659
|
+
if (typeof until !== "string" || until === "") {
|
|
660
|
+
const { UsageError: UsageError4 } = await import("@aubron/ankerts");
|
|
661
|
+
throw new UsageError4({ message: "--until <condition> is required" });
|
|
662
|
+
}
|
|
663
|
+
const cond = parseWaitCondition(until);
|
|
664
|
+
const client = ctx.client();
|
|
665
|
+
const pollSec = Number(ctx.args.values.poll ?? 2) || 2;
|
|
666
|
+
const final = await client.waitFor(cond, {
|
|
667
|
+
timeoutMs: timeoutMs(ctx, 6e5),
|
|
668
|
+
pollMs: pollSec * 1e3,
|
|
669
|
+
onTick: (s) => ctx.out.log(JSON.stringify(s))
|
|
670
|
+
});
|
|
671
|
+
await client.close();
|
|
672
|
+
ctx.out.emit(final);
|
|
673
|
+
}
|
|
674
|
+
});
|
|
675
|
+
var printerCommands = [list, status, select, wait];
|
|
676
|
+
|
|
677
|
+
// src/commands/skills.ts
|
|
678
|
+
import { cpSync, existsSync } from "fs";
|
|
679
|
+
import { homedir } from "os";
|
|
680
|
+
import { dirname, join, resolve } from "path";
|
|
681
|
+
import { fileURLToPath } from "url";
|
|
682
|
+
import { UsageError as UsageError3 } from "@aubron/ankerts";
|
|
683
|
+
var SKILL_NAME = "ankerts";
|
|
684
|
+
function bundledSkillDir() {
|
|
685
|
+
return resolve(dirname(fileURLToPath(import.meta.url)), "..", "skills", SKILL_NAME);
|
|
686
|
+
}
|
|
687
|
+
var install = defineCommand({
|
|
688
|
+
path: ["skills", "install"],
|
|
689
|
+
summary: "Install the bundled `ankerts` Agent Skill into a skills directory.",
|
|
690
|
+
description: "Copies the SKILL.md that ships inside this package into a skills directory so an agent learns how to drive ankerts. Defaults to the project's .claude/skills/; use --global for ~/.claude/skills, or --dir to target any directory (works for any SKILL.md-compatible agent). Explicit and idempotent \u2014 re-run with --force to overwrite.",
|
|
691
|
+
transport: "none",
|
|
692
|
+
flags: [
|
|
693
|
+
{
|
|
694
|
+
name: "global",
|
|
695
|
+
type: "boolean",
|
|
696
|
+
description: "Install into ~/.claude/skills instead of the project."
|
|
697
|
+
},
|
|
698
|
+
{
|
|
699
|
+
name: "dir",
|
|
700
|
+
type: "string",
|
|
701
|
+
description: "Target skills directory (overrides --global/project)."
|
|
702
|
+
},
|
|
703
|
+
{ name: "force", type: "boolean", description: "Overwrite an existing install." }
|
|
704
|
+
],
|
|
705
|
+
exitCodes: [0, 1, 2],
|
|
706
|
+
examples: [
|
|
707
|
+
{ description: "Install into the current project", cmd: "ankerts skills install" },
|
|
708
|
+
{ description: "Install for all projects", cmd: "ankerts skills install --global" },
|
|
709
|
+
{
|
|
710
|
+
description: "Install for another agent",
|
|
711
|
+
cmd: "ankerts skills install --dir ~/.codex/skills"
|
|
712
|
+
}
|
|
713
|
+
],
|
|
714
|
+
run(ctx) {
|
|
715
|
+
const src = bundledSkillDir();
|
|
716
|
+
if (!existsSync(src)) {
|
|
717
|
+
throw new UsageError3({
|
|
718
|
+
code: "skill_not_bundled",
|
|
719
|
+
message: "Bundled skill not found next to the CLI",
|
|
720
|
+
hint: "Reinstall @aubron/ankerts-cli \u2014 the published package ships skills/ankerts/SKILL.md."
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
const dir = flagStr(ctx.args, "dir");
|
|
724
|
+
const skillsDir = dir ? resolve(dir) : flagBool(ctx.args, "global") ? join(homedir(), ".claude", "skills") : resolve(".claude", "skills");
|
|
725
|
+
const dest = join(skillsDir, SKILL_NAME);
|
|
726
|
+
if (existsSync(dest) && !flagBool(ctx.args, "force")) {
|
|
727
|
+
throw new UsageError3({
|
|
728
|
+
code: "skill_exists",
|
|
729
|
+
message: `Skill already installed at ${dest}`,
|
|
730
|
+
hint: "Re-run with --force to overwrite.",
|
|
731
|
+
input: { dest }
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
if (ctx.globals.dryRun) {
|
|
735
|
+
ctx.out.emit({ dryRun: true, action: "skills install", from: src, to: dest });
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
cpSync(src, dest, { recursive: true });
|
|
739
|
+
ctx.out.log(`installed ${SKILL_NAME} skill \u2192 ${dest}`);
|
|
740
|
+
ctx.out.emit({ installed: dest, skill: SKILL_NAME });
|
|
741
|
+
}
|
|
742
|
+
});
|
|
743
|
+
var skillsCommands = [install];
|
|
744
|
+
|
|
745
|
+
// src/commands/index.ts
|
|
746
|
+
var baseCommands = [
|
|
747
|
+
...authCommands,
|
|
748
|
+
...printerCommands,
|
|
749
|
+
...gcodeCommands,
|
|
750
|
+
...jobCommands,
|
|
751
|
+
...skillsCommands
|
|
752
|
+
];
|
|
753
|
+
var describe = defineCommand({
|
|
754
|
+
path: ["describe"],
|
|
755
|
+
summary: "Print the full command tree as JSON (machine-readable introspection).",
|
|
756
|
+
description: "Emits every command, flag, type, default, exit code, and example as one JSON document \u2014 so an agent can map the tool's entire surface in a single call instead of crawling --help pages.",
|
|
757
|
+
transport: "none",
|
|
758
|
+
exitCodes: [0, 1],
|
|
759
|
+
examples: [
|
|
760
|
+
{
|
|
761
|
+
description: "List every command path",
|
|
762
|
+
cmd: "ankerts describe --json | jq -r '.commands[].command'"
|
|
763
|
+
}
|
|
764
|
+
],
|
|
765
|
+
run(ctx) {
|
|
766
|
+
ctx.out.emit(buildDescribeTree(allCommands));
|
|
767
|
+
}
|
|
768
|
+
});
|
|
769
|
+
var allCommands = [...baseCommands, describe];
|
|
770
|
+
|
|
771
|
+
// src/help.ts
|
|
772
|
+
var BIN = "ankerts";
|
|
773
|
+
var TRANSPORT_BLURB = {
|
|
774
|
+
mqtt: "MQTT over TLS (Anker cloud broker) \u2014 works anywhere with internet + credentials.",
|
|
775
|
+
pppp: "PPPP (P2P over UDP) \u2014 LAN only. Needs the printer's IP stored via discovery.",
|
|
776
|
+
https: "HTTPS (Anker cloud API) \u2014 account auth and printer list; works anywhere.",
|
|
777
|
+
none: "No printer transport (local/config operation)."
|
|
778
|
+
};
|
|
779
|
+
function flagUsage(f) {
|
|
780
|
+
const dashName = f.name;
|
|
781
|
+
const short = f.short ? `-${f.short}, ` : "";
|
|
782
|
+
const valued = f.type === "boolean" ? "" : ` <${f.type}>`;
|
|
783
|
+
const def = f.default !== void 0 ? ` (default: ${String(f.default)})` : "";
|
|
784
|
+
return ` ${short}--${dashName}${valued}
|
|
785
|
+
${f.description}${def}`;
|
|
786
|
+
}
|
|
787
|
+
function renderCommandHelp(spec) {
|
|
788
|
+
const path = spec.path.join(" ");
|
|
789
|
+
const argUsage = spec.args.map(
|
|
790
|
+
(a) => a.required ? `<${a.name}${a.variadic ? "..." : ""}>` : `[${a.name}${a.variadic ? "..." : ""}]`
|
|
791
|
+
).join(" ");
|
|
792
|
+
const lines = [];
|
|
793
|
+
lines.push(`${BIN} ${path} \u2014 ${spec.summary}`, "");
|
|
794
|
+
lines.push("USAGE", ` ${BIN} ${path}${argUsage ? ` ${argUsage}` : ""} [flags]`, "");
|
|
795
|
+
lines.push("DESCRIPTION", ` ${spec.description}`, "");
|
|
796
|
+
lines.push("TRANSPORT", ` ${TRANSPORT_BLURB[spec.transport]}`, "");
|
|
797
|
+
if (spec.args.length) {
|
|
798
|
+
lines.push("ARGUMENTS");
|
|
799
|
+
for (const a of spec.args) {
|
|
800
|
+
lines.push(` ${a.name}${a.variadic ? "..." : ""} ${a.description}`);
|
|
801
|
+
}
|
|
802
|
+
lines.push("");
|
|
803
|
+
}
|
|
804
|
+
const cmdFlags = spec.flags.filter((f) => !GLOBAL_FLAGS.some((g) => g.name === f.name));
|
|
805
|
+
if (cmdFlags.length) {
|
|
806
|
+
lines.push("FLAGS");
|
|
807
|
+
for (const f of cmdFlags) lines.push(flagUsage(f));
|
|
808
|
+
lines.push("");
|
|
809
|
+
}
|
|
810
|
+
lines.push("GLOBAL FLAGS");
|
|
811
|
+
for (const f of GLOBAL_FLAGS) lines.push(flagUsage(f));
|
|
812
|
+
lines.push("");
|
|
813
|
+
lines.push("EXIT CODES");
|
|
814
|
+
for (const code of spec.exitCodes) lines.push(` ${code} ${EXIT_CODES[code] ?? ""}`);
|
|
815
|
+
lines.push("");
|
|
816
|
+
if (spec.examples.length) {
|
|
817
|
+
lines.push("EXAMPLES");
|
|
818
|
+
for (const ex of spec.examples) {
|
|
819
|
+
if (ex.description) lines.push(` # ${ex.description}`);
|
|
820
|
+
lines.push(` $ ${ex.cmd}`);
|
|
821
|
+
if (ex.output) for (const o of ex.output.split("\n")) lines.push(` ${o}`);
|
|
822
|
+
lines.push("");
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
return lines.join("\n");
|
|
826
|
+
}
|
|
827
|
+
function renderRootHelp(specs) {
|
|
828
|
+
const lines = [];
|
|
829
|
+
lines.push(
|
|
830
|
+
`${BIN} \u2014 agent-first CLI for AnkerMake / eufyMake M5 printers`,
|
|
831
|
+
"",
|
|
832
|
+
"USAGE",
|
|
833
|
+
` ${BIN} <noun> <verb> [args] [flags]`,
|
|
834
|
+
""
|
|
835
|
+
);
|
|
836
|
+
const groups = /* @__PURE__ */ new Map();
|
|
837
|
+
for (const s of specs) {
|
|
838
|
+
const noun = s.path[0];
|
|
839
|
+
(groups.get(noun) ?? groups.set(noun, []).get(noun)).push(s);
|
|
840
|
+
}
|
|
841
|
+
lines.push("COMMANDS");
|
|
842
|
+
for (const [noun, group] of groups) {
|
|
843
|
+
lines.push(` ${noun}`);
|
|
844
|
+
for (const s of group) {
|
|
845
|
+
lines.push(` ${s.path.join(" ").padEnd(22)} ${s.summary}`);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
lines.push("");
|
|
849
|
+
lines.push(
|
|
850
|
+
"CONCEPTS",
|
|
851
|
+
" The M5 speaks THREE independent transports, split by reachability:",
|
|
852
|
+
" \u2022 MQTT over TLS \u2014 gcode, status, control. Works ANYWHERE (internet + creds).",
|
|
853
|
+
" \u2022 PPPP over UDP \u2014 file upload, camera. LAN ONLY; needs a discovered IP.",
|
|
854
|
+
" \u2022 HTTPS cloud \u2014 login, account, printer list. Works anywhere.",
|
|
855
|
+
" Cloud-reachable \u2260 LAN-reachable: gcode can work while upload (exit 6) cannot,",
|
|
856
|
+
" until you run `ankerts discover --store` on the same LAN as the printer.",
|
|
857
|
+
"",
|
|
858
|
+
"OUTPUT",
|
|
859
|
+
" Data \u2192 stdout; logs/progress \u2192 stderr. JSON by default when piped (text on a",
|
|
860
|
+
" TTY). Use --output, --quiet, and --fields to shape it. `ankerts describe --json`",
|
|
861
|
+
" dumps the entire command tree for one-call introspection.",
|
|
862
|
+
"",
|
|
863
|
+
`Run \`${BIN} <command> --help\` for details, transport, exit codes, and examples.`
|
|
864
|
+
);
|
|
865
|
+
return lines.join("\n");
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// src/output.ts
|
|
869
|
+
function resolveMode(opts) {
|
|
870
|
+
const pick = (v) => v === "json" || v === "ndjson" || v === "text" ? v : void 0;
|
|
871
|
+
if (opts.json) return "json";
|
|
872
|
+
return pick(opts.flag) ?? pick(opts.env) ?? (opts.isTty ? "text" : "json");
|
|
873
|
+
}
|
|
874
|
+
function pickFields(value, paths) {
|
|
875
|
+
if (paths.length === 0) return value;
|
|
876
|
+
if (Array.isArray(value)) return value.map((v) => pickFields(v, paths));
|
|
877
|
+
if (value === null || typeof value !== "object") return value;
|
|
878
|
+
const out = {};
|
|
879
|
+
for (const path of paths) {
|
|
880
|
+
const segs = path.split(".");
|
|
881
|
+
let src = value;
|
|
882
|
+
for (const s of segs) {
|
|
883
|
+
src = src && typeof src === "object" ? src[s] : void 0;
|
|
884
|
+
}
|
|
885
|
+
if (src !== void 0) out[path] = src;
|
|
886
|
+
}
|
|
887
|
+
return out;
|
|
888
|
+
}
|
|
889
|
+
function getPath(obj, path) {
|
|
890
|
+
let cur = obj;
|
|
891
|
+
for (const s of path.split(".")) {
|
|
892
|
+
cur = cur && typeof cur === "object" ? cur[s] : void 0;
|
|
893
|
+
}
|
|
894
|
+
return cur;
|
|
895
|
+
}
|
|
896
|
+
function scalar(v) {
|
|
897
|
+
if (v === null || v === void 0) return "";
|
|
898
|
+
if (typeof v === "object") return JSON.stringify(v);
|
|
899
|
+
return String(v);
|
|
900
|
+
}
|
|
901
|
+
var Output = class {
|
|
902
|
+
mode;
|
|
903
|
+
quiet;
|
|
904
|
+
fields;
|
|
905
|
+
out;
|
|
906
|
+
err;
|
|
907
|
+
color;
|
|
908
|
+
constructor(opts = {}) {
|
|
909
|
+
this.mode = opts.mode ?? "json";
|
|
910
|
+
this.quiet = opts.quiet ?? false;
|
|
911
|
+
this.fields = opts.fields;
|
|
912
|
+
this.out = opts.stdout ?? ((s) => process.stdout.write(s));
|
|
913
|
+
this.err = opts.stderr ?? ((s) => process.stderr.write(s));
|
|
914
|
+
this.color = !!opts.isTty && !opts.noColor && this.mode === "text";
|
|
915
|
+
}
|
|
916
|
+
/** A diagnostic/progress line → stderr (never pollutes stdout). */
|
|
917
|
+
log(message) {
|
|
918
|
+
this.err(`${message}
|
|
919
|
+
`);
|
|
920
|
+
}
|
|
921
|
+
/** A single NDJSON object → stdout, newline-terminated (for streams). */
|
|
922
|
+
ndjsonLine(obj) {
|
|
923
|
+
const masked = this.fields ? pickFields(obj, this.fields) : obj;
|
|
924
|
+
this.out(`${JSON.stringify(masked)}
|
|
925
|
+
`);
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* Emit a command's primary result to stdout, honoring mode/quiet/fields.
|
|
929
|
+
* `quietProjection` is the default field(s) printed in --quiet mode (e.g.
|
|
930
|
+
* `["duid"]` so `printer list -q` yields bare DUIDs).
|
|
931
|
+
*/
|
|
932
|
+
emit(value, opts = {}) {
|
|
933
|
+
if (this.quiet) return this.emitQuiet(value, opts.quietProjection);
|
|
934
|
+
const masked = this.fields ? pickFields(value, this.fields) : value;
|
|
935
|
+
if (this.mode === "ndjson") {
|
|
936
|
+
const arr = Array.isArray(masked) ? masked : [masked];
|
|
937
|
+
for (const item of arr) this.out(`${JSON.stringify(item)}
|
|
938
|
+
`);
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
if (this.mode === "json") {
|
|
942
|
+
this.out(`${JSON.stringify(masked, null, 2)}
|
|
943
|
+
`);
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
this.out(`${this.renderText(masked)}
|
|
947
|
+
`);
|
|
948
|
+
}
|
|
949
|
+
emitQuiet(value, projection) {
|
|
950
|
+
const fields = this.fields ?? projection;
|
|
951
|
+
const rows = Array.isArray(value) ? value : [value];
|
|
952
|
+
for (const row of rows) {
|
|
953
|
+
if (fields && fields.length && row && typeof row === "object") {
|
|
954
|
+
this.out(`${fields.map((f) => scalar(getPath(row, f))).join(" ")}
|
|
955
|
+
`);
|
|
956
|
+
} else {
|
|
957
|
+
this.out(`${scalar(row)}
|
|
958
|
+
`);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
/**
|
|
963
|
+
* Emit a structured error (brief §3). In json/ndjson mode the `{ error: … }`
|
|
964
|
+
* body goes to STDOUT so `… --json | jq '.error.hint'` works; in text mode a
|
|
965
|
+
* readable rendering goes to STDERR. The exit code is set by the caller.
|
|
966
|
+
*/
|
|
967
|
+
emitError(body) {
|
|
968
|
+
if (this.mode === "json") {
|
|
969
|
+
this.out(`${JSON.stringify(body, null, 2)}
|
|
970
|
+
`);
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
if (this.mode === "ndjson") {
|
|
974
|
+
this.out(`${JSON.stringify(body)}
|
|
975
|
+
`);
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
const e = body.error;
|
|
979
|
+
const parts = [`error[${e.code}]: ${e.message}`];
|
|
980
|
+
if (e.transport) parts.push(` transport: ${String(e.transport)}`);
|
|
981
|
+
parts.push(` retriable: ${String(e.retriable)}`);
|
|
982
|
+
if (e.hint) parts.push(` hint: ${String(e.hint)}`);
|
|
983
|
+
if (e.input) parts.push(` input: ${JSON.stringify(e.input)}`);
|
|
984
|
+
this.err(`${parts.join("\n")}
|
|
985
|
+
`);
|
|
986
|
+
}
|
|
987
|
+
/** Best-effort human rendering for text mode. */
|
|
988
|
+
renderText(value) {
|
|
989
|
+
if (value === null || value === void 0) return "";
|
|
990
|
+
if (Array.isArray(value)) {
|
|
991
|
+
return value.map((v) => this.renderText(v)).join("\n");
|
|
992
|
+
}
|
|
993
|
+
if (typeof value === "object") {
|
|
994
|
+
return Object.entries(value).map(([k, v]) => `${k}: ${typeof v === "object" && v ? JSON.stringify(v) : scalar(v)}`).join("\n");
|
|
995
|
+
}
|
|
996
|
+
return scalar(value);
|
|
997
|
+
}
|
|
998
|
+
};
|
|
999
|
+
|
|
1000
|
+
// src/index.ts
|
|
1001
|
+
function matchCommand(argv) {
|
|
1002
|
+
const nounTokens = [];
|
|
1003
|
+
for (const tok of argv) {
|
|
1004
|
+
if (tok.startsWith("-")) break;
|
|
1005
|
+
nounTokens.push(tok);
|
|
1006
|
+
}
|
|
1007
|
+
let best;
|
|
1008
|
+
for (const spec of allCommands) {
|
|
1009
|
+
if (spec.path.length > nounTokens.length) continue;
|
|
1010
|
+
if (spec.path.every((seg, i) => nounTokens[i] === seg)) {
|
|
1011
|
+
if (!best || spec.path.length > best.path.length) best = spec;
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
if (!best) return { rest: argv };
|
|
1015
|
+
const rest = [];
|
|
1016
|
+
let dropped = 0;
|
|
1017
|
+
for (const tok of argv) {
|
|
1018
|
+
if (dropped < best.path.length && !tok.startsWith("-") && tok === best.path[dropped]) {
|
|
1019
|
+
dropped++;
|
|
1020
|
+
continue;
|
|
1021
|
+
}
|
|
1022
|
+
rest.push(tok);
|
|
1023
|
+
}
|
|
1024
|
+
return { spec: best, rest };
|
|
1025
|
+
}
|
|
1026
|
+
function readStdin() {
|
|
1027
|
+
return new Promise((resolve2, reject) => {
|
|
1028
|
+
if (process.stdin.isTTY) return resolve2("");
|
|
1029
|
+
let data = "";
|
|
1030
|
+
process.stdin.setEncoding("utf8");
|
|
1031
|
+
process.stdin.on("data", (chunk) => data += chunk);
|
|
1032
|
+
process.stdin.on("end", () => resolve2(data));
|
|
1033
|
+
process.stdin.on("error", reject);
|
|
1034
|
+
});
|
|
1035
|
+
}
|
|
1036
|
+
async function main() {
|
|
1037
|
+
const argv = process.argv.slice(2);
|
|
1038
|
+
const isTty = !!process.stdout.isTTY;
|
|
1039
|
+
const noColor = process.env.NO_COLOR !== void 0;
|
|
1040
|
+
const { spec, rest } = matchCommand(argv);
|
|
1041
|
+
let parsed;
|
|
1042
|
+
try {
|
|
1043
|
+
parsed = parseCommandArgs(rest, spec?.flags ?? []);
|
|
1044
|
+
} catch (err) {
|
|
1045
|
+
const out2 = new Output({ mode: isTty ? "text" : "json", isTty, noColor });
|
|
1046
|
+
const e = toAnkerError(err);
|
|
1047
|
+
out2.emitError({ error: { ...e.body(), code: "usage", retriable: false } });
|
|
1048
|
+
return 2;
|
|
1049
|
+
}
|
|
1050
|
+
const globals = extractGlobals(parsed.values);
|
|
1051
|
+
const out = new Output({
|
|
1052
|
+
mode: resolveMode({
|
|
1053
|
+
flag: globals.output,
|
|
1054
|
+
json: globals.json,
|
|
1055
|
+
env: process.env.ANKER_OUTPUT,
|
|
1056
|
+
isTty
|
|
1057
|
+
}),
|
|
1058
|
+
quiet: globals.quiet,
|
|
1059
|
+
fields: globals.fields,
|
|
1060
|
+
isTty,
|
|
1061
|
+
noColor
|
|
1062
|
+
});
|
|
1063
|
+
if (!spec) {
|
|
1064
|
+
if (argv.length && !globals.help && argv[0] !== "help") {
|
|
1065
|
+
out.emitError({
|
|
1066
|
+
error: {
|
|
1067
|
+
code: "unknown_command",
|
|
1068
|
+
message: `unknown command: ${argv.filter((a) => !a.startsWith("-")).join(" ") || "(none)"}`,
|
|
1069
|
+
retriable: false,
|
|
1070
|
+
hint: "Run `ankerts --help`, or `ankerts describe --json` to introspect the full tree."
|
|
1071
|
+
}
|
|
1072
|
+
});
|
|
1073
|
+
return 2;
|
|
1074
|
+
}
|
|
1075
|
+
process.stdout.write(`${renderRootHelp(allCommands)}
|
|
1076
|
+
`);
|
|
1077
|
+
return 0;
|
|
1078
|
+
}
|
|
1079
|
+
if (globals.help) {
|
|
1080
|
+
process.stdout.write(`${renderCommandHelp(spec)}
|
|
1081
|
+
`);
|
|
1082
|
+
return 0;
|
|
1083
|
+
}
|
|
1084
|
+
const ctx = {
|
|
1085
|
+
out,
|
|
1086
|
+
args: parsed,
|
|
1087
|
+
globals,
|
|
1088
|
+
client: () => AnkerClient2.fromStoredConfig(void 0, {
|
|
1089
|
+
log: (m) => out.log(m),
|
|
1090
|
+
printer: globals.printer,
|
|
1091
|
+
insecure: globals.insecure
|
|
1092
|
+
}),
|
|
1093
|
+
readStdin
|
|
1094
|
+
};
|
|
1095
|
+
try {
|
|
1096
|
+
await spec.run(ctx);
|
|
1097
|
+
return 0;
|
|
1098
|
+
} catch (err) {
|
|
1099
|
+
const e = toAnkerError(err);
|
|
1100
|
+
out.emitError(e.toJSON());
|
|
1101
|
+
return e.exitCode;
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
main().then((code) => process.exit(code)).catch((err) => {
|
|
1105
|
+
process.stderr.write(`${String(err)}
|
|
1106
|
+
`);
|
|
1107
|
+
process.exit(1);
|
|
1108
|
+
});
|
|
1109
|
+
//# sourceMappingURL=index.js.map
|