@cosyte/cli 0.0.6 → 0.1.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.
@@ -6,6 +6,7 @@ var index_js = require('@modelcontextprotocol/sdk/server/index.js');
6
6
  var stdio_js = require('@modelcontextprotocol/sdk/server/stdio.js');
7
7
  var types_js = require('@modelcontextprotocol/sdk/types.js');
8
8
  var promises = require('fs/promises');
9
+ var crypto = require('crypto');
9
10
 
10
11
  var __defProp = Object.defineProperty;
11
12
  var __getOwnPropNames = Object.getOwnPropertyNames;
@@ -29,21 +30,29 @@ var init_exit_codes = __esm({
29
30
  EXIT = {
30
31
  /** Success: the operation completed; `validate` found the input **valid**. */
31
32
  OK: 0,
32
- /** Operation-level failure: `validate` found the input **invalid** (parseable but non-conformant):
33
- * a real, expected CI signal that the message is bad and the tool worked. Never emitted for
34
- * unparseable input (that is `DATAERR`). */
33
+ /** Operation-level failure: `validate` found the input **invalid** (parseable but non-conformant),
34
+ * or `redact` could not de-identify every locus: a real, expected CI signal that the tool worked
35
+ * and the operation did not complete. Never emitted for unparseable input (that is `DATAERR`). */
35
36
  INVALID: 1,
36
- /** Usage error: unknown command, bad flag, missing argument (`EX_USAGE`). */
37
+ /** Usage error: unknown command, bad flag, missing argument. A value specific to this CLI, not a
38
+ * `sysexits.h` constant. */
37
39
  USAGE: 2,
38
40
  /** Data error: input could not be parsed or its format could not be detected (`EX_DATAERR`). */
39
41
  DATAERR: 65,
40
42
  /** No input: the named file does not exist or is unreadable (`EX_NOINPUT`). */
41
43
  NOINPUT: 66,
42
- /** Unavailable: a required capability is not yet wired (e.g. `redact` before `@cosyte/deid`), a
43
- * distinct non-zero signal that is never a fake success (`EX_UNAVAILABLE`). */
44
+ /** Unavailable: a required capability is not wired for this input (e.g. `redact` on a format
45
+ * `@cosyte/deid` has no adapter for, or with that optional library absent), a distinct non-zero
46
+ * signal that is never a fake success (`EX_UNAVAILABLE`). */
44
47
  UNAVAILABLE: 69,
45
48
  /** Internal error: an unexpected exception (a bug), distinct from a handled bad input (`EX_SOFTWARE`). */
46
- SOFTWARE: 70
49
+ SOFTWARE: 70,
50
+ /** Output error: the stream the CLI was writing to closed before the output had been delivered, so
51
+ * the consumer holds nothing or holds only part of the answer. The shape a downstream consumer
52
+ * that exits early produces (a pager quit, a `head` that has seen enough). A handled condition the
53
+ * consumer owns, never a defect in the CLI, so it is deliberately distinct from `SOFTWARE`
54
+ * (`EX_IOERR`). */
55
+ IOERR: 74
47
56
  };
48
57
  }
49
58
  });
@@ -88,12 +97,27 @@ var init_diagnostics = __esm({
88
97
  CLI_PARSER_UNAVAILABLE: "CLI_PARSER_UNAVAILABLE",
89
98
  /** The wrapped parser rejected the input. Positional context only, never the offending bytes. Exit `65`. */
90
99
  CLI_PARSE_FAILED: "CLI_PARSE_FAILED",
100
+ /** The input is larger than the documented size limit the CLI reads in one invocation. Raised
101
+ * against the running byte count while the input is read, so the refusal always arrives before an
102
+ * allocation ceiling could. Names the limit, never the input. Exit `65`, never `70`. */
103
+ CLI_INPUT_TOO_LARGE: "CLI_INPUT_TOO_LARGE",
104
+ /** The output stream could not be written: a consumer that closes the pipe ends the invocation
105
+ * here rather than as an unhandled platform error, whether it went away part way through a record
106
+ * stream or before a single-result write was delivered. Names no input, and never the platform's
107
+ * own error text. Exit `74`. */
108
+ CLI_OUTPUT_WRITE_FAILED: "CLI_OUTPUT_WRITE_FAILED",
91
109
  /** The BYO ConceptMap supplied to `map-codes` is not valid JSON or not a loadable FHIR ConceptMap.
92
110
  * Names the stable terminology-loader code, never the map's bytes. Exit `65`. */
93
111
  CLI_MAP_INVALID: "CLI_MAP_INVALID",
94
- /** A command whose ground-layer library is not yet wired (e.g. `redact` before `@cosyte/deid` is
95
- * wired). Never a fake success: a distinct, value-free "unavailable" signal. Exit `69`. */
112
+ /** A command whose ground-layer library does not cover the resolved format (e.g. `redact` on a
113
+ * format `@cosyte/deid` ships no adapter for). Never a fake success: a distinct, value-free
114
+ * "unavailable" signal. Exit `69`. */
96
115
  CLI_NOT_IMPLEMENTED: "CLI_NOT_IMPLEMENTED",
116
+ /** The delegated de-identifier reported that it could not handle every element of the input (a
117
+ * fail-closed `blocked` locus, or its own fatal). The run is **not** a de-identified copy, so no
118
+ * output is emitted: an operation-level failure, never a bug. Names loci and codes, never a
119
+ * value. Exit `1`. */
120
+ CLI_DEID_INCOMPLETE: "CLI_DEID_INCOMPLETE",
97
121
  /** An unexpected internal error (a bug). Exit `70`. */
98
122
  CLI_INTERNAL: "CLI_INTERNAL"
99
123
  };
@@ -117,6 +141,28 @@ var init_diagnostics = __esm({
117
141
  }
118
142
  });
119
143
 
144
+ // src/core/limits.ts
145
+ function describeByteLimit(limit = MAX_INPUT_BYTES) {
146
+ const mib = limit / BYTES_PER_MIB;
147
+ return Number.isInteger(mib) ? `${String(limit)} bytes (${String(mib)} MiB)` : `${String(limit)} bytes`;
148
+ }
149
+ function inputTooLargeError(limit = MAX_INPUT_BYTES) {
150
+ return new CliError(
151
+ CLI_CODES.CLI_INPUT_TOO_LARGE,
152
+ EXIT.DATAERR,
153
+ `input is larger than the ${describeByteLimit(limit)} this command reads in one invocation; split the input and re-run`
154
+ );
155
+ }
156
+ var BYTES_PER_MIB, MAX_INPUT_BYTES;
157
+ var init_limits = __esm({
158
+ "src/core/limits.ts"() {
159
+ init_diagnostics();
160
+ init_exit_codes();
161
+ BYTES_PER_MIB = 1024 * 1024;
162
+ MAX_INPUT_BYTES = 64 * BYTES_PER_MIB;
163
+ }
164
+ });
165
+
120
166
  // src/core/format.ts
