@plantnet/planttaxomatcher 0.1.3 → 0.3.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/dist/index.js CHANGED
@@ -1,11 +1,19 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { promises as fs4 } from "fs";
5
- import { parse as parsePath } from "path";
6
- import { confirm, password } from "@inquirer/prompts";
7
- import { Command } from "commander";
8
- import kleur2 from "kleur";
4
+ import { createRequire } from "module";
5
+
6
+ // src/deps.ts
7
+ import { spawn } from "child_process";
8
+ import { homedir as homedir2, hostname } from "os";
9
+ import { fileURLToPath } from "url";
10
+ import { setTimeout as sleep } from "timers/promises";
11
+ import { checkbox, confirm } from "@inquirer/prompts";
12
+
13
+ // src/api-client.ts
14
+ import { openAsBlob } from "fs";
15
+ import { basename } from "path";
16
+ import { FormData, fetch } from "undici";
9
17
 
10
18
  // ../shared/src/schemas/identifiers.ts
11
19
  import { z } from "zod";
@@ -31,7 +39,9 @@ var evidenceTypeSchema = z2.enum([
31
39
  "local_fuzzy",
32
40
  "external_fuzzy",
33
41
  "team_history",
34
- "llm"
42
+ "llm",
43
+ // Resolved via the OTHER backbone (WFO↔WCVP) then mapped back to the target.
44
+ "cross_backbone"
35
45
  ]);
36
46
  var gradeSchema = z2.enum(["A", "B", "C"]);
37
47
  var reviewStatusSchema = z2.enum(["not_required", "pending", "accepted", "rejected", "overridden"]);
@@ -59,8 +69,80 @@ var scoreBreakdownSchema = z2.object({
59
69
  });
60
70
 
61
71
  // ../shared/src/schemas/job.ts
72
+ import { z as z4 } from "zod";
73
+
74
+ // ../shared/src/schemas/plugins.ts
62
75
  import { z as z3 } from "zod";
