@agentscraper-cc/cli 0.0.0-stage → 0.0.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 +75 -2
- package/bin/cli.js +13 -0
- package/brand.json +14 -0
- package/openapi.json +1277 -0
- package/package.json +44 -4
- package/src/commands.js +381 -0
- package/src/config.js +102 -0
- package/src/help.js +127 -0
- package/src/http.js +96 -0
- package/src/main.js +282 -0
- package/src/output.js +57 -0
- package/src/spec.js +109 -0
- package/src/update.js +71 -0
package/package.json
CHANGED
|
@@ -1,6 +1,46 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agentscraper-cc/cli",
|
|
3
|
-
"version": "0.0.0
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "AgentScraper CLI: live web pages as clean Markdown, text or HTML in your terminal, through the Live Web Data API. The commands follow the live API spec.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"cli",
|
|
7
|
+
"web-scraping",
|
|
8
|
+
"markdown",
|
|
9
|
+
"llm",
|
|
10
|
+
"ai-agents",
|
|
11
|
+
"rag",
|
|
12
|
+
"mcp",
|
|
13
|
+
"live-web-data",
|
|
14
|
+
"agentscraper"
|
|
15
|
+
],
|
|
16
|
+
"homepage": "https://agentscraper.cc",
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/agentscraper-cc/cli/issues"
|
|
19
|
+
},
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/agentscraper-cc/cli.git"
|
|
23
|
+
},
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"author": "Teneo Labs Inc.",
|
|
26
|
+
"type": "module",
|
|
27
|
+
"bin": {
|
|
28
|
+
"agentscraper": "bin/cli.js"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"bin",
|
|
32
|
+
"src",
|
|
33
|
+
"openapi.json",
|
|
34
|
+
"brand.json"
|
|
35
|
+
],
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=18"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"test": "node --test test/*.test.js",
|
|
41
|
+
"sync-spec": "node scripts/sync-spec.js"
|
|
42
|
+
},
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public"
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/commands.js
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Commands and flags from the OpenAPI document. One command per operation: `x-cli.command`, else the operationId in
|
|
4
|
+
* kebab case without a leading get/list/create (getContents → contents). Flags come from the operation's parameters
|
|
5
|
+
* and the request body's properties, nested objects as dotted flags (--text.max-characters). A body field the API
|
|
6
|
+
* adds tomorrow is a flag tomorrow, without a new CLI release; --data covers anything a flag cannot express.
|
|
7
|
+
*
|
|
8
|
+
* Optional hints in the operation's `x-cli` object: command, aliases[], positional (the body property positional
|
|
9
|
+
* arguments fill), output { items, fields[], heading, errors } for `-o text`, hidden.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export class UsageError extends Error {}
|
|
13
|
+
|
|
14
|
+
const METHODS = ["get", "post", "put", "patch", "delete"];
|
|
15
|
+
|
|
16
|
+
/** fooBar / Foo-Bar / foo_bar → foo-bar */
|
|
17
|
+
export function kebab(/** @type {string} */ s) {
|
|
18
|
+
return s
|
|
19
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
|
20
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2")
|
|
21
|
+
.replace(/[^A-Za-z0-9]+/g, "-")
|
|
22
|
+
.replace(/^-+|-+$/g, "")
|
|
23
|
+
.toLowerCase();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** getContents → contents, getAccount → account; without an operationId: post-v1-contents. */
|
|
27
|
+
export function commandName(/** @type {string | undefined} */ operationId, /** @type {string} */ method, /** @type {string} */ path) {
|
|
28
|
+
if (operationId) return kebab(operationId).replace(/^(get|list|create)-(?=.)/, "");
|
|
29
|
+
return kebab(`${method} ${path}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Resolves a local $ref (#/…); anything else is returned as is. */
|
|
33
|
+
export function deref(/** @type {any} */ spec, /** @type {any} */ schema, depth = 0) {
|
|
34
|
+
if (!schema || typeof schema !== "object" || typeof schema.$ref !== "string" || depth > 20) return schema;
|
|
35
|
+
if (!schema.$ref.startsWith("#/")) return {};
|
|
36
|
+
let node = spec;
|
|
37
|
+
for (const part of schema.$ref.slice(2).split("/")) node = node?.[part.replace(/~1/g, "/").replace(/~0/g, "~")];
|
|
38
|
+
return deref(spec, node ?? {}, depth + 1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @typedef {object} Flag
|
|
43
|
+
* @property {string} name kebab, dotted for nested body fields
|
|
44
|
+
* @property {"body" | "header" | "query" | "path"} in
|
|
45
|
+
* @property {string[]} path body path, or [parameter name]
|
|
46
|
+
* @property {"string" | "integer" | "number" | "boolean" | "array" | "json"} kind
|
|
47
|
+
* @property {string} [itemKind] array items
|
|
48
|
+
* @property {boolean} [toggle] a boolean-or-object field: --x / --no-x plus nested flags
|
|
49
|
+
* @property {boolean} required
|
|
50
|
+
* @property {boolean} hidden
|
|
51
|
+
* @property {string} description
|
|
52
|
+
* @property {any[]} [enum]
|
|
53
|
+
* @property {string} [format]
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @typedef {object} Command
|
|
58
|
+
* @property {string} name
|
|
59
|
+
* @property {string[]} aliases
|
|
60
|
+
* @property {string} method
|
|
61
|
+
* @property {string} path
|
|
62
|
+
* @property {any} op
|
|
63
|
+
* @property {any} cli the x-cli hints ({} without)
|
|
64
|
+
* @property {Flag[]} flags
|
|
65
|
+
* @property {string | null} positional body property filled by positional arguments
|
|
66
|
+
* @property {boolean} hasBody
|
|
67
|
+
* @property {string[]} requiredBody
|
|
68
|
+
* @property {Flag | null} idempotencyKey
|
|
69
|
+
* @property {boolean} needsAuth
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
const typeOf = (/** @type {any} */ s) => {
|
|
73
|
+
if (!s || typeof s !== "object") return undefined;
|
|
74
|
+
const t = Array.isArray(s.type) ? s.type.find((/** @type {string} */ x) => x !== "null") : s.type;
|
|
75
|
+
if (t) return t;
|
|
76
|
+
if ("const" in s) return typeof s.const === "number" ? (Number.isInteger(s.const) ? "integer" : "number") : typeof s.const;
|
|
77
|
+
if (Array.isArray(s.enum)) return typeof s.enum[0];
|
|
78
|
+
return undefined;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const leafKind = (/** @type {string | undefined} */ t) =>
|
|
82
|
+
t === "string" || t === "integer" || t === "number" || t === "boolean" ? t : "json";
|
|
83
|
+
|
|
84
|
+
/** Body flags for one property, recursing into objects. */
|
|
85
|
+
function bodyFlags(/** @type {any} */ spec, /** @type {any} */ raw, /** @type {string[]} */ path, /** @type {boolean} */ required, /** @type {Flag[]} */ out) {
|
|
86
|
+
const s = deref(spec, raw);
|
|
87
|
+
const name = path.map(kebab).join(".");
|
|
88
|
+
const description = String(s?.description ?? "").trim();
|
|
89
|
+
const hidden = s?.deprecated === true;
|
|
90
|
+
const variants = (s?.anyOf ?? s?.oneOf ?? []).map((/** @type {any} */ v) => deref(spec, v));
|
|
91
|
+
const object = [s, ...variants].find((v) => typeOf(v) === "object" && v.properties && typeof v.properties === "object");
|
|
92
|
+
const hasBool = variants.some((/** @type {any} */ v) => typeOf(v) === "boolean");
|
|
93
|
+
|
|
94
|
+
if (object) {
|
|
95
|
+
out.push({ name, in: "body", path, kind: hasBool ? "boolean" : "json", toggle: hasBool, required, hidden: hidden || !hasBool, description });
|
|
96
|
+
for (const [key, child] of Object.entries(object.properties)) bodyFlags(spec, child, [...path, key], false, out);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const t = typeOf(s) ?? (variants.length ? typeOf(variants.find((/** @type {any} */ v) => typeOf(v))) : undefined);
|
|
100
|
+
if (t === "array") {
|
|
101
|
+
const items = deref(spec, s.items);
|
|
102
|
+
out.push({ name, in: "body", path, kind: "array", itemKind: leafKind(typeOf(items)), required, hidden, description, enum: items?.enum, format: items?.format });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
out.push({ name, in: "body", path, kind: leafKind(t), required, hidden, description, enum: s?.enum ?? ("const" in (s ?? {}) ? [s.const] : undefined), format: s?.format });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** All commands of a spec, in document order. */
|
|
109
|
+
export function buildCommands(/** @type {any} */ spec) {
|
|
110
|
+
/** @type {Command[]} */
|
|
111
|
+
const commands = [];
|
|
112
|
+
const taken = new Set();
|
|
113
|
+
for (const [path, rawItem] of Object.entries(spec?.paths ?? {})) {
|
|
114
|
+
const item = deref(spec, rawItem);
|
|
115
|
+
if (!item || typeof item !== "object") continue;
|
|
116
|
+
for (const method of METHODS) {
|
|
117
|
+
const op = item[method];
|
|
118
|
+
if (!op || typeof op !== "object") continue;
|
|
119
|
+
const cli = op["x-cli"] && typeof op["x-cli"] === "object" ? op["x-cli"] : {};
|
|
120
|
+
if (cli.hidden === true) continue;
|
|
121
|
+
let name = typeof cli.command === "string" && cli.command ? kebab(cli.command) : commandName(op.operationId, method, path);
|
|
122
|
+
if (taken.has(name)) name = `${name}-${method}`;
|
|
123
|
+
taken.add(name);
|
|
124
|
+
/** @type {string[]} */
|
|
125
|
+
const aliases = (Array.isArray(cli.aliases) ? cli.aliases : []).map(String).map(kebab).filter((/** @type {string} */ a) => a && !taken.has(a));
|
|
126
|
+
aliases.forEach((a) => taken.add(a));
|
|
127
|
+
|
|
128
|
+
/** @type {Flag[]} */
|
|
129
|
+
const flags = [];
|
|
130
|
+
/** @type {Map<string, any>} */
|
|
131
|
+
const params = new Map();
|
|
132
|
+
for (const p of [...(item.parameters ?? []), ...(op.parameters ?? [])].map((p) => deref(spec, p))) {
|
|
133
|
+
if (p && typeof p.name === "string" && ["header", "query", "path"].includes(p.in)) params.set(`${p.in}:${p.name}`, p);
|
|
134
|
+
}
|
|
135
|
+
for (const p of params.values()) {
|
|
136
|
+
const s = deref(spec, p.schema);
|
|
137
|
+
const t = typeOf(s);
|
|
138
|
+
flags.push({
|
|
139
|
+
name: kebab(p.name),
|
|
140
|
+
in: p.in,
|
|
141
|
+
path: [p.name],
|
|
142
|
+
kind: t === "array" ? "array" : leafKind(t),
|
|
143
|
+
itemKind: t === "array" ? leafKind(typeOf(deref(spec, s.items))) : undefined,
|
|
144
|
+
required: p.required === true || p.in === "path",
|
|
145
|
+
hidden: p.deprecated === true,
|
|
146
|
+
description: String(p.description ?? s?.description ?? "").trim(),
|
|
147
|
+
enum: s?.enum,
|
|
148
|
+
format: s?.format,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const bodySchema = deref(spec, op.requestBody)?.content?.["application/json"]?.schema;
|
|
153
|
+
const body = deref(spec, bodySchema);
|
|
154
|
+
/** @type {string[]} */
|
|
155
|
+
const requiredBody = Array.isArray(body?.required) ? body.required.map(String) : [];
|
|
156
|
+
if (body?.properties && typeof body.properties === "object") {
|
|
157
|
+
const names = new Set(flags.map((f) => f.name));
|
|
158
|
+
/** @type {Flag[]} */
|
|
159
|
+
const fromBody = [];
|
|
160
|
+
for (const [key, child] of Object.entries(body.properties)) bodyFlags(spec, child, [key], requiredBody.includes(key), fromBody);
|
|
161
|
+
for (const f of fromBody) if (!names.has(f.name)) flags.push(f);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Positional arguments fill x-cli.positional, else the only required array field of the body (urls).
|
|
165
|
+
let positional = typeof cli.positional === "string" && body?.properties?.[cli.positional] ? cli.positional : null;
|
|
166
|
+
if (!positional) {
|
|
167
|
+
const arrays = requiredBody.filter((k) => typeOf(deref(spec, body?.properties?.[k])) === "array");
|
|
168
|
+
if (arrays.length === 1) positional = arrays[0];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const security = op.security ?? spec.security ?? [];
|
|
172
|
+
commands.push({
|
|
173
|
+
name,
|
|
174
|
+
aliases,
|
|
175
|
+
method,
|
|
176
|
+
path,
|
|
177
|
+
op,
|
|
178
|
+
cli,
|
|
179
|
+
flags,
|
|
180
|
+
positional,
|
|
181
|
+
hasBody: !!bodySchema,
|
|
182
|
+
requiredBody,
|
|
183
|
+
idempotencyKey: flags.find((f) => f.in === "header" && f.name === "idempotency-key") ?? null,
|
|
184
|
+
needsAuth: Array.isArray(security) && security.length > 0 && security.every((r) => r && Object.keys(r).length > 0),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return commands;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function findCommand(/** @type {Command[]} */ commands, /** @type {string} */ name) {
|
|
192
|
+
const k = kebab(name);
|
|
193
|
+
return commands.find((c) => c.name === k || c.aliases.includes(k)) ?? null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const TRUE = new Set(["true", "1", "yes", "on"]);
|
|
197
|
+
const FALSE = new Set(["false", "0", "no", "off"]);
|
|
198
|
+
|
|
199
|
+
/** One flag value from the command line into its JSON type. */
|
|
200
|
+
export function coerce(/** @type {Flag} */ flag, /** @type {string} */ raw, /** @type {string} */ kind = flag.kind) {
|
|
201
|
+
/** @type {any} */
|
|
202
|
+
let v;
|
|
203
|
+
if (kind === "string") v = raw;
|
|
204
|
+
else if (kind === "integer") {
|
|
205
|
+
if (!/^-?\d+$/.test(raw.trim())) throw new UsageError(`--${flag.name} needs an integer, got "${raw}"`);
|
|
206
|
+
v = Number(raw);
|
|
207
|
+
} else if (kind === "number") {
|
|
208
|
+
v = Number(raw);
|
|
209
|
+
if (raw.trim() === "" || !Number.isFinite(v)) throw new UsageError(`--${flag.name} needs a number, got "${raw}"`);
|
|
210
|
+
} else if (kind === "boolean") {
|
|
211
|
+
const k = raw.trim().toLowerCase();
|
|
212
|
+
if (TRUE.has(k)) v = true;
|
|
213
|
+
else if (FALSE.has(k)) v = false;
|
|
214
|
+
else throw new UsageError(`--${flag.name} needs true or false, got "${raw}"`);
|
|
215
|
+
} else {
|
|
216
|
+
try {
|
|
217
|
+
v = JSON.parse(raw);
|
|
218
|
+
} catch {
|
|
219
|
+
throw new UsageError(`--${flag.name} needs JSON, got "${raw}"`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const allowed = kind === flag.kind || flag.kind === "array" ? flag.enum : undefined;
|
|
223
|
+
if (Array.isArray(allowed) && allowed.length && !allowed.includes(v)) {
|
|
224
|
+
throw new UsageError(`--${flag.name} must be one of ${allowed.map((a) => JSON.stringify(a)).join(", ")}, got ${JSON.stringify(v)}`);
|
|
225
|
+
}
|
|
226
|
+
return v;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const isObject = (/** @type {any} */ v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
230
|
+
|
|
231
|
+
/** Deep merge of plain objects; `over` wins. */
|
|
232
|
+
export function merge(/** @type {any} */ base, /** @type {any} */ over) {
|
|
233
|
+
if (!isObject(base) || !isObject(over)) return over;
|
|
234
|
+
const out = { ...base };
|
|
235
|
+
for (const [k, v] of Object.entries(over)) out[k] = k in out ? merge(out[k], v) : v;
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function setPath(/** @type {any} */ obj, /** @type {string[]} */ path, /** @type {any} */ value) {
|
|
240
|
+
let node = obj;
|
|
241
|
+
for (const key of path.slice(0, -1)) {
|
|
242
|
+
if (!isObject(node[key])) node[key] = {};
|
|
243
|
+
node = node[key];
|
|
244
|
+
}
|
|
245
|
+
node[path[path.length - 1]] = value;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Flag lookup accepting the kebab name and the spec's own spelling (--country-code, --countryCode). */
|
|
249
|
+
function flagIndex(/** @type {Command} */ command) {
|
|
250
|
+
const index = new Map();
|
|
251
|
+
for (const f of command.flags) index.set(f.name, f);
|
|
252
|
+
return (/** @type {string} */ given) => index.get(given.split(".").map(kebab).join("."));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* @typedef {object} Request
|
|
257
|
+
* @property {Record<string, string>} headers
|
|
258
|
+
* @property {Record<string, string | string[]>} query
|
|
259
|
+
* @property {Record<string, string>} pathParams
|
|
260
|
+
* @property {any} body undefined when the operation takes none
|
|
261
|
+
* @property {string | null} idempotencyKey
|
|
262
|
+
*/
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Command arguments (everything after the command name, global options already removed) into a request.
|
|
266
|
+
* @param {Command} command
|
|
267
|
+
* @param {string[]} args
|
|
268
|
+
* @param {any} [data] parsed --data, merged under the flags
|
|
269
|
+
* @returns {Request}
|
|
270
|
+
*/
|
|
271
|
+
export function buildRequest(command, args, data) {
|
|
272
|
+
const find = flagIndex(command);
|
|
273
|
+
/** @type {string[]} */
|
|
274
|
+
const positionals = [];
|
|
275
|
+
/** @type {{ flag: Flag, value: any }[]} */
|
|
276
|
+
const toggles = [];
|
|
277
|
+
/** @type {{ flag: Flag, value: any }[]} */
|
|
278
|
+
const values = [];
|
|
279
|
+
/** @type {Map<Flag, any[]>} */
|
|
280
|
+
const arrays = new Map();
|
|
281
|
+
|
|
282
|
+
for (let i = 0; i < args.length; i++) {
|
|
283
|
+
const arg = args[i];
|
|
284
|
+
if (arg === "--") {
|
|
285
|
+
positionals.push(...args.slice(i + 1));
|
|
286
|
+
break;
|
|
287
|
+
}
|
|
288
|
+
if (!arg.startsWith("--") || arg === "-") {
|
|
289
|
+
if (arg.startsWith("-") && arg !== "-" && !/^-\d/.test(arg)) throw new UsageError(`unknown option ${arg}`);
|
|
290
|
+
positionals.push(arg);
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
const eq = arg.indexOf("=");
|
|
294
|
+
const given = eq === -1 ? arg.slice(2) : arg.slice(2, eq);
|
|
295
|
+
let flag = find(given);
|
|
296
|
+
let negated = false;
|
|
297
|
+
if (!flag && given.startsWith("no-")) {
|
|
298
|
+
const f = find(given.slice(3));
|
|
299
|
+
if (f && f.kind === "boolean") {
|
|
300
|
+
flag = f;
|
|
301
|
+
negated = true;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (!flag) throw new UsageError(`unknown option --${given} for "${command.name}" (see ${command.name} --help)`);
|
|
305
|
+
if (flag.kind === "boolean") {
|
|
306
|
+
if (negated && eq !== -1) throw new UsageError(`--no-${flag.name} takes no value`);
|
|
307
|
+
const value = negated ? false : eq === -1 ? true : coerce(flag, arg.slice(eq + 1));
|
|
308
|
+
(flag.toggle ? toggles : values).push({ flag, value });
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
let raw;
|
|
312
|
+
if (eq !== -1) raw = arg.slice(eq + 1);
|
|
313
|
+
else {
|
|
314
|
+
if (i + 1 >= args.length) throw new UsageError(`--${flag.name} needs a value`);
|
|
315
|
+
raw = args[++i];
|
|
316
|
+
}
|
|
317
|
+
if (flag.kind === "array") {
|
|
318
|
+
const list = arrays.get(flag) ?? [];
|
|
319
|
+
list.push(coerce(flag, raw, flag.itemKind));
|
|
320
|
+
arrays.set(flag, list);
|
|
321
|
+
} else values.push({ flag, value: coerce(flag, raw) });
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** @type {Request} */
|
|
325
|
+
const req = { headers: {}, query: {}, pathParams: {}, body: undefined, idempotencyKey: null };
|
|
326
|
+
/** @type {any} */
|
|
327
|
+
let body = command.hasBody ? (isObject(data) ? structuredClone(data) : {}) : undefined;
|
|
328
|
+
if (data !== undefined && !command.hasBody) throw new UsageError(`"${command.name}" takes no request body (--data)`);
|
|
329
|
+
if (data !== undefined && !isObject(data)) body = data;
|
|
330
|
+
|
|
331
|
+
if (positionals.length) {
|
|
332
|
+
if (!command.positional) throw new UsageError(`"${command.name}" takes no arguments, got "${positionals[0]}"`);
|
|
333
|
+
const flag = command.flags.find((f) => f.in === "body" && f.path.length === 1 && f.path[0] === command.positional);
|
|
334
|
+
const list = arrays.get(/** @type {Flag} */ (flag)) ?? [];
|
|
335
|
+
for (const p of positionals) list.push(flag?.kind === "array" ? coerce(flag, p, flag.itemKind) : p);
|
|
336
|
+
if (flag?.kind === "array") arrays.set(flag, list);
|
|
337
|
+
else if (flag) values.push({ flag, value: coerce(flag, positionals.join(" ")) });
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const assign = (/** @type {Flag} */ flag, /** @type {any} */ value) => {
|
|
341
|
+
if (flag.in === "body") {
|
|
342
|
+
if (!isObject(body)) throw new UsageError(`--${flag.name} cannot be combined with a non-object --data`);
|
|
343
|
+
setPath(body, flag.path, value);
|
|
344
|
+
} else if (flag.in === "header") req.headers[flag.path[0]] = String(value);
|
|
345
|
+
else if (flag.in === "query") req.query[flag.path[0]] = Array.isArray(value) ? value.map(String) : String(value);
|
|
346
|
+
else req.pathParams[flag.path[0]] = String(value);
|
|
347
|
+
};
|
|
348
|
+
// Toggles first (--text), so a nested flag (--text.max-characters) turns `true` into an object.
|
|
349
|
+
for (const { flag, value } of toggles) {
|
|
350
|
+
const nested = values.some((v) => v.flag.in === "body" && v.flag.path.length > flag.path.length && flag.path.every((k, j) => v.flag.path[j] === k));
|
|
351
|
+
if (value === false && nested) throw new UsageError(`--no-${flag.name} cannot be combined with --${flag.name}.* options`);
|
|
352
|
+
if (!nested) assign(flag, value);
|
|
353
|
+
}
|
|
354
|
+
for (const { flag, value } of values) assign(flag, value);
|
|
355
|
+
for (const [flag, list] of arrays) assign(flag, list);
|
|
356
|
+
|
|
357
|
+
for (const f of command.flags) {
|
|
358
|
+
if (!f.required) continue;
|
|
359
|
+
const present = f.in === "body" ? isObject(body) && body[f.path[0]] !== undefined : f.in === "header" ? f.path[0] in req.headers : f.in === "query" ? f.path[0] in req.query : f.path[0] in req.pathParams;
|
|
360
|
+
if (!present) {
|
|
361
|
+
const how = command.positional === f.path[0] ? `pass it as an argument or with --${f.name}` : `pass --${f.name}`;
|
|
362
|
+
throw new UsageError(`"${command.name}" needs ${f.path[0]}: ${how}`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
req.body = body;
|
|
366
|
+
if (command.idempotencyKey) req.idempotencyKey = req.headers[command.idempotencyKey.path[0]] ?? null;
|
|
367
|
+
return req;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** The request URL on the base URL. Path parameters are encoded; the result must stay on the base URL's origin. */
|
|
371
|
+
export function requestUrl(/** @type {string} */ baseUrl, /** @type {Command} */ command, /** @type {Request} */ req) {
|
|
372
|
+
const path = command.path.replace(/\{([^}]+)\}/g, (_, name) => {
|
|
373
|
+
if (!(name in req.pathParams)) throw new UsageError(`"${command.name}" needs --${kebab(name)}`);
|
|
374
|
+
return encodeURIComponent(req.pathParams[name]);
|
|
375
|
+
});
|
|
376
|
+
const base = new URL(baseUrl);
|
|
377
|
+
const url = new URL(baseUrl.replace(/\/+$/, "") + (path.startsWith("/") ? path : `/${path}`));
|
|
378
|
+
if (url.origin !== base.origin) throw new UsageError(`refusing a request outside ${base.origin}: ${url.href}`);
|
|
379
|
+
for (const [k, v] of Object.entries(req.query)) for (const one of Array.isArray(v) ? v : [v]) url.searchParams.append(k, one);
|
|
380
|
+
return url;
|
|
381
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Brand, version, directories and the API key. The brand (brand.json) is the only thing that differs between the 7
|
|
4
|
+
* site CLIs; everything else is the same code.
|
|
5
|
+
*/
|
|
6
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {object} Brand
|
|
13
|
+
* @property {string} name site name, e.g. "FetchMCP"
|
|
14
|
+
* @property {string} tagline
|
|
15
|
+
* @property {string} domain e.g. "fetchmcp.net"
|
|
16
|
+
* @property {string} bin command name, e.g. "fetchmcp"
|
|
17
|
+
* @property {string} npm npm package name
|
|
18
|
+
* @property {string} org GitHub org
|
|
19
|
+
* @property {string} repo GitHub repo in the org
|
|
20
|
+
* @property {string} siteUrl
|
|
21
|
+
* @property {string} docsUrl
|
|
22
|
+
* @property {string} apiName "Live Web Data API"
|
|
23
|
+
* @property {string} apiBaseUrl where requests (and the key) go
|
|
24
|
+
* @property {string} specUrl where the commands come from
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** The package root (bin/, src/, brand.json, openapi.json, package.json). */
|
|
28
|
+
export const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
29
|
+
|
|
30
|
+
/** @returns {Brand} */
|
|
31
|
+
export function loadBrand(root = ROOT) {
|
|
32
|
+
return JSON.parse(readFileSync(join(root, "brand.json"), "utf8"));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** @returns {string} */
|
|
36
|
+
export function loadVersion(root = ROOT) {
|
|
37
|
+
return JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const BUNDLED_SPEC = join(ROOT, "openapi.json");
|
|
41
|
+
|
|
42
|
+
/** @typedef {Record<string, string | undefined>} Env */
|
|
43
|
+
|
|
44
|
+
/** First of `names` that is set and not empty. */
|
|
45
|
+
export function envValue(/** @type {Env} */ env, /** @type {string[]} */ ...names) {
|
|
46
|
+
for (const name of names) if (env[name]) return env[name];
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const home = (/** @type {Env} */ env) => env.HOME || homedir();
|
|
51
|
+
|
|
52
|
+
/** `${XDG_CACHE_HOME:-~/.cache}/<bin>`: the spec cache and the update check. */
|
|
53
|
+
export function cacheDir(/** @type {Env} */ env, /** @type {string} */ bin) {
|
|
54
|
+
return join(env.XDG_CACHE_HOME || join(home(env), ".cache"), bin);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** `${XDG_CONFIG_HOME:-~/.config}/<bin>`: config.json with the saved key. */
|
|
58
|
+
export function configDir(/** @type {Env} */ env, /** @type {string} */ bin) {
|
|
59
|
+
return join(env.XDG_CONFIG_HOME || join(home(env), ".config"), bin);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const configFile = (/** @type {Env} */ env, /** @type {string} */ bin) => join(configDir(env, bin), "config.json");
|
|
63
|
+
|
|
64
|
+
/** @returns {{ apiKey?: string }} */
|
|
65
|
+
export function readConfig(/** @type {string} */ file) {
|
|
66
|
+
try {
|
|
67
|
+
const data = JSON.parse(readFileSync(file, "utf8"));
|
|
68
|
+
return data && typeof data === "object" ? data : {};
|
|
69
|
+
} catch {
|
|
70
|
+
return {};
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Writes the key readable by the owner only (0600, directory 0700), also when the file existed with wider modes. */
|
|
75
|
+
export function saveApiKey(/** @type {string} */ file, /** @type {string} */ apiKey) {
|
|
76
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
77
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
78
|
+
writeFileSync(tmp, JSON.stringify({ ...readConfig(file), apiKey }, null, 2) + "\n", { mode: 0o600 });
|
|
79
|
+
chmodSync(tmp, 0o600);
|
|
80
|
+
renameSync(tmp, file);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** @returns {boolean} whether a saved key was removed */
|
|
84
|
+
export function removeApiKey(/** @type {string} */ file) {
|
|
85
|
+
if (!existsSync(file)) return false;
|
|
86
|
+
const { apiKey, ...rest } = readConfig(file);
|
|
87
|
+
if (Object.keys(rest).length) writeFileSync(file, JSON.stringify(rest, null, 2) + "\n", { mode: 0o600 });
|
|
88
|
+
else rmSync(file, { force: true });
|
|
89
|
+
return apiKey !== undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The key, in this order: --api-key, LIVE_WEB_DATA_API_KEY, the legacy TENEO_API_KEY, the saved key.
|
|
94
|
+
* @returns {{ apiKey?: string, from?: string }}
|
|
95
|
+
*/
|
|
96
|
+
export function resolveApiKey(/** @type {string | undefined} */ flag, /** @type {Env} */ env, /** @type {string} */ bin) {
|
|
97
|
+
if (flag) return { apiKey: flag, from: "--api-key" };
|
|
98
|
+
for (const name of ["LIVE_WEB_DATA_API_KEY", "TENEO_API_KEY"]) if (env[name]) return { apiKey: env[name], from: name };
|
|
99
|
+
const file = configFile(env, bin);
|
|
100
|
+
const saved = readConfig(file).apiKey;
|
|
101
|
+
return saved ? { apiKey: saved, from: file } : {};
|
|
102
|
+
}
|
package/src/help.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/** Help texts. Command help is generated from the spec: summary, description, flags, enums and the request examples. */
|
|
3
|
+
import { kebab } from "./commands.js";
|
|
4
|
+
|
|
5
|
+
const WIDTH = 100;
|
|
6
|
+
|
|
7
|
+
/** Word wrap with a hanging indent. */
|
|
8
|
+
export function wrap(/** @type {string} */ text, /** @type {number} */ indent, width = WIDTH) {
|
|
9
|
+
const lines = [];
|
|
10
|
+
for (const para of text.split("\n")) {
|
|
11
|
+
let line = "";
|
|
12
|
+
for (const word of para.split(/\s+/).filter(Boolean)) {
|
|
13
|
+
if (line && indent + line.length + 1 + word.length > width) {
|
|
14
|
+
lines.push(line);
|
|
15
|
+
line = word;
|
|
16
|
+
} else line = line ? `${line} ${word}` : word;
|
|
17
|
+
}
|
|
18
|
+
lines.push(line);
|
|
19
|
+
}
|
|
20
|
+
return lines.join("\n" + " ".repeat(indent));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Two columns: names padded to the longest (capped), descriptions wrapped under each other. */
|
|
24
|
+
function columns(/** @type {[string, string][]} */ rows) {
|
|
25
|
+
const w = Math.min(34, Math.max(...rows.map(([a]) => a.length)) + 2);
|
|
26
|
+
return rows.map(([a, b]) => (a.length + 2 > w ? ` ${a}\n${" ".repeat(w + 2)}${wrap(b, w + 2)}` : ` ${a.padEnd(w)}${wrap(b, w + 2)}`)).join("\n");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** @param {import("./commands.js").Flag} f */
|
|
30
|
+
function placeholder(f) {
|
|
31
|
+
if (f.kind === "boolean") return "";
|
|
32
|
+
const one = (/** @type {string | undefined} */ kind) =>
|
|
33
|
+
Array.isArray(f.enum) && f.enum.length ? `<${f.enum.join("|")}>` : f.format === "uri" ? "<url>" : `<${kind === "json" ? "json" : kind}>`;
|
|
34
|
+
return f.kind === "array" ? ` ${one(f.itemKind)}…` : ` ${one(f.kind)}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** @param {import("./commands.js").Flag} f */
|
|
38
|
+
function flagLabel(f) {
|
|
39
|
+
if (f.kind === "boolean") return `--${f.name}, --no-${f.name}`;
|
|
40
|
+
return `--${f.name}${placeholder(f)}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The first request example of an operation as a command line, when every field maps to a flag. */
|
|
44
|
+
export function exampleLine(/** @type {string} */ bin, /** @type {import("./commands.js").Command} */ c) {
|
|
45
|
+
const examples = c.op?.requestBody?.content?.["application/json"]?.examples;
|
|
46
|
+
const first = examples && typeof examples === "object" ? Object.values(examples)[0] : undefined;
|
|
47
|
+
const value = first?.value ?? c.op?.requestBody?.content?.["application/json"]?.example;
|
|
48
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
49
|
+
const parts = [bin, c.name];
|
|
50
|
+
const walk = (/** @type {any} */ obj, /** @type {string[]} */ path) => {
|
|
51
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
52
|
+
const p = [...path, k];
|
|
53
|
+
const name = p.map(kebab).join(".");
|
|
54
|
+
const flag = c.flags.find((f) => f.name === name);
|
|
55
|
+
if (path.length === 0 && k === c.positional && Array.isArray(v)) parts.push(...v.map(String));
|
|
56
|
+
else if (flag?.kind === "array" && Array.isArray(v)) v.forEach((x) => parts.push(`--${name}`, String(x)));
|
|
57
|
+
else if (v && typeof v === "object" && !Array.isArray(v)) walk(v, p);
|
|
58
|
+
else if (flag?.kind === "boolean") parts.push(v ? `--${name}` : `--no-${name}`);
|
|
59
|
+
else if (flag) parts.push(`--${name}`, String(v));
|
|
60
|
+
else return false;
|
|
61
|
+
}
|
|
62
|
+
return true;
|
|
63
|
+
};
|
|
64
|
+
return walk(value, []) ? parts.join(" ") : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @param {import("./config.js").Brand} brand
|
|
69
|
+
* @param {import("./commands.js").Command[]} commands
|
|
70
|
+
* @param {{ source: string }} spec
|
|
71
|
+
*/
|
|
72
|
+
export function mainHelp(brand, commands, spec) {
|
|
73
|
+
const b = brand.bin;
|
|
74
|
+
const rows = /** @type {[string, string][]} */ ([
|
|
75
|
+
...commands.map((c) => /** @type {[string, string]} */ ([[c.name, ...c.aliases].join(", "), String(c.op.summary ?? c.op.operationId ?? `${c.method.toUpperCase()} ${c.path}`)])),
|
|
76
|
+
["login", "Save your API key for this CLI"],
|
|
77
|
+
["logout", "Remove the saved API key"],
|
|
78
|
+
["spec [refresh]", "Show or refresh the API spec the commands come from"],
|
|
79
|
+
]);
|
|
80
|
+
return `${brand.name}: ${brand.tagline}
|
|
81
|
+
${wrap(`The ${b} command line client for the ${brand.apiName}: live web pages as clean Markdown, text or HTML.`, 0)}
|
|
82
|
+
|
|
83
|
+
Usage: ${b} <command> [options]
|
|
84
|
+
|
|
85
|
+
Commands:
|
|
86
|
+
${columns(rows)}
|
|
87
|
+
|
|
88
|
+
Options for every command:
|
|
89
|
+
${columns([
|
|
90
|
+
["--api-key <key>", `API key. Default: env LIVE_WEB_DATA_API_KEY, then the key saved by \`${b} login\``],
|
|
91
|
+
["--base-url <url>", `API base URL. Default: env LIVE_WEB_DATA_API_BASE_URL, then ${brand.apiBaseUrl}`],
|
|
92
|
+
["--spec-url <url>", `API spec URL. Default: env LIVE_WEB_DATA_SPEC_URL, then ${brand.specUrl}`],
|
|
93
|
+
["--offline", "Use the cached or bundled spec, do not download it"],
|
|
94
|
+
["-o, --output <mode>", "json (default), text, or one field of each result, e.g. markdown, html, title"],
|
|
95
|
+
["-d, --data <json>", "Request body as JSON, @file or - (stdin); options override its fields"],
|
|
96
|
+
["--timeout <seconds>", "Per attempt. Default 120"],
|
|
97
|
+
["--max-wait <seconds>", "How long to repeat a 202 (still fetching) answer. Default 300"],
|
|
98
|
+
["-h, --help", `Help; for one command: ${b} <command> --help`],
|
|
99
|
+
["-v, --version", "Print the version"],
|
|
100
|
+
])}
|
|
101
|
+
|
|
102
|
+
${wrap(`The commands and their options come from the live API spec (${spec.source === "bundled" ? "now: the copy bundled with this version" : "cached for an hour"}), so new API features show up here without an update.`, 0)}
|
|
103
|
+
Docs: ${brand.docsUrl}
|
|
104
|
+
`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** @param {import("./config.js").Brand} brand @param {import("./commands.js").Command} c */
|
|
108
|
+
export function commandHelp(brand, c) {
|
|
109
|
+
// Body fields first, then headers and query/path parameters.
|
|
110
|
+
const visible = [...c.flags.filter((f) => !f.hidden && f.in === "body"), ...c.flags.filter((f) => !f.hidden && f.in !== "body")];
|
|
111
|
+
const usage = `${brand.bin} ${c.name}${c.positional ? ` <${kebab(c.positional)}…>` : ""}${visible.length ? " [options]" : ""}`;
|
|
112
|
+
const rows = visible.map((f) => /** @type {[string, string]} */ ([flagLabel(f), `${f.required ? "Required. " : ""}${f.in === "header" ? "(header) " : f.in === "query" ? "(query) " : ""}${f.description}`]));
|
|
113
|
+
const example = exampleLine(brand.bin, c);
|
|
114
|
+
return [
|
|
115
|
+
`${brand.bin} ${c.name}: ${c.op.summary ?? ""}`.trimEnd(),
|
|
116
|
+
c.aliases.length ? `Alias: ${c.aliases.join(", ")}` : "",
|
|
117
|
+
"",
|
|
118
|
+
`Usage: ${usage}`,
|
|
119
|
+
c.op.description ? `\n${wrap(String(c.op.description), 0)}` : "",
|
|
120
|
+
rows.length ? `\nOptions:\n${columns(rows)}` : "",
|
|
121
|
+
c.hasBody ? `\n${wrap("-d/--data takes the whole request body as JSON (or @file, or - for stdin); options override its fields. Any API field works there, also one without an option.", 0)}` : "",
|
|
122
|
+
example ? `\nExample:\n ${example}` : "",
|
|
123
|
+
"",
|
|
124
|
+
]
|
|
125
|
+
.filter((s, i, all) => s !== "" || (i > 0 && all[i - 1] !== ""))
|
|
126
|
+
.join("\n");
|
|
127
|
+
}
|