dsh-web-search-pro 0.2.0 → 0.2.1

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.
Files changed (80) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +75 -6
  3. package/assets/skills/dsh-web-search-pro/SKILL.md +1 -1
  4. package/assets/skills/dsh-web-search-pro/references/sources.md +11 -2
  5. package/catalog/sources.v1.json +20 -12
  6. package/lib/actions/search.js +5 -4
  7. package/lib/actions/search.js.map +1 -1
  8. package/lib/actions/sources.js +42 -25
  9. package/lib/actions/sources.js.map +1 -1
  10. package/lib/cli/builtin-specs.js +306 -0
  11. package/lib/cli/builtin-specs.js.map +1 -0
  12. package/lib/cli/chain.js +192 -0
  13. package/lib/cli/chain.js.map +1 -0
  14. package/lib/cli/chains-spec.js +98 -0
  15. package/lib/cli/chains-spec.js.map +1 -0
  16. package/lib/cli/engine.js +83 -0
  17. package/lib/cli/engine.js.map +1 -0
  18. package/lib/cli/opencli.js +105 -0
  19. package/lib/cli/opencli.js.map +1 -0
  20. package/lib/cli/probe.js +128 -0
  21. package/lib/cli/probe.js.map +1 -0
  22. package/lib/cli/runner.js +374 -0
  23. package/lib/cli/runner.js.map +1 -0
  24. package/lib/cli/spec.js +416 -0
  25. package/lib/cli/spec.js.map +1 -0
  26. package/lib/client/SettingsCard.js +1 -1
  27. package/lib/client/SettingsCard.js.map +1 -1
  28. package/lib/client/form-specs.js +2 -0
  29. package/lib/client/form-specs.js.map +1 -1
  30. package/lib/client/form.js +14 -3
  31. package/lib/client/form.js.map +1 -1
  32. package/lib/client/locales.js +4 -0
  33. package/lib/client/locales.js.map +1 -1
  34. package/lib/client/validators.js +15 -0
  35. package/lib/client/validators.js.map +1 -1
  36. package/lib/client.js +1425 -3
  37. package/lib/client.js.map +1 -1
  38. package/lib/config.js +4 -0
  39. package/lib/config.js.map +1 -1
  40. package/lib/deps.js +109 -102
  41. package/lib/deps.js.map +1 -1
  42. package/lib/engine-error.js +19 -0
  43. package/lib/engine-error.js.map +1 -0
  44. package/lib/engines.js +31 -114
  45. package/lib/engines.js.map +1 -1
  46. package/lib/pipeline/candidates.js.map +1 -1
  47. package/lib/pipeline/run.js +4 -2
  48. package/lib/pipeline/run.js.map +1 -1
  49. package/lib/providers/builtin.js +10 -14
  50. package/lib/providers/builtin.js.map +1 -1
  51. package/lib/providers/platforms.js +114 -55
  52. package/lib/providers/platforms.js.map +1 -1
  53. package/lib/providers/registry.js.map +1 -1
  54. package/lib/router.js +63 -6
  55. package/lib/router.js.map +1 -1
  56. package/lib/types/cli/builtin-specs.d.ts +30 -0
  57. package/lib/types/cli/chain.d.ts +83 -0
  58. package/lib/types/cli/chains-spec.d.ts +39 -0
  59. package/lib/types/cli/engine.d.ts +43 -0
  60. package/lib/types/cli/opencli.d.ts +38 -0
  61. package/lib/types/cli/probe.d.ts +53 -0
  62. package/lib/types/cli/runner.d.ts +87 -0
  63. package/lib/types/cli/spec.d.ts +176 -0
  64. package/lib/types/client/form-specs.d.ts +1 -1
  65. package/lib/types/client/locales.d.ts +4 -0
  66. package/lib/types/client/validators.d.ts +6 -0
  67. package/lib/types/config.d.ts +11 -0
  68. package/lib/types/deps.d.ts +25 -23
  69. package/lib/types/engine-error.d.ts +12 -0
  70. package/lib/types/engines.d.ts +16 -9
  71. package/lib/types/index.d.ts +4 -0
  72. package/lib/types/pipeline/candidates.d.ts +2 -0
  73. package/lib/types/pipeline/run.d.ts +2 -1
  74. package/lib/types/providers/platforms.d.ts +15 -3
  75. package/lib/types/providers/registry.d.ts +3 -0
  76. package/lib/types/router.d.ts +13 -0
  77. package/lib/types/util.d.ts +6 -1
  78. package/lib/util.js +7 -3
  79. package/lib/util.js.map +1 -1
  80. package/package.json +2 -2
