stitchkit 0.45.0 → 0.47.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.
Files changed (122) hide show
  1. package/README.md +5 -4
  2. package/dist/browser/cancellation.d.ts +14 -0
  3. package/dist/browser/cancellation.d.ts.map +1 -0
  4. package/dist/browser/client-multipart.d.ts +3 -1
  5. package/dist/browser/client-multipart.d.ts.map +1 -1
  6. package/dist/browser/client.d.ts +1 -0
  7. package/dist/browser/client.d.ts.map +1 -1
  8. package/dist/browser/http.d.ts +1 -0
  9. package/dist/browser/http.d.ts.map +1 -1
  10. package/dist/browser/socket-io.d.ts +3 -1
  11. package/dist/browser/socket-io.d.ts.map +1 -1
  12. package/dist/cli.js +3 -4
  13. package/dist/contract/define.d.ts +59 -15
  14. package/dist/contract/define.d.ts.map +1 -1
  15. package/dist/contract/errors.d.ts +1 -0
  16. package/dist/contract/errors.d.ts.map +1 -1
  17. package/dist/contract/index.d.ts +1 -1
  18. package/dist/contract/index.d.ts.map +1 -1
  19. package/dist/contract/index.js +2 -2
  20. package/dist/{index-w0mec79h.js → index-44xysy8r.js} +697 -301
  21. package/dist/{index-r6czv7yt.js → index-45dz4m51.js} +50 -3
  22. package/dist/index-8ekq6res.js +181 -0
  23. package/dist/{index-yh0p5ddn.js → index-ee621cmy.js} +39 -19
  24. package/dist/{index-mvjyq9h9.js → index-kp8xamqp.js} +309 -144
  25. package/dist/{index-36f1xkdb.js → index-nrytvb30.js} +5 -4
  26. package/dist/{index-6jypn22c.js → index-r1qp4rve.js} +9 -1
  27. package/dist/{index-s4qsmgwe.js → index-tss6bk5c.js} +4 -1
  28. package/dist/index-yxpe3phd.js +63 -0
  29. package/dist/index.d.ts +1 -0
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +315 -134
  32. package/dist/internal/errors.d.ts.map +1 -1
  33. package/dist/internal/secure-fetch.d.ts +23 -3
  34. package/dist/internal/secure-fetch.d.ts.map +1 -1
  35. package/dist/internal/typed.d.ts +7 -0
  36. package/dist/internal/typed.d.ts.map +1 -1
  37. package/dist/internal/within-dir.d.ts +6 -0
  38. package/dist/internal/within-dir.d.ts.map +1 -1
  39. package/dist/logger.d.ts +8 -0
  40. package/dist/logger.d.ts.map +1 -0
  41. package/dist/node.js +3 -4
  42. package/dist/observability/audit.d.ts +22 -3
  43. package/dist/observability/audit.d.ts.map +1 -1
  44. package/dist/observability/event.d.ts +7 -0
  45. package/dist/observability/event.d.ts.map +1 -1
  46. package/dist/observability/index.d.ts +1 -1
  47. package/dist/observability/index.d.ts.map +1 -1
  48. package/dist/observability/index.js +130 -134
  49. package/dist/observability/sanitize.d.ts +8 -1
  50. package/dist/observability/sanitize.d.ts.map +1 -1
  51. package/dist/observability/trace.d.ts +3 -1
  52. package/dist/observability/trace.d.ts.map +1 -1
  53. package/dist/react/cache-bridge.d.ts +4 -0
  54. package/dist/react/cache-bridge.d.ts.map +1 -1
  55. package/dist/react.js +46 -5
  56. package/dist/realtime/contract.d.ts +4 -1
  57. package/dist/realtime/contract.d.ts.map +1 -1
  58. package/dist/realtime/rejection.d.ts +10 -0
  59. package/dist/realtime/rejection.d.ts.map +1 -0
  60. package/dist/realtime/socket.d.ts +14 -1
  61. package/dist/realtime/socket.d.ts.map +1 -1
  62. package/dist/server/cache.d.ts +5 -1
  63. package/dist/server/cache.d.ts.map +1 -1
  64. package/dist/server/context.d.ts +8 -5
  65. package/dist/server/context.d.ts.map +1 -1
  66. package/dist/server/create.d.ts.map +1 -1
  67. package/dist/server/error-hook.d.ts +1 -0
  68. package/dist/server/error-hook.d.ts.map +1 -1
  69. package/dist/server/file.d.ts +6 -0
  70. package/dist/server/file.d.ts.map +1 -1
  71. package/dist/server/implement.d.ts +33 -2
  72. package/dist/server/implement.d.ts.map +1 -1
  73. package/dist/server/index.d.ts +5 -6
  74. package/dist/server/index.d.ts.map +1 -1
  75. package/dist/server/index.js +133 -14
  76. package/dist/server/middleware/auth.d.ts +7 -4
  77. package/dist/server/middleware/auth.d.ts.map +1 -1
  78. package/dist/server/middleware/cors.d.ts.map +1 -1
  79. package/dist/server/multipart.d.ts +13 -18
  80. package/dist/server/multipart.d.ts.map +1 -1
  81. package/dist/server/openapi.d.ts.map +1 -1
  82. package/dist/server/realtime.d.ts +2 -0
  83. package/dist/server/realtime.d.ts.map +1 -1
  84. package/dist/server/router.d.ts +0 -8
  85. package/dist/server/router.d.ts.map +1 -1
  86. package/dist/server/stream.d.ts.map +1 -1
  87. package/dist/server/types.d.ts +50 -22
  88. package/dist/server/types.d.ts.map +1 -1
  89. package/dist/tools/cli-args.d.ts +11 -3
  90. package/dist/tools/cli-args.d.ts.map +1 -1
  91. package/dist/tools/cli.d.ts +5 -0
  92. package/dist/tools/cli.d.ts.map +1 -1
  93. package/dist/tools/coerce.d.ts.map +1 -1
  94. package/dist/tools/json-schema.d.ts.map +1 -1
  95. package/dist/tools/mcp-handler.d.ts +4 -3
  96. package/dist/tools/mcp-handler.d.ts.map +1 -1
  97. package/dist/tools/mcp-prepare.d.ts +95 -0
  98. package/dist/tools/mcp-prepare.d.ts.map +1 -0
  99. package/dist/tools/mcp-round.d.ts +4 -0
  100. package/dist/tools/mcp-round.d.ts.map +1 -1
  101. package/dist/tools/mcp-stdio.d.ts +2 -1
  102. package/dist/tools/mcp-stdio.d.ts.map +1 -1
  103. package/dist/tools/mcp.d.ts +2 -78
  104. package/dist/tools/mcp.d.ts.map +1 -1
  105. package/dist/tools/mount-download.d.ts +5 -0
  106. package/dist/tools/mount-download.d.ts.map +1 -1
  107. package/dist/tools/native-mcp.d.ts +1 -1
  108. package/dist/tools/native-mcp.d.ts.map +1 -1
  109. package/dist/tools/oauth-provider.d.ts +20 -1
  110. package/dist/tools/oauth-provider.d.ts.map +1 -1
  111. package/dist/tools/tool-logger.d.ts +1 -1
  112. package/dist/tools/tool-logger.d.ts.map +1 -1
  113. package/dist/tools/view-file.d.ts +9 -0
  114. package/dist/tools/view-file.d.ts.map +1 -1
  115. package/dist/tools.d.ts +2 -1
  116. package/dist/tools.d.ts.map +1 -1
  117. package/dist/tools.js +505 -278
  118. package/llms-full.txt +473 -85
  119. package/llms.txt +1 -1
  120. package/package.json +2 -2
  121. package/dist/index-frfyw9fa.js +0 -33
  122. package/dist/index-x3fcszf8.js +0 -8
