predictable-ai 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.
@@ -0,0 +1,181 @@
1
+ /**
2
+ * GENERATED FILE — DO NOT EDIT.
3
+ *
4
+ * Source of truth: dashboard/src/lib/exa-agent/csv-safety.ts
5
+ * Regenerate: npm run generate:agent-surface (from dashboard/)
6
+ * Guard: npm run guard:agent-surface (CI re-derives and diffs)
7
+ *
8
+ * §5.1a requires ONE serializer for every CSV egress. The CLI is a separate
9
+ * npm package that publishes only dist/, so it cannot import across the
10
+ * repo; it gets a generated mirror instead, on the same terms as
11
+ * commands.json. Edit the source above, never this file.
12
+ */
13
+ /**
14
+ * THE CANONICAL CSV SAFETY LIB — one hazard probe, one serializer (spec §5.1
15
+ * step 3 and §5.1a; ticket ga-mfacl.8 / T7, ACs 2 and 2b).
16
+ *
17
+ * ONE IMPLEMENTATION. Everything that emits CSV out of this product imports
18
+ * from here: this epic's routes, T8's egress re-cuts (the live exporter, the
19
+ * unresolved-suppression export, the campaign-bundle writers) and the CLI's
20
+ * bundle assembly. Nothing re-implements it. The spec's reason is measured
21
+ * history, not tidiness — three separate censuses of "who writes CSV" each
22
+ * missed a writer, and the mayor's own pass found a FOURTH local quoting helper
23
+ * (`dashboard/src/lib/suppression/import-preview.ts:77`) after the gate had
24
+ * finished counting. T8 replaces census with construction: a static audit that
25
+ * fails any non-test file emitting CSV without importing this module.
26
+ *
27
+ * ── DETECT AT INGEST, NEUTRALIZE AT EGRESS ─────────────────────────────────
28
+ *
29
+ * The r1 F1 ruling reversed an earlier design that neutralized on the way IN.
30
+ * Two things killed it. It corrupted legitimate data (the stored cell stopped
31
+ * being what the client uploaded), and it was bypassable: promotion `btrim`s
32
+ * the staged title before the global write
33
+ * (`20260820000017_exa_agent_email_claim_and_dispatch.sql:894`), so any
34
+ * ingest-time FIRST-CHARACTER rule dies to one leading space. Hence:
35
+ *
36
+ * - ingest stores the cell BYTE-IDENTICAL and records `formula_hazard` as
37
+ * metadata beside it (T7-AC2);
38
+ * - that stored flag is INFORMATIONAL ONLY — the review report, counts, a UI
39
+ * cue. It is never an enforcement input, because a stored flag goes stale
40
+ * the moment anything transforms the value (r1 F3 ruling);
41
+ * - enforcement is always a FRESH probe of the FINAL value at the boundary,
42
+ * which is what `serializeCsvRows` does and what T8's provider-payload
43
+ * refusal will do.
44
+ *
45
+ * ── THE PROBE TABLE LIVES IN JSON, NOT IN THIS FILE ────────────────────────
46
+ *
47
+ * `csv-hazard.fixture.json` is the one address for the case table. vitest
48
+ * drives this implementation from it; T8's Python mirror inside
49
+ * `exabase/scripts/crm_push.py` drives the Python implementation from the SAME
50
+ * file. Two runtimes, one table. A second copy of the table would be a
51
+ * duplicated live fact and the two probes would diverge on the first case
52
+ * anyone added.
53
+ */
54
+ /**
55
+ * The four characters a spreadsheet treats as "this cell is a formula".
56
+ * `@` is the Lotus-compatible lead-in Excel still honours; `-` and `+` are the
57
+ * signed-number lead-ins that also start an expression.
58
+ */
59
+ const TRIGGERS = new Set(['=', '+', '-', '@']);
60
+ /**
61
+ * The characters stripped off the FRONT before the trigger test, as one maximal
62
+ * run over the UNION of the classes — not one class at a time.
63
+ *
64
+ * Each class is here because it is invisible to (or skipped by) a spreadsheet
65
+ * while hiding a trigger behind it, and each was measured rather than assumed
66
+ * (see the probe-class table in `csv-safety.test.ts`):
67
+ *
68
+ * \p{White_Space} every Unicode space, plus U+0085 which `\s` misses.
69
+ * \p{Cc} C0 (U+0000-U+001F), DEL and C1 (U+007F-U+009F).
70
+ * \p{Cf} format characters: BOM U+FEFF, the zero-width family
71
+ * U+200B-U+200D, the bidi marks and overrides
72
+ * U+200E/U+200F/U+202A-U+202E and the isolates
73
+ * U+2066-U+2069, soft hyphen U+00AD.
74
+ * ' " ‘ ’ “ ” apostrophes and quotes. AN UPLOADED APOSTROPHE IS DATA,
75
+ * NOT OUR NEUTRALIZATION: honouring it would let whoever
76
+ * wrote the cell decide whether their own cell gets defanged.
77
+ * The serializer always adds its own.
78
+ *
79
+ * STRIPPING MORE CAN ONLY FLAG MORE. No character in this class is a trigger,
80
+ * so widening it never turns a hazard into a pass — the failure direction is
81
+ * false positives, which the spec accepts by name (see `serializeCsvRows`).
82
+ */
83
+ const LEADING_RUN = /^[\p{White_Space}\p{Cc}\p{Cf}'"‘’“”]+/u;
84
+ /** C0/DEL/C1 — the second clause of the rule tests the ORIGINAL against this. */
85
+ const CONTROL = /\p{Cc}/u;
86
+ /**
87
+ * Is this value a CSV formula hazard?
88
+ *
89
+ * THE RULE, verbatim from spec §5.1 step 3: NFKC-fold full-width =+-@;
90
+ * strip the maximal leading run above; hazard iff the next code point is one of
91
+ * the four triggers, OR the ORIGINAL begins with a C0/C1 control.
92
+ *
93
+ * NFKC IS INSPECTION ONLY. It folds = (U+FF1D) to `=`, + to `+`, - to `-`
94
+ * and @ to `@` — measured, not assumed — so a full-width payload cannot walk
95
+ * past a test written against ASCII. The folded string is never stored,
96
+ * exported or returned; callers keep their own value.
97
+ *
98
+ * WHY THE CONTROL CLAUSE TESTS THE ORIGINAL. It is a claim about the bytes the
99
+ * client actually uploaded. Folding first would let a future Unicode revision
100
+ * that maps some code point onto a control decide it, and "the value starts
101
+ * with a control character" is a fact about the input, not about a normal form
102
+ * of it.
103
+ */
104
+ export function isFormulaHazard(value) {
105
+ if (typeof value !== 'string' || value.length === 0)
106
+ return false;
107
+ // Clause 2 first: it reads the ORIGINAL, so no folding may have happened yet.
108
+ const first = String.fromCodePoint(value.codePointAt(0));
109
+ if (CONTROL.test(first))
110
+ return true;
111
+ const rest = value.normalize('NFKC').replace(LEADING_RUN, '');
112
+ if (rest.length === 0)
113
+ return false;
114
+ return TRIGGERS.has(String.fromCodePoint(rest.codePointAt(0)));
115
+ }
116
+ /**
117
+ * Coerce any value a caller holds into the string that will be written.
118
+ *
119
+ * ONE COERCION, DEFINED ONCE. Every adopter needs it, and an adopter-local
120
+ * version is how two exports come to disagree about what `null` looks like.
121
+ * The rules match the live exporter's `escapeCsv`
122
+ * (`api/export/[icpId]/route.ts:10-17`) exactly, so T8's swap changes what a
123
+ * hazardous cell looks like and nothing else.
124
+ */
125
+ export function toCsvValue(value) {
126
+ if (value === null || value === undefined)
127
+ return '';
128
+ if (typeof value === 'string')
129
+ return value;
130
+ if (typeof value === 'object')
131
+ return JSON.stringify(value);
132
+ return String(value);
133
+ }
134
+ /**
135
+ * Serialize ONE cell: always quoted, embedded quotes doubled, a
136
+ * serializer-owned apostrophe inside the quotes when the FINAL value is
137
+ * hazardous.
138
+ *
139
+ * ALWAYS QUOTED, not "quoted when it contains a delimiter". A conditional
140
+ * quote is a second decision that can disagree with the first, and RFC 4180
141
+ * permits quoting every field. It also means a cell can never grow a delimiter
142
+ * meaning through a later edit.
143
+ *
144
+ * THE APOSTROPHE GOES INSIDE THE QUOTES AND IS NOT DOUBLED. It is the
145
+ * serializer's own character, not part of the value: `'` then the escaped
146
+ * value, all inside one pair of quotes. A reader that strips it gets the
147
+ * original back.
148
+ */
149
+ export function serializeCsvCell(value) {
150
+ const str = toCsvValue(value);
151
+ const prefix = isFormulaHazard(str) ? "'" : '';
152
+ return `"${prefix}${str.replace(/"/g, '""')}"`;
153
+ }
154
+ /**
155
+ * Serialize a whole document: comma delimiter, CRLF records, every cell through
156
+ * `serializeCsvCell`.
157
+ *
158
+ * HEADERS SERIALIZE AS DATA. There is no header parameter. A header row is
159
+ * `rows[0]`, quoted and probed exactly like any other row — a header is a cell
160
+ * an attacker can often choose (a merge-var name, a column label), and a
161
+ * serializer with a privileged header path is a serializer with an unprobed
162
+ * one.
163
+ *
164
+ * TRAILING CRLF ON EVERY RECORD, including the last. RFC 4180 allows either;
165
+ * emitting it unconditionally means there is no last-row branch to get wrong.
166
+ *
167
+ * THE ACCEPTED COST, stated where it is paid: benign leading `-`/`+` data
168
+ * (phone numbers, "-5 Main St") gains an apostrophe in the export. That is the
169
+ * OWASP-documented integrity cost of CSV hazard handling, ruled acceptable in
170
+ * §5.1 step 3, and it costs nothing in the database — the stored value stays
171
+ * byte-identical either way.
172
+ *
173
+ * THE HONESTY BOUNDARY, also from the spec: neutralization is best-effort
174
+ * across spreadsheet consumers. LibreOffice import options can still evaluate.
175
+ * If "cannot execute anywhere" ever becomes a hard requirement, the answer is
176
+ * XLSX with explicit string cells, which v2 excludes by ruling.
177
+ */
178
+ export function serializeCsvRows(rows) {
179
+ return rows.map((row) => row.map(serializeCsvCell).join(',') + '\r\n').join('');
180
+ }
181
+ //# sourceMappingURL=csv-safety.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"csv-safety.js","sourceRoot":"","sources":["../../src/generated/csv-safety.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AAEH;;;;GAIG;AACH,MAAM,QAAQ,GAAG,IAAI,GAAG,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,CAAC,CAAA;AAE9C;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,WAAW,GAAG,wCAAwC,CAAA;AAE5D,iFAAiF;AACjF,MAAM,OAAO,GAAG,SAAS,CAAA;AAEzB;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,UAAU,eAAe,CAAC,KAAa;IAC3C,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,KAAK,CAAA;IAEjE,8EAA8E;IAC9E,MAAM,KAAK,GAAG,MAAM,CAAC,aAAa,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC,CAAW,CAAC,CAAA;IAClE,IAAI,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC;QAAE,OAAO,IAAI,CAAA;IAEpC,MAAM,IAAI,GAAG,KAAK,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,OAAO,CAAC,WAAW,EAAE,EAAE,CAAC,CAAA;IAC7D,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,KAAK,CAAA;IACnC,OAAO,QAAQ,CAAC,GAAG,CAAC,MAAM,CAAC,aAAa,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC,CAAW,CAAC,CAAC,CAAA;AAC1E,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,UAAU,CAAC,KAAc;IACvC,IAAI,KAAK,KAAK,IAAI,IAAI,KAAK,KAAK,SAAS;QAAE,OAAO,EAAE,CAAA;IACpD,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,KAAK,CAAA;IAC3C,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAA;IAC3D,OAAO,MAAM,CAAC,KAAK,CAAC,CAAA;AACtB,CAAC;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,gBAAgB,CAAC,KAAc;IAC7C,MAAM,GAAG,GAAG,UAAU,CAAC,KAAK,CAAC,CAAA;IAC7B,MAAM,MAAM,GAAG,eAAe,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAA;IAC9C,OAAO,IAAI,MAAM,GAAG,GAAG,CAAC,OAAO,CAAC,IAAI,EAAE,IAAI,CAAC,GAAG,CAAA;AAChD,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAM,UAAU,gBAAgB,CAAC,IAAqC;IACpE,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,gBAAgB,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,MAAM,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;AACjF,CAAC"}
@@ -0,0 +1,184 @@
1
+ /**
2
+ * GENERATED FILE — DO NOT EDIT.
3
+ *
4
+ * Source of truth: dashboard/src/lib/exa-agent/csv-safety.ts
5
+ * Regenerate: npm run generate:agent-surface (from dashboard/)
6
+ * Guard: npm run guard:agent-surface (CI re-derives and diffs)
7
+ *
8
+ * §5.1a requires ONE serializer for every CSV egress. The CLI is a separate
9
+ * npm package that publishes only dist/, so it cannot import across the
10
+ * repo; it gets a generated mirror instead, on the same terms as
11
+ * commands.json. Edit the source above, never this file.
12
+ */
13
+
14
+ /**
15
+ * THE CANONICAL CSV SAFETY LIB — one hazard probe, one serializer (spec §5.1
16
+ * step 3 and §5.1a; ticket ga-mfacl.8 / T7, ACs 2 and 2b).
17
+ *
18
+ * ONE IMPLEMENTATION. Everything that emits CSV out of this product imports
19
+ * from here: this epic's routes, T8's egress re-cuts (the live exporter, the
20
+ * unresolved-suppression export, the campaign-bundle writers) and the CLI's
21
+ * bundle assembly. Nothing re-implements it. The spec's reason is measured
22
+ * history, not tidiness — three separate censuses of "who writes CSV" each
23
+ * missed a writer, and the mayor's own pass found a FOURTH local quoting helper
24
+ * (`dashboard/src/lib/suppression/import-preview.ts:77`) after the gate had
25
+ * finished counting. T8 replaces census with construction: a static audit that
26
+ * fails any non-test file emitting CSV without importing this module.
27
+ *
28
+ * ── DETECT AT INGEST, NEUTRALIZE AT EGRESS ─────────────────────────────────
29
+ *
30
+ * The r1 F1 ruling reversed an earlier design that neutralized on the way IN.
31
+ * Two things killed it. It corrupted legitimate data (the stored cell stopped
32
+ * being what the client uploaded), and it was bypassable: promotion `btrim`s
33
+ * the staged title before the global write
34
+ * (`20260820000017_exa_agent_email_claim_and_dispatch.sql:894`), so any
35
+ * ingest-time FIRST-CHARACTER rule dies to one leading space. Hence:
36
+ *
37
+ * - ingest stores the cell BYTE-IDENTICAL and records `formula_hazard` as
38
+ * metadata beside it (T7-AC2);
39
+ * - that stored flag is INFORMATIONAL ONLY — the review report, counts, a UI
40
+ * cue. It is never an enforcement input, because a stored flag goes stale
41
+ * the moment anything transforms the value (r1 F3 ruling);
42
+ * - enforcement is always a FRESH probe of the FINAL value at the boundary,
43
+ * which is what `serializeCsvRows` does and what T8's provider-payload
44
+ * refusal will do.
45
+ *
46
+ * ── THE PROBE TABLE LIVES IN JSON, NOT IN THIS FILE ────────────────────────
47
+ *
48
+ * `csv-hazard.fixture.json` is the one address for the case table. vitest
49
+ * drives this implementation from it; T8's Python mirror inside
50
+ * `exabase/scripts/crm_push.py` drives the Python implementation from the SAME
51
+ * file. Two runtimes, one table. A second copy of the table would be a
52
+ * duplicated live fact and the two probes would diverge on the first case
53
+ * anyone added.
54
+ */
55
+
56
+ /**
57
+ * The four characters a spreadsheet treats as "this cell is a formula".
58
+ * `@` is the Lotus-compatible lead-in Excel still honours; `-` and `+` are the
59
+ * signed-number lead-ins that also start an expression.
60
+ */
61
+ const TRIGGERS = new Set(['=', '+', '-', '@'])
62
+
63
+ /**
64
+ * The characters stripped off the FRONT before the trigger test, as one maximal
65
+ * run over the UNION of the classes — not one class at a time.
66
+ *
67
+ * Each class is here because it is invisible to (or skipped by) a spreadsheet
68
+ * while hiding a trigger behind it, and each was measured rather than assumed
69
+ * (see the probe-class table in `csv-safety.test.ts`):
70
+ *
71
+ * \p{White_Space} every Unicode space, plus U+0085 which `\s` misses.
72
+ * \p{Cc} C0 (U+0000-U+001F), DEL and C1 (U+007F-U+009F).
73
+ * \p{Cf} format characters: BOM U+FEFF, the zero-width family
74
+ * U+200B-U+200D, the bidi marks and overrides
75
+ * U+200E/U+200F/U+202A-U+202E and the isolates
76
+ * U+2066-U+2069, soft hyphen U+00AD.
77
+ * ' " ‘ ’ “ ” apostrophes and quotes. AN UPLOADED APOSTROPHE IS DATA,
78
+ * NOT OUR NEUTRALIZATION: honouring it would let whoever
79
+ * wrote the cell decide whether their own cell gets defanged.
80
+ * The serializer always adds its own.
81
+ *
82
+ * STRIPPING MORE CAN ONLY FLAG MORE. No character in this class is a trigger,
83
+ * so widening it never turns a hazard into a pass — the failure direction is
84
+ * false positives, which the spec accepts by name (see `serializeCsvRows`).
85
+ */
86
+ const LEADING_RUN = /^[\p{White_Space}\p{Cc}\p{Cf}'"‘’“”]+/u
87
+
88
+ /** C0/DEL/C1 — the second clause of the rule tests the ORIGINAL against this. */
89
+ const CONTROL = /\p{Cc}/u
90
+
91
+ /**
92
+ * Is this value a CSV formula hazard?
93
+ *
94
+ * THE RULE, verbatim from spec §5.1 step 3: NFKC-fold full-width =+-@;
95
+ * strip the maximal leading run above; hazard iff the next code point is one of
96
+ * the four triggers, OR the ORIGINAL begins with a C0/C1 control.
97
+ *
98
+ * NFKC IS INSPECTION ONLY. It folds = (U+FF1D) to `=`, + to `+`, - to `-`
99
+ * and @ to `@` — measured, not assumed — so a full-width payload cannot walk
100
+ * past a test written against ASCII. The folded string is never stored,
101
+ * exported or returned; callers keep their own value.
102
+ *
103
+ * WHY THE CONTROL CLAUSE TESTS THE ORIGINAL. It is a claim about the bytes the
104
+ * client actually uploaded. Folding first would let a future Unicode revision
105
+ * that maps some code point onto a control decide it, and "the value starts
106
+ * with a control character" is a fact about the input, not about a normal form
107
+ * of it.
108
+ */
109
+ export function isFormulaHazard(value: string): boolean {
110
+ if (typeof value !== 'string' || value.length === 0) return false
111
+
112
+ // Clause 2 first: it reads the ORIGINAL, so no folding may have happened yet.
113
+ const first = String.fromCodePoint(value.codePointAt(0) as number)
114
+ if (CONTROL.test(first)) return true
115
+
116
+ const rest = value.normalize('NFKC').replace(LEADING_RUN, '')
117
+ if (rest.length === 0) return false
118
+ return TRIGGERS.has(String.fromCodePoint(rest.codePointAt(0) as number))
119
+ }
120
+
121
+ /**
122
+ * Coerce any value a caller holds into the string that will be written.
123
+ *
124
+ * ONE COERCION, DEFINED ONCE. Every adopter needs it, and an adopter-local
125
+ * version is how two exports come to disagree about what `null` looks like.
126
+ * The rules match the live exporter's `escapeCsv`
127
+ * (`api/export/[icpId]/route.ts:10-17`) exactly, so T8's swap changes what a
128
+ * hazardous cell looks like and nothing else.
129
+ */
130
+ export function toCsvValue(value: unknown): string {
131
+ if (value === null || value === undefined) return ''
132
+ if (typeof value === 'string') return value
133
+ if (typeof value === 'object') return JSON.stringify(value)
134
+ return String(value)
135
+ }
136
+
137
+ /**
138
+ * Serialize ONE cell: always quoted, embedded quotes doubled, a
139
+ * serializer-owned apostrophe inside the quotes when the FINAL value is
140
+ * hazardous.
141
+ *
142
+ * ALWAYS QUOTED, not "quoted when it contains a delimiter". A conditional
143
+ * quote is a second decision that can disagree with the first, and RFC 4180
144
+ * permits quoting every field. It also means a cell can never grow a delimiter
145
+ * meaning through a later edit.
146
+ *
147
+ * THE APOSTROPHE GOES INSIDE THE QUOTES AND IS NOT DOUBLED. It is the
148
+ * serializer's own character, not part of the value: `'` then the escaped
149
+ * value, all inside one pair of quotes. A reader that strips it gets the
150
+ * original back.
151
+ */
152
+ export function serializeCsvCell(value: unknown): string {
153
+ const str = toCsvValue(value)
154
+ const prefix = isFormulaHazard(str) ? "'" : ''
155
+ return `"${prefix}${str.replace(/"/g, '""')}"`
156
+ }
157
+
158
+ /**
159
+ * Serialize a whole document: comma delimiter, CRLF records, every cell through
160
+ * `serializeCsvCell`.
161
+ *
162
+ * HEADERS SERIALIZE AS DATA. There is no header parameter. A header row is
163
+ * `rows[0]`, quoted and probed exactly like any other row — a header is a cell
164
+ * an attacker can often choose (a merge-var name, a column label), and a
165
+ * serializer with a privileged header path is a serializer with an unprobed
166
+ * one.
167
+ *
168
+ * TRAILING CRLF ON EVERY RECORD, including the last. RFC 4180 allows either;
169
+ * emitting it unconditionally means there is no last-row branch to get wrong.
170
+ *
171
+ * THE ACCEPTED COST, stated where it is paid: benign leading `-`/`+` data
172
+ * (phone numbers, "-5 Main St") gains an apostrophe in the export. That is the
173
+ * OWASP-documented integrity cost of CSV hazard handling, ruled acceptable in
174
+ * §5.1 step 3, and it costs nothing in the database — the stored value stays
175
+ * byte-identical either way.
176
+ *
177
+ * THE HONESTY BOUNDARY, also from the spec: neutralization is best-effort
178
+ * across spreadsheet consumers. LibreOffice import options can still evaluate.
179
+ * If "cannot execute anywhere" ever becomes a hard requirement, the answer is
180
+ * XLSX with explicit string cells, which v2 excludes by ruling.
181
+ */
182
+ export function serializeCsvRows(rows: readonly (readonly unknown[])[]): string {
183
+ return rows.map((row) => row.map(serializeCsvCell).join(',') + '\r\n').join('')
184
+ }
package/dist/help.js ADDED
@@ -0,0 +1,133 @@
1
+ import { BASE_URL_ENV_VAR, DEFAULT_BASE_URL, TOKEN_ENV_VAR, CONFIG_DIR_NAME, CONFIG_FILE_NAME } from './config.js';
2
+ /**
3
+ * Help text.
4
+ *
5
+ * NO EXAMPLE HERE EVER PUTS A TOKEN ON A COMMAND LINE (ticket AC5). The
6
+ * authentication note names the environment variable and the config file and
7
+ * stops there; `test/hygiene.test.ts` renders every command's help and asserts
8
+ * it. Writing `--token` into a single example would ship the habit to every
9
+ * user who copies it.
10
+ */
11
+ const AUTH_NOTE = [
12
+ 'Authentication:',
13
+ ` Set ${TOKEN_ENV_VAR} in your environment, or put {"token": "<your token>"} in`,
14
+ ` ~/${CONFIG_DIR_NAME}/${CONFIG_FILE_NAME} with mode 600. The token is never read from the`,
15
+ ' command line — anything you type is visible in your shell history and in ps.',
16
+ ].join('\n');
17
+ const BASE_URL_NOTE = [
18
+ 'Deployment:',
19
+ ` ${DEFAULT_BASE_URL} by default; set ${BASE_URL_ENV_VAR} to talk to a preview.`,
20
+ ].join('\n');
21
+ const EXIT_NOTE = [
22
+ 'Exit codes:',
23
+ ' 0 ok · 1 usage or a rejected request · 2 credential refused · 3 rate limited',
24
+ ' 4 server error or a verb this deployment does not serve yet · 5 quote required',
25
+ ].join('\n');
26
+ /**
27
+ * Soft-wrap a paragraph to `width`, so a long line of coaching does not run off
28
+ * an 80-column terminal. Word boundaries only; a single word longer than the
29
+ * width is left whole rather than cut.
30
+ */
31
+ function wrapAt(text, width) {
32
+ const out = [];
33
+ let line = '';
34
+ for (const word of text.split(/\s+/)) {
35
+ if (line === '')
36
+ line = word;
37
+ else if (line.length + 1 + word.length <= width)
38
+ line += ` ${word}`;
39
+ else {
40
+ out.push(line);
41
+ line = word;
42
+ }
43
+ }
44
+ if (line !== '')
45
+ out.push(line);
46
+ return out.join('\n');
47
+ }
48
+ function flagLine(name, placeholder, describe) {
49
+ const left = placeholder ? `--${name} ${placeholder}` : `--${name}`;
50
+ return ` ${left.padEnd(24)}${describe}`;
51
+ }
52
+ export function renderCommandHelp(spec) {
53
+ const lines = [];
54
+ lines.push(`predictable ${spec.name} — ${spec.summary}`);
55
+ lines.push('');
56
+ lines.push(`Usage: predictable ${spec.name}${spec.params.length > 0 ? ' [flags]' : ''}`);
57
+ if (spec.params.length > 0) {
58
+ lines.push('');
59
+ lines.push('Flags:');
60
+ for (const param of spec.params) {
61
+ const suffix = param.required ? ' (required)' : '';
62
+ lines.push(flagLine(param.name, param.boolean ? '' : param.placeholder, `${param.describe}${suffix}`));
63
+ }
64
+ }
65
+ const actions = [...new Set(spec.routes.filter((r) => r.action).map((r) => r.action))];
66
+ if (actions.length > 1) {
67
+ lines.push(flagLine('action', `<${actions.join('|')}>`, `Which call to make. Default: ${spec.defaultAction}.`));
68
+ }
69
+ // THE COACHING GOES ABOVE THE FLAGS, not below the routes (T15 §15.1, AC2).
70
+ // A reader who has typed `--help` because they are about to run the verb sees
71
+ // it before the mechanics, which is the only position where guidance about
72
+ // WHAT to ask for can still change what they type.
73
+ //
74
+ // The text is the MANIFEST's — `buildRegistry` copies it off the artifact and
75
+ // nothing here writes one. `cli/test/guide.test.ts` plants a divergence and
76
+ // requires it to go red.
77
+ if (spec.guidance) {
78
+ lines.push('');
79
+ lines.push(wrapAt(spec.guidance, 76));
80
+ }
81
+ lines.push('');
82
+ lines.push('Common flags:');
83
+ lines.push(flagLine('json', '', "Print the server's response body verbatim."));
84
+ lines.push(flagLine('help', '', 'Show this message.'));
85
+ if (spec.mode === 'paid') {
86
+ lines.push('');
87
+ lines.push('This verb spends credits, and it is two steps:');
88
+ lines.push(` 1. predictable ${spec.name} --preview [flags] prints a quote id, its credits and its expiry`);
89
+ lines.push(` 2. predictable ${spec.name} --quote <id> [flags] spends against that quote`);
90
+ lines.push(' Without --quote, step 2 refuses locally and sends nothing.');
91
+ }
92
+ if (spec.routes.length > 0) {
93
+ lines.push('');
94
+ lines.push('Routes:');
95
+ for (const route of spec.routes)
96
+ lines.push(` ${route.method} ${route.path}`);
97
+ }
98
+ lines.push('');
99
+ lines.push(AUTH_NOTE);
100
+ lines.push('');
101
+ lines.push(BASE_URL_NOTE);
102
+ return lines.join('\n');
103
+ }
104
+ export function renderRootHelp(specs, version, surfaceVersion) {
105
+ const lines = [];
106
+ lines.push(`predictable ${version} — the Predictable agent surface, on the command line.`);
107
+ lines.push(`Agent surface ${surfaceVersion}.`);
108
+ lines.push('');
109
+ lines.push('Usage: predictable <command> [flags]');
110
+ lines.push(' predictable <command> --help');
111
+ lines.push('');
112
+ const packs = [...new Set(specs.map((s) => s.pack))];
113
+ for (const pack of packs) {
114
+ lines.push(`${pack} commands:`);
115
+ for (const spec of specs.filter((s) => s.pack === pack)) {
116
+ const paid = spec.mode === 'paid' ? ' [spends credits]' : '';
117
+ lines.push(` ${spec.name.padEnd(22)}${spec.summary}${paid}`);
118
+ }
119
+ lines.push('');
120
+ }
121
+ lines.push('Global:');
122
+ lines.push(flagLine('version', '', 'Print this client and the deployment surface version.'));
123
+ lines.push(flagLine('json', '', "Print the server's response body verbatim."));
124
+ lines.push(flagLine('help', '', 'Show this message.'));
125
+ lines.push('');
126
+ lines.push(AUTH_NOTE);
127
+ lines.push('');
128
+ lines.push(BASE_URL_NOTE);
129
+ lines.push('');
130
+ lines.push(EXIT_NOTE);
131
+ return lines.join('\n');
132
+ }
133
+ //# sourceMappingURL=help.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"help.js","sourceRoot":"","sources":["../src/help.ts"],"names":[],"mappings":"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"}
package/dist/http.js ADDED
@@ -0,0 +1,146 @@
1
+ import { CliError, EXIT } from './errors.js';
2
+ /**
3
+ * Join the deployment's base URL and a route path.
4
+ *
5
+ * The base URL may carry a PATH of its own — a preview served under a prefix,
6
+ * or a proxy that mounts the API somewhere other than the root. `new URL(path,
7
+ * base)` with an absolute `/api/...` path discards that prefix silently and
8
+ * sends the request to the wrong place, so the prefix is joined explicitly.
9
+ */
10
+ export function buildUrl(baseUrl, path, query) {
11
+ const base = new URL(baseUrl);
12
+ const prefix = base.pathname.replace(/\/+$/, '');
13
+ // Rebuilt from the base URL itself, not from its origin: an origin drops any
14
+ // userinfo the base carried, and dropping half a URL is the failure this is
15
+ // fixing, not a smaller version of it. Assigning `pathname` leaves the
16
+ // caller's percent-escapes alone.
17
+ const url = new URL(base.toString());
18
+ // ga-paal4 item 2: SPLIT THE PATH FROM ITS QUERY FIRST. Assigning `pathname`
19
+ // says "all of this is a path", so a `?` inside `path` is percent-encoded to
20
+ // %3F and the request goes to a literal segment ending `...%3Ftarget=5` while
21
+ // the server sees no query at all — a silently dropped parameter on a client
22
+ // whose whole job is calling parameterized routes.
23
+ //
24
+ // Split on the FIRST unescaped `?` only. An already-escaped %3F is pathname
25
+ // data the caller asked for and stays escaped; everything after the first `?`
26
+ // is the query, including any later `?` characters, which is what a URL parser
27
+ // does. Assigning `pathname` still leaves the caller's other percent-escapes
28
+ // alone.
29
+ const split = path.indexOf('?');
30
+ const pathPart = split === -1 ? path : path.slice(0, split);
31
+ const embeddedQuery = split === -1 ? '' : path.slice(split + 1);
32
+ url.pathname = `${prefix}/${pathPart.replace(/^\/+/, '')}`;
33
+ url.search = '';
34
+ url.hash = '';
35
+ // The embedded query goes on FIRST so an explicit `query` entry overrides it:
36
+ // the caller passing a parameter by name is the more specific statement, and
37
+ // leaving the precedence to whichever assignment happened to run second is how
38
+ // two sources for one parameter become a coin flip.
39
+ for (const [key, value] of new URLSearchParams(embeddedQuery)) {
40
+ if (value !== '')
41
+ url.searchParams.set(key, value);
42
+ }
43
+ for (const [key, value] of Object.entries(query ?? {})) {
44
+ if (value !== undefined && value !== '')
45
+ url.searchParams.set(key, value);
46
+ }
47
+ return url.toString();
48
+ }
49
+ function serverErrorCode(text) {
50
+ try {
51
+ const parsed = JSON.parse(text);
52
+ if (typeof parsed.error === 'string')
53
+ return parsed.error;
54
+ if (typeof parsed.reason === 'string')
55
+ return parsed.reason;
56
+ }
57
+ catch {
58
+ /* a non-JSON body has no code to name */
59
+ }
60
+ return undefined;
61
+ }
62
+ /** Send one request. Transport failures become a server-class CliError. */
63
+ export async function send(ctx, call) {
64
+ // A supplied transport replaces the network entirely — no base URL is joined
65
+ // and no bearer is attached, because an in-process caller has neither and
66
+ // must not be handed a credential it did not present.
67
+ if (ctx.transport)
68
+ return ctx.transport(call);
69
+ const headers = { accept: 'application/json', ...ctx.extraHeaders };
70
+ if (ctx.token)
71
+ headers.authorization = `Bearer ${ctx.token}`;
72
+ if (call.body !== undefined)
73
+ headers['content-type'] = 'application/json';
74
+ let response;
75
+ try {
76
+ response = await fetch(buildUrl(ctx.baseUrl, call.path, call.query), {
77
+ method: call.method,
78
+ headers,
79
+ ...(call.body !== undefined ? { body: call.body } : {}),
80
+ });
81
+ }
82
+ catch (cause) {
83
+ throw new CliError(EXIT.SERVER, `could not reach ${ctx.baseUrl}`, cause instanceof Error ? cause.message : String(cause));
84
+ }
85
+ return { status: response.status, headers: response.headers, text: await response.text() };
86
+ }
87
+ /**
88
+ * Turn a non-2xx into the CliError its status calls for.
89
+ *
90
+ * `unavailable` is set when the deployment's own manifest says this route is
91
+ * pending or gated: a 404 then means "your client is newer than this
92
+ * deployment", which is a different fact from "that id does not exist", and
93
+ * saying so is the difference between a usable message and a raw 404.
94
+ */
95
+ export function classify(response, call, unavailable) {
96
+ if (response.status >= 200 && response.status < 300)
97
+ return undefined;
98
+ const code = serverErrorCode(response.text);
99
+ const where = `${call.method} ${call.template ?? call.path}`;
100
+ if (response.status === 401 || response.status === 403) {
101
+ return new CliError(EXIT.AUTH, `error: ${code ?? 'unauthorized'}`, `${where} → ${response.status}`);
102
+ }
103
+ if (response.status === 429) {
104
+ const retry = response.headers.get('retry-after');
105
+ return new CliError(EXIT.RATE_LIMITED, `error: ${code ?? 'rate_limited'}`, retry ? `retry after ${retry}s (${where})` : where);
106
+ }
107
+ if (response.status === 404 && unavailable) {
108
+ return new CliError(EXIT.SERVER, 'not available on this deployment yet', unavailable);
109
+ }
110
+ if (response.status >= 500) {
111
+ return new CliError(EXIT.SERVER, `error: ${code ?? `server ${response.status}`}`, where);
112
+ }
113
+ return new CliError(EXIT.USAGE, `error: ${code ?? `request rejected (${response.status})`}`, where);
114
+ }
115
+ /**
116
+ * Ask the deployment whether it serves a route yet.
117
+ *
118
+ * Best effort by design: it needs a token and a reachable server, and it runs
119
+ * only after a 404 has already happened, so a failure here just means the
120
+ * caller gets the plain 404 message it would have got anyway.
121
+ */
122
+ export async function routeUnavailableReason(ctx, route) {
123
+ if (!ctx.token)
124
+ return undefined;
125
+ let manifest;
126
+ try {
127
+ const response = await send(ctx, { method: 'GET', path: '/api/agent/manifest' });
128
+ if (response.status !== 200)
129
+ return undefined;
130
+ manifest = JSON.parse(response.text);
131
+ }
132
+ catch {
133
+ return undefined;
134
+ }
135
+ const key = `${route.method} ${route.path}`;
136
+ if (manifest.pending?.includes(key)) {
137
+ return `${key} has no route file on this deployment yet (manifest version ${manifest.version}).`;
138
+ }
139
+ const gated = manifest.gated ?? [];
140
+ const hit = gated.find((g) => g.method === route.method && g.path === route.path);
141
+ if (hit) {
142
+ return `${key} is held out of this deployment's allowlist until ${hit.gatedUntil ?? 'a later slice'}.`;
143
+ }
144
+ return undefined;
145
+ }
146
+ //# sourceMappingURL=http.js.map