package/lib/client.js CHANGED
@@ -779,6 +779,8 @@ window.__ModuleLoader__.load({
779
779
  jsonField("platformRules"),
780
780
  jsonField("customPlatforms"),
781
781
  jsonField("browserBindings"),
782
+ jsonField("platformBackends"),
783
+ jsonField("cliAdapters"),
782
784
  booleanField("verbose")
783
785
  ];
784
786
  /** The effective judge mode: the neutral `judge.mode` wins over the legacy `jevMode`. */
@@ -1008,6 +1010,1384 @@ window.__ModuleLoader__.load({
1008
1010
  };
1009
1011
  }
1010
1012
  //#endregion
1013
+ //#region src/cli/spec.ts
1014
+ /**
1015
+ * Declarative CLI adapter spec (dev-plan M12): what a standalone command-line tool is, which of its commands the plugin
1016
+ * may run, and how its output maps to search sources. Pure data plus validation, no Node imports, so the settings card
1017
+ * (client bundle) validates `cliAdapters` with the very function the server uses.
1018
+ *
1019
+ * The read-only guarantee lives here and applies to built-in and user-defined specs alike:
1020
+ * - argv is an array of tokens, never a shell string (the runner spawns without a shell);
1021
+ * - a spec declares `allowedSubcommands`, and every command word its argv uses (the leading literal tokens before the
1022
+ * first flag or placeholder) must be in that list;
1023
+ * - no command word, declared subcommand or flag may match the global deny-list (login, post, delete*, like, ...,
1024
+ * and the flags that make a CLI read browser cookies or carry secrets in argv).
1025
+ * @module web-search-pro/cli/spec
1026
+ */
1027
+ const OUTPUT_FORMATS = [
1028
+ "json",
1029
+ "ndjson",
1030
+ "yaml",
1031
+ "text"
1032
+ ];
1033
+ /** How far a spec has been checked against the real tool (recorded in the spec and shown in `sources.status`). */
1034
+ const CLI_VERIFICATIONS = [
1035
+ "live",
1036
+ "contract-only",
1037
+ "docs-only"
1038
+ ];
1039
+ /** Command words that write, log in, or change local or remote state. Matched exactly, or as `<word>-...` / `<word>_...`. */
1040
+ const DENIED_SUBCOMMANDS = [
1041
+ "login",
1042
+ "logout",
1043
+ "signin",
1044
+ "signout",
1045
+ "sign-in",
1046
+ "sign-out",
1047
+ "auth",
1048
+ "whoami-login",
1049
+ "post",
1050
+ "comment",
1051
+ "reply",
1052
+ "publish",
1053
+ "ask",
1054
+ "article",
1055
+ "pin",
1056
+ "draft",
1057
+ "upload",
1058
+ "send",
1059
+ "create",
1060
+ "edit",
1061
+ "write",
1062
+ "add",
1063
+ "like",
1064
+ "unlike",
1065
+ "dislike",
1066
+ "upvote",
1067
+ "downvote",
1068
+ "vote",
1069
+ "follow",
1070
+ "unfollow",
1071
+ "subscribe",
1072
+ "unsubscribe",
1073
+ "favorite",
1074
+ "unfavorite",
1075
+ "bookmark",
1076
+ "unbookmark",
1077
+ "save",
1078
+ "unsave",
1079
+ "retweet",
1080
+ "unretweet",
1081
+ "quote",
1082
+ "share",
1083
+ "block",
1084
+ "unblock",
1085
+ "mute",
1086
+ "accept",
1087
+ "forward",
1088
+ "setup",
1089
+ "init",
1090
+ "install",
1091
+ "uninstall",
1092
+ "update",
1093
+ "upgrade",
1094
+ "config",
1095
+ "configure",
1096
+ "register",
1097
+ "set",
1098
+ "import",
1099
+ "export",
1100
+ "bridge",
1101
+ "daemon",
1102
+ "plugin",
1103
+ "adapter",
1104
+ "external",
1105
+ "profile",
1106
+ "mcp",
1107
+ "serve"
1108
+ ];
1109
+ /** Command words denied by prefix alone (`delete`, `delete-note`, `deleteNote`, `remove...`). */
1110
+ const DENIED_PREFIXES = [
1111
+ "delete",
1112
+ "remove",
1113
+ "destroy",
1114
+ "drop",
1115
+ "purge",
1116
+ "revoke"
1117
+ ];
1118
+ /** Flags (without leading dashes) that read browser cookies or carry credentials in argv. */
1119
+ const DENIED_FLAGS = [
1120
+ "cookie-source",
1121
+ "cookie",
1122
+ "cookies",
1123
+ "cookies-from-browser",
1124
+ "browser-cookies",
1125
+ "from-browser",
1126
+ "browser",
1127
+ "password",
1128
+ "passwd",
1129
+ "secret",
1130
+ "token",
1131
+ "access-token",
1132
+ "auth-token",
1133
+ "api-key",
1134
+ "apikey",
1135
+ "key",
1136
+ "username",
1137
+ "user-token",
1138
+ "ap-password",
1139
+ "netrc",
1140
+ "client-secret",
1141
+ "authorization",
1142
+ "auth"
1143
+ ];
1144
+ function matchesDeniedSubcommand(word) {
1145
+ const w = word.toLowerCase();
1146
+ if (DENIED_PREFIXES.some((prefix) => w.startsWith(prefix))) return true;
1147
+ return DENIED_SUBCOMMANDS.some((denied) => w === denied || w.startsWith(denied + "-") || w.startsWith(denied + "_"));
1148
+ }
1149
+ const flagName = (token) => token.replace(/^-+/, "").split("=")[0].toLowerCase();
1150
+ const isFlag = (token) => token.startsWith("-") && token.length > 1;
1151
+ const hasPlaceholder = (token) => /\{[A-Za-z0-9_.]+\}/.test(token);
1152
+ /** The command words of an argv: leading literal tokens before the first flag or placeholder token. */
1153
+ function commandWords(argv) {
1154
+ const words = [];
1155
+ for (const token of argv) {
1156
+ if (isFlag(token) || hasPlaceholder(token)) break;
1157
+ words.push(token);
1158
+ }
1159
+ return words;
1160
+ }
1161
+ /** Problems of one argv template with respect to the read-only guard (empty = fine). */
1162
+ function argvProblems(argv, allowed, where) {
1163
+ const problems = [];
1164
+ for (const word of commandWords(argv)) if (matchesDeniedSubcommand(word)) problems.push(where + ": \"" + word + "\" is a write / login command (read-only guard)");
1165
+ else if (!allowed.includes(word)) problems.push(where + ": command word \"" + word + "\" is not in allowedSubcommands");
1166
+ for (const token of argv) if (isFlag(token) && DENIED_FLAGS.includes(flagName(token))) problems.push(where + ": flag \"" + token + "\" reads cookies or carries credentials (read-only guard)");
1167
+ return problems;
1168
+ }
1169
+ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
1170
+ const isStr = (v, max = 200) => typeof v === "string" && v.length > 0 && v.length <= max;
1171
+ const strList = (v, maxItems, maxLen = 200) => Array.isArray(v) && v.length <= maxItems && v.every((x) => isStr(x, maxLen));
1172
+ const ID_RE = /^[a-z0-9][a-z0-9._-]{0,39}$/;
1173
+ const BIN_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
1174
+ const ENV_RE = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
1175
+ const PATH_RE = /^[A-Za-z0-9_$-]+(\.[A-Za-z0-9_$-]+)*$/;
1176
+ const VERSION_RE = /^\d+(\.\d+){0,3}$/;
1177
+ const PLACEHOLDERS = /* @__PURE__ */ new Set([
1178
+ "query",
1179
+ "count",
1180
+ "url"
1181
+ ]);
1182
+ const MAX_TIMEOUT_MS = 18e4;
1183
+ const MAX_OUTPUT_BYTES = 16777216;
1184
+ const SPEC_KEYS = /* @__PURE__ */ new Set([
1185
+ "id",
1186
+ "bins",
1187
+ "packageNote",
1188
+ "platforms",
1189
+ "probe",
1190
+ "allowedSubcommands",
1191
+ "search",
1192
+ "searches",
1193
+ "read",
1194
+ "env",
1195
+ "needsLogin",
1196
+ "login",
1197
+ "timeoutMs",
1198
+ "maxOutputBytes",
1199
+ "verification"
1200
+ ]);
1201
+ const PROBE_KEYS = /* @__PURE__ */ new Set([
1202
+ "versionArgs",
1203
+ "minVersion",
1204
+ "helpArgs",
1205
+ "mustContain"
1206
+ ]);
1207
+ const SEARCH_KEYS = /* @__PURE__ */ new Set([
1208
+ "argv",
1209
+ "maxCount",
1210
+ "output",
1211
+ "emptyWhen",
1212
+ "notLoggedInPatterns"
1213
+ ]);
1214
+ const OUTPUT_KEYS = /* @__PURE__ */ new Set([
1215
+ "format",
1216
+ "itemsPath",
1217
+ "expect",
1218
+ "errorPaths",
1219
+ "lines",
1220
+ "fields",
1221
+ "stripTags",
1222
+ "emptyValues",
1223
+ "snippetMax",
1224
+ "titleMax",
1225
+ "requireTitle"
1226
+ ]);
1227
+ const FIELD_KEYS = /* @__PURE__ */ new Set([
1228
+ "url",
1229
+ "title",
1230
+ "snippet",
1231
+ "publishedAt"
1232
+ ]);
1233
+ const ENV_KEYS = /* @__PURE__ */ new Set([
1234
+ "required",
1235
+ "passthrough",
1236
+ "set"
1237
+ ]);
1238
+ const LOGIN_KEYS = /* @__PURE__ */ new Set([
1239
+ "command",
1240
+ "savedCredentialPaths",
1241
+ "readsBrowserCookiesWithoutLogin"
1242
+ ]);
1243
+ function closed(obj, keys, where, errors) {
1244
+ for (const key of Object.keys(obj)) if (!keys.has(key)) errors.push(where + "." + key + " is not a spec field");
1245
+ }
1246
+ function templateProblems(text, where, allowedPlaceholders, errors) {
1247
+ for (const match of text.matchAll(/\{([^}]*)\}/g)) {
1248
+ const name = match[1];
1249
+ if (allowedPlaceholders ? !allowedPlaceholders.has(name) : !PATH_RE.test(name)) errors.push(where + ": unknown placeholder {" + name + "}");
1250
+ }
1251
+ if (/\{[^}]*$/.test(text) || /^[^{]*\}/.test(text)) errors.push(where + ": unbalanced brace");
1252
+ }
1253
+ function checkFieldSource(value, where, errors) {
1254
+ if (typeof value === "string") {
1255
+ if (!PATH_RE.test(value)) errors.push(where + ": \"" + value + "\" is not a dotted path");
1256
+ return;
1257
+ }
1258
+ if (Array.isArray(value)) {
1259
+ if (!value.length || value.length > 12) errors.push(where + ": a source list needs 1..12 entries");
1260
+ for (const entry of value) if (typeof entry === "string") {
1261
+ if (!PATH_RE.test(entry)) errors.push(where + ": \"" + entry + "\" is not a dotted path");
1262
+ } else if (isObject(entry) && typeof entry.template === "string") checkFieldSource(entry, where, errors);
1263
+ else errors.push(where + ": list entries are paths or { template, when? }");
1264
+ return;
1265
+ }
1266
+ if (!isObject(value)) {
1267
+ errors.push(where + " must be a path, a list, { template }, { join }, { epoch } or { date }");
1268
+ return;
1269
+ }
1270
+ const keys = Object.keys(value);
1271
+ if (keys.length === 1 && (keys[0] === "epoch" || keys[0] === "date")) {
1272
+ if (typeof value[keys[0]] !== "string" || !PATH_RE.test(value[keys[0]])) errors.push(where + "." + keys[0] + " must be a dotted path");
1273
+ return;
1274
+ }
1275
+ if (keys.every((k) => k === "template" || k === "when") && "template" in value) {
1276
+ if (!isStr(value.template, 300)) errors.push(where + ".template must be a string");
1277
+ else templateProblems(value.template, where + ".template", void 0, errors);
1278
+ if (value.when !== void 0) {
1279
+ const w = value.when;
1280
+ if (!(isObject(w) && typeof w.path === "string" && PATH_RE.test(w.path) && [
1281
+ "string",
1282
+ "number",
1283
+ "boolean"
1284
+ ].includes(typeof w.equals) && Object.keys(w).every((k) => k === "path" || k === "equals"))) errors.push(where + ".when must be { path, equals }");
1285
+ }
1286
+ return;
1287
+ }
1288
+ if (keys.every((k) => k === "join" || k === "sep") && Array.isArray(value.join)) {
1289
+ if (!value.join.length || value.join.length > 8) errors.push(where + ".join needs 1..8 parts");
1290
+ for (const part of value.join) if (typeof part === "string") {
1291
+ if (!PATH_RE.test(part)) errors.push(where + ".join: \"" + part + "\" is not a dotted path");
1292
+ } else if (isObject(part) && typeof part.path === "string" && PATH_RE.test(part.path) && Object.keys(part).every((k) => [
1293
+ "path",
1294
+ "prefix",
1295
+ "suffix"
1296
+ ].includes(k))) {
1297
+ for (const k of ["prefix", "suffix"]) if (part[k] !== void 0 && !(typeof part[k] === "string" && part[k].length <= 40)) errors.push(where + ".join." + k + " must be a short string");
1298
+ } else errors.push(where + ".join: each part is a path or { path, prefix?, suffix? }");
1299
+ if (value.sep !== void 0 && !(typeof value.sep === "string" && value.sep.length <= 16)) errors.push(where + ".sep must be a short string");
1300
+ return;
1301
+ }
1302
+ errors.push(where + " must be a path, a list, { template }, { join }, { epoch } or { date }");
1303
+ }
1304
+ function checkOutput(value, where, errors) {
1305
+ if (!isObject(value)) {
1306
+ errors.push(where + " must be an object");
1307
+ return;
1308
+ }
1309
+ closed(value, OUTPUT_KEYS, where, errors);
1310
+ if (!OUTPUT_FORMATS.includes(value.format)) errors.push(where + ".format must be one of " + OUTPUT_FORMATS.join("|"));
1311
+ if (value.itemsPath !== void 0 && !(typeof value.itemsPath === "string" && PATH_RE.test(value.itemsPath))) errors.push(where + ".itemsPath must be a dotted path");
1312
+ if (value.expect !== void 0) {
1313
+ if (!Array.isArray(value.expect) || value.expect.length > 8) errors.push(where + ".expect must be a list of { path, equals }");
1314
+ else for (const e of value.expect) if (!(isObject(e) && typeof e.path === "string" && PATH_RE.test(e.path) && [
1315
+ "string",
1316
+ "number",
1317
+ "boolean"
1318
+ ].includes(typeof e.equals) && Object.keys(e).every((k) => k === "path" || k === "equals"))) errors.push(where + ".expect entries are { path, equals }");
1319
+ }
1320
+ if (value.errorPaths !== void 0 && !(Array.isArray(value.errorPaths) && value.errorPaths.length <= 6 && value.errorPaths.every((p) => typeof p === "string" && PATH_RE.test(p)))) errors.push(where + ".errorPaths must be dotted paths");
1321
+ if (value.lines !== void 0) {
1322
+ const l = value.lines;
1323
+ if (isObject(l) && l.mode === "url-lines" && Object.keys(l).length === 1) {} else if (isObject(l) && l.mode === "tsv" && strList(l.columns, 24, 40) && l.columns.length >= 2 && l.columns.every((c) => /^[A-Za-z0-9_]+$/.test(c)) && Object.keys(l).every((k) => k === "mode" || k === "columns")) {} else errors.push(where + ".lines must be { mode: \"url-lines\" } or { mode: \"tsv\", columns: [...] }");
1324
+ }
1325
+ if (value.format === "text" && value.lines === void 0) errors.push(where + ".lines is required for format text");
1326
+ if (value.format !== "text" && value.lines !== void 0) errors.push(where + ".lines is only for format text");
1327
+ if (!isObject(value.fields)) errors.push(where + ".fields must be an object");
1328
+ else {
1329
+ closed(value.fields, FIELD_KEYS, where + ".fields", errors);
1330
+ if (value.fields.url === void 0) errors.push(where + ".fields.url is required");
1331
+ for (const key of Object.keys(value.fields)) if (FIELD_KEYS.has(key)) checkFieldSource(value.fields[key], where + ".fields." + key, errors);
1332
+ }
1333
+ if (value.stripTags !== void 0 && typeof value.stripTags !== "boolean") errors.push(where + ".stripTags must be a boolean");
1334
+ if (value.emptyValues !== void 0 && !strList(value.emptyValues, 8, 20)) errors.push(where + ".emptyValues must be short strings");
1335
+ for (const key of ["snippetMax", "titleMax"]) if (value[key] !== void 0 && !(Number.isInteger(value[key]) && value[key] >= 40 && value[key] <= 2e3)) errors.push(where + "." + key + " must be an integer 40..2000");
1336
+ if (value.requireTitle !== void 0 && typeof value.requireTitle !== "boolean") errors.push(where + ".requireTitle must be a boolean");
1337
+ }
1338
+ function checkSearch(value, where, allowed, errors) {
1339
+ if (!isObject(value)) {
1340
+ errors.push(where + " must be an object");
1341
+ return;
1342
+ }
1343
+ closed(value, SEARCH_KEYS, where, errors);
1344
+ if (!Array.isArray(value.argv) || !value.argv.length || value.argv.length > 24 || !value.argv.every((t) => typeof t === "string" && t.length > 0 && t.length <= 200)) errors.push(where + ".argv must be an array of 1..24 non-empty strings (never a shell string)");
1345
+ else {
1346
+ const argv = value.argv;
1347
+ argv.forEach((token) => templateProblems(token, where + ".argv", PLACEHOLDERS, errors));
1348
+ if (!argv.some((t) => /\{(query|url)\}/.test(t))) errors.push(where + ".argv must carry a {query} (or {url}) placeholder");
1349
+ errors.push(...argvProblems(argv, allowed, where + ".argv"));
1350
+ }
1351
+ if (value.maxCount !== void 0 && !(Number.isInteger(value.maxCount) && value.maxCount >= 1 && value.maxCount <= 100)) errors.push(where + ".maxCount must be an integer 1..100");
1352
+ checkOutput(value.output, where + ".output", errors);
1353
+ for (const key of ["emptyWhen", "notLoggedInPatterns"]) if (value[key] !== void 0 && !strList(value[key], 12, 120)) errors.push(where + "." + key + " must be a list of short strings");
1354
+ }
1355
+ /**
1356
+ * Validate one adapter spec (built-in or user-defined). Never throws. A user-defined spec (`origin: 'user'`) may not carry a
1357
+ * `verification` (it is unverified by definition) and its `platforms` are ignored: it is registered as `custom-cli:<id>`.
1358
+ */
1359
+ function validateCliAdapterSpec(raw, origin = "user") {
1360
+ const errors = [];
1361
+ if (!isObject(raw)) return {
1362
+ ok: false,
1363
+ errors: ["spec must be an object"]
1364
+ };
1365
+ const at = "cliAdapters." + (typeof raw.id === "string" ? raw.id : "(no id)");
1366
+ closed(raw, SPEC_KEYS, at, errors);
1367
+ if (typeof raw.id !== "string" || !ID_RE.test(raw.id)) errors.push(at + ".id must match " + String(ID_RE));
1368
+ if (!Array.isArray(raw.bins) || !raw.bins.length || raw.bins.length > 4 || !raw.bins.every((b) => typeof b === "string" && BIN_RE.test(b))) errors.push(at + ".bins must be 1..4 command names (no paths)");
1369
+ if (!isStr(raw.packageNote, 240)) errors.push(at + ".packageNote must be a short string (what the package is called, which can differ from the command)");
1370
+ if (!strList(raw.platforms, 8, 40) || !raw.platforms.length) errors.push(at + ".platforms must list 1..8 platform ids");
1371
+ if (typeof raw.needsLogin !== "boolean") errors.push(at + ".needsLogin must be a boolean");
1372
+ if (!(Number.isInteger(raw.timeoutMs) && raw.timeoutMs >= 1e3 && raw.timeoutMs <= MAX_TIMEOUT_MS)) errors.push(at + ".timeoutMs must be an integer 1000..180000");
1373
+ if (!(Number.isInteger(raw.maxOutputBytes) && raw.maxOutputBytes >= 1024 && raw.maxOutputBytes <= MAX_OUTPUT_BYTES)) errors.push(at + ".maxOutputBytes must be an integer 1024..16777216");
1374
+ const allowed = strList(raw.allowedSubcommands, 12, 60) ? raw.allowedSubcommands : void 0;
1375
+ if (!allowed) errors.push(at + ".allowedSubcommands must list the command words the spec uses (up to 12; empty for a tool without subcommands)");
1376
+ else for (const word of allowed) if (matchesDeniedSubcommand(word)) errors.push(at + ".allowedSubcommands: \"" + word + "\" is a write / login command (read-only guard)");
1377
+ if (!isObject(raw.probe)) errors.push(at + ".probe must be an object");
1378
+ else {
1379
+ const p = raw.probe;
1380
+ closed(p, PROBE_KEYS, at + ".probe", errors);
1381
+ for (const key of ["versionArgs", "helpArgs"]) if (!Array.isArray(p[key]) || !p[key].every((t) => typeof t === "string" && t.length > 0 && t.length <= 80) || p[key].length > 6) errors.push(at + ".probe." + key + " must be an array of short strings");
1382
+ else if (allowed) errors.push(...argvProblems(p[key], allowed, at + ".probe." + key));
1383
+ if (!(Array.isArray(p.helpArgs) && p.helpArgs.length)) errors.push(at + ".probe.helpArgs must not be empty");
1384
+ if (p.minVersion !== void 0 && !(typeof p.minVersion === "string" && VERSION_RE.test(p.minVersion))) errors.push(at + ".probe.minVersion must look like 1.2.3");
1385
+ if (!strList(p.mustContain, 12, 80)) errors.push(at + ".probe.mustContain must be a list of short strings (the subcommands / flags that prove the contract)");
1386
+ }
1387
+ const searchWhere = (key) => at + "." + key;
1388
+ if (allowed) {
1389
+ checkSearch(raw.search, searchWhere("search"), allowed, errors);
1390
+ if (raw.searches !== void 0) {
1391
+ if (!isObject(raw.searches)) errors.push(at + ".searches must be an object keyed by platform id");
1392
+ else for (const [platform, s] of Object.entries(raw.searches)) {
1393
+ if (!ID_RE.test(platform)) errors.push(at + ".searches: \"" + platform + "\" is not a platform id");
1394
+ checkSearch(s, at + ".searches." + platform, allowed, errors);
1395
+ }
1396
+ }
1397
+ if (raw.read !== void 0) checkSearch(raw.read, searchWhere("read"), allowed, errors);
1398
+ }
1399
+ if (!isObject(raw.env)) errors.push(at + ".env must be an object");
1400
+ else {
1401
+ closed(raw.env, ENV_KEYS, at + ".env", errors);
1402
+ for (const key of ["required", "passthrough"]) {
1403
+ const list = raw.env[key];
1404
+ if (list !== void 0 && !(Array.isArray(list) && list.length <= 16 && list.every((n) => typeof n === "string" && ENV_RE.test(n)))) errors.push(at + ".env." + key + " must be environment variable NAMES (never values)");
1405
+ }
1406
+ if (raw.env.set !== void 0) {
1407
+ const set = raw.env.set;
1408
+ if (!isObject(set) || Object.keys(set).length > 8 || !Object.entries(set).every(([k, val]) => ENV_RE.test(k) && typeof val === "string" && val.length <= 80)) errors.push(at + ".env.set must be a few NAME: short literal pairs");
1409
+ else if (Object.keys(set).some((k) => /key|token|secret|password|cookie/i.test(k))) errors.push(at + ".env.set must not set credentials: name them in env.required / env.passthrough and keep the value in your environment");
1410
+ }
1411
+ }
1412
+ if (raw.login !== void 0) {
1413
+ const l = raw.login;
1414
+ if (!isObject(l)) errors.push(at + ".login must be an object");
1415
+ else {
1416
+ closed(l, LOGIN_KEYS, at + ".login", errors);
1417
+ if (!isStr(l.command, 120)) errors.push(at + ".login.command must be a short string (what the user runs themselves)");
1418
+ if (l.savedCredentialPaths !== void 0 && !(strList(l.savedCredentialPaths, 4, 160) && l.savedCredentialPaths.every((p) => /^~\/[^\0]+$/.test(p) && !p.includes("..")))) errors.push(at + ".login.savedCredentialPaths must be paths starting with ~/ (no ..)");
1419
+ if (l.readsBrowserCookiesWithoutLogin !== void 0 && typeof l.readsBrowserCookiesWithoutLogin !== "boolean") errors.push(at + ".login.readsBrowserCookiesWithoutLogin must be a boolean");
1420
+ if (l.readsBrowserCookiesWithoutLogin === true && !(Array.isArray(l.savedCredentialPaths) && l.savedCredentialPaths.length)) errors.push(at + ".login: a tool that reads browser cookies without a login needs savedCredentialPaths, so the plugin can refuse to run it without one");
1421
+ }
1422
+ }
1423
+ if (raw.needsLogin === true && !isObject(raw.login) && !(isObject(raw.env) && Array.isArray(raw.env.required) && raw.env.required.length)) errors.push(at + ": needsLogin without login.command or env.required gives the user nothing to run");
1424
+ if (raw.verification !== void 0) {
1425
+ const v = raw.verification;
1426
+ if (origin === "user") errors.push(at + ".verification is for built-in specs only");
1427
+ else if (!(isObject(v) && CLI_VERIFICATIONS.includes(v.status) && Object.keys(v).every((k) => [
1428
+ "status",
1429
+ "version",
1430
+ "date",
1431
+ "note"
1432
+ ].includes(k)))) errors.push(at + ".verification must be { status, version?, date?, note? }");
1433
+ else if (v.status === "live" && !(isStr(v.version, 40) && typeof v.date === "string" && /^\d{4}-\d{2}-\d{2}$/.test(v.date))) errors.push(at + ".verification: live needs the verified version and a YYYY-MM-DD date");
1434
+ }
1435
+ if (errors.length) return {
1436
+ ok: false,
1437
+ errors
1438
+ };
1439
+ return {
1440
+ ok: true,
1441
+ errors: [],
1442
+ spec: raw
1443
+ };
1444
+ }
1445
+ /** Validate the `cliAdapters` setting: the valid specs by id and the problems of the rest (an invalid entry is ignored, never fatal). */
1446
+ function resolveCliAdapters(input) {
1447
+ const specs = /* @__PURE__ */ new Map();
1448
+ const diagnostics = [];
1449
+ if (input === void 0 || input === null) return {
1450
+ specs,
1451
+ diagnostics
1452
+ };
1453
+ if (!isObject(input)) return {
1454
+ specs,
1455
+ diagnostics: ["cliAdapters must be an object keyed by adapter id"]
1456
+ };
1457
+ for (const [key, raw] of Object.entries(input)) {
1458
+ if (isObject(raw) && raw.id !== void 0 && raw.id !== key) {
1459
+ diagnostics.push("cliAdapters." + key + " ignored: its id \"" + String(raw.id) + "\" must equal its key");
1460
+ continue;
1461
+ }
1462
+ const result = validateCliAdapterSpec(isObject(raw) ? {
1463
+ ...raw,
1464
+ id: key
1465
+ } : raw, "user");
1466
+ if (!result.ok) {
1467
+ diagnostics.push("cliAdapters." + key + " ignored: " + result.errors.slice(0, 4).join("; ") + (result.errors.length > 4 ? " (+" + (result.errors.length - 4) + " more)" : ""));
1468
+ continue;
1469
+ }
1470
+ specs.set(key, result.spec);
1471
+ }
1472
+ return {
1473
+ specs,
1474
+ diagnostics
1475
+ };
1476
+ }
1477
+ /** `platformBackends`: ordered backend ids per platform. Pure shape check; ids are checked against the known backends by the caller. */
1478
+ function platformBackendProblems(input) {
1479
+ if (input === void 0 || input === null) return [];
1480
+ if (!isObject(input)) return ["platformBackends must be an object: { <platform>: [backend ids] }"];
1481
+ const problems = [];
1482
+ for (const [platform, ids] of Object.entries(input)) {
1483
+ if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,59}$/.test(platform)) problems.push("platformBackends: \"" + platform + "\" is not a platform id");
1484
+ if (!(Array.isArray(ids) && ids.length <= 12 && ids.every((id) => typeof id === "string" && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,59}$/.test(id)))) problems.push("platformBackends." + platform + " must be a list of backend ids");
1485
+ else if (new Set(ids).size !== ids.length) problems.push("platformBackends." + platform + " lists a backend twice");
1486
+ }
1487
+ return problems;
1488
+ }
1489
+ //#endregion
1490
+ //#region src/cli/builtin-specs.ts
1491
+ /**
1492
+ * The built-in CLI adapter specs (dev-plan M12). Every argv, flag and output field was derived from the tool's own
1493
+ * `--help` (and, where the help does not show the output shape, from the installed package source or the upstream
1494
+ * README), and `verification` records how far each was checked:
1495
+ * - `live`: a real read-only search was run and its output parsed (bili, gh, omnireach/wechat);
1496
+ * - `contract-only`: help and version probed on a real install, output shape taken from the tool's own source,
1497
+ * README or documented output option, because a search needs the user's login or reads browser cookies, or was outside
1498
+ * the checks allowed (xhs, zhihu, rdt, twitter, opencli sites, yt-dlp, wx-search-cli);
1499
+ * - `docs-only`: not installed here; derived from the upstream README (tanso).
1500
+ * Fixtures for the contract-only and docs-only parsers are marked "constructed from help/docs" in test/fixtures/cli.
1501
+ * @module web-search-pro/cli/builtin-specs
1502
+ */
1503
+ const BILI_CLI_VERSION = "0.6.2";
1504
+ const BILI_CLI_SOURCE = `git+https://github.com/public-clis/bilibili-cli@489607468f967e0e11f3cdff6efc022d011e982a`;
1505
+ `${BILI_CLI_SOURCE}`, `${BILI_CLI_SOURCE}`, `${BILI_CLI_SOURCE}`;
1506
+ /** The date of the checks recorded below. */
1507
+ const SPEC_CHECK_DATE = "2026-10-06";
1508
+ const MB = 1048576;
1509
+ /** The versioned `{ ok, schema_version, data }` envelope of the agent-friendly Python CLIs (bili, xhs, rdt). */
1510
+ const ENVELOPE = [{
1511
+ path: "ok",
1512
+ equals: true
1513
+ }, {
1514
+ path: "schema_version",
1515
+ equals: "1"
1516
+ }];
1517
+ /** Why a login-needing CLI is gated on its saved login files: it reads the browser's cookies by itself when it has none. */
1518
+ const NOT_LOGGED_IN = [
1519
+ "not authenticated",
1520
+ "not_authenticated",
1521
+ "not logged in",
1522
+ "login required",
1523
+ "please log in",
1524
+ "no 'a1' cookie",
1525
+ "no cookies"
1526
+ ];
1527
+ const bili = {
1528
+ id: "bili",
1529
+ bins: ["bili"],
1530
+ packageNote: `bilibili-cli from public-clis/bilibili-cli ${BILI_CLI_VERSION} (the PyPI package of the same name has another contract; sources.deps shows the pinned install)`,
1531
+ platforms: ["bilibili"],
1532
+ probe: {
1533
+ versionArgs: ["--version"],
1534
+ minVersion: BILI_CLI_VERSION,
1535
+ helpArgs: ["search", "--help"],
1536
+ mustContain: [
1537
+ "--type",
1538
+ "--max",
1539
+ "--json"
1540
+ ]
1541
+ },
1542
+ allowedSubcommands: ["search"],
1543
+ search: {
1544
+ argv: [
1545
+ "search",
1546
+ "{query}",
1547
+ "--type",
1548
+ "video",
1549
+ "--max",
1550
+ "{count}",
1551
+ "--json"
1552
+ ],
1553
+ maxCount: 10,
1554
+ output: {
1555
+ format: "json",
1556
+ itemsPath: "data",
1557
+ expect: ENVELOPE,
1558
+ errorPaths: ["message", "error"],
1559
+ stripTags: true,
1560
+ snippetMax: 300,
1561
+ fields: {
1562
+ url: [
1563
+ { template: "https://www.bilibili.com/video/{bvid}" },
1564
+ "url",
1565
+ "link",
1566
+ "arcurl"
1567
+ ],
1568
+ title: "title",
1569
+ snippet: { join: [
1570
+ {
1571
+ path: "author",
1572
+ prefix: "UP: "
1573
+ },
1574
+ {
1575
+ path: "play",
1576
+ prefix: "播放: "
1577
+ },
1578
+ "duration"
1579
+ ] }
1580
+ }
1581
+ }
1582
+ },
1583
+ env: {},
1584
+ needsLogin: false,
1585
+ timeoutMs: 3e4,
1586
+ maxOutputBytes: 4 * MB,
1587
+ verification: {
1588
+ status: "live",
1589
+ version: BILI_CLI_VERSION,
1590
+ date: SPEC_CHECK_DATE,
1591
+ note: "`bili search DeepSeek --type video --max 2 --json` parsed (fixture bili-search.json)"
1592
+ }
1593
+ };
1594
+ const ytDlp = {
1595
+ id: "yt-dlp",
1596
+ bins: ["yt-dlp"],
1597
+ packageNote: "yt-dlp (uv tool install yt-dlp)",
1598
+ platforms: ["youtube"],
1599
+ probe: {
1600
+ versionArgs: ["--version"],
1601
+ helpArgs: ["--help"],
1602
+ mustContain: [
1603
+ "--flat-playlist",
1604
+ "--print",
1605
+ "--skip-download"
1606
+ ]
1607
+ },
1608
+ allowedSubcommands: [],
1609
+ search: {
1610
+ argv: [
1611
+ "ytsearch{count}:{query}",
1612
+ "--flat-playlist",
1613
+ "--skip-download",
1614
+ "--no-warnings",
1615
+ "--print",
1616
+ "%(id)s %(title)s %(channel)s %(view_count)s %(duration_string)s"
1617
+ ],
1618
+ maxCount: 10,
1619
+ output: {
1620
+ format: "text",
1621
+ lines: {
1622
+ mode: "tsv",
1623
+ columns: [
1624
+ "id",
1625
+ "title",
1626
+ "channel",
1627
+ "view_count",
1628
+ "duration"
1629
+ ]
1630
+ },
1631
+ emptyValues: ["None"],
1632
+ requireTitle: true,
1633
+ fields: {
1634
+ url: { template: "https://www.youtube.com/watch?v={id}" },
1635
+ title: "title",
1636
+ snippet: { join: [
1637
+ "channel",
1638
+ {
1639
+ path: "view_count",
1640
+ suffix: " views"
1641
+ },
1642
+ "duration"
1643
+ ] }
1644
+ }
1645
+ }
1646
+ },
1647
+ env: {},
1648
+ needsLogin: false,
1649
+ timeoutMs: 6e4,
1650
+ maxOutputBytes: 4 * MB,
1651
+ verification: {
1652
+ status: "contract-only",
1653
+ version: "2026.08.19",
1654
+ date: SPEC_CHECK_DATE,
1655
+ note: "help and version probed; flags from `yt-dlp --help`; the print template is the one the plugin has used since 0.1; no search was run"
1656
+ }
1657
+ };
1658
+ const twitter = {
1659
+ id: "twitter",
1660
+ bins: ["twitter"],
1661
+ packageNote: "twitter-cli (PyPI twitter-cli; the command is `twitter`; Agent-Reach alone does not provide it)",
1662
+ platforms: ["twitter"],
1663
+ probe: {
1664
+ versionArgs: ["--version"],
1665
+ helpArgs: ["search", "--help"],
1666
+ mustContain: ["search"]
1667
+ },
1668
+ allowedSubcommands: ["search"],
1669
+ search: {
1670
+ argv: [
1671
+ "search",
1672
+ "{query}",
1673
+ "-n",
1674
+ "{count}"
1675
+ ],
1676
+ maxCount: 10,
1677
+ output: {
1678
+ format: "text",
1679
+ lines: { mode: "url-lines" },
1680
+ titleMax: 200,
1681
+ requireTitle: true,
1682
+ fields: {
1683
+ url: "url",
1684
+ title: "title"
1685
+ }
1686
+ },
1687
+ notLoggedInPatterns: [
1688
+ "not authenticated",
1689
+ "unauthorized",
1690
+ "no twitter cookies",
1691
+ "auth_token",
1692
+ "ct0"
1693
+ ]
1694
+ },
1695
+ env: { required: ["TWITTER_AUTH_TOKEN", "TWITTER_CT0"] },
1696
+ needsLogin: true,
1697
+ timeoutMs: 45e3,
1698
+ maxOutputBytes: 4 * MB,
1699
+ verification: {
1700
+ status: "contract-only",
1701
+ version: "0.8.5",
1702
+ date: SPEC_CHECK_DATE,
1703
+ note: "help probed; the plain-text output (`search QUERY -n N`) is the one the plugin has used since 0.1; the tool also offers --json, not used here; no search was run (needs your tokens)"
1704
+ }
1705
+ };
1706
+ const xhs = {
1707
+ id: "xhs",
1708
+ bins: ["xhs"],
1709
+ packageNote: "xiaohongshu-cli (PyPI xiaohongshu-cli; the command is `xhs`)",
1710
+ platforms: ["xiaohongshu"],
1711
+ probe: {
1712
+ versionArgs: ["--version"],
1713
+ helpArgs: ["search", "--help"],
1714
+ mustContain: [
1715
+ "keyword",
1716
+ "--json",
1717
+ "--sort"
1718
+ ]
1719
+ },
1720
+ allowedSubcommands: ["search"],
1721
+ search: {
1722
+ argv: [
1723
+ "search",
1724
+ "{query}",
1725
+ "--json"
1726
+ ],
1727
+ output: {
1728
+ format: "json",
1729
+ itemsPath: "data.items",
1730
+ expect: ENVELOPE,
1731
+ errorPaths: ["error.message"],
1732
+ fields: {
1733
+ url: { template: "https://www.xiaohongshu.com/explore/{id}?xsec_token={xsec_token}&xsec_source=pc_search" },
1734
+ title: ["note_card.display_title", "note_card.title"],
1735
+ snippet: { join: [{
1736
+ path: "note_card.user.nickname",
1737
+ prefix: "作者: "
1738
+ }, {
1739
+ path: "note_card.interact_info.liked_count",
1740
+ prefix: "赞: "
1741
+ }] }
1742
+ }
1743
+ },
1744
+ notLoggedInPatterns: NOT_LOGGED_IN
1745
+ },
1746
+ env: {},
1747
+ needsLogin: true,
1748
+ login: {
1749
+ command: "xhs login",
1750
+ savedCredentialPaths: ["~/.xiaohongshu-cli/cookies.json"],
1751
+ readsBrowserCookiesWithoutLogin: true
1752
+ },
1753
+ timeoutMs: 45e3,
1754
+ maxOutputBytes: 4 * MB,
1755
+ verification: {
1756
+ status: "contract-only",
1757
+ version: "0.6.4",
1758
+ date: SPEC_CHECK_DATE,
1759
+ note: "help probed; output shape from the installed package source (client response + its own normalizer); no search was run: it needs your login and, without a saved one, reads browser cookies"
1760
+ }
1761
+ };
1762
+ const zhihu = {
1763
+ id: "zhihu",
1764
+ bins: ["zhihu"],
1765
+ packageNote: "zhihu-cli (PyPI pyzhihu-cli, BAIGUANGMEI/zhihu-cli; the command is `zhihu`; not the Access-Secret \"zhihu-cli\" of dawnswwwww)",
1766
+ platforms: ["zhihu"],
1767
+ probe: {
1768
+ versionArgs: ["--version"],
1769
+ helpArgs: ["search", "--help"],
1770
+ mustContain: [
1771
+ "query",
1772
+ "--json",
1773
+ "--limit",
1774
+ "--type"
1775
+ ]
1776
+ },
1777
+ allowedSubcommands: ["search"],
1778
+ search: {
1779
+ argv: [
1780
+ "search",
1781
+ "{query}",
1782
+ "--limit",
1783
+ "{count}",
1784
+ "--answers",
1785
+ "0",
1786
+ "--json"
1787
+ ],
1788
+ maxCount: 20,
1789
+ output: {
1790
+ format: "json",
1791
+ itemsPath: "data",
1792
+ stripTags: true,
1793
+ fields: {
1794
+ url: [
1795
+ {
1796
+ template: "https://www.zhihu.com/question/{object.question.id}/answer/{object.id}",
1797
+ when: {
1798
+ path: "object.type",
1799
+ equals: "answer"
1800
+ }
1801
+ },
1802
+ {
1803
+ template: "https://zhuanlan.zhihu.com/p/{object.id}",
1804
+ when: {
1805
+ path: "object.type",
1806
+ equals: "article"
1807
+ }
1808
+ },
1809
+ {
1810
+ template: "https://www.zhihu.com/question/{object.id}",
1811
+ when: {
1812
+ path: "object.type",
1813
+ equals: "question"
1814
+ }
1815
+ }
1816
+ ],
1817
+ title: [
1818
+ "object.title",
1819
+ "object.question.name",
1820
+ "object.name"
1821
+ ],
1822
+ snippet: ["object.excerpt", "object.content"],
1823
+ publishedAt: { epoch: "object.created_time" }
1824
+ }
1825
+ },
1826
+ notLoggedInPatterns: ["not authenticated", "zhihu login"]
1827
+ },
1828
+ env: {},
1829
+ needsLogin: true,
1830
+ login: {
1831
+ command: "zhihu login",
1832
+ savedCredentialPaths: ["~/.zhihu-cli/cookies.json"]
1833
+ },
1834
+ timeoutMs: 45e3,
1835
+ maxOutputBytes: 4 * MB,
1836
+ verification: {
1837
+ status: "contract-only",
1838
+ version: "0.2.4",
1839
+ date: SPEC_CHECK_DATE,
1840
+ note: "help probed; output shape (`data[].object`) from the installed package source; the tool never reads browser cookies itself; no search was run (needs your login)"
1841
+ }
1842
+ };
1843
+ const rdt = {
1844
+ id: "rdt",
1845
+ bins: ["rdt"],
1846
+ packageNote: "rdt-cli (public-clis/rdt-cli; the command is `rdt`)",
1847
+ platforms: ["reddit"],
1848
+ probe: {
1849
+ versionArgs: ["--version"],
1850
+ helpArgs: ["search", "--help"],
1851
+ mustContain: [
1852
+ "query",
1853
+ "--json",
1854
+ "--compact",
1855
+ "--limit"
1856
+ ]
1857
+ },
1858
+ allowedSubcommands: ["search"],
1859
+ search: {
1860
+ argv: [
1861
+ "search",
1862
+ "{query}",
1863
+ "--limit",
1864
+ "{count}",
1865
+ "--compact",
1866
+ "--json"
1867
+ ],
1868
+ maxCount: 25,
1869
+ output: {
1870
+ format: "json",
1871
+ itemsPath: "data",
1872
+ expect: ENVELOPE,
1873
+ errorPaths: ["error.message"],
1874
+ fields: {
1875
+ url: { template: "https://www.reddit.com{permalink}" },
1876
+ title: "title",
1877
+ snippet: { join: [
1878
+ {
1879
+ path: "subreddit",
1880
+ prefix: "r/"
1881
+ },
1882
+ {
1883
+ path: "score",
1884
+ prefix: "score "
1885
+ },
1886
+ {
1887
+ path: "num_comments",
1888
+ suffix: " comments"
1889
+ },
1890
+ "selftext"
1891
+ ] },
1892
+ publishedAt: { epoch: "created_utc" }
1893
+ }
1894
+ },
1895
+ notLoggedInPatterns: NOT_LOGGED_IN
1896
+ },
1897
+ env: {},
1898
+ needsLogin: true,
1899
+ login: {
1900
+ command: "rdt login",
1901
+ savedCredentialPaths: ["~/.config/rdt-cli/credential.json"],
1902
+ readsBrowserCookiesWithoutLogin: true
1903
+ },
1904
+ timeoutMs: 45e3,
1905
+ maxOutputBytes: 4 * MB,
1906
+ verification: {
1907
+ status: "contract-only",
1908
+ version: "0.4.2",
1909
+ date: SPEC_CHECK_DATE,
1910
+ note: "help probed; `--compact --json` output (post dicts in the `data` envelope) from the installed package source; no search was run: without a saved login it reads browser cookies"
1911
+ }
1912
+ };
1913
+ const OMNIREACH_OUTPUT = {
1914
+ format: "json",
1915
+ itemsPath: "results",
1916
+ errorPaths: ["errors.0.error"],
1917
+ stripTags: true,
1918
+ fields: {
1919
+ url: "url",
1920
+ title: "title",
1921
+ snippet: "content",
1922
+ publishedAt: "ts"
1923
+ }
1924
+ };
1925
+ /** The `wechat` platform's two tools and the multi-source `omnireach` platform come from the same specs; ids are the backend ids. */
1926
+ const BUILTIN_CLI_SPECS = [
1927
+ bili,
1928
+ ytDlp,
1929
+ twitter,
1930
+ xhs,
1931
+ zhihu,
1932
+ rdt,
1933
+ {
1934
+ id: "omnireach",
1935
+ bins: ["omnireach"],
1936
+ packageNote: "omnireach (Python, Daily-AC/omnireach; uv tool install from its repository)",
1937
+ platforms: ["omnireach", "wechat"],
1938
+ probe: {
1939
+ versionArgs: ["--version"],
1940
+ minVersion: "0.19.0",
1941
+ helpArgs: ["search", "--help"],
1942
+ mustContain: [
1943
+ "--on",
1944
+ "--limit",
1945
+ "--json"
1946
+ ]
1947
+ },
1948
+ allowedSubcommands: ["search"],
1949
+ search: {
1950
+ argv: [
1951
+ "search",
1952
+ "{query}",
1953
+ "--limit",
1954
+ "{count}",
1955
+ "--json"
1956
+ ],
1957
+ maxCount: 20,
1958
+ output: OMNIREACH_OUTPUT
1959
+ },
1960
+ searches: { wechat: {
1961
+ argv: [
1962
+ "search",
1963
+ "{query}",
1964
+ "--on",
1965
+ "wechat",
1966
+ "--limit",
1967
+ "{count}",
1968
+ "--json"
1969
+ ],
1970
+ maxCount: 10,
1971
+ output: OMNIREACH_OUTPUT
1972
+ } },
1973
+ env: {},
1974
+ needsLogin: false,
1975
+ timeoutMs: 6e4,
1976
+ maxOutputBytes: 8 * MB,
1977
+ verification: {
1978
+ status: "live",
1979
+ version: "0.19.0-alpha",
1980
+ date: SPEC_CHECK_DATE,
1981
+ note: "`omnireach search 大模型 备案 --on wechat --limit 2 --json` parsed (fixture omnireach-wechat.json); the multi-source command was not run"
1982
+ }
1983
+ },
1984
+ {
1985
+ id: "gh",
1986
+ bins: ["gh"],
1987
+ packageNote: "GitHub CLI (https://cli.github.com; brew install gh / winget install GitHub.cli)",
1988
+ platforms: [
1989
+ "github",
1990
+ "github-issues",
1991
+ "github-code"
1992
+ ],
1993
+ probe: {
1994
+ versionArgs: ["--version"],
1995
+ helpArgs: [
1996
+ "search",
1997
+ "repos",
1998
+ "--help"
1999
+ ],
2000
+ mustContain: [
2001
+ "--json",
2002
+ "--limit",
2003
+ "stargazersCount"
2004
+ ]
2005
+ },
2006
+ allowedSubcommands: [
2007
+ "search",
2008
+ "repos",
2009
+ "issues",
2010
+ "code"
2011
+ ],
2012
+ search: {
2013
+ argv: [
2014
+ "search",
2015
+ "repos",
2016
+ "--json",
2017
+ "fullName,description,url,stargazersCount,language,updatedAt,forksCount",
2018
+ "--limit",
2019
+ "{count}",
2020
+ "--sort",
2021
+ "stars",
2022
+ "--order",
2023
+ "desc",
2024
+ "--",
2025
+ "{query}"
2026
+ ],
2027
+ maxCount: 15,
2028
+ output: {
2029
+ format: "json",
2030
+ stripTags: false,
2031
+ fields: {
2032
+ url: "url",
2033
+ title: "fullName",
2034
+ publishedAt: { date: "updatedAt" },
2035
+ snippet: { join: [
2036
+ "description",
2037
+ {
2038
+ path: "stargazersCount",
2039
+ prefix: "⭐"
2040
+ },
2041
+ {
2042
+ path: "language",
2043
+ prefix: "[",
2044
+ suffix: "]"
2045
+ },
2046
+ {
2047
+ path: "forksCount",
2048
+ prefix: "forks "
2049
+ }
2050
+ ] }
2051
+ }
2052
+ },
2053
+ notLoggedInPatterns: [
2054
+ "gh auth login",
2055
+ "not logged in",
2056
+ "authentication required"
2057
+ ]
2058
+ },
2059
+ searches: {
2060
+ "github-issues": {
2061
+ argv: [
2062
+ "search",
2063
+ "issues",
2064
+ "--json",
2065
+ "title,url,state,repository,commentsCount,updatedAt",
2066
+ "--limit",
2067
+ "{count}",
2068
+ "--sort",
2069
+ "updated",
2070
+ "--order",
2071
+ "desc",
2072
+ "--",
2073
+ "{query}"
2074
+ ],
2075
+ maxCount: 15,
2076
+ output: {
2077
+ format: "json",
2078
+ fields: {
2079
+ url: "url",
2080
+ title: "title",
2081
+ publishedAt: { date: "updatedAt" },
2082
+ snippet: {
2083
+ join: [
2084
+ {
2085
+ path: "state",
2086
+ prefix: "[",
2087
+ suffix: "]"
2088
+ },
2089
+ "repository.nameWithOwner",
2090
+ {
2091
+ path: "commentsCount",
2092
+ suffix: " comments"
2093
+ }
2094
+ ],
2095
+ sep: " · "
2096
+ }
2097
+ }
2098
+ },
2099
+ notLoggedInPatterns: [
2100
+ "gh auth login",
2101
+ "not logged in",
2102
+ "authentication required"
2103
+ ]
2104
+ },
2105
+ "github-code": {
2106
+ argv: [
2107
+ "search",
2108
+ "code",
2109
+ "--json",
2110
+ "path,repository,url",
2111
+ "--limit",
2112
+ "{count}",
2113
+ "--",
2114
+ "{query}"
2115
+ ],
2116
+ maxCount: 15,
2117
+ output: {
2118
+ format: "json",
2119
+ fields: {
2120
+ url: "url",
2121
+ title: { template: "{repository.nameWithOwner} / {path}" },
2122
+ snippet: { template: "仓库: {repository.nameWithOwner}" }
2123
+ }
2124
+ },
2125
+ notLoggedInPatterns: [
2126
+ "gh auth login",
2127
+ "not logged in",
2128
+ "authentication required"
2129
+ ]
2130
+ }
2131
+ },
2132
+ env: { passthrough: [
2133
+ "GH_TOKEN",
2134
+ "GITHUB_TOKEN",
2135
+ "GH_HOST",
2136
+ "GH_CONFIG_DIR"
2137
+ ] },
2138
+ needsLogin: true,
2139
+ login: { command: "gh auth login" },
2140
+ timeoutMs: 3e4,
2141
+ maxOutputBytes: 4 * MB,
2142
+ verification: {
2143
+ status: "live",
2144
+ version: "2.101.0",
2145
+ date: SPEC_CHECK_DATE,
2146
+ note: "`gh search repos --json … --sort stars -- \"dsh web search\"` parsed (fixture gh-repos.json); issues and code searches follow the documented JSON fields of their help pages and were not run"
2147
+ }
2148
+ },
2149
+ {
2150
+ id: "wx-search-cli",
2151
+ bins: ["wx-search-cli"],
2152
+ packageNote: "wx-search-cli (npm i -g wx-search-cli, tjx666/wx-search-cli; the command is `wx-search-cli`)",
2153
+ platforms: ["wechat"],
2154
+ probe: {
2155
+ versionArgs: ["--version"],
2156
+ helpArgs: ["--help"],
2157
+ mustContain: ["search <query>", "sogou"]
2158
+ },
2159
+ allowedSubcommands: ["search"],
2160
+ search: {
2161
+ argv: ["search", "{query}"],
2162
+ output: {
2163
+ format: "json",
2164
+ fields: {
2165
+ url: "real_url",
2166
+ title: "title",
2167
+ publishedAt: "publish_time"
2168
+ }
2169
+ }
2170
+ },
2171
+ env: {},
2172
+ needsLogin: false,
2173
+ timeoutMs: 45e3,
2174
+ maxOutputBytes: 2 * MB,
2175
+ verification: {
2176
+ status: "contract-only",
2177
+ version: "0.1.0",
2178
+ date: SPEC_CHECK_DATE,
2179
+ note: "help and version probed on a real install; output shape (JSON array of { title, link, real_url, publish_time, page }) from the upstream README and src/index.ts read with gh api; no search was run"
2180
+ }
2181
+ },
2182
+ {
2183
+ id: "tanso",
2184
+ bins: ["tanso"],
2185
+ packageNote: "tanso (npm i -g @geekjourneyx/tanso, geekjourneyx/tanso; a Go binary; needs your Bocha / Zhihu keys in its own config)",
2186
+ platforms: ["tanso"],
2187
+ probe: {
2188
+ versionArgs: ["version"],
2189
+ helpArgs: ["--help"],
2190
+ mustContain: [
2191
+ "bocha",
2192
+ "zhihu",
2193
+ "--json"
2194
+ ]
2195
+ },
2196
+ allowedSubcommands: ["version"],
2197
+ search: {
2198
+ argv: [
2199
+ "{query}",
2200
+ "--json",
2201
+ "--source",
2202
+ "bocha_web",
2203
+ "--source",
2204
+ "zhihu_search"
2205
+ ],
2206
+ output: {
2207
+ format: "json",
2208
+ itemsPath: "results",
2209
+ errorPaths: ["errors.0.message", "errors.0"],
2210
+ fields: {
2211
+ url: "url",
2212
+ title: "title",
2213
+ snippet: "snippet"
2214
+ }
2215
+ },
2216
+ notLoggedInPatterns: [
2217
+ "credential",
2218
+ "api_key",
2219
+ "access_secret",
2220
+ "config error"
2221
+ ]
2222
+ },
2223
+ env: { passthrough: [
2224
+ "BOCHA_API_KEY",
2225
+ "ZHIHU_ACCESS_SECRET",
2226
+ "ZHIHU_API_KEY",
2227
+ "TANSO_CONFIG",
2228
+ "XDG_CONFIG_HOME"
2229
+ ] },
2230
+ needsLogin: true,
2231
+ login: { command: "tanso config init" },
2232
+ timeoutMs: 6e4,
2233
+ maxOutputBytes: 2 * MB,
2234
+ verification: {
2235
+ status: "docs-only",
2236
+ version: "2.0.2",
2237
+ date: SPEC_CHECK_DATE,
2238
+ note: "not installed here; from the upstream README (envelope with results[].{source,title,url,snippet}); --limit is not documented and not used"
2239
+ }
2240
+ }
2241
+ ];
2242
+ for (const spec of BUILTIN_CLI_SPECS) {
2243
+ const result = validateCliAdapterSpec(spec, "builtin");
2244
+ if (!result.ok) throw new Error("built-in CLI spec " + spec.id + " is invalid: " + result.errors.join("; "));
2245
+ }
2246
+ /** Platforms whose provider is a chain (everything in the default table). */
2247
+ const CHAIN_PLATFORMS = Object.keys({
2248
+ xiaohongshu: [
2249
+ "opencli",
2250
+ "xhs",
2251
+ "browser-opencli"
2252
+ ],
2253
+ reddit: [
2254
+ "rdt",
2255
+ "opencli",
2256
+ "browser-opencli"
2257
+ ],
2258
+ twitter: [
2259
+ "twitter",
2260
+ "opencli",
2261
+ "browser-opencli"
2262
+ ],
2263
+ zhihu: ["zhihu", "browser-search"],
2264
+ bilibili: ["bili"],
2265
+ youtube: ["yt-dlp"],
2266
+ github: ["rest", "gh"],
2267
+ "github-issues": ["rest", "gh"],
2268
+ "github-code": ["rest", "gh"],
2269
+ wechat: ["omnireach", "wx-search-cli"],
2270
+ omnireach: ["omnireach"],
2271
+ tanso: ["tanso"],
2272
+ instagram: ["browser-opencli"],
2273
+ facebook: ["browser-opencli"],
2274
+ weibo: ["browser-search"],
2275
+ douban: ["browser-search"],
2276
+ tieba: ["browser-search"],
2277
+ douyin: ["browser-search"],
2278
+ kuaishou: ["browser-search"]
2279
+ });
2280
+ /** Platform -> site of the standalone OpenCLI (`opencli <site> search`). */
2281
+ const OPENCLI_SITE_OF = {
2282
+ xiaohongshu: "xiaohongshu",
2283
+ twitter: "twitter",
2284
+ reddit: "reddit",
2285
+ instagram: "instagram",
2286
+ facebook: "facebook",
2287
+ zhihu: "zhihu",
2288
+ weibo: "weibo",
2289
+ douban: "douban",
2290
+ tieba: "tieba",
2291
+ douyin: "douyin",
2292
+ bilibili: "bilibili",
2293
+ youtube: "youtube",
2294
+ wechat: "weixin"
2295
+ };
2296
+ /** Platforms the dsh-browser OpenCLI bridge has a site adapter for (pinned to engines.ts by a test). */
2297
+ const BROWSER_OPENCLI_PLATFORMS = [
2298
+ "xiaohongshu",
2299
+ "twitter",
2300
+ "reddit",
2301
+ "instagram",
2302
+ "facebook"
2303
+ ];
2304
+ /** Platforms with a built-in rendered-search-page spec in dsh-browser's searchResults (pinned to platform-search.ts by a test). */
2305
+ const BROWSER_SEARCH_PLATFORMS = [
2306
+ "zhihu",
2307
+ "weibo",
2308
+ "douban",
2309
+ "tieba",
2310
+ "douyin",
2311
+ "kuaishou"
2312
+ ];
2313
+ /** Platforms the plugin's own REST client serves. */
2314
+ const REST_PLATFORMS = [
2315
+ "github",
2316
+ "github-issues",
2317
+ "github-code"
2318
+ ];
2319
+ /** Backend ids that are not CLI specs. */
2320
+ const FIXED_BACKENDS = [
2321
+ "opencli",
2322
+ "browser-opencli",
2323
+ "browser-search",
2324
+ "rest"
2325
+ ];
2326
+ /**
2327
+ * Whether backend `id` can serve `platform`. `specPlatforms` maps a CLI spec id (built-in, or `custom-cli:<id>` of the user)
2328
+ * to the platforms it lists; a user spec serves any platform its chain names.
2329
+ */
2330
+ function backendServes(id, platform, specPlatforms) {
2331
+ if (id === "opencli") return OPENCLI_SITE_OF[platform] ? { ok: true } : {
2332
+ ok: false,
2333
+ why: "has no site for platform " + platform
2334
+ };
2335
+ if (id === "browser-opencli") return BROWSER_OPENCLI_PLATFORMS.includes(platform) ? { ok: true } : {
2336
+ ok: false,
2337
+ why: "does not serve platform " + platform
2338
+ };
2339
+ if (id === "browser-search") return BROWSER_SEARCH_PLATFORMS.includes(platform) ? { ok: true } : {
2340
+ ok: false,
2341
+ why: "does not serve platform " + platform
2342
+ };
2343
+ if (id === "rest") return REST_PLATFORMS.includes(platform) ? { ok: true } : {
2344
+ ok: false,
2345
+ why: "does not serve platform " + platform
2346
+ };
2347
+ const platforms = specPlatforms.get(id);
2348
+ if (!platforms) return {
2349
+ ok: false,
2350
+ why: "is not a known backend (built-in ids: " + [...specPlatforms.keys()].filter((k) => !k.startsWith("custom-cli:")).join(", ") + ", " + FIXED_BACKENDS.join(", ") + ")"
2351
+ };
2352
+ if (!id.startsWith("custom-cli:") && !platforms.includes(platform)) return {
2353
+ ok: false,
2354
+ why: "does not serve platform " + platform + " (it serves " + platforms.join(", ") + ")"
2355
+ };
2356
+ return { ok: true };
2357
+ }
2358
+ /** The CLI specs a chain may name: the built-ins by id and the user's valid `cliAdapters` as `custom-cli:<id>`. */
2359
+ function specPlatformsOf(cliAdapters) {
2360
+ const platforms = new Map(BUILTIN_CLI_SPECS.map((spec) => [spec.id, spec.platforms]));
2361
+ const user = resolveCliAdapters(cliAdapters);
2362
+ for (const [id, spec] of user.specs) platforms.set("custom-cli:" + id, spec.platforms);
2363
+ return {
2364
+ platforms,
2365
+ diagnostics: user.diagnostics
2366
+ };
2367
+ }
2368
+ /**
2369
+ * Every problem of the `platformBackends` setting (shape, unknown platform, unknown or foreign backend id) given the
2370
+ * `cliAdapters` setting. The settings card and `sources.status` both call this.
2371
+ */
2372
+ function platformBackendIssues(platformBackends, cliAdapters) {
2373
+ const shape = platformBackendProblems(platformBackends);
2374
+ if (shape.length || !platformBackends || typeof platformBackends !== "object") return shape;
2375
+ const { platforms } = specPlatformsOf(cliAdapters);
2376
+ const known = /* @__PURE__ */ new Set([...CHAIN_PLATFORMS, ...[...platforms.keys()].filter((id) => id.startsWith("custom-cli:"))]);
2377
+ const out = [];
2378
+ for (const [platform, ids] of Object.entries(platformBackends)) {
2379
+ if (!known.has(platform)) {
2380
+ out.push("platformBackends." + platform + ": no such platform with a backend chain (known: " + [...known].join(", ") + ")");
2381
+ continue;
2382
+ }
2383
+ for (const id of ids) {
2384
+ const served = backendServes(id, platform, platforms);
2385
+ if (!served.ok) out.push("platformBackends." + platform + ": \"" + id + "\" " + served.why);
2386
+ }
2387
+ }
2388
+ return out;
2389
+ }
2390
+ //#endregion
1011
2391
  //#region src/client/validators.ts