@@ -1,10 +1,8 @@
1
1
  import {
2
+ isWithinDir,
2
3
  jsonSchemaFields,
3
4
  toJsonSchema
4
- } from "./index-frfyw9fa.js";
5
- import {
6
- isWithinDir
7
- } from "./index-x3fcszf8.js";
5
+ } from "./index-yxpe3phd.js";
8
6
  import {
9
7
  AppError,
10
8
  STITCH_ERROR_STATUS,
@@ -17,7 +15,7 @@ import {
17
15
  runWithRequestContext,
18
16
  safeJsonParse,
19
17
  validateDeclaredOutput
20
- } from "./index-yh0p5ddn.js";
18
+ } from "./index-ee621cmy.js";
21
19
 
22
20
  // src/tools/coerce.ts
23
21
  import { z } from "zod";
@@ -30,6 +28,24 @@ function needsJsonCoercion(schema) {
30
28
  }
31
29
  return false;
32
30
  }
31
+ function acceptsRawString(schema) {
32
+ const type = schema._zod.def.type;
33
+ if (type === "string" || type === "template_literal")
34
+ return true;
35
+ if (schema instanceof z.ZodLiteral) {
36
+ return schema.def.values.some((value) => typeof value === "string");
37
+ }
38
+ if (schema instanceof z.ZodEnum) {
39
+ return schema.options.some((value) => typeof value === "string");
40
+ }
41
+ if (schema instanceof z.ZodOptional || schema instanceof z.ZodNullable || schema instanceof z.ZodDefault) {
42
+ return acceptsRawString(schema.unwrap());
43
+ }
44
+ if (schema instanceof z.ZodUnion) {
45
+ return schema.def.options.some((option) => acceptsRawString(option));
46
+ }
47
+ return false;
48
+ }
33
49
  function matchingVariant(union, value) {
34
50
  const disc = union.def.discriminator;
35
51
  const tag = value[disc];
@@ -52,6 +68,8 @@ function coerceValue(value, schema) {
52
68
  }
53
69
  let current = value;
54
70
  if (typeof current === "string" && needsJsonCoercion(schema)) {
71
+ if (acceptsRawString(schema))
72
+ return current;
55
73
  try {
56
74
  current = safeJsonParse(current);
57
75
  } catch {
@@ -574,15 +592,21 @@ async function runToolMethod(method, toolName, rawArgs, context, hooks, lifecycl
574
592
  const startedAt = Date.now();
575
593
  let hookContext = context;
576
594
  const finish = async (result, thrown) => {
577
- await hooks?.afterToolCall?.({
578
- toolName,
579
- args: rawArgs,
580
- result,
581
- durationMs: Date.now() - startedAt,
582
- context: hookContext,
583
- endpoint: method,
584
- ...thrown !== undefined && { error: thrown }
585
- });
595
+ try {
596
+ await hooks?.afterToolCall?.({
597
+ toolName,
598
+ args: rawArgs,
599
+ result,
600
+ durationMs: Date.now() - startedAt,
601
+ context: hookContext,
602
+ endpoint: method,
603
+ ...thrown !== undefined && { error: thrown }
604
+ });
605
+ } catch (hookError) {
606
+ try {
607
+ console.error("[stitchkit] afterToolCall hook failed:", hookError);
608
+ } catch {}
609
+ }
586
610
  return result;
587
611
  };
588
612
  let beforeRan = false;
@@ -615,7 +639,9 @@ async function runToolMethod(method, toolName, rawArgs, context, hooks, lifecycl
615
639
  endpoint: method
616
640
  });
617
641
  } catch (hookErr) {
618
- console.error("[stitchkit] onToolError hook failed:", hookErr);
642
+ try {
643
+ console.error("[stitchkit] onToolError hook failed:", hookErr);
644
+ } catch {}
619
645
  }
620
646
  }
621
647
  return finish(toolResultFromError(err), err);
@@ -994,8 +1020,14 @@ function formatToolError(result, toolName, errorHint) {
994
1020
 
995
1021
  // src/tools/cli-args.ts
996
1022
  import { z as z5 } from "zod";
1023
+ var NUMERIC_VALUE = /^-(\d+\.?\d*|\.\d+)$/;
997
1024
  var BOOL_OPTIONS = new Set(["json", "wait", "quiet", "dry-run", "help"]);
998
1025
  var VALUE_OPTIONS = new Set(["wait-timeout", "output-dir"]);
1026
+ var RESERVED_CLI_OPTIONS = new Set([...BOOL_OPTIONS, ...VALUE_OPTIONS]);
1027
+
1028
+ class CliArgumentError extends Error {
1029
+ name = "CliArgumentError";
1030
+ }
999
1031
  function unwrap(field) {
1000
1032
  if (field instanceof z5.ZodOptional || field instanceof z5.ZodNullable || field instanceof z5.ZodDefault) {
1001
1033
  return unwrap(field.unwrap());
@@ -1021,31 +1053,71 @@ function classify(field) {
1021
1053
  return "string";
1022
1054
  return "other";
1023
1055
  }
1024
- function describeSchemaFields(schema) {
1025
- const fields = new Map;
1026
- if (!(schema instanceof z5.ZodObject))
1027
- return fields;
1028
- for (const [name, raw] of Object.entries(schema.shape)) {
1029
- const base = unwrap(raw);
1030
- const kind = classify(base);
1031
- if (kind === "array" && base instanceof z5.ZodArray) {
1032
- fields.set(name, { kind, elementKind: classify(unwrap(base.element)) });
1033
- } else {
1034
- fields.set(name, { kind });
1056
+ function mergeField(fields, name, info) {
1057
+ const existing = fields.get(name);
1058
+ if (!existing) {
1059
+ fields.set(name, info);
1060
+ return;
1061
+ }
1062
+ if (existing.kind === info.kind)
1063
+ return;
1064
+ if (existing.kind === "boolean" || info.kind === "boolean") {
1065
+ fields.set(name, { kind: "boolean" });
1066
+ return;
1067
+ }
1068
+ fields.set(name, { kind: "other" });
1069
+ }
1070
+ function collectSchemaFields(schema, fields) {
1071
+ const base = unwrap(schema);
1072
+ if (base instanceof z5.ZodObject) {
1073
+ for (const [name, raw] of Object.entries(base.shape)) {
1074
+ const fieldBase = unwrap(raw);
1075
+ const kind = classify(fieldBase);
1076
+ if (kind === "array" && fieldBase instanceof z5.ZodArray) {
1077
+ mergeField(fields, name, { kind, elementKind: classify(unwrap(fieldBase.element)) });
1078
+ } else {
1079
+ mergeField(fields, name, { kind });
1080
+ }
1035
1081
  }
1082
+ return;
1083
+ }
1084
+ if (base instanceof z5.ZodUnion) {
1085
+ for (const option of base.def.options)
1086
+ collectSchemaFields(option, fields);
1087
+ return;
1036
1088
  }
1089
+ if (base instanceof z5.ZodIntersection) {
1090
+ collectSchemaFields(base.def.left, fields);
1091
+ collectSchemaFields(base.def.right, fields);
1092
+ return;
1093
+ }
1094
+ }
1095
+ function describeSchemaFields(schema) {
1096
+ const fields = new Map;
1097
+ if (schema)
1098
+ collectSchemaFields(schema, fields);
1037
1099
  return fields;
1038
1100
  }
1039
- function parseBool(value) {
1101
+ var TRUE_WORDS = new Set(["true", "1", "yes", "on"]);
1102
+ var FALSE_WORDS = new Set(["false", "0", "no", "off"]);
1103
+ function parseReservedBool(name, value) {
1040
1104
  const v = value.toLowerCase();
1041
- if (v === "false" || v === "0" || v === "no" || v === "off")
1105
+ if (TRUE_WORDS.has(v))
1106
+ return true;
1107
+ if (FALSE_WORDS.has(v))
1042
1108
  return false;
1043
- return true;
1109
+ throw new CliArgumentError(`--${name} expects a boolean (true/false), got "${value}"`);
1044
1110
  }
1045
1111
  function coerceScalar(kind, value) {
1046
1112
  switch (kind) {
1047
- case "boolean":
1048
- return parseBool(value);
1113
+ case "boolean": {
1114
+ const v = value.toLowerCase();
1115
+ if (TRUE_WORDS.has(v))
1116
+ return true;
1117
+ if (FALSE_WORDS.has(v))
1118
+ return false;
1119
+ return value;
1120
+ }
1049
1121
  case "number": {
1050
1122
  const n = Number(value);
1051
1123
  return value.trim() !== "" && !Number.isNaN(n) ? n : value;
@@ -1090,8 +1162,10 @@ function coerceField(info, values) {
1090
1162
  return coerceScalar(info.kind, last);
1091
1163
  }
1092
1164
  function setNested(target, path, value) {
1093
- if (path.some(isUnsafeKey))
1094
- return;
1165
+ const unsafe = path.find(isUnsafeKey);
1166
+ if (unsafe !== undefined) {
1167
+ throw new CliArgumentError(`Unsafe option path segment "${unsafe}"`);
1168
+ }
1095
1169
  let node = target;
1096
1170
  for (let i = 0;i < path.length - 1; i++) {
1097
1171
  const key = path[i];
@@ -1110,7 +1184,7 @@ function setNested(target, path, value) {
1110
1184
  if (leaf !== undefined)
1111
1185
  node[leaf] = value;
1112
1186
  }
1113
- function parseCliArgs(argv, schema) {
1187
+ function parseCliArgs(argv, schema, config = {}) {
1114
1188
  const options = {
1115
1189
  json: false,
1116
1190
  wait: false,
@@ -1118,50 +1192,11 @@ function parseCliArgs(argv, schema) {
1118
1192
  dryRun: false,
1119
1193
  help: false
1120
1194
  };
1121
- const rest = [];
1122
- for (let i = 0;i < argv.length; i++) {
1123
- const tok = argv[i];
1124
- if (tok === undefined)
1125
- continue;
1126
- if (tok === "-h") {
1127
- options.help = true;
1128
- continue;
1129
- }
1130
- if (tok.startsWith("--")) {
1131
- const eq = tok.indexOf("=");
1132
- const name = eq >= 0 ? tok.slice(2, eq) : tok.slice(2);
1133
- const inline = eq >= 0 ? tok.slice(eq + 1) : undefined;
1134
- if (BOOL_OPTIONS.has(name)) {
1135
- const on = inline === undefined ? true : parseBool(inline);
1136
- if (name === "dry-run")
1137
- options.dryRun = on;
1138
- else if (name === "json")
1139
- options.json = on;
1140
- else if (name === "wait")
1141
- options.wait = on;
1142
- else if (name === "quiet")
1143
- options.quiet = on;
1144
- else if (name === "help")
1145
- options.help = on;
1146
- continue;
1147
- }
1148
- if (VALUE_OPTIONS.has(name)) {
1149
- const value = inline ?? argv[++i];
1150
- if (value !== undefined) {
1151
- if (name === "wait-timeout") {
1152
- const n = Number(value);
1153
- if (!Number.isNaN(n))
1154
- options.waitTimeout = n;
1155
- } else if (name === "output-dir") {
1156
- options.outputDir = value;
1157
- }
1158
- }
1159
- continue;
1160
- }
1161
- }
1162
- rest.push(tok);
1163
- }
1164
1195
  const fields = describeSchemaFields(schema);
1196
+ for (const name of config.knownFields ?? []) {
1197
+ if (!fields.has(name))
1198
+ fields.set(name, { kind: "other" });
1199
+ }
1165
1200
  const flags = new Map;
1166
1201
  const boolFlags = new Map;
1167
1202
  const positionals = [];
@@ -1172,34 +1207,88 @@ function parseCliArgs(argv, schema) {
1172
1207
  else
1173
1208
  flags.set(name, [value]);
1174
1209
  };
1175
- for (let i = 0;i < rest.length; i++) {
1176
- const tok = rest[i];
1210
+ let optionsEnded = false;
1211
+ for (let i = 0;i < argv.length; i++) {
1212
+ const tok = argv[i];
1177
1213
  if (tok === undefined)
1178
1214
  continue;
1179
- if (!tok.startsWith("--")) {
1215
+ if (!optionsEnded && tok === "--") {
1216
+ optionsEnded = true;
1217
+ continue;
1218
+ }
1219
+ if (optionsEnded || !tok.startsWith("-") || tok === "-") {
1180
1220
  positionals.push(tok);
1181
1221
  continue;
1182
1222
  }
1223
+ if (tok === "-h") {
1224
+ options.help = true;
1225
+ continue;
1226
+ }
1227
+ if (!tok.startsWith("--")) {
1228
+ throw new CliArgumentError(`Unknown option "${tok}"`);
1229
+ }
1183
1230
  const eq = tok.indexOf("=");
1184
1231
  const name = eq >= 0 ? tok.slice(2, eq) : tok.slice(2);
1185
1232
  let value = eq >= 0 ? tok.slice(eq + 1) : undefined;
1233
+ if (name.length === 0)
1234
+ throw new CliArgumentError("Invalid empty option name");
1235
+ if (BOOL_OPTIONS.has(name)) {
1236
+ const enabled = value === undefined ? true : parseReservedBool(name, value);
1237
+ if (name === "dry-run")
1238
+ options.dryRun = enabled;
1239
+ else if (name === "json")
1240
+ options.json = enabled;
1241
+ else if (name === "wait")
1242
+ options.wait = enabled;
1243
+ else if (name === "quiet")
1244
+ options.quiet = enabled;
1245
+ else
1246
+ options.help = enabled;
1247
+ continue;
1248
+ }
1249
+ if (VALUE_OPTIONS.has(name)) {
1250
+ const next = argv[i + 1];
1251
+ value = value ?? next;
1252
+ if (value === undefined || value.startsWith("-") && !NUMERIC_VALUE.test(value)) {
1253
+ throw new CliArgumentError(`--${name} requires a value`);
1254
+ }
1255
+ if (eq < 0)
1256
+ i++;
1257
+ if (name === "wait-timeout") {
1258
+ const timeout = Number(value);
1259
+ if (!Number.isFinite(timeout) || timeout <= 0) {
1260
+ throw new CliArgumentError("--wait-timeout must be a positive number");
1261
+ }
1262
+ options.waitTimeout = timeout;
1263
+ } else {
1264
+ options.outputDir = value;
1265
+ }
1266
+ continue;
1267
+ }
1186
1268
  if (value === undefined && name.startsWith("no-") && fields.get(name.slice(3))?.kind === "boolean") {
1187
1269
  boolFlags.set(name.slice(3), false);
1188
1270
  continue;
1189
1271
  }
1272
+ const unsafeSegment = name.split(".").find(isUnsafeKey);
1273
+ if (unsafeSegment !== undefined) {
1274
+ throw new CliArgumentError(`Unsafe option name "--${name}"`);
1275
+ }
1190
1276
  const info = fields.get(name);
1277
+ const rootName = name.split(".")[0] ?? name;
1278
+ if (!fields.has(rootName) && !config.allowUnknown) {
1279
+ throw new CliArgumentError(`Unknown option "--${name}"`);
1280
+ }
1191
1281
  if (value === undefined) {
1192
1282
  if (info?.kind === "boolean") {
1193
1283
  boolFlags.set(name, true);
1194
1284
  continue;
1195
1285
  }
1196
- const next = rest[i + 1];
1197
- if (next !== undefined && !next.startsWith("--")) {
1286
+ const next = argv[i + 1];
1287
+ if (next !== undefined && (!next.startsWith("-") || NUMERIC_VALUE.test(next))) {
1198
1288
  value = next;
1199
1289
  i++;
1200
1290
  } else {
1201
- boolFlags.set(name, true);
1202
- continue;
1291
+ throw new CliArgumentError(`--${name} requires a value`);
1203
1292
  }
1204
1293
  }
1205
1294
  pushFlag(name, value);
@@ -1216,19 +1305,36 @@ function parseCliArgs(argv, schema) {
1216
1305
  if (value !== undefined)
1217
1306
  toolArgs[key] = coerceField(fields.get(key), [value]);
1218
1307
  }
1308
+ if (pi < positionals.length) {
1309
+ throw new CliArgumentError(`Unexpected positional argument "${positionals[pi]}"`);
1310
+ }
1219
1311
  for (const [key, value] of boolFlags) {
1220
- if (isUnsafeKey(key))
1221
- continue;
1222
1312
  toolArgs[key] = value;
1223
1313
  }
1314
+ const dottedRoots = new Map;
1315
+ for (const key of flags.keys()) {
1316
+ const dot = key.indexOf(".");
1317
+ if (dot > 0)
1318
+ dottedRoots.set(key.slice(0, dot), key);
1319
+ }
1320
+ for (const [root, dotted] of dottedRoots) {
1321
+ if (flags.has(root)) {
1322
+ throw new CliArgumentError(`--${root} conflicts with --${dotted} — pass one form, not both`);
1323
+ }
1324
+ }
1224
1325
  for (const [key, values] of flags) {
1225
1326
  if (key.includes(".")) {
1226
- setNested(toolArgs, key.split("."), looseCoerce(values[values.length - 1] ?? ""));
1327
+ if (values.length > 1) {
1328
+ throw new CliArgumentError(`--${key} was passed ${values.length} times`);
1329
+ }
1330
+ setNested(toolArgs, key.split("."), looseCoerce(values[0] ?? ""));
1227
1331
  continue;
1228
1332
  }
1229
- if (isUnsafeKey(key))
1230
- continue;
1231
- toolArgs[key] = coerceField(fields.get(key), values);
1333
+ const info = fields.get(key);
1334
+ if (values.length > 1 && info !== undefined && info.kind !== "array" && info.kind !== "other") {
1335
+ throw new CliArgumentError(`--${key} was passed ${values.length} times`);
1336
+ }
1337
+ toolArgs[key] = coerceField(info, values);
1232
1338
  }
1233
1339
  return { toolArgs, options };
1234
1340
  }
@@ -1415,47 +1521,71 @@ function isPrivateIp(ip) {
1415
1521
  }
1416
1522
  return false;
1417
1523
  }
1418
- async function assertPublicUrl(url) {
1419
- assertHttpUrl(url);
1420
- const host = url.hostname.replace(/^\[|\]$/g, "");
1421
- if (!host)
1422
- throw new Error("refusing to fetch a URL with no host");
1423
- if (isIP(host)) {
1424
- if (isPrivateIp(host))
1425
- throw new Error("refusing to fetch a private address");
1426
- return;
1427
- }
1428
- if (NUMERIC_HOST.test(host)) {
1429
- throw new Error("refusing to fetch a non-canonical numeric host");
1524
+ function raceWithAbort(promise, signal) {
1525
+ if (signal.aborted) {
1526
+ promise.catch(() => {
1527
+ return;
1528
+ });
1529
+ return Promise.reject(signal.reason ?? new Error("aborted"));
1530
+ }
1531
+ return new Promise((resolve, reject) => {
1532
+ const onAbort = () => reject(signal.reason ?? new Error("aborted"));
1533
+ signal.addEventListener("abort", onAbort, { once: true });
1534
+ promise.then((value) => {
1535
+ signal.removeEventListener("abort", onAbort);
1536
+ resolve(value);
1537
+ }, (error) => {
1538
+ signal.removeEventListener("abort", onAbort);
1539
+ reject(error);
1540
+ });
1541
+ });
1542
+ }
1543
+ async function assertPublicUrl(url, signal) {
1544
+ await resolvePublicAddress(url, signal);
1545
+ }
1546
+ async function fetchGuarded(start, allowPrivate, options = {}) {
1547
+ const timeoutMs = options.timeoutMs ?? 15000;
1548
+ const bodyTimeoutMs = options.bodyTimeoutMs ?? 120000;
1549
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
1550
+ throw new Error("timeoutMs must be positive");
1430
1551
  }
1431
- if (host === "localhost" || host.endsWith(".local") || host.endsWith(".internal")) {
1432
- throw new Error("refusing to fetch an internal host");
1552
+ if (!Number.isFinite(bodyTimeoutMs) || bodyTimeoutMs <= 0) {
1553
+ throw new Error("bodyTimeoutMs must be positive");
1433
1554
  }
1434
- const records = await lookup(host, { all: true });
1435
- for (const record of records) {
1436
- if (isPrivateIp(record.address)) {
1437
- throw new Error("refusing to fetch a host that resolves to a private address");
1555
+ const controller = new AbortController;
1556
+ const signal = options.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
1557
+ const headerTimer = setTimeout(() => {
1558
+ controller.abort(new Error(`fetch did not produce response headers within ${timeoutMs}ms`));
1559
+ }, timeoutMs);
1560
+ headerTimer.unref?.();
1561
+ let settled = false;
1562
+ try {
1563
+ let url = start;
1564
+ for (let hop = 0;hop <= MAX_REDIRECTS; hop++) {
1565
+ if (allowPrivate)
1566
+ assertHttpUrl(url);
1567
+ else
1568
+ await assertPublicUrl(url, signal);
1569
+ const res = await fetch(url, { redirect: "manual", signal });
1570
+ const location = res.status >= 300 && res.status < 400 ? res.headers.get("location") : null;
1571
+ if (location === null) {
1572
+ settled = true;
1573
+ clearTimeout(headerTimer);
1574
+ const bodyTimer = setTimeout(() => {
1575
+ controller.abort(new Error(`fetch body was not consumed within ${bodyTimeoutMs}ms`));
1576
+ }, bodyTimeoutMs);
1577
+ bodyTimer.unref?.();
1578
+ return res;
1579
+ }
1580
+ await res.body?.cancel();
1581
+ url = new URL(location, url);
1438
1582
  }
1583
+ throw new Error("too many redirects");
1584
+ } finally {
1585
+ if (!settled)
1586
+ clearTimeout(headerTimer);
1439
1587
  }
1440
1588
  }
1441
- async function fetchGuarded(start, allowPrivate) {
1442
- let url = start;
1443
- for (let hop = 0;hop <= MAX_REDIRECTS; hop++) {
1444
- if (allowPrivate)
1445
- assertHttpUrl(url);
1446
- else
1447
- await assertPublicUrl(url);
1448
- const res = await fetch(url, { redirect: "manual" });
1449
- if (res.status < 300 || res.status >= 400)
1450
- return res;
1451
- const location = res.headers.get("location");
1452
- if (!location)
1453
- return res;
1454
- await res.body?.cancel();
1455
- url = new URL(location, url);
1456
- }
1457
- throw new Error("too many redirects");
1458
- }
1459
1589
  async function resolvePublicAddress(url, signal) {
1460
1590
  assertHttpUrl(url);
1461
1591
  const host = url.hostname.replace(/^\[|\]$/g, "");
@@ -1473,12 +1603,8 @@ async function resolvePublicAddress(url, signal) {
1473
1603
  if (host === "localhost" || host.endsWith(".local") || host.endsWith(".internal")) {
1474
1604
  throw new Error("refusing to fetch an internal host");
1475
1605
  }
1476
- const records = await Promise.race([
1477
- lookup(host, { all: true }),
1478
- new Promise((_resolve, reject) => {
1479
- signal.addEventListener("abort", () => reject(signal.reason), { once: true });
1480
- })
1481
- ]);
1606
+ const pending = lookup(host, { all: true });
1607
+ const records = signal ? await raceWithAbort(pending, signal) : await pending;
1482
1608
  for (const record2 of records) {
1483
1609
  if (isPrivateIp(record2.address)) {
1484
1610
  throw new Error("refusing to fetch a host that resolves to a private address");
@@ -1501,8 +1627,8 @@ async function fetchPinnedDocument(start, options) {
1501
1627
  if (!Number.isInteger(maxRedirects) || maxRedirects < 0) {
1502
1628
  throw new Error("maxRedirects must be a non-negative integer");
1503
1629
  }
1630
+ const signal = AbortSignal.timeout(options.timeoutMs);
1504
1631
  for (let hop = 0;hop <= maxRedirects; hop++) {
1505
- const signal = AbortSignal.timeout(options.timeoutMs);
1506
1632
  if (options.requireHttps && url.protocol !== "https:") {
1507
1633
  throw new Error("document URL and every redirect must use https");
1508
1634
  }
@@ -1704,12 +1830,7 @@ function renderTopHelp(name, version, tools) {
1704
1830
  }
1705
1831
  function renderCommandHelp(name, command, tool) {
1706
1832
  const lines = [tool.method.desc, "", `Usage: ${name} ${command} [args] [--flags]`, ""];
1707
- let fields = [];
1708
- try {
1709
- fields = jsonSchemaFields(tool.presentationSchema);
1710
- } catch {
1711
- lines.push("(arguments: pass a JSON object — this command has a complex schema)", "");
1712
- }
1833
+ const fields = jsonSchemaFields(tool.presentationSchema);
1713
1834
  if (fields.length > 0) {
1714
1835
  lines.push("Arguments:");
1715
1836
  const width = Math.max(...fields.map((f) => f.name.length));
@@ -1725,11 +1846,12 @@ function renderCommandHelp(name, command, tool) {
1725
1846
  `;
1726
1847
  }
1727
1848
  var DEFAULT_DOWNLOAD_MAX_BYTES = 100 * 1024 * 1024;
1728
- async function downloadResults(files, dir, stderr, quiet, allowPrivate, maxBytes) {
1849
+ async function downloadResults(files, dir, stderr, quiet, allowPrivate, maxBytes, timeoutMs) {
1729
1850
  const root = resolve(dir);
1851
+ let succeeded = true;
1730
1852
  for (const file of files) {
1731
1853
  try {
1732
- const res = await fetchGuarded(new URL(file.url), allowPrivate);
1854
+ const res = await fetchGuarded(new URL(file.url), allowPrivate, { timeoutMs });
1733
1855
  if (!res.ok)
1734
1856
  throw new Error(`HTTP ${res.status}`);
1735
1857
  const buffer = await readCapped(res, maxBytes);
@@ -1741,10 +1863,12 @@ async function downloadResults(files, dir, stderr, quiet, allowPrivate, maxBytes
1741
1863
  stderr(`saved ${target} (${(buffer.length / 1024).toFixed(0)}KB)
1742
1864
  `);
1743
1865
  } catch (err) {
1866
+ succeeded = false;
1744
1867
  stderr(`failed to download ${file.url}: ${err instanceof Error ? err.message : String(err)}
1745
1868
  `);
1746
1869
  }
1747
1870
  }
1871
+ return succeeded;
1748
1872
  }
1749
1873
  async function createCli(config) {
1750
1874
  const stdout = config.stdout ?? ((text) => void process.stdout.write(text));
@@ -1759,6 +1883,13 @@ async function createCli(config) {
1759
1883
  for (const service of services) {
1760
1884
  for (const mountable of collectTools(service, "CLI")) {
1761
1885
  assertUniqueToolName(mountable.name, tools.has(mountable.name), "CLI command");
1886
+ if (mountable.name === "help" || mountable.name === "version") {
1887
+ throw new Error(`[stitchkit] CLI command "${mountable.name}" is reserved`);
1888
+ }
1889
+ const conflicting = jsonSchemaFields(mountable.presentationSchema).map((field) => field.name).filter((name) => RESERVED_CLI_OPTIONS.has(name));
1890
+ if (conflicting.length > 0) {
1891
+ throw new Error(`[stitchkit] CLI command "${mountable.name}" declares reserved option field(s): ${conflicting.join(", ")}`);
1892
+ }
1762
1893
  tools.set(mountable.name, mountable);
1763
1894
  }
1764
1895
  }
@@ -1786,7 +1917,40 @@ async function createCli(config) {
1786
1917
  `);
1787
1918
  return exit(1);
1788
1919
  }
1789
- const { toolArgs, options } = parseCliArgs(commandArgv, tool.argumentSchema);
1920
+ const beforeSeparator = commandArgv.indexOf("--") === -1 ? commandArgv : commandArgv.slice(0, commandArgv.indexOf("--"));
1921
+ if (beforeSeparator.includes("--help") || beforeSeparator.includes("-h")) {
1922
+ stdout(renderCommandHelp(config.name, command, tool));
1923
+ return exit(0);
1924
+ }
1925
+ let parsed;
1926
+ try {
1927
+ parsed = parseCliArgs(commandArgv, tool.argumentSchema, {
1928
+ allowUnknown: config.passthrough?.[command] !== undefined,
1929
+ knownFields: jsonSchemaFields(tool.presentationSchema).map((field) => field.name)
1930
+ });
1931
+ } catch (error) {
1932
+ if (!(error instanceof CliArgumentError))
1933
+ throw error;
1934
+ stderr(`${error.message}
1935
+ `);
1936
+ return exit(2);
1937
+ }
1938
+ const { toolArgs, options } = parsed;
1939
+ if (options.wait && !config.wait?.[command]) {
1940
+ stderr(`--wait is not configured for command "${command}"
1941
+ `);
1942
+ return exit(2);
1943
+ }
1944
+ if (options.waitTimeout !== undefined && !options.wait) {
1945
+ stderr(`--wait-timeout requires --wait
1946
+ `);
1947
+ return exit(2);
1948
+ }
1949
+ if (options.outputDir !== undefined && !config.download) {
1950
+ stderr(`--output-dir is not configured for this CLI
1951
+ `);
1952
+ return exit(2);
1953
+ }
1790
1954
  if (options.help) {
1791
1955
  stdout(renderCommandHelp(config.name, command, tool));
1792
1956
  return exit(0);
@@ -1834,8 +1998,9 @@ async function createCli(config) {
1834
1998
  `)
1835
1999
  });
1836
2000
  }
2001
+ let downloadsOk = true;
1837
2002
  if (options.outputDir && result.ok && config.download) {
1838
- await downloadResults(config.download(result.data), options.outputDir, stderr, options.quiet, config.allowPrivateDownloadHosts ?? false, config.maxDownloadBytes ?? DEFAULT_DOWNLOAD_MAX_BYTES);
2003
+ downloadsOk = await downloadResults(config.download(result.data), options.outputDir, stderr, options.quiet, config.allowPrivateDownloadHosts ?? false, config.maxDownloadBytes ?? DEFAULT_DOWNLOAD_MAX_BYTES, config.downloadTimeoutMs);
1839
2004
  }
1840
2005
  const exitCode = emitResult(result, { stdout, stderr }, {
1841
2006
  json: options.json,
@@ -1843,7 +2008,7 @@ async function createCli(config) {
1843
2008
  errorHint: config.errorHint,
1844
2009
  exitCodes: { ...DEFAULT_EXIT_CODES, ...config.exitCodes }
1845
2010
  });
1846
- return exit(exitCode);
2011
+ return exit(downloadsOk ? exitCode : 1);
1847
2012
  }
1848
2013
  function safeInputSchema(tool) {
1849
2014
  return tool.presentationSchema;