63
- var jobStatusSchema = z3.enum([
76
+ var verifierStateSchema = z3.enum(["pass", "fail", "error", "skipped"]);
77
+ var verifierColumnKindSchema = z3.enum(["check", "badge", "link", "text"]);
78
+ var verifierColumnSchema = z3.object({
79
+ /** Export column id, e.g. `verify_plantnet`. */
80
+ key: z3.string(),
81
+ /** Human-facing header. */
82
+ header: z3.string(),
83
+ kind: verifierColumnKindSchema
84
+ });
85
+ var pluginRunStatusSchema = z3.enum(["queued", "running", "completed", "failed", "cancelled"]);
86
+ var pluginRunTriggerSchema = z3.enum(["auto", "manual"]);
87
+ var pluginConfigOptionSchema = z3.object({
88
+ value: z3.string(),
89
+ label: z3.string()
90
+ });
91
+ var pluginConfigFieldSchema = z3.object({
92
+ key: z3.string(),
93
+ label: z3.string(),
94
+ type: z3.literal("select"),
95
+ options: z3.array(pluginConfigOptionSchema).min(1),
96
+ default: z3.string()
97
+ });
98
+ var pluginDescriptorSchema = z3.object({
99
+ id: z3.string(),
100
+ version: z3.string(),
101
+ title: z3.string(),
102
+ description: z3.string(),
103
+ /** Deployment can actually run it (key/env present). */
104
+ available: z3.boolean(),
105
+ columns: z3.array(verifierColumnSchema),
106
+ configFields: z3.array(pluginConfigFieldSchema)
107
+ });
108
+ var pluginRunRequestSchema = z3.object({
109
+ config: z3.record(z3.string(), z3.string()).default({})
110
+ });
111
+ var pluginRunSummarySchema = z3.object({
112
+ id: z3.string().uuid(),
113
+ jobId: z3.string().ulid(),
114
+ pluginId: z3.string(),
115
+ pluginVersion: z3.string(),
116
+ config: z3.record(z3.string(), z3.unknown()),
117
+ status: pluginRunStatusSchema,
118
+ trigger: pluginRunTriggerSchema,
119
+ totalUnits: z3.number().int().nonnegative(),
120
+ processedUnits: z3.number().int().nonnegative(),
121
+ passUnits: z3.number().int().nonnegative(),
122
+ errorUnits: z3.number().int().nonnegative(),
123
+ /** Label of the token that triggered a manual run; null for auto runs. */
124
+ triggeredBy: z3.string().nullable(),
125
+ /** `failed` run: why it stopped. `completed` run: why its errored rows
126
+ * couldn't be checked (most frequent reasons first). */
127
+ error: z3.string().nullable(),
128
+ createdAt: z3.string().datetime(),
129
+ startedAt: z3.string().datetime().nullable(),
130
+ completedAt: z3.string().datetime().nullable()
131
+ });
132
+ var pluginRunsResponseSchema = z3.object({
133
+ runs: z3.array(pluginRunSummarySchema)
134
+ });
135
+ var jobRowPluginResultSchema = z3.object({
136
+ pluginId: z3.string(),
137
+ state: verifierStateSchema,
138
+ label: z3.string().nullable(),
139
+ url: z3.string().nullable(),
140
+ /** Why this verdict — the error reason, or what a `fail` was checked against. */
141
+ detail: z3.string().nullable()
142
+ });
143
+
144
+ // ../shared/src/schemas/job.ts
145
+ var jobStatusSchema = z4.enum([
64
146
  "queued",
65
147
  "parsing",
66
148
  "matching",
@@ -69,39 +151,90 @@ var jobStatusSchema = z3.enum([
69
151
  "completed",
70
152
  "failed"
71
153
  ]);
72
- var authorModeSchema = z3.enum(["ignore", "prefer", "strict"]);
73
- var reviewModeSchema = z3.enum(["off", "recommended", "strict"]);
74
- var jobConfigSchema = z3.object({
75
- nameColumn: z3.string().min(1),
76
- idColumn: z3.string().nullable().optional(),
77
- familyColumn: z3.string().nullable().optional(),
78
- genusColumn: z3.string().nullable().optional(),
79
- rankColumn: z3.string().nullable().optional(),
80
- authorColumn: z3.string().nullable().optional(),
81
- sourceReferentialColumn: z3.string().nullable().optional(),
82
- sourceIdColumn: z3.string().nullable().optional(),
154
+ var authorModeSchema = z4.enum(["ignore", "prefer", "strict"]);
155
+ var reviewModeSchema = z4.enum(["off", "recommended", "strict"]);
156
+ var idTypeOptionSchema = z4.enum(["auto", "wcvp", "gbif"]);
157
+ var jobConfigSchema = z4.object({
158
+ nameColumn: z4.string().min(1),
159
+ idColumn: z4.string().nullable().optional(),
160
+ idType: idTypeOptionSchema.default("auto").optional(),
161
+ familyColumn: z4.string().nullable().optional(),
162
+ genusColumn: z4.string().nullable().optional(),
163
+ rankColumn: z4.string().nullable().optional(),
164
+ authorColumn: z4.string().nullable().optional(),
165
+ sourceReferentialColumn: z4.string().nullable().optional(),
166
+ sourceIdColumn: z4.string().nullable().optional(),
83
167
  authorMode: authorModeSchema.default("prefer"),
84
- matchAuthors: z3.boolean().default(true),
85
- parallelism: z3.number().int().min(1).max(10).default(4),
86
- allowFuzzy: z3.boolean().default(true),
87
- allowLlm: z3.boolean().default(false),
88
- llmCostCapCents: z3.number().int().min(0).default(500),
168
+ matchAuthors: z4.boolean().default(true),
169
+ parallelism: z4.number().int().min(1).max(10).default(4),
170
+ allowFuzzy: z4.boolean().default(true),
171
+ allowLlm: z4.boolean().default(false),
172
+ llmCostCapCents: z4.number().int().min(0).default(500),
89
173
  reviewMode: reviewModeSchema.default("recommended"),
90
- exportConfirmedOnly: z3.boolean().default(false),
174
+ exportConfirmedOnly: z4.boolean().default(false),
175
+ /**
176
+ * When false, this job's review decisions (accept + reject) do NOT feed the
177
+ * team's Layer 0.5 match-history cache — a one-off or experimental job can't
178
+ * teach (or poison) the shared cache. Default false (opt-in).
179
+ */
180
+ contributeToTeamCache: z4.boolean().default(false),
181
+ /**
182
+ * Roll infraspecific results up to the species: when an input resolves to an
183
+ * infraspecific accepted taxon (Variety / Subspecies / Form / …), replace it
184
+ * with its parent Species so every output row sits at species level.
185
+ *
186
+ * Default FALSE — the resolved rank is preserved as-is. Rolling up discards
187
+ * information the source data carried, so it is opt-in: turn it on only when
188
+ * the consuming system works at species level and you would otherwise have to
189
+ * flatten the export yourself.
190
+ */
191
+ speciesLevelAcceptedOnly: z4.boolean().default(false),
192
+ /**
193
+ * "The author isn't important." When the canonical name matches exactly one
194
+ * taxon but the input's author could not be CONFIRMED (flag
195
+ * `author-unconfirmed`, or `author-uncomparable` when it can't be compared
196
+ * at all), auto-accept the match instead of sending it to
197
+ * review. The taxon itself was never in doubt in that case — only whether
198
+ * the author string cites it the way WCVP does — so a dataset whose author
199
+ * column is unreliable (or absent from the source) can skip that queue.
200
+ *
201
+ * Default FALSE. This does NOT relax a genuine author CONFLICT
202
+ * (`author-mismatch`): a conflicting author may point at a different plant,
203
+ * so those still go to review. Every other review trigger (qualifiers,
204
+ * parse quality, force-review families, alternatives) is untouched.
205
+ */
206
+ acceptUnconfirmedAuthor: z4.boolean().default(false),
207
+ /**
208
+ * Which taxonomic backbone to match against. `wcvp` (default) runs the full
209
+ * cascade; `wfo` matches against the World Flora Online snapshot using the
210
+ * local layers (L1–L4). Stored on `jobs.referential`.
211
+ */
212
+ referential: z4.enum(["wcvp", "wfo"]).default("wcvp"),
91
213
  /**
92
- * Keep the identified accepted name at species level: when an input
93
- * resolves to an infraspecific accepted taxon (Variety / Subspecies /
94
- * Form / …), collapse it up to its parent Species. Default true; set false
95
- * to keep the exact infraspecific accepted taxon.
214
+ * Which snapshot version of the chosen backbone to match against. When
215
+ * omitted, the API resolves it at submit time: the team's configured default
216
+ * snapshot for this backbone (Admin → Backbone) when it is still installed,
217
+ * otherwise the most-recently-imported one. The job stores the resolved
218
+ * version on `jobs.referential_version` so re-runs are reproducible even if
219
+ * a newer snapshot lands later.
96
220
  */
97
- speciesLevelAcceptedOnly: z3.boolean().default(true),
221
+ referentialVersion: z4.string().min(1).optional(),
98
222
  /**
99
- * Which WCVP snapshot to match against. When omitted, the API picks the
100
- * most-recently-imported snapshot at submit time. The job stores the
101
- * resolved version on `jobs.referential_version` so re-runs are
102
- * reproducible even if a newer snapshot lands later.
223
+ * WGSRPD Level-3 area code (e.g. 'MAS' = Massachusetts) the import is scoped
224
+ * to. When set, an ambiguous multi-candidate match is narrowed to the taxa
225
+ * that occur in this area — exactly one survivor resolves the match (Grade B,
226
+ * flagged `resolved-by-area:<code>`). null/omitted = no disambiguation.
103
227
  */
104
- referentialVersion: z3.string().min(1).optional(),
228
+ area: z4.string().nullable().optional(),
229
+ /**
230
+ * Auto-run the Pl@ntNet verification plugin for this job at completion —
231
+ * tags each match with whether its accepted taxon aligns to a Pl@ntNet
232
+ * species. Defaults on; set false to skip the auto-run for this job (it can
233
+ * still be triggered manually from the Plugins menu). Only has an effect
234
+ * when the server has Pl@ntNet configured (key set + not globally disabled);
235
+ * otherwise it is a no-op regardless.
236
+ */
237
+ plantnet: z4.boolean().optional(),
105
238
  /**
106
239
  * Row filter: keep only rows whose `filterColumn` value equals
107
240
  * `filterValue` (trimmed, case-insensitive); all other rows are skipped at
@@ -109,306 +242,498 @@ var jobConfigSchema = z3.object({
109
242
  * apply. Lets a user match a subset of a mixed file (e.g. only
110
243
  * `kingdom = Plantae`).
111
244
  */
112
- filterColumn: z3.string().nullable().optional(),
113
- filterValue: z3.string().nullable().optional()
245
+ filterColumn: z4.string().nullable().optional(),
246
+ filterValue: z4.string().nullable().optional()
114
247
  });
115
- var wcvpSnapshotSummarySchema = z3.object({
116
- version: z3.string(),
117
- recordCount: z3.number().int().nonnegative(),
118
- importedAt: z3.string().datetime(),
119
- isLatest: z3.boolean()
120
- });
121
- var idTypeDetectionSchema = z3.object({
122
- sampleSize: z3.number().int().nonnegative(),
123
- counts: z3.record(z3.string(), z3.number().int().nonnegative()),
124
- dominant: z3.string().nullable(),
125
- dominantConfidence: z3.number().min(0).max(1),
126
- minorityExamples: z3.array(
127
- z3.object({
128
- rowIndex: z3.number().int().nonnegative(),
129
- value: z3.string(),
130
- type: z3.string()
248
+ var wcvpSnapshotSummarySchema = z4.object({
249
+ version: z4.string(),
250
+ recordCount: z4.number().int().nonnegative(),
251
+ importedAt: z4.string().datetime(),
252
+ /** Newest OFFICIAL snapshot (derived ones never count as latest). */
253
+ isLatest: z4.boolean(),
254
+ kind: z4.enum(["official", "derived"]),
255
+ baseVersion: z4.string().nullable(),
256
+ label: z4.string().nullable(),
257
+ ownerTeamId: z4.string().nullable()
258
+ });
259
+ var idTypeDetectionSchema = z4.object({
260
+ sampleSize: z4.number().int().nonnegative(),
261
+ counts: z4.record(z4.string(), z4.number().int().nonnegative()),
262
+ dominant: z4.string().nullable(),
263
+ dominantConfidence: z4.number().min(0).max(1),
264
+ minorityExamples: z4.array(
265
+ z4.object({
266
+ rowIndex: z4.number().int().nonnegative(),
267
+ value: z4.string(),
268
+ type: z4.string()
131
269
  })
132
270
  )
133
271
  });
134
- var publicAccessSchema = z3.enum(["none", "read", "review"]);
135
- var jobSummarySchema = z3.object({
136
- id: z3.string().ulid(),
137
- teamId: z3.string().uuid(),
138
- userId: z3.string().uuid(),
272
+ var publicAccessSchema = z4.enum(["none", "read", "review"]);
273
+ var jobSummarySchema = z4.object({
274
+ id: z4.string().ulid(),
275
+ teamId: z4.string().uuid(),
276
+ userId: z4.string().uuid(),
139
277
  /** Optional user-supplied label. URL still uses `id`; null when unset. */
140
- name: z3.string().nullable(),
141
- /** Display name of the user who submitted the job (from their token's
142
- * user). Null when unavailable (e.g. mutation responses). */
143
- submittedBy: z3.string().nullable(),
278
+ name: z4.string().nullable(),
279
+ /** The job's author: the label of the token that submitted it (snapshotted
280
+ * at creation, so it survives the token being renamed or revoked). Null for
281
+ * jobs created before authorship was tracked. */
282
+ submittedBy: z4.string().nullable(),
144
283
  status: jobStatusSchema,
145
284
  /** 'none' = private. 'read'/'review' = anyone with the link, no sign-in. */
146
285
  publicAccess: publicAccessSchema,
147
- referentialVersion: z3.string(),
148
- idColumn: z3.string().nullable(),
286
+ referentialVersion: z4.string(),
287
+ idColumn: z4.string().nullable(),
149
288
  idTypeDetection: idTypeDetectionSchema.nullable(),
150
- totalRows: z3.number().int().nonnegative(),
151
- uniqueQueries: z3.number().int().nonnegative(),
152
- processedRows: z3.number().int().nonnegative(),
153
- matchedRows: z3.number().int().nonnegative(),
154
- ambiguousRows: z3.number().int().nonnegative(),
155
- errorRows: z3.number().int().nonnegative(),
156
- needsReviewRows: z3.number().int().nonnegative(),
157
- llmCostCents: z3.number().int().nonnegative(),
158
- llmCostCapCents: z3.number().int().nonnegative(),
159
- createdAt: z3.string().datetime(),
160
- startedAt: z3.string().datetime().nullable(),
161
- completedAt: z3.string().datetime().nullable(),
162
- expiresAt: z3.string().datetime()
289
+ totalRows: z4.number().int().nonnegative(),
290
+ uniqueQueries: z4.number().int().nonnegative(),
291
+ processedRows: z4.number().int().nonnegative(),
292
+ matchedRows: z4.number().int().nonnegative(),
293
+ ambiguousRows: z4.number().int().nonnegative(),
294
+ errorRows: z4.number().int().nonnegative(),
295
+ needsReviewRows: z4.number().int().nonnegative(),
296
+ llmCostCents: z4.number().int().nonnegative(),
297
+ llmCostCapCents: z4.number().int().nonnegative(),
298
+ createdAt: z4.string().datetime(),
299
+ startedAt: z4.string().datetime().nullable(),
300
+ completedAt: z4.string().datetime().nullable(),
301
+ expiresAt: z4.string().datetime()
163
302
  });
164
- var jobPublicAccessUpdateSchema = z3.object({
303
+ var jobPublicAccessUpdateSchema = z4.object({
165
304
  publicAccess: publicAccessSchema
166
305
  });
167
- var progressEventSchema = z3.object({
168
- type: z3.enum(["progress", "status", "error", "completed"]),
169
- jobId: z3.string().ulid(),
170
- timestamp: z3.string().datetime(),
171
- processedRows: z3.number().int().nonnegative().optional(),
172
- totalRows: z3.number().int().nonnegative().optional(),
173
- status: jobStatusSchema.optional(),
174
- message: z3.string().optional()
306
+ var jobRenameSchema = z4.object({
307
+ name: z4.string().trim().min(1).max(200)
175
308
  });
176
- var taxonIdentifierRefSchema = z3.object({
177
- namespace: z3.string(),
178
- value: z3.string()
309
+ var jobStreamPhaseSchema = z4.enum(["loading", "parsing", "matching"]);
310
+ var nonNegativeInt = z4.number().int().nonnegative();
311
+ var throttledProviderSchema = z4.object({
312
+ /** Provider id: `gbif`, `tnrs`, `gnverifier`, `plantnet`, `openrouter`. */
313
+ provider: z4.string(),
314
+ /** Calls waiting for that provider's limiter right now. */
315
+ waiting: nonNegativeInt,
316
+ /** How long the job has been waiting on it without a break. */
317
+ waitedMs: nonNegativeInt
179
318
  });
180
- var jobRowSummarySchema = z3.object({
181
- id: z3.string().uuid(),
182
- rowIndex: z3.number().int().nonnegative(),
183
- inputName: z3.string().nullable(),
184
- inputId: z3.string().nullable(),
185
- inputFamily: z3.string().nullable(),
319
+ var frameBase = { jobId: z4.string().optional(), timestamp: z4.string().optional() };
320
+ var jobStreamEventSchema = z4.discriminatedUnion("type", [
321
+ /** A status change, or the snapshot a stream opens with. */
322
+ z4.object({
323
+ ...frameBase,
324
+ type: z4.literal("status"),
325
+ status: jobStatusSchema,
326
+ phase: jobStreamPhaseSchema.optional(),
327
+ snapshot: z4.string().optional(),
328
+ processedRows: nonNegativeInt.optional(),
329
+ totalRows: nonNegativeInt.optional(),
330
+ processedQueries: nonNegativeInt.optional(),
331
+ totalQueries: nonNegativeInt.optional()
332
+ }),
333
+ /** Live counters for the whole job, a few times a second at most. */
334
+ z4.object({
335
+ ...frameBase,
336
+ type: z4.literal("progress"),
337
+ phase: jobStreamPhaseSchema,
338
+ processedQueries: nonNegativeInt,
339
+ totalQueries: nonNegativeInt,
340
+ matched: nonNegativeInt,
341
+ ambiguous: nonNegativeInt,
342
+ errors: nonNegativeInt,
343
+ review: nonNegativeInt,
344
+ processed: nonNegativeInt
345
+ }),
346
+ /**
347
+ * External providers are holding the match stage back: their rate
348
+ * limiter, a retry backoff after a 429 / 5xx, or the in-flight cap. Repeated while it
349
+ * lasts and simply not sent once it stops, so a reader should let the
350
+ * state lapse a few seconds after the last one.
351
+ */
352
+ z4.object({ ...frameBase, type: z4.literal("throttle"), providers: z4.array(throttledProviderSchema).min(1) }),
353
+ /** Progress of a post-match verification plugin run. */
354
+ z4.object({ ...frameBase, type: z4.literal("plugin"), pluginId: z4.string() }).passthrough(),
355
+ z4.object({ ...frameBase, type: z4.literal("completed"), status: z4.enum(["completed", "cancelled"]) }),
356
+ z4.object({ ...frameBase, type: z4.literal("error"), message: z4.string() }),
357
+ z4.object({ ...frameBase, type: z4.literal("heartbeat") })
358
+ ]);
359
+ var taxonIdentifierRefSchema = z4.object({
360
+ namespace: z4.string(),
361
+ value: z4.string()
362
+ });
363
+ var jobRowSummarySchema = z4.object({
364
+ id: z4.string().uuid(),
365
+ rowIndex: z4.number().int().nonnegative(),
366
+ inputName: z4.string().nullable(),
367
+ inputId: z4.string().nullable(),
368
+ inputFamily: z4.string().nullable(),
186
369
  matchStatus: matchStatusSchema.nullable(),
187
370
  grade: gradeSchema.nullable(),
188
- evidenceType: z3.string().nullable(),
371
+ evidenceType: z4.string().nullable(),
189
372
  reviewStatus: reviewStatusSchema,
190
- matchQueryId: z3.string().uuid().nullable(),
191
- confidence: z3.number().nullable(),
192
- layer: z3.string().nullable(),
193
- flags: z3.array(z3.string()).nullable(),
194
- candidateAcceptedName: z3.string().nullable(),
373
+ matchQueryId: z4.string().uuid().nullable(),
374
+ confidence: z4.number().nullable(),
375
+ layer: z4.string().nullable(),
376
+ flags: z4.array(z4.string()).nullable(),
377
+ candidateAcceptedName: z4.string().nullable(),
195
378
  /** Authorship of the accepted taxon (distinct from `candidateAuthorship`
196
379
  * which carries the matched-row author when the match resolves via a synonym). */
197
- candidateAcceptedAuthorship: z3.string().nullable(),
198
- candidateAcceptedIdentifiers: z3.array(taxonIdentifierRefSchema).nullable(),
199
- candidateScientificName: z3.string().nullable(),
200
- candidateAuthorship: z3.string().nullable(),
201
- candidateFamily: z3.string().nullable(),
202
- candidateReason: z3.string().nullable(),
203
- candidateTargetUrl: z3.string().nullable()
204
- });
205
- var gradeCountsSchema = z3.object({
206
- A: z3.number().int().nonnegative(),
207
- B: z3.number().int().nonnegative(),
208
- C: z3.number().int().nonnegative(),
209
- ungraded: z3.number().int().nonnegative()
210
- });
211
- var statusCountsSchema = z3.object({
212
- matched: z3.number().int().nonnegative(),
213
- ambiguous: z3.number().int().nonnegative(),
214
- no_match: z3.number().int().nonnegative(),
215
- error: z3.number().int().nonnegative(),
216
- skipped: z3.number().int().nonnegative(),
380
+ candidateAcceptedAuthorship: z4.string().nullable(),
381
+ candidateAcceptedIdentifiers: z4.array(taxonIdentifierRefSchema).nullable(),
382
+ candidateScientificName: z4.string().nullable(),
383
+ candidateAuthorship: z4.string().nullable(),
384
+ candidateFamily: z4.string().nullable(),
385
+ candidateReason: z4.string().nullable(),
386
+ candidateTargetUrl: z4.string().nullable(),
387
+ /** Post-job verification-plugin results for this row, one per plugin that
388
+ * has a completed run. Empty when no plugin has run. Sourced from
389
+ * `job_plugin_results` joined via the row's match query. */
390
+ plugins: z4.array(jobRowPluginResultSchema).default([])
391
+ });
392
+ var gradeCountsSchema = z4.object({
393
+ A: z4.number().int().nonnegative(),
394
+ B: z4.number().int().nonnegative(),
395
+ C: z4.number().int().nonnegative(),
396
+ ungraded: z4.number().int().nonnegative()
397
+ });
398
+ var statusCountsSchema = z4.object({
399
+ matched: z4.number().int().nonnegative(),
400
+ ambiguous: z4.number().int().nonnegative(),
401
+ no_match: z4.number().int().nonnegative(),
402
+ error: z4.number().int().nonnegative(),
403
+ skipped: z4.number().int().nonnegative(),
217
404
  /** Rows whose match query exists but hasn't been processed yet (mid-run).
218
405
  * Distinct from `skipped` (no query — empty/guarded input). Not part of the
219
406
  * outcome funnel; the SPA can show it as in-progress. */
220
- pending: z3.number().int().nonnegative()
407
+ pending: z4.number().int().nonnegative()
221
408
  });
222
- var jobRowsPageSchema = z3.object({
223
- rows: z3.array(jobRowSummarySchema),
409
+ var layerCountsSchema = z4.record(z4.string(), z4.number().int().nonnegative());
410
+ var rowSortSchema = z4.enum([
411
+ "rowIndex",
412
+ "inputName",
413
+ "matchStatus",
414
+ "grade",
415
+ "confidence",
416
+ "acceptedName",
417
+ "family"
418
+ ]);
419
+ var rowOrderSchema = z4.enum(["asc", "desc"]);
420
+ var jobRowsPageSchema = z4.object({
421
+ rows: z4.array(jobRowSummarySchema),
224
422
  /** Row count matching the CURRENT filter (not the whole job) — drives
225
423
  * pagination so page count adapts to active grade/status/search/conf filters. */
226
- total: z3.number().int().nonnegative(),
227
- offset: z3.number().int().nonnegative(),
228
- limit: z3.number().int().positive(),
424
+ total: z4.number().int().nonnegative(),
425
+ offset: z4.number().int().nonnegative(),
426
+ limit: z4.number().int().positive(),
427
+ /** Opaque keyset cursor for the page after this one, in the requested sort
428
+ * + order; null on the last page. Pass it back as `cursor`. */
429
+ nextCursor: z4.string().nullable(),
229
430
  /** Per-grade row counts for the whole job, independent of the current
230
431
  * filter. Used by the SPA to show "B (12)" next to each grade chip. */
231
432
  gradeCounts: gradeCountsSchema,
232
433
  /** Per-status row counts (matched / ambiguous / no_match / error /
233
434
  * skipped), same scope + purpose as `gradeCounts`. */
234
- statusCounts: statusCountsSchema
235
- });
236
- var wcvpSearchHitSchema = z3.object({
237
- taxonId: z3.string(),
238
- scientificName: z3.string(),
239
- canonicalName: z3.string(),
240
- authorship: z3.string().nullable(),
241
- rank: z3.string().nullable(),
242
- taxonomicStatus: z3.string().nullable(),
243
- family: z3.string().nullable(),
244
- acceptedTaxonId: z3.string().nullable(),
245
- acceptedName: z3.string().nullable(),
246
- similarity: z3.number()
247
- });
248
- var wcvpSearchResponseSchema = z3.array(wcvpSearchHitSchema);
249
- var jobDiffRowSchema = z3.object({
250
- rowId: z3.string().uuid(),
251
- rowIndex: z3.number().int().nonnegative(),
252
- inputName: z3.string().nullable(),
253
- inputFamily: z3.string().nullable(),
254
- matchedName: z3.string().nullable(),
255
- matchedAuthorship: z3.string().nullable(),
256
- matchedTaxonomicStatus: z3.string().nullable(),
257
- acceptedName: z3.string().nullable(),
258
- acceptedAuthorship: z3.string().nullable(),
259
- acceptedFamily: z3.string().nullable(),
260
- isSynonym: z3.boolean(),
261
- familyChanged: z3.boolean(),
435
+ statusCounts: statusCountsSchema,
436
+ /** Per-layer row counts, keyed by evidence type. Same scope + purpose as
437
+ * `gradeCounts`; only layers present in the job appear. */
438
+ layerCounts: layerCountsSchema
439
+ });
440
+ var wcvpRankLevelSchema = z4.enum(["genus", "species", "infra"]);
441
+ var wcvpSearchHitSchema = z4.object({
442
+ taxonId: z4.string(),
443
+ scientificName: z4.string(),
444
+ canonicalName: z4.string(),
445
+ authorship: z4.string().nullable(),
446
+ rank: z4.string().nullable(),
447
+ taxonomicStatus: z4.string().nullable(),
448
+ family: z4.string().nullable(),
449
+ acceptedTaxonId: z4.string().nullable(),
450
+ acceptedName: z4.string().nullable(),
451
+ similarity: z4.number()
452
+ });
453
+ var wcvpSearchResponseSchema = z4.array(wcvpSearchHitSchema);
454
+ var speciesRollupStatusSchema = z4.enum([
455
+ "available",
456
+ "already-species",
457
+ "no-species-ancestor",
458
+ "no-selection",
459
+ "unknown-taxon"
460
+ ]);
461
+ var speciesRollupTaxonSchema = z4.object({
462
+ taxonId: z4.string(),
463
+ scientificName: z4.string(),
464
+ canonicalName: z4.string(),
465
+ authorship: z4.string().nullable(),
466
+ rank: z4.string().nullable(),
467
+ family: z4.string().nullable()
468
+ });
469
+ var speciesRollupResponseSchema = z4.object({
470
+ status: speciesRollupStatusSchema,
471
+ /** The accepted taxon the row currently resolves to. */
472
+ current: speciesRollupTaxonSchema.nullable(),
473
+ /** Only set when `status` is `available`. */
474
+ target: speciesRollupTaxonSchema.nullable()
475
+ });
476
+ var jobDiffRowSchema = z4.object({
477
+ rowId: z4.string().uuid(),
478
+ rowIndex: z4.number().int().nonnegative(),
479
+ inputName: z4.string().nullable(),
480
+ inputFamily: z4.string().nullable(),
481
+ matchedName: z4.string().nullable(),
482
+ matchedAuthorship: z4.string().nullable(),
483
+ matchedTaxonomicStatus: z4.string().nullable(),
484
+ acceptedName: z4.string().nullable(),
485
+ acceptedAuthorship: z4.string().nullable(),
486
+ acceptedFamily: z4.string().nullable(),
487
+ isSynonym: z4.boolean(),
488
+ familyChanged: z4.boolean(),
262
489
  grade: gradeSchema.nullable(),
263
490
  reviewStatus: reviewStatusSchema,
264
- targetUrl: z3.string().nullable()
491
+ targetUrl: z4.string().nullable()
265
492
  });
266
- var jobDiffPageSchema = z3.object({
267
- rows: z3.array(jobDiffRowSchema),
268
- total: z3.number().int().nonnegative()
493
+ var jobDiffPageSchema = z4.object({
494
+ rows: z4.array(jobDiffRowSchema),
495
+ total: z4.number().int().nonnegative()
269
496
  });
270
- var reviewActionSchema = z3.enum(["accept", "reject"]);
271
- var reviewRequestSchema = z3.object({
497
+ var reviewActionSchema = z4.enum(["accept", "reject"]);
498
+ var reviewRequestSchema = z4.object({
272
499
  action: reviewActionSchema,
273
- candidateId: z3.string().uuid()
500
+ // Optional so a row with no candidates can still be resolved as "no
501
+ // match" (reject with no candidate). Accept always needs one.
502
+ candidateId: z4.string().uuid().optional()
503
+ }).refine((v) => v.action === "reject" || !!v.candidateId, {
504
+ message: "candidateId is required to accept a candidate",
505
+ path: ["candidateId"]
274
506
  });
275
- var bulkReviewRequestSchema = z3.object({
507
+ var bulkReviewRequestSchema = z4.object({
276
508
  action: reviewActionSchema,
277
- filter: z3.object({
509
+ filter: z4.object({
278
510
  grade: gradeSchema.optional(),
279
511
  matchStatus: matchStatusSchema.optional(),
280
- family: z3.string().optional()
512
+ family: z4.string().optional(),
513
+ /** Restrict the bulk action to these specific match-query ids. The
514
+ * review queue's grouped/batch view computes a group's membership
515
+ * client-side (by issue category or author-equivalence pair) and
516
+ * sends the exact ids — flag/category logic that a coarse
517
+ * grade/family filter can't express. ANDed with any other filter;
518
+ * the server still restricts to currently-pending, owned queries. */
519
+ matchQueryIds: z4.array(z4.string().uuid()).max(5e4).optional()
281
520
  }).default({}),
282
- dryRun: z3.boolean().default(false)
521
+ dryRun: z4.boolean().default(false)
283
522
  });
284
- var bulkReviewResponseSchema = z3.object({
523
+ var bulkReviewResponseSchema = z4.object({
285
524
  action: reviewActionSchema,
286
- matchedQueries: z3.number().int().nonnegative(),
287
- dryRun: z3.boolean(),
288
- appliedAt: z3.string().datetime().nullable()
525
+ matchedQueries: z4.number().int().nonnegative(),
526
+ dryRun: z4.boolean(),
527
+ appliedAt: z4.string().datetime().nullable()
289
528
  });
290
- var reviewResponseSchema = z3.object({
291
- matchQueryId: z3.string().uuid(),
529
+ var reviewResponseSchema = z4.object({
530
+ matchQueryId: z4.string().uuid(),
292
531
  action: reviewActionSchema,
293
- candidateId: z3.string().uuid(),
532
+ // Null only for a "no match" reject (a row with no candidate). An accept
533
+ // always resolves to a candidate — the refine catches a server that
534
+ // returned an accept without one.
535
+ candidateId: z4.string().uuid().nullable(),
294
536
  reviewStatus: reviewStatusSchema,
295
- reviewEventId: z3.string().uuid()
537
+ reviewEventId: z4.string().uuid()
538
+ }).refine((v) => v.action !== "accept" || v.candidateId !== null, {
539
+ message: "an accept must resolve to a candidateId",
540
+ path: ["candidateId"]
296
541
  });
297
- var matchAttemptSummarySchema = z3.object({
298
- id: z3.string().uuid(),
299
- layer: z3.string(),
300
- provider: z3.string(),
301
- status: z3.string(),
302
- durationMs: z3.number().int().nonnegative(),
542
+ var matchAttemptSummarySchema = z4.object({
543
+ id: z4.string().uuid(),
544
+ layer: z4.string(),
545
+ provider: z4.string(),
546
+ status: z4.string(),
547
+ durationMs: z4.number().int().nonnegative(),
303
548
  // JSONB fields accept any shape. Note: `z.unknown()` infers as optional,
304
549
  // so consumers should guard for `undefined` even though we always send
305
550
  // them as part of the response.
306
- query: z3.unknown(),
307
- rawResponseSummary: z3.unknown().nullable(),
308
- createdAt: z3.string().datetime()
551
+ query: z4.unknown(),
552
+ rawResponseSummary: z4.unknown().nullable(),
553
+ createdAt: z4.string().datetime()
309
554
  });
310
- var matchCandidateSummarySchema = z3.object({
311
- id: z3.string().uuid(),
312
- attemptId: z3.string().uuid(),
313
- source: z3.string(),
314
- sourceId: z3.string().nullable(),
315
- scientificName: z3.string(),
316
- canonicalName: z3.string(),
317
- authorship: z3.string().nullable(),
318
- rank: z3.string().nullable(),
319
- taxonomicStatus: z3.string().nullable(),
320
- acceptedName: z3.string().nullable(),
321
- acceptedAuthorship: z3.string().nullable(),
322
- acceptedIdentifiers: z3.array(taxonIdentifierRefSchema).nullable(),
323
- identifiers: z3.array(taxonIdentifierRefSchema),
324
- family: z3.string().nullable(),
325
- confidence: z3.number().nullable(),
555
+ var matchCandidateSummarySchema = z4.object({
556
+ id: z4.string().uuid(),
557
+ attemptId: z4.string().uuid(),
558
+ source: z4.string(),
559
+ sourceId: z4.string().nullable(),
560
+ scientificName: z4.string(),
561
+ canonicalName: z4.string(),
562
+ authorship: z4.string().nullable(),
563
+ rank: z4.string().nullable(),
564
+ taxonomicStatus: z4.string().nullable(),
565
+ acceptedName: z4.string().nullable(),
566
+ acceptedAuthorship: z4.string().nullable(),
567
+ acceptedIdentifiers: z4.array(taxonIdentifierRefSchema).nullable(),
568
+ identifiers: z4.array(taxonIdentifierRefSchema),
569
+ family: z4.string().nullable(),
570
+ confidence: z4.number().nullable(),
326
571
  grade: gradeSchema.nullable(),
327
- reason: z3.string().nullable(),
328
- flags: z3.array(z3.string()).nullable(),
329
- state: z3.enum(["proposed", "accepted", "rejected", "overridden"]),
330
- targetUrl: z3.string().nullable()
572
+ reason: z4.string().nullable(),
573
+ flags: z4.array(z4.string()).nullable(),
574
+ state: z4.enum(["proposed", "accepted", "rejected", "overridden"]),
575
+ targetUrl: z4.string().nullable()
331
576
  });
332
- var matchQueryDetailSchema = z3.object({
333
- id: z3.string().uuid(),
334
- jobId: z3.string().ulid(),
335
- normalizedInput: z3.string(),
336
- parsed: z3.unknown(),
577
+ var matchQueryDetailSchema = z4.object({
578
+ id: z4.string().uuid(),
579
+ jobId: z4.string().ulid(),
580
+ normalizedInput: z4.string(),
581
+ parsed: z4.unknown(),
337
582
  matchStatus: matchStatusSchema.nullable(),
338
- evidenceType: z3.string().nullable(),
583
+ evidenceType: z4.string().nullable(),
339
584
  grade: gradeSchema.nullable(),
340
- confidence: z3.number().nullable(),
341
- flags: z3.array(z3.string()).nullable(),
585
+ confidence: z4.number().nullable(),
586
+ flags: z4.array(z4.string()).nullable(),
342
587
  reviewStatus: reviewStatusSchema,
343
- selectedCandidateId: z3.string().uuid().nullable()
588
+ selectedCandidateId: z4.string().uuid().nullable()
589
+ });
590
+ var otherVersionMatchSchema = z4.object({
591
+ version: z4.string(),
592
+ importedAt: z4.string(),
593
+ isLatest: z4.boolean(),
594
+ scientificName: z4.string(),
595
+ authorship: z4.string().nullable(),
596
+ taxonomicStatus: z4.string().nullable(),
597
+ acceptedName: z4.string().nullable()
344
598
  });
345
- var queryCandidatesResponseSchema = z3.object({
599
+ var queryCandidatesResponseSchema = z4.object({
346
600
  query: matchQueryDetailSchema,
347
- candidates: z3.array(matchCandidateSummarySchema),
348
- attempts: z3.array(matchAttemptSummarySchema)
349
- });
350
- var reviewChatMessageSchema = z3.object({
351
- role: z3.enum(["user", "assistant"]),
352
- content: z3.string().min(1).max(4e3)
353
- });
354
- var reviewChatBodySchema = z3.object({
355
- messages: z3.array(reviewChatMessageSchema).min(1).max(40)
356
- });
357
- var reviewChatResponseSchema = z3.object({
358
- reply: z3.string(),
359
- model: z3.string()
360
- });
361
- var replayableLayerSchema = z3.enum(["L1", "L2", "L3", "L4", "L5", "L6", "L7"]);
362
- var replayLayerBodySchema = z3.object({ layer: replayableLayerSchema });
363
- var replayCandidateSchema = z3.object({
364
- scientificName: z3.string(),
365
- canonicalName: z3.string(),
366
- authorship: z3.string().nullable(),
367
- rank: z3.string().nullable(),
368
- taxonomicStatus: z3.string().nullable(),
369
- acceptedName: z3.string().nullable(),
370
- acceptedAuthorship: z3.string().nullable(),
371
- family: z3.string().nullable(),
601
+ candidates: z4.array(matchCandidateSummarySchema),
602
+ attempts: z4.array(matchAttemptSummarySchema),
603
+ /** A newer/other backbone version that has this exact name (see schema). */
604
+ otherVersionMatch: otherVersionMatchSchema.nullable()
605
+ });
606
+ var reviewChatMessageSchema = z4.object({
607
+ role: z4.enum(["user", "assistant"]),
608
+ content: z4.string().min(1).max(4e3)
609
+ });
610
+ var reviewChatBodySchema = z4.object({
611
+ messages: z4.array(reviewChatMessageSchema).min(1).max(40),
612
+ /** Let the assistant also search the web (OpenRouter web plugin). Default on
613
+ * server-side when omitted; the client sends it explicitly from its toggle. */
614
+ webSearch: z4.boolean().optional(),
615
+ /** Per-chat model override (an OpenRouter id / alias). When omitted the
616
+ * team's configured heavy model is used. Length-capped defensively. */
617
+ model: z4.string().trim().min(1).max(200).optional()
618
+ });
619
+ var reviewChatResponseSchema = z4.object({
620
+ reply: z4.string(),
621
+ model: z4.string()
622
+ });
623
+ var reviewChatStreamEventSchema = z4.discriminatedUnion("type", [
624
+ /** A chunk of the model's visible answer. */
625
+ z4.object({ type: z4.literal("text"), delta: z4.string() }),
626
+ /** A chunk of the model's reasoning/thinking (when the model exposes it). */
627
+ z4.object({ type: z4.literal("reasoning"), delta: z4.string() }),
628
+ /** The model decided to call a tool, with the (JSON) arguments it chose. */
629
+ z4.object({ type: z4.literal("tool_call"), id: z4.string(), name: z4.string(), arguments: z4.string() }),
630
+ /** The result we fed back to the model after running that tool. `sql` is the
631
+ * statement the tool actually ran, shown to the reviewer for transparency —
632
+ * it is emitted on this event ONLY and never added to the model's context. */
633
+ z4.object({
634
+ type: z4.literal("tool_result"),
635
+ id: z4.string(),
636
+ name: z4.string(),
637
+ result: z4.string(),
638
+ sql: z4.string().optional()
639
+ }),
640
+ /** Terminal success — carries the model id that answered. */
641
+ z4.object({ type: z4.literal("done"), model: z4.string() }),
642
+ /** Terminal failure — a human-readable reason. */
643
+ z4.object({ type: z4.literal("error"), error: z4.string() })
644
+ ]);
645
+ var replayableLayerSchema = z4.enum(["L1", "L1.5", "L2", "L3", "L4", "L5", "L6", "L7"]);
646
+ var replayLayerBodySchema = z4.object({ layer: replayableLayerSchema });
647
+ var replayCandidateSchema = z4.object({
648
+ scientificName: z4.string(),
649
+ canonicalName: z4.string(),
650
+ authorship: z4.string().nullable(),
651
+ rank: z4.string().nullable(),
652
+ taxonomicStatus: z4.string().nullable(),
653
+ acceptedName: z4.string().nullable(),
654
+ acceptedAuthorship: z4.string().nullable(),
655
+ family: z4.string().nullable(),
372
656
  /** WCVP taxon id of the matched row (null for unresolved external proposals). */
373
- taxonId: z3.string().nullable(),
657
+ taxonId: z4.string().nullable(),
374
658
  /** WCVP taxon id of the resolved accepted taxon (null when unresolved). */
375
- acceptedTaxonId: z3.string().nullable(),
376
- confidence: z3.number().nullable(),
377
- reason: z3.string().nullable(),
378
- flags: z3.array(z3.string()),
379
- targetUrl: z3.string().nullable(),
659
+ acceptedTaxonId: z4.string().nullable(),
660
+ confidence: z4.number().nullable(),
661
+ reason: z4.string().nullable(),
662
+ flags: z4.array(z4.string()),
663
+ targetUrl: z4.string().nullable(),
380
664
  /** Provider that proposed this candidate (e.g. tnrs, gbif, openrouter,
381
665
  * wcvp-local). */
382
- provider: z3.string().nullable()
666
+ provider: z4.string().nullable()
383
667
  });
384
- var replayAttemptSchema = z3.object({
385
- provider: z3.string(),
386
- status: z3.string(),
387
- durationMs: z3.number().int().nonnegative(),
388
- summary: z3.unknown().nullable()
668
+ var replayAttemptSchema = z4.object({
669
+ provider: z4.string(),
670
+ status: z4.string(),
671
+ durationMs: z4.number().int().nonnegative(),
672
+ summary: z4.unknown().nullable()
389
673
  });
390
- var replayLayerResultSchema = z3.object({
674
+ var replayLayerResultSchema = z4.object({
391
675
  layer: replayableLayerSchema,
392
- kind: z3.enum(["unique", "ambiguous", "miss"]),
393
- reason: z3.string().nullable(),
676
+ kind: z4.enum(["unique", "ambiguous", "miss"]),
677
+ reason: z4.string().nullable(),
394
678
  /** Human-facing note when the layer couldn't run as-is (e.g. LLM not
395
679
  * configured, no external providers enabled). */
396
- note: z3.string().nullable(),
397
- providers: z3.array(z3.string()),
398
- durationMs: z3.number().int().nonnegative(),
680
+ note: z4.string().nullable(),
681
+ providers: z4.array(z4.string()),
682
+ durationMs: z4.number().int().nonnegative(),
399
683
  /** What was actually submitted to the layer (post gnparser). */
400
- query: z3.object({
401
- canonical: z3.string(),
402
- authorship: z3.string().nullable(),
403
- inputId: z3.string().nullable()
684
+ query: z4.object({
685
+ canonical: z4.string(),
686
+ authorship: z4.string().nullable(),
687
+ inputId: z4.string().nullable()
688
+ }),
689
+ candidates: z4.array(replayCandidateSchema),
690
+ attempts: z4.array(replayAttemptSchema)
691
+ });
692
+
693
+ // ../shared/src/schemas/live-match.ts
694
+ import { z as z5 } from "zod";
695
+ var liveMatchBodySchema = z5.object({
696
+ name: z5.string().trim().min(3).max(300),
697
+ referential: z5.enum(["wcvp", "wfo"]).default("wcvp"),
698
+ referentialVersion: z5.string().min(1),
699
+ /** WGSRPD Level-3 area code narrowing an ambiguous outcome (null = off). */
700
+ area: z5.string().nullable().optional(),
701
+ speciesLevelAcceptedOnly: z5.boolean().default(false),
702
+ acceptUnconfirmedAuthor: z5.boolean().default(false)
703
+ });
704
+ var liveMatchCandidateSchema = matchCandidateSummarySchema.omit({ id: true, attemptId: true });
705
+ var liveMatchAttemptSchema = z5.object({
706
+ layer: z5.string(),
707
+ provider: z5.string(),
708
+ status: z5.string(),
709
+ durationMs: z5.number().int().nonnegative(),
710
+ query: z5.unknown(),
711
+ rawResponseSummary: z5.unknown().nullable()
712
+ });
713
+ var liveMatchResultSchema = z5.object({
714
+ query: z5.object({
715
+ input: z5.string(),
716
+ canonical: z5.string(),
717
+ authorship: z5.string().nullable(),
718
+ parser: z5.enum(["gnparser", "fallback"])
404
719
  }),
405
- candidates: z3.array(replayCandidateSchema),
406
- attempts: z3.array(replayAttemptSchema)
720
+ matchStatus: matchStatusSchema,
721
+ evidenceType: evidenceTypeSchema.nullable(),
722
+ grade: gradeSchema.nullable(),
723
+ confidence: z5.number().nullable(),
724
+ /** Layer that settled the outcome (null for no_match). */
725
+ decidedBy: z5.string().nullable(),
726
+ flags: z5.array(z5.string()),
727
+ /** Index into `candidates` of the pick a job would have selected. */
728
+ selectedIndex: z5.number().int().nonnegative().nullable(),
729
+ candidates: z5.array(liveMatchCandidateSchema),
730
+ attempts: z5.array(liveMatchAttemptSchema),
731
+ durationMs: z5.number().int().nonnegative()
407
732
  });
408
733
 
409
734
  // ../shared/src/schemas/auth.ts
410
- import { z as z4 } from "zod";
411
- var tokenScopeSchema = z4.enum([
735
+ import { z as z6 } from "zod";
736
+ var tokenScopeSchema = z6.enum([
412
737
  "submit:job",
413
738
  "read:job",
414
739
  "cancel:job",
@@ -420,77 +745,87 @@ var tokenScopeSchema = z4.enum([
420
745
  "admin:teams"
421
746
  ]);
422
747
  var ALL_TOKEN_SCOPES = tokenScopeSchema.options;
423
- var tokenStringSchema = z4.string().regex(/^ptm_[a-zA-Z0-9]{12}_[a-zA-Z0-9]{32}$/, "Malformed token");
424
- var meSchema = z4.object({
425
- userId: z4.string().uuid(),
426
- teamId: z4.string().uuid(),
427
- displayName: z4.string(),
428
- scopes: z4.array(tokenScopeSchema),
429
- tokenLabel: z4.string().nullable()
748
+ var tokenStringSchema = z6.string().regex(/^ptm_[a-zA-Z0-9]{12}_[a-zA-Z0-9]{32}$/, "Malformed token");
749
+ var meSchema = z6.object({
750
+ userId: z6.string().uuid(),
751
+ teamId: z6.string().uuid(),
752
+ displayName: z6.string(),
753
+ scopes: z6.array(tokenScopeSchema),
754
+ tokenLabel: z6.string().nullable(),
755
+ /** DB id of the token that authenticated this request — matches a row's
756
+ * `id` in the admin token list, so the UI can highlight "this is you". */
757
+ tokenId: z6.string().uuid().nullable(),
758
+ /** Where this deployment's web app lives, for links to a job page. Absent from older servers. */
759
+ appUrl: z6.string().optional()
430
760
  });
431
- var tokenCreateBodySchema = z4.object({
432
- label: z4.string().min(1).max(120),
433
- scopes: z4.array(tokenScopeSchema).min(1),
434
- expiresAt: z4.string().datetime().optional()
761
+ var tokenCreateBodySchema = z6.object({
762
+ label: z6.string().min(1).max(120),
763
+ scopes: z6.array(tokenScopeSchema).min(1),
764
+ expiresAt: z6.string().datetime().optional()
435
765
  });
436
- var tokenSummarySchema = z4.object({
437
- id: z4.string().uuid(),
438
- tokenIdPrefix: z4.string(),
439
- label: z4.string(),
440
- displayName: z4.string(),
441
- scopes: z4.array(tokenScopeSchema),
442
- createdAt: z4.string().datetime(),
443
- lastUsedAt: z4.string().datetime().nullable(),
444
- expiresAt: z4.string().datetime().nullable()
766
+ var tokenRenameSchema = z6.object({
767
+ label: z6.string().trim().min(1).max(120)
768
+ });
769
+ var tokenSummarySchema = z6.object({
770
+ id: z6.string().uuid(),
771
+ /** Team the token belongs to — `admin:teams` callers list every team's tokens. */
772
+ teamId: z6.string().uuid(),
773
+ tokenIdPrefix: z6.string(),
774
+ label: z6.string(),
775
+ displayName: z6.string(),
776
+ scopes: z6.array(tokenScopeSchema),
777
+ createdAt: z6.string().datetime(),
778
+ lastUsedAt: z6.string().datetime().nullable(),
779
+ expiresAt: z6.string().datetime().nullable()
445
780
  });
446
781
  var tokenCreatedSchema = tokenSummarySchema.extend({
447
782
  tokenString: tokenStringSchema
448
783
  });
449
- var teamSummarySchema = z4.object({
450
- id: z4.string().uuid(),
451
- name: z4.string(),
452
- createdAt: z4.string().datetime()
784
+ var teamSummarySchema = z6.object({
785
+ id: z6.string().uuid(),
786
+ name: z6.string(),
787
+ createdAt: z6.string().datetime()
453
788
  });
454
- var teamCreateBodySchema = z4.object({
455
- name: z4.string().min(1).max(120),
456
- initialUserDisplayName: z4.string().min(1).max(120).default("Admin"),
457
- initialToken: z4.object({
458
- label: z4.string().min(1).max(120),
459
- scopes: z4.array(tokenScopeSchema).min(1)
789
+ var teamCreateBodySchema = z6.object({
790
+ name: z6.string().min(1).max(120),
791
+ initialUserDisplayName: z6.string().min(1).max(120).default("Admin"),
792
+ initialToken: z6.object({
793
+ label: z6.string().min(1).max(120),
794
+ scopes: z6.array(tokenScopeSchema).min(1)
460
795
  }).optional()
461
796
  });
462
797
  var teamCreatedSchema = teamSummarySchema.extend({
463
- initialUser: z4.object({
464
- id: z4.string().uuid(),
465
- displayName: z4.string()
798
+ initialUser: z6.object({
799
+ id: z6.string().uuid(),
800
+ displayName: z6.string()
466
801
  }),
467
802
  initialToken: tokenCreatedSchema.nullable()
468
803
  });
469
- var setupStatusSchema = z4.object({
470
- needsSetup: z4.boolean()
804
+ var setupStatusSchema = z6.object({
805
+ needsSetup: z6.boolean()
471
806
  });
472
- var setupBodySchema = z4.object({
473
- teamName: z4.string().min(1).max(120),
474
- displayName: z4.string().min(1).max(120).default("Admin")
807
+ var setupBodySchema = z6.object({
808
+ teamName: z6.string().min(1).max(120),
809
+ displayName: z6.string().min(1).max(120).default("Admin")
475
810
  });
476
- var setupResultSchema = z4.object({
811
+ var setupResultSchema = z6.object({
477
812
  team: teamSummarySchema,
478
- user: z4.object({ id: z4.string().uuid(), displayName: z4.string() }),
813
+ user: z6.object({ id: z6.string().uuid(), displayName: z6.string() }),
479
814
  tokenString: tokenStringSchema,
480
- scopes: z4.array(tokenScopeSchema)
815
+ scopes: z6.array(tokenScopeSchema)
481
816
  });
482
- var teamLlmSettingsSchema = z4.object({
483
- keyConfigured: z4.boolean(),
484
- keyHint: z4.string().nullable(),
485
- lightModel: z4.string(),
486
- heavyModel: z4.string()
817
+ var teamLlmSettingsSchema = z6.object({
818
+ keyConfigured: z6.boolean(),
819
+ keyHint: z6.string().nullable(),
820
+ lightModel: z6.string(),
821
+ heavyModel: z6.string()
487
822
  });
488
- var teamLlmUpdateSchema = z4.object({
489
- openrouterApiKey: z4.string().max(400).nullable().optional(),
490
- lightModel: z4.string().min(1).max(200).optional(),
491
- heavyModel: z4.string().min(1).max(200).optional()
823
+ var teamLlmUpdateSchema = z6.object({
824
+ openrouterApiKey: z6.string().max(400).nullable().optional(),
825
+ lightModel: z6.string().min(1).max(200).optional(),
826
+ heavyModel: z6.string().min(1).max(200).optional()
492
827
  });
493
- var wcvpImportStatusSchema = z4.enum([
828
+ var wcvpImportStatusSchema = z6.enum([
494
829
  "queued",
495
830
  "downloading",
496
831
  "importing",
@@ -498,68 +833,73 @@ var wcvpImportStatusSchema = z4.enum([
498
833
  "failed",
499
834
  "cancelled"
500
835
  ]);
501
- var wcvpImportRunSchema = z4.object({
502
- id: z4.string().uuid(),
503
- version: z4.string(),
504
- sourceUrl: z4.string(),
836
+ var wcvpImportRunSchema = z6.object({
837
+ id: z6.string().uuid(),
838
+ version: z6.string(),
839
+ sourceUrl: z6.string(),
505
840
  status: wcvpImportStatusSchema,
506
- forceOverwrite: z4.boolean(),
507
- bytesDownloaded: z4.number().int().nonnegative(),
508
- totalBytes: z4.number().int().nonnegative().nullable(),
509
- insertedCount: z4.number().int().nonnegative(),
510
- recordCount: z4.number().int().nonnegative().nullable(),
511
- message: z4.string().nullable(),
512
- createdAt: z4.string().datetime(),
513
- startedAt: z4.string().datetime().nullable(),
514
- completedAt: z4.string().datetime().nullable()
841
+ forceOverwrite: z6.boolean(),
842
+ bytesDownloaded: z6.number().int().nonnegative(),
843
+ totalBytes: z6.number().int().nonnegative().nullable(),
844
+ insertedCount: z6.number().int().nonnegative(),
845
+ recordCount: z6.number().int().nonnegative().nullable(),
846
+ message: z6.string().nullable(),
847
+ createdAt: z6.string().datetime(),
848
+ startedAt: z6.string().datetime().nullable(),
849
+ completedAt: z6.string().datetime().nullable()
515
850
  });
516
- var wcvpImportCreateBodySchema = z4.object({
517
- version: z4.string().min(1).max(120),
851
+ var wcvpImportCreateBodySchema = z6.object({
852
+ version: z6.string().min(1).max(120),
518
853
  /** Defaults to the Kew SFTP URL when omitted, so the admin doesn't have
519
854
  * to remember it for routine v13/v14 imports. */
520
- url: z4.string().url().optional(),
521
- force: z4.boolean().optional()
855
+ url: z6.string().url().optional(),
856
+ force: z6.boolean().optional()
522
857
  });
523
- var adminHealthErrorSchema = z4.object({
524
- id: z4.string().uuid(),
525
- action: z4.string(),
526
- entityType: z4.string(),
527
- entityId: z4.string().nullable(),
528
- timestamp: z4.string().datetime(),
529
- metadata: z4.unknown().nullable()
530
- });
531
- var adminHealthSchema = z4.object({
532
- queueDepth: z4.object({
533
- waiting: z4.number().int().nonnegative(),
534
- active: z4.number().int().nonnegative(),
535
- delayed: z4.number().int().nonnegative(),
536
- completed: z4.number().int().nonnegative(),
537
- failed: z4.number().int().nonnegative(),
538
- paused: z4.number().int().nonnegative()
539
- }),
540
- workerCount: z4.number().int().nonnegative(),
541
- lastErrors: z4.array(adminHealthErrorSchema),
542
- rateLimitBudget: z4.object({
543
- max: z4.number().int().nonnegative(),
544
- timeWindowSeconds: z4.number().int().nonnegative()
858
+ var adminHealthErrorSchema = z6.object({
859
+ id: z6.string().uuid(),
860
+ action: z6.string(),
861
+ entityType: z6.string(),
862
+ entityId: z6.string().nullable(),
863
+ timestamp: z6.string().datetime(),
864
+ metadata: z6.unknown().nullable()
865
+ });
866
+ var queueDepthSchema = z6.object({
867
+ waiting: z6.number().int().nonnegative(),
868
+ active: z6.number().int().nonnegative(),
869
+ delayed: z6.number().int().nonnegative(),
870
+ completed: z6.number().int().nonnegative(),
871
+ failed: z6.number().int().nonnegative(),
872
+ paused: z6.number().int().nonnegative()
873
+ });
874
+ var adminQueueSchema = z6.object({
875
+ queueDepth: queueDepthSchema,
876
+ workerCount: z6.number().int().nonnegative()
877
+ });
878
+ var adminHealthSchema = z6.object({
879
+ queueDepth: queueDepthSchema,
880
+ workerCount: z6.number().int().nonnegative(),
881
+ lastErrors: z6.array(adminHealthErrorSchema),
882
+ rateLimitBudget: z6.object({
883
+ max: z6.number().int().nonnegative(),
884
+ timeWindowSeconds: z6.number().int().nonnegative()
545
885
  }),
546
- snapshotVersion: z4.string().nullable(),
547
- snapshotImportedAt: z4.string().datetime().nullable(),
886
+ snapshotVersion: z6.string().nullable(),
887
+ snapshotImportedAt: z6.string().datetime().nullable(),
548
888
  /**
549
889
  * Number of cached accepted-name mappings (L0.5 match-history rows) for the
550
890
  * caller's team — the size of the team accepted-name cache.
551
891
  */
552
- teamHistorySize: z4.number().int().nonnegative(),
892
+ teamHistorySize: z6.number().int().nonnegative(),
553
893
  /**
554
894
  * Overall (all-teams) tally of which cascade layer produced the winning
555
895
  * candidate, across every matched query. `layer` is the raw attempt layer
556
896
  * (`L1`…`L7`, `L0.5`, `OVERRIDE`); `count` is the number of matched queries
557
897
  * that layer resolved. Descending by count.
558
898
  */
559
- layerUsage: z4.array(
560
- z4.object({
561
- layer: z4.string(),
562
- count: z4.number().int().nonnegative()
899
+ layerUsage: z6.array(
900
+ z6.object({
901
+ layer: z6.string(),
902
+ count: z6.number().int().nonnegative()
563
903
  })
564
904
  ),
565
905
  /**
@@ -569,18 +909,301 @@ var adminHealthSchema = z4.object({
569
909
  * Lets the health page surface each external source's usage, hit/miss/error
570
910
  * breakdown, latency, and recency — so a misbehaving provider is visible.
571
911
  */
572
- externalProviders: z4.array(
573
- z4.object({
574
- provider: z4.string(),
575
- total: z4.number().int().nonnegative(),
576
- hits: z4.number().int().nonnegative(),
577
- misses: z4.number().int().nonnegative(),
578
- errors: z4.number().int().nonnegative(),
579
- avgDurationMs: z4.number().int().nonnegative(),
580
- lastUsedAt: z4.string().datetime().nullable()
912
+ externalProviders: z6.array(
913
+ z6.object({
914
+ provider: z6.string(),
915
+ total: z6.number().int().nonnegative(),
916
+ hits: z6.number().int().nonnegative(),
917
+ misses: z6.number().int().nonnegative(),
918
+ errors: z6.number().int().nonnegative(),
919
+ avgDurationMs: z6.number().int().nonnegative(),
920
+ lastUsedAt: z6.string().datetime().nullable()
581
921
  })
582
922
  )
583
923
  });
924
+ var matchHistoryEntrySchema = z6.object({
925
+ id: z6.string().uuid(),
926
+ /** The normalized input name this mapping is keyed on. */
927
+ normalizedInput: z6.string(),
928
+ acceptedName: z6.string(),
929
+ acceptedIdentifier: z6.object({ namespace: z6.string(), value: z6.string() }).nullable(),
930
+ /** Resolved from the backbone snapshot at read time (null when the stored
931
+ * identifier no longer resolves): authorship + family of the accepted taxon,
932
+ * its identifiers (backbone id, IPNI LSID, portal url) and the portal link. */
933
+ acceptedAuthorship: z6.string().nullable(),
934
+ family: z6.string().nullable(),
935
+ acceptedIdentifiers: z6.array(z6.object({ namespace: z6.string(), value: z6.string() })),
936
+ targetUrl: z6.string().nullable(),
937
+ targetReferential: z6.string(),
938
+ referentialVersion: z6.string(),
939
+ independentUserAcceptanceCount: z6.number().int().nonnegative(),
940
+ rejectionCount: z6.number().int().nonnegative(),
941
+ blacklisted: z6.boolean(),
942
+ needsReconfirmation: z6.boolean(),
943
+ /** Currently usable as a cache hit (not blacklisted, acceptances > rejections). */
944
+ active: z6.boolean(),
945
+ /** Auto-accept (Grade A): acceptances ≥ the team's promotion threshold. */
946
+ promoted: z6.boolean(),
947
+ /** Label of the token that last reviewed this mapping (falls back to the
948
+ * reviewer's display name for rows predating token tracking). */
949
+ lastReviewedBy: z6.string().nullable(),
950
+ firstSeenAt: z6.string().datetime(),
951
+ lastAcceptedAt: z6.string().datetime().nullable()
952
+ });
953
+ var teamCacheResponseSchema = z6.object({
954
+ /** Distinct-user acceptances needed to promote a hit to Grade A. */
955
+ promotionThreshold: z6.number().int().positive(),
956
+ /** Total entries in the team cache (before the limit). */
957
+ total: z6.number().int().nonnegative(),
958
+ entries: z6.array(matchHistoryEntrySchema)
959
+ });
960
+
961
+ // ../shared/src/schemas/device-auth.ts
962
+ import { z as z7 } from "zod";
963
+ var SLOW_DOWN_STEP_SECONDS = 5;
964
+ var deviceAuthorizationStatusSchema = z7.enum(["pending", "approved", "denied", "consumed"]);
965
+ var deviceAuthorizationCreateSchema = z7.object({
966
+ /** Shown to the approver so they recognise their own machine (the CLI sends its hostname). */
967
+ clientName: z7.string().trim().min(1).max(100)
968
+ });
969
+ var deviceAuthorizationCreatedSchema = z7.object({
970
+ deviceCode: z7.string(),
971
+ userCode: z7.string(),
972
+ verificationUri: z7.string(),
973
+ verificationUriComplete: z7.string(),
974
+ expiresIn: z7.number().int().positive(),
975
+ interval: z7.number().int().positive()
976
+ });
977
+ var deviceAuthorizationSchema = z7.object({
978
+ userCode: z7.string(),
979
+ clientName: z7.string(),
980
+ clientIp: z7.string().nullable(),
981
+ status: deviceAuthorizationStatusSchema,
982
+ scopes: z7.array(tokenScopeSchema),
983
+ createdAt: z7.string().datetime(),
984
+ expiresAt: z7.string().datetime()
985
+ });
986
+ var deviceAuthorizationDecisionSchema = z7.object({
987
+ status: z7.enum(["approved", "denied"])
988
+ });
989
+ var deviceTokenRequestSchema = z7.object({
990
+ deviceCode: z7.string().min(1).max(200)
991
+ });
992
+ var deviceTokenSchema = z7.object({
993
+ token: z7.string(),
994
+ label: z7.string(),
995
+ scopes: z7.array(tokenScopeSchema),
996
+ expiresAt: z7.string().datetime()
997
+ });
998
+ var deviceTokenErrorCodeSchema = z7.enum([
999
+ "authorization_pending",
1000
+ "slow_down",
1001
+ "expired_token",
1002
+ "access_denied"
1003
+ ]);
1004
+ var deviceTokenErrorSchema = z7.object({
1005
+ error: deviceTokenErrorCodeSchema,
1006
+ /** Seconds to wait before the next poll, sent with `slow_down`. */
1007
+ interval: z7.number().int().positive().optional()
1008
+ });
1009
+
1010
+ // ../shared/src/schemas/backbone.ts
1011
+ import { z as z8 } from "zod";
1012
+ var backboneSchema = z8.enum(["wcvp", "wfo"]);
1013
+ var wfoImportStatusSchema = z8.enum([
1014
+ "queued",
1015
+ "downloading",
1016
+ "importing",
1017
+ "completed",
1018
+ "failed",
1019
+ "cancelled"
1020
+ ]);
1021
+ var wfoImportRunSchema = z8.object({
1022
+ id: z8.string().uuid(),
1023
+ version: z8.string(),
1024
+ sourceUrl: z8.string(),
1025
+ status: wfoImportStatusSchema,
1026
+ forceOverwrite: z8.boolean(),
1027
+ bytesDownloaded: z8.number().int().nonnegative(),
1028
+ totalBytes: z8.number().int().nonnegative().nullable(),
1029
+ insertedCount: z8.number().int().nonnegative(),
1030
+ recordCount: z8.number().int().nonnegative().nullable(),
1031
+ message: z8.string().nullable(),
1032
+ createdAt: z8.string().datetime(),
1033
+ startedAt: z8.string().datetime().nullable(),
1034
+ completedAt: z8.string().datetime().nullable()
1035
+ });
1036
+ var wfoImportCreateBodySchema = z8.object({
1037
+ version: z8.string().min(1).max(120),
1038
+ /** Direct download URL (from the Zenodo version listing). Required for WFO
1039
+ * since versions aren't derivable from the label like WCVP's Kew URLs. */
1040
+ url: z8.string().url().optional(),
1041
+ force: z8.boolean().optional()
1042
+ });
1043
+ var snapshotKindSchema = z8.enum(["official", "derived"]);
1044
+ var wfoSnapshotSummarySchema = z8.object({
1045
+ version: z8.string(),
1046
+ recordCount: z8.number().int().nonnegative(),
1047
+ importedAt: z8.string().datetime(),
1048
+ isLatest: z8.boolean(),
1049
+ kind: snapshotKindSchema,
1050
+ baseVersion: z8.string().nullable(),
1051
+ label: z8.string().nullable(),
1052
+ ownerTeamId: z8.string().nullable()
1053
+ });
1054
+ var wfoVersionSchema = z8.object({
1055
+ /** Release label, e.g. "2025-12". */
1056
+ version: z8.string(),
1057
+ /** Zenodo record id for this version. */
1058
+ recordId: z8.string(),
1059
+ /** The plant-list archive filename inside the record. */
1060
+ fileName: z8.string(),
1061
+ /** Direct content download URL. */
1062
+ url: z8.string().url(),
1063
+ sizeBytes: z8.number().int().nonnegative().nullable(),
1064
+ publishedAt: z8.string().nullable(),
1065
+ isLatest: z8.boolean()
1066
+ });
1067
+ var wfoVersionListSchema = z8.array(wfoVersionSchema);
1068
+ var wcvpVersionSchema = z8.object({
1069
+ /** Snapshot label the import uses, e.g. "wcvp-v15". */
1070
+ version: z8.string(),
1071
+ /** Kew release number, e.g. 15. */
1072
+ release: z8.number().int().positive(),
1073
+ /** Direct download URL. */
1074
+ url: z8.string().url(),
1075
+ /** Size as Kew's directory index prints it, e.g. "85M". */
1076
+ sizeLabel: z8.string().nullable(),
1077
+ /** Last-modified timestamp from the index, "YYYY-MM-DD HH:MM". */
1078
+ publishedAt: z8.string().nullable()
1079
+ });
1080
+ var wcvpVersionListSchema = z8.array(wcvpVersionSchema);
1081
+ var backboneKeySchema = z8.enum(["wcvp", "wfo"]);
1082
+ var backboneDefaultsSchema = z8.object({
1083
+ wcvp: z8.string().nullable(),
1084
+ wfo: z8.string().nullable()
1085
+ });
1086
+ var backboneDefaultUpdateSchema = z8.object({
1087
+ backbone: backboneKeySchema,
1088
+ version: z8.string().min(1).nullable()
1089
+ });
1090
+ var DERIVED_SLUG_RE = /^[a-z0-9][a-z0-9-]{1,40}$/;
1091
+ var MAX_BASE_VERSION_LENGTH = 120;
1092
+ var MAX_SNAPSHOT_VERSION_LENGTH = MAX_BASE_VERSION_LENGTH + 1 + 41;
1093
+ var derivedUploadMetaSchema = z8.object({
1094
+ baseVersion: z8.string().min(1).max(MAX_BASE_VERSION_LENGTH),
1095
+ slug: z8.string().regex(DERIVED_SLUG_RE, "slug: lowercase letters, digits and dashes (2\u201341 chars)"),
1096
+ label: z8.string().trim().min(1).max(120),
1097
+ notes: z8.string().trim().max(2e3).optional()
1098
+ });
1099
+ var derivedUploadStatusSchema = z8.enum([
1100
+ "staging",
1101
+ "ready",
1102
+ "invalid",
1103
+ "importing",
1104
+ "imported",
1105
+ "failed",
1106
+ "discarded"
1107
+ ]);
1108
+ var derivedIssueSeveritySchema = z8.enum(["error", "warning"]);
1109
+ var derivedIssueSchema = z8.object({
1110
+ code: z8.string(),
1111
+ severity: derivedIssueSeveritySchema,
1112
+ count: z8.number().int().nonnegative(),
1113
+ /** Free-form detail for the UI (e.g. the offending vocabulary values). */
1114
+ detail: z8.string().nullable(),
1115
+ samples: z8.array(
1116
+ z8.object({
1117
+ taxonId: z8.string().nullable(),
1118
+ name: z8.string().nullable(),
1119
+ detail: z8.string().nullable(),
1120
+ line: z8.number().int().nullable()
1121
+ })
1122
+ )
1123
+ });
1124
+ var derivedDiffFieldSchema = z8.enum([
1125
+ "canonical_name",
1126
+ "scientific_name",
1127
+ "authorship",
1128
+ "rank",
1129
+ "taxonomic_status",
1130
+ "accepted_name_usage_id",
1131
+ "parent_name_usage_id",
1132
+ "family"
1133
+ ]);
1134
+ var DERIVED_DIFF_FIELDS = derivedDiffFieldSchema.options;
1135
+ var diffSampleSchema = z8.object({ taxonId: z8.string(), name: z8.string().nullable() });
1136
+ var changedSampleSchema = diffSampleSchema.extend({
1137
+ changes: z8.array(
1138
+ z8.object({ field: derivedDiffFieldSchema, before: z8.string().nullable(), after: z8.string().nullable() })
1139
+ )
1140
+ });
1141
+ var derivedReportSchema = z8.object({
1142
+ rowsRead: z8.number().int().nonnegative(),
1143
+ rowsStaged: z8.number().int().nonnegative(),
1144
+ rowsRejected: z8.number().int().nonnegative(),
1145
+ baseRowCount: z8.number().int().nonnegative(),
1146
+ errors: z8.number().int().nonnegative(),
1147
+ warnings: z8.number().int().nonnegative(),
1148
+ issues: z8.array(derivedIssueSchema),
1149
+ diff: z8.object({
1150
+ added: z8.object({ count: z8.number().int().nonnegative(), samples: z8.array(diffSampleSchema) }),
1151
+ removed: z8.object({ count: z8.number().int().nonnegative(), samples: z8.array(diffSampleSchema) }),
1152
+ changed: z8.object({
1153
+ count: z8.number().int().nonnegative(),
1154
+ byField: z8.record(z8.string(), z8.number().int().nonnegative()),
1155
+ samples: z8.array(changedSampleSchema)
1156
+ }),
1157
+ unchanged: z8.number().int().nonnegative()
1158
+ })
1159
+ });
1160
+ var derivedUploadSchema = z8.object({
1161
+ id: z8.string().uuid(),
1162
+ backbone: backboneSchema,
1163
+ baseVersion: z8.string(),
1164
+ slug: z8.string(),
1165
+ label: z8.string(),
1166
+ notes: z8.string().nullable(),
1167
+ /** The snapshot version this upload installs as (`<base>+<slug>`). */
1168
+ version: z8.string(),
1169
+ originalFilename: z8.string().nullable(),
1170
+ sha256: z8.string(),
1171
+ sizeBytes: z8.number().int().nonnegative(),
1172
+ status: derivedUploadStatusSchema,
1173
+ rowsRead: z8.number().int().nonnegative(),
1174
+ report: derivedReportSchema.nullable(),
1175
+ message: z8.string().nullable(),
1176
+ createdAt: z8.string().datetime(),
1177
+ expiresAt: z8.string().datetime(),
1178
+ importedVersion: z8.string().nullable()
1179
+ });
1180
+ var derivedConfirmBodySchema = z8.object({
1181
+ /** Acknowledge warnings. Never overrides errors. */
1182
+ force: z8.boolean().optional()
1183
+ });
1184
+
1185
+ // ../shared/src/schemas/openrouter.ts
1186
+ import { z as z9 } from "zod";
1187
+ var openRouterModelSchema = z9.object({
1188
+ /** Full model id, e.g. `anthropic/claude-opus-4.8` or the alias
1189
+ * `~anthropic/claude-haiku-latest`. Used verbatim as the OpenRouter model. */
1190
+ id: z9.string(),
1191
+ /** Human label from OpenRouter (falls back to the id). */
1192
+ name: z9.string(),
1193
+ /** Author slug (the part before `/`), with any leading `~` stripped. */
1194
+ author: z9.string(),
1195
+ /** Unix seconds the model was published; used to rank "latest". */
1196
+ created: z9.number(),
1197
+ /** Context window in tokens, when OpenRouter reports it. */
1198
+ contextLength: z9.number().nullable(),
1199
+ /** USD price per PROMPT token (input). Null when OpenRouter doesn't report a
1200
+ * numeric price. 0 = free. */
1201
+ promptPriceUsd: z9.number().nullable(),
1202
+ /** USD price per COMPLETION token (output). */
1203
+ completionPriceUsd: z9.number().nullable(),
1204
+ /** An auto-updating `…-latest` pointer (e.g. `~anthropic/claude-haiku-latest`). */
1205
+ isAlias: z9.boolean()
1206
+ });
584
1207
 
585
1208
  // ../shared/src/normalize.ts
586
1209
  var NULL_SENTINELS = /* @__PURE__ */ new Set(["", "na", "n/a", "null", "-", "\u2014", "unknown", "undet", "undet."]);
@@ -708,183 +1331,1175 @@ function detectIdTypeDistribution(samples, opts) {
708
1331
  }
709
1332
 
710
1333
  // ../shared/src/column-map.ts
711
- import { z as z5 } from "zod";
712
- var columnMappingSchema = z5.object({
713
- nameColumn: z5.string().nullable(),
714
- idColumn: z5.string().nullable(),
715
- familyColumn: z5.string().nullable(),
716
- genusColumn: z5.string().nullable(),
717
- rankColumn: z5.string().nullable(),
718
- authorColumn: z5.string().nullable()
719
- });
720
- var detectColumnsBodySchema = z5.object({
721
- headers: z5.array(z5.string().min(1)).min(1).max(200)
722
- });
723
- var detectColumnsResponseSchema = z5.object({
1334
+ import { z as z10 } from "zod";
1335
+ var columnMappingSchema = z10.object({
1336
+ nameColumn: z10.string().nullable(),
1337
+ idColumn: z10.string().nullable(),
1338
+ familyColumn: z10.string().nullable(),
1339
+ genusColumn: z10.string().nullable(),
1340
+ rankColumn: z10.string().nullable(),
1341
+ authorColumn: z10.string().nullable()
1342
+ });
1343
+ var detectColumnsBodySchema = z10.object({
1344
+ headers: z10.array(z10.string().min(1)).min(1).max(200)
1345
+ });
1346
+ var detectColumnsResponseSchema = z10.object({
724
1347
  mapping: columnMappingSchema,
725
- usedLlm: z5.boolean()
1348
+ usedLlm: z10.boolean()
726
1349
  });
727
1350
 
728
- // src/api-client.ts
729
- import { promises as fs } from "fs";
730
- import { basename } from "path";
731
- import { FormData, fetch, request } from "undici";
732
- var ApiError = class extends Error {
733
- constructor(message, status, body) {
1351
+ // ../shared/src/env-flag.ts
1352
+ var FALSY = /* @__PURE__ */ new Set(["false", "0", "no", "off"]);
1353
+ function parseEnvFlag(raw, fallback) {
1354
+ if (raw === void 0 || raw.trim() === "") return fallback;
1355
+ return !FALSY.has(raw.trim().toLowerCase());
1356
+ }
1357
+
1358
+ // ../shared/src/public-server.ts
1359
+ var DEFAULT_SERVER_URL = "https://planttaxomatcher.plantnet.org";
1360
+
1361
+ // src/errors.ts
1362
+ import { CommanderError } from "commander";
1363
+ var EXIT = {
1364
+ ok: 0,
1365
+ error: 1,
1366
+ usage: 2,
1367
+ auth: 3,
1368
+ jobFailed: 4,
1369
+ jobPaused: 5
1370
+ };
1371
+ var LOGIN_HINT = "Run `planttaxomatcher login`, or set PLANTTAXOMATCHER_TOKEN.";
1372
+ var CliError = class extends Error {
1373
+ constructor(message, exitCode = EXIT.error, hint) {
734
1374
  super(message);
1375
+ this.exitCode = exitCode;
1376
+ this.hint = hint;
1377
+ }
1378
+ exitCode;
1379
+ hint;
1380
+ };
1381
+ var ApiError = class extends Error {
1382
+ constructor(status, body) {
1383
+ super(describeApiFailure(status, body));
735
1384
  this.status = status;
736
1385
  this.body = body;
737
1386
  }
738
1387
  status;
739
1388
  body;
740
1389
  };
741
- async function call(creds, path, init) {
742
- const url = new URL(path, creds.server).toString();
743
- const hasBody = init?.body !== void 0;
744
- const res = await request(url, {
745
- method: init?.method ?? "GET",
746
- headers: {
747
- authorization: `Bearer ${creds.token}`,
748
- ...hasBody ? { "content-type": "application/json" } : {}
749
- },
750
- ...hasBody ? { body: JSON.stringify(init.body) } : {}
751
- });
752
- const text = await res.body.text();
753
- const parsed = text ? safeJson(text) : null;
754
- if (res.statusCode >= 400) {
755
- throw new ApiError(`HTTP ${res.statusCode}`, res.statusCode, parsed ?? text);
1390
+ function describeApiFailure(status, body) {
1391
+ const parsed = body && typeof body === "object" ? body : {};
1392
+ const text = typeof body === "string" ? body.trim() : "";
1393
+ const code = typeof parsed.error === "string" ? parsed.error : "";
1394
+ const message = typeof parsed.message === "string" ? parsed.message : "";
1395
+ if (code === "missing_scope" && Array.isArray(parsed.required)) {
1396
+ return `HTTP ${status}: this token lacks the ${parsed.required.join(", ")} scope`;
756
1397
  }
757
- return parsed;
1398
+ if (text.startsWith("<"))
1399
+ return `HTTP ${status}: the server answered with a web page, not the API \u2014 check the server URL`;
1400
+ const detail = message || code || text.slice(0, 200);
1401
+ return detail ? `HTTP ${status}: ${detail}` : `HTTP ${status}`;
1402
+ }
1403
+ function describeError(err) {
1404
+ if (err instanceof CommanderError) {
1405
+ return { exitCode: err.exitCode === 0 ? EXIT.ok : EXIT.usage, message: null };
1406
+ }
1407
+ if (err instanceof CliError) {
1408
+ return { exitCode: err.exitCode, message: err.message, ...err.hint ? { hint: err.hint } : {} };
1409
+ }
1410
+ if (err instanceof ApiError) {
1411
+ if (err.status === 401) return { exitCode: EXIT.auth, message: err.message, hint: LOGIN_HINT };
1412
+ return { exitCode: EXIT.error, message: err.message };
1413
+ }
1414
+ return { exitCode: EXIT.error, message: err instanceof Error ? err.message : String(err) };
1415
+ }
1416
+ function unreachable(server, err) {
1417
+ const cause = err instanceof Error && err.cause instanceof Error ? err.cause : err;
1418
+ const code = cause?.code;
1419
+ const reason = code ?? (cause instanceof Error ? cause.message : String(cause));
1420
+ return new CliError(`cannot reach ${server}: ${reason}`);
758
1421
  }
759
- function safeJson(s) {
1422
+
1423
+ // src/ndjson.ts
1424
+ async function* parseNdjson(chunks) {
1425
+ const decoder = new TextDecoder("utf-8");
1426
+ let buffered = "";
1427
+ for await (const chunk of chunks) {
1428
+ buffered += typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
1429
+ let newline;
1430
+ while ((newline = buffered.indexOf("\n")) >= 0) {
1431
+ const frame = parseLine(buffered.slice(0, newline));
1432
+ buffered = buffered.slice(newline + 1);
1433
+ if (frame) yield frame;
1434
+ }
1435
+ }
1436
+ const last = parseLine(buffered + decoder.decode());
1437
+ if (last) yield last;
1438
+ }
1439
+ function parseLine(line) {
1440
+ const trimmed = line.trim();
1441
+ if (!trimmed) return null;
760
1442
  try {
761
- return JSON.parse(s);
1443
+ const value = JSON.parse(trimmed);
1444
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
762
1445
  } catch {
763
1446
  return null;
764
1447
  }
765
1448
  }
766
- var apiClient = {
767
- me: (c) => call(c, "/v1/me"),
768
- listJobs: (c) => call(c, "/v1/jobs"),
769
- getJob: (c, id) => call(c, `/v1/jobs/${id}`),
770
- downloadColumns: (c, id) => call(c, `/v1/jobs/${id}/download/columns`),
771
- pauseJob: (c, id) => call(c, `/v1/jobs/${id}/pause`, { method: "POST" }),
772
- resumeJob: (c, id) => call(c, `/v1/jobs/${id}/resume`, { method: "POST" }),
773
- cancelJob: (c, id) => call(c, `/v1/jobs/${id}/cancel`, { method: "POST" }),
774
- async submitJob(creds, filePath, config, name) {
775
- const buf = await fs.readFile(filePath);
776
- const fd = new FormData();
777
- const lower = filePath.toLowerCase();
778
- const mime = lower.endsWith(".json") ? "application/json" : "text/csv";
779
- fd.set("file", new Blob([buf], { type: mime }), basename(filePath));
780
- fd.set("config", JSON.stringify(config));
781
- if (name && name.trim()) fd.set("name", name.trim());
782
- const url = new URL("/v1/jobs", creds.server).toString();
783
- const res = await fetch(url, {
784
- method: "POST",
785
- body: fd,
786
- headers: { authorization: `Bearer ${creds.token}` }
1449
+
1450
+ // src/api-client.ts
1451
+ var MIME_BY_EXTENSION = {
1452
+ json: "application/json",
1453
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
1454
+ xls: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
1455
+ };
1456
+ function uploadMimeType(filePath) {
1457
+ const extension = filePath.toLowerCase().split(".").pop() ?? "";
1458
+ return MIME_BY_EXTENSION[extension] ?? "text/csv";
1459
+ }
1460
+ function downloadFilename(contentDisposition, jobId, opts) {
1461
+ const match = /filename="?([^";]+)"?/.exec(contentDisposition ?? "");
1462
+ const suggested = match?.[1] ? basename(match[1].replaceAll("\\", "/")) : "";
1463
+ if (suggested && suggested !== "." && suggested !== "..") return suggested;
1464
+ return `planttaxomatcher_${jobId}.${opts.bundle ? "zip" : opts.format}`;
1465
+ }
1466
+ function downloadQuery(opts) {
1467
+ const params = new URLSearchParams({ format: opts.format });
1468
+ if (opts.confirmedOnly) params.set("confirmedOnly", "true");
1469
+ if (opts.dedupe) params.set("dedupe", "true");
1470
+ if (opts.bundle) params.set("bundle", "true");
1471
+ if (opts.delimiter) params.set("delimiter", opts.delimiter);
1472
+ if (opts.columns) params.set("columns", opts.columns);
1473
+ if (opts.wcvpExtra) params.set("wcvpExtra", opts.wcvpExtra);
1474
+ return params;
1475
+ }
1476
+ function notAnApi(server, path) {
1477
+ return new CliError(`${server} did not answer ${path} like a PlantTaxoMatcher API \u2014 check the server URL`);
1478
+ }
1479
+ function safeJson(text) {
1480
+ try {
1481
+ return JSON.parse(text);
1482
+ } catch {
1483
+ return null;
1484
+ }
1485
+ }
1486
+ function createApiClient(options = {}) {
1487
+ const dispatcher = options.dispatcher ? { dispatcher: options.dispatcher } : {};
1488
+ async function send(target, path, init = {}) {
1489
+ const headers = target.token ? { authorization: `Bearer ${target.token}` } : {};
1490
+ if (typeof init.body === "string") headers["content-type"] = "application/json";
1491
+ try {
1492
+ return await fetch(new URL(path, target.server), {
1493
+ method: init.method ?? "GET",
1494
+ headers,
1495
+ ...init.body !== void 0 ? { body: init.body } : {},
1496
+ ...dispatcher
1497
+ });
1498
+ } catch (err) {
1499
+ throw unreachable(target.server, err);
1500
+ }
1501
+ }
1502
+ async function call(creds, path, init = {}) {
1503
+ const res = await send(creds, path, {
1504
+ ...init.method ? { method: init.method } : {},
1505
+ ...init.json !== void 0 ? { body: JSON.stringify(init.json) } : {}
787
1506
  });
788
1507
  const text = await res.text();
789
1508
  const parsed = text ? safeJson(text) : null;
790
- if (!res.ok) throw new ApiError(`HTTP ${res.status}`, res.status, parsed ?? text);
1509
+ if (!res.ok) throw new ApiError(res.status, parsed ?? text);
1510
+ if (parsed === null) throw notAnApi(creds.server, path);
791
1511
  return parsed;
792
- },
793
- async downloadJob(creds, id, opts) {
794
- const params = new URLSearchParams({ format: opts.format });
795
- if (opts.confirmedOnly) params.set("confirmedOnly", "true");
796
- if (opts.bundle) params.set("bundle", "true");
797
- if (opts.delimiter) params.set("delimiter", opts.delimiter);
798
- if (opts.columns) params.set("columns", opts.columns);
799
- if (opts.wcvpExtra) params.set("wcvpExtra", opts.wcvpExtra);
800
- const url = new URL(`/v1/jobs/${id}/download?${params}`, creds.server).toString();
801
- const res = await fetch(url, { headers: { authorization: `Bearer ${creds.token}` } });
802
- if (!res.ok) {
803
- const text = await res.text().catch(() => "");
804
- throw new ApiError(`HTTP ${res.status}`, res.status, text);
805
- }
806
- const disp = res.headers.get("content-disposition") ?? "";
807
- const m = /filename="?([^"]+)"?/.exec(disp);
808
- const ext = opts.bundle ? "zip" : opts.format === "csv" ? "csv" : opts.format === "json" ? "json" : opts.format === "xlsx" ? "xlsx" : "ndjson";
809
- const filename = m?.[1] ?? `planttaxomatcher_${id}.${ext}`;
810
- const buf = Buffer.from(await res.arrayBuffer());
811
- return { filename, body: buf };
812
- },
813
- async *streamJob(creds, id) {
814
- const url = new URL(`/v1/jobs/${id}/stream`, creds.server).toString();
815
- const res = await fetch(url, { headers: { authorization: `Bearer ${creds.token}` } });
816
- if (!res.ok || !res.body) {
817
- const text = await res.text().catch(() => "");
818
- throw new ApiError(`HTTP ${res.status}`, res.status, text);
819
- }
820
- const reader = res.body.getReader();
821
- const decoder = new TextDecoder("utf-8");
822
- let buf = "";
823
- try {
824
- for (; ; ) {
825
- const { done, value } = await reader.read();
826
- if (done) break;
827
- buf += decoder.decode(value, { stream: true });
828
- let nl;
829
- while ((nl = buf.indexOf("\n")) >= 0) {
830
- const line = buf.slice(0, nl).trim();
831
- buf = buf.slice(nl + 1);
832
- if (!line) continue;
833
- try {
834
- yield JSON.parse(line);
835
- } catch {
836
- }
1512
+ }
1513
+ async function failWith(res) {
1514
+ const text = await res.text().catch(() => "");
1515
+ throw new ApiError(res.status, (text && safeJson(text)) ?? text);
1516
+ }
1517
+ return {
1518
+ async startDeviceAuthorization(server, clientName) {
1519
+ let created;
1520
+ try {
1521
+ created = await call({ server }, "/v1/device-authorizations", {
1522
+ method: "POST",
1523
+ json: { clientName }
1524
+ });
1525
+ } catch (err) {
1526
+ if (err instanceof ApiError && err.status === 404) {
1527
+ throw new CliError(
1528
+ `${server} does not offer browser sign-in`,
1529
+ EXIT.usage,
1530
+ "Pass a personal token instead: `planttaxomatcher login --token-stdin`."
1531
+ );
837
1532
  }
1533
+ throw err;
838
1534
  }
839
- } finally {
840
- await reader.cancel().catch(() => {
1535
+ const checked = deviceAuthorizationCreatedSchema.safeParse(created);
1536
+ if (!checked.success) throw notAnApi(server, "/v1/device-authorizations");
1537
+ return checked.data;
1538
+ },
1539
+ /** One poll: the token once approved, else the RFC 8628 reason to keep waiting or stop. */
1540
+ async pollDeviceToken(server, deviceCode) {
1541
+ const res = await send({ server }, "/v1/device-tokens", {
1542
+ method: "POST",
1543
+ body: JSON.stringify({ deviceCode })
841
1544
  });
1545
+ const text = await res.text();
1546
+ const parsed = text ? safeJson(text) : null;
1547
+ if (res.status === 400) {
1548
+ const refusal = deviceTokenErrorSchema.safeParse(parsed);
1549
+ if (refusal.success) return refusal.data;
1550
+ }
1551
+ if (!res.ok) throw new ApiError(res.status, parsed ?? text);
1552
+ const token = deviceTokenSchema.safeParse(parsed);
1553
+ if (!token.success) throw notAnApi(server, "/v1/device-tokens");
1554
+ return token.data;
1555
+ },
1556
+ async me(c) {
1557
+ const checked = meSchema.safeParse(await call(c, "/v1/me"));
1558
+ if (!checked.success) throw notAnApi(c.server, "/v1/me");
1559
+ return checked.data;
1560
+ },
1561
+ listJobs(c, opts = {}) {
1562
+ const params = new URLSearchParams();
1563
+ if (opts.limit !== void 0) params.set("limit", String(opts.limit));
1564
+ if (opts.status) params.set("status", opts.status);
1565
+ const query = params.size > 0 ? `?${params}` : "";
1566
+ return call(c, `/v1/jobs${query}`);
1567
+ },
1568
+ getJob: (c, id) => call(c, `/v1/jobs/${encodeURIComponent(id)}`),
1569
+ downloadColumns: (c, id) => call(c, `/v1/jobs/${encodeURIComponent(id)}/download/columns`),
1570
+ pauseJob: (c, id) => call(c, `/v1/jobs/${encodeURIComponent(id)}/pause`, { method: "POST" }),
1571
+ resumeJob: (c, id) => call(c, `/v1/jobs/${encodeURIComponent(id)}/resume`, { method: "POST" }),
1572
+ cancelJob: (c, id) => call(c, `/v1/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST" }),
1573
+ async submitJob(c, filePath, config, name) {
1574
+ const form = new FormData();
1575
+ form.set("config", JSON.stringify(config));
1576
+ if (name) form.set("name", name);
1577
+ form.set("file", await openAsBlob(filePath, { type: uploadMimeType(filePath) }), basename(filePath));
1578
+ const res = await send(c, "/v1/jobs", { method: "POST", body: form });
1579
+ if (!res.ok) return failWith(res);
1580
+ return await res.json();
1581
+ },
1582
+ async downloadJob(c, id, opts) {
1583
+ const res = await send(c, `/v1/jobs/${encodeURIComponent(id)}/download?${downloadQuery(opts)}`);
1584
+ if (!res.ok) return failWith(res);
1585
+ const filename = downloadFilename(res.headers.get("content-disposition"), id, opts);
1586
+ return { filename, body: Buffer.from(await res.arrayBuffer()) };
1587
+ },
1588
+ async *streamJob(c, id) {
1589
+ const res = await send(c, `/v1/jobs/${encodeURIComponent(id)}/stream`);
1590
+ if (!res.ok || !res.body) return failWith(res);
1591
+ const reader = res.body.getReader();
1592
+ const chunks = {
1593
+ async *[Symbol.asyncIterator]() {
1594
+ for (; ; ) {
1595
+ const { done, value } = await reader.read();
1596
+ if (done) return;
1597
+ yield value;
1598
+ }
1599
+ }
1600
+ };
1601
+ try {
1602
+ yield* parseNdjson(chunks);
1603
+ } finally {
1604
+ await reader.cancel().catch(() => {
1605
+ });
1606
+ }
842
1607
  }
843
- }
844
- };
1608
+ };
1609
+ }
845
1610
 
846
1611
  // src/config.ts
847
- import { promises as fs2 } from "fs";
1612
+ import { promises as fs } from "fs";
848
1613
  import { homedir } from "os";
849
1614
  import { join } from "path";
850
- var CONFIG_DIR = join(homedir(), ".config", "planttaxomatcher");
851
- var CONFIG_FILE = join(CONFIG_DIR, "credentials");
852
- async function readCredentials() {
1615
+
1616
+ // src/transport.ts
1617
+ function isLoopback(hostname2) {
1618
+ return hostname2 === "localhost" || hostname2 === "127.0.0.1" || hostname2 === "[::1]" || hostname2 === "::1" || hostname2.endsWith(".localhost");
1619
+ }
1620
+ function assertServerTransport(server, options = { allowInsecure: false }) {
1621
+ let url;
1622
+ try {
1623
+ url = new URL(server);
1624
+ } catch {
1625
+ throw new CliError(`invalid server URL: ${server}`, EXIT.usage);
1626
+ }
1627
+ if (url.protocol === "https:") return;
1628
+ if (url.protocol !== "http:") {
1629
+ throw new CliError(`server must use http or https (got ${url.protocol})`, EXIT.usage);
1630
+ }
1631
+ if (!isLoopback(url.hostname) && !options.allowInsecure) {
1632
+ const override = options.insecureFlag ? ", or pass --insecure to override (NOT recommended)" : "";
1633
+ throw new CliError(
1634
+ `refusing to send a token in cleartext to ${url.host}. Use an https URL${override}.`,
1635
+ EXIT.usage
1636
+ );
1637
+ }
1638
+ }
1639
+
1640
+ // src/config.ts
1641
+ function defaultConfigDir() {
1642
+ return join(homedir(), ".config", "planttaxomatcher");
1643
+ }
1644
+ function createCredentialStore(dir = defaultConfigDir()) {
1645
+ const file = join(dir, "credentials");
1646
+ return {
1647
+ file,
1648
+ async read() {
1649
+ try {
1650
+ return JSON.parse(await fs.readFile(file, "utf8"));
1651
+ } catch (err) {
1652
+ if (err.code === "ENOENT") return null;
1653
+ throw err;
1654
+ }
1655
+ },
1656
+ async write(creds) {
1657
+ await fs.mkdir(dir, { recursive: true, mode: 448 });
1658
+ await fs.writeFile(file, JSON.stringify(creds, null, 2), { mode: 384 });
1659
+ await fs.chmod(file, 384);
1660
+ },
1661
+ async clear() {
1662
+ await fs.rm(file, { force: true });
1663
+ }
1664
+ };
1665
+ }
1666
+ function envValue(env2, key) {
1667
+ const value = env2[key]?.trim();
1668
+ return value ? value : void 0;
1669
+ }
1670
+ function serverFromEnv(env2) {
1671
+ return envValue(env2, "PLANTTAXOMATCHER_SERVER");
1672
+ }
1673
+ function tokenFromEnv(env2) {
1674
+ return envValue(env2, "PLANTTAXOMATCHER_TOKEN");
1675
+ }
1676
+ function sameServer(a, b) {
1677
+ const normalize = (url) => url.trim().replace(/\/+$/, "").toLowerCase();
1678
+ return normalize(a) === normalize(b);
1679
+ }
1680
+ function resolveCredentials(stored, env2) {
1681
+ const envToken = tokenFromEnv(env2);
1682
+ const envServer = serverFromEnv(env2);
1683
+ if (envToken) return { token: envToken, server: envServer ?? DEFAULT_SERVER_URL };
1684
+ if (!stored) return null;
1685
+ if (envServer && !sameServer(envServer, stored.server)) {
1686
+ throw new CliError(
1687
+ `PLANTTAXOMATCHER_SERVER (${envServer}) is not the server of the saved login (${stored.server})`,
1688
+ EXIT.usage,
1689
+ "Set PLANTTAXOMATCHER_TOKEN for that server too, or run `planttaxomatcher login --server <url>`."
1690
+ );
1691
+ }
1692
+ return stored;
1693
+ }
1694
+ async function requireCredentials(store, env2) {
1695
+ const envServer = serverFromEnv(env2);
1696
+ if (envServer) assertServerTransport(envServer, { allowInsecure: false });
1697
+ const creds = resolveCredentials(await store.read(), env2);
1698
+ if (!creds) throw new CliError("not signed in", EXIT.auth, LOGIN_HINT);
1699
+ return creds;
1700
+ }
1701
+
1702
+ // src/skills/paths.ts
1703
+ import { join as join2 } from "path";
1704
+ var SKILL_NAME = "planttaxomatcher";
1705
+ var MARKER_FILE = ".planttaxomatcher-managed";
1706
+ var AGENTS = ["claude", "codex", "opencode", "pi"];
1707
+ var SKILL_DIRS = ["claude", "agents", "opencode", "pi"];
1708
+ var env = (value) => value?.trim() ? value.trim() : void 0;
1709
+ function skillPaths(home, vars) {
1710
+ const dataHome = env(vars.XDG_DATA_HOME) ?? join2(home, ".local", "share");
1711
+ const configHome = env(vars.XDG_CONFIG_HOME) ?? join2(home, ".config");
1712
+ const claudeHome = env(vars.CLAUDE_CONFIG_DIR) ?? join2(home, ".claude");
1713
+ return {
1714
+ canonical: join2(dataHome, "planttaxomatcher", "skills", SKILL_NAME),
1715
+ entries: {
1716
+ claude: join2(claudeHome, "skills", SKILL_NAME),
1717
+ agents: join2(home, ".agents", "skills", SKILL_NAME),
1718
+ opencode: join2(configHome, "opencode", "skills", SKILL_NAME),
1719
+ pi: join2(home, ".pi", "agent", "skills", SKILL_NAME)
1720
+ },
1721
+ agentHomes: {
1722
+ claude: claudeHome,
1723
+ codex: join2(home, ".codex"),
1724
+ opencode: join2(configHome, "opencode"),
1725
+ pi: join2(home, ".pi")
1726
+ }
1727
+ };
1728
+ }
1729
+ var READS = {
1730
+ claude: ["claude"],
1731
+ codex: ["agents"],
1732
+ opencode: ["opencode", "agents", "claude"],
1733
+ pi: ["pi", "agents"]
1734
+ };
1735
+ function linkPlan(agents, alreadyLinked) {
1736
+ const plan = /* @__PURE__ */ new Set();
1737
+ if (agents.includes("claude")) plan.add("claude");
1738
+ if (agents.includes("codex") || agents.includes("pi")) plan.add("agents");
1739
+ const openCodeCovered = ["claude", "agents"].some(
1740
+ (dir) => plan.has(dir) || alreadyLinked.has(dir)
1741
+ );
1742
+ if (agents.includes("opencode") && !openCodeCovered) plan.add("opencode");
1743
+ return [...plan];
1744
+ }
1745
+ function parseAgents(value) {
1746
+ const names = value.split(",").map((name) => name.trim().toLowerCase()).filter(Boolean);
1747
+ if (names.includes("all")) return "all";
1748
+ return names.length > 0 && names.every((name) => AGENTS.includes(name)) ? names : null;
1749
+ }
1750
+ function isNewerVersion(candidate, current) {
1751
+ const parse = (v) => v.split(/[.+-]/).slice(0, 3).map((n) => Number.parseInt(n, 10) || 0);
1752
+ const [a, b] = [parse(candidate), parse(current)];
1753
+ for (let i = 0; i < 3; i++) {
1754
+ if (a[i] !== b[i]) return a[i] > b[i];
1755
+ }
1756
+ return false;
1757
+ }
1758
+
1759
+ // src/deps.ts
1760
+ async function readAllStdin() {
1761
+ const chunks = [];
1762
+ for await (const chunk of process.stdin) chunks.push(chunk);
1763
+ return Buffer.concat(chunks).toString("utf8").trim();
1764
+ }
1765
+ function browserCommand(url) {
1766
+ if (process.platform === "darwin") return ["open", [url]];
1767
+ if (process.platform === "win32") return ["rundll32", ["url.dll,FileProtocolHandler", url]];
1768
+ return ["xdg-open", [url]];
1769
+ }
1770
+ function openInBrowser(url, env2) {
1771
+ if (env2.SSH_CONNECTION || env2.SSH_TTY) return Promise.resolve(false);
1772
+ const [command, args] = browserCommand(url);
1773
+ return new Promise((resolve2) => {
1774
+ const child = spawn(command, args, { stdio: "ignore", detached: true });
1775
+ child.once("error", () => resolve2(false));
1776
+ child.once("spawn", () => {
1777
+ child.unref();
1778
+ resolve2(true);
1779
+ });
1780
+ });
1781
+ }
1782
+ function defaultDeps() {
1783
+ return {
1784
+ api: createApiClient(),
1785
+ store: createCredentialStore(),
1786
+ env: process.env,
1787
+ stdout: process.stdout,
1788
+ stderr: process.stderr,
1789
+ stdinIsTTY: !!process.stdin.isTTY,
1790
+ readStdin: readAllStdin,
1791
+ promptConfirm: (message) => confirm({ message, default: true }),
1792
+ openBrowser: (url) => openInBrowser(url, process.env),
1793
+ promptAgents: (detected) => checkbox({
1794
+ message: "Install the skill for",
1795
+ choices: AGENTS.map((agent) => ({ value: agent, checked: detected.includes(agent) }))
1796
+ }),
1797
+ hostname,
1798
+ home: homedir2(),
1799
+ // `src/` in development and the bundled `dist/` both sit next to `skills/`.
1800
+ skillSource: fileURLToPath(new URL("../skills/planttaxomatcher", import.meta.url)),
1801
+ sleep: (ms) => sleep(ms),
1802
+ now: Date.now
1803
+ };
1804
+ }
1805
+
1806
+ // src/program.ts
1807
+ import { Command } from "commander";
1808
+ import kleur9 from "kleur";
1809
+
1810
+ // src/output.ts
1811
+ import kleur from "kleur";
1812
+ function createOutput(stdout, stderr, json, now = Date.now) {
1813
+ const human = json ? stderr : stdout;
1814
+ const line = (text) => human.write(`${text}
1815
+ `);
1816
+ return {
1817
+ json,
1818
+ info: line,
1819
+ success: (message) => line(`${kleur.green("\u2713")} ${message}`),
1820
+ warn: (message) => stderr.write(`${kleur.yellow("!")} ${message}
1821
+ `),
1822
+ data: (value) => stdout.write(`${JSON.stringify(value)}
1823
+ `),
1824
+ progress: createProgressWriter(human, now)
1825
+ };
1826
+ }
1827
+ var NON_TTY_PROGRESS_INTERVAL_MS = 5e3;
1828
+ function createProgressWriter(sink, now, intervalMs = NON_TTY_PROGRESS_INTERVAL_MS) {
1829
+ let width = 0;
1830
+ let lastEmittedAt = null;
1831
+ let pending = null;
1832
+ if (sink.isTTY) {
1833
+ return {
1834
+ update(line) {
1835
+ sink.write(`\r${line.padEnd(width)}`);
1836
+ width = line.length;
1837
+ },
1838
+ done() {
1839
+ if (width > 0) sink.write("\n");
1840
+ width = 0;
1841
+ }
1842
+ };
1843
+ }
1844
+ return {
1845
+ update(line) {
1846
+ const at = now();
1847
+ if (lastEmittedAt !== null && at - lastEmittedAt < intervalMs) {
1848
+ pending = line;
1849
+ return;
1850
+ }
1851
+ sink.write(`${line}
1852
+ `);
1853
+ lastEmittedAt = at;
1854
+ pending = null;
1855
+ },
1856
+ done() {
1857
+ if (pending !== null) sink.write(`${pending}
1858
+ `);
1859
+ pending = null;
1860
+ lastEmittedAt = null;
1861
+ }
1862
+ };
1863
+ }
1864
+
1865
+ // src/device-login.ts
1866
+ import kleur2 from "kleur";
1867
+ var RESTART_HINT = "Run `planttaxomatcher login` again.";
1868
+ var CLIENT_NAME_MAX = 100;
1869
+ function isTransient(err) {
1870
+ if (err instanceof ApiError) return err.status === 429 || err.status >= 500;
1871
+ return err instanceof CliError && err.message.startsWith("cannot reach");
1872
+ }
1873
+ async function pollOnce(deps2, server, deviceCode) {
853
1874
  try {
854
- const raw = await fs2.readFile(CONFIG_FILE, "utf8");
855
- return JSON.parse(raw);
1875
+ return await deps2.api.pollDeviceToken(server, deviceCode);
856
1876
  } catch (err) {
857
- if (err.code === "ENOENT") return null;
1877
+ if (isTransient(err)) return { error: "slow_down" };
858
1878
  throw err;
859
1879
  }
860
1880
  }
861
- async function writeCredentials(creds) {
862
- await fs2.mkdir(CONFIG_DIR, { recursive: true, mode: 448 });
863
- await fs2.writeFile(CONFIG_FILE, JSON.stringify(creds, null, 2), { mode: 384 });
1881
+ async function deviceLogin(deps2, out, server, options) {
1882
+ const clientName = deps2.hostname().trim().slice(0, CLIENT_NAME_MAX) || "unknown";
1883
+ const request = await deps2.api.startDeviceAuthorization(server, clientName);
1884
+ out.info(`To sign in, open this page and approve the code ${kleur2.bold(request.userCode)}:`);
1885
+ out.info(` ${kleur2.cyan(request.verificationUriComplete)}`);
1886
+ if (options.openBrowser && await deps2.openBrowser(request.verificationUriComplete)) {
1887
+ out.info(kleur2.gray("Opened it in your browser."));
1888
+ }
1889
+ out.info(kleur2.gray("Waiting for approval\u2026"));
1890
+ const deadline = deps2.now() + request.expiresIn * 1e3;
1891
+ let interval = request.interval;
1892
+ while (deps2.now() < deadline) {
1893
+ await deps2.sleep(interval * 1e3);
1894
+ const answer = await pollOnce(deps2, server, request.deviceCode);
1895
+ if ("token" in answer) return answer;
1896
+ if (answer.error === "slow_down") interval = answer.interval ?? interval + SLOW_DOWN_STEP_SECONDS;
1897
+ else if (answer.error === "access_denied")
1898
+ throw new CliError("the sign-in was denied in the browser", EXIT.auth);
1899
+ else if (answer.error === "expired_token") break;
1900
+ }
1901
+ throw new CliError("the sign-in code expired before it was approved", EXIT.auth, RESTART_HINT);
1902
+ }
1903
+
1904
+ // src/commands/auth.ts
1905
+ async function suppliedToken(opts, deps2) {
1906
+ if (opts.tokenStdin) {
1907
+ const token = (await deps2.readStdin()).trim();
1908
+ if (!token) throw new CliError("--token-stdin was set but stdin was empty", EXIT.usage);
1909
+ return token;
1910
+ }
1911
+ if (opts.token) return opts.token.trim();
1912
+ return tokenFromEnv(deps2.env) ?? null;
1913
+ }
1914
+ async function browserToken(deps2, out, server, opts) {
1915
+ if (parseEnvFlag(deps2.env.CI, false)) {
1916
+ throw new CliError(
1917
+ "no token given, and a browser sign-in cannot be approved in CI",
1918
+ EXIT.usage,
1919
+ "Set PLANTTAXOMATCHER_TOKEN, or pipe one to `planttaxomatcher login --token-stdin`."
1920
+ );
1921
+ }
1922
+ return (await deviceLogin(deps2, out, server, { openBrowser: opts.browser !== false })).token;
864
1923
  }
865
- async function clearCredentials() {
1924
+ async function verifyToken(deps2, server, token) {
866
1925
  try {
867
- await fs2.unlink(CONFIG_FILE);
1926
+ return await deps2.api.me({ server, token });
868
1927
  } catch (err) {
869
- if (err.code !== "ENOENT") throw err;
1928
+ if (err instanceof ApiError && err.status === 401) {
1929
+ throw new CliError(`${server} rejected this token (${err.message})`, EXIT.auth);
1930
+ }
1931
+ if (err instanceof ApiError && err.status === 403) return null;
1932
+ throw err;
870
1933
  }
871
1934
  }
872
- async function requireCredentials() {
873
- const creds = await readCredentials();
874
- if (!creds) {
875
- throw new Error("Not logged in. Run: planttaxomatcher login --token <t> --server <url>");
1935
+ function registerAuthCommands(program, deps2) {
1936
+ program.command("login").description(
1937
+ "Sign in through the browser (approve a code on the web app), or save a personal token you pass in"
1938
+ ).option(
1939
+ "--token <token>",
1940
+ "Personal token (ptm_...). Avoid on shared hosts: it is visible in shell history and the process list. Prefer --token-stdin or PLANTTAXOMATCHER_TOKEN."
1941
+ ).option(
1942
+ "--token-stdin",
1943
+ "Read the token from stdin (e.g. `cat token.txt | planttaxomatcher login --token-stdin`)",
1944
+ false
1945
+ ).option("--server <url>", `API server URL (default: $PLANTTAXOMATCHER_SERVER, else ${DEFAULT_SERVER_URL})`).option(
1946
+ "--insecure",
1947
+ "Allow sending the token over cleartext http to a non-loopback server (NOT recommended)",
1948
+ false
1949
+ ).option("--no-browser", "Print the sign-in link without opening a browser").option("--json", "Print the result as JSON", false).action(async (opts) => {
1950
+ const out = createOutput(deps2.stdout, deps2.stderr, !!opts.json, deps2.now);
1951
+ const server = opts.server ?? serverFromEnv(deps2.env) ?? DEFAULT_SERVER_URL;
1952
+ assertServerTransport(server, { allowInsecure: !!opts.insecure, insecureFlag: true });
1953
+ const token = await suppliedToken(opts, deps2) ?? await browserToken(deps2, out, server, opts);
1954
+ if (!tokenStringSchema.safeParse(token).success) {
1955
+ throw new CliError("malformed token \u2014 expected ptm_<12 chars>_<32 chars>", EXIT.usage);
1956
+ }
1957
+ const me = await verifyToken(deps2, server, token);
1958
+ await deps2.store.write({ token, server, ...me?.appUrl ? { appUrl: me.appUrl } : {} });
1959
+ if (out.json) {
1960
+ out.data({ server, displayName: me?.displayName ?? null, scopes: me?.scopes ?? null });
1961
+ return;
1962
+ }
1963
+ out.success(me ? `Signed in to ${server} as ${me.displayName}` : `Signed in to ${server}`);
1964
+ if (!me) out.warn("this token cannot read jobs (no read:job scope)");
1965
+ });
1966
+ program.command("logout").description("Forget the saved token").action(async () => {
1967
+ await deps2.store.clear();
1968
+ createOutput(deps2.stdout, deps2.stderr, false).success("Logged out");
1969
+ });
1970
+ program.command("whoami").description("Show who the current token belongs to").option("--json", "Print the result as JSON", false).action(async (opts) => {
1971
+ const out = createOutput(deps2.stdout, deps2.stderr, !!opts.json, deps2.now);
1972
+ const creds = await requireCredentials(deps2.store, deps2.env);
1973
+ const me = await deps2.api.me(creds);
1974
+ if (out.json) {
1975
+ out.data({ ...me, server: creds.server });
1976
+ return;
1977
+ }
1978
+ out.info(`${me.displayName} team=${me.teamId} scopes=${me.scopes.join(",")}`);
1979
+ out.info(`server=${creds.server}`);
1980
+ });
1981
+ }
1982
+
1983
+ // src/commands/download.ts
1984
+ import { writeFile } from "fs/promises";
1985
+ import kleur3 from "kleur";
1986
+ var FORMATS = ["csv", "xlsx", "json", "ndjson"];
1987
+ var DELIMITERS = ["comma", "semicolon", "tab", "pipe"];
1988
+ function toDownloadOptions(opts) {
1989
+ const format = opts.format;
1990
+ if (!FORMATS.includes(format)) {
1991
+ throw new CliError(`--format must be ${FORMATS.join(", ")} (got ${opts.format})`, EXIT.usage);
876
1992
  }
877
- return creds;
1993
+ const delimiter = opts.delimiter;
1994
+ if (!DELIMITERS.includes(delimiter)) {
1995
+ throw new CliError(`--delimiter must be ${DELIMITERS.join(", ")} (got ${opts.delimiter})`, EXIT.usage);
1996
+ }
1997
+ return {
1998
+ format,
1999
+ confirmedOnly: opts.confirmedOnly,
2000
+ dedupe: opts.dedupe,
2001
+ bundle: opts.bundle,
2002
+ ...format === "csv" && delimiter !== "comma" ? { delimiter } : {},
2003
+ ...opts.columns ? { columns: opts.columns } : {},
2004
+ ...opts.wcvpExtra ? { wcvpExtra: opts.wcvpExtra } : {}
2005
+ };
2006
+ }
2007
+ function printColumns(out, columns) {
2008
+ out.info(kleur3.bold("Result columns (--columns):"));
2009
+ for (const column of columns.result) out.info(` ${column.key} ${kleur3.gray(`(${column.group})`)}`);
2010
+ if (columns.original.length > 0) {
2011
+ out.info(kleur3.bold("\nYour upload columns (--columns):"));
2012
+ for (const key of columns.original) out.info(` ${key}`);
2013
+ }
2014
+ out.info(kleur3.bold("\nWCVP extra fields (--wcvp-extra):"));
2015
+ for (const field of columns.wcvpExtra) out.info(` ${field.key} ${kleur3.gray(`(${field.group})`)}`);
2016
+ }
2017
+ function registerDownloadCommand(program, deps2) {
2018
+ program.command("download <jobId>").description("Download a job export (CSV, XLSX, JSON or NDJSON; optionally zipped with NOTICE.md)").option("--format <fmt>", FORMATS.join(" | "), "csv").option("--confirmed-only", "Only matched rows that are accepted / not pending", false).option("--dedupe", "Collapse rows that resolved to the same accepted taxon to one line", false).option("--delimiter <sep>", `CSV separator: ${DELIMITERS.join(" | ")} (csv only)`, "comma").option("--bundle", "Wrap in a ZIP with NOTICE.md citing the WCVP snapshot and providers", false).option(
2019
+ "--columns <list>",
2020
+ "Comma-separated result/upload column keys to keep (default: all). See --list-columns"
2021
+ ).option(
2022
+ "--wcvp-extra <list>",
2023
+ "Comma-separated extra WCVP fields appended as wcvp_<key> columns (e.g. ipni_id,powo_id). See --list-columns"
2024
+ ).option("--list-columns", "Print the columns available for this job and exit", false).option(
2025
+ "--output <path>",
2026
+ "Write to this path (default: the server-provided file name in the current directory)"
2027
+ ).option("--json", "Print the result (or --list-columns) as JSON", false).action(async (jobId, opts) => {
2028
+ const out = createOutput(deps2.stdout, deps2.stderr, opts.json, deps2.now);
2029
+ const download = toDownloadOptions(opts);
2030
+ const creds = await requireCredentials(deps2.store, deps2.env);
2031
+ if (opts.listColumns) {
2032
+ const columns = await deps2.api.downloadColumns(creds, jobId);
2033
+ if (out.json) return out.data(columns);
2034
+ return printColumns(out, columns);
2035
+ }
2036
+ const { filename, body } = await deps2.api.downloadJob(creds, jobId, download);
2037
+ const path = opts.output ?? filename;
2038
+ await writeFile(path, body);
2039
+ if (out.json) return out.data({ path, bytes: body.length, format: download.format });
2040
+ out.success(`wrote ${body.length} bytes to ${path}`);
2041
+ });
2042
+ }
2043
+
2044
+ // src/commands/jobs.ts
2045
+ import kleur5 from "kleur";
2046
+
2047
+ // src/flags.ts
2048
+ function parseIntegerFlag(value, flag, range = {}) {
2049
+ const parsed = Number(value);
2050
+ const { min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER } = range;
2051
+ if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
2052
+ const bounds = range.min !== void 0 || range.max !== void 0 ? ` between ${min} and ${max}` : "";
2053
+ throw new CliError(`${flag} must be an integer${bounds} (got "${value}")`, EXIT.usage);
2054
+ }
2055
+ return parsed;
2056
+ }
2057
+
2058
+ // src/watch.ts
2059
+ import kleur4 from "kleur";
2060
+ var STOP = /* @__PURE__ */ new Set(["completed", "failed", "cancelled", "paused"]);
2061
+ function isStop(status) {
2062
+ return typeof status === "string" && STOP.has(status);
2063
+ }
2064
+ function stopStatusOf(frame) {
2065
+ if (frame.type === "status" || frame.type === "completed") return isStop(frame.status) ? frame.status : null;
2066
+ if (frame.type === "error") return "failed";
2067
+ return null;
2068
+ }
2069
+ function progressLine(frame) {
2070
+ const processed = Number(frame.processedQueries ?? frame.processedRows ?? 0);
2071
+ const total = Number(frame.totalQueries ?? frame.totalRows ?? 0);
2072
+ const phase = processed === 0 && typeof frame.phase === "string" ? ` (${frame.phase}\u2026)` : "";
2073
+ return ` progress: ${processed}/${total}${phase}`;
2074
+ }
2075
+ function render(out, frame) {
2076
+ if (frame.type === "progress") {
2077
+ out.progress.update(progressLine(frame));
2078
+ return;
2079
+ }
2080
+ if (frame.type === "status") {
2081
+ out.progress.done();
2082
+ out.info(`${kleur4.gray("\u2022")} ${String(frame.status)}`);
2083
+ } else if (frame.type === "error") {
2084
+ out.progress.done();
2085
+ out.info(`${kleur4.red("error:")} ${String(frame.message ?? "job failed")}`);
2086
+ }
2087
+ }
2088
+ async function watchJob(api, out, creds, jobId) {
2089
+ out.progress.done();
2090
+ if (!out.json) out.info(`${kleur4.cyan("\u2192")} Streaming progress for ${jobId} \u2026`);
2091
+ for await (const frame of api.streamJob(creds, jobId)) {
2092
+ if (frame.type === "heartbeat") continue;
2093
+ const status = stopStatusOf(frame);
2094
+ if (out.json) out.data(frame);
2095
+ else if (!(status && frame.type === "status")) render(out, frame);
2096
+ if (status) return finish(out, status);
2097
+ }
2098
+ out.progress.done();
2099
+ const job = await api.getJob(creds, jobId);
2100
+ if (isStop(job.status)) return finish(out, job.status);
2101
+ throw new CliError(
2102
+ `the progress stream closed while job ${jobId} was still ${job.status}`,
2103
+ void 0,
2104
+ `Run \`planttaxomatcher watch ${jobId}\` to follow it again.`
2105
+ );
2106
+ }
2107
+ function finish(out, status) {
2108
+ out.progress.done();
2109
+ if (!out.json) {
2110
+ if (status === "completed") out.success("completed");
2111
+ else if (status === "paused") out.info(kleur4.yellow("paused \u2014 resume it, then watch again"));
2112
+ else out.info(kleur4.red(`\u2717 ${status}`));
2113
+ }
2114
+ return status;
878
2115
  }
879
2116
 
2117
+ // src/commands/jobs.ts
2118
+ function jobUrl(creds, jobId) {
2119
+ return new URL(`/jobs/${encodeURIComponent(jobId)}`, creds.appUrl ?? creds.server).toString();
2120
+ }
2121
+ function watchOutcomeError(results) {
2122
+ const stopped = results.filter((r) => r.status !== "completed");
2123
+ if (stopped.length === 0) return null;
2124
+ const list = stopped.map((r) => `${r.id} (${r.status})`).join(", ");
2125
+ const onlyPaused = stopped.every((r) => r.status === "paused");
2126
+ return new CliError(
2127
+ onlyPaused ? `job paused: ${list}` : `job did not complete: ${list}`,
2128
+ onlyPaused ? EXIT.jobPaused : EXIT.jobFailed,
2129
+ onlyPaused ? "Run `planttaxomatcher resume <jobId>`, then `planttaxomatcher watch <jobId>`." : void 0
2130
+ );
2131
+ }
2132
+ function listLine(job) {
2133
+ const matched = `${String(job.matchedRows).padStart(6)}/${String(job.totalRows).padStart(6)} matched`;
2134
+ return `${job.id} ${job.status.padEnd(10)} ${matched} ${kleur5.gray(job.name ?? "")}`;
2135
+ }
2136
+ var JOB_ACTIONS = {
2137
+ pause: { description: "Pause a running job (the worker stops between match queries)", past: "paused" },
2138
+ resume: { description: "Resume a paused job", past: "resumed" },
2139
+ cancel: { description: "Cancel a job. Rows already matched are kept; pending queries stop.", past: "cancelled" }
2140
+ };
2141
+ function runAction(deps2, action, creds, jobId) {
2142
+ if (action === "pause") return deps2.api.pauseJob(creds, jobId);
2143
+ if (action === "resume") return deps2.api.resumeJob(creds, jobId);
2144
+ return deps2.api.cancelJob(creds, jobId);
2145
+ }
2146
+ function registerJobCommands(program, deps2) {
2147
+ program.command("list").description("List recent jobs, newest first").option("--limit <n>", "How many jobs to show (1-100)", "20").option("--status <status>", `Only jobs in this status: ${jobStatusSchema.options.join(", ")}`).option("--json", "Print the jobs as a JSON array", false).action(async (opts) => {
2148
+ const out = createOutput(deps2.stdout, deps2.stderr, !!opts.json, deps2.now);
2149
+ const status = opts.status === void 0 ? void 0 : jobStatusSchema.safeParse(opts.status);
2150
+ if (status && !status.success) {
2151
+ throw new CliError(`--status must be one of ${jobStatusSchema.options.join(", ")}`, EXIT.usage);
2152
+ }
2153
+ const limit = parseIntegerFlag(opts.limit, "--limit", { min: 1, max: 100 });
2154
+ const creds = await requireCredentials(deps2.store, deps2.env);
2155
+ const jobs = await deps2.api.listJobs(creds, { limit, ...status ? { status: status.data } : {} });
2156
+ if (out.json) return out.data(jobs);
2157
+ if (jobs.length === 0) return out.info(kleur5.gray("No jobs."));
2158
+ for (const job of jobs) out.info(listLine(job));
2159
+ });
2160
+ program.command("status <jobId>").description("Print a job snapshot as JSON (counts, status, timestamps)").option("--json", "Accepted for symmetry: the output is always JSON", false).action(async (jobId) => {
2161
+ const creds = await requireCredentials(deps2.store, deps2.env);
2162
+ const job = await deps2.api.getJob(creds, jobId);
2163
+ deps2.stdout.write(`${JSON.stringify({ ...job, url: jobUrl(creds, job.id) }, null, 2)}
2164
+ `);
2165
+ });
2166
+ program.command("watch <jobId>").description("Follow a job until it stops. Exits 4 if it fails or is cancelled, 5 if it is paused.").option("--json", "Echo each progress frame as one NDJSON line", false).action(async (jobId, opts) => {
2167
+ const out = createOutput(deps2.stdout, deps2.stderr, !!opts.json, deps2.now);
2168
+ const creds = await requireCredentials(deps2.store, deps2.env);
2169
+ const status = await watchJob(deps2.api, out, creds, jobId);
2170
+ const failure = watchOutcomeError([{ id: jobId, status }]);
2171
+ if (failure) throw failure;
2172
+ });
2173
+ for (const [action, { description, past }] of Object.entries(JOB_ACTIONS)) {
2174
+ program.command(`${action} <jobId>`).description(description).option("--json", "Print the updated job as JSON", false).action(async (jobId, opts) => {
2175
+ const out = createOutput(deps2.stdout, deps2.stderr, !!opts.json, deps2.now);
2176
+ const creds = await requireCredentials(deps2.store, deps2.env);
2177
+ const job = await runAction(deps2, action, creds, jobId);
2178
+ if (out.json) return out.data(job);
2179
+ out.success(`${past}: ${job.id} (status=${job.status})`);
2180
+ });
2181
+ }
2182
+ }
2183
+
2184
+ // src/commands/skills.ts
2185
+ import kleur6 from "kleur";
2186
+
2187
+ // src/skills/manage.ts
2188
+ import { promises as fs3 } from "fs";
2189
+ import { dirname as dirname2, resolve } from "path";
2190
+
2191
+ // src/skills/content.ts
2192
+ import { createHash } from "crypto";
2193
+ import { promises as fs2 } from "fs";
2194
+ import { dirname, join as join3, relative } from "path";
2195
+ async function walk(dir, root = dir) {
2196
+ const found = [];
2197
+ for (const entry of await fs2.readdir(dir, { withFileTypes: true })) {
2198
+ const path = join3(dir, entry.name);
2199
+ if (entry.isDirectory()) found.push(...await walk(path, root));
2200
+ else if (entry.isFile() && entry.name !== MARKER_FILE) found.push(relative(root, path));
2201
+ }
2202
+ return found.sort();
2203
+ }
2204
+ async function renderSkill(sourceDir, version2) {
2205
+ const files = /* @__PURE__ */ new Map();
2206
+ for (const path of await walk(sourceDir)) {
2207
+ const text = await fs2.readFile(join3(sourceDir, path), "utf8");
2208
+ files.set(path, text.replaceAll("{{version}}", version2));
2209
+ }
2210
+ return files;
2211
+ }
2212
+ function hashFiles(files) {
2213
+ const hash = createHash("sha256");
2214
+ for (const [path, text] of [...files].sort(([a], [b]) => a.localeCompare(b))) {
2215
+ hash.update(path).update("\0").update(text).update("\0");
2216
+ }
2217
+ return hash.digest("hex");
2218
+ }
2219
+ async function readSkillDir(dir) {
2220
+ const files = /* @__PURE__ */ new Map();
2221
+ for (const path of await walk(dir)) files.set(path, await fs2.readFile(join3(dir, path), "utf8"));
2222
+ return files;
2223
+ }
2224
+ async function readMarker(dir) {
2225
+ try {
2226
+ const parsed = JSON.parse(await fs2.readFile(join3(dir, MARKER_FILE), "utf8"));
2227
+ return typeof parsed.version === "string" && typeof parsed.hash === "string" ? { version: parsed.version, hash: parsed.hash } : null;
2228
+ } catch {
2229
+ return null;
2230
+ }
2231
+ }
2232
+ async function writeSkillDir(dir, files, version2) {
2233
+ const staging = join3(dirname(dirname(dir)), `.planttaxomatcher-staging-${process.pid}-${Date.now()}`);
2234
+ try {
2235
+ for (const [path, text] of files) {
2236
+ await fs2.mkdir(dirname(join3(staging, path)), { recursive: true });
2237
+ await fs2.writeFile(join3(staging, path), text);
2238
+ }
2239
+ const marker = { version: version2, hash: hashFiles(files) };
2240
+ await fs2.writeFile(join3(staging, MARKER_FILE), `${JSON.stringify(marker, null, 2)}
2241
+ `);
2242
+ await fs2.rm(dir, { recursive: true, force: true });
2243
+ await fs2.mkdir(dirname(dir), { recursive: true });
2244
+ await fs2.rename(staging, dir);
2245
+ } finally {
2246
+ await fs2.rm(staging, { recursive: true, force: true });
2247
+ }
2248
+ }
2249
+ async function isPristine(dir) {
2250
+ const marker = await readMarker(dir);
2251
+ return !!marker && hashFiles(await readSkillDir(dir)) === marker.hash;
2252
+ }
2253
+
2254
+ // src/skills/manage.ts
2255
+ var symlink = (target, path) => fs3.symlink(target, path, "junction");
2256
+ function samePath(a, b) {
2257
+ const norm = (p) => resolve(p.replace(/^\\\\\?\\/, ""));
2258
+ return process.platform === "win32" ? norm(a).toLowerCase() === norm(b).toLowerCase() : norm(a) === norm(b);
2259
+ }
2260
+ var OUR_LAYOUT = /[/\\]planttaxomatcher[/\\]skills[/\\]planttaxomatcher$/;
2261
+ async function exists(path) {
2262
+ return fs3.stat(path).then(
2263
+ () => true,
2264
+ () => false
2265
+ );
2266
+ }
2267
+ async function classify(entry, canonical) {
2268
+ const stat = await fs3.lstat(entry).catch(() => null);
2269
+ if (!stat) return { kind: "missing" };
2270
+ if (stat.isSymbolicLink()) {
2271
+ const target = resolve(dirname2(entry), await fs3.readlink(entry));
2272
+ if (samePath(target, canonical)) return await exists(canonical) ? { kind: "link" } : { kind: "broken" };
2273
+ return OUR_LAYOUT.test(target) || await readMarker(target) ? { kind: "stale" } : { kind: "foreign" };
2274
+ }
2275
+ if (!stat.isDirectory()) return { kind: "foreign" };
2276
+ const marker = await readMarker(entry);
2277
+ return marker ? { kind: "copy", version: marker.version, pristine: await isPristine(entry) } : { kind: "foreign" };
2278
+ }
2279
+ async function classifyAll(paths) {
2280
+ const states = {};
2281
+ for (const dir of SKILL_DIRS) states[dir] = await classify(paths.entries[dir], paths.canonical);
2282
+ return states;
2283
+ }
2284
+ var isOurs = (state) => state.kind === "link" || state.kind === "copy" || state.kind === "stale";
2285
+ async function detectAgents(paths) {
2286
+ const found = [];
2287
+ for (const agent of AGENTS) if (await exists(paths.agentHomes[agent])) found.push(agent);
2288
+ return found;
2289
+ }
2290
+ async function writeCanonical(paths, files, version2, force) {
2291
+ const state = await fs3.lstat(paths.canonical).catch(() => null);
2292
+ if (state && !force) {
2293
+ const marker = await readMarker(paths.canonical);
2294
+ if (marker && isNewerVersion(marker.version, version2)) {
2295
+ throw new CliError(
2296
+ `the installed skill (${marker.version}) is newer than this CLI (${version2})`,
2297
+ EXIT.usage,
2298
+ "Run the newer CLI (`npx -y @plantnet/planttaxomatcher@latest skills install`), or pass --force."
2299
+ );
2300
+ }
2301
+ if (!marker || !await isPristine(paths.canonical)) {
2302
+ throw new CliError(
2303
+ `${paths.canonical} was edited or is not ours`,
2304
+ EXIT.usage,
2305
+ "Pass --force to replace it with the skill shipped with this CLI."
2306
+ );
2307
+ }
2308
+ }
2309
+ await writeSkillDir(paths.canonical, files, version2);
2310
+ }
2311
+ async function place(paths, dir, state, files, version2, options) {
2312
+ const entry = paths.entries[dir];
2313
+ if (state.kind === "foreign") return "skipped-foreign";
2314
+ if (state.kind === "link") return "kept";
2315
+ if (state.kind === "copy") {
2316
+ if (!state.pristine && !options.force) return "skipped-edited";
2317
+ await writeSkillDir(entry, files, version2);
2318
+ return "refreshed";
2319
+ }
2320
+ if (state.kind === "broken" || state.kind === "stale") await fs3.unlink(entry);
2321
+ await fs3.mkdir(dirname2(entry), { recursive: true });
2322
+ try {
2323
+ await options.link(paths.canonical, entry);
2324
+ return "linked";
2325
+ } catch {
2326
+ await writeSkillDir(entry, files, version2);
2327
+ return "copied";
2328
+ }
2329
+ }
2330
+ async function installSkill(paths, source, version2, agents, options = {}) {
2331
+ const opts = { force: !!options.force, link: options.link ?? symlink };
2332
+ const files = await renderSkill(source, version2);
2333
+ await writeCanonical(paths, files, version2, opts.force);
2334
+ const states = await classifyAll(paths);
2335
+ const linked = new Set(SKILL_DIRS.filter((dir) => isOurs(states[dir])));
2336
+ const planned = new Set(linkPlan(agents, linked));
2337
+ const entries = [];
2338
+ for (const dir of SKILL_DIRS) {
2339
+ const state = states[dir];
2340
+ if (!planned.has(dir) && !(state.kind === "copy" && state.pristine)) continue;
2341
+ entries.push({ dir, path: paths.entries[dir], action: await place(paths, dir, state, files, version2, opts) });
2342
+ }
2343
+ return { canonical: paths.canonical, entries };
2344
+ }
2345
+ async function uninstallSkill(paths, options = {}) {
2346
+ const entries = [];
2347
+ for (const [dir, state] of Object.entries(await classifyAll(paths))) {
2348
+ const path = paths.entries[dir];
2349
+ if (state.kind === "link" || state.kind === "broken" || state.kind === "stale") {
2350
+ await fs3.unlink(path);
2351
+ entries.push({ dir, path, action: "removed" });
2352
+ } else if (state.kind === "copy") {
2353
+ const removable = state.pristine || options.force;
2354
+ if (removable) await fs3.rm(path, { recursive: true, force: true });
2355
+ entries.push({ dir, path, action: removable ? "removed" : "skipped-edited" });
2356
+ }
2357
+ }
2358
+ const marker = await readMarker(paths.canonical);
2359
+ if (marker && (options.force || await isPristine(paths.canonical))) {
2360
+ await fs3.rm(paths.canonical, { recursive: true, force: true });
2361
+ }
2362
+ return { canonical: paths.canonical, entries };
2363
+ }
2364
+ async function skillStatus(paths) {
2365
+ const states = await classifyAll(paths);
2366
+ const marker = await readMarker(paths.canonical);
2367
+ const installed = new Set(await detectAgents(paths));
2368
+ return {
2369
+ canonical: {
2370
+ path: paths.canonical,
2371
+ version: marker?.version ?? null,
2372
+ pristine: marker ? await isPristine(paths.canonical) : false
2373
+ },
2374
+ entries: SKILL_DIRS.map((dir) => ({ dir, path: paths.entries[dir], ...states[dir] })),
2375
+ agents: AGENTS.map((agent) => {
2376
+ const loads = READS[agent].find((dir) => states[dir].kind !== "missing") ?? null;
2377
+ return { agent, installed: installed.has(agent), loads, state: loads ? states[loads].kind : "missing" };
2378
+ })
2379
+ };
2380
+ }
2381
+ async function refreshSkillIfOutdated(paths, source, version2) {
2382
+ try {
2383
+ const marker = await readMarker(paths.canonical);
2384
+ if (!marker || !isNewerVersion(version2, marker.version) || !await isPristine(paths.canonical)) return false;
2385
+ const files = await renderSkill(source, version2);
2386
+ await writeSkillDir(paths.canonical, files, version2);
2387
+ for (const [dir, state] of Object.entries(await classifyAll(paths))) {
2388
+ if (state.kind === "copy" && state.pristine) await writeSkillDir(paths.entries[dir], files, version2);
2389
+ }
2390
+ return true;
2391
+ } catch {
2392
+ return false;
2393
+ }
2394
+ }
2395
+
2396
+ // src/commands/skills.ts
2397
+ var AGENT_NAMES = { claude: "Claude Code", codex: "Codex", opencode: "OpenCode", pi: "pi" };
2398
+ var SERVES = {
2399
+ claude: "Claude Code (and OpenCode)",
2400
+ agents: "Codex, pi (and OpenCode)",
2401
+ opencode: "OpenCode",
2402
+ pi: "pi"
2403
+ };
2404
+ var ACTION_TEXT = {
2405
+ linked: "linked",
2406
+ copied: "copied (links are not available here)",
2407
+ kept: "already linked",
2408
+ refreshed: "copy refreshed",
2409
+ removed: "removed",
2410
+ "skipped-foreign": "left alone: a skill of the same name that is not ours",
2411
+ "skipped-edited": "left alone: edited by hand (use --force)"
2412
+ };
2413
+ var RELOAD_HINTS = {
2414
+ opencode: "Restart OpenCode to load it.",
2415
+ pi: "In pi, run /reload."
2416
+ };
2417
+ async function chooseAgents(deps2, requested, detected) {
2418
+ if (requested !== void 0) {
2419
+ const parsed = parseAgents(requested);
2420
+ if (!parsed) throw new CliError(`--agent takes ${AGENTS.join(", ")} or all (got "${requested}")`, EXIT.usage);
2421
+ return parsed === "all" ? [...AGENTS] : parsed;
2422
+ }
2423
+ if (deps2.stdinIsTTY) return deps2.promptAgents(detected);
2424
+ if (detected.length === 0) {
2425
+ throw new CliError(
2426
+ "no supported agent found in your home directory",
2427
+ EXIT.usage,
2428
+ `Pass --agent with any of ${AGENTS.join(", ")}, or all.`
2429
+ );
2430
+ }
2431
+ return detected;
2432
+ }
2433
+ function tilde(path, home) {
2434
+ return path === home || path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
2435
+ }
2436
+ function printEntries(out, result, home) {
2437
+ for (const { dir, path, action } of result.entries) {
2438
+ const line = `${SERVES[dir]}: ${ACTION_TEXT[action]} ${kleur6.gray(tilde(path, home))}`;
2439
+ if (action.startsWith("skipped")) out.warn(line);
2440
+ else out.success(line);
2441
+ }
2442
+ }
2443
+ function registerSkillsCommands(program, deps2, version2) {
2444
+ const skills = program.command("skills").description("Add the PlantTaxoMatcher skill to your AI coding agents (Claude Code, Codex, OpenCode, pi)");
2445
+ const paths = () => skillPaths(deps2.home, deps2.env);
2446
+ skills.command("install").description(
2447
+ "Install the skill for your user. The files live in one CLI-owned folder; each agent only gets a link to it."
2448
+ ).option(
2449
+ "--agent <list>",
2450
+ `Comma-separated: ${AGENTS.join(", ")}, or all (default: the agents found, or a prompt)`
2451
+ ).option("--force", "Replace an installed skill even if it was edited by hand", false).option("--json", "Print the result as JSON", false).action(async (opts) => {
2452
+ const out = createOutput(deps2.stdout, deps2.stderr, opts.json, deps2.now);
2453
+ const agents = await chooseAgents(deps2, opts.agent, await detectAgents(paths()));
2454
+ if (agents.length === 0) throw new CliError("no agent selected", EXIT.usage);
2455
+ const result = await installSkill(paths(), deps2.skillSource, version2, agents, { force: opts.force });
2456
+ if (out.json) return out.data({ version: version2, agents, ...result });
2457
+ out.info(
2458
+ `${kleur6.bold("planttaxomatcher")} skill ${version2} \u2192 ${kleur6.gray(tilde(result.canonical, deps2.home))}`
2459
+ );
2460
+ printEntries(out, result, deps2.home);
2461
+ for (const agent of agents) if (RELOAD_HINTS[agent]) out.info(kleur6.gray(RELOAD_HINTS[agent]));
2462
+ out.info(kleur6.gray(`Try it: ask your agent to "match the plant names in my CSV with PlantTaxoMatcher".`));
2463
+ });
2464
+ skills.command("uninstall").description("Remove the links and copies this CLI installed, then its own copy of the skill").option("--force", "Also remove copies edited by hand", false).option("--json", "Print the result as JSON", false).action(async (opts) => {
2465
+ const out = createOutput(deps2.stdout, deps2.stderr, opts.json, deps2.now);
2466
+ const result = await uninstallSkill(paths(), { force: opts.force });
2467
+ if (out.json) return out.data(result);
2468
+ if (result.entries.length === 0) out.info(kleur6.gray("No installed skill links found."));
2469
+ printEntries(out, result, deps2.home);
2470
+ });
2471
+ skills.command("status").description("Show where the skill is installed and which copy each agent loads").option("--json", "Print the status as JSON", false).action(async (opts) => {
2472
+ const out = createOutput(deps2.stdout, deps2.stderr, opts.json, deps2.now);
2473
+ const status = await skillStatus(paths());
2474
+ if (out.json) return out.data({ cliVersion: version2, ...status });
2475
+ const { canonical } = status;
2476
+ out.info(
2477
+ canonical.version ? `Skill ${canonical.version}${canonical.pristine ? "" : " (edited)"} at ${kleur6.gray(tilde(canonical.path, deps2.home))}` : kleur6.gray("Not installed. Run `planttaxomatcher skills install`.")
2478
+ );
2479
+ for (const entry of status.entries) {
2480
+ if (entry.kind !== "missing")
2481
+ out.info(` ${entry.kind.padEnd(8)} ${kleur6.gray(tilde(entry.path, deps2.home))}`);
2482
+ }
2483
+ for (const { agent, installed, loads, state } of status.agents) {
2484
+ const entry = status.entries.find((e) => e.dir === loads);
2485
+ const where = entry ? `${state} in ${tilde(entry.path, deps2.home)}` : "no skill";
2486
+ out.info(` ${AGENT_NAMES[agent].padEnd(12)} ${installed ? where : kleur6.gray(`not found (${where})`)}`);
2487
+ }
2488
+ });
2489
+ }
2490
+
2491
+ // src/commands/submit.ts
2492
+ import { parse as parsePath } from "path";
2493
+ import kleur8 from "kleur";
2494
+
880
2495
  // src/dry-run.ts
881
- import { promises as fs3, createReadStream } from "fs";
2496
+ import { promises as fs4, createReadStream } from "fs";
882
2497
  import Papa from "papaparse";
883
- import kleur from "kleur";
2498
+ import kleur7 from "kleur";
884
2499
  async function readSample(file, sampleLimit) {
885
2500
  const lower = file.toLowerCase();
886
2501
  if (lower.endsWith(".json")) {
887
- const text = await fs3.readFile(file, "utf8");
2502
+ const text = await fs4.readFile(file, "utf8");
888
2503
  const arr = JSON.parse(text);
889
2504
  if (!Array.isArray(arr)) throw new Error("JSON file must be an array of row objects");
890
2505
  const out = [];
@@ -898,13 +2513,13 @@ async function readSample(file, sampleLimit) {
898
2513
  }
899
2514
  return out;
900
2515
  }
901
- return await new Promise((resolve, reject) => {
2516
+ return await new Promise((resolve2, reject) => {
902
2517
  const out = [];
903
2518
  let done = false;
904
- const finish = () => {
2519
+ const finish2 = () => {
905
2520
  if (done) return;
906
2521
  done = true;
907
- resolve(out);
2522
+ resolve2(out);
908
2523
  };
909
2524
  const stream = createReadStream(file);
910
2525
  const parseStream = Papa.parse(Papa.NODE_STREAM_INPUT, {
@@ -922,10 +2537,10 @@ async function readSample(file, sampleLimit) {
922
2537
  out.push(o);
923
2538
  if (out.length >= sampleLimit) {
924
2539
  stream.destroy();
925
- finish();
2540
+ finish2();
926
2541
  }
927
2542
  });
928
- parseStream.on("end", finish);
2543
+ parseStream.on("end", finish2);
929
2544
  stream.pipe(parseStream);
930
2545
  });
931
2546
  }
@@ -943,7 +2558,7 @@ async function buildDryRunReport(file, opts) {
943
2558
  };
944
2559
  }
945
2560
  if (!(opts.nameColumn in rows[0])) {
946
- throw new Error(`name column "${opts.nameColumn}" not present in file header`);
2561
+ throw new CliError(`name column "${opts.nameColumn}" not present in file header`, EXIT.usage);
947
2562
  }
948
2563
  let nullNameCount = 0;
949
2564
  let qualifierFlagCount = 0;
@@ -983,347 +2598,241 @@ async function buildDryRunReport(file, opts) {
983
2598
  idTypeDetection
984
2599
  };
985
2600
  }
986
- function printDryRunReport(report) {
987
- console.log();
988
- console.log(kleur.bold("Dry-run preview"));
989
- console.log(
990
- ` Read ${kleur.cyan(report.rowsRead)} rows \xB7 ${kleur.yellow(report.nullNameCount)} with no usable name \xB7 ${kleur.yellow(report.qualifierFlagCount)} with qualifier flags`
2601
+ function printDryRunReport(report, log) {
2602
+ log("");
2603
+ log(kleur7.bold("Dry-run preview"));
2604
+ log(
2605
+ ` Read ${kleur7.cyan(report.rowsRead)} rows \xB7 ${kleur7.yellow(report.nullNameCount)} with no usable name \xB7 ${kleur7.yellow(report.qualifierFlagCount)} with qualifier flags`
991
2606
  );
992
2607
  if (report.idTypeDetection) {
993
2608
  const d = report.idTypeDetection;
994
2609
  const pct = Math.round(d.dominantConfidence * 100);
995
- console.log(
996
- ` ID-column detection: ${kleur.green(d.dominant ?? "\u2014")} (${pct}% of ${d.sampleSize} sampled)`
997
- );
2610
+ log(` ID-column detection: ${kleur7.green(d.dominant ?? "\u2014")} (${pct}% of ${d.sampleSize} sampled)`);
998
2611
  if (d.minorityExamples.length > 0) {
999
- console.log(` Minority examples:`);
2612
+ log(` Minority examples:`);
1000
2613
  for (const m of d.minorityExamples) {
1001
- console.log(
1002
- ` row ${m.rowIndex + 1}: ${kleur.gray(m.value)} \u2192 ${kleur.dim(m.type)}`
1003
- );
2614
+ log(` row ${m.rowIndex + 1}: ${kleur7.gray(m.value)} \u2192 ${kleur7.dim(m.type)}`);
1004
2615
  }
1005
2616
  }
1006
2617
  }
1007
- console.log();
1008
- console.log(kleur.bold("First rows after normalization:"));
1009
- console.log(
1010
- ` ${"#".padStart(4)} ${"input".padEnd(36)} ${"normalized".padEnd(36)} flags`
1011
- );
2618
+ log("");
2619
+ log(kleur7.bold("First rows after normalization:"));
2620
+ log(` ${"#".padStart(4)} ${"input".padEnd(36)} ${"normalized".padEnd(36)} flags`);
1012
2621
  for (const r of report.sampleRows) {
1013
2622
  const idx = String(r.rowIndex + 1).padStart(4);
1014
2623
  const inp = trunc(r.input ?? "\u2205", 36).padEnd(36);
1015
2624
  const norm = trunc(r.normalized ?? "\u2205", 36).padEnd(36);
1016
- console.log(` ${idx} ${inp} ${norm} ${r.flags.join(" ") || ""}`);
2625
+ log(` ${idx} ${inp} ${norm} ${r.flags.join(" ") || ""}`);
1017
2626
  }
1018
- console.log();
2627
+ log("");
1019
2628
  }
1020
2629
  function trunc(s, n) {
1021
2630
  if (s.length <= n) return s;
1022
2631
  return s.slice(0, n - 1) + "\u2026";
1023
2632
  }
1024
2633
 
1025
- // src/index.ts
1026
- var program = new Command();
1027
- program.name("planttaxomatcher").description("PlantTaxoMatcher CLI").version("0.1.1");
1028
- program.command("login").description("Save a personal token + server URL").option(
1029
- "--token <token>",
1030
- "Personal token (ptm_...). Avoid on shared hosts: it is visible in shell history and the process list. Prefer --token-stdin or the PLANTTAXOMATCHER_TOKEN env var."
1031
- ).option(
1032
- "--token-stdin",
1033
- "Read the token from stdin instead of argv (e.g. `cat token.txt | planttaxomatcher login --token-stdin`).",
1034
- false
1035
- ).option("--server <url>", "API server base URL", "http://localhost:4000").option(
1036
- "--insecure",
1037
- "Allow sending the token over cleartext http to a non-loopback server (NOT recommended).",
1038
- false
1039
- ).action(
1040
- async (opts) => {
1041
- assertServerTransport(opts.server, !!opts.insecure);
1042
- const token = await resolveLoginToken(opts);
1043
- if (!tokenStringSchema.safeParse(token).success) {
1044
- throw new Error("malformed token \u2014 expected ptm_<12 chars>_<32 chars>");
1045
- }
1046
- await writeCredentials({ token, server: opts.server });
1047
- console.log(kleur2.green("\u2713"), `Logged in to ${opts.server}`);
1048
- }
1049
- );
1050
- program.command("logout").description("Clear saved credentials").action(async () => {
1051
- await clearCredentials();
1052
- console.log(kleur2.green("\u2713"), "Logged out");
1053
- });
1054
- program.command("whoami").description("Show current user").action(async () => {
1055
- const creds = await requireCredentials();
1056
- const me = await apiClient.me(creds);
1057
- console.log(`${me.displayName} team=${me.teamId} scopes=${me.scopes.join(",")}`);
1058
- console.log(`server=${creds.server}`);
1059
- });
1060
- program.command("list").description("List recent jobs").action(async () => {
1061
- const creds = await requireCredentials();
1062
- const jobs = await apiClient.listJobs(creds);
1063
- if (jobs.length === 0) {
1064
- console.log(kleur2.gray("No jobs."));
1065
- return;
1066
- }
1067
- for (const j of jobs) {
1068
- console.log(
1069
- `${j.id} ${j.status.padEnd(10)} ${String(j.matchedRows).padStart(6)}/${String(j.totalRows).padStart(6)} matched`
1070
- );
1071
- }
1072
- });
1073
- program.command("status <jobId>").description("Show job status snapshot").action(async (jobId) => {
1074
- const creds = await requireCredentials();
1075
- const job = await apiClient.getJob(creds, jobId);
1076
- console.log(JSON.stringify(job, null, 2));
1077
- });
1078
- program.command("submit <files...>").description(
1079
- 'Submit one or more CSV/JSON files for matching. Accepts shell-expanded paths or quoted glob patterns (e.g. "data/*.csv"). Each file becomes its own job; the file name (without extension) is used as the job name.'
1080
- ).option(
1081
- "--name <label>",
1082
- "Job name override (single file only; ignored when multiple files match \u2014 the file name is used)"
1083
- ).requiredOption("--name-column <name>", "Column holding the scientific name").option("--id-column <name>", "Column holding a known ID").option("--family-column <name>", "Column holding family").option("--genus-column <name>", "Column holding genus").option("--rank-column <name>", "Column holding rank").option("--author-column <name>", "Column holding authorship").option("--filter-column <name>", "Only process rows where this column matches --filter-value; others are skipped").option("--filter-value <value>", "Value the --filter-column must equal (trimmed, case-insensitive)").option("--author-mode <mode>", "ignore|prefer|strict", "prefer").option("--parallel <n>", "Per-job parallelism", "4").option("--review-mode <mode>", "off|recommended|strict", "recommended").option(
1084
- "--keep-infraspecific",
1085
- "Keep infraspecific accepted taxa (varieties, subspecies, forms) instead of collapsing them up to the species",
1086
- false
1087
- ).option("--allow-llm", "Allow Layer 7 LLM cascade (breaks ties between competing matches)", false).option("--llm-cap-cents <cents>", "Max LLM spend in cents", "500").option("--referential <version>", "WCVP snapshot version to match against (default: latest)").option("--no-watch", "Do not stream progress after submit").option(
1088
- "--dry-run",
1089
- "Preview locally (normalize first rows + detect ID type) and confirm before uploading",
1090
- false
1091
- ).option(
1092
- "--dry-run-rows <n>",
1093
- "Number of rows to show in the dry-run preview table (default 10)",
1094
- "10"
1095
- ).action(async (files, opts) => {
1096
- const creds = await requireCredentials();
1097
- const inputs = await expandInputs(files);
1098
- if (inputs.length === 0) throw new Error("no input files");
1099
- if (opts.name && inputs.length > 1) {
1100
- console.log(
1101
- kleur2.yellow("!"),
1102
- "--name ignored for multi-file submit; using each file name as the job name"
1103
- );
1104
- }
1105
- if (inputs.length > 1) {
1106
- console.log(kleur2.cyan("\u2192"), `${inputs.length} files matched:`);
1107
- for (const f of inputs) console.log(kleur2.gray(` ${f}`));
1108
- }
1109
- if (opts.dryRun) {
1110
- const idColumn = opts.idColumn ? String(opts.idColumn) : null;
1111
- const previewRows = Number(opts.dryRunRows ?? 10);
1112
- for (const file of inputs) {
1113
- if (inputs.length > 1) console.log(kleur2.bold(`
1114
- ${file}`));
1115
- const report = await buildDryRunReport(file, {
1116
- nameColumn: String(opts.nameColumn),
1117
- idColumn,
1118
- previewRows: Number.isFinite(previewRows) ? previewRows : 10,
1119
- sampleLimit: 1e3
2634
+ // src/inputs.ts
2635
+ import { promises as fs5 } from "fs";
2636
+ var GLOB_MAGIC = /[*?[\]{}!()]/;
2637
+ async function expandInputs(patterns) {
2638
+ const files = /* @__PURE__ */ new Set();
2639
+ for (const pattern of patterns) {
2640
+ if (GLOB_MAGIC.test(pattern)) {
2641
+ let matched = false;
2642
+ for await (const file of fs5.glob(pattern)) {
2643
+ files.add(file);
2644
+ matched = true;
2645
+ }
2646
+ if (!matched) throw new CliError(`no files matched: ${pattern}`, EXIT.usage);
2647
+ } else {
2648
+ await fs5.access(pattern).catch(() => {
2649
+ throw new CliError(`file not found: ${pattern}`, EXIT.usage);
1120
2650
  });
1121
- printDryRunReport(report);
1122
- }
1123
- const proceed = await confirm({
1124
- message: inputs.length > 1 ? `Proceed with upload of ${inputs.length} files?` : "Proceed with upload?",
1125
- default: true
1126
- });
1127
- if (!proceed) {
1128
- console.log(kleur2.gray("Aborted."));
1129
- return;
2651
+ files.add(pattern);
1130
2652
  }
1131
2653
  }
2654
+ return [...files].sort();
2655
+ }
2656
+
2657
+ // src/job-config.ts
2658
+ import "zod";
2659
+ function buildJobConfig(opts) {
1132
2660
  const config = {
1133
- nameColumn: String(opts.nameColumn),
1134
- idColumn: opts.idColumn ? String(opts.idColumn) : null,
1135
- familyColumn: opts.familyColumn ? String(opts.familyColumn) : null,
1136
- genusColumn: opts.genusColumn ? String(opts.genusColumn) : null,
1137
- rankColumn: opts.rankColumn ? String(opts.rankColumn) : null,
1138
- authorColumn: opts.authorColumn ? String(opts.authorColumn) : null,
1139
- filterColumn: opts.filterColumn ? String(opts.filterColumn) : null,
1140
- filterValue: opts.filterValue != null ? String(opts.filterValue) : null,
1141
- authorMode: String(opts.authorMode ?? "prefer"),
2661
+ nameColumn: opts.nameColumn,
2662
+ idColumn: opts.idColumn ?? null,
2663
+ idType: opts.idType,
2664
+ familyColumn: opts.familyColumn ?? null,
2665
+ genusColumn: opts.genusColumn ?? null,
2666
+ rankColumn: opts.rankColumn ?? null,
2667
+ authorColumn: opts.authorColumn ?? null,
2668
+ filterColumn: opts.filterColumn ?? null,
2669
+ filterValue: opts.filterValue ?? null,
2670
+ authorMode: opts.authorMode,
1142
2671
  matchAuthors: true,
1143
- parallelism: Number(opts.parallel ?? 4),
2672
+ parallelism: parseIntegerFlag(opts.parallel, "--parallel"),
1144
2673
  allowFuzzy: true,
1145
- allowLlm: !!opts.allowLlm,
1146
- llmCostCapCents: Number(opts.llmCapCents ?? 500),
1147
- reviewMode: String(opts.reviewMode ?? "recommended"),
1148
- // UI/CLI opt-in inverts the config flag: by default we collapse an
1149
- // infraspecific accepted taxon up to its species.
1150
- speciesLevelAcceptedOnly: !opts.keepInfraspecific,
2674
+ allowLlm: opts.allowLlm,
2675
+ llmCostCapCents: parseIntegerFlag(opts.llmCapCents, "--llm-cap-cents"),
2676
+ reviewMode: opts.reviewMode,
2677
+ speciesLevelAcceptedOnly: opts.speciesLevel,
2678
+ acceptUnconfirmedAuthor: opts.ignoreAuthor,
1151
2679
  exportConfirmedOnly: false,
1152
- forceReviewFamilies: [],
1153
- ...opts.referential ? { referentialVersion: String(opts.referential) } : {}
2680
+ ...opts.referential ? { referentialVersion: opts.referential } : {}
1154
2681
  };
1155
- const submitted = [];
1156
- for (const file of inputs) {
1157
- const jobName = inputs.length === 1 && opts.name ? String(opts.name) : parsePath(file).name;
1158
- console.log(kleur2.cyan("\u2192"), `Uploading ${file} \u2026`);
1159
- const job = await apiClient.submitJob(creds, file, config, jobName || null);
1160
- console.log(kleur2.green("\u2713"), `Job created: ${job.id} ${kleur2.gray(jobName)}`);
1161
- console.log(` rows=${job.totalRows} uniqueQueries=${job.uniqueQueries}`);
1162
- submitted.push({ id: job.id, file });
1163
- }
1164
- if (opts.watch === false) return;
1165
- for (const s of submitted) {
1166
- if (submitted.length > 1) console.log(kleur2.bold(`
1167
- [${s.file}] ${s.id}`));
1168
- await streamJob(creds, s.id);
1169
- }
1170
- });
1171
- program.command("watch <jobId>").description("Stream NDJSON progress for a job").action(async (jobId) => {
1172
- const creds = await requireCredentials();
1173
- await streamJob(creds, jobId);
1174
- });
1175
- program.command("pause <jobId>").description("Pause a running job (worker stops between match queries)").action(async (jobId) => {
1176
- const creds = await requireCredentials();
1177
- const job = await apiClient.pauseJob(creds, jobId);
1178
- console.log(kleur2.green("\u2713"), `paused: ${job.id} (status=${job.status})`);
1179
- });
1180
- program.command("resume <jobId>").description("Resume a paused job (re-enqueues the match stage)").action(async (jobId) => {
1181
- const creds = await requireCredentials();
1182
- const job = await apiClient.resumeJob(creds, jobId);
1183
- console.log(kleur2.green("\u2713"), `resumed: ${job.id} (status=${job.status})`);
1184
- });
1185
- program.command("cancel <jobId>").description("Cancel a job. Already-matched rows are kept; pending queries stop.").action(async (jobId) => {
1186
- const creds = await requireCredentials();
1187
- const job = await apiClient.cancelJob(creds, jobId);
1188
- console.log(kleur2.green("\u2713"), `cancelled: ${job.id} (status=${job.status})`);
1189
- });
1190
- program.command("download <jobId>").description("Download a job export (CSV, JSON, or NDJSON; optionally bundled with NOTICE.md)").option("--format <fmt>", "csv | xlsx | json | ndjson", "csv").option("--confirmed-only", "only matched rows that are accepted / not pending", false).option("--delimiter <sep>", "CSV separator: comma | semicolon | tab | pipe (csv format only)", "comma").option("--bundle", "wrap in a ZIP with NOTICE.md citing the WCVP snapshot + providers", false).option(
1191
- "--columns <list>",
1192
- "comma-separated result/upload column keys to KEEP (default: all). See --list-columns"
1193
- ).option(
1194
- "--wcvp-extra <list>",
1195
- "comma-separated extra WCVP fields to append as wcvp_<key> columns (e.g. ipni_id,powo_id). See --list-columns"
1196
- ).option("--list-columns", "print the columns available for this job and exit", false).option("--output <path>", "write to this path; default is the server-provided filename in CWD").action(async (jobId, opts) => {
1197
- const creds = await requireCredentials();
1198
- if (opts.listColumns) {
1199
- const cat = await apiClient.downloadColumns(creds, jobId);
1200
- console.log(kleur2.bold("Result columns (--columns):"));
1201
- for (const r of cat.result) console.log(` ${r.key} ${kleur2.gray(`(${r.group})`)}`);
1202
- if (cat.original.length > 0) {
1203
- console.log(kleur2.bold("\nYour upload columns (--columns):"));
1204
- for (const k of cat.original) console.log(` ${k}`);
1205
- }
1206
- console.log(kleur2.bold("\nWCVP extra fields (--wcvp-extra):"));
1207
- for (const f of cat.wcvpExtra) console.log(` ${f.key} ${kleur2.gray(`(${f.group})`)}`);
1208
- return;
1209
- }
1210
- const format = opts.format ?? "csv";
1211
- if (format !== "csv" && format !== "json" && format !== "ndjson" && format !== "xlsx") {
1212
- throw new Error(`--format must be csv, xlsx, json or ndjson (got ${String(format)})`);
1213
- }
1214
- const confirmedOnly = !!opts.confirmedOnly;
1215
- const bundle = !!opts.bundle;
1216
- const delimiter = String(opts.delimiter ?? "comma");
1217
- if (!["comma", "semicolon", "tab", "pipe"].includes(delimiter)) {
1218
- throw new Error(`--delimiter must be comma, semicolon, tab or pipe (got ${delimiter})`);
1219
- }
1220
- const { filename, body } = await apiClient.downloadJob(creds, jobId, {
1221
- format,
1222
- confirmedOnly,
1223
- bundle,
1224
- ...format === "csv" && delimiter !== "comma" ? { delimiter } : {},
1225
- ...opts.columns ? { columns: String(opts.columns) } : {},
1226
- ...opts.wcvpExtra ? { wcvpExtra: String(opts.wcvpExtra) } : {}
1227
- });
1228
- const outPath = opts.output ?? filename;
1229
- const { writeFile } = await import("fs/promises");
1230
- await writeFile(outPath, body);
1231
- console.log(kleur2.green("\u2713"), `wrote ${body.length} bytes to ${outPath}`);
1232
- });
1233
- program.parseAsync(process.argv).catch((err) => {
1234
- const msg = err instanceof Error ? err.message : String(err);
1235
- console.error(kleur2.red("error:"), msg);
1236
- process.exit(1);
1237
- });
1238
- function assertServerTransport(server, insecure) {
1239
- let u;
1240
- try {
1241
- u = new URL(server);
1242
- } catch {
1243
- throw new Error(`invalid --server URL: ${server}`);
1244
- }
1245
- if (u.protocol === "https:") return;
1246
- if (u.protocol !== "http:") {
1247
- throw new Error(`--server must use http or https (got ${u.protocol})`);
1248
- }
1249
- const host = u.hostname;
1250
- const isLoopback = host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]" || host.endsWith(".localhost");
1251
- if (!isLoopback && !insecure) {
1252
- throw new Error(
1253
- `refusing to send a token in cleartext to ${u.host}. Use an https URL, or pass --insecure to override (NOT recommended).`
1254
- );
2682
+ const checked = jobConfigSchema.safeParse(config);
2683
+ if (!checked.success) {
2684
+ const issue = checked.error.issues[0];
2685
+ const field = issue?.path.join(".") ?? "config";
2686
+ throw new CliError(`invalid ${field}: ${issue?.message ?? "rejected"}`, EXIT.usage);
1255
2687
  }
2688
+ return config;
1256
2689
  }
1257
- async function readStdinLine() {
1258
- const chunks = [];
1259
- for await (const chunk of process.stdin) chunks.push(chunk);
1260
- return Buffer.concat(chunks).toString("utf8").trim();
2690
+
2691
+ // src/commands/submit.ts
2692
+ function summarize(submitted, finals, creds) {
2693
+ return submitted.map(({ file, job }) => ({
2694
+ id: job.id,
2695
+ name: job.name,
2696
+ file,
2697
+ status: finals.find((f) => f.id === job.id)?.status ?? job.status,
2698
+ totalRows: job.totalRows,
2699
+ uniqueQueries: job.uniqueQueries,
2700
+ url: jobUrl(creds, job.id)
2701
+ }));
1261
2702
  }
1262
- async function resolveLoginToken(opts) {
1263
- if (opts.tokenStdin) {
1264
- const t = await readStdinLine();
1265
- if (!t) throw new Error("--token-stdin was set but stdin was empty");
1266
- return t;
2703
+ function jobNameFor(file, fileCount, name) {
2704
+ return fileCount === 1 && name?.trim() ? name.trim() : parsePath(file).name;
2705
+ }
2706
+ async function confirmDryRun(deps2, out, files, opts) {
2707
+ const previewRows = Number(opts.dryRunRows);
2708
+ for (const file of files) {
2709
+ if (files.length > 1) out.info(kleur8.bold(`
2710
+ ${file}`));
2711
+ const report = await buildDryRunReport(file, {
2712
+ nameColumn: opts.nameColumn,
2713
+ idColumn: opts.idColumn ?? null,
2714
+ previewRows: Number.isFinite(previewRows) ? previewRows : 10,
2715
+ sampleLimit: 1e3
2716
+ });
2717
+ printDryRunReport(report, out.info);
1267
2718
  }
1268
- if (opts.token) return opts.token.trim();
1269
- const env = process.env.PLANTTAXOMATCHER_TOKEN;
1270
- if (env && env.trim()) return env.trim();
1271
- if (!process.stdin.isTTY) {
1272
- throw new Error(
1273
- "no token provided. Pass --token-stdin, set PLANTTAXOMATCHER_TOKEN, or run interactively."
1274
- );
2719
+ if (opts.yes) return true;
2720
+ if (!deps2.stdinIsTTY) {
2721
+ throw new CliError("--dry-run needs a confirmation", EXIT.usage, "Pass --yes to upload after the preview.");
1275
2722
  }
1276
- const entered = await password({ message: "Personal token (ptm_...)", mask: true });
1277
- return entered.trim();
2723
+ const message = files.length > 1 ? `Proceed with upload of ${files.length} files?` : "Proceed with upload?";
2724
+ return deps2.promptConfirm(message);
1278
2725
  }
1279
- var GLOB_MAGIC = /[*?[\]{}!()]/;
1280
- async function expandInputs(patterns) {
1281
- const out = /* @__PURE__ */ new Set();
1282
- for (const p of patterns) {
1283
- if (GLOB_MAGIC.test(p)) {
1284
- let matched = false;
1285
- for await (const m of fs4.glob(p)) {
1286
- out.add(m);
1287
- matched = true;
1288
- }
1289
- if (!matched) throw new Error(`no files matched: ${p}`);
1290
- } else {
1291
- await fs4.access(p).catch(() => {
1292
- throw new Error(`file not found: ${p}`);
1293
- });
1294
- out.add(p);
2726
+ function registerSubmitCommand(program, deps2) {
2727
+ program.command("submit <files...>").description(
2728
+ 'Submit CSV/XLSX/JSON files for matching, one job per file (named after the file). Accepts shell-expanded paths or quoted globs such as "data/*.csv". Follows progress until the jobs stop unless --no-watch; exits 4 if one fails, 5 if one is paused.'
2729
+ ).option("--name <label>", "Job name (single file only; with several files each job is named after its file)").requiredOption("--name-column <name>", "Column holding the scientific name").option("--id-column <name>", "Column holding a known ID").option("--family-column <name>", "Column holding family").option("--genus-column <name>", "Column holding genus").option("--rank-column <name>", "Column holding rank").option("--author-column <name>", "Column holding authorship").option(
2730
+ "--filter-column <name>",
2731
+ "Only process rows where this column matches --filter-value; others are skipped"
2732
+ ).option("--filter-value <value>", "Value the --filter-column must equal (trimmed, case-insensitive)").option("--author-mode <mode>", "ignore|prefer|strict", "prefer").option("--parallel <n>", "Per-job parallelism (1-10)", "4").option("--review-mode <mode>", "off|recommended|strict", "recommended").option(
2733
+ "--id-type <type>",
2734
+ "What --id-column holds: auto (backbone id, else GBIF key for bare integers), wcvp, gbif",
2735
+ "auto"
2736
+ ).option(
2737
+ "--species-level",
2738
+ "Roll infraspecific accepted taxa (varieties, subspecies, forms) up to their species",
2739
+ false
2740
+ ).option(
2741
+ "--ignore-author",
2742
+ "Accept a unique canonical name match whose author couldn't be confirmed instead of sending it to review (a CONFLICTING author still reviews)",
2743
+ false
2744
+ ).option("--keep-infraspecific", "Deprecated \u2014 now the default; has no effect", false).option("--allow-llm", "Allow the Layer 7 LLM to break ties between competing matches", false).option("--llm-cap-cents <cents>", "Max LLM spend in cents", "500").option(
2745
+ "--referential <version>",
2746
+ "WCVP snapshot version to match against (default: your team's default snapshot, else the newest import)"
2747
+ ).option("--no-watch", "Return as soon as the jobs are created").option("--dry-run", "Preview the first rows locally, then confirm before uploading", false).option("--dry-run-rows <n>", "Rows to show in the dry-run preview", "10").option("-y, --yes", "Skip the --dry-run confirmation (needed without a terminal)", false).option("--json", "Print the created jobs as a JSON array (with --watch: once they end)", false).action(async (patterns, opts) => {
2748
+ const out = createOutput(deps2.stdout, deps2.stderr, opts.json, deps2.now);
2749
+ const config = buildJobConfig(opts);
2750
+ const creds = await requireCredentials(deps2.store, deps2.env);
2751
+ const files = await expandInputs(patterns);
2752
+ if (opts.name && files.length > 1)
2753
+ out.warn("--name ignored for a multi-file submit; each job is named after its file");
2754
+ if (opts.keepInfraspecific) {
2755
+ out.warn(
2756
+ "--keep-infraspecific is deprecated and does nothing \u2014 pass --species-level to roll up to the species"
2757
+ );
1295
2758
  }
1296
- }
1297
- return [...out].sort();
1298
- }
1299
- async function streamJob(creds, jobId) {
1300
- console.log(kleur2.cyan("\u2192"), `Streaming progress for ${jobId} \u2026`);
1301
- for await (const evt of apiClient.streamJob(creds, jobId)) {
1302
- const t = String(evt.type ?? "");
1303
- if (t === "heartbeat") continue;
1304
- if (t === "status") {
1305
- const status = String(evt.status ?? "");
1306
- console.log(kleur2.gray("\u2022"), status);
1307
- if (status === "completed") {
1308
- console.log(kleur2.green("\u2713"), "completed");
1309
- return;
2759
+ if (files.length > 1) {
2760
+ out.info(`${kleur8.cyan("\u2192")} ${files.length} files matched:`);
2761
+ for (const file of files) out.info(kleur8.gray(` ${file}`));
2762
+ }
2763
+ if (opts.dryRun && !await confirmDryRun(deps2, out, files, opts)) {
2764
+ out.info(kleur8.gray("Aborted."));
2765
+ return;
2766
+ }
2767
+ const submitted = [];
2768
+ const finals = [];
2769
+ try {
2770
+ for (const file of files) {
2771
+ const name = jobNameFor(file, files.length, opts.name);
2772
+ out.info(`${kleur8.cyan("\u2192")} Uploading ${file} \u2026`);
2773
+ const job = await deps2.api.submitJob(creds, file, config, name || null);
2774
+ out.success(`Job created: ${job.id} ${kleur8.gray(name)}`);
2775
+ out.info(` rows=${job.totalRows} uniqueQueries=${job.uniqueQueries}`);
2776
+ submitted.push({ file, job });
1310
2777
  }
1311
- if (status === "failed" || status === "cancelled") {
1312
- console.error(kleur2.red(status));
1313
- return;
2778
+ if (opts.watch) {
2779
+ const watchOut = opts.json ? createOutput(deps2.stderr, deps2.stderr, false, deps2.now) : out;
2780
+ for (const { file, job } of submitted) {
2781
+ if (submitted.length > 1) watchOut.info(kleur8.bold(`
2782
+ [${file}] ${job.id}`));
2783
+ finals.push({ id: job.id, status: await watchJob(deps2.api, watchOut, creds, job.id) });
2784
+ }
1314
2785
  }
1315
- } else if (t === "progress") {
1316
- const p = evt.processedQueries ?? evt.processedRows ?? 0;
1317
- const total = evt.totalQueries ?? evt.totalRows ?? 0;
1318
- process.stdout.write(`\r progress: ${p}/${total} `);
1319
- } else if (t === "completed") {
1320
- process.stdout.write("\n");
1321
- console.log(kleur2.green("\u2713"), "completed");
1322
- return;
1323
- } else if (t === "error") {
1324
- process.stdout.write("\n");
1325
- console.error(kleur2.red("error:"), evt.message);
1326
- return;
2786
+ } finally {
2787
+ if (opts.json) out.data(summarize(submitted, finals, creds));
1327
2788
  }
2789
+ const failure = watchOutcomeError(finals);
2790
+ if (failure) throw failure;
2791
+ });
2792
+ }
2793
+
2794
+ // src/program.ts
2795
+ var EXIT_CODES_HELP = `
2796
+ Exit codes:
2797
+ 0 success
2798
+ 1 error (API or network failure)
2799
+ 2 usage error (bad flag or argument)
2800
+ 3 not signed in, or the token was rejected
2801
+ 4 a watched job ended failed or cancelled
2802
+ 5 a watched job was paused (resume it, then watch again)
2803
+
2804
+ Environment:
2805
+ PLANTTAXOMATCHER_TOKEN token to use instead of the saved login
2806
+ PLANTTAXOMATCHER_SERVER its server (default: the public server); alone, it
2807
+ must match the saved login's server`;
2808
+ function buildProgram(deps2, version2) {
2809
+ const program = new Command().name("planttaxomatcher").description("Reconcile plant names against WCVP with the PlantTaxoMatcher API").version(version2).addHelpText("after", EXIT_CODES_HELP).exitOverride().configureOutput({
2810
+ writeOut: (text) => deps2.stdout.write(text),
2811
+ writeErr: (text) => deps2.stderr.write(text)
2812
+ });
2813
+ registerAuthCommands(program, deps2);
2814
+ registerJobCommands(program, deps2);
2815
+ registerSubmitCommand(program, deps2);
2816
+ registerDownloadCommand(program, deps2);
2817
+ registerSkillsCommands(program, deps2, version2);
2818
+ return program;
2819
+ }
2820
+ async function runCli(argv, deps2, version2) {
2821
+ try {
2822
+ await buildProgram(deps2, version2).parseAsync(argv, { from: "user" });
2823
+ return 0;
2824
+ } catch (err) {
2825
+ const { exitCode, message, hint } = describeError(err);
2826
+ if (message) deps2.stderr.write(`${kleur9.red("error:")} ${message}
2827
+ `);
2828
+ if (hint) deps2.stderr.write(`${kleur9.gray(hint)}
2829
+ `);
2830
+ return exitCode;
1328
2831
  }
1329
2832
  }
2833
+
2834
+ // src/index.ts
2835
+ var { version } = createRequire(import.meta.url)("../package.json");
2836
+ var deps = defaultDeps();
2837
+ await refreshSkillIfOutdated(skillPaths(deps.home, deps.env), deps.skillSource, version);
2838
+ process.exitCode = await runCli(process.argv.slice(2), deps, version);