1012
2392
  /**
1013
2393
  * Validation behind the settings card. Every rule that has a server counterpart is the server's own function
@@ -1179,6 +2559,26 @@ window.__ModuleLoader__.load({
1179
2559
  function unknownRubricIds(rubrics) {
1180
2560
  return isRecord(rubrics) ? Object.keys(rubrics).filter((id) => !BUILTIN_RUBRIC_IDS.includes(id)) : [];
1181
2561
  }
2562
+ /**
2563
+ * Problems of the CLI adapter settings, from the server's own functions: `cliAdapters` entries the server would ignore
2564
+ * (read-only guard, bad argv, secrets in env) and `platformBackends` that name an unknown platform or a backend that
2565
+ * cannot serve it.
2566
+ */
2567
+ function cliIssues(platformBackends, cliAdapters) {
2568
+ const issues = [];
2569
+ for (const message of resolveCliAdapters(cliAdapters).diagnostics) issues.push({
2570
+ field: "cliAdapters",
2571
+ message,
2572
+ level: "error"
2573
+ });
2574
+ for (const message of platformBackendIssues(platformBackends, cliAdapters)) issues.push({
2575
+ field: "platformBackends",
2576
+ message,
2577
+ level: "error",
2578
+ related: ["cliAdapters"]
2579
+ });
2580
+ return issues;
2581
+ }
1182
2582
  //#endregion
