footnote-data 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,238 @@
1
+ # footnote — the Footnote Data API from your terminal
2
+
3
+ Employment-diligence data from primary public records: statutory WARN layoff
4
+ notices, certified H-1B/LCA wage filings, USCIS petition outcomes, DOL Wage &
5
+ Hour and OSHA enforcement, federal litigation posture, and severance terms
6
+ parsed from SEC filings. Counts are measured, matches are exact-or-absent, and
7
+ every response carries `data_gaps` — absence of evidence is always
8
+ distinguished from evidence of absence.
9
+
10
+ Zero runtime dependencies. Node 18+. Docs and pricing: <https://usefootnote.com/data>.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ # one-off
16
+ npx footnote-data warn recent --state CA
17
+
18
+ # global
19
+ npm install -g footnote-data
20
+ footnote warn recent --state CA
21
+ ```
22
+
23
+ ## Quickstart
24
+
25
+ ```bash
26
+ # 1. Create a free account at https://usefootnote.com
27
+ # 2. Issue your API key at https://usefootnote.com/account (Data API section — shown once)
28
+ # 3. Put it in your environment:
29
+ export FOOTNOTE_API_KEY=fn_live_your_key_here
30
+
31
+ # First call: layoff notices filed in California in the last 30 days
32
+ footnote warn recent --state CA
33
+ ```
34
+
35
+ No key yet? Any command tells you exactly how to get one:
36
+
37
+ ```text
38
+ $ footnote warn recent
39
+ Pass your API key as: Authorization: Bearer fn_live_…
40
+
41
+ Get a free API key (50 calls/day) in three steps:
42
+ 1. Create a free account at https://usefootnote.com
43
+ 2. Issue your key at https://usefootnote.com/account (Data API section — the key is shown once)
44
+ 3. export FOOTNOTE_API_KEY=fn_live_… (or pass --key fn_live_…)
45
+ ```
46
+
47
+ ## Commands
48
+
49
+ Sample outputs below are illustrative of the response shape — run the command
50
+ for live data.
51
+
52
+ ### WARN layoff notices
53
+
54
+ ```bash
55
+ # Recent notices, newest first (free tier: last 30 days)
56
+ footnote warn recent --state CA --days 14 --limit 25
57
+
58
+ # Search by employer across states with a date window and pagination
59
+ footnote warn notices --employer "Boeing" --since 2026-06-01 --limit 50
60
+ footnote warn notices --state TX --until 2026-07-01 --offset 100
61
+
62
+ # Per-state summary: notice counts, workers affected, latest filing
63
+ footnote warn states
64
+ ```
65
+
66
+ ```text
67
+ $ footnote warn recent --state CA --limit 2
68
+ employer_name_raw state_code notice_date effective_date workers_affected city layoff_type source_url
69
+ ─────────────────────── ────────── ─────────── ────────────── ──────────────── ────────── ─────────── ────────────────────────────
70
+ Example Manufacturing CA 2026-07-10 2026-09-08 214 Fremont Closure https://edd.ca.gov/...
71
+ Sample Logistics Co CA 2026-07-09 — 88 Ontario Layoff https://edd.ca.gov/...
72
+
73
+ count: 2 · window_days: 30 · state: CA · tier: free
74
+ source: Official state WARN publications, ingested daily by Footnote (usefootnote.com)
75
+ license: CC BY 4.0 for display with attribution; no redistribution as a dataset/API
76
+ ```
77
+
78
+ ### H-1B sponsorship & wages
79
+
80
+ ```bash
81
+ # Does this employer sponsor? (certified LCA corpus)
82
+ footnote h1b employer "Databricks"
83
+
84
+ # Top sponsors by certified filing volume (free tier: top 25)
85
+ footnote h1b top-sponsors --limit 10
86
+
87
+ # Petition OUTCOMES (USCIS) + current attestation INTENT (LCA), in one view
88
+ footnote sponsorship "Meta Platforms"
89
+
90
+ # Advertised (job posting) vs DOL-attested wages, same occupation and area
91
+ footnote wage-comparison "Example Corp"
92
+ ```
93
+
94
+ ```text
95
+ $ footnote sponsorship "Example Corp"
96
+ employer example corp
97
+ matched_as entity
98
+ matched true
99
+ posture growing
100
+ latest_fiscal_year.fiscal_year 2023
101
+ latest_fiscal_year.initial_petition_approvals 412
102
+ current_attestation.matched true
103
+ current_attestation.fiscal_year 2026
104
+ current_attestation.certified_filings 380
105
+ data_gaps (none)
106
+
107
+ tier: free
108
+ ▲ upgrade: Full fiscal-year series and denial rates are on Data Pro — usefootnote.com/data
109
+ ```
110
+
111
+ ### Employer enforcement & filings records
112
+
113
+ ```bash
114
+ # DOL Wage & Hour enforcement posture (concluded cases, exact-name matched)
115
+ footnote enforcement "Example Restaurants LLC"
116
+
117
+ # OSHA inspections and citations
118
+ footnote osha "Example Warehousing"
119
+
120
+ # Federal litigation posture (CourtListener/RECAP; employer-level aggregates only)
121
+ footnote litigation "Example Corp"
122
+
123
+ # Executive severance / change-in-control terms from SEC Exhibit 10 filings
124
+ footnote severance "Example Corp"
125
+ ```
126
+
127
+ ### Dataset provenance
128
+
129
+ ```bash
130
+ # Row counts, coverage windows, and freshness — measured live at request time
131
+ footnote datasets
132
+ ```
133
+
134
+ Built for citations: the response includes a suggested footnote of the form
135
+ `"Footnote Data API (usefootnote.com/data), retrieved <date>; dataset: <dataset>."`
136
+
137
+ ### `footnote get` — works with endpoints that don't exist yet
138
+
139
+ The passthrough issues an authenticated GET against any path under the API, so
140
+ when Footnote ships a new endpoint the CLI already supports it — no update
141
+ required. This is the auto-upgrade property: the CLI grows with the API.
142
+
143
+ ```bash
144
+ # Any endpoint, current or future
145
+ footnote get /api/data/v1/warn/recent state=CA limit=5
146
+
147
+ # Discover every endpoint from the live OpenAPI contract (public, no key needed)
148
+ footnote get /api/data/v1/openapi.json
149
+
150
+ # CSV export (Data Pro) — text bodies pass through untouched
151
+ footnote get /api/data/v1/warn/export.csv > warn-24mo.csv
152
+
153
+ # Full historical archive (one-time purchase)
154
+ footnote get /api/data/v1/warn/historical.csv > warn-all.csv
155
+ ```
156
+
157
+ ## Output modes
158
+
159
+ - **TTY**: aligned tables for lists, key/value views for lookups, meta printed
160
+ beneath.
161
+ - **`--json` or piped**: the raw response body, exactly as the API returned it.
162
+
163
+ ```bash
164
+ footnote warn states --json | jq '.data[0]'
165
+ footnote h1b top-sponsors | jq '.data[].employer_name' # piped ⇒ JSON automatically
166
+ ```
167
+
168
+ Exit codes: `0` success · `1` API or network error · `2` usage error.
169
+
170
+ ## Rate limits
171
+
172
+ | Tier | Calls/day | Burst | WARN window | Price |
173
+ |---|---|---|---|---|
174
+ | Free | 50 | 10/min | 30 days | $0 |
175
+ | Data Pro | 10,000 | 120/min | 24 months | see [usefootnote.com/data](https://usefootnote.com/data) |
176
+ | Enterprise | 100,000 | 600/min | full archive | contact |
177
+
178
+ Hitting the free cap is the product working — the CLI shows you the way up:
179
+
180
+ ```text
181
+ ╭────────────────────────────────────────────────────────────────────────────╮
182
+ │ Daily cap reached (50/day on the free tier). │
183
+ │ │
184
+ │ The free tier is enough to confirm Footnote works inside your product. │
185
+ │ For production volume, Data Pro is 10,000 calls/day with a 24-month WARN │
186
+ │ window — $79/mo. │
187
+ │ │
188
+ │ Your daily meter resets at 2026-07-17T00:00:00.000Z. │
189
+ │ │
190
+ │ https://usefootnote.com/data#pricing │
191
+ ╰────────────────────────────────────────────────────────────────────────────╯
192
+ ```
193
+
194
+ ## Configuration
195
+
196
+ | Setting | Env var | Flag | Default |
197
+ |---|---|---|---|
198
+ | API key | `FOOTNOTE_API_KEY` | `--key fn_live_…` | — |
199
+ | API origin | `FOOTNOTE_API_URL` | `--api-url https://…` | `https://usefootnote.com` |
200
+ | Colors | `NO_COLOR` | `--no-color` | on for TTY |
201
+
202
+ ## MCP / AI assistants
203
+
204
+ An MCP server (`@footnote/mcp`) exposing these endpoints as tools is planned.
205
+ Today, point any OpenAPI-capable agent or tool at the live machine-readable
206
+ contract:
207
+
208
+ ```text
209
+ https://usefootnote.com/api/data/v1/openapi.json
210
+ ```
211
+
212
+ ## License & attribution
213
+
214
+ CLI code: MIT.
215
+
216
+ Data returned on the **free tier** is licensed **CC BY 4.0 for display with
217
+ attribution** — credit `Data: Footnote (usefootnote.com)` where you show it;
218
+ redistribution as a dataset or API is not permitted. Paid tiers are governed by
219
+ your Footnote Data agreement. Every response's `meta.license` states the terms
220
+ that apply to that call.
221
+
222
+ ## Endpoint reference
223
+
224
+ | Command | Endpoint |
225
+ |---|---|
226
+ | `warn recent` | `GET /api/data/v1/warn/recent` |
227
+ | `warn notices` | `GET /api/data/v1/warn/notices` |
228
+ | `warn states` | `GET /api/data/v1/warn/states` |
229
+ | `h1b employer` | `GET /api/data/v1/h1b/employer` |
230
+ | `h1b top-sponsors` | `GET /api/data/v1/h1b/top-sponsors` |
231
+ | `sponsorship` | `GET /api/data/v1/sponsorship/employer` |
232
+ | `enforcement` | `GET /api/data/v1/enforcement/employer` |
233
+ | `litigation` | `GET /api/data/v1/litigation/employer` |
234
+ | `osha` | `GET /api/data/v1/osha/employer` |
235
+ | `severance` | `GET /api/data/v1/severance/employer` |
236
+ | `wage-comparison` | `GET /api/data/v1/wage-comparison/employer` |
237
+ | `datasets` | `GET /api/data/v1/datasets` |
238
+ | `get <path>` | `GET <any /api/data/v1 path>` |
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ import { run } from "../footnote.mjs";
3
+
4
+ run(process.argv.slice(2)).then((code) => {
5
+ process.exitCode = code;
6
+ });
package/footnote.mjs ADDED
@@ -0,0 +1,536 @@
1
+ /**
2
+ * footnote — the Footnote Data API from your terminal (https://usefootnote.com/data).
3
+ *
4
+ * Zero runtime dependencies; Node >= 18 (global fetch). Every command spec below
5
+ * mirrors the server route file it names — params here are the route's REAL query
6
+ * params, read from the source, never guessed.
7
+ */
8
+
9
+ export const VERSION = "0.1.0";
10
+ export const DEFAULT_API_URL = "https://usefootnote.com";
11
+
12
+ const UPGRADE_URL = "https://usefootnote.com/data";
13
+ const KEYS_URL = "https://usefootnote.com/account";
14
+ // Mirrors TIER_LIMITS in lib/data-api/authorize.ts (pro: 10k/day, 120/min, 730-day window).
15
+ const DEFAULT_UPGRADE_TEXT = "Data Pro: 10,000 calls/day · 120 req/min · 24-month WARN window.";
16
+
17
+ const CELL_MAX = 48;
18
+
19
+ /* ---------------------------------- styling ---------------------------------- */
20
+
21
+ const PLAIN = { bold: (s) => s, dim: (s) => s, cyan: (s) => s, yellow: (s) => s };
22
+
23
+ function makeStyle(enabled) {
24
+ if (!enabled) return PLAIN;
25
+ const wrap = (open, close) => (s) => `\u001b[${open}m${s}\u001b[${close}m`;
26
+ return { bold: wrap(1, 22), dim: wrap(2, 22), cyan: wrap(36, 39), yellow: wrap(33, 39) };
27
+ }
28
+
29
+ /* ------------------------------ argument parsing ------------------------------ */
30
+
31
+ const VALIDATORS = {
32
+ state: {
33
+ ok: (v) => /^[A-Za-z]{2}$/.test(v),
34
+ hint: "a two-letter U.S. state code (e.g. CA)",
35
+ map: (v) => v.toUpperCase(),
36
+ },
37
+ posInt: { ok: (v) => /^\d+$/.test(v) && Number(v) >= 1, hint: "a positive integer" },
38
+ nonNegInt: { ok: (v) => /^\d+$/.test(v), hint: "a non-negative integer" },
39
+ date: { ok: (v) => /^\d{4}-\d{2}-\d{2}$/.test(v), hint: "a date formatted YYYY-MM-DD" },
40
+ text3: { ok: (v) => v.trim().length >= 3, hint: "at least 3 characters" },
41
+ };
42
+
43
+ /**
44
+ * The command registry. `route` names the server file each spec was read from;
45
+ * `flags`/`positional` are that route's actual accepted params.
46
+ */
47
+ export const COMMANDS = [
48
+ {
49
+ name: "warn recent",
50
+ path: "/api/data/v1/warn/recent",
51
+ route: "app/api/data/v1/warn/recent/route.ts",
52
+ summary: "Recent WARN layoff notices, newest first",
53
+ flags: { state: "state", days: "posInt", limit: "posInt" },
54
+ },
55
+ {
56
+ name: "warn notices",
57
+ path: "/api/data/v1/warn/notices",
58
+ route: "app/api/data/v1/warn/notices/route.ts",
59
+ summary: "Search WARN notices (employer, state, date window, pagination)",
60
+ flags: { state: "state", employer: "text3", since: "date", until: "date", limit: "posInt", offset: "nonNegInt" },
61
+ },
62
+ {
63
+ name: "warn states",
64
+ path: "/api/data/v1/warn/states",
65
+ route: "app/api/data/v1/warn/states/route.ts",
66
+ summary: "Per-state WARN summaries over your tier's window",
67
+ flags: {},
68
+ },
69
+ {
70
+ name: "h1b employer",
71
+ path: "/api/data/v1/h1b/employer",
72
+ route: "app/api/data/v1/h1b/employer/route.ts",
73
+ summary: "H-1B sponsorship lookup over certified LCA filings",
74
+ positional: { param: "name", label: "<company>", min: 3 },
75
+ },
76
+ {
77
+ name: "h1b top-sponsors",
78
+ path: "/api/data/v1/h1b/top-sponsors",
79
+ route: "app/api/data/v1/h1b/top-sponsors/route.ts",
80
+ summary: "Top H-1B sponsors by certified filing volume",
81
+ flags: { limit: "posInt" },
82
+ },
83
+ {
84
+ name: "sponsorship",
85
+ path: "/api/data/v1/sponsorship/employer",
86
+ route: "app/api/data/v1/sponsorship/employer/route.ts",
87
+ summary: "USCIS petition outcomes + current LCA attestation intent",
88
+ positional: { param: "name", label: "<employer>", min: 2, max: 160 },
89
+ },
90
+ {
91
+ name: "enforcement",
92
+ path: "/api/data/v1/enforcement/employer",
93
+ route: "app/api/data/v1/enforcement/employer/route.ts",
94
+ summary: "DOL Wage & Hour enforcement posture",
95
+ positional: { param: "name", label: "<employer>", min: 2 },
96
+ },
97
+ {
98
+ name: "litigation",
99
+ path: "/api/data/v1/litigation/employer",
100
+ route: "app/api/data/v1/litigation/employer/route.ts",
101
+ summary: "Federal litigation posture (CourtListener/RECAP)",
102
+ positional: { param: "name", label: "<employer>", min: 2 },
103
+ },
104
+ {
105
+ name: "osha",
106
+ path: "/api/data/v1/osha/employer",
107
+ route: "app/api/data/v1/osha/employer/route.ts",
108
+ summary: "OSHA inspections and citations",
109
+ positional: { param: "name", label: "<employer>", min: 2 },
110
+ },
111
+ {
112
+ name: "severance",
113
+ path: "/api/data/v1/severance/employer",
114
+ route: "app/api/data/v1/severance/employer/route.ts",
115
+ summary: "Executive severance terms parsed from SEC Exhibit 10 filings",
116
+ positional: { param: "name", label: "<employer>", min: 2 },
117
+ },
118
+ {
119
+ name: "wage-comparison",
120
+ path: "/api/data/v1/wage-comparison/employer",
121
+ route: "app/api/data/v1/wage-comparison/employer/route.ts",
122
+ summary: "Advertised (posting) vs DOL-attested (LCA) wages",
123
+ positional: { param: "name", label: "<employer>", min: 2 },
124
+ },
125
+ {
126
+ name: "datasets",
127
+ path: "/api/data/v1/datasets",
128
+ route: "app/api/data/v1/datasets/route.ts",
129
+ summary: "Live provenance index: row counts, coverage, freshness",
130
+ flags: {},
131
+ },
132
+ ];
133
+
134
+ function findCommand(words) {
135
+ const two = words.slice(0, 2).join(" ");
136
+ const one = words[0];
137
+ return (
138
+ COMMANDS.find((c) => c.name === two) ??
139
+ COMMANDS.find((c) => c.name === one) ??
140
+ null
141
+ );
142
+ }
143
+
144
+ /**
145
+ * Splits global flags from command words. Returns { globals, words } or
146
+ * { usageError } when a flag is malformed.
147
+ */
148
+ export function parseGlobals(argv) {
149
+ const globals = { json: false, key: null, apiUrl: null, noColor: false, help: false, version: false };
150
+ const words = [];
151
+ for (let i = 0; i < argv.length; i++) {
152
+ const a = argv[i];
153
+ if (a === "--json") globals.json = true;
154
+ else if (a === "--no-color") globals.noColor = true;
155
+ else if (a === "--help" || a === "-h") globals.help = true;
156
+ else if (a === "--version" || a === "-V") globals.version = true;
157
+ else if (a === "--key" || a === "--api-url") {
158
+ const v = argv[++i];
159
+ if (v === undefined) return { usageError: `${a} needs a value` };
160
+ if (a === "--key") globals.key = v;
161
+ else globals.apiUrl = v;
162
+ } else if (a.startsWith("--key=")) globals.key = a.slice("--key=".length);
163
+ else if (a.startsWith("--api-url=")) globals.apiUrl = a.slice("--api-url=".length);
164
+ else words.push(a);
165
+ }
166
+ return { globals, words };
167
+ }
168
+
169
+ /**
170
+ * Parses one command's tokens into query params. Returns
171
+ * { path, params: [k, v][] } or { usageError }.
172
+ */
173
+ export function parseCommand(words) {
174
+ if (words[0] === "get") return parsePassthrough(words.slice(1));
175
+
176
+ const cmd = findCommand(words);
177
+ if (!cmd) return { usageError: `unknown command: ${words.join(" ")}` };
178
+
179
+ const rest = words.slice(cmd.name.split(" ").length);
180
+ const params = new Map();
181
+ const positionalWords = [];
182
+
183
+ for (let i = 0; i < rest.length; i++) {
184
+ const token = rest[i];
185
+ if (token.startsWith("--")) {
186
+ const eq = token.indexOf("=");
187
+ const flag = eq === -1 ? token.slice(2) : token.slice(2, eq);
188
+ const spec = (cmd.flags ?? {})[flag];
189
+ if (!spec) return { usageError: `unknown flag --${flag} for "${cmd.name}"` };
190
+ const value = eq === -1 ? rest[++i] : token.slice(eq + 1);
191
+ if (value === undefined) return { usageError: `--${flag} needs a value` };
192
+ const validator = VALIDATORS[spec];
193
+ if (!validator.ok(value)) return { usageError: `--${flag} must be ${validator.hint} (got "${value}")` };
194
+ params.set(flag, validator.map ? validator.map(value) : value);
195
+ } else {
196
+ if (!cmd.positional) return { usageError: `unexpected argument "${token}" for "${cmd.name}"` };
197
+ positionalWords.push(token);
198
+ }
199
+ }
200
+
201
+ if (cmd.positional) {
202
+ const value = positionalWords.join(" ").trim();
203
+ const { param, label, min, max } = cmd.positional;
204
+ if (!value) return { usageError: `"${cmd.name}" needs ${label}, e.g. footnote ${cmd.name} "Acme Corp"` };
205
+ if (value.length < min) return { usageError: `${label} needs at least ${min} characters (got "${value}")` };
206
+ if (max && value.length > max) return { usageError: `${label} must be at most ${max} characters` };
207
+ params.set(param, value);
208
+ }
209
+
210
+ return { path: cmd.path, params: [...params.entries()] };
211
+ }
212
+
213
+ function parsePassthrough(tokens) {
214
+ const path = tokens[0];
215
+ if (!path) return { usageError: "get needs a path, e.g. footnote get /api/data/v1/warn/recent state=CA" };
216
+ if (!path.startsWith("/") || path.includes("://") || !/^\/[A-Za-z0-9/_.\-]*$/.test(path)) {
217
+ return { usageError: `get takes an absolute API path like /api/data/v1/..., not "${path}"` };
218
+ }
219
+ const params = [];
220
+ for (const token of tokens.slice(1)) {
221
+ const eq = token.indexOf("=");
222
+ if (eq <= 0) return { usageError: `get params are key=value pairs (got "${token}")` };
223
+ params.push([token.slice(0, eq), token.slice(eq + 1)]);
224
+ }
225
+ return { path, params };
226
+ }
227
+
228
+ /** Builds the request URL from an origin, a path, and [k, v] query pairs. */
229
+ export function buildUrl(apiUrl, path, params) {
230
+ const base = apiUrl.replace(/\/+$/, "");
231
+ const url = new URL(base + path);
232
+ for (const [k, v] of params) url.searchParams.append(k, v);
233
+ return url;
234
+ }
235
+
236
+ /* -------------------------------- rendering -------------------------------- */
237
+
238
+ function truncateCell(s) {
239
+ return s.length > CELL_MAX ? `${s.slice(0, CELL_MAX - 1)}…` : s;
240
+ }
241
+
242
+ function formatCell(v) {
243
+ if (v === null || v === undefined) return "—";
244
+ if (typeof v === "object") return truncateCell(JSON.stringify(v));
245
+ return truncateCell(String(v));
246
+ }
247
+
248
+ /** Aligned text table over an array of objects (columns in first-appearance order). */
249
+ export function renderTable(rows, style = PLAIN) {
250
+ if (!rows.length) return "(no rows)";
251
+ const cols = [];
252
+ for (const row of rows) for (const k of Object.keys(row)) if (!cols.includes(k)) cols.push(k);
253
+ const cells = rows.map((row) => cols.map((c) => formatCell(row[c])));
254
+ const numeric = cols.map((c) => rows.every((r) => r[c] === null || r[c] === undefined || typeof r[c] === "number"));
255
+ const widths = cols.map((c, i) => Math.max(c.length, ...cells.map((row) => row[i].length)));
256
+ const pad = (s, i) => (numeric[i] ? s.padStart(widths[i]) : s.padEnd(widths[i]));
257
+ const lines = [
258
+ style.bold(cols.map((c, i) => pad(c, i)).join(" ").trimEnd()),
259
+ style.dim(widths.map((w) => "─".repeat(w)).join(" ")),
260
+ ...cells.map((row) => row.map((s, i) => pad(s, i)).join(" ").trimEnd()),
261
+ ];
262
+ return lines.join("\n");
263
+ }
264
+
265
+ /** Key/value view for object payloads; nested objects flatten one level, arrays of objects become sub-tables. */
266
+ export function renderObject(obj, style = PLAIN) {
267
+ const scalars = [];
268
+ const tables = [];
269
+ for (const [k, v] of Object.entries(obj)) {
270
+ if (v !== null && typeof v === "object" && !Array.isArray(v)) {
271
+ for (const [sub, sv] of Object.entries(v)) pushEntry(scalars, tables, `${k}.${sub}`, sv);
272
+ } else {
273
+ pushEntry(scalars, tables, k, v);
274
+ }
275
+ }
276
+ const width = scalars.reduce((w, [k]) => Math.max(w, k.length), 0);
277
+ const lines = scalars.map(([k, v]) => `${style.dim(k.padEnd(width))} ${v}`);
278
+ for (const [label, rows] of tables) lines.push("", style.bold(label), renderTable(rows, style));
279
+ return lines.join("\n");
280
+ }
281
+
282
+ function pushEntry(scalars, tables, label, v) {
283
+ if (v === null || v === undefined) scalars.push([label, "—"]);
284
+ else if (Array.isArray(v)) {
285
+ if (v.length === 0) scalars.push([label, "(none)"]);
286
+ else if (typeof v[0] === "object" && v[0] !== null) tables.push([label, v]);
287
+ else scalars.push([label, v.join(", ")]);
288
+ } else if (typeof v === "object") scalars.push([label, truncateCell(JSON.stringify(v))]);
289
+ else scalars.push([label, String(v)]);
290
+ }
291
+
292
+ function renderMeta(meta, style) {
293
+ if (!meta || typeof meta !== "object") return [];
294
+ const inline = ["count", "offset", "window_days", "window_start", "state", "tier"]
295
+ .filter((k) => meta[k] !== undefined && meta[k] !== null)
296
+ .map((k) => `${k}: ${meta[k]}`);
297
+ const lines = [];
298
+ if (inline.length) lines.push(style.dim(inline.join(" · ")));
299
+ for (const k of ["match", "units", "interpretation", "privacy", "note", "source", "license", "citation"]) {
300
+ if (typeof meta[k] === "string") lines.push(style.dim(`${k}: ${meta[k]}`));
301
+ }
302
+ if (typeof meta.upgrade === "string") lines.push(style.yellow(`▲ upgrade: ${meta.upgrade}`));
303
+ return lines;
304
+ }
305
+
306
+ function wrapText(text, width) {
307
+ const lines = [];
308
+ let line = "";
309
+ for (const word of String(text).split(/\s+/)) {
310
+ if (line && line.length + 1 + word.length > width) {
311
+ lines.push(line);
312
+ line = word;
313
+ } else line = line ? `${line} ${word}` : word;
314
+ }
315
+ if (line) lines.push(line);
316
+ return lines;
317
+ }
318
+
319
+ /** Box-drawn upgrade prompt; the URL always gets its own line. */
320
+ export function renderUpgradeBox({ message, upgradeText, upgradeUrl, resetsAt }, style = PLAIN) {
321
+ const lines = [
322
+ ...wrapText(message ?? "", 72),
323
+ "",
324
+ ...wrapText(upgradeText ?? "", 72),
325
+ ...(resetsAt ? ["", `Your daily meter resets at ${resetsAt}.`] : []),
326
+ "",
327
+ upgradeUrl ?? "",
328
+ ];
329
+ const width = Math.max(...lines.map((l) => l.length));
330
+ return [
331
+ `╭${"─".repeat(width + 4)}╮`,
332
+ ...lines.map((l, i) => `│ ${(i === lines.length - 1 ? style.cyan(l.padEnd(width)) : l.padEnd(width))} │`),
333
+ `╰${"─".repeat(width + 4)}╯`,
334
+ ].join("\n");
335
+ }
336
+
337
+ function upgradeFromBody(body) {
338
+ const raw = body?.upgrade ?? body?.error?.upgrade ?? body?.meta?.upgrade ?? null;
339
+ if (raw === null || raw === undefined) return null;
340
+ if (typeof raw === "string") {
341
+ const url = (raw.match(/https?:\/\/\S+/) ?? [])[0] ?? UPGRADE_URL;
342
+ return { upgradeText: raw, upgradeUrl: url };
343
+ }
344
+ if (typeof raw === "object") {
345
+ return {
346
+ upgradeText: String(raw.message ?? raw.text ?? DEFAULT_UPGRADE_TEXT),
347
+ upgradeUrl: String(raw.upgrade_url ?? raw.url ?? UPGRADE_URL),
348
+ resetsAt: typeof raw.resets_at === "string" ? raw.resets_at : null,
349
+ };
350
+ }
351
+ return null;
352
+ }
353
+
354
+ function renderApiError(status, body, headers, io, style) {
355
+ const code = body?.error?.code ?? "";
356
+ const message = body?.error?.message ?? `API error (HTTP ${status})`;
357
+ const upgrade = upgradeFromBody(body);
358
+
359
+ if (status === 401) {
360
+ if (code === "invalid_key") {
361
+ io.err(message);
362
+ io.err(`Check your keys at ${style.cyan(KEYS_URL)} — a revoked key stops working immediately.`);
363
+ } else {
364
+ io.err(message);
365
+ io.err("");
366
+ io.err("Get a free API key (50 calls/day) in three steps:");
367
+ io.err(` 1. Create a free account at ${style.cyan("https://usefootnote.com")}`);
368
+ io.err(` 2. Issue your key at ${style.cyan(KEYS_URL)} (Data API section — the key is shown once)`);
369
+ io.err(" 3. export FOOTNOTE_API_KEY=fn_live_… (or pass --key fn_live_…)");
370
+ }
371
+ return 1;
372
+ }
373
+
374
+ // Hitting a cap is the product working: render the upgrade path, prominently.
375
+ if ((status === 429 && (code === "daily_cap" || upgrade)) ||
376
+ (status === 403 && (code === "upgrade_required" || code === "purchase_required"))) {
377
+ io.err(renderUpgradeBox({
378
+ message,
379
+ upgradeText: upgrade?.upgradeText ?? DEFAULT_UPGRADE_TEXT,
380
+ upgradeUrl: upgrade?.upgradeUrl ?? UPGRADE_URL,
381
+ resetsAt: upgrade?.resetsAt ?? null,
382
+ }, style));
383
+ return 1;
384
+ }
385
+
386
+ if (status === 429) {
387
+ const retry = headers?.get?.("retry-after");
388
+ io.err(retry ? `${message} Retry in ${retry}s.` : message);
389
+ return 1;
390
+ }
391
+
392
+ io.err(`${message}${code ? ` [${code}]` : ""} (HTTP ${status})`);
393
+ return 1;
394
+ }
395
+
396
+ /* ---------------------------------- help ---------------------------------- */
397
+
398
+ export function helpText() {
399
+ const rows = COMMANDS.map((c) => {
400
+ const flags = Object.entries(c.flags ?? {})
401
+ .map(([f, t]) => `[--${f} ${t === "date" ? "YYYY-MM-DD" : t === "state" ? "CA" : t === "text3" ? "TEXT" : "N"}]`)
402
+ .join(" ");
403
+ const pos = c.positional ? ` ${c.positional.label}` : "";
404
+ return [` footnote ${c.name}${pos}${flags ? ` ${flags}` : ""}`, c.summary];
405
+ });
406
+ rows.push([" footnote get <path> [k=v ...]", "Raw GET against any /api/data/v1 endpoint (current or future)"]);
407
+ const width = rows.reduce((w, [l]) => Math.max(w, l.length), 0);
408
+ return [
409
+ `footnote v${VERSION} — the Footnote Data API from your terminal (${UPGRADE_URL})`,
410
+ "",
411
+ "Usage",
412
+ ...rows.map(([l, s]) => `${l.padEnd(width)} ${s}`),
413
+ "",
414
+ "Options",
415
+ " --json Raw JSON output (also the default when piped)",
416
+ " --key <key> API key (default: $FOOTNOTE_API_KEY)",
417
+ ` --api-url <url> API origin (default: $FOOTNOTE_API_URL or ${DEFAULT_API_URL})`,
418
+ " --no-color Disable ANSI colors",
419
+ " -h, --help Show this help",
420
+ " -V, --version Show version",
421
+ "",
422
+ "Exit codes: 0 ok · 1 API/network error · 2 usage error",
423
+ "",
424
+ `Keys: create a free account at https://usefootnote.com, issue a key at ${KEYS_URL},`,
425
+ "then: export FOOTNOTE_API_KEY=fn_live_…",
426
+ ].join("\n");
427
+ }
428
+
429
+ /* ----------------------------------- run ----------------------------------- */
430
+
431
+ /**
432
+ * Runs the CLI. `io` is injectable for tests: { env, isTTY, out, err }.
433
+ * Network goes through globalThis.fetch so tests can swap it.
434
+ */
435
+ export async function run(argv, io = {}) {
436
+ const env = io.env ?? process.env;
437
+ const isTTY = io.isTTY ?? process.stdout.isTTY ?? false;
438
+ const out = io.out ?? ((s) => process.stdout.write(`${s}\n`));
439
+ const err = io.err ?? ((s) => process.stderr.write(`${s}\n`));
440
+ const ioPair = { out, err };
441
+
442
+ const parsed = parseGlobals(argv);
443
+ if (parsed.usageError) return usageFail(err, parsed.usageError);
444
+ const { globals, words } = parsed;
445
+ const style = makeStyle(isTTY && !globals.noColor && !env.NO_COLOR);
446
+
447
+ if (globals.version) {
448
+ out(VERSION);
449
+ return 0;
450
+ }
451
+ if (globals.help || words.length === 0) {
452
+ if (globals.help) {
453
+ out(helpText());
454
+ return 0;
455
+ }
456
+ err(helpText());
457
+ return 2;
458
+ }
459
+
460
+ const command = parseCommand(words);
461
+ if (command.usageError) return usageFail(err, command.usageError);
462
+
463
+ const apiUrl = globals.apiUrl ?? env.FOOTNOTE_API_URL ?? DEFAULT_API_URL;
464
+ let url;
465
+ try {
466
+ url = buildUrl(apiUrl, command.path, command.params);
467
+ } catch {
468
+ return usageFail(err, `invalid API url: ${apiUrl}`);
469
+ }
470
+
471
+ const key = globals.key ?? env.FOOTNOTE_API_KEY ?? null;
472
+ const headers = { accept: "application/json", "user-agent": `footnote-cli/${VERSION}` };
473
+ if (key) headers.authorization = `Bearer ${key}`;
474
+
475
+ let res;
476
+ try {
477
+ res = await globalThis.fetch(url, { headers });
478
+ } catch (cause) {
479
+ err(`Network error: ${cause?.message ?? cause}`);
480
+ return 1;
481
+ }
482
+
483
+ const jsonMode = globals.json || !isTTY;
484
+ const contentType = res.headers?.get?.("content-type") ?? "";
485
+
486
+ if (!contentType.includes("application/json")) {
487
+ // CSV exports and other text bodies pass through untouched.
488
+ const text = await res.text();
489
+ if (res.ok) {
490
+ out(text.endsWith("\n") ? text.slice(0, -1) : text);
491
+ return 0;
492
+ }
493
+ err(`API error (HTTP ${res.status})`);
494
+ if (text.trim()) err(text.trim());
495
+ return 1;
496
+ }
497
+
498
+ let body;
499
+ try {
500
+ body = await res.json();
501
+ } catch {
502
+ err(`API error: unparseable JSON body (HTTP ${res.status})`);
503
+ return 1;
504
+ }
505
+
506
+ if (!res.ok) {
507
+ if (jsonMode) {
508
+ out(JSON.stringify(body, null, 2));
509
+ return 1;
510
+ }
511
+ return renderApiError(res.status, body, res.headers, ioPair, style);
512
+ }
513
+
514
+ if (jsonMode) {
515
+ out(JSON.stringify(body, null, 2));
516
+ return 0;
517
+ }
518
+
519
+ const data = body?.data;
520
+ if (Array.isArray(data)) out(renderTable(data, style));
521
+ else if (data && typeof data === "object") out(renderObject(data, style));
522
+ else out(JSON.stringify(body, null, 2));
523
+
524
+ const metaLines = renderMeta(body?.meta, style);
525
+ if (metaLines.length) {
526
+ out("");
527
+ for (const line of metaLines) out(line);
528
+ }
529
+ return 0;
530
+ }
531
+
532
+ function usageFail(err, message) {
533
+ err(`Usage error: ${message}`);
534
+ err("Run `footnote --help` for commands and options.");
535
+ return 2;
536
+ }
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "footnote-data",
3
+ "version": "0.1.0",
4
+ "description": "The Footnote Data API from your terminal \u2014 WARN layoff notices, H-1B sponsorship, DOL/OSHA enforcement, severance terms, and wage comparisons from primary public records.",
5
+ "keywords": [
6
+ "warn-act",
7
+ "layoffs",
8
+ "h1b",
9
+ "lca",
10
+ "dol",
11
+ "osha",
12
+ "employment-data",
13
+ "labor-data",
14
+ "cli",
15
+ "footnote"
16
+ ],
17
+ "homepage": "https://usefootnote.com/data",
18
+ "bugs": {
19
+ "email": "hello@usefootnote.com"
20
+ },
21
+ "license": "MIT",
22
+ "type": "module",
23
+ "bin": {
24
+ "footnote": "bin/footnote.mjs"
25
+ },
26
+ "files": [
27
+ "bin/",
28
+ "footnote.mjs",
29
+ "README.md"
30
+ ],
31
+ "engines": {
32
+ "node": ">=18"
33
+ },
34
+ "scripts": {
35
+ "test": "node test.mjs"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ }
40
+ }