121
167
  function leadingText(bytes) {
122
168
  const slice = bytes.subarray(0, SNIFF_BYTES);
@@ -192,12 +238,13 @@ function detectionError(detected) {
192
238
  function asCosyteFormat(value) {
193
239
  return KNOWN_FORMATS.includes(value) ? value : null;
194
240
  }
195
- var SNIFF_BYTES, SIGNATURES, DETECTABLE_FORMATS, KNOWN_FORMATS;
241
+ var DETECT_PREFIX_BYTES, SNIFF_BYTES, SIGNATURES, DETECTABLE_FORMATS, KNOWN_FORMATS;
196
242
  var init_format = __esm({
197
243
  "src/core/format.ts"() {
198
244
  init_diagnostics();
199
245
  init_exit_codes();
200
- SNIFF_BYTES = 512;
246
+ DETECT_PREFIX_BYTES = 512;
247
+ SNIFF_BYTES = DETECT_PREFIX_BYTES;
201
248
  SIGNATURES = [
202
249
  { format: "hl7", match: (prefix) => looksLikeHl7(prefix) },
203
250
  { format: "mllp", match: (prefix, bytes) => looksLikeMllp(prefix, bytes) },
@@ -563,11 +610,7 @@ async function validateFormat(format, bytes) {
563
610
  }
564
611
  async function deframeMllp(bytes) {
565
612
  if (bytes.lastIndexOf(11) > bytes.lastIndexOf(28)) {
566
- throw new CliError(
567
- CLI_CODES.CLI_PARSE_FAILED,
568
- EXIT.DATAERR,
569
- "truncated MLLP stream: an unterminated frame at the end of input (no closing FS/CR)"
570
- );
613
+ throw truncatedMllpError();
571
614
  }
572
615
  const { FrameReader } = await loadOptional("mllp", () => import('@cosyte/mllp'));
573
616
  const payloads = [];
@@ -579,6 +622,13 @@ async function deframeMllp(bytes) {
579
622
  reader.push(Buffer.from(bytes));
580
623
  return { payloads, warningCount };
581
624
  }
625
+ function truncatedMllpError() {
626
+ return new CliError(
627
+ CLI_CODES.CLI_PARSE_FAILED,
628
+ EXIT.DATAERR,
629
+ "truncated MLLP stream: an unterminated frame at the end of input (no closing FS/CR)"
630
+ );
631
+ }
582
632
  function decode(bytes) {
583
633
  return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
584
634
  }
@@ -614,6 +664,91 @@ var init_parsers = __esm({
614
664
  }
615
665
  });
616
666
 
667
+ // src/core/records.ts
668
+ function asBuffer(chunk) {
669
+ return Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk.buffer, chunk.byteOffset, chunk.length);
670
+ }
671
+ async function* oneChunk(bytes) {
672
+ await Promise.resolve();
673
+ if (bytes.length > 0) yield bytes;
674
+ }
675
+ async function* withinLimit(chunks, limit = MAX_INPUT_BYTES) {
676
+ let total = 0;
677
+ for await (const chunk of chunks) {
678
+ total += chunk.length;
679
+ if (total > limit) throw inputTooLargeError(limit);
680
+ yield chunk;
681
+ }
682
+ }
683
+ async function collectChunks(chunks) {
684
+ const parts = [];
685
+ for await (const chunk of chunks) parts.push(asBuffer(chunk));
686
+ return Buffer.concat(parts);
687
+ }
688
+ function isBlank(line) {
689
+ return new TextDecoder("utf-8", { fatal: false }).decode(line).trim().length === 0;
690
+ }
691
+ function stripCarriageReturn(line) {
692
+ return line.length > 0 && line[line.length - 1] === CARRIAGE_RETURN ? line.subarray(0, line.length - 1) : line;
693
+ }
694
+ async function* ndjsonRecords(chunks) {
695
+ let carry = Buffer.alloc(0);
696
+ for await (const chunk of chunks) {
697
+ const buf = carry.length > 0 ? Buffer.concat([carry, asBuffer(chunk)]) : asBuffer(chunk);
698
+ let start = 0;
699
+ for (; ; ) {
700
+ const end = buf.indexOf(LINE_FEED, start);
701
+ if (end < 0) break;
702
+ const line = stripCarriageReturn(buf.subarray(start, end));
703
+ if (!isBlank(line)) yield line;
704
+ start = end + 1;
705
+ }
706
+ carry = start === 0 ? asBuffer(buf) : Buffer.from(buf.subarray(start));
707
+ }
708
+ const last = stripCarriageReturn(carry);
709
+ if (!isBlank(last)) yield last;
710
+ }
711
+ async function* mllpFrames(chunks) {
712
+ const { FrameReader } = await loadOptional("mllp", () => import('@cosyte/mllp'));
713
+ const ready = [];
714
+ const reader = new FrameReader({
715
+ onFrame: (payload) => ready.push(Buffer.from(payload)),
716
+ onWarning: () => void 0,
717
+ // The CLI's own documented input limit is the binding one, so the reader's smaller default frame
718
+ // ceiling is raised to it: an over-limit MLLP stream is refused by the limit the help output and
719
+ // the command reference state, not by an undocumented one underneath it.
720
+ maxFrameSizeBytes: MAX_INPUT_BYTES
721
+ });
722
+ let lastStart = -1;
723
+ let lastEnd = -1;
724
+ let offset = 0;
725
+ for await (const chunk of chunks) {
726
+ const buf = asBuffer(chunk);
727
+ const start = buf.lastIndexOf(VERTICAL_TAB);
728
+ if (start >= 0) lastStart = offset + start;
729
+ const end = buf.lastIndexOf(FILE_SEPARATOR);
730
+ if (end >= 0) lastEnd = offset + end;
731
+ offset += buf.length;
732
+ reader.push(buf);
733
+ while (ready.length > 0) {
734
+ const frame = ready.shift();
735
+ if (frame !== void 0) yield frame;
736
+ }
737
+ }
738
+ if (lastStart > lastEnd) throw truncatedMllpError();
739
+ }
740
+ var LINE_FEED, CARRIAGE_RETURN, VERTICAL_TAB, FILE_SEPARATOR;
741
+ var init_records = __esm({
742
+ "src/core/records.ts"() {
743
+ init_limits();
744
+ init_parsers();
745
+ LINE_FEED = 10;
746
+ CARRIAGE_RETURN = 13;
747
+ VERTICAL_TAB = 11;
748
+ FILE_SEPARATOR = 28;
749
+ }
750
+ });
751
+
617
752
  // src/core/input.ts
618
753
  async function resolveInput(source, formatOverride, deps2, op) {
619
754
  if (source === void 0) {
@@ -635,34 +770,91 @@ async function resolveInput(source, formatOverride, deps2, op) {
635
770
  if (bytes.length === 0) {
636
771
  return fail(new CliError(CLI_CODES.CLI_EMPTY_INPUT, EXIT.DATAERR, "input is empty"));
637
772
  }
773
+ const format = resolveFormat(bytes, formatOverride, op);
774
+ if (format instanceof CliError) return fail(format);
775
+ return { ok: true, input: { format, bytes } };
776
+ }
777
+ async function resolveInputStream(source, formatOverride, deps2, op, limit = MAX_INPUT_BYTES) {
778
+ if (source === void 0) {
779
+ return fail(
780
+ new CliError(
781
+ CLI_CODES.CLI_USAGE,
782
+ EXIT.USAGE,
783
+ "missing <file> argument; pass a path or `-` to read stdin"
784
+ )
785
+ );
786
+ }
787
+ const iterator = withinLimit(openChunks(source, deps2), limit)[Symbol.asyncIterator]();
788
+ const need = formatOverride === void 0 ? DETECT_PREFIX_BYTES : 1;
789
+ const head = [];
790
+ let headBytes = 0;
791
+ try {
792
+ while (headBytes < need) {
793
+ const step = await iterator.next();
794
+ if (step.done === true) break;
795
+ head.push(step.value);
796
+ headBytes += step.value.length;
797
+ }
798
+ } catch (e) {
799
+ if (e instanceof CliError) return fail(e);
800
+ throw e;
801
+ }
802
+ if (headBytes === 0) {
803
+ return fail(new CliError(CLI_CODES.CLI_EMPTY_INPUT, EXIT.DATAERR, "input is empty"));
804
+ }
805
+ const prefix = Buffer.concat(
806
+ head.map((c) => Buffer.from(c.buffer, c.byteOffset, c.length)),
807
+ Math.min(headBytes, DETECT_PREFIX_BYTES)
808
+ );
809
+ const format = resolveFormat(prefix, formatOverride, op);
810
+ if (format instanceof CliError) {
811
+ await iterator.return(void 0);
812
+ return fail(format);
813
+ }
814
+ return { ok: true, input: { format, chunks: rejoin(head, iterator) } };
815
+ }
816
+ function openChunks(source, deps2) {
817
+ if (source === "-") {
818
+ return deps2.openStdin === void 0 ? readerAsChunks(() => deps2.readStdin()) : deps2.openStdin();
819
+ }
820
+ return deps2.openFile === void 0 ? readerAsChunks(() => deps2.readFile(source)) : deps2.openFile(source);
821
+ }
822
+ async function* readerAsChunks(read) {
823
+ yield* oneChunk(await read());
824
+ }
825
+ async function* rejoin(head, rest) {
826
+ for (const chunk of head) yield chunk;
827
+ for (; ; ) {
828
+ const step = await rest.next();
829
+ if (step.done === true) return;
830
+ yield step.value;
831
+ }
832
+ }
833
+ function resolveFormat(prefix, formatOverride, op) {
638
834
  let format;
639
835
  if (formatOverride !== void 0) {
640
836
  const narrowed = asCosyteFormat(formatOverride);
641
837
  if (narrowed === null) {
642
- return fail(
643
- new CliError(
644
- CLI_CODES.CLI_USAGE,
645
- EXIT.USAGE,
646
- "unknown --format value; expected one of hl7, fhir, dicom, x12, ccda, ncpdp, astm, mllp"
647
- )
838
+ return new CliError(
839
+ CLI_CODES.CLI_USAGE,
840
+ EXIT.USAGE,
841
+ "unknown --format value; expected one of hl7, fhir, dicom, x12, ccda, ncpdp, astm, mllp"
648
842
  );
649
843
  }
650
844
  format = narrowed;
651
845
  } else {
652
- const detected = detectFormat(bytes);
653
- if (detected.format === null) return fail(detectionError(detected));
846
+ const detected = detectFormat(prefix);
847
+ if (detected.format === null) return detectionError(detected);
654
848
  format = detected.format;
655
849
  }
656
- if (!supportsOp(format, op)) {
657
- return fail(
658
- new CliError(
659
- CLI_CODES.CLI_FORMAT_UNSUPPORTED,
660
- EXIT.DATAERR,
661
- `format '${format}' does not support \`${op}\` in this CLI build (${op} supports: ${formatsSupporting(op).join(", ")})`
662
- )
850
+ if (op !== null && !supportsOp(format, op)) {
851
+ return new CliError(
852
+ CLI_CODES.CLI_FORMAT_UNSUPPORTED,
853
+ EXIT.DATAERR,
854
+ `format '${format}' does not support \`${op}\` in this CLI build (${op} supports: ${formatsSupporting(op).join(", ")})`
663
855
  );
664
856
  }
665
- return { ok: true, input: { format, bytes } };
857
+ return format;
666
858
  }
667
859
  function fail(e) {
668
860
  return { ok: false, result: errorResult(e) };
@@ -672,7 +864,9 @@ var init_input = __esm({
672
864
  init_format();
673
865
  init_diagnostics();
674
866
  init_exit_codes();
867
+ init_limits();
675
868
  init_parsers();
869
+ init_records();
676
870
  }
677
871
  });
678
872
 
@@ -974,11 +1168,24 @@ async function parseCommand(args, deps2, posture = VALUE_FREE) {
974
1168
  )
975
1169
  );
976
1170
  }
977
- const resolved = await resolveInput(positionals[0], values.format, deps2, "parse");
1171
+ const resolved = await resolveInputStream(positionals[0], values.format, deps2, "parse");
978
1172
  if (!resolved.ok) return resolved.result;
979
- const { format, bytes } = resolved.input;
1173
+ const { format, chunks } = resolved.input;
980
1174
  if (format === "mllp" || values.ndjson === true) {
981
- return await parseMulti(format, bytes, values.ndjson === true, values.quiet === true);
1175
+ return await parseMulti(
1176
+ format,
1177
+ chunks,
1178
+ values.ndjson === true,
1179
+ values.quiet === true,
1180
+ deps2.writeStdout
1181
+ );
1182
+ }
1183
+ let bytes;
1184
+ try {
1185
+ bytes = await collectChunks(chunks);
1186
+ } catch (e) {
1187
+ if (e instanceof CliError) return errorResult(e);
1188
+ throw e;
982
1189
  }
983
1190
  return await parseSingle(format, bytes, values.json === true, values.quiet === true, posture);
984
1191
  }
@@ -997,23 +1204,57 @@ async function parseSingle(format, bytes, json, quiet, posture) {
997
1204
  ` : "";
998
1205
  return { stdout, stderr, exit: EXIT.OK };
999
1206
  }
1000
- async function parseMulti(format, bytes, ndjson, quiet) {
1001
- let records;
1002
- let recordFormat;
1207
+ async function parseMulti(format, chunks, ndjson, quiet, writeStdout) {
1208
+ const recordFormat = format === "mllp" ? "hl7" : format;
1209
+ const records = format === "mllp" ? mllpFrames(chunks) : ndjsonRecords(chunks);
1210
+ const held = [];
1211
+ const emit2 = (line) => {
1212
+ if (writeStdout === void 0) {
1213
+ held.push(line);
1214
+ return;
1215
+ }
1216
+ try {
1217
+ writeStdout(line);
1218
+ } catch {
1219
+ throw new CliError(
1220
+ CLI_CODES.CLI_OUTPUT_WRITE_FAILED,
1221
+ EXIT.IOERR,
1222
+ "could not write to the output stream; it closed before the record stream finished"
1223
+ );
1224
+ }
1225
+ };
1226
+ const written = () => held.join("");
1227
+ let total = 0;
1228
+ let failed = 0;
1229
+ let warnings = 0;
1003
1230
  try {
1004
- if (format === "mllp") {
1005
- const { payloads } = await deframeMllp(bytes);
1006
- records = payloads;
1007
- recordFormat = "hl7";
1008
- } else {
1009
- records = splitLines(bytes);
1010
- recordFormat = format;
1231
+ for await (const record of records) {
1232
+ let line;
1233
+ try {
1234
+ const { model, warnings: ws } = await parseFormat(recordFormat, record);
1235
+ warnings += ws.length;
1236
+ line = { record: total, format: recordFormat, model, warnings: ws };
1237
+ } catch (e) {
1238
+ if (e instanceof CliError) throw e;
1239
+ failed += 1;
1240
+ line = {
1241
+ record: total,
1242
+ format: recordFormat,
1243
+ error: extractStableCode(e) ?? "CLI_PARSE_FAILED"
1244
+ };
1245
+ }
1246
+ total += 1;
1247
+ emit2(JSON.stringify(line) + "\n");
1011
1248
  }
1012
1249
  } catch (e) {
1013
- if (e instanceof CliError) return errorResult(e);
1014
- return parseFailureResult(format, bytes, VALUE_FREE, e);
1250
+ if (e instanceof CliError) {
1251
+ return { stdout: written(), stderr: `${formatDiagnostic(e)}
1252
+ `, exit: e.exit };
1253
+ }
1254
+ const rejected = parseFailureResult(format, new Uint8Array(), VALUE_FREE, e);
1255
+ return { stdout: written(), stderr: rejected.stderr, exit: rejected.exit };
1015
1256
  }
1016
- if (records.length === 0) {
1257
+ if (total === 0) {
1017
1258
  return errorResult(
1018
1259
  new CliError(
1019
1260
  CLI_CODES.CLI_PARSE_FAILED,
@@ -1022,34 +1263,9 @@ async function parseMulti(format, bytes, ndjson, quiet) {
1022
1263
  )
1023
1264
  );
1024
1265
  }
1025
- const lines = [];
1026
- let failed = 0;
1027
- let warnings = 0;
1028
- for (let i = 0; i < records.length; i += 1) {
1029
- const rec = records[i];
1030
- try {
1031
- const { model, warnings: ws } = await parseFormat(recordFormat, rec);
1032
- warnings += ws.length;
1033
- lines.push({ record: i, format: recordFormat, model, warnings: ws });
1034
- } catch (e) {
1035
- if (e instanceof CliError) return errorResult(e);
1036
- failed += 1;
1037
- lines.push({
1038
- record: i,
1039
- format: recordFormat,
1040
- error: extractStableCode(e) ?? "CLI_PARSE_FAILED"
1041
- });
1042
- }
1043
- }
1044
- const stdout = lines.map((l) => JSON.stringify(l)).join("\n") + "\n";
1045
1266
  const exit = failed > 0 ? EXIT.DATAERR : EXIT.OK;
1046
- const stderr = quiet ? "" : `cosyte: parsed ${String(records.length)} ${recordFormat} record(s) (${String(warnings)} warning(s), ${String(failed)} failed)` + (ndjson ? " [ndjson]" : format === "mllp" ? " [mllp]" : "") + "\n";
1047
- return { stdout, stderr, exit };
1048
- }
1049
- function splitLines(bytes) {
1050
- const text = new TextDecoder("utf-8", { fatal: false }).decode(bytes);
1051
- const enc = new TextEncoder();
1052
- return text.split(/\r?\n/).filter((line) => line.trim().length > 0).map((line) => enc.encode(line));
1267
+ const stderr = quiet ? "" : `cosyte: parsed ${String(total)} ${recordFormat} record(s) (${String(warnings)} warning(s), ${String(failed)} failed)` + (ndjson ? " [ndjson]" : format === "mllp" ? " [mllp]" : "") + "\n";
1268
+ return { stdout: written(), stderr, exit };
1053
1269
  }
1054
1270
  var PARSE_OPTIONS;
1055
1271
  var init_parse = __esm({
@@ -1059,6 +1275,7 @@ var init_parse = __esm({
1059
1275
  init_input();
1060
1276
  init_parsers();
1061
1277
  init_phi();
1278
+ init_records();
1062
1279
  init_wrap();
1063
1280
  PARSE_OPTIONS = {
1064
1281
  format: { type: "string" },
@@ -1151,11 +1368,19 @@ var init_validate = __esm({
1151
1368
  var VERSION;
1152
1369
  var init_version = __esm({
1153
1370
  "src/core/version.ts"() {
1154
- VERSION = "0.0.6";
1371
+ VERSION = "0.1.1";
1155
1372
  }
1156
1373
  });
1157
1374
 
1158
1375
  // src/mcp/tools.ts
1376
+ function outputSchema(data) {
1377
+ return {
1378
+ type: "object",
1379
+ properties: { ...OUTCOME_PROPS, data },
1380
+ required: [...OUTCOME_REQUIRED],
1381
+ additionalProperties: false
1382
+ };
1383
+ }
1159
1384
  function inlineDeps(content) {
1160
1385
  const bytes = new TextEncoder().encode(content);
1161
1386
  const noFile = new CliError(
@@ -1174,37 +1399,65 @@ function stringArg(args, key) {
1174
1399
  const v = args[key];
1175
1400
  return typeof v === "string" ? v : null;
1176
1401
  }
1177
- function toToolResult(result) {
1178
- const hardError = result.stdout === "";
1179
- const text = hardError ? result.stderr.trim() : result.stdout.trim();
1402
+ function structuredResult(structuredContent) {
1180
1403
  return {
1181
- content: [
1182
- { type: "text", text: text.length > 0 ? text : `cosyte: exit ${String(result.exit)}` }
1183
- ],
1184
- isError: hardError,
1185
- structuredContent: { exit: result.exit, ok: !hardError }
1404
+ content: [{ type: "text", text: JSON.stringify(structuredContent) }],
1405
+ isError: structuredContent.status === "failed",
1406
+ structuredContent
1186
1407
  };
1187
1408
  }
1188
- function usageError(message) {
1189
- return {
1190
- content: [{ type: "text", text: `cosyte: ${CLI_CODES.CLI_USAGE}: ${message}` }],
1191
- isError: true,
1192
- structuredContent: { exit: EXIT.USAGE, ok: false }
1193
- };
1409
+ function isRecordLine(value) {
1410
+ return typeof value === "object" && value !== null && "record" in value;
1411
+ }
1412
+ function parsePayload(stdout) {
1413
+ const values = [];
1414
+ for (const line of stdout.split("\n")) {
1415
+ if (line.trim().length === 0) continue;
1416
+ try {
1417
+ values.push(JSON.parse(line));
1418
+ } catch {
1419
+ return void 0;
1420
+ }
1421
+ }
1422
+ const only = values.length === 1 ? values[0] : void 0;
1423
+ return only !== void 0 && !isRecordLine(only) ? only : { records: values };
1424
+ }
1425
+ function diagnosticCode(stderr) {
1426
+ return Object.values(CLI_CODES).find((code) => stderr.includes(`cosyte: ${code}: `));
1427
+ }
1428
+ function toToolResult(result) {
1429
+ if (result.stdout === "") {
1430
+ const code = diagnosticCode(result.stderr);
1431
+ return structuredResult({
1432
+ ok: false,
1433
+ status: "failed",
1434
+ exit: result.exit,
1435
+ ...code !== void 0 ? { code } : {}
1436
+ });
1437
+ }
1438
+ const data = parsePayload(result.stdout);
1439
+ return structuredResult({
1440
+ ok: true,
1441
+ status: result.exit === EXIT.OK ? "success" : "verdict",
1442
+ exit: result.exit,
1443
+ ...data !== void 0 ? { data } : {}
1444
+ });
1445
+ }
1446
+ function usageError() {
1447
+ return structuredResult({
1448
+ ok: false,
1449
+ status: "failed",
1450
+ exit: EXIT.USAGE,
1451
+ code: CLI_CODES.CLI_USAGE
1452
+ });
1194
1453
  }
1195
1454
  function internalError(e) {
1196
1455
  const err = toCliError(e);
1197
- return {
1198
- content: [{ type: "text", text: `cosyte: ${err.code}: ${err.message}` }],
1199
- isError: true,
1200
- structuredContent: { exit: err.exit, ok: false }
1201
- };
1456
+ return structuredResult({ ok: false, status: "failed", exit: err.exit, code: err.code });
1202
1457
  }
1203
1458
  async function dispatchTool(name, args, deps2) {
1204
1459
  const content = stringArg(args, "content");
1205
- if (content === null) {
1206
- return usageError("missing required 'content' argument (the message text to operate on)");
1207
- }
1460
+ if (content === null) return usageError();
1208
1461
  const runDeps = inlineDeps(content);
1209
1462
  const format = stringArg(args, "format");
1210
1463
  const fmtFlag = format !== null ? ["--format", format] : [];
@@ -1223,13 +1476,13 @@ async function dispatchTool(name, args, deps2) {
1223
1476
  return toToolResult(await convertCommand(["-", "--to", to, "--json"], runDeps, VALUE_FREE));
1224
1477
  }
1225
1478
  default:
1226
- return usageError(`unknown tool '${name}'`);
1479
+ return usageError();
1227
1480
  }
1228
1481
  } catch (e) {
1229
1482
  return internalError(e);
1230
1483
  }
1231
1484
  }
1232
- var CONTENT_PROP, FORMAT_PROP, TOOL_DEFS;
1485
+ var CONTENT_PROP, FORMAT_PROP, OUTCOME_PROPS, OUTCOME_REQUIRED, TOOL_DEFS;
1233
1486
  var init_tools = __esm({
1234
1487
  "src/mcp/tools.ts"() {
1235
1488
  init_convert();
@@ -1252,39 +1505,108 @@ var init_tools = __esm({
1252
1505
  description: "Optional format override; omit to autodetect by content. A format whose parser does not support the requested operation returns a value-free CLI_FORMAT_UNSUPPORTED (never a fake)."
1253
1506
  }
1254
1507
  };
1508
+ OUTCOME_PROPS = {
1509
+ ok: {
1510
+ type: "boolean",
1511
+ description: "True iff the tool call produced data. A negative verdict about the message is still true (the tool worked); only a call that produced no data at all is false."
1512
+ },
1513
+ status: {
1514
+ type: "string",
1515
+ enum: ["success", "verdict", "failed"],
1516
+ description: "The outcome to branch on without parsing text: 'success' the operation completed cleanly, 'verdict' the tool ran and reports a negative finding about the message, 'failed' the call produced no data (a usage mistake, unparseable input, an unavailable parser, an internal error)."
1517
+ },
1518
+ exit: {
1519
+ type: "integer",
1520
+ enum: Object.values(EXIT),
1521
+ description: "The exit code the underlying command resolved to, from the documented exit-code contract."
1522
+ },
1523
+ code: {
1524
+ type: "string",
1525
+ enum: Object.values(CLI_CODES),
1526
+ description: "The stable diagnostic code on a failed call, drawn from a fixed registry so it never carries an input value. Absent on a call that produced data."
1527
+ }
1528
+ };
1529
+ OUTCOME_REQUIRED = ["ok", "status", "exit"];
1255
1530
  TOOL_DEFS = [
1256
1531
  {
1257
1532
  name: "parse",
1533
+ title: "Parse a healthcare message",
1258
1534
  description: "Parse a healthcare message (HL7 v2 or FHIR R4) to typed JSON. Format is autodetected by content. Returns the parsed model plus value-free warnings.",
1259
1535
  inputSchema: {
1260
1536
  type: "object",
1261
1537
  properties: { ...CONTENT_PROP, ...FORMAT_PROP },
1262
1538
  required: ["content"],
1263
1539
  additionalProperties: false
1264
- }
1540
+ },
1541
+ outputSchema: outputSchema({
1542
+ type: "object",
1543
+ description: "The parse payload, present on a call that produced data. A single message parses to format + model + warnings. A multi-record input (an MLLP stream) parses to `records`, one entry per record: a parsed record, or a value-free per-record error code.",
1544
+ properties: {
1545
+ format: { type: "string", description: "The format the input was parsed as." },
1546
+ model: { description: "The wrapped library's parsed model, verbatim: the data channel." },
1547
+ warnings: {
1548
+ type: "array",
1549
+ description: "Value-free parse warnings: a stable code plus positional context."
1550
+ },
1551
+ records: {
1552
+ type: "array",
1553
+ description: "One entry per record of a multi-record input, in stream order."
1554
+ }
1555
+ },
1556
+ additionalProperties: false
1557
+ })
1265
1558
  },
1266
1559
  {
1267
1560
  name: "validate",
1561
+ title: "Validate a healthcare message",
1268
1562
  description: "Validate a message and carry the verdict: ok=true with a valid result, or a result reporting value-free findings. The parsed-but-invalid verdict is a successful call (not a tool error).",
1269
1563
  inputSchema: {
1270
1564
  type: "object",
1271
1565
  properties: { ...CONTENT_PROP, ...FORMAT_PROP },
1272
1566
  required: ["content"],
1273
1567
  additionalProperties: false
1274
- }
1568
+ },
1569
+ outputSchema: outputSchema({
1570
+ type: "object",
1571
+ description: "The validation payload, present on a call that produced data (a valid result and a negative verdict alike). Value-free throughout: findings are codes, severities and positional locators, never a field value.",
1572
+ properties: {
1573
+ format: { type: "string", description: "The format the input was validated as." },
1574
+ valid: {
1575
+ type: "boolean",
1576
+ description: "The verdict: false means parsed-but-non-conformant, not a failed call."
1577
+ },
1578
+ findings: {
1579
+ type: "array",
1580
+ description: "Value-free findings: a stable code, a severity, and a positional locator."
1581
+ }
1582
+ },
1583
+ required: ["format", "valid", "findings"],
1584
+ additionalProperties: false
1585
+ })
1275
1586
  },
1276
1587
  {
1277
1588
  name: "inspect",
1589
+ title: "Inspect a healthcare message's structure",
1278
1590
  description: "Return a value-free structural summary of a message: its type, segment/entry counts, and a warning/issue count. Never includes a field value.",
1279
1591
  inputSchema: {
1280
1592
  type: "object",
1281
1593
  properties: { ...CONTENT_PROP, ...FORMAT_PROP },
1282
1594
  required: ["content"],
1283
1595
  additionalProperties: false
1284
- }
1596
+ },
1597
+ outputSchema: outputSchema({
1598
+ type: "object",
1599
+ description: "The value-free structural summary, present on a call that produced data. `format` names the shape of the rest, which is that format's own counts and structural type codes (a message type, a resource type, a transaction-set id): classification, never a value.",
1600
+ properties: {
1601
+ format: { type: "string", description: "The format the summary describes." }
1602
+ },
1603
+ required: ["format"],
1604
+ additionalProperties: true
1605
+ })
1285
1606
  },
1286
1607
  {
1287
1608
  name: "convert",
1609
+ title: "Convert HL7 v2 to a FHIR R4 Bundle",
1288
1610
  description: "Convert an HL7 v2 message to a FHIR R4 Bundle via @cosyte/transform. Returns the converted Bundle; an error-severity conversion issue is reported with ok=false.",
1289
1611
  inputSchema: {
1290
1612
  type: "object",
@@ -1298,7 +1620,21 @@ var init_tools = __esm({
1298
1620
  },
1299
1621
  required: ["content"],
1300
1622
  additionalProperties: false
1301
- }
1623
+ },
1624
+ outputSchema: outputSchema({
1625
+ type: "object",
1626
+ description: "The conversion payload, present on a call that produced data. An error-severity finding is a negative verdict (the Bundle is still returned), not a failed call.",
1627
+ properties: {
1628
+ format: { type: "string", description: "The conversion target the Bundle is in." },
1629
+ bundle: { type: "object", description: "The converted FHIR R4 Bundle: the data channel." },
1630
+ findings: {
1631
+ type: "array",
1632
+ description: "Value-free conversion findings: a code, a severity, and a positional locator."
1633
+ }
1634
+ },
1635
+ required: ["format", "bundle", "findings"],
1636
+ additionalProperties: false
1637
+ })
1302
1638
  }
1303
1639
  ];
1304
1640
  }
@@ -1316,12 +1652,21 @@ function createMcpServer() {
1316
1652
  server.setRequestHandler(types_js.ListToolsRequestSchema, () => ({
1317
1653
  tools: TOOL_DEFS.map((t) => ({
1318
1654
  name: t.name,
1655
+ title: t.title,
1319
1656
  description: t.description,
1320
1657
  inputSchema: {
1321
1658
  type: t.inputSchema.type,
1322
1659
  properties: { ...t.inputSchema.properties },
1323
1660
  ...t.inputSchema.required !== void 0 ? { required: [...t.inputSchema.required] } : {},
1324
1661
  ...t.inputSchema.additionalProperties !== void 0 ? { additionalProperties: t.inputSchema.additionalProperties } : {}
1662
+ },
1663
+ // Every advertised tool publishes its result schema; a client validates a reply against it
1664
+ // instead of pattern-matching the text block. Declared, so it is copied unconditionally.
1665
+ outputSchema: {
1666
+ type: t.outputSchema.type,
1667
+ properties: { ...t.outputSchema.properties },
1668
+ required: [...t.outputSchema.required],
1669
+ additionalProperties: t.outputSchema.additionalProperties
1325
1670
  }
1326
1671
  }))
1327
1672
  }));
@@ -1331,7 +1676,9 @@ function createMcpServer() {
1331
1676
  return {
1332
1677
  content: result.content.map((c) => ({ type: c.type, text: c.text })),
1333
1678
  isError: result.isError,
1334
- structuredContent: { exit: result.structuredContent.exit, ok: result.structuredContent.ok }
1679
+ // The whole structured value reaches the wire. A field-by-field copy here would silently
1680
+ // truncate the payload the called tool's own schema promises.
1681
+ structuredContent: { ...result.structuredContent }
1335
1682
  };
1336
1683
  });
1337
1684
  return server;
@@ -1349,27 +1696,73 @@ var init_server = __esm({
1349
1696
  }
1350
1697
  });
1351
1698
 
1699
+ // src/bin/cosyte.ts
1700
+ init_diagnostics();
1701
+ init_exit_codes();
1702
+
1352
1703
  // src/core/io.ts
1353
1704
  init_diagnostics();
1354
1705
  init_exit_codes();
1355
- async function readFileBytes(path) {
1706
+ init_limits();
1707
+ function noInputError(path) {
1708
+ return new CliError(
1709
+ CLI_CODES.CLI_NO_INPUT,
1710
+ EXIT.NOINPUT,
1711
+ `cannot read input file: ${path} (does it exist and is it readable?)`
1712
+ );
1713
+ }
1714
+ async function readFileBytes(path, limit = MAX_INPUT_BYTES) {
1715
+ let handle;
1356
1716
  try {
1357
- return await promises.readFile(path);
1717
+ handle = await promises.open(path, "r");
1358
1718
  } catch {
1359
- throw new CliError(
1360
- CLI_CODES.CLI_NO_INPUT,
1361
- EXIT.NOINPUT,
1362
- `cannot read input file: ${path} (does it exist and is it readable?)`
1363
- );
1719
+ throw noInputError(path);
1720
+ }
1721
+ try {
1722
+ const info = await handle.stat();
1723
+ if (info.isFile() && info.size > limit) throw inputTooLargeError(limit);
1724
+ return await handle.readFile();
1725
+ } catch (e) {
1726
+ if (e instanceof CliError) throw e;
1727
+ throw noInputError(path);
1728
+ } finally {
1729
+ await handle.close();
1364
1730
  }
1365
1731
  }
1366
- async function readStreamBytes(stream) {
1732
+ async function readStreamBytes(stream, limit = MAX_INPUT_BYTES) {
1367
1733
  const chunks = [];
1734
+ let total = 0;
1368
1735
  for await (const chunk of stream) {
1369
- chunks.push(typeof chunk === "string" ? Buffer.from(chunk, "utf-8") : chunk);
1736
+ const buf = typeof chunk === "string" ? Buffer.from(chunk, "utf-8") : chunk;
1737
+ total += buf.length;
1738
+ if (total > limit) throw inputTooLargeError(limit);
1739
+ chunks.push(buf);
1370
1740
  }
1371
1741
  return Buffer.concat(chunks);
1372
1742
  }
1743
+ async function* streamChunks(stream) {
1744
+ for await (const chunk of stream) {
1745
+ yield typeof chunk === "string" ? Buffer.from(chunk, "utf-8") : chunk;
1746
+ }
1747
+ }
1748
+ async function* fileChunks(path) {
1749
+ let handle;
1750
+ try {
1751
+ handle = await promises.open(path, "r");
1752
+ } catch {
1753
+ throw noInputError(path);
1754
+ }
1755
+ try {
1756
+ const stream = handle.createReadStream();
1757
+ try {
1758
+ for await (const chunk of stream) yield chunk;
1759
+ } catch {
1760
+ throw noInputError(path);
1761
+ }
1762
+ } finally {
1763
+ await handle.close();
1764
+ }
1765
+ }
1373
1766
 
1374
1767
  // src/commands/completion.ts
1375
1768
  init_diagnostics();
@@ -1630,47 +2023,248 @@ function renderNote(result) {
1630
2023
  // src/core/run.ts
1631
2024
  init_parse();
1632
2025
 
1633
- // src/commands/redact.ts
1634
- init_diagnostics();
1635
-
1636
2026
  // src/core/deid.ts
1637
- var DEID_UNAVAILABLE_REASON = "redact/deid is not yet available. It delegates to @cosyte/deid, which the CLI does not wire yet. The CLI ships no built-in redactor: a partial Safe-Harbor scrub over only the obvious PHI loci would leave PHI behind and present a false-safety impression, so nothing is emitted. This command will produce a de-identified copy once that integration lands and is vetted.";
1638
- function deidStatus() {
1639
- return { available: false, reason: DEID_UNAVAILABLE_REASON };
2027
+ init_diagnostics();
2028
+ init_exit_codes();
2029
+ init_parsers();
2030
+ init_wrap();
2031
+ var DEID_PACKAGE = "@cosyte/deid";
2032
+ var DEID_COVERAGE = {
2033
+ hl7: "covered",
2034
+ fhir: "covered",
2035
+ x12: "covered",
2036
+ ccda: "covered",
2037
+ // The delegate's NCPDP adapter is Telecom; this CLI resolves SCRIPT.
2038
+ ncpdp: "no-adapter",
2039
+ astm: "no-adapter",
2040
+ mllp: "no-adapter",
2041
+ // Covered by the delegate, but its result is Part 10 bytes and stdout here is text.
2042
+ dicom: "unserializable"
2043
+ };
2044
+ function deidCoverage(format) {
2045
+ return DEID_COVERAGE[format];
2046
+ }
2047
+ function isDeidCovered(format) {
2048
+ return DEID_COVERAGE[format] === "covered";
2049
+ }
2050
+ function deidCoveredFormats() {
2051
+ return Object.keys(DEID_COVERAGE).filter(isDeidCovered).sort();
2052
+ }
2053
+ var DEID_BLOCKED = "blocked";
2054
+ var DEID_UNAVAILABLE_REASON = `the ${DEID_PACKAGE} de-identification library is not installed; install it to use redact/deid (it is an optional dependency). The CLI ships no built-in redactor: a partial Safe-Harbor scrub over only the obvious PHI loci would leave PHI behind and present a false-safety impression, so nothing is emitted`;
2055
+ function adapterUnavailable(format) {
2056
+ return `the ${DEID_PACKAGE}/${format} de-identification adapter could not be loaded; it needs both ${DEID_PACKAGE} and the @cosyte/${format} parser to be installed`;
2057
+ }
2058
+ function parseOrFail(format, parse) {
2059
+ try {
2060
+ return parse();
2061
+ } catch (e) {
2062
+ const code = extractStableCode(e);
2063
+ throw new CliError(
2064
+ CLI_CODES.CLI_PARSE_FAILED,
2065
+ EXIT.DATAERR,
2066
+ `the ${format} parser rejected the input${code === null ? "" : ` (${code})`}`
2067
+ );
2068
+ }
2069
+ }
2070
+ function deidentifyOrFail(run2) {
2071
+ try {
2072
+ return run2();
2073
+ } catch (e) {
2074
+ const code = extractStableCode(e);
2075
+ if (code === null) throw e;
2076
+ throw new CliError(
2077
+ CLI_CODES.CLI_DEID_INCOMPLETE,
2078
+ EXIT.INVALID,
2079
+ `the ${DEID_PACKAGE} de-identifier could not complete this input (${code}); no output was emitted`
2080
+ );
2081
+ }
2082
+ }
2083
+ function decode2(bytes) {
2084
+ return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
2085
+ }
2086
+ function toRecords(manifest) {
2087
+ return manifest.map((e) => ({
2088
+ category: e.category,
2089
+ transform: e.transform,
2090
+ locus: e.locus,
2091
+ count: e.count,
2092
+ disposition: e.disposition,
2093
+ code: e.code
2094
+ }));
2095
+ }
2096
+ async function loadDeidDelegate(importCore = () => import('@cosyte/deid')) {
2097
+ const core = await loadOptionalPackage(DEID_UNAVAILABLE_REASON, importCore);
2098
+ const context = core.createDeidContext({ key: crypto.randomBytes(32) });
2099
+ return {
2100
+ label: core.OUTPUT_LABEL,
2101
+ version: core.VERSION,
2102
+ redact: async (format, bytes) => {
2103
+ switch (format) {
2104
+ case "hl7": {
2105
+ const { parseHL7 } = await import('@cosyte/hl7');
2106
+ const { deidentifyHl7 } = await loadOptionalPackage(
2107
+ adapterUnavailable("hl7"),
2108
+ () => import('@cosyte/deid/hl7')
2109
+ );
2110
+ const msg = parseOrFail("hl7", () => parseHL7(Buffer.from(bytes)));
2111
+ return deidentifyOrFail(() => {
2112
+ const { document, manifest } = deidentifyHl7(msg, { context });
2113
+ return { output: document.toString(), manifest: toRecords(manifest) };
2114
+ });
2115
+ }
2116
+ case "fhir": {
2117
+ const { parseResource, serializeResource } = await loadFhir();
2118
+ const { deidentifyFhir } = await loadOptionalPackage(
2119
+ adapterUnavailable("fhir"),
2120
+ () => import('@cosyte/deid/fhir')
2121
+ );
2122
+ const resource = parseOrFail("fhir", () => parseResource(decode2(bytes)).resource);
2123
+ return deidentifyOrFail(() => {
2124
+ const { document, manifest } = deidentifyFhir(resource, { context });
2125
+ return { output: serializeResource(document), manifest: toRecords(manifest) };
2126
+ });
2127
+ }
2128
+ case "x12": {
2129
+ const { parseX12 } = await loadOptional("x12", () => import('@cosyte/x12'));
2130
+ const { deidentifyX12 } = await loadOptionalPackage(
2131
+ adapterUnavailable("x12"),
2132
+ () => import('@cosyte/deid/x12')
2133
+ );
2134
+ const interchange = parseOrFail("x12", () => parseX12(Buffer.from(bytes)));
2135
+ return deidentifyOrFail(() => {
2136
+ const { x12, manifest } = deidentifyX12(interchange, { context });
2137
+ return { output: x12, manifest: toRecords(manifest) };
2138
+ });
2139
+ }
2140
+ case "ccda": {
2141
+ const { parseCcda, serializeCcda } = await loadOptional(
2142
+ "ccda",
2143
+ () => import('@cosyte/ccda')
2144
+ );
2145
+ const { deidentifyCcda } = await loadOptionalPackage(
2146
+ adapterUnavailable("ccda"),
2147
+ () => import('@cosyte/deid/ccda')
2148
+ );
2149
+ const doc = parseOrFail("ccda", () => parseCcda(decode2(bytes)));
2150
+ return deidentifyOrFail(() => {
2151
+ const { document, manifest } = deidentifyCcda(doc, { context });
2152
+ return { output: serializeCcda(document), manifest: toRecords(manifest) };
2153
+ });
2154
+ }
2155
+ }
2156
+ }
2157
+ };
1640
2158
  }
1641
2159
 
1642
2160
  // src/commands/redact.ts
2161
+ init_diagnostics();
1643
2162
  init_exit_codes();
2163
+ init_input();
1644
2164
  var REDACT_OPTIONS = {
1645
2165
  format: { type: "string" }
1646
2166
  };
1647
- function redactCommand(args) {
2167
+ function manifestLine(entry) {
2168
+ return `cosyte: redact: ${entry.category} ${entry.transform} ${entry.locus} x${String(entry.count)} ${entry.disposition} ${entry.code}
2169
+ `;
2170
+ }
2171
+ function attribution(delegate) {
2172
+ return `cosyte: redact: delegated to ${DEID_PACKAGE} ${delegate.version}: ${delegate.label}
2173
+ `;
2174
+ }
2175
+ function manifestLines(outcome) {
2176
+ return outcome.manifest.map(manifestLine).join("");
2177
+ }
2178
+ function countOf(outcome, disposition) {
2179
+ return outcome.manifest.filter((e) => e.disposition === disposition).length;
2180
+ }
2181
+ function tally(format, outcome) {
2182
+ const transformed = countOf(outcome, "transformed");
2183
+ const removed = countOf(outcome, "removed");
2184
+ const blocked = countOf(outcome, DEID_BLOCKED);
2185
+ return `cosyte: redact: ${format}: ${String(outcome.manifest.length)} loci acted on (${String(transformed)} transformed, ${String(removed)} removed, ${String(blocked)} blocked)
2186
+ `;
2187
+ }
2188
+ var EPHEMERAL_KEY_NOTE = "cosyte: redact: identifier surrogates are keyed with a per-invocation ephemeral key: consistent within this output, not stable across runs\n";
2189
+ async function redactCommand(args, deps2, loadDelegate = loadDeidDelegate) {
2190
+ let values;
2191
+ let positionals;
1648
2192
  try {
1649
- util.parseArgs({ args, options: REDACT_OPTIONS, allowPositionals: true });
2193
+ const parsed = util.parseArgs({ args, options: REDACT_OPTIONS, allowPositionals: true });
2194
+ values = parsed.values;
2195
+ positionals = parsed.positionals;
1650
2196
  } catch {
2197
+ return errorResult(
2198
+ new CliError(
2199
+ CLI_CODES.CLI_USAGE,
2200
+ EXIT.USAGE,
2201
+ "invalid arguments to `redact` (see `cosyte --help`)"
2202
+ )
2203
+ );
2204
+ }
2205
+ let delegate;
2206
+ try {
2207
+ delegate = await loadDelegate();
2208
+ } catch (e) {
2209
+ if (e instanceof CliError) return errorResult(e);
2210
+ throw e;
2211
+ }
2212
+ const resolved = await resolveInput(positionals[0], values.format, deps2, null);
2213
+ if (!resolved.ok) return resolved.result;
2214
+ const { format, bytes } = resolved.input;
2215
+ if (!isDeidCovered(format)) return errorResult(uncovered(format));
2216
+ let outcome;
2217
+ try {
2218
+ outcome = await delegate.redact(format, bytes);
2219
+ } catch (e) {
2220
+ if (e instanceof CliError) return errorResult(e);
2221
+ throw e;
2222
+ }
2223
+ const blocked = outcome.manifest.filter((e) => e.disposition === DEID_BLOCKED);
2224
+ if (blocked.length > 0) {
2225
+ const incomplete = new CliError(
2226
+ CLI_CODES.CLI_DEID_INCOMPLETE,
2227
+ EXIT.INVALID,
2228
+ `${DEID_PACKAGE} blocked ${String(blocked.length)} of ${String(outcome.manifest.length)} loci in this ${format} input; no output was emitted`
2229
+ );
1651
2230
  return {
1652
2231
  stdout: "",
1653
- stderr: `cosyte: ${CLI_CODES.CLI_USAGE}: invalid arguments to \`redact\` (see \`cosyte --help\`)
2232
+ stderr: attribution(delegate) + manifestLines(outcome) + tally(format, outcome) + `cosyte: ${incomplete.code}: ${incomplete.message}
1654
2233
  `,
1655
- exit: EXIT.USAGE
2234
+ exit: incomplete.exit
1656
2235
  };
1657
2236
  }
1658
- const status = deidStatus();
1659
2237
  return {
1660
- stdout: "",
1661
- stderr: `cosyte: ${CLI_CODES.CLI_NOT_IMPLEMENTED}: ${status.reason}
2238
+ stdout: `${outcome.output}
1662
2239
  `,
1663
- exit: EXIT.UNAVAILABLE
2240
+ stderr: attribution(delegate) + manifestLines(outcome) + tally(format, outcome) + EPHEMERAL_KEY_NOTE,
2241
+ exit: EXIT.OK
1664
2242
  };
1665
2243
  }
2244
+ function uncovered(format) {
2245
+ if (deidCoverage(format) === "unserializable") {
2246
+ return new CliError(
2247
+ CLI_CODES.CLI_FORMAT_UNSUPPORTED,
2248
+ EXIT.DATAERR,
2249
+ `format '${format}' cannot be redacted onto this CLI's text output channel (its de-identified form is a binary stream, so \`parse\` and \`fmt\` are unwired too)`
2250
+ );
2251
+ }
2252
+ return new CliError(
2253
+ CLI_CODES.CLI_NOT_IMPLEMENTED,
2254
+ EXIT.UNAVAILABLE,
2255
+ `${DEID_PACKAGE} does not de-identify the ${format} standard this CLI resolves, and the CLI ships no redactor of its own, so nothing is emitted`
2256
+ );
2257
+ }
1666
2258
 
1667
2259
  // src/core/run.ts
1668
2260
  init_validate();
1669
2261
  init_diagnostics();
1670
2262
  init_exit_codes();
2263
+ init_limits();
1671
2264
  init_phi();
1672
2265
  init_version();
1673
- var HELP = `cosyte: a PHI-safe developer CLI over the @cosyte/* healthcare parsers
2266
+ function helpText(limitBytes = MAX_INPUT_BYTES) {
2267
+ return `cosyte: a PHI-safe developer CLI over the @cosyte/* healthcare parsers
1674
2268
 
1675
2269
  Usage:
1676
2270
  cosyte <command> [options]
@@ -1682,7 +2276,7 @@ Commands:
1682
2276
  fmt <file|-> Canonically re-serialize via the parser's spec-clean serializer
1683
2277
  convert <file|-> Convert HL7 v2 \u2192 FHIR R4 via @cosyte/transform (use --to fhir)
1684
2278
  map-codes <cmap|-> Translate a code through a BYO FHIR ConceptMap via @cosyte/terminology
1685
- redact <file|-> De-identify a message (alias: deid): gated on @cosyte/deid, not yet wired
2279
+ redact <file|-> De-identify a message via @cosyte/deid (alias: deid): ${deidCoveredFormats().join(" ")}
1686
2280
  mcp Start the stdio MCP server (agent front door; also the cosyte-mcp bin)
1687
2281
  completion <shell> Print a shell completion script (bash | zsh | fish)
1688
2282
 
@@ -1703,22 +2297,44 @@ Common options (parse / validate / inspect / fmt):
1703
2297
  convert options:
1704
2298
  --to fhir The conversion target (required; only HL7 v2 \u2192 FHIR R4 today)
1705
2299
 
2300
+ redact / deid (de-identification is delegated to @cosyte/deid; the CLI adds no policy of its own):
2301
+ Covered formats: ${deidCoveredFormats().join(", ")}. Every other format is refused rather than
2302
+ approximated: astm, mllp and ncpdp have no adapter (69), and dicom is covered by the library but
2303
+ its de-identified form is a binary stream this text stdout cannot carry (65). If the library
2304
+ reports it could not handle an element, the run exits 1 and emits no output at all: a partial
2305
+ scrub presented as de-identified is the one outcome this command exists to prevent.
2306
+ stdout carries the de-identified document; stderr carries the library's own value-free manifest
2307
+ (category, transform, locus, count, disposition code) plus its published label and version.
2308
+ Identifier surrogates are keyed with a per-invocation ephemeral key: consistent within one
2309
+ output, not stable across runs.
2310
+
1706
2311
  map-codes options (the positional is a BYO FHIR ConceptMap; a code is not PHI):
1707
2312
  --code <code> The source code to translate (required)
1708
2313
  --system <uri> The source code system (optional; selects the ConceptMap group)
1709
2314
  --version <v> The source code system version (optional)
1710
2315
  --display <d> The source display (optional)
1711
2316
 
2317
+ Input size:
2318
+ parse reads at most ${describeByteLimit(limitBytes)} of input per invocation. A larger input is
2319
+ refused with CLI_INPUT_TOO_LARGE and the data-error exit code (65), never the internal-error
2320
+ code; split the input and re-run. Multi-record output (--ndjson, MLLP) is written record by
2321
+ record as it is parsed, so a refusal can arrive after some records have already been emitted:
2322
+ the exit code, not the output, is the signal that the run did not complete.
2323
+
1712
2324
  Exit codes:
1713
- 0 success / valid 65 data error (unparseable / undetected format)
2325
+ 0 success / valid 65 data error (unparseable / undetected format / input too large)
1714
2326
  1 invalid (validate) 66 no input (missing/unreadable file)
1715
- 2 usage error 69 unavailable (a capability is not yet built, e.g. redact)
2327
+ or de-identification incomplete (redact)
2328
+ 2 usage error 69 unavailable (a capability is not wired here, e.g. redact on a
2329
+ format @cosyte/deid has no adapter for)
1716
2330
  70 internal error
1717
2331
 
1718
2332
  PHI posture: the parsed model goes to stdout (the data channel you chose); every diagnostic on
1719
2333
  stderr is value-free (codes and positions only, never a field value) unless you pass the loud,
1720
2334
  opt-in --unsafe-show-values (which permits a bounded input excerpt in a failure diagnostic).
1721
2335
  `;
2336
+ }
2337
+ var HELP = helpText();
1722
2338
  function wantsHelp(argv) {
1723
2339
  return argv.includes("--help") || argv.includes("-h");
1724
2340
  }
@@ -1748,7 +2364,7 @@ async function run(argv, deps2) {
1748
2364
  return await mapCodesCommand(rest, deps2);
1749
2365
  case "redact":
1750
2366
  case "deid":
1751
- return redactCommand(rest);
2367
+ return await redactCommand(rest, deps2);
1752
2368
  case "completion":
1753
2369
  return completionCommand(rest);
1754
2370
  default:
@@ -1770,22 +2386,90 @@ function resolve(e) {
1770
2386
  }
1771
2387
 
1772
2388
  // src/bin/cosyte.ts
2389
+ var serverMode = process.argv[2] === "mcp";
2390
+ var stdoutOpen = true;
2391
+ var stderrOpen = true;
2392
+ var reported = false;
2393
+ process.stderr.on("error", () => {
2394
+ stderrOpen = false;
2395
+ });
2396
+ function note(line) {
2397
+ if (!stderrOpen) return;
2398
+ try {
2399
+ process.stderr.write(line);
2400
+ } catch {
2401
+ stderrOpen = false;
2402
+ }
2403
+ }
2404
+ function reportOutputClosed() {
2405
+ if (reported) return;
2406
+ reported = true;
2407
+ note(
2408
+ `cosyte: ${CLI_CODES.CLI_OUTPUT_WRITE_FAILED}: could not write to the output stream; it closed before the output was delivered
2409
+ `
2410
+ );
2411
+ process.exitCode = EXIT.IOERR;
2412
+ }
2413
+ process.stdout.on("error", () => {
2414
+ stdoutOpen = false;
2415
+ if (!serverMode) return;
2416
+ reportOutputClosed();
2417
+ process.stdin.destroy();
2418
+ });
2419
+ var produced = false;
2420
+ var delivered = true;
2421
+ var queued = 0;
2422
+ var onFlushed;
2423
+ function emit(chunk) {
2424
+ produced = true;
2425
+ if (!stdoutOpen) {
2426
+ delivered = false;
2427
+ return;
2428
+ }
2429
+ queued += 1;
2430
+ process.stdout.write(chunk, (err) => {
2431
+ if (err !== null && err !== void 0) delivered = false;
2432
+ queued -= 1;
2433
+ if (queued > 0 || onFlushed === void 0) return;
2434
+ const resume = onFlushed;
2435
+ onFlushed = void 0;
2436
+ resume();
2437
+ });
2438
+ }
2439
+ function flushed() {
2440
+ if (queued === 0) return Promise.resolve();
2441
+ return new Promise((resolve2) => {
2442
+ onFlushed = resolve2;
2443
+ });
2444
+ }
1773
2445
  var deps = {
1774
2446
  readFile: (path) => readFileBytes(path),
1775
- readStdin: () => readStreamBytes(process.stdin)
2447
+ readStdin: () => readStreamBytes(process.stdin),
2448
+ openFile: (path) => fileChunks(path),
2449
+ openStdin: () => streamChunks(process.stdin),
2450
+ writeStdout: (chunk) => {
2451
+ if (!stdoutOpen) throw new Error("stdout closed");
2452
+ emit(chunk);
2453
+ }
1776
2454
  };
1777
- if (process.argv[2] === "mcp") {
2455
+ if (serverMode) {
1778
2456
  Promise.resolve().then(() => (init_server(), server_exports)).then(({ startStdioServer: startStdioServer2 }) => startStdioServer2()).catch(() => {
1779
- process.stderr.write("cosyte: CLI_INTERNAL: the MCP server failed to start\n");
2457
+ note("cosyte: CLI_INTERNAL: the MCP server failed to start\n");
1780
2458
  process.exitCode = 70;
1781
2459
  });
1782
2460
  } else {
1783
- run(process.argv.slice(2), deps).then((result) => {
1784
- if (result.stdout) process.stdout.write(result.stdout);
1785
- if (result.stderr) process.stderr.write(result.stderr);
2461
+ run(process.argv.slice(2), deps).then(async (result) => {
2462
+ if (result.stdout !== "") emit(result.stdout);
2463
+ if (produced) process.exitCode = EXIT.IOERR;
2464
+ await flushed();
2465
+ if (produced && !(delivered && stdoutOpen)) {
2466
+ reportOutputClosed();
2467
+ return;
2468
+ }
2469
+ if (result.stderr) note(result.stderr);
1786
2470
  process.exitCode = result.exit;
1787
2471
  }).catch(() => {
1788
- process.stderr.write("cosyte: CLI_INTERNAL: an unexpected internal error occurred\n");
2472
+ note("cosyte: CLI_INTERNAL: an unexpected internal error occurred\n");
1789
2473
  process.exitCode = 70;
1790
2474
  });
1791
2475
  }