1183
2583
  //#region src/client/form.ts
1184
2584
  const ROOTS = [
@@ -1592,9 +2992,21 @@ window.__ModuleLoader__.load({
1592
2992
  const base = this.scope.getSnapshot().base?.evidence;
1593
2993
  const evidence = deepMerge(isRecord(base) ? base : {}, candidates.get("evidence"));
1594
2994
  const baseSources = this.scope.getSnapshot().base?.sources;
1595
- const issues = [...evidenceIssues(evidence), ...sourcesIssues(deepMerge(isRecord(baseSources) ? baseSources : {}, candidates.get("sources")))];
2995
+ const topValue = (field) => {
2996
+ const draft = this.staged.get(field);
2997
+ if (draft === void 0) return this.sectionValue(field);
2998
+ if (draft.clear) return void 0;
2999
+ const write = this.spec(field).parse(draft.text);
3000
+ return write?.kind === "set" ? write.value : void 0;
3001
+ };
3002
+ const issues = [
3003
+ ...evidenceIssues(evidence),
3004
+ ...sourcesIssues(deepMerge(isRecord(baseSources) ? baseSources : {}, candidates.get("sources"))),
3005
+ ...cliIssues(topValue("platformBackends"), topValue("cliAdapters"))
3006
+ ];
1596
3007
  const blocked = /* @__PURE__ */ new Set();
1597
- for (const issue of issues) if (issue.level === "error" && (staged.has(issue.field) || (issue.related ?? []).some((related) => staged.has(related)))) blocked.add(issue.field);
3008
+ const touched = (field) => staged.has(field) || this.staged.has(field);
3009
+ for (const issue of issues) if (issue.level === "error" && (touched(issue.field) || (issue.related ?? []).some((related) => touched(related)))) blocked.add(issue.field);
1598
3010
  return {
1599
3011
  settings,
1600
3012
  issues,
@@ -2724,7 +4136,9 @@ window.__ModuleLoader__.load({
2724
4136
  toggle("enableCliBackends", "enableCliBackends", "enableCliBackendsHint"),
2725
4137
  toggle("opencliEnabled", "opencliEnabled", "opencliEnabledHint"),
2726
4138
  toggle("agentReachEnabled", "agentReachEnabled", "agentReachEnabledHint"),
2727
- json("playwright", "playwright", "playwrightHint", 4)
4139
+ json("playwright", "playwright", "playwrightHint", 4),
4140
+ json("platformBackends", "platformBackends", "platformBackendsHint", 7),
4141
+ json("cliAdapters", "cliAdapters", "cliAdaptersHint", 12)
2728
4142
  ]
2729
4143
  })
2730
4144
  }),
@@ -2859,6 +4273,10 @@ window.__ModuleLoader__.load({
2859
4273
  platformRulesHint: "按平台设置 item/title/link/text 选择器。",
2860
4274
  customPlatforms: "自定义平台(JSON)",
2861
4275
  customPlatformsHint: "按平台定义搜索 URL 与选择器;登录态请通过浏览器绑定引用 AuthProfile。",
4276
+ platformBackends: "平台后端顺序(JSON)",
4277
+ platformBackendsHint: "每个平台依次尝试的后端,如 {\"xiaohongshu\": [\"xhs\", \"opencli\", \"browser-opencli\"]}。id:各 CLI(xhs、rdt、zhihu、twitter、bili、yt-dlp、omnireach、wx-search-cli、gh、tanso)、opencli、browser-opencli、browser-search、rest、custom-cli:<id>。缺失或未登录的后端会被跳过并说明原因。",
4278
+ cliAdapters: "自定义 CLI 适配(JSON)",
4279
+ cliAdaptersHint: "按 id 定义只读 CLI 适配规格(argv 数组、输出字段、探测契约、allowedSubcommands)。写操作和读取浏览器 Cookie 的参数会被拒绝;无效条目被忽略并在 sources.status 说明。登录始终由你自己运行 CLI。",
2862
4280
  browserBindings: "浏览器绑定(JSON)",
2863
4281
  browserBindingsHint: "把平台绑定到 dsh-browser AuthProfile 与 RulePack。",
2864
4282
  verbose: "详细诊断日志",
@@ -3087,6 +4505,10 @@ window.__ModuleLoader__.load({
3087
4505
  platformRulesHint: "Set item/title/link/text selectors per platform.",
3088
4506
  customPlatforms: "Custom platforms (JSON)",
3089
4507
  customPlatformsHint: "Define search URLs and selectors; bind an AuthProfile for authenticated access.",
4508
+ platformBackends: "Platform backend order (JSON)",
4509
+ platformBackendsHint: "Backends each platform tries in order, e.g. {\"xiaohongshu\": [\"xhs\", \"opencli\", \"browser-opencli\"]}. Ids: the CLIs (xhs, rdt, zhihu, twitter, bili, yt-dlp, omnireach, wx-search-cli, gh, tanso), opencli, browser-opencli, browser-search, rest, custom-cli:<id>. A missing or logged-out backend is skipped with its reason.",
4510
+ cliAdapters: "Custom CLI adapters (JSON)",
4511
+ cliAdaptersHint: "Read-only CLI adapter specs by id (argv array, output fields, probe contract, allowedSubcommands). Write commands and cookie-reading flags are rejected; an invalid entry is ignored and explained in sources.status. Logging in is always yours to do.",
3090
4512
  browserBindings: "Browser bindings (JSON)",
3091
4513
  browserBindingsHint: "Bind platforms to dsh-browser AuthProfiles and RulePacks.",
3092
4514
  verbose: "Verbose diagnostics",