# CLI

edfcore ships a command line so you can look at a file before writing any code. Every command reads one file, prints to stdout, and returns an exit code a script can branch on.

_From the edfcore documentation (Reference). Canonical page: https://edfcore.vercel.app/docs/cli_

---

```bash
npx edfcore header <file>      # the header, the signals, and any diagnostics
npx edfcore validate <file>    # a full conformance sweep, scanning every sample
npx edfcore events <file>      # the annotations, counted by text
npx edfcore signals <file>     # one tab-separated line per signal, for grep and awk
npx edfcore gaps <file>        # the discontinuities, from a full scan
npx edfcore json <file>        # the header as JSON, for piping into jq
npx edfcore --version          # the installed version
```

Flags: `--patient` includes both identification fields, local patient and local recording
(`header`, `validate`, `json`), and stops the diagnostics that quote them being redacted. `--list`
makes
`events` print one event per line instead of counting them by text, and `--limit <n>` caps the
diagnostics, events or gaps printed (`header`, `validate`, `events --list`, `gaps`). Each is accepted and ignored
by the commands it does not name, and the counted `events` output is never capped.

```bash
npx edfcore events recording.edf --list --limit 100
# 0<TAB>30<TAB>Sleep stage W<TAB>
```

The onset column is `onsetSecondsFromFirstRecord` — the axis `gaps` and every read use, where
`t = 0` is the start of record 0. A truncated listing says how many events it withheld, because a
silently cut one reads as a complete one.

`json` emits the geometry, the start, one entry per signal and the diagnostic codes. A signal entry carries the four declared range numbers and `scale`, so the document is enough to reach physical units on its own — `scale.bitValue * (scale.offset + digital)`. The key is absent when the header has no usable gain, the way `sampleRateHz` is absent for a legal zero record duration — and always for an annotations channel, whose bytes are TAL text rather than measurements, so there is nothing to scale however well formed its declared range is. That is the case you will actually see: the range an EDF+ writer puts on an annotations signal is usually a perfectly usable `-1..1` over `-32768..32767`. A diagnostic carries `signalIndex` when it is about a channel rather than the file. It reports
`spanSeconds` and `coveredSeconds` as a pair, and two numbers that were measured rather than
declared are worth more here than `variant`, which carries only the claim the writer made. Read
their difference with its sign: `spanSeconds - coveredSeconds` positive is a hole, negative means
records overlap. The
diagnostics are both sets the library holds — the header's and the record probes' — each entry
carrying `source: "header"` or `source: "recordProbe"`. Before 0.6.12 only the header's were
emitted, so an EDF+C file with a real hole reported nothing about it here while `edfcore gaps`
reported the gap. That release is why the pair is no longer the document's only measured answer:
`DISCONTINUITY_IN_CONTINUOUS_FILE` and `RECORD_ONSET_SPACING_VIOLATION` both arrive with
`source: "recordProbe"`, and both contradict a `variant` that says the file is continuous. The
start
carries `dateSource` and `clockSource` alongside the values, because a `null` clock and a file that
starts at midnight are otherwise the same output, and a date resolved from the EDF+ recording
identification is the unambiguous one. It was added in 0.6.6; before that the command reported
`DATE_CLIPPED_TO_1985_2084` and not the field that diagnostic tells you to read.

```json
"start": { "date": "2095-04-24", "dateSource": "recordingIdField",
           "clock": "22:30:00", "clockSource": "headerField",
           "offsetSeconds": 0.25 }
```

`offsetSeconds` is the sub-second start carried by record 0's timekeeping TAL, in `[0, 1)`, and it
is what joins the two clocks in this document. `date` and `clock` are header fields, on the
header's timebase; `spanSeconds`, `coveredSeconds` and the onsets `events` prints are on record
0's. Adding a `clock` to an `events` onset without it lands up to a second early — silently, and
only on the files that carry one. `formatHeader` cannot print it, because a header alone does not
know it; this command opens the recording and has already paid for the probe that reads it.

`header` is for reading and `signals` is for piping. The second emits six tab-separated columns,
in this order, annotations channel included:

| # | Column | Note |
|---|---|---|
| 1 | `index` | |
| 2 | `label` | trimmed |
| 3 | `kind` | `data` or `annotations` |
| 4 | `sampleRateHz` | **empty** for a legal zero record duration — it is derived |
| 5 | `physicalDimension` | trimmed |
| 6 | `samplesPerRecord` | the authoritative count; index by this, never by the rate |

Column 6 was added in 0.2.42 and appended rather than inserted, so nothing that parsed the first
five by position moved. Before that this page claimed the command emitted samples per record when
it emitted `kind` instead, and the authoritative field was in no column at all. `gaps` runs a full scan rather
than the two probes `openEdf` makes, because a probed index cannot see a gap in the middle and
reporting "none" from it would be a claim nobody verified.

Exit codes are the contract, so a script can act on them without parsing the output:

| Code | Meaning |
|---|---|
| `0` | success |
| `1` | the file could not be read, or validation failed |
| `2` | bad usage — unknown command or option, missing file, extra files, bad flag value |

Bad usage really does exit 2 since 0.2.27; before that `parseArgs` threw a plain `RangeError` that
the shell reported as 1, so `--limit all` was indistinguishable from a corrupt recording to the job
gating on it. Three related things changed with it:

- An **unknown option** is refused rather than ignored. A misspelled `--patinet` used to be dropped
  silently, so the command printed the identification the caller was trying to withhold, and
  exited 0.
- **Extra files** are refused. `edfcore validate *.edf` used to validate the first file the shell
  expanded, exit 0, and say nothing about the rest — inside the CI gate the exit code exists for.
  Loop instead: `for f in *.edf; do edfcore validate "$f" || exit 1; done`
- **`--help` and `-h`** exit 0. They are flags, and `parseArgs` never puts a dash-prefixed argument
  in the command slot, so the old `command === '--help'` branch was unreachable and
  `npx edfcore --help` fell through to "no command" and exited 2.

`edfcore validate` exiting non-zero is the intended way to gate a CI job on file conformance.

Patient identification is omitted from `header`, `validate` and `json` unless `--patient` is passed, for the
same reason `formatHeader` withholds it: the obvious thing to do with CLI output is pipe it
somewhere.

That covers the diagnostics too, which is the part that is easy to miss. A diagnostic names the
raw bytes as written — that is the message contract, and it is what makes a report actionable —
so a NON-CONFORMANT identification field had its whole content printed in the diagnostics block
underneath the summary that had just withheld it. That is not a rare file: a writer that packs the
name into a single token fails the EDF+ grammar, and a file that behaves oddly is exactly the one
someone runs `edfcore header` on and pastes into an issue. Since 0.2.26 both are gated by the same
flag, and the diagnostic still reports its code, severity, byte offset and rule with the value
replaced by `[redacted]`.

`formatDiagnostics` and `formatValidationReport` take `redactFields` for the same purpose:

```ts
formatDiagnostics(header.diagnostics, { redactFields: ['patientId', 'recordingId'] });
```

The brackets are load-bearing. A string is iterable, so `redactFields: 'patientId'` walked its
characters and refused with `options.redactFields names "p"` — a value nobody wrote, about a
vocabulary that was never the problem. It is named as the string it is from 0.6.143 on.
