@plantnet/planttaxomatcher 0.2.0 → 0.4.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
@@ -2,11 +2,18 @@
2
2
 
3
3
  // src/index.ts
4
4
  import { createRequire } from "module";
5
- import { promises as fs4 } from "fs";
6
- import { parse as parsePath } from "path";
7
- import { confirm, password } from "@inquirer/prompts";
8
- import { Command } from "commander";
9
- import kleur2 from "kleur";
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";
10
17
 
11
18
  // ../shared/src/schemas/identifiers.ts
12
19
  import { z } from "zod";
@@ -146,9 +153,11 @@ var jobStatusSchema = z4.enum([
146
153
  ]);
147
154
  var authorModeSchema = z4.enum(["ignore", "prefer", "strict"]);
148
155
  var reviewModeSchema = z4.enum(["off", "recommended", "strict"]);
156
+ var idTypeOptionSchema = z4.enum(["auto", "wcvp", "gbif"]);
149
157
  var jobConfigSchema = z4.object({
150
158
  nameColumn: z4.string().min(1),
151
159
  idColumn: z4.string().nullable().optional(),
160
+ idType: idTypeOptionSchema.default("auto").optional(),
152
161
  familyColumn: z4.string().nullable().optional(),
153
162
  genusColumn: z4.string().nullable().optional(),
154
163
  rankColumn: z4.string().nullable().optional(),
@@ -183,7 +192,8 @@ var jobConfigSchema = z4.object({
183
192
  /**
184
193
  * "The author isn't important." When the canonical name matches exactly one
185
194
  * taxon but the input's author could not be CONFIRMED (flag
186
- * `author-unconfirmed`), auto-accept the match instead of sending it to
195
+ * `author-unconfirmed`, or `author-uncomparable` when it can't be compared
196
+ * at all), auto-accept the match instead of sending it to
187
197
  * review. The taxon itself was never in doubt in that case — only whether
188
198
  * the author string cites it the way WCVP does — so a dataset whose author
189
199
  * column is unreliable (or absent from the source) can skip that queue.
@@ -241,6 +251,8 @@ var wcvpSnapshotSummarySchema = z4.object({
241
251
  importedAt: z4.string().datetime(),
242
252
  /** Newest OFFICIAL snapshot (derived ones never count as latest). */
243
253
  isLatest: z4.boolean(),
254
+ /** The version a job uses when none is named: the team's default if still visible, else the latest. */
255
+ isDefault: z4.boolean(),
244
256
  kind: z4.enum(["official", "derived"]),
245
257
  baseVersion: z4.string().nullable(),
246
258
  label: z4.string().nullable(),
@@ -296,15 +308,56 @@ var jobPublicAccessUpdateSchema = z4.object({
296
308
  var jobRenameSchema = z4.object({
297
309
  name: z4.string().trim().min(1).max(200)
298
310
  });
299
- var progressEventSchema = z4.object({
300
- type: z4.enum(["progress", "status", "error", "completed"]),
301
- jobId: z4.string().ulid(),
302
- timestamp: z4.string().datetime(),
303
- processedRows: z4.number().int().nonnegative().optional(),
304
- totalRows: z4.number().int().nonnegative().optional(),
305
- status: jobStatusSchema.optional(),
306
- message: z4.string().optional()
307
- });
311
+ var jobStreamPhaseSchema = z4.enum(["loading", "parsing", "matching"]);
312
+ var nonNegativeInt = z4.number().int().nonnegative();
313
+ var throttledProviderSchema = z4.object({
314
+ /** Provider id: `gbif`, `tnrs`, `gnverifier`, `plantnet`, `openrouter`. */
315
+ provider: z4.string(),
316
+ /** Calls waiting for that provider's limiter right now. */
317
+ waiting: nonNegativeInt,
318
+ /** How long the job has been waiting on it without a break. */
319
+ waitedMs: nonNegativeInt
320
+ });
321
+ var frameBase = { jobId: z4.string().optional(), timestamp: z4.string().optional() };
322
+ var jobStreamEventSchema = z4.discriminatedUnion("type", [
323
+ /** A status change, or the snapshot a stream opens with. */
324
+ z4.object({
325
+ ...frameBase,
326
+ type: z4.literal("status"),
327
+ status: jobStatusSchema,
328
+ phase: jobStreamPhaseSchema.optional(),
329
+ snapshot: z4.string().optional(),
330
+ processedRows: nonNegativeInt.optional(),
331
+ totalRows: nonNegativeInt.optional(),
332
+ processedQueries: nonNegativeInt.optional(),
333
+ totalQueries: nonNegativeInt.optional()
334
+ }),
335
+ /** Live counters for the whole job, a few times a second at most. */
336
+ z4.object({
337
+ ...frameBase,
338
+ type: z4.literal("progress"),
339
+ phase: jobStreamPhaseSchema,
340
+ processedQueries: nonNegativeInt,
341
+ totalQueries: nonNegativeInt,
342
+ matched: nonNegativeInt,
343
+ ambiguous: nonNegativeInt,
344
+ errors: nonNegativeInt,
345
+ review: nonNegativeInt,
346
+ processed: nonNegativeInt
347
+ }),
348
+ /**
349
+ * External providers are holding the match stage back: their rate
350
+ * limiter, a retry backoff after a 429 / 5xx, or the in-flight cap. Repeated while it
351
+ * lasts and simply not sent once it stops, so a reader should let the
352
+ * state lapse a few seconds after the last one.
353
+ */
354
+ z4.object({ ...frameBase, type: z4.literal("throttle"), providers: z4.array(throttledProviderSchema).min(1) }),
355
+ /** Progress of a post-match verification plugin run. */
356
+ z4.object({ ...frameBase, type: z4.literal("plugin"), pluginId: z4.string() }).passthrough(),
357
+ z4.object({ ...frameBase, type: z4.literal("completed"), status: z4.enum(["completed", "cancelled"]) }),
358
+ z4.object({ ...frameBase, type: z4.literal("error"), message: z4.string() }),
359
+ z4.object({ ...frameBase, type: z4.literal("heartbeat") })
360
+ ]);
308
361
  var taxonIdentifierRefSchema = z4.object({
309
362
  namespace: z4.string(),
310
363
  value: z4.string()
@@ -356,6 +409,16 @@ var statusCountsSchema = z4.object({
356
409
  pending: z4.number().int().nonnegative()
357
410
  });
358
411
  var layerCountsSchema = z4.record(z4.string(), z4.number().int().nonnegative());
412
+ var rowSortSchema = z4.enum([
413
+ "rowIndex",
414
+ "inputName",
415
+ "matchStatus",
416
+ "grade",
417
+ "confidence",
418
+ "acceptedName",
419
+ "family"
420
+ ]);
421
+ var rowOrderSchema = z4.enum(["asc", "desc"]);
359
422
  var jobRowsPageSchema = z4.object({
360
423
  rows: z4.array(jobRowSummarySchema),
361
424
  /** Row count matching the CURRENT filter (not the whole job) — drives
@@ -363,6 +426,9 @@ var jobRowsPageSchema = z4.object({
363
426
  total: z4.number().int().nonnegative(),
364
427
  offset: z4.number().int().nonnegative(),
365
428
  limit: z4.number().int().positive(),
429
+ /** Opaque keyset cursor for the page after this one, in the requested sort
430
+ * + order; null on the last page. Pass it back as `cursor`. */
431
+ nextCursor: z4.string().nullable(),
366
432
  /** Per-grade row counts for the whole job, independent of the current
367
433
  * filter. Used by the SPA to show "B (12)" next to each grade chip. */
368
434
  gradeCounts: gradeCountsSchema,
@@ -578,7 +644,7 @@ var reviewChatStreamEventSchema = z4.discriminatedUnion("type", [
578
644
  /** Terminal failure — a human-readable reason. */
579
645
  z4.object({ type: z4.literal("error"), error: z4.string() })
580
646
  ]);
581
- var replayableLayerSchema = z4.enum(["L1", "L2", "L3", "L4", "L5", "L6", "L7"]);
647
+ var replayableLayerSchema = z4.enum(["L1", "L1.5", "L2", "L3", "L4", "L5", "L6", "L7"]);
582
648
  var replayLayerBodySchema = z4.object({ layer: replayableLayerSchema });
583
649
  var replayCandidateSchema = z4.object({
584
650
  scientificName: z4.string(),
@@ -626,9 +692,50 @@ var replayLayerResultSchema = z4.object({
626
692
  attempts: z4.array(replayAttemptSchema)
627
693
  });
628
694
 
629
- // ../shared/src/schemas/auth.ts
695
+ // ../shared/src/schemas/live-match.ts
630
696
  import { z as z5 } from "zod";
631
- var tokenScopeSchema = z5.enum([
697
+ var liveMatchBodySchema = z5.object({
698
+ name: z5.string().trim().min(3).max(300),
699
+ referential: z5.enum(["wcvp", "wfo"]).default("wcvp"),
700
+ referentialVersion: z5.string().min(1),
701
+ /** WGSRPD Level-3 area code narrowing an ambiguous outcome (null = off). */
702
+ area: z5.string().nullable().optional(),
703
+ speciesLevelAcceptedOnly: z5.boolean().default(false),
704
+ acceptUnconfirmedAuthor: z5.boolean().default(false)
705
+ });
706
+ var liveMatchCandidateSchema = matchCandidateSummarySchema.omit({ id: true, attemptId: true });
707
+ var liveMatchAttemptSchema = z5.object({
708
+ layer: z5.string(),
709
+ provider: z5.string(),
710
+ status: z5.string(),
711
+ durationMs: z5.number().int().nonnegative(),
712
+ query: z5.unknown(),
713
+ rawResponseSummary: z5.unknown().nullable()
714
+ });
715
+ var liveMatchResultSchema = z5.object({
716
+ query: z5.object({
717
+ input: z5.string(),
718
+ canonical: z5.string(),
719
+ authorship: z5.string().nullable(),
720
+ parser: z5.enum(["gnparser", "fallback"])
721
+ }),
722
+ matchStatus: matchStatusSchema,
723
+ evidenceType: evidenceTypeSchema.nullable(),
724
+ grade: gradeSchema.nullable(),
725
+ confidence: z5.number().nullable(),
726
+ /** Layer that settled the outcome (null for no_match). */
727
+ decidedBy: z5.string().nullable(),
728
+ flags: z5.array(z5.string()),
729
+ /** Index into `candidates` of the pick a job would have selected. */
730
+ selectedIndex: z5.number().int().nonnegative().nullable(),
731
+ candidates: z5.array(liveMatchCandidateSchema),
732
+ attempts: z5.array(liveMatchAttemptSchema),
733
+ durationMs: z5.number().int().nonnegative()
734
+ });
735
+
736
+ // ../shared/src/schemas/auth.ts
737
+ import { z as z6 } from "zod";
738
+ var tokenScopeSchema = z6.enum([
632
739
  "submit:job",
633
740
  "read:job",
634
741
  "cancel:job",
@@ -640,83 +747,87 @@ var tokenScopeSchema = z5.enum([
640
747
  "admin:teams"
641
748
  ]);
642
749
  var ALL_TOKEN_SCOPES = tokenScopeSchema.options;
643
- var tokenStringSchema = z5.string().regex(/^ptm_[a-zA-Z0-9]{12}_[a-zA-Z0-9]{32}$/, "Malformed token");
644
- var meSchema = z5.object({
645
- userId: z5.string().uuid(),
646
- teamId: z5.string().uuid(),
647
- displayName: z5.string(),
648
- scopes: z5.array(tokenScopeSchema),
649
- tokenLabel: z5.string().nullable(),
750
+ var tokenStringSchema = z6.string().regex(/^ptm_[a-zA-Z0-9]{12}_[a-zA-Z0-9]{32}$/, "Malformed token");
751
+ var meSchema = z6.object({
752
+ userId: z6.string().uuid(),
753
+ teamId: z6.string().uuid(),
754
+ displayName: z6.string(),
755
+ scopes: z6.array(tokenScopeSchema),
756
+ tokenLabel: z6.string().nullable(),
650
757
  /** DB id of the token that authenticated this request — matches a row's
651
758
  * `id` in the admin token list, so the UI can highlight "this is you". */
652
- tokenId: z5.string().uuid().nullable()
653
- });
654
- var tokenCreateBodySchema = z5.object({
655
- label: z5.string().min(1).max(120),
656
- scopes: z5.array(tokenScopeSchema).min(1),
657
- expiresAt: z5.string().datetime().optional()
658
- });
659
- var tokenRenameSchema = z5.object({
660
- label: z5.string().trim().min(1).max(120)
661
- });
662
- var tokenSummarySchema = z5.object({
663
- id: z5.string().uuid(),
664
- tokenIdPrefix: z5.string(),
665
- label: z5.string(),
666
- displayName: z5.string(),
667
- scopes: z5.array(tokenScopeSchema),
668
- createdAt: z5.string().datetime(),
669
- lastUsedAt: z5.string().datetime().nullable(),
670
- expiresAt: z5.string().datetime().nullable()
759
+ tokenId: z6.string().uuid().nullable(),
760
+ /** Where this deployment's web app lives, for links to a job page. Absent from older servers. */
761
+ appUrl: z6.string().optional()
762
+ });
763
+ var tokenCreateBodySchema = z6.object({
764
+ label: z6.string().min(1).max(120),
765
+ scopes: z6.array(tokenScopeSchema).min(1),
766
+ expiresAt: z6.string().datetime().optional()
767
+ });
768
+ var tokenRenameSchema = z6.object({
769
+ label: z6.string().trim().min(1).max(120)
770
+ });
771
+ var tokenSummarySchema = z6.object({
772
+ id: z6.string().uuid(),
773
+ /** Team the token belongs to — `admin:teams` callers list every team's tokens. */
774
+ teamId: z6.string().uuid(),
775
+ tokenIdPrefix: z6.string(),
776
+ label: z6.string(),
777
+ displayName: z6.string(),
778
+ scopes: z6.array(tokenScopeSchema),
779
+ createdAt: z6.string().datetime(),
780
+ lastUsedAt: z6.string().datetime().nullable(),
781
+ expiresAt: z6.string().datetime().nullable()
671
782
  });
672
783
  var tokenCreatedSchema = tokenSummarySchema.extend({
673
784
  tokenString: tokenStringSchema
674
785
  });
675
- var teamSummarySchema = z5.object({
676
- id: z5.string().uuid(),
677
- name: z5.string(),
678
- createdAt: z5.string().datetime()
679
- });
680
- var teamCreateBodySchema = z5.object({
681
- name: z5.string().min(1).max(120),
682
- initialUserDisplayName: z5.string().min(1).max(120).default("Admin"),
683
- initialToken: z5.object({
684
- label: z5.string().min(1).max(120),
685
- scopes: z5.array(tokenScopeSchema).min(1)
786
+ var teamSummarySchema = z6.object({
787
+ id: z6.string().uuid(),
788
+ name: z6.string(),
789
+ createdAt: z6.string().datetime()
790
+ });
791
+ var teamCreateBodySchema = z6.object({
792
+ name: z6.string().min(1).max(120),
793
+ initialUserDisplayName: z6.string().min(1).max(120).default("Admin"),
794
+ initialToken: z6.object({
795
+ label: z6.string().min(1).max(120),
796
+ scopes: z6.array(tokenScopeSchema).min(1)
686
797
  }).optional()
687
798
  });
688
799
  var teamCreatedSchema = teamSummarySchema.extend({
689
- initialUser: z5.object({
690
- id: z5.string().uuid(),
691
- displayName: z5.string()
800
+ initialUser: z6.object({
801
+ id: z6.string().uuid(),
802
+ displayName: z6.string()
692
803
  }),
693
804
  initialToken: tokenCreatedSchema.nullable()
694
805
  });
695
- var setupStatusSchema = z5.object({
696
- needsSetup: z5.boolean()
806
+ var setupStatusSchema = z6.object({
807
+ needsSetup: z6.boolean()
697
808
  });
698
- var setupBodySchema = z5.object({
699
- teamName: z5.string().min(1).max(120),
700
- displayName: z5.string().min(1).max(120).default("Admin")
809
+ var setupBodySchema = z6.object({
810
+ teamName: z6.string().min(1).max(120),
811
+ displayName: z6.string().min(1).max(120).default("Admin")
701
812
  });
702
- var setupResultSchema = z5.object({
813
+ var setupResultSchema = z6.object({
703
814
  team: teamSummarySchema,
704
- user: z5.object({ id: z5.string().uuid(), displayName: z5.string() }),
815
+ user: z6.object({ id: z6.string().uuid(), displayName: z6.string() }),
705
816
  tokenString: tokenStringSchema,
706
- scopes: z5.array(tokenScopeSchema)
817
+ scopes: z6.array(tokenScopeSchema)
707
818
  });
708
- var teamLlmSettingsSchema = z5.object({
709
- keyConfigured: z5.boolean(),
710
- keyHint: z5.string().nullable(),
711
- lightModel: z5.string(),
712
- heavyModel: z5.string()
819
+ var teamLlmSettingsSchema = z6.object({
820
+ keyConfigured: z6.boolean(),
821
+ keyHint: z6.string().nullable(),
822
+ lightModel: z6.string(),
823
+ heavyModel: z6.string()
713
824
  });
714
- var teamLlmUpdateSchema = z5.object({
715
- openrouterApiKey: z5.string().max(400).nullable().optional(),
716
- lightModel: z5.string().min(1).max(200).optional(),
717
- heavyModel: z5.string().min(1).max(200).optional()
825
+ var teamLlmUpdateSchema = z6.object({
826
+ openrouterApiKey: z6.string().max(400).nullable().optional(),
827
+ lightModel: z6.string().min(1).max(200).optional(),
828
+ heavyModel: z6.string().min(1).max(200).optional()
718
829
  });
719
- var wcvpImportStatusSchema = z5.enum([
830
+ var wcvpImportStatusSchema = z6.enum([
720
831
  "queued",
721
832
  "downloading",
722
833
  "importing",
@@ -724,68 +835,73 @@ var wcvpImportStatusSchema = z5.enum([
724
835
  "failed",
725
836
  "cancelled"
726
837
  ]);
727
- var wcvpImportRunSchema = z5.object({
728
- id: z5.string().uuid(),
729
- version: z5.string(),
730
- sourceUrl: z5.string(),
838
+ var wcvpImportRunSchema = z6.object({
839
+ id: z6.string().uuid(),
840
+ version: z6.string(),
841
+ sourceUrl: z6.string(),
731
842
  status: wcvpImportStatusSchema,
732
- forceOverwrite: z5.boolean(),
733
- bytesDownloaded: z5.number().int().nonnegative(),
734
- totalBytes: z5.number().int().nonnegative().nullable(),
735
- insertedCount: z5.number().int().nonnegative(),
736
- recordCount: z5.number().int().nonnegative().nullable(),
737
- message: z5.string().nullable(),
738
- createdAt: z5.string().datetime(),
739
- startedAt: z5.string().datetime().nullable(),
740
- completedAt: z5.string().datetime().nullable()
741
- });
742
- var wcvpImportCreateBodySchema = z5.object({
743
- version: z5.string().min(1).max(120),
843
+ forceOverwrite: z6.boolean(),
844
+ bytesDownloaded: z6.number().int().nonnegative(),
845
+ totalBytes: z6.number().int().nonnegative().nullable(),
846
+ insertedCount: z6.number().int().nonnegative(),
847
+ recordCount: z6.number().int().nonnegative().nullable(),
848
+ message: z6.string().nullable(),
849
+ createdAt: z6.string().datetime(),
850
+ startedAt: z6.string().datetime().nullable(),
851
+ completedAt: z6.string().datetime().nullable()
852
+ });
853
+ var wcvpImportCreateBodySchema = z6.object({
854
+ version: z6.string().min(1).max(120),
744
855
  /** Defaults to the Kew SFTP URL when omitted, so the admin doesn't have
745
856
  * to remember it for routine v13/v14 imports. */
746
- url: z5.string().url().optional(),
747
- force: z5.boolean().optional()
748
- });
749
- var adminHealthErrorSchema = z5.object({
750
- id: z5.string().uuid(),
751
- action: z5.string(),
752
- entityType: z5.string(),
753
- entityId: z5.string().nullable(),
754
- timestamp: z5.string().datetime(),
755
- metadata: z5.unknown().nullable()
756
- });
757
- var adminHealthSchema = z5.object({
758
- queueDepth: z5.object({
759
- waiting: z5.number().int().nonnegative(),
760
- active: z5.number().int().nonnegative(),
761
- delayed: z5.number().int().nonnegative(),
762
- completed: z5.number().int().nonnegative(),
763
- failed: z5.number().int().nonnegative(),
764
- paused: z5.number().int().nonnegative()
765
- }),
766
- workerCount: z5.number().int().nonnegative(),
767
- lastErrors: z5.array(adminHealthErrorSchema),
768
- rateLimitBudget: z5.object({
769
- max: z5.number().int().nonnegative(),
770
- timeWindowSeconds: z5.number().int().nonnegative()
857
+ url: z6.string().url().optional(),
858
+ force: z6.boolean().optional()
859
+ });
860
+ var adminHealthErrorSchema = z6.object({
861
+ id: z6.string().uuid(),
862
+ action: z6.string(),
863
+ entityType: z6.string(),
864
+ entityId: z6.string().nullable(),
865
+ timestamp: z6.string().datetime(),
866
+ metadata: z6.unknown().nullable()
867
+ });
868
+ var queueDepthSchema = z6.object({
869
+ waiting: z6.number().int().nonnegative(),
870
+ active: z6.number().int().nonnegative(),
871
+ delayed: z6.number().int().nonnegative(),
872
+ completed: z6.number().int().nonnegative(),
873
+ failed: z6.number().int().nonnegative(),
874
+ paused: z6.number().int().nonnegative()
875
+ });
876
+ var adminQueueSchema = z6.object({
877
+ queueDepth: queueDepthSchema,
878
+ workerCount: z6.number().int().nonnegative()
879
+ });
880
+ var adminHealthSchema = z6.object({
881
+ queueDepth: queueDepthSchema,
882
+ workerCount: z6.number().int().nonnegative(),
883
+ lastErrors: z6.array(adminHealthErrorSchema),
884
+ rateLimitBudget: z6.object({
885
+ max: z6.number().int().nonnegative(),
886
+ timeWindowSeconds: z6.number().int().nonnegative()
771
887
  }),
772
- snapshotVersion: z5.string().nullable(),
773
- snapshotImportedAt: z5.string().datetime().nullable(),
888
+ snapshotVersion: z6.string().nullable(),
889
+ snapshotImportedAt: z6.string().datetime().nullable(),
774
890
  /**
775
891
  * Number of cached accepted-name mappings (L0.5 match-history rows) for the
776
892
  * caller's team — the size of the team accepted-name cache.
777
893
  */
778
- teamHistorySize: z5.number().int().nonnegative(),
894
+ teamHistorySize: z6.number().int().nonnegative(),
779
895
  /**
780
896
  * Overall (all-teams) tally of which cascade layer produced the winning
781
897
  * candidate, across every matched query. `layer` is the raw attempt layer
782
898
  * (`L1`…`L7`, `L0.5`, `OVERRIDE`); `count` is the number of matched queries
783
899
  * that layer resolved. Descending by count.
784
900
  */
785
- layerUsage: z5.array(
786
- z5.object({
787
- layer: z5.string(),
788
- count: z5.number().int().nonnegative()
901
+ layerUsage: z6.array(
902
+ z6.object({
903
+ layer: z6.string(),
904
+ count: z6.number().int().nonnegative()
789
905
  })
790
906
  ),
791
907
  /**
@@ -795,59 +911,108 @@ var adminHealthSchema = z5.object({
795
911
  * Lets the health page surface each external source's usage, hit/miss/error
796
912
  * breakdown, latency, and recency — so a misbehaving provider is visible.
797
913
  */
798
- externalProviders: z5.array(
799
- z5.object({
800
- provider: z5.string(),
801
- total: z5.number().int().nonnegative(),
802
- hits: z5.number().int().nonnegative(),
803
- misses: z5.number().int().nonnegative(),
804
- errors: z5.number().int().nonnegative(),
805
- avgDurationMs: z5.number().int().nonnegative(),
806
- lastUsedAt: z5.string().datetime().nullable()
914
+ externalProviders: z6.array(
915
+ z6.object({
916
+ provider: z6.string(),
917
+ total: z6.number().int().nonnegative(),
918
+ hits: z6.number().int().nonnegative(),
919
+ misses: z6.number().int().nonnegative(),
920
+ errors: z6.number().int().nonnegative(),
921
+ avgDurationMs: z6.number().int().nonnegative(),
922
+ lastUsedAt: z6.string().datetime().nullable()
807
923
  })
808
924
  )
809
925
  });
810
- var matchHistoryEntrySchema = z5.object({
811
- id: z5.string().uuid(),
926
+ var matchHistoryEntrySchema = z6.object({
927
+ id: z6.string().uuid(),
812
928
  /** The normalized input name this mapping is keyed on. */
813
- normalizedInput: z5.string(),
814
- acceptedName: z5.string(),
815
- acceptedIdentifier: z5.object({ namespace: z5.string(), value: z5.string() }).nullable(),
929
+ normalizedInput: z6.string(),
930
+ acceptedName: z6.string(),
931
+ acceptedIdentifier: z6.object({ namespace: z6.string(), value: z6.string() }).nullable(),
816
932
  /** Resolved from the backbone snapshot at read time (null when the stored
817
933
  * identifier no longer resolves): authorship + family of the accepted taxon,
818
934
  * its identifiers (backbone id, IPNI LSID, portal url) and the portal link. */
819
- acceptedAuthorship: z5.string().nullable(),
820
- family: z5.string().nullable(),
821
- acceptedIdentifiers: z5.array(z5.object({ namespace: z5.string(), value: z5.string() })),
822
- targetUrl: z5.string().nullable(),
823
- targetReferential: z5.string(),
824
- referentialVersion: z5.string(),
825
- independentUserAcceptanceCount: z5.number().int().nonnegative(),
826
- rejectionCount: z5.number().int().nonnegative(),
827
- blacklisted: z5.boolean(),
828
- needsReconfirmation: z5.boolean(),
935
+ acceptedAuthorship: z6.string().nullable(),
936
+ family: z6.string().nullable(),
937
+ acceptedIdentifiers: z6.array(z6.object({ namespace: z6.string(), value: z6.string() })),
938
+ targetUrl: z6.string().nullable(),
939
+ targetReferential: z6.string(),
940
+ referentialVersion: z6.string(),
941
+ independentUserAcceptanceCount: z6.number().int().nonnegative(),
942
+ rejectionCount: z6.number().int().nonnegative(),
943
+ blacklisted: z6.boolean(),
944
+ needsReconfirmation: z6.boolean(),
829
945
  /** Currently usable as a cache hit (not blacklisted, acceptances > rejections). */
830
- active: z5.boolean(),
946
+ active: z6.boolean(),
831
947
  /** Auto-accept (Grade A): acceptances ≥ the team's promotion threshold. */
832
- promoted: z5.boolean(),
948
+ promoted: z6.boolean(),
833
949
  /** Label of the token that last reviewed this mapping (falls back to the
834
950
  * reviewer's display name for rows predating token tracking). */
835
- lastReviewedBy: z5.string().nullable(),
836
- firstSeenAt: z5.string().datetime(),
837
- lastAcceptedAt: z5.string().datetime().nullable()
951
+ lastReviewedBy: z6.string().nullable(),
952
+ firstSeenAt: z6.string().datetime(),
953
+ lastAcceptedAt: z6.string().datetime().nullable()
838
954
  });
839
- var teamCacheResponseSchema = z5.object({
955
+ var teamCacheResponseSchema = z6.object({
840
956
  /** Distinct-user acceptances needed to promote a hit to Grade A. */
841
- promotionThreshold: z5.number().int().positive(),
957
+ promotionThreshold: z6.number().int().positive(),
842
958
  /** Total entries in the team cache (before the limit). */
843
- total: z5.number().int().nonnegative(),
844
- entries: z5.array(matchHistoryEntrySchema)
959
+ total: z6.number().int().nonnegative(),
960
+ entries: z6.array(matchHistoryEntrySchema)
961
+ });
962
+
963
+ // ../shared/src/schemas/device-auth.ts
964
+ import { z as z7 } from "zod";
965
+ var SLOW_DOWN_STEP_SECONDS = 5;
966
+ var deviceAuthorizationStatusSchema = z7.enum(["pending", "approved", "denied", "consumed"]);
967
+ var deviceAuthorizationCreateSchema = z7.object({
968
+ /** Shown to the approver so they recognise their own machine (the CLI sends its hostname). */
969
+ clientName: z7.string().trim().min(1).max(100)
970
+ });
971
+ var deviceAuthorizationCreatedSchema = z7.object({
972
+ deviceCode: z7.string(),
973
+ userCode: z7.string(),
974
+ verificationUri: z7.string(),
975
+ verificationUriComplete: z7.string(),
976
+ expiresIn: z7.number().int().positive(),
977
+ interval: z7.number().int().positive()
978
+ });
979
+ var deviceAuthorizationSchema = z7.object({
980
+ userCode: z7.string(),
981
+ clientName: z7.string(),
982
+ clientIp: z7.string().nullable(),
983
+ status: deviceAuthorizationStatusSchema,
984
+ scopes: z7.array(tokenScopeSchema),
985
+ createdAt: z7.string().datetime(),
986
+ expiresAt: z7.string().datetime()
987
+ });
988
+ var deviceAuthorizationDecisionSchema = z7.object({
989
+ status: z7.enum(["approved", "denied"])
990
+ });
991
+ var deviceTokenRequestSchema = z7.object({
992
+ deviceCode: z7.string().min(1).max(200)
993
+ });
994
+ var deviceTokenSchema = z7.object({
995
+ token: z7.string(),
996
+ label: z7.string(),
997
+ scopes: z7.array(tokenScopeSchema),
998
+ expiresAt: z7.string().datetime()
999
+ });
1000
+ var deviceTokenErrorCodeSchema = z7.enum([
1001
+ "authorization_pending",
1002
+ "slow_down",
1003
+ "expired_token",
1004
+ "access_denied"
1005
+ ]);
1006
+ var deviceTokenErrorSchema = z7.object({
1007
+ error: deviceTokenErrorCodeSchema,
1008
+ /** Seconds to wait before the next poll, sent with `slow_down`. */
1009
+ interval: z7.number().int().positive().optional()
845
1010
  });
846
1011
 
847
1012
  // ../shared/src/schemas/backbone.ts
848
- import { z as z6 } from "zod";
849
- var backboneSchema = z6.enum(["wcvp", "wfo"]);
850
- var wfoImportStatusSchema = z6.enum([
1013
+ import { z as z8 } from "zod";
1014
+ var backboneSchema = z8.enum(["wcvp", "wfo"]);
1015
+ var wfoImportStatusSchema = z8.enum([
851
1016
  "queued",
852
1017
  "downloading",
853
1018
  "importing",
@@ -855,72 +1020,87 @@ var wfoImportStatusSchema = z6.enum([
855
1020
  "failed",
856
1021
  "cancelled"
857
1022
  ]);
858
- var wfoImportRunSchema = z6.object({
859
- id: z6.string().uuid(),
860
- version: z6.string(),
861
- sourceUrl: z6.string(),
1023
+ var wfoImportRunSchema = z8.object({
1024
+ id: z8.string().uuid(),
1025
+ version: z8.string(),
1026
+ sourceUrl: z8.string(),
862
1027
  status: wfoImportStatusSchema,
863
- forceOverwrite: z6.boolean(),
864
- bytesDownloaded: z6.number().int().nonnegative(),
865
- totalBytes: z6.number().int().nonnegative().nullable(),
866
- insertedCount: z6.number().int().nonnegative(),
867
- recordCount: z6.number().int().nonnegative().nullable(),
868
- message: z6.string().nullable(),
869
- createdAt: z6.string().datetime(),
870
- startedAt: z6.string().datetime().nullable(),
871
- completedAt: z6.string().datetime().nullable()
872
- });
873
- var wfoImportCreateBodySchema = z6.object({
874
- version: z6.string().min(1).max(120),
1028
+ forceOverwrite: z8.boolean(),
1029
+ bytesDownloaded: z8.number().int().nonnegative(),
1030
+ totalBytes: z8.number().int().nonnegative().nullable(),
1031
+ insertedCount: z8.number().int().nonnegative(),
1032
+ recordCount: z8.number().int().nonnegative().nullable(),
1033
+ message: z8.string().nullable(),
1034
+ createdAt: z8.string().datetime(),
1035
+ startedAt: z8.string().datetime().nullable(),
1036
+ completedAt: z8.string().datetime().nullable()
1037
+ });
1038
+ var wfoImportCreateBodySchema = z8.object({
1039
+ version: z8.string().min(1).max(120),
875
1040
  /** Direct download URL (from the Zenodo version listing). Required for WFO
876
1041
  * since versions aren't derivable from the label like WCVP's Kew URLs. */
877
- url: z6.string().url().optional(),
878
- force: z6.boolean().optional()
879
- });
880
- var snapshotKindSchema = z6.enum(["official", "derived"]);
881
- var wfoSnapshotSummarySchema = z6.object({
882
- version: z6.string(),
883
- recordCount: z6.number().int().nonnegative(),
884
- importedAt: z6.string().datetime(),
885
- isLatest: z6.boolean(),
1042
+ url: z8.string().url().optional(),
1043
+ force: z8.boolean().optional()
1044
+ });
1045
+ var snapshotKindSchema = z8.enum(["official", "derived"]);
1046
+ var wfoSnapshotSummarySchema = z8.object({
1047
+ version: z8.string(),
1048
+ recordCount: z8.number().int().nonnegative(),
1049
+ importedAt: z8.string().datetime(),
1050
+ isLatest: z8.boolean(),
1051
+ /** The version a job uses when none is named: the team's default if still visible, else the latest. */
1052
+ isDefault: z8.boolean(),
886
1053
  kind: snapshotKindSchema,
887
- baseVersion: z6.string().nullable(),
888
- label: z6.string().nullable(),
889
- ownerTeamId: z6.string().nullable()
1054
+ baseVersion: z8.string().nullable(),
1055
+ label: z8.string().nullable(),
1056
+ ownerTeamId: z8.string().nullable()
890
1057
  });
891
- var wfoVersionSchema = z6.object({
1058
+ var wfoVersionSchema = z8.object({
892
1059
  /** Release label, e.g. "2025-12". */
893
- version: z6.string(),
1060
+ version: z8.string(),
894
1061
  /** Zenodo record id for this version. */
895
- recordId: z6.string(),
1062
+ recordId: z8.string(),
896
1063
  /** The plant-list archive filename inside the record. */
897
- fileName: z6.string(),
1064
+ fileName: z8.string(),
898
1065
  /** Direct content download URL. */
899
- url: z6.string().url(),
900
- sizeBytes: z6.number().int().nonnegative().nullable(),
901
- publishedAt: z6.string().nullable(),
902
- isLatest: z6.boolean()
903
- });
904
- var wfoVersionListSchema = z6.array(wfoVersionSchema);
905
- var backboneKeySchema = z6.enum(["wcvp", "wfo"]);
906
- var backboneDefaultsSchema = z6.object({
907
- wcvp: z6.string().nullable(),
908
- wfo: z6.string().nullable()
909
- });
910
- var backboneDefaultUpdateSchema = z6.object({
1066
+ url: z8.string().url(),
1067
+ sizeBytes: z8.number().int().nonnegative().nullable(),
1068
+ publishedAt: z8.string().nullable(),
1069
+ isLatest: z8.boolean()
1070
+ });
1071
+ var wfoVersionListSchema = z8.array(wfoVersionSchema);
1072
+ var wcvpVersionSchema = z8.object({
1073
+ /** Snapshot label the import uses, e.g. "wcvp-v15". */
1074
+ version: z8.string(),
1075
+ /** Kew release number, e.g. 15. */
1076
+ release: z8.number().int().positive(),
1077
+ /** Direct download URL. */
1078
+ url: z8.string().url(),
1079
+ /** Size as Kew's directory index prints it, e.g. "85M". */
1080
+ sizeLabel: z8.string().nullable(),
1081
+ /** Last-modified timestamp from the index, "YYYY-MM-DD HH:MM". */
1082
+ publishedAt: z8.string().nullable()
1083
+ });
1084
+ var wcvpVersionListSchema = z8.array(wcvpVersionSchema);
1085
+ var backboneKeySchema = z8.enum(["wcvp", "wfo"]);
1086
+ var backboneDefaultsSchema = z8.object({
1087
+ wcvp: z8.string().nullable(),
1088
+ wfo: z8.string().nullable()
1089
+ });
1090
+ var backboneDefaultUpdateSchema = z8.object({
911
1091
  backbone: backboneKeySchema,
912
- version: z6.string().min(1).nullable()
1092
+ version: z8.string().min(1).nullable()
913
1093
  });
914
1094
  var DERIVED_SLUG_RE = /^[a-z0-9][a-z0-9-]{1,40}$/;
915
1095
  var MAX_BASE_VERSION_LENGTH = 120;
916
1096
  var MAX_SNAPSHOT_VERSION_LENGTH = MAX_BASE_VERSION_LENGTH + 1 + 41;
917
- var derivedUploadMetaSchema = z6.object({
918
- baseVersion: z6.string().min(1).max(MAX_BASE_VERSION_LENGTH),
919
- slug: z6.string().regex(DERIVED_SLUG_RE, "slug: lowercase letters, digits and dashes (2\u201341 chars)"),
920
- label: z6.string().trim().min(1).max(120),
921
- notes: z6.string().trim().max(2e3).optional()
1097
+ var derivedUploadMetaSchema = z8.object({
1098
+ baseVersion: z8.string().min(1).max(MAX_BASE_VERSION_LENGTH),
1099
+ slug: z8.string().regex(DERIVED_SLUG_RE, "slug: lowercase letters, digits and dashes (2\u201341 chars)"),
1100
+ label: z8.string().trim().min(1).max(120),
1101
+ notes: z8.string().trim().max(2e3).optional()
922
1102
  });
923
- var derivedUploadStatusSchema = z6.enum([
1103
+ var derivedUploadStatusSchema = z8.enum([
924
1104
  "staging",
925
1105
  "ready",
926
1106
  "invalid",
@@ -929,23 +1109,23 @@ var derivedUploadStatusSchema = z6.enum([
929
1109
  "failed",
930
1110
  "discarded"
931
1111
  ]);
932
- var derivedIssueSeveritySchema = z6.enum(["error", "warning"]);
933
- var derivedIssueSchema = z6.object({
934
- code: z6.string(),
1112
+ var derivedIssueSeveritySchema = z8.enum(["error", "warning"]);
1113
+ var derivedIssueSchema = z8.object({
1114
+ code: z8.string(),
935
1115
  severity: derivedIssueSeveritySchema,
936
- count: z6.number().int().nonnegative(),
1116
+ count: z8.number().int().nonnegative(),
937
1117
  /** Free-form detail for the UI (e.g. the offending vocabulary values). */
938
- detail: z6.string().nullable(),
939
- samples: z6.array(
940
- z6.object({
941
- taxonId: z6.string().nullable(),
942
- name: z6.string().nullable(),
943
- detail: z6.string().nullable(),
944
- line: z6.number().int().nullable()
1118
+ detail: z8.string().nullable(),
1119
+ samples: z8.array(
1120
+ z8.object({
1121
+ taxonId: z8.string().nullable(),
1122
+ name: z8.string().nullable(),
1123
+ detail: z8.string().nullable(),
1124
+ line: z8.number().int().nullable()
945
1125
  })
946
1126
  )
947
1127
  });
948
- var derivedDiffFieldSchema = z6.enum([
1128
+ var derivedDiffFieldSchema = z8.enum([
949
1129
  "canonical_name",
950
1130
  "scientific_name",
951
1131
  "authorship",
@@ -956,77 +1136,77 @@ var derivedDiffFieldSchema = z6.enum([
956
1136
  "family"
957
1137
  ]);
958
1138
  var DERIVED_DIFF_FIELDS = derivedDiffFieldSchema.options;
959
- var diffSampleSchema = z6.object({ taxonId: z6.string(), name: z6.string().nullable() });
1139
+ var diffSampleSchema = z8.object({ taxonId: z8.string(), name: z8.string().nullable() });
960
1140
  var changedSampleSchema = diffSampleSchema.extend({
961
- changes: z6.array(
962
- z6.object({ field: derivedDiffFieldSchema, before: z6.string().nullable(), after: z6.string().nullable() })
1141
+ changes: z8.array(
1142
+ z8.object({ field: derivedDiffFieldSchema, before: z8.string().nullable(), after: z8.string().nullable() })
963
1143
  )
964
1144
  });
965
- var derivedReportSchema = z6.object({
966
- rowsRead: z6.number().int().nonnegative(),
967
- rowsStaged: z6.number().int().nonnegative(),
968
- rowsRejected: z6.number().int().nonnegative(),
969
- baseRowCount: z6.number().int().nonnegative(),
970
- errors: z6.number().int().nonnegative(),
971
- warnings: z6.number().int().nonnegative(),
972
- issues: z6.array(derivedIssueSchema),
973
- diff: z6.object({
974
- added: z6.object({ count: z6.number().int().nonnegative(), samples: z6.array(diffSampleSchema) }),
975
- removed: z6.object({ count: z6.number().int().nonnegative(), samples: z6.array(diffSampleSchema) }),
976
- changed: z6.object({
977
- count: z6.number().int().nonnegative(),
978
- byField: z6.record(z6.string(), z6.number().int().nonnegative()),
979
- samples: z6.array(changedSampleSchema)
1145
+ var derivedReportSchema = z8.object({
1146
+ rowsRead: z8.number().int().nonnegative(),
1147
+ rowsStaged: z8.number().int().nonnegative(),
1148
+ rowsRejected: z8.number().int().nonnegative(),
1149
+ baseRowCount: z8.number().int().nonnegative(),
1150
+ errors: z8.number().int().nonnegative(),
1151
+ warnings: z8.number().int().nonnegative(),
1152
+ issues: z8.array(derivedIssueSchema),
1153
+ diff: z8.object({
1154
+ added: z8.object({ count: z8.number().int().nonnegative(), samples: z8.array(diffSampleSchema) }),
1155
+ removed: z8.object({ count: z8.number().int().nonnegative(), samples: z8.array(diffSampleSchema) }),
1156
+ changed: z8.object({
1157
+ count: z8.number().int().nonnegative(),
1158
+ byField: z8.record(z8.string(), z8.number().int().nonnegative()),
1159
+ samples: z8.array(changedSampleSchema)
980
1160
  }),
981
- unchanged: z6.number().int().nonnegative()
1161
+ unchanged: z8.number().int().nonnegative()
982
1162
  })
983
1163
  });
984
- var derivedUploadSchema = z6.object({
985
- id: z6.string().uuid(),
1164
+ var derivedUploadSchema = z8.object({
1165
+ id: z8.string().uuid(),
986
1166
  backbone: backboneSchema,
987
- baseVersion: z6.string(),
988
- slug: z6.string(),
989
- label: z6.string(),
990
- notes: z6.string().nullable(),
1167
+ baseVersion: z8.string(),
1168
+ slug: z8.string(),
1169
+ label: z8.string(),
1170
+ notes: z8.string().nullable(),
991
1171
  /** The snapshot version this upload installs as (`<base>+<slug>`). */
992
- version: z6.string(),
993
- originalFilename: z6.string().nullable(),
994
- sha256: z6.string(),
995
- sizeBytes: z6.number().int().nonnegative(),
1172
+ version: z8.string(),
1173
+ originalFilename: z8.string().nullable(),
1174
+ sha256: z8.string(),
1175
+ sizeBytes: z8.number().int().nonnegative(),
996
1176
  status: derivedUploadStatusSchema,
997
- rowsRead: z6.number().int().nonnegative(),
1177
+ rowsRead: z8.number().int().nonnegative(),
998
1178
  report: derivedReportSchema.nullable(),
999
- message: z6.string().nullable(),
1000
- createdAt: z6.string().datetime(),
1001
- expiresAt: z6.string().datetime(),
1002
- importedVersion: z6.string().nullable()
1179
+ message: z8.string().nullable(),
1180
+ createdAt: z8.string().datetime(),
1181
+ expiresAt: z8.string().datetime(),
1182
+ importedVersion: z8.string().nullable()
1003
1183
  });
1004
- var derivedConfirmBodySchema = z6.object({
1184
+ var derivedConfirmBodySchema = z8.object({
1005
1185
  /** Acknowledge warnings. Never overrides errors. */
1006
- force: z6.boolean().optional()
1186
+ force: z8.boolean().optional()
1007
1187
  });
1008
1188
 
1009
1189
  // ../shared/src/schemas/openrouter.ts
1010
- import { z as z7 } from "zod";
1011
- var openRouterModelSchema = z7.object({
1190
+ import { z as z9 } from "zod";
1191
+ var openRouterModelSchema = z9.object({
1012
1192
  /** Full model id, e.g. `anthropic/claude-opus-4.8` or the alias
1013
1193
  * `~anthropic/claude-haiku-latest`. Used verbatim as the OpenRouter model. */
1014
- id: z7.string(),
1194
+ id: z9.string(),
1015
1195
  /** Human label from OpenRouter (falls back to the id). */
1016
- name: z7.string(),
1196
+ name: z9.string(),
1017
1197
  /** Author slug (the part before `/`), with any leading `~` stripped. */
1018
- author: z7.string(),
1198
+ author: z9.string(),
1019
1199
  /** Unix seconds the model was published; used to rank "latest". */
1020
- created: z7.number(),
1200
+ created: z9.number(),
1021
1201
  /** Context window in tokens, when OpenRouter reports it. */
1022
- contextLength: z7.number().nullable(),
1202
+ contextLength: z9.number().nullable(),
1023
1203
  /** USD price per PROMPT token (input). Null when OpenRouter doesn't report a
1024
1204
  * numeric price. 0 = free. */
1025
- promptPriceUsd: z7.number().nullable(),
1205
+ promptPriceUsd: z9.number().nullable(),
1026
1206
  /** USD price per COMPLETION token (output). */
1027
- completionPriceUsd: z7.number().nullable(),
1207
+ completionPriceUsd: z9.number().nullable(),
1028
1208
  /** An auto-updating `…-latest` pointer (e.g. `~anthropic/claude-haiku-latest`). */
1029
- isAlias: z7.boolean()
1209
+ isAlias: z9.boolean()
1030
1210
  });
1031
1211
 
1032
1212
  // ../shared/src/normalize.ts
@@ -1155,184 +1335,1450 @@ function detectIdTypeDistribution(samples, opts) {
1155
1335
  }
1156
1336
 
1157
1337
  // ../shared/src/column-map.ts
1158
- import { z as z8 } from "zod";
1159
- var columnMappingSchema = z8.object({
1160
- nameColumn: z8.string().nullable(),
1161
- idColumn: z8.string().nullable(),
1162
- familyColumn: z8.string().nullable(),
1163
- genusColumn: z8.string().nullable(),
1164
- rankColumn: z8.string().nullable(),
1165
- authorColumn: z8.string().nullable()
1166
- });
1167
- var detectColumnsBodySchema = z8.object({
1168
- headers: z8.array(z8.string().min(1)).min(1).max(200)
1169
- });
1170
- var detectColumnsResponseSchema = z8.object({
1338
+ import { z as z10 } from "zod";
1339
+ var columnMappingSchema = z10.object({
1340
+ nameColumn: z10.string().nullable(),
1341
+ idColumn: z10.string().nullable(),
1342
+ familyColumn: z10.string().nullable(),
1343
+ genusColumn: z10.string().nullable(),
1344
+ rankColumn: z10.string().nullable(),
1345
+ authorColumn: z10.string().nullable()
1346
+ });
1347
+ var detectColumnsBodySchema = z10.object({
1348
+ headers: z10.array(z10.string().min(1)).min(1).max(200)
1349
+ });
1350
+ var detectColumnsResponseSchema = z10.object({
1171
1351
  mapping: columnMappingSchema,
1172
- usedLlm: z8.boolean()
1352
+ usedLlm: z10.boolean()
1173
1353
  });
1174
1354
 
1175
- // src/api-client.ts
1176
- import { promises as fs } from "fs";
1177
- import { basename } from "path";
1178
- import { FormData, fetch, request } from "undici";
1179
- var ApiError = class extends Error {
1180
- constructor(message, status, body) {
1355
+ // ../shared/src/env-flag.ts
1356
+ var FALSY = /* @__PURE__ */ new Set(["false", "0", "no", "off"]);
1357
+ function parseEnvFlag(raw, fallback) {
1358
+ if (raw === void 0 || raw.trim() === "") return fallback;
1359
+ return !FALSY.has(raw.trim().toLowerCase());
1360
+ }
1361
+
1362
+ // ../shared/src/public-server.ts
1363
+ var DEFAULT_SERVER_URL = "https://planttaxomatcher.plantnet.org";
1364
+
1365
+ // ../shared/src/export-columns.ts
1366
+ import { z as z11 } from "zod";
1367
+ var RESULT_COLUMN_DESCRIPTIONS = {
1368
+ planttaxomatcher_job_id: "Id of the job this row belongs to.",
1369
+ planttaxomatcher_input_normalized: "The input name as it was matched, after cleaning (spacing, case, qualifiers removed).",
1370
+ planttaxomatcher_input_qualifier: "Identification qualifier found in the input: cf, aff, sensu_lato, sensu_stricto, aggregate, complex, sp, spp, indet, cultivar, hybrid_formula, informal. Empty when none.",
1371
+ planttaxomatcher_parse_quality: "How cleanly the name parsed (gnparser): 1 clean, 2 minor issues, 3 significant issues, 4 badly formed; empty when unparsed.",
1372
+ planttaxomatcher_match_status: "matched, ambiguous (several candidates, none chosen), no_match, error, or skipped (filtered out, or no usable name).",
1373
+ planttaxomatcher_evidence_type: "How the match was found: local_exact, local_canonical_unique, local_fuzzy, external_validated, external_fuzzy, team_history, cross_backbone, llm.",
1374
+ planttaxomatcher_grade: "A (confident, accepted automatically), B (plausible, needs review) or C (weakest: a language-model suggestion or genus only).",
1375
+ planttaxomatcher_review_status: "not_required (grade A), pending (waiting for review), accepted, rejected, or overridden (a reviewer picked another taxon).",
1376
+ planttaxomatcher_confidence: "Match score between 0 and 1.",
1377
+ planttaxomatcher_score_breakdown: "JSON of the score components: canonical, author, rank and family similarity, provider agreement.",
1378
+ planttaxomatcher_layer: "The cascade layer family that settled the row (same values as the evidence type).",
1379
+ planttaxomatcher_reason: "One-line explanation of the decision.",
1380
+ planttaxomatcher_flags: "Comma-separated reasons a match needs a look, e.g. author-unconfirmed, author-mismatch, genus-fallback.",
1381
+ planttaxomatcher_matched_identifier_namespace: "Namespace of the matched name\u2019s identifier, e.g. wcvp:taxonID or wfo:taxonID.",
1382
+ planttaxomatcher_matched_identifier: "Identifier of the matched name (possibly a synonym).",
1383
+ planttaxomatcher_accepted_identifier_namespace: "Namespace of the accepted taxon\u2019s identifier: wcvp:acceptedNameUsageID or wfo:acceptedNameUsageID.",
1384
+ planttaxomatcher_accepted_identifier: "Identifier of the accepted taxon, in the job\u2019s backbone (WCVP or WFO).",
1385
+ plantnet_identifiable: "true when Pl@ntNet can identify the accepted taxon from photos (when Pl@ntNet is enabled).",
1386
+ plantnet_id: "Pl@ntNet species id of the accepted taxon (when Pl@ntNet is enabled).",
1387
+ wcvp_matched_name: "The backbone name the input matched, which may be a synonym.",
1388
+ wcvp_matched_taxonomic_status: "Status of the matched name in the backbone, e.g. Accepted, Synonym or Illegitimate.",
1389
+ wcvp_accepted_name: "The accepted name the match resolves to, without author.",
1390
+ wcvp_accepted_author: "Author of the accepted name.",
1391
+ wcvp_accepted_name_with_author: "The accepted name and its author in one cell, ready to cite, e.g. Calicotome spinosa (L.) Link. The column to hand back as the cleaned name.",
1392
+ wcvp_accepted_taxon_id: "WCVP id (plant_name_id) of the accepted taxon. Empty for WFO jobs, whose id is in planttaxomatcher_accepted_identifier.",
1393
+ wcvp_accepted_usage_id: "Same id as wcvp_accepted_taxon_id, under its Darwin Core name (acceptedNameUsageID).",
1394
+ ipni_lsid: "IPNI LSID of the accepted name: urn:lsid:ipni.org:names: followed by its IPNI id.",
1395
+ powo_url: "Plants of the World Online page of the accepted taxon.",
1396
+ wcvp_family: "Family of the accepted taxon.",
1397
+ wcvp_rank: "Rank of the accepted taxon, e.g. Species, Subspecies, Variety or Genus.",
1398
+ planttaxomatcher_alternatives: "JSON list of the other candidates, mostly for ambiguous rows.",
1399
+ planttaxomatcher_reviewed: "true when a person reviewed the row.",
1400
+ planttaxomatcher_referential_version: "Backbone version the job matched against, e.g. wcvp-v14.",
1401
+ planttaxomatcher_normalizer_version: "Version of the name normaliser, for reproducibility.",
1402
+ planttaxomatcher_parser_version: "Version of the name parser, for reproducibility.",
1403
+ planttaxomatcher_matcher_version: "Version of the matching cascade, for reproducibility.",
1404
+ planttaxomatcher_scoring_version: "Version of the grading rules, for reproducibility."
1405
+ };
1406
+ var taxonLevelSchema = z11.enum(["all", "species_below", "species_only"]);
1407
+ var WCVP_EXTRA_DESCRIPTIONS = {
1408
+ ipni_id: "IPNI id of the name (the part after urn:lsid:ipni.org:names:).",
1409
+ powo_id: "Plants of the World Online id of the taxon.",
1410
+ plant_name_id: "WCVP id of the record.",
1411
+ accepted_plant_name_id: "WCVP id of the accepted name the record points to.",
1412
+ basionym_plant_name_id: "WCVP id of the basionym (the original name this one is based on).",
1413
+ parent_plant_name_id: "WCVP id of the parent taxon, e.g. the species of a variety.",
1414
+ taxon_authors: "Full author string of the name.",
1415
+ primary_author: "Author or authors who published the name.",
1416
+ parenthetical_author: "Author of the basionym, shown in parentheses in the author string.",
1417
+ publication_author: "Author of the book or article where the name was published, when different.",
1418
+ first_published: "Year of first publication.",
1419
+ place_of_publication: "Journal or book where the name was published.",
1420
+ volume_and_page: "Volume and page of the publication.",
1421
+ geographic_area: "Native distribution, as a short narrative statement.",
1422
+ climate_description: "Habitat or climate type, from published habitat information.",
1423
+ lifeform_description: "Life form, in a modified Raunki\xE6r system (e.g. phanerophyte).",
1424
+ homotypic_synonym: "Whether the name is a homotypic synonym (same type as the accepted name).",
1425
+ replaced_synonym_author: "Author of the replaced synonym, for a replacement name.",
1426
+ nomenclatural_remarks: "Nomenclatural notes, e.g. nom. illeg.",
1427
+ genus_hybrid: "Hybrid marker (\xD7) of the genus, when it is a hybrid.",
1428
+ species_hybrid: "Hybrid marker (\xD7) of the species, when it is a hybrid.",
1429
+ hybrid_formula: "Hybrid formula, e.g. Mentha aquatica \xD7 M. spicata.",
1430
+ infraspecies: "Infraspecific epithet.",
1431
+ infraspecific_rank: "Infraspecific rank, e.g. subsp., var. or f.",
1432
+ species: "Species epithet.",
1433
+ taxon_name: "Full name without author.",
1434
+ reviewed: "Whether Kew has peer-reviewed the family of this taxon.",
1435
+ pn_change: "Team-edited versions only: what the team changed on this record (codes such as re_accept;author).",
1436
+ pn_source: "Team-edited versions only: source of the change.",
1437
+ pn_taxon_id: "Team-edited versions only: the team\u2019s own taxon id.",
1438
+ wcvp13_taxon_status: "Team-edited versions only: taxonomic status before the edit (WCVP v13).",
1439
+ wcvp13_accepted_plant_name_id: "Team-edited versions only: accepted name id before the edit (WCVP v13).",
1440
+ wcvp13_taxon_name: "Team-edited versions only: name before the edit (WCVP v13).",
1441
+ wcvp13_taxon_authors: "Team-edited versions only: authors before the edit (WCVP v13).",
1442
+ wcvp13_family: "Team-edited versions only: family before the edit (WCVP v13).",
1443
+ wcvp13_genus: "Team-edited versions only: genus before the edit (WCVP v13)."
1444
+ };
1445
+
1446
+ // src/errors.ts
1447
+ import { CommanderError } from "commander";
1448
+ var EXIT = {
1449
+ ok: 0,
1450
+ error: 1,
1451
+ usage: 2,
1452
+ auth: 3,
1453
+ jobFailed: 4,
1454
+ jobPaused: 5
1455
+ };
1456
+ var LOGIN_HINT = "Run `planttaxomatcher login`, or set PLANTTAXOMATCHER_TOKEN.";
1457
+ var CliError = class extends Error {
1458
+ constructor(message, exitCode = EXIT.error, hint) {
1181
1459
  super(message);
1460
+ this.exitCode = exitCode;
1461
+ this.hint = hint;
1462
+ }
1463
+ exitCode;
1464
+ hint;
1465
+ };
1466
+ var ApiError = class extends Error {
1467
+ constructor(status, body) {
1468
+ super(describeApiFailure(status, body));
1182
1469
  this.status = status;
1183
1470
  this.body = body;
1184
1471
  }
1185
1472
  status;
1186
1473
  body;
1187
1474
  };
1188
- async function call(creds, path, init) {
1189
- const url = new URL(path, creds.server).toString();
1190
- const hasBody = init?.body !== void 0;
1191
- const res = await request(url, {
1192
- method: init?.method ?? "GET",
1193
- headers: {
1194
- authorization: `Bearer ${creds.token}`,
1195
- ...hasBody ? { "content-type": "application/json" } : {}
1196
- },
1197
- ...hasBody ? { body: JSON.stringify(init.body) } : {}
1198
- });
1199
- const text = await res.body.text();
1200
- const parsed = text ? safeJson(text) : null;
1201
- if (res.statusCode >= 400) {
1202
- throw new ApiError(`HTTP ${res.statusCode}`, res.statusCode, parsed ?? text);
1475
+ function describeApiFailure(status, body) {
1476
+ const parsed = body && typeof body === "object" ? body : {};
1477
+ const text = typeof body === "string" ? body.trim() : "";
1478
+ const code = typeof parsed.error === "string" ? parsed.error : "";
1479
+ const message = typeof parsed.message === "string" ? parsed.message : "";
1480
+ if (code === "missing_scope" && Array.isArray(parsed.required)) {
1481
+ return `HTTP ${status}: this token lacks the ${parsed.required.join(", ")} scope`;
1203
1482
  }
1204
- return parsed;
1483
+ if (text.startsWith("<"))
1484
+ return `HTTP ${status}: the server answered with a web page, not the API \u2014 check the server URL`;
1485
+ const detail = message || code || text.slice(0, 200);
1486
+ return detail ? `HTTP ${status}: ${detail}` : `HTTP ${status}`;
1205
1487
  }
1206
- function safeJson(s) {
1488
+ function describeError(err) {
1489
+ if (err instanceof CommanderError) {
1490
+ return { exitCode: err.exitCode === 0 ? EXIT.ok : EXIT.usage, message: null };
1491
+ }
1492
+ if (err instanceof CliError) {
1493
+ return { exitCode: err.exitCode, message: err.message, ...err.hint ? { hint: err.hint } : {} };
1494
+ }
1495
+ if (err instanceof ApiError) {
1496
+ if (err.status === 401) return { exitCode: EXIT.auth, message: err.message, hint: LOGIN_HINT };
1497
+ return { exitCode: EXIT.error, message: err.message };
1498
+ }
1499
+ return { exitCode: EXIT.error, message: err instanceof Error ? err.message : String(err) };
1500
+ }
1501
+ function unreachable(server, err) {
1502
+ const cause = err instanceof Error && err.cause instanceof Error ? err.cause : err;
1503
+ const code = cause?.code;
1504
+ const reason = code ?? (cause instanceof Error ? cause.message : String(cause));
1505
+ return new CliError(`cannot reach ${server}: ${reason}`);
1506
+ }
1507
+
1508
+ // src/ndjson.ts
1509
+ async function* parseNdjson(chunks) {
1510
+ const decoder = new TextDecoder("utf-8");
1511
+ let buffered = "";
1512
+ for await (const chunk of chunks) {
1513
+ buffered += typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
1514
+ let newline;
1515
+ while ((newline = buffered.indexOf("\n")) >= 0) {
1516
+ const frame = parseLine(buffered.slice(0, newline));
1517
+ buffered = buffered.slice(newline + 1);
1518
+ if (frame) yield frame;
1519
+ }
1520
+ }
1521
+ const last = parseLine(buffered + decoder.decode());
1522
+ if (last) yield last;
1523
+ }
1524
+ function parseLine(line) {
1525
+ const trimmed = line.trim();
1526
+ if (!trimmed) return null;
1207
1527
  try {
1208
- return JSON.parse(s);
1528
+ const value = JSON.parse(trimmed);
1529
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
1209
1530
  } catch {
1210
1531
  return null;
1211
1532
  }
1212
1533
  }
1213
- var apiClient = {
1214
- me: (c) => call(c, "/v1/me"),
1215
- listJobs: (c) => call(c, "/v1/jobs"),
1216
- getJob: (c, id) => call(c, `/v1/jobs/${id}`),
1217
- downloadColumns: (c, id) => call(c, `/v1/jobs/${id}/download/columns`),
1218
- pauseJob: (c, id) => call(c, `/v1/jobs/${id}/pause`, { method: "POST" }),
1219
- resumeJob: (c, id) => call(c, `/v1/jobs/${id}/resume`, { method: "POST" }),
1220
- cancelJob: (c, id) => call(c, `/v1/jobs/${id}/cancel`, { method: "POST" }),
1221
- async submitJob(creds, filePath, config, name) {
1222
- const buf = await fs.readFile(filePath);
1223
- const fd = new FormData();
1224
- const lower = filePath.toLowerCase();
1225
- const mime = lower.endsWith(".json") ? "application/json" : lower.endsWith(".xlsx") || lower.endsWith(".xls") ? "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" : "text/csv";
1226
- fd.set("file", new Blob([buf], { type: mime }), basename(filePath));
1227
- fd.set("config", JSON.stringify(config));
1228
- if (name && name.trim()) fd.set("name", name.trim());
1229
- const url = new URL("/v1/jobs", creds.server).toString();
1230
- const res = await fetch(url, {
1231
- method: "POST",
1232
- body: fd,
1233
- headers: { authorization: `Bearer ${creds.token}` }
1534
+
1535
+ // src/api-client.ts
1536
+ var MIME_BY_EXTENSION = {
1537
+ json: "application/json",
1538
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
1539
+ xls: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
1540
+ };
1541
+ function uploadMimeType(filePath) {
1542
+ const extension = filePath.toLowerCase().split(".").pop() ?? "";
1543
+ return MIME_BY_EXTENSION[extension] ?? "text/csv";
1544
+ }
1545
+ function downloadFilename(contentDisposition, jobId, opts) {
1546
+ const match = /filename="?([^";]+)"?/.exec(contentDisposition ?? "");
1547
+ const suggested = match?.[1] ? basename(match[1].replaceAll("\\", "/")) : "";
1548
+ if (suggested && suggested !== "." && suggested !== "..") return suggested;
1549
+ return `planttaxomatcher_${jobId}.${opts.bundle ? "zip" : opts.format}`;
1550
+ }
1551
+ function filterParams(filters, params = new URLSearchParams()) {
1552
+ if (filters.confirmedOnly) params.set("confirmedOnly", "true");
1553
+ if (filters.resolvedOnly) params.set("notEmptyOnly", "true");
1554
+ if (filters.dedupe) params.set("dedupe", "true");
1555
+ if (filters.taxonLevel && filters.taxonLevel !== "all") params.set("taxonLevel", filters.taxonLevel);
1556
+ return params;
1557
+ }
1558
+ function downloadQuery(opts) {
1559
+ const params = filterParams(opts, new URLSearchParams({ format: opts.format }));
1560
+ if (opts.bundle) params.set("bundle", "true");
1561
+ if (opts.delimiter) params.set("delimiter", opts.delimiter);
1562
+ if (opts.columns) params.set("columns", opts.columns);
1563
+ if (opts.wcvpExtra) params.set("wcvpExtra", opts.wcvpExtra);
1564
+ return params;
1565
+ }
1566
+ function notAnApi(server, path) {
1567
+ return new CliError(`${server} did not answer ${path} like a PlantTaxoMatcher API \u2014 check the server URL`);
1568
+ }
1569
+ function safeJson(text) {
1570
+ try {
1571
+ return JSON.parse(text);
1572
+ } catch {
1573
+ return null;
1574
+ }
1575
+ }
1576
+ function createApiClient(options = {}) {
1577
+ const dispatcher = options.dispatcher ? { dispatcher: options.dispatcher } : {};
1578
+ async function send(target, path, init = {}) {
1579
+ const headers = target.token ? { authorization: `Bearer ${target.token}` } : {};
1580
+ if (typeof init.body === "string") headers["content-type"] = "application/json";
1581
+ try {
1582
+ return await fetch(new URL(path, target.server), {
1583
+ method: init.method ?? "GET",
1584
+ headers,
1585
+ ...init.body !== void 0 ? { body: init.body } : {},
1586
+ ...dispatcher
1587
+ });
1588
+ } catch (err) {
1589
+ throw unreachable(target.server, err);
1590
+ }
1591
+ }
1592
+ async function call(creds, path, init = {}) {
1593
+ const res = await send(creds, path, {
1594
+ ...init.method ? { method: init.method } : {},
1595
+ ...init.json !== void 0 ? { body: JSON.stringify(init.json) } : {}
1234
1596
  });
1235
1597
  const text = await res.text();
1236
1598
  const parsed = text ? safeJson(text) : null;
1237
- if (!res.ok) throw new ApiError(`HTTP ${res.status}`, res.status, parsed ?? text);
1599
+ if (!res.ok) throw new ApiError(res.status, parsed ?? text);
1600
+ if (parsed === null) throw notAnApi(creds.server, path);
1238
1601
  return parsed;
1239
- },
1240
- async downloadJob(creds, id, opts) {
1241
- const params = new URLSearchParams({ format: opts.format });
1242
- if (opts.confirmedOnly) params.set("confirmedOnly", "true");
1243
- if (opts.dedupe) params.set("dedupe", "true");
1244
- if (opts.bundle) params.set("bundle", "true");
1245
- if (opts.delimiter) params.set("delimiter", opts.delimiter);
1246
- if (opts.columns) params.set("columns", opts.columns);
1247
- if (opts.wcvpExtra) params.set("wcvpExtra", opts.wcvpExtra);
1248
- const url = new URL(`/v1/jobs/${id}/download?${params}`, creds.server).toString();
1249
- const res = await fetch(url, { headers: { authorization: `Bearer ${creds.token}` } });
1250
- if (!res.ok) {
1251
- const text = await res.text().catch(() => "");
1252
- throw new ApiError(`HTTP ${res.status}`, res.status, text);
1253
- }
1254
- const disp = res.headers.get("content-disposition") ?? "";
1255
- const m = /filename="?([^"]+)"?/.exec(disp);
1256
- const ext = opts.bundle ? "zip" : opts.format === "csv" ? "csv" : opts.format === "json" ? "json" : opts.format === "xlsx" ? "xlsx" : "ndjson";
1257
- const filename = m?.[1] ?? `planttaxomatcher_${id}.${ext}`;
1258
- const buf = Buffer.from(await res.arrayBuffer());
1259
- return { filename, body: buf };
1260
- },
1261
- async *streamJob(creds, id) {
1262
- const url = new URL(`/v1/jobs/${id}/stream`, creds.server).toString();
1263
- const res = await fetch(url, { headers: { authorization: `Bearer ${creds.token}` } });
1264
- if (!res.ok || !res.body) {
1265
- const text = await res.text().catch(() => "");
1266
- throw new ApiError(`HTTP ${res.status}`, res.status, text);
1267
- }
1268
- const reader = res.body.getReader();
1269
- const decoder = new TextDecoder("utf-8");
1270
- let buf = "";
1271
- try {
1272
- for (; ; ) {
1273
- const { done, value } = await reader.read();
1274
- if (done) break;
1275
- buf += decoder.decode(value, { stream: true });
1276
- let nl;
1277
- while ((nl = buf.indexOf("\n")) >= 0) {
1278
- const line = buf.slice(0, nl).trim();
1279
- buf = buf.slice(nl + 1);
1280
- if (!line) continue;
1281
- try {
1282
- yield JSON.parse(line);
1283
- } catch {
1284
- }
1602
+ }
1603
+ async function failWith(res) {
1604
+ const text = await res.text().catch(() => "");
1605
+ throw new ApiError(res.status, (text && safeJson(text)) ?? text);
1606
+ }
1607
+ return {
1608
+ async startDeviceAuthorization(server, clientName) {
1609
+ let created;
1610
+ try {
1611
+ created = await call({ server }, "/v1/device-authorizations", {
1612
+ method: "POST",
1613
+ json: { clientName }
1614
+ });
1615
+ } catch (err) {
1616
+ if (err instanceof ApiError && err.status === 404) {
1617
+ throw new CliError(
1618
+ `${server} does not offer browser sign-in`,
1619
+ EXIT.usage,
1620
+ "Pass a personal token instead: `planttaxomatcher login --token-stdin`."
1621
+ );
1285
1622
  }
1623
+ throw err;
1286
1624
  }
1287
- } finally {
1288
- await reader.cancel().catch(() => {
1625
+ const checked = deviceAuthorizationCreatedSchema.safeParse(created);
1626
+ if (!checked.success) throw notAnApi(server, "/v1/device-authorizations");
1627
+ return checked.data;
1628
+ },
1629
+ /** One poll: the token once approved, else the RFC 8628 reason to keep waiting or stop. */
1630
+ async pollDeviceToken(server, deviceCode) {
1631
+ const res = await send({ server }, "/v1/device-tokens", {
1632
+ method: "POST",
1633
+ body: JSON.stringify({ deviceCode })
1289
1634
  });
1635
+ const text = await res.text();
1636
+ const parsed = text ? safeJson(text) : null;
1637
+ if (res.status === 400) {
1638
+ const refusal = deviceTokenErrorSchema.safeParse(parsed);
1639
+ if (refusal.success) return refusal.data;
1640
+ }
1641
+ if (!res.ok) throw new ApiError(res.status, parsed ?? text);
1642
+ const token = deviceTokenSchema.safeParse(parsed);
1643
+ if (!token.success) throw notAnApi(server, "/v1/device-tokens");
1644
+ return token.data;
1645
+ },
1646
+ async me(c) {
1647
+ const checked = meSchema.safeParse(await call(c, "/v1/me"));
1648
+ if (!checked.success) throw notAnApi(c.server, "/v1/me");
1649
+ return checked.data;
1650
+ },
1651
+ listJobs(c, opts = {}) {
1652
+ const params = new URLSearchParams();
1653
+ if (opts.limit !== void 0) params.set("limit", String(opts.limit));
1654
+ if (opts.status) params.set("status", opts.status);
1655
+ const query = params.size > 0 ? `?${params}` : "";
1656
+ return call(c, `/v1/jobs${query}`);
1657
+ },
1658
+ getJob: (c, id) => call(c, `/v1/jobs/${encodeURIComponent(id)}`),
1659
+ downloadColumns: (c, id) => call(c, `/v1/jobs/${encodeURIComponent(id)}/download/columns`),
1660
+ downloadCounts(c, id, filters) {
1661
+ const query = filterParams(filters);
1662
+ const suffix = query.size > 0 ? `?${query}` : "";
1663
+ return call(c, `/v1/jobs/${encodeURIComponent(id)}/download/count${suffix}`);
1664
+ },
1665
+ listVersions: (c, backbone) => call(c, `/v1/${backbone}/snapshots`),
1666
+ async listAreas(c) {
1667
+ return (await call(c, "/v1/wgsrpd/areas")).areas;
1668
+ },
1669
+ pauseJob: (c, id) => call(c, `/v1/jobs/${encodeURIComponent(id)}/pause`, { method: "POST" }),
1670
+ resumeJob: (c, id) => call(c, `/v1/jobs/${encodeURIComponent(id)}/resume`, { method: "POST" }),
1671
+ cancelJob: (c, id) => call(c, `/v1/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST" }),
1672
+ async submitJob(c, filePath, config, name) {
1673
+ const form = new FormData();
1674
+ form.set("config", JSON.stringify(config));
1675
+ if (name) form.set("name", name);
1676
+ form.set("file", await openAsBlob(filePath, { type: uploadMimeType(filePath) }), basename(filePath));
1677
+ const res = await send(c, "/v1/jobs", { method: "POST", body: form });
1678
+ if (!res.ok) return failWith(res);
1679
+ return await res.json();
1680
+ },
1681
+ async downloadJob(c, id, opts) {
1682
+ const res = await send(c, `/v1/jobs/${encodeURIComponent(id)}/download?${downloadQuery(opts)}`);
1683
+ if (!res.ok) return failWith(res);
1684
+ const filename = downloadFilename(res.headers.get("content-disposition"), id, opts);
1685
+ return { filename, body: Buffer.from(await res.arrayBuffer()) };
1686
+ },
1687
+ async *streamJob(c, id) {
1688
+ const res = await send(c, `/v1/jobs/${encodeURIComponent(id)}/stream`);
1689
+ if (!res.ok || !res.body) return failWith(res);
1690
+ const reader = res.body.getReader();
1691
+ const chunks = {
1692
+ async *[Symbol.asyncIterator]() {
1693
+ for (; ; ) {
1694
+ const { done, value } = await reader.read();
1695
+ if (done) return;
1696
+ yield value;
1697
+ }
1698
+ }
1699
+ };
1700
+ try {
1701
+ yield* parseNdjson(chunks);
1702
+ } finally {
1703
+ await reader.cancel().catch(() => {
1704
+ });
1705
+ }
1290
1706
  }
1291
- }
1292
- };
1707
+ };
1708
+ }
1293
1709
 
1294
1710
  // src/config.ts
1295
- import { promises as fs2 } from "fs";
1711
+ import { promises as fs } from "fs";
1296
1712
  import { homedir } from "os";
1297
1713
  import { join } from "path";
1298
- var CONFIG_DIR = join(homedir(), ".config", "planttaxomatcher");
1299
- var CONFIG_FILE = join(CONFIG_DIR, "credentials");
1300
- async function readCredentials() {
1714
+
1715
+ // src/transport.ts
1716
+ function isLoopback(hostname2) {
1717
+ return hostname2 === "localhost" || hostname2 === "127.0.0.1" || hostname2 === "[::1]" || hostname2 === "::1" || hostname2.endsWith(".localhost");
1718
+ }
1719
+ function assertServerTransport(server, options = { allowInsecure: false }) {
1720
+ let url;
1721
+ try {
1722
+ url = new URL(server);
1723
+ } catch {
1724
+ throw new CliError(`invalid server URL: ${server}`, EXIT.usage);
1725
+ }
1726
+ if (url.protocol === "https:") return;
1727
+ if (url.protocol !== "http:") {
1728
+ throw new CliError(`server must use http or https (got ${url.protocol})`, EXIT.usage);
1729
+ }
1730
+ if (!isLoopback(url.hostname) && !options.allowInsecure) {
1731
+ const override = options.insecureFlag ? ", or pass --insecure to override (NOT recommended)" : "";
1732
+ throw new CliError(
1733
+ `refusing to send a token in cleartext to ${url.host}. Use an https URL${override}.`,
1734
+ EXIT.usage
1735
+ );
1736
+ }
1737
+ }
1738
+
1739
+ // src/config.ts
1740
+ function defaultConfigDir() {
1741
+ return join(homedir(), ".config", "planttaxomatcher");
1742
+ }
1743
+ function createCredentialStore(dir = defaultConfigDir()) {
1744
+ const file = join(dir, "credentials");
1745
+ return {
1746
+ file,
1747
+ async read() {
1748
+ try {
1749
+ return JSON.parse(await fs.readFile(file, "utf8"));
1750
+ } catch (err) {
1751
+ if (err.code === "ENOENT") return null;
1752
+ throw err;
1753
+ }
1754
+ },
1755
+ async write(creds) {
1756
+ await fs.mkdir(dir, { recursive: true, mode: 448 });
1757
+ await fs.writeFile(file, JSON.stringify(creds, null, 2), { mode: 384 });
1758
+ await fs.chmod(file, 384);
1759
+ },
1760
+ async clear() {
1761
+ await fs.rm(file, { force: true });
1762
+ }
1763
+ };
1764
+ }
1765
+ function envValue(env2, key) {
1766
+ const value = env2[key]?.trim();
1767
+ return value ? value : void 0;
1768
+ }
1769
+ function serverFromEnv(env2) {
1770
+ return envValue(env2, "PLANTTAXOMATCHER_SERVER");
1771
+ }
1772
+ function tokenFromEnv(env2) {
1773
+ return envValue(env2, "PLANTTAXOMATCHER_TOKEN");
1774
+ }
1775
+ function sameServer(a, b) {
1776
+ const normalize = (url) => url.trim().replace(/\/+$/, "").toLowerCase();
1777
+ return normalize(a) === normalize(b);
1778
+ }
1779
+ function resolveCredentials(stored, env2) {
1780
+ const envToken = tokenFromEnv(env2);
1781
+ const envServer = serverFromEnv(env2);
1782
+ if (envToken) return { token: envToken, server: envServer ?? DEFAULT_SERVER_URL };
1783
+ if (!stored) return null;
1784
+ if (envServer && !sameServer(envServer, stored.server)) {
1785
+ throw new CliError(
1786
+ `PLANTTAXOMATCHER_SERVER (${envServer}) is not the server of the saved login (${stored.server})`,
1787
+ EXIT.usage,
1788
+ "Set PLANTTAXOMATCHER_TOKEN for that server too, or run `planttaxomatcher login --server <url>`."
1789
+ );
1790
+ }
1791
+ return stored;
1792
+ }
1793
+ async function requireCredentials(store, env2) {
1794
+ const envServer = serverFromEnv(env2);
1795
+ if (envServer) assertServerTransport(envServer, { allowInsecure: false });
1796
+ const creds = resolveCredentials(await store.read(), env2);
1797
+ if (!creds) throw new CliError("not signed in", EXIT.auth, LOGIN_HINT);
1798
+ return creds;
1799
+ }
1800
+
1801
+ // src/skills/paths.ts
1802
+ import { join as join2 } from "path";
1803
+ var SKILL_NAME = "planttaxomatcher";
1804
+ var MARKER_FILE = ".planttaxomatcher-managed";
1805
+ var AGENTS = ["claude", "codex", "opencode", "pi"];
1806
+ var SKILL_DIRS = ["claude", "agents", "opencode", "pi"];
1807
+ var env = (value) => value?.trim() ? value.trim() : void 0;
1808
+ function skillPaths(home, vars) {
1809
+ const dataHome = env(vars.XDG_DATA_HOME) ?? join2(home, ".local", "share");
1810
+ const configHome = env(vars.XDG_CONFIG_HOME) ?? join2(home, ".config");
1811
+ const claudeHome = env(vars.CLAUDE_CONFIG_DIR) ?? join2(home, ".claude");
1812
+ return {
1813
+ canonical: join2(dataHome, "planttaxomatcher", "skills", SKILL_NAME),
1814
+ entries: {
1815
+ claude: join2(claudeHome, "skills", SKILL_NAME),
1816
+ agents: join2(home, ".agents", "skills", SKILL_NAME),
1817
+ opencode: join2(configHome, "opencode", "skills", SKILL_NAME),
1818
+ pi: join2(home, ".pi", "agent", "skills", SKILL_NAME)
1819
+ },
1820
+ agentHomes: {
1821
+ claude: claudeHome,
1822
+ codex: join2(home, ".codex"),
1823
+ opencode: join2(configHome, "opencode"),
1824
+ pi: join2(home, ".pi")
1825
+ }
1826
+ };
1827
+ }
1828
+ var READS = {
1829
+ claude: ["claude"],
1830
+ codex: ["agents"],
1831
+ opencode: ["opencode", "agents", "claude"],
1832
+ pi: ["pi", "agents"]
1833
+ };
1834
+ function linkPlan(agents, alreadyLinked) {
1835
+ const plan = /* @__PURE__ */ new Set();
1836
+ if (agents.includes("claude")) plan.add("claude");
1837
+ if (agents.includes("codex") || agents.includes("pi")) plan.add("agents");
1838
+ const openCodeCovered = ["claude", "agents"].some(
1839
+ (dir) => plan.has(dir) || alreadyLinked.has(dir)
1840
+ );
1841
+ if (agents.includes("opencode") && !openCodeCovered) plan.add("opencode");
1842
+ return [...plan];
1843
+ }
1844
+ function parseAgents(value) {
1845
+ const names = value.split(",").map((name) => name.trim().toLowerCase()).filter(Boolean);
1846
+ if (names.includes("all")) return "all";
1847
+ return names.length > 0 && names.every((name) => AGENTS.includes(name)) ? names : null;
1848
+ }
1849
+ function isNewerVersion(candidate, current) {
1850
+ const parse = (v) => v.split(/[.+-]/).slice(0, 3).map((n) => Number.parseInt(n, 10) || 0);
1851
+ const [a, b] = [parse(candidate), parse(current)];
1852
+ for (let i = 0; i < 3; i++) {
1853
+ if (a[i] !== b[i]) return a[i] > b[i];
1854
+ }
1855
+ return false;
1856
+ }
1857
+
1858
+ // src/deps.ts
1859
+ async function readAllStdin() {
1860
+ const chunks = [];
1861
+ for await (const chunk of process.stdin) chunks.push(chunk);
1862
+ return Buffer.concat(chunks).toString("utf8").trim();
1863
+ }
1864
+ function browserCommand(url) {
1865
+ if (process.platform === "darwin") return ["open", [url]];
1866
+ if (process.platform === "win32") return ["rundll32", ["url.dll,FileProtocolHandler", url]];
1867
+ return ["xdg-open", [url]];
1868
+ }
1869
+ function openInBrowser(url, env2) {
1870
+ if (env2.SSH_CONNECTION || env2.SSH_TTY) return Promise.resolve(false);
1871
+ const [command, args] = browserCommand(url);
1872
+ return new Promise((resolve2) => {
1873
+ const child = spawn(command, args, { stdio: "ignore", detached: true });
1874
+ child.once("error", () => resolve2(false));
1875
+ child.once("spawn", () => {
1876
+ child.unref();
1877
+ resolve2(true);
1878
+ });
1879
+ });
1880
+ }
1881
+ function defaultDeps() {
1882
+ return {
1883
+ api: createApiClient(),
1884
+ store: createCredentialStore(),
1885
+ env: process.env,
1886
+ stdout: process.stdout,
1887
+ stderr: process.stderr,
1888
+ stdinIsTTY: !!process.stdin.isTTY,
1889
+ readStdin: readAllStdin,
1890
+ promptConfirm: (message) => confirm({ message, default: true }),
1891
+ openBrowser: (url) => openInBrowser(url, process.env),
1892
+ promptAgents: (detected) => checkbox({
1893
+ message: "Install the skill for",
1894
+ choices: AGENTS.map((agent) => ({ value: agent, checked: detected.includes(agent) }))
1895
+ }),
1896
+ hostname,
1897
+ home: homedir2(),
1898
+ // `src/` in development and the bundled `dist/` both sit next to `skills/`.
1899
+ skillSource: fileURLToPath(new URL("../skills/planttaxomatcher", import.meta.url)),
1900
+ sleep: (ms) => sleep(ms),
1901
+ now: Date.now
1902
+ };
1903
+ }
1904
+
1905
+ // src/program.ts
1906
+ import { Command } from "commander";
1907
+ import kleur10 from "kleur";
1908
+
1909
+ // src/output.ts
1910
+ import kleur from "kleur";
1911
+ function createOutput(stdout, stderr, json, now = Date.now) {
1912
+ const human = json ? stderr : stdout;
1913
+ const line = (text) => human.write(`${text}
1914
+ `);
1915
+ return {
1916
+ json,
1917
+ info: line,
1918
+ success: (message) => line(`${kleur.green("\u2713")} ${message}`),
1919
+ warn: (message) => stderr.write(`${kleur.yellow("!")} ${message}
1920
+ `),
1921
+ data: (value) => stdout.write(`${JSON.stringify(value)}
1922
+ `),
1923
+ progress: createProgressWriter(human, now)
1924
+ };
1925
+ }
1926
+ var NON_TTY_PROGRESS_INTERVAL_MS = 5e3;
1927
+ function createProgressWriter(sink, now, intervalMs = NON_TTY_PROGRESS_INTERVAL_MS) {
1928
+ let width = 0;
1929
+ let lastEmittedAt = null;
1930
+ let pending = null;
1931
+ if (sink.isTTY) {
1932
+ return {
1933
+ update(line) {
1934
+ sink.write(`\r${line.padEnd(width)}`);
1935
+ width = line.length;
1936
+ },
1937
+ done() {
1938
+ if (width > 0) sink.write("\n");
1939
+ width = 0;
1940
+ }
1941
+ };
1942
+ }
1943
+ return {
1944
+ update(line) {
1945
+ const at = now();
1946
+ if (lastEmittedAt !== null && at - lastEmittedAt < intervalMs) {
1947
+ pending = line;
1948
+ return;
1949
+ }
1950
+ sink.write(`${line}
1951
+ `);
1952
+ lastEmittedAt = at;
1953
+ pending = null;
1954
+ },
1955
+ done() {
1956
+ if (pending !== null) sink.write(`${pending}
1957
+ `);
1958
+ pending = null;
1959
+ lastEmittedAt = null;
1960
+ }
1961
+ };
1962
+ }
1963
+
1964
+ // src/device-login.ts
1965
+ import kleur2 from "kleur";
1966
+ var RESTART_HINT = "Run `planttaxomatcher login` again.";
1967
+ var CLIENT_NAME_MAX = 100;
1968
+ function isTransient(err) {
1969
+ if (err instanceof ApiError) return err.status === 429 || err.status >= 500;
1970
+ return err instanceof CliError && err.message.startsWith("cannot reach");
1971
+ }
1972
+ async function pollOnce(deps2, server, deviceCode) {
1301
1973
  try {
1302
- const raw = await fs2.readFile(CONFIG_FILE, "utf8");
1303
- return JSON.parse(raw);
1974
+ return await deps2.api.pollDeviceToken(server, deviceCode);
1304
1975
  } catch (err) {
1305
- if (err.code === "ENOENT") return null;
1976
+ if (isTransient(err)) return { error: "slow_down" };
1306
1977
  throw err;
1307
1978
  }
1308
1979
  }
1309
- async function writeCredentials(creds) {
1310
- await fs2.mkdir(CONFIG_DIR, { recursive: true, mode: 448 });
1311
- await fs2.writeFile(CONFIG_FILE, JSON.stringify(creds, null, 2), { mode: 384 });
1980
+ async function deviceLogin(deps2, out, server, options) {
1981
+ const clientName = deps2.hostname().trim().slice(0, CLIENT_NAME_MAX) || "unknown";
1982
+ const request = await deps2.api.startDeviceAuthorization(server, clientName);
1983
+ out.info(`To sign in, open this page and approve the code ${kleur2.bold(request.userCode)}:`);
1984
+ out.info(` ${kleur2.cyan(request.verificationUriComplete)}`);
1985
+ if (options.openBrowser && await deps2.openBrowser(request.verificationUriComplete)) {
1986
+ out.info(kleur2.gray("Opened it in your browser."));
1987
+ }
1988
+ out.info(kleur2.gray("Waiting for approval\u2026"));
1989
+ const deadline = deps2.now() + request.expiresIn * 1e3;
1990
+ let interval = request.interval;
1991
+ while (deps2.now() < deadline) {
1992
+ await deps2.sleep(interval * 1e3);
1993
+ const answer = await pollOnce(deps2, server, request.deviceCode);
1994
+ if ("token" in answer) return answer;
1995
+ if (answer.error === "slow_down") interval = answer.interval ?? interval + SLOW_DOWN_STEP_SECONDS;
1996
+ else if (answer.error === "access_denied")
1997
+ throw new CliError("the sign-in was denied in the browser", EXIT.auth);
1998
+ else if (answer.error === "expired_token") break;
1999
+ }
2000
+ throw new CliError("the sign-in code expired before it was approved", EXIT.auth, RESTART_HINT);
1312
2001
  }
1313
- async function clearCredentials() {
2002
+
2003
+ // src/commands/auth.ts
2004
+ async function suppliedToken(opts, deps2) {
2005
+ if (opts.tokenStdin) {
2006
+ const token = (await deps2.readStdin()).trim();
2007
+ if (!token) throw new CliError("--token-stdin was set but stdin was empty", EXIT.usage);
2008
+ return token;
2009
+ }
2010
+ if (opts.token) return opts.token.trim();
2011
+ return tokenFromEnv(deps2.env) ?? null;
2012
+ }
2013
+ async function browserToken(deps2, out, server, opts) {
2014
+ if (parseEnvFlag(deps2.env.CI, false)) {
2015
+ throw new CliError(
2016
+ "no token given, and a browser sign-in cannot be approved in CI",
2017
+ EXIT.usage,
2018
+ "Set PLANTTAXOMATCHER_TOKEN, or pipe one to `planttaxomatcher login --token-stdin`."
2019
+ );
2020
+ }
2021
+ return (await deviceLogin(deps2, out, server, { openBrowser: opts.browser !== false })).token;
2022
+ }
2023
+ async function verifyToken(deps2, server, token) {
1314
2024
  try {
1315
- await fs2.unlink(CONFIG_FILE);
2025
+ return await deps2.api.me({ server, token });
1316
2026
  } catch (err) {
1317
- if (err.code !== "ENOENT") throw err;
2027
+ if (err instanceof ApiError && err.status === 401) {
2028
+ throw new CliError(`${server} rejected this token (${err.message})`, EXIT.auth);
2029
+ }
2030
+ if (err instanceof ApiError && err.status === 403) return null;
2031
+ throw err;
1318
2032
  }
1319
2033
  }
1320
- async function requireCredentials() {
1321
- const creds = await readCredentials();
1322
- if (!creds) {
1323
- throw new Error("Not logged in. Run: planttaxomatcher login --token <t> --server <url>");
2034
+ function registerAuthCommands(program, deps2) {
2035
+ program.command("login").description(
2036
+ "Sign in through the browser (approve a code on the web app), or save a personal token you pass in"
2037
+ ).option(
2038
+ "--token <token>",
2039
+ "Personal token (ptm_...). Avoid on shared hosts: it is visible in shell history and the process list. Prefer --token-stdin or PLANTTAXOMATCHER_TOKEN."
2040
+ ).option(
2041
+ "--token-stdin",
2042
+ "Read the token from stdin (e.g. `cat token.txt | planttaxomatcher login --token-stdin`)",
2043
+ false
2044
+ ).option("--server <url>", `API server URL (default: $PLANTTAXOMATCHER_SERVER, else ${DEFAULT_SERVER_URL})`).option(
2045
+ "--insecure",
2046
+ "Allow sending the token over cleartext http to a non-loopback server (NOT recommended)",
2047
+ false
2048
+ ).option("--no-browser", "Print the sign-in link without opening a browser").option("--json", "Print the result as JSON", false).action(async (opts) => {
2049
+ const out = createOutput(deps2.stdout, deps2.stderr, !!opts.json, deps2.now);
2050
+ const server = opts.server ?? serverFromEnv(deps2.env) ?? DEFAULT_SERVER_URL;
2051
+ assertServerTransport(server, { allowInsecure: !!opts.insecure, insecureFlag: true });
2052
+ const token = await suppliedToken(opts, deps2) ?? await browserToken(deps2, out, server, opts);
2053
+ if (!tokenStringSchema.safeParse(token).success) {
2054
+ throw new CliError("malformed token \u2014 expected ptm_<12 chars>_<32 chars>", EXIT.usage);
2055
+ }
2056
+ const me = await verifyToken(deps2, server, token);
2057
+ await deps2.store.write({ token, server, ...me?.appUrl ? { appUrl: me.appUrl } : {} });
2058
+ if (out.json) {
2059
+ out.data({ server, displayName: me?.displayName ?? null, scopes: me?.scopes ?? null });
2060
+ return;
2061
+ }
2062
+ out.success(me ? `Signed in to ${server} as ${me.displayName}` : `Signed in to ${server}`);
2063
+ if (!me) out.warn("this token cannot read jobs (no read:job scope)");
2064
+ });
2065
+ program.command("logout").description("Forget the saved token").action(async () => {
2066
+ await deps2.store.clear();
2067
+ createOutput(deps2.stdout, deps2.stderr, false).success("Logged out");
2068
+ });
2069
+ program.command("whoami").description("Show who the current token belongs to").option("--json", "Print the result as JSON", false).action(async (opts) => {
2070
+ const out = createOutput(deps2.stdout, deps2.stderr, !!opts.json, deps2.now);
2071
+ const creds = await requireCredentials(deps2.store, deps2.env);
2072
+ const me = await deps2.api.me(creds);
2073
+ if (out.json) {
2074
+ out.data({ ...me, server: creds.server });
2075
+ return;
2076
+ }
2077
+ out.info(`${me.displayName} team=${me.teamId} scopes=${me.scopes.join(",")}`);
2078
+ out.info(`server=${creds.server}`);
2079
+ });
2080
+ }
2081
+
2082
+ // src/commands/catalog.ts
2083
+ import kleur3 from "kleur";
2084
+
2085
+ // src/catalog.ts
2086
+ function parseBackbone(value) {
2087
+ const parsed = backboneSchema.safeParse(value.trim().toLowerCase());
2088
+ if (!parsed.success) throw new CliError(`--backbone must be wcvp or wfo (got "${value}")`, EXIT.usage);
2089
+ return parsed.data;
2090
+ }
2091
+ function toVersionEntries(backbone, summaries) {
2092
+ return summaries.map((s) => ({
2093
+ backbone,
2094
+ version: s.version,
2095
+ kind: s.kind,
2096
+ label: s.label,
2097
+ baseVersion: s.baseVersion,
2098
+ isLatest: s.isLatest,
2099
+ // Servers before isDefault fall back to "latest", which is what they used.
2100
+ isDefault: s.isDefault ?? s.isLatest,
2101
+ recordCount: s.recordCount,
2102
+ importedAt: s.importedAt
2103
+ }));
2104
+ }
2105
+ var MAX_LISTED = 12;
2106
+ function requireVersion(entries, backbone, requested) {
2107
+ const found = entries.find((e) => e.version === requested);
2108
+ if (found) return found;
2109
+ const available = entries.map((e) => e.version);
2110
+ const listed = available.slice(0, MAX_LISTED).join(", ") + (available.length > MAX_LISTED ? ", \u2026" : "");
2111
+ throw new CliError(
2112
+ `no ${backbone.toUpperCase()} version "${requested}"${available.length ? ` (available: ${listed})` : ""}`,
2113
+ EXIT.usage,
2114
+ `Run \`planttaxomatcher versions --backbone ${backbone}\` to see them all.`
2115
+ );
2116
+ }
2117
+ function matches(area, needle) {
2118
+ return [area.code, area.name, area.region, area.continent].some((field) => field?.toLowerCase().includes(needle));
2119
+ }
2120
+ function searchAreas(areas, search) {
2121
+ const needle = search?.trim().toLowerCase();
2122
+ return needle ? areas.filter((area) => matches(area, needle)) : areas;
2123
+ }
2124
+ function requireArea(areas, requested) {
2125
+ const code = requested.trim().toUpperCase();
2126
+ const found = areas.find((a) => a.code.toUpperCase() === code);
2127
+ if (found) return found;
2128
+ const suggestions = searchAreas(areas, requested).slice(0, 5).map((a) => `${a.code} (${a.name ?? "?"})`);
2129
+ throw new CliError(
2130
+ `unknown area "${requested}"${suggestions.length ? `; did you mean ${suggestions.join(", ")}?` : ""}`,
2131
+ EXIT.usage,
2132
+ `--area takes a WGSRPD level-3 code: \`planttaxomatcher areas --search ${requested.trim()}\`.`
2133
+ );
2134
+ }
2135
+ function describeVersion(e) {
2136
+ const tags = [e.isDefault ? "default" : null, e.isLatest ? "latest" : null].filter(Boolean).join(", ");
2137
+ const origin = e.kind === "derived" ? `team-edited from ${e.baseVersion ?? "?"}` : "official";
2138
+ const label = e.label ? ` \u201C${e.label}\u201D` : "";
2139
+ return ` ${e.version.padEnd(28)} ${origin}${label}${tags ? ` [${tags}]` : ""} ${e.recordCount.toLocaleString("en")} names, imported ${e.importedAt.slice(0, 10)}`;
2140
+ }
2141
+ function versionLines(entries) {
2142
+ const lines = [];
2143
+ for (const backbone of ["wcvp", "wfo"]) {
2144
+ const ofBackbone = entries.filter((e) => e.backbone === backbone);
2145
+ if (ofBackbone.length === 0) continue;
2146
+ lines.push(backbone.toUpperCase(), ...ofBackbone.map(describeVersion));
2147
+ }
2148
+ return lines;
2149
+ }
2150
+ function areaLine(area) {
2151
+ const place2 = [area.region, area.continent].filter(Boolean).join(", ");
2152
+ return `${area.code.padEnd(4)} ${area.name ?? ""}${place2 ? ` (${place2})` : ""}`;
2153
+ }
2154
+
2155
+ // src/commands/catalog.ts
2156
+ async function fetchVersions(deps2, creds, backbone) {
2157
+ return toVersionEntries(backbone, await deps2.api.listVersions(creds, backbone));
2158
+ }
2159
+ function registerCatalogCommands(program, deps2) {
2160
+ program.command("versions").description(
2161
+ "List the backbone versions a job can match against (--referential), marking the one used by default"
2162
+ ).option("--backbone <name>", "Only this backbone: wcvp or wfo (default: both)").option("--json", "Print the versions as a JSON array", false).action(async (opts) => {
2163
+ const out = createOutput(deps2.stdout, deps2.stderr, opts.json, deps2.now);
2164
+ const backbones = opts.backbone ? [parseBackbone(opts.backbone)] : backboneSchema.options;
2165
+ const creds = await requireCredentials(deps2.store, deps2.env);
2166
+ const entries = (await Promise.all(backbones.map((b) => fetchVersions(deps2, creds, b)))).flat();
2167
+ if (out.json) return out.data(entries);
2168
+ if (entries.length === 0) return out.info(kleur3.gray("No backbone version is installed on this server."));
2169
+ for (const line of versionLines(entries)) out.info(line);
2170
+ });
2171
+ program.command("areas").description("List the WGSRPD level-3 areas (botanical countries) that --area accepts").option("--search <text>", "Only areas whose code, name, region or continent contains this text").option("--json", "Print the areas as a JSON array", false).action(async (opts) => {
2172
+ const out = createOutput(deps2.stdout, deps2.stderr, opts.json, deps2.now);
2173
+ const creds = await requireCredentials(deps2.store, deps2.env);
2174
+ const areas = searchAreas(await deps2.api.listAreas(creds), opts.search);
2175
+ if (out.json) return out.data(areas);
2176
+ if (areas.length === 0) return out.info(kleur3.gray("No matching area."));
2177
+ for (const area of areas) out.info(areaLine(area));
2178
+ });
2179
+ }
2180
+
2181
+ // src/commands/download.ts
2182
+ import { writeFile } from "fs/promises";
2183
+ import kleur4 from "kleur";
2184
+ var FORMATS = ["csv", "xlsx", "json", "ndjson"];
2185
+ var DELIMITERS = ["comma", "semicolon", "tab", "pipe"];
2186
+ var TAXON_LEVELS = taxonLevelSchema.options;
2187
+ function toDownloadFilters(opts) {
2188
+ const parsed = taxonLevelSchema.safeParse(opts.taxonLevel);
2189
+ if (!parsed.success) {
2190
+ throw new CliError(`--taxon-level must be ${TAXON_LEVELS.join(", ")} (got ${opts.taxonLevel})`, EXIT.usage);
2191
+ }
2192
+ const taxonLevel = parsed.data;
2193
+ return {
2194
+ confirmedOnly: opts.confirmedOnly,
2195
+ resolvedOnly: opts.resolvedOnly,
2196
+ dedupe: opts.dedupe,
2197
+ ...taxonLevel !== "all" ? { taxonLevel } : {}
2198
+ };
2199
+ }
2200
+ function toDownloadOptions(opts) {
2201
+ const format = opts.format;
2202
+ if (!FORMATS.includes(format)) {
2203
+ throw new CliError(`--format must be ${FORMATS.join(", ")} (got ${opts.format})`, EXIT.usage);
2204
+ }
2205
+ const delimiter = opts.delimiter;
2206
+ if (!DELIMITERS.includes(delimiter)) {
2207
+ throw new CliError(`--delimiter must be ${DELIMITERS.join(", ")} (got ${opts.delimiter})`, EXIT.usage);
2208
+ }
2209
+ return {
2210
+ format,
2211
+ ...toDownloadFilters(opts),
2212
+ bundle: opts.bundle,
2213
+ ...format === "csv" && delimiter !== "comma" ? { delimiter } : {},
2214
+ ...opts.columns ? { columns: opts.columns } : {},
2215
+ ...opts.wcvpExtra ? { wcvpExtra: opts.wcvpExtra } : {}
2216
+ };
2217
+ }
2218
+ function describeColumns(columns) {
2219
+ return {
2220
+ result: columns.result.map((c) => ({
2221
+ ...c,
2222
+ description: c.description ?? RESULT_COLUMN_DESCRIPTIONS[c.key] ?? null
2223
+ })),
2224
+ original: columns.original,
2225
+ wcvpExtra: columns.wcvpExtra.map((f) => ({
2226
+ ...f,
2227
+ description: f.description ?? WCVP_EXTRA_DESCRIPTIONS[f.key] ?? null
2228
+ }))
2229
+ };
2230
+ }
2231
+ function printColumns(out, columns) {
2232
+ const line = (key, description) => out.info(` ${key}${description ? `
2233
+ ${kleur4.gray(description)}` : ""}`);
2234
+ out.info(kleur4.bold("Result columns (--columns):"));
2235
+ for (const column of columns.result) line(column.key, column.description);
2236
+ if (columns.original.length > 0) {
2237
+ out.info(kleur4.bold("\nYour upload columns (--columns):"));
2238
+ for (const key of columns.original) line(key);
2239
+ }
2240
+ out.info(kleur4.bold("\nWCVP fields to append (--wcvp-extra), exported as wcvp_<field>:"));
2241
+ for (const field of columns.wcvpExtra) line(field.key, field.description);
2242
+ }
2243
+ function printCounts(out, counts) {
2244
+ const rows = [
2245
+ ["all rows", counts.total, ""],
2246
+ ["confirmed only", counts.confirmed, "--confirmed-only"],
2247
+ ["resolved only", counts.resolved, "--resolved-only"],
2248
+ ["one row per accepted taxon", counts.deduped, "--dedupe"],
2249
+ ["with the filters given", counts.selected, ""]
2250
+ ];
2251
+ for (const [label, count, flag] of rows) {
2252
+ out.info(` ${label.padEnd(28)} ${String(count).padStart(8)} ${kleur4.gray(flag)}`);
2253
+ }
2254
+ }
2255
+ function registerDownloadCommand(program, deps2) {
2256
+ 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(
2257
+ "--confirmed-only",
2258
+ "Confirmed rows only: accepted automatically (grade A) or by a reviewer, or overridden",
2259
+ false
2260
+ ).option(
2261
+ "--resolved-only",
2262
+ "Resolved rows only: drop rows with no accepted name (no match, error, rejected)",
2263
+ false
2264
+ ).option(
2265
+ "--dedupe",
2266
+ "Remove duplicate accepted taxa: rows resolving to the same accepted taxon (synonyms, subspecies\u2026) become one line; unmatched rows are kept",
2267
+ false
2268
+ ).option(
2269
+ "--taxon-level <level>",
2270
+ "Keep rows whose accepted taxon is at this level: all, species_below (species and infraspecific, drops genus/family), species_only. Unmatched rows are kept; add --resolved-only to drop them",
2271
+ "all"
2272
+ ).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(
2273
+ "--columns <list>",
2274
+ "Comma-separated result/upload column keys to keep (default: all). See --list-columns"
2275
+ ).option(
2276
+ "--wcvp-extra <list>",
2277
+ "Comma-separated extra WCVP fields appended as wcvp_<key> columns (e.g. ipni_id,powo_id). See --list-columns"
2278
+ ).option("--list-columns", "Print the columns available for this job, with what each holds, and exit", false).option("--counts", "Print how many rows each filter keeps (as the web download dialog shows) and exit", false).option(
2279
+ "--output <path>",
2280
+ "Write to this path (default: the server-provided file name in the current directory)"
2281
+ ).option("--json", "Print the result (or --list-columns / --counts) as JSON", false).action(async (jobId, opts) => {
2282
+ const out = createOutput(deps2.stdout, deps2.stderr, opts.json, deps2.now);
2283
+ const download = toDownloadOptions(opts);
2284
+ const creds = await requireCredentials(deps2.store, deps2.env);
2285
+ if (opts.listColumns) {
2286
+ const columns = describeColumns(await deps2.api.downloadColumns(creds, jobId));
2287
+ if (out.json) return out.data(columns);
2288
+ return printColumns(out, columns);
2289
+ }
2290
+ if (opts.counts) {
2291
+ const counts = await deps2.api.downloadCounts(creds, jobId, toDownloadFilters(opts));
2292
+ if (out.json) return out.data(counts);
2293
+ return printCounts(out, counts);
2294
+ }
2295
+ const { filename, body } = await deps2.api.downloadJob(creds, jobId, download);
2296
+ const path = opts.output ?? filename;
2297
+ await writeFile(path, body);
2298
+ if (out.json) return out.data({ path, bytes: body.length, format: download.format });
2299
+ out.success(`wrote ${body.length} bytes to ${path}`);
2300
+ });
2301
+ }
2302
+
2303
+ // src/commands/jobs.ts
2304
+ import kleur6 from "kleur";
2305
+
2306
+ // src/flags.ts
2307
+ function parseIntegerFlag(value, flag, range = {}) {
2308
+ const parsed = Number(value);
2309
+ const { min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER } = range;
2310
+ if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
2311
+ const bounds = range.min !== void 0 || range.max !== void 0 ? ` between ${min} and ${max}` : "";
2312
+ throw new CliError(`${flag} must be an integer${bounds} (got "${value}")`, EXIT.usage);
2313
+ }
2314
+ return parsed;
2315
+ }
2316
+
2317
+ // src/watch.ts
2318
+ import kleur5 from "kleur";
2319
+ var STOP = /* @__PURE__ */ new Set(["completed", "failed", "cancelled", "paused"]);
2320
+ function isStop(status) {
2321
+ return typeof status === "string" && STOP.has(status);
2322
+ }
2323
+ function stopStatusOf(frame) {
2324
+ if (frame.type === "status" || frame.type === "completed") return isStop(frame.status) ? frame.status : null;
2325
+ if (frame.type === "error") return "failed";
2326
+ return null;
2327
+ }
2328
+ function progressLine(frame) {
2329
+ const processed = Number(frame.processedQueries ?? frame.processedRows ?? 0);
2330
+ const total = Number(frame.totalQueries ?? frame.totalRows ?? 0);
2331
+ const phase = processed === 0 && typeof frame.phase === "string" ? ` (${frame.phase}\u2026)` : "";
2332
+ return ` progress: ${processed}/${total}${phase}`;
2333
+ }
2334
+ function render(out, frame) {
2335
+ if (frame.type === "progress") {
2336
+ out.progress.update(progressLine(frame));
2337
+ return;
2338
+ }
2339
+ if (frame.type === "status") {
2340
+ out.progress.done();
2341
+ out.info(`${kleur5.gray("\u2022")} ${String(frame.status)}`);
2342
+ } else if (frame.type === "error") {
2343
+ out.progress.done();
2344
+ out.info(`${kleur5.red("error:")} ${String(frame.message ?? "job failed")}`);
2345
+ }
2346
+ }
2347
+ async function watchJob(api, out, creds, jobId) {
2348
+ out.progress.done();
2349
+ if (!out.json) out.info(`${kleur5.cyan("\u2192")} Streaming progress for ${jobId} \u2026`);
2350
+ for await (const frame of api.streamJob(creds, jobId)) {
2351
+ if (frame.type === "heartbeat") continue;
2352
+ const status = stopStatusOf(frame);
2353
+ if (out.json) out.data(frame);
2354
+ else if (!(status && frame.type === "status")) render(out, frame);
2355
+ if (status) return finish(out, status);
2356
+ }
2357
+ out.progress.done();
2358
+ const job = await api.getJob(creds, jobId);
2359
+ if (isStop(job.status)) return finish(out, job.status);
2360
+ throw new CliError(
2361
+ `the progress stream closed while job ${jobId} was still ${job.status}`,
2362
+ void 0,
2363
+ `Run \`planttaxomatcher watch ${jobId}\` to follow it again.`
2364
+ );
2365
+ }
2366
+ function finish(out, status) {
2367
+ out.progress.done();
2368
+ if (!out.json) {
2369
+ if (status === "completed") out.success("completed");
2370
+ else if (status === "paused") out.info(kleur5.yellow("paused \u2014 resume it, then watch again"));
2371
+ else out.info(kleur5.red(`\u2717 ${status}`));
2372
+ }
2373
+ return status;
2374
+ }
2375
+
2376
+ // src/commands/jobs.ts
2377
+ function jobUrl(creds, jobId) {
2378
+ return new URL(`/jobs/${encodeURIComponent(jobId)}`, creds.appUrl ?? creds.server).toString();
2379
+ }
2380
+ function watchOutcomeError(results) {
2381
+ const stopped = results.filter((r) => r.status !== "completed");
2382
+ if (stopped.length === 0) return null;
2383
+ const list = stopped.map((r) => `${r.id} (${r.status})`).join(", ");
2384
+ const onlyPaused = stopped.every((r) => r.status === "paused");
2385
+ return new CliError(
2386
+ onlyPaused ? `job paused: ${list}` : `job did not complete: ${list}`,
2387
+ onlyPaused ? EXIT.jobPaused : EXIT.jobFailed,
2388
+ onlyPaused ? "Run `planttaxomatcher resume <jobId>`, then `planttaxomatcher watch <jobId>`." : void 0
2389
+ );
2390
+ }
2391
+ function listLine(job) {
2392
+ const matched = `${String(job.matchedRows).padStart(6)}/${String(job.totalRows).padStart(6)} matched`;
2393
+ return `${job.id} ${job.status.padEnd(10)} ${matched} ${kleur6.gray(job.name ?? "")}`;
2394
+ }
2395
+ var JOB_ACTIONS = {
2396
+ pause: { description: "Pause a running job (the worker stops between match queries)", past: "paused" },
2397
+ resume: { description: "Resume a paused job", past: "resumed" },
2398
+ cancel: { description: "Cancel a job. Rows already matched are kept; pending queries stop.", past: "cancelled" }
2399
+ };
2400
+ function runAction(deps2, action, creds, jobId) {
2401
+ if (action === "pause") return deps2.api.pauseJob(creds, jobId);
2402
+ if (action === "resume") return deps2.api.resumeJob(creds, jobId);
2403
+ return deps2.api.cancelJob(creds, jobId);
2404
+ }
2405
+ function registerJobCommands(program, deps2) {
2406
+ 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) => {
2407
+ const out = createOutput(deps2.stdout, deps2.stderr, !!opts.json, deps2.now);
2408
+ const status = opts.status === void 0 ? void 0 : jobStatusSchema.safeParse(opts.status);
2409
+ if (status && !status.success) {
2410
+ throw new CliError(`--status must be one of ${jobStatusSchema.options.join(", ")}`, EXIT.usage);
2411
+ }
2412
+ const limit = parseIntegerFlag(opts.limit, "--limit", { min: 1, max: 100 });
2413
+ const creds = await requireCredentials(deps2.store, deps2.env);
2414
+ const jobs = await deps2.api.listJobs(creds, { limit, ...status ? { status: status.data } : {} });
2415
+ if (out.json) return out.data(jobs);
2416
+ if (jobs.length === 0) return out.info(kleur6.gray("No jobs."));
2417
+ for (const job of jobs) out.info(listLine(job));
2418
+ });
2419
+ 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) => {
2420
+ const creds = await requireCredentials(deps2.store, deps2.env);
2421
+ const job = await deps2.api.getJob(creds, jobId);
2422
+ deps2.stdout.write(`${JSON.stringify({ ...job, url: jobUrl(creds, job.id) }, null, 2)}
2423
+ `);
2424
+ });
2425
+ 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) => {
2426
+ const out = createOutput(deps2.stdout, deps2.stderr, !!opts.json, deps2.now);
2427
+ const creds = await requireCredentials(deps2.store, deps2.env);
2428
+ const status = await watchJob(deps2.api, out, creds, jobId);
2429
+ const failure = watchOutcomeError([{ id: jobId, status }]);
2430
+ if (failure) throw failure;
2431
+ });
2432
+ for (const [action, { description, past }] of Object.entries(JOB_ACTIONS)) {
2433
+ program.command(`${action} <jobId>`).description(description).option("--json", "Print the updated job as JSON", false).action(async (jobId, opts) => {
2434
+ const out = createOutput(deps2.stdout, deps2.stderr, !!opts.json, deps2.now);
2435
+ const creds = await requireCredentials(deps2.store, deps2.env);
2436
+ const job = await runAction(deps2, action, creds, jobId);
2437
+ if (out.json) return out.data(job);
2438
+ out.success(`${past}: ${job.id} (status=${job.status})`);
2439
+ });
2440
+ }
2441
+ }
2442
+
2443
+ // src/commands/skills.ts
2444
+ import kleur7 from "kleur";
2445
+
2446
+ // src/skills/manage.ts
2447
+ import { promises as fs3 } from "fs";
2448
+ import { dirname as dirname2, resolve } from "path";
2449
+
2450
+ // src/skills/content.ts
2451
+ import { createHash } from "crypto";
2452
+ import { promises as fs2 } from "fs";
2453
+ import { dirname, join as join3, relative } from "path";
2454
+ async function walk(dir, root = dir) {
2455
+ const found = [];
2456
+ for (const entry of await fs2.readdir(dir, { withFileTypes: true })) {
2457
+ const path = join3(dir, entry.name);
2458
+ if (entry.isDirectory()) found.push(...await walk(path, root));
2459
+ else if (entry.isFile() && entry.name !== MARKER_FILE) found.push(relative(root, path));
2460
+ }
2461
+ return found.sort();
2462
+ }
2463
+ function table(rows, keyOf) {
2464
+ const lines = Object.entries(rows).map(([key, description]) => `| \`${keyOf(key)}\` | ${description} |`);
2465
+ return ["| Column | What it holds |", "| --- | --- |", ...lines].join("\n");
2466
+ }
2467
+ function exportColumnsMarkdown() {
2468
+ return [
2469
+ "## Every column",
2470
+ "",
2471
+ table(RESULT_COLUMN_DESCRIPTIONS, (key) => key),
2472
+ "",
2473
+ "## Fields `--wcvp-extra` can append",
2474
+ "",
2475
+ "Columns of the accepted taxon's WCVP record, exported as `wcvp_<field>`, e.g. `--wcvp-extra ipni_id,powo_id`.",
2476
+ "",
2477
+ table(WCVP_EXTRA_DESCRIPTIONS, (key) => key)
2478
+ ].join("\n");
2479
+ }
2480
+ async function renderSkill(sourceDir, version2) {
2481
+ const files = /* @__PURE__ */ new Map();
2482
+ for (const path of await walk(sourceDir)) {
2483
+ const text = await fs2.readFile(join3(sourceDir, path), "utf8");
2484
+ files.set(
2485
+ path,
2486
+ text.replaceAll("{{version}}", version2).replaceAll("{{exportColumns}}", exportColumnsMarkdown())
2487
+ );
2488
+ }
2489
+ return files;
2490
+ }
2491
+ function hashFiles(files) {
2492
+ const hash = createHash("sha256");
2493
+ for (const [path, text] of [...files].sort(([a], [b]) => a.localeCompare(b))) {
2494
+ hash.update(path).update("\0").update(text).update("\0");
2495
+ }
2496
+ return hash.digest("hex");
2497
+ }
2498
+ async function readSkillDir(dir) {
2499
+ const files = /* @__PURE__ */ new Map();
2500
+ for (const path of await walk(dir)) files.set(path, await fs2.readFile(join3(dir, path), "utf8"));
2501
+ return files;
2502
+ }
2503
+ async function readMarker(dir) {
2504
+ try {
2505
+ const parsed = JSON.parse(await fs2.readFile(join3(dir, MARKER_FILE), "utf8"));
2506
+ return typeof parsed.version === "string" && typeof parsed.hash === "string" ? { version: parsed.version, hash: parsed.hash } : null;
2507
+ } catch {
2508
+ return null;
2509
+ }
2510
+ }
2511
+ async function writeSkillDir(dir, files, version2) {
2512
+ const staging = join3(dirname(dirname(dir)), `.planttaxomatcher-staging-${process.pid}-${Date.now()}`);
2513
+ try {
2514
+ for (const [path, text] of files) {
2515
+ await fs2.mkdir(dirname(join3(staging, path)), { recursive: true });
2516
+ await fs2.writeFile(join3(staging, path), text);
2517
+ }
2518
+ const marker = { version: version2, hash: hashFiles(files) };
2519
+ await fs2.writeFile(join3(staging, MARKER_FILE), `${JSON.stringify(marker, null, 2)}
2520
+ `);
2521
+ await fs2.rm(dir, { recursive: true, force: true });
2522
+ await fs2.mkdir(dirname(dir), { recursive: true });
2523
+ await fs2.rename(staging, dir);
2524
+ } finally {
2525
+ await fs2.rm(staging, { recursive: true, force: true });
2526
+ }
2527
+ }
2528
+ async function isPristine(dir) {
2529
+ const marker = await readMarker(dir);
2530
+ return !!marker && hashFiles(await readSkillDir(dir)) === marker.hash;
2531
+ }
2532
+
2533
+ // src/skills/manage.ts
2534
+ var symlink = (target, path) => fs3.symlink(target, path, "junction");
2535
+ function samePath(a, b) {
2536
+ const norm = (p) => resolve(p.replace(/^\\\\\?\\/, ""));
2537
+ return process.platform === "win32" ? norm(a).toLowerCase() === norm(b).toLowerCase() : norm(a) === norm(b);
2538
+ }
2539
+ var OUR_LAYOUT = /[/\\]planttaxomatcher[/\\]skills[/\\]planttaxomatcher$/;
2540
+ async function exists(path) {
2541
+ return fs3.stat(path).then(
2542
+ () => true,
2543
+ () => false
2544
+ );
2545
+ }
2546
+ async function classify(entry, canonical) {
2547
+ const stat = await fs3.lstat(entry).catch(() => null);
2548
+ if (!stat) return { kind: "missing" };
2549
+ if (stat.isSymbolicLink()) {
2550
+ const target = resolve(dirname2(entry), await fs3.readlink(entry));
2551
+ if (samePath(target, canonical)) return await exists(canonical) ? { kind: "link" } : { kind: "broken" };
2552
+ return OUR_LAYOUT.test(target) || await readMarker(target) ? { kind: "stale" } : { kind: "foreign" };
2553
+ }
2554
+ if (!stat.isDirectory()) return { kind: "foreign" };
2555
+ const marker = await readMarker(entry);
2556
+ return marker ? { kind: "copy", version: marker.version, pristine: await isPristine(entry) } : { kind: "foreign" };
2557
+ }
2558
+ async function classifyAll(paths) {
2559
+ const states = {};
2560
+ for (const dir of SKILL_DIRS) states[dir] = await classify(paths.entries[dir], paths.canonical);
2561
+ return states;
2562
+ }
2563
+ var isOurs = (state) => state.kind === "link" || state.kind === "copy" || state.kind === "stale";
2564
+ async function detectAgents(paths) {
2565
+ const found = [];
2566
+ for (const agent of AGENTS) if (await exists(paths.agentHomes[agent])) found.push(agent);
2567
+ return found;
2568
+ }
2569
+ async function writeCanonical(paths, files, version2, force) {
2570
+ const state = await fs3.lstat(paths.canonical).catch(() => null);
2571
+ if (state && !force) {
2572
+ const marker = await readMarker(paths.canonical);
2573
+ if (marker && isNewerVersion(marker.version, version2)) {
2574
+ throw new CliError(
2575
+ `the installed skill (${marker.version}) is newer than this CLI (${version2})`,
2576
+ EXIT.usage,
2577
+ "Run the newer CLI (`npx -y @plantnet/planttaxomatcher@latest skills install`), or pass --force."
2578
+ );
2579
+ }
2580
+ if (!marker || !await isPristine(paths.canonical)) {
2581
+ throw new CliError(
2582
+ `${paths.canonical} was edited or is not ours`,
2583
+ EXIT.usage,
2584
+ "Pass --force to replace it with the skill shipped with this CLI."
2585
+ );
2586
+ }
2587
+ }
2588
+ await writeSkillDir(paths.canonical, files, version2);
2589
+ }
2590
+ async function place(paths, dir, state, files, version2, options) {
2591
+ const entry = paths.entries[dir];
2592
+ if (state.kind === "foreign") return "skipped-foreign";
2593
+ if (state.kind === "link") return "kept";
2594
+ if (state.kind === "copy") {
2595
+ if (!state.pristine && !options.force) return "skipped-edited";
2596
+ await writeSkillDir(entry, files, version2);
2597
+ return "refreshed";
2598
+ }
2599
+ if (state.kind === "broken" || state.kind === "stale") await fs3.unlink(entry);
2600
+ await fs3.mkdir(dirname2(entry), { recursive: true });
2601
+ try {
2602
+ await options.link(paths.canonical, entry);
2603
+ return "linked";
2604
+ } catch {
2605
+ await writeSkillDir(entry, files, version2);
2606
+ return "copied";
2607
+ }
2608
+ }
2609
+ async function installSkill(paths, source, version2, agents, options = {}) {
2610
+ const opts = { force: !!options.force, link: options.link ?? symlink };
2611
+ const files = await renderSkill(source, version2);
2612
+ await writeCanonical(paths, files, version2, opts.force);
2613
+ const states = await classifyAll(paths);
2614
+ const linked = new Set(SKILL_DIRS.filter((dir) => isOurs(states[dir])));
2615
+ const planned = new Set(linkPlan(agents, linked));
2616
+ const entries = [];
2617
+ for (const dir of SKILL_DIRS) {
2618
+ const state = states[dir];
2619
+ if (!planned.has(dir) && !(state.kind === "copy" && state.pristine)) continue;
2620
+ entries.push({ dir, path: paths.entries[dir], action: await place(paths, dir, state, files, version2, opts) });
2621
+ }
2622
+ return { canonical: paths.canonical, entries };
2623
+ }
2624
+ async function uninstallSkill(paths, options = {}) {
2625
+ const entries = [];
2626
+ for (const [dir, state] of Object.entries(await classifyAll(paths))) {
2627
+ const path = paths.entries[dir];
2628
+ if (state.kind === "link" || state.kind === "broken" || state.kind === "stale") {
2629
+ await fs3.unlink(path);
2630
+ entries.push({ dir, path, action: "removed" });
2631
+ } else if (state.kind === "copy") {
2632
+ const removable = state.pristine || options.force;
2633
+ if (removable) await fs3.rm(path, { recursive: true, force: true });
2634
+ entries.push({ dir, path, action: removable ? "removed" : "skipped-edited" });
2635
+ }
2636
+ }
2637
+ const marker = await readMarker(paths.canonical);
2638
+ if (marker && (options.force || await isPristine(paths.canonical))) {
2639
+ await fs3.rm(paths.canonical, { recursive: true, force: true });
2640
+ }
2641
+ return { canonical: paths.canonical, entries };
2642
+ }
2643
+ async function skillStatus(paths) {
2644
+ const states = await classifyAll(paths);
2645
+ const marker = await readMarker(paths.canonical);
2646
+ const installed = new Set(await detectAgents(paths));
2647
+ return {
2648
+ canonical: {
2649
+ path: paths.canonical,
2650
+ version: marker?.version ?? null,
2651
+ pristine: marker ? await isPristine(paths.canonical) : false
2652
+ },
2653
+ entries: SKILL_DIRS.map((dir) => ({ dir, path: paths.entries[dir], ...states[dir] })),
2654
+ agents: AGENTS.map((agent) => {
2655
+ const loads = READS[agent].find((dir) => states[dir].kind !== "missing") ?? null;
2656
+ return { agent, installed: installed.has(agent), loads, state: loads ? states[loads].kind : "missing" };
2657
+ })
2658
+ };
2659
+ }
2660
+ async function refreshSkillIfOutdated(paths, source, version2) {
2661
+ try {
2662
+ const marker = await readMarker(paths.canonical);
2663
+ if (!marker || !isNewerVersion(version2, marker.version) || !await isPristine(paths.canonical)) return false;
2664
+ const files = await renderSkill(source, version2);
2665
+ await writeSkillDir(paths.canonical, files, version2);
2666
+ for (const [dir, state] of Object.entries(await classifyAll(paths))) {
2667
+ if (state.kind === "copy" && state.pristine) await writeSkillDir(paths.entries[dir], files, version2);
2668
+ }
2669
+ return true;
2670
+ } catch {
2671
+ return false;
2672
+ }
2673
+ }
2674
+
2675
+ // src/commands/skills.ts
2676
+ var AGENT_NAMES = { claude: "Claude Code", codex: "Codex", opencode: "OpenCode", pi: "pi" };
2677
+ var SERVES = {
2678
+ claude: "Claude Code (and OpenCode)",
2679
+ agents: "Codex, pi (and OpenCode)",
2680
+ opencode: "OpenCode",
2681
+ pi: "pi"
2682
+ };
2683
+ var ACTION_TEXT = {
2684
+ linked: "linked",
2685
+ copied: "copied (links are not available here)",
2686
+ kept: "already linked",
2687
+ refreshed: "copy refreshed",
2688
+ removed: "removed",
2689
+ "skipped-foreign": "left alone: a skill of the same name that is not ours",
2690
+ "skipped-edited": "left alone: edited by hand (use --force)"
2691
+ };
2692
+ var RELOAD_HINTS = {
2693
+ opencode: "Restart OpenCode to load it.",
2694
+ pi: "In pi, run /reload."
2695
+ };
2696
+ async function chooseAgents(deps2, requested, detected) {
2697
+ if (requested !== void 0) {
2698
+ const parsed = parseAgents(requested);
2699
+ if (!parsed) throw new CliError(`--agent takes ${AGENTS.join(", ")} or all (got "${requested}")`, EXIT.usage);
2700
+ return parsed === "all" ? [...AGENTS] : parsed;
2701
+ }
2702
+ if (deps2.stdinIsTTY) return deps2.promptAgents(detected);
2703
+ if (detected.length === 0) {
2704
+ throw new CliError(
2705
+ "no supported agent found in your home directory",
2706
+ EXIT.usage,
2707
+ `Pass --agent with any of ${AGENTS.join(", ")}, or all.`
2708
+ );
2709
+ }
2710
+ return detected;
2711
+ }
2712
+ function tilde(path, home) {
2713
+ return path === home || path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
2714
+ }
2715
+ function printEntries(out, result, home) {
2716
+ for (const { dir, path, action } of result.entries) {
2717
+ const line = `${SERVES[dir]}: ${ACTION_TEXT[action]} ${kleur7.gray(tilde(path, home))}`;
2718
+ if (action.startsWith("skipped")) out.warn(line);
2719
+ else out.success(line);
1324
2720
  }
1325
- return creds;
2721
+ }
2722
+ function registerSkillsCommands(program, deps2, version2) {
2723
+ const skills = program.command("skills").description("Add the PlantTaxoMatcher skill to your AI coding agents (Claude Code, Codex, OpenCode, pi)");
2724
+ const paths = () => skillPaths(deps2.home, deps2.env);
2725
+ skills.command("install").description(
2726
+ "Install the skill for your user. The files live in one CLI-owned folder; each agent only gets a link to it."
2727
+ ).option(
2728
+ "--agent <list>",
2729
+ `Comma-separated: ${AGENTS.join(", ")}, or all (default: the agents found, or a prompt)`
2730
+ ).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) => {
2731
+ const out = createOutput(deps2.stdout, deps2.stderr, opts.json, deps2.now);
2732
+ const agents = await chooseAgents(deps2, opts.agent, await detectAgents(paths()));
2733
+ if (agents.length === 0) throw new CliError("no agent selected", EXIT.usage);
2734
+ const result = await installSkill(paths(), deps2.skillSource, version2, agents, { force: opts.force });
2735
+ if (out.json) return out.data({ version: version2, agents, ...result });
2736
+ out.info(
2737
+ `${kleur7.bold("planttaxomatcher")} skill ${version2} \u2192 ${kleur7.gray(tilde(result.canonical, deps2.home))}`
2738
+ );
2739
+ printEntries(out, result, deps2.home);
2740
+ for (const agent of agents) if (RELOAD_HINTS[agent]) out.info(kleur7.gray(RELOAD_HINTS[agent]));
2741
+ out.info(kleur7.gray(`Try it: ask your agent to "match the plant names in my CSV with PlantTaxoMatcher".`));
2742
+ });
2743
+ 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) => {
2744
+ const out = createOutput(deps2.stdout, deps2.stderr, opts.json, deps2.now);
2745
+ const result = await uninstallSkill(paths(), { force: opts.force });
2746
+ if (out.json) return out.data(result);
2747
+ if (result.entries.length === 0) out.info(kleur7.gray("No installed skill links found."));
2748
+ printEntries(out, result, deps2.home);
2749
+ });
2750
+ 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) => {
2751
+ const out = createOutput(deps2.stdout, deps2.stderr, opts.json, deps2.now);
2752
+ const status = await skillStatus(paths());
2753
+ if (out.json) return out.data({ cliVersion: version2, ...status });
2754
+ const { canonical } = status;
2755
+ out.info(
2756
+ canonical.version ? `Skill ${canonical.version}${canonical.pristine ? "" : " (edited)"} at ${kleur7.gray(tilde(canonical.path, deps2.home))}` : kleur7.gray("Not installed. Run `planttaxomatcher skills install`.")
2757
+ );
2758
+ for (const entry of status.entries) {
2759
+ if (entry.kind !== "missing")
2760
+ out.info(` ${entry.kind.padEnd(8)} ${kleur7.gray(tilde(entry.path, deps2.home))}`);
2761
+ }
2762
+ for (const { agent, installed, loads, state } of status.agents) {
2763
+ const entry = status.entries.find((e) => e.dir === loads);
2764
+ const where = entry ? `${state} in ${tilde(entry.path, deps2.home)}` : "no skill";
2765
+ out.info(` ${AGENT_NAMES[agent].padEnd(12)} ${installed ? where : kleur7.gray(`not found (${where})`)}`);
2766
+ }
2767
+ });
1326
2768
  }
1327
2769
 
2770
+ // src/commands/submit.ts
2771
+ import { parse as parsePath } from "path";
2772
+ import kleur9 from "kleur";
2773
+
1328
2774
  // src/dry-run.ts
1329
- import { promises as fs3, createReadStream } from "fs";
2775
+ import { promises as fs4, createReadStream } from "fs";
1330
2776
  import Papa from "papaparse";
1331
- import kleur from "kleur";
2777
+ import kleur8 from "kleur";
1332
2778
  async function readSample(file, sampleLimit) {
1333
2779
  const lower = file.toLowerCase();
1334
2780
  if (lower.endsWith(".json")) {
1335
- const text = await fs3.readFile(file, "utf8");
2781
+ const text = await fs4.readFile(file, "utf8");
1336
2782
  const arr = JSON.parse(text);
1337
2783
  if (!Array.isArray(arr)) throw new Error("JSON file must be an array of row objects");
1338
2784
  const out = [];
@@ -1346,13 +2792,13 @@ async function readSample(file, sampleLimit) {
1346
2792
  }
1347
2793
  return out;
1348
2794
  }
1349
- return await new Promise((resolve, reject) => {
2795
+ return await new Promise((resolve2, reject) => {
1350
2796
  const out = [];
1351
2797
  let done = false;
1352
- const finish = () => {
2798
+ const finish2 = () => {
1353
2799
  if (done) return;
1354
2800
  done = true;
1355
- resolve(out);
2801
+ resolve2(out);
1356
2802
  };
1357
2803
  const stream = createReadStream(file);
1358
2804
  const parseStream = Papa.parse(Papa.NODE_STREAM_INPUT, {
@@ -1370,10 +2816,10 @@ async function readSample(file, sampleLimit) {
1370
2816
  out.push(o);
1371
2817
  if (out.length >= sampleLimit) {
1372
2818
  stream.destroy();
1373
- finish();
2819
+ finish2();
1374
2820
  }
1375
2821
  });
1376
- parseStream.on("end", finish);
2822
+ parseStream.on("end", finish2);
1377
2823
  stream.pipe(parseStream);
1378
2824
  });
1379
2825
  }
@@ -1391,7 +2837,7 @@ async function buildDryRunReport(file, opts) {
1391
2837
  };
1392
2838
  }
1393
2839
  if (!(opts.nameColumn in rows[0])) {
1394
- throw new Error(`name column "${opts.nameColumn}" not present in file header`);
2840
+ throw new CliError(`name column "${opts.nameColumn}" not present in file header`, EXIT.usage);
1395
2841
  }
1396
2842
  let nullNameCount = 0;
1397
2843
  let qualifierFlagCount = 0;
@@ -1431,352 +2877,255 @@ async function buildDryRunReport(file, opts) {
1431
2877
  idTypeDetection
1432
2878
  };
1433
2879
  }
1434
- function printDryRunReport(report) {
1435
- console.log();
1436
- console.log(kleur.bold("Dry-run preview"));
1437
- console.log(
1438
- ` Read ${kleur.cyan(report.rowsRead)} rows \xB7 ${kleur.yellow(report.nullNameCount)} with no usable name \xB7 ${kleur.yellow(report.qualifierFlagCount)} with qualifier flags`
2880
+ function printDryRunReport(report, log) {
2881
+ log("");
2882
+ log(kleur8.bold("Dry-run preview"));
2883
+ log(
2884
+ ` Read ${kleur8.cyan(report.rowsRead)} rows \xB7 ${kleur8.yellow(report.nullNameCount)} with no usable name \xB7 ${kleur8.yellow(report.qualifierFlagCount)} with qualifier flags`
1439
2885
  );
1440
2886
  if (report.idTypeDetection) {
1441
2887
  const d = report.idTypeDetection;
1442
2888
  const pct = Math.round(d.dominantConfidence * 100);
1443
- console.log(
1444
- ` ID-column detection: ${kleur.green(d.dominant ?? "\u2014")} (${pct}% of ${d.sampleSize} sampled)`
1445
- );
2889
+ log(` ID-column detection: ${kleur8.green(d.dominant ?? "\u2014")} (${pct}% of ${d.sampleSize} sampled)`);
1446
2890
  if (d.minorityExamples.length > 0) {
1447
- console.log(` Minority examples:`);
2891
+ log(` Minority examples:`);
1448
2892
  for (const m of d.minorityExamples) {
1449
- console.log(
1450
- ` row ${m.rowIndex + 1}: ${kleur.gray(m.value)} \u2192 ${kleur.dim(m.type)}`
1451
- );
2893
+ log(` row ${m.rowIndex + 1}: ${kleur8.gray(m.value)} \u2192 ${kleur8.dim(m.type)}`);
1452
2894
  }
1453
2895
  }
1454
2896
  }
1455
- console.log();
1456
- console.log(kleur.bold("First rows after normalization:"));
1457
- console.log(
1458
- ` ${"#".padStart(4)} ${"input".padEnd(36)} ${"normalized".padEnd(36)} flags`
1459
- );
2897
+ log("");
2898
+ log(kleur8.bold("First rows after normalization:"));
2899
+ log(` ${"#".padStart(4)} ${"input".padEnd(36)} ${"normalized".padEnd(36)} flags`);
1460
2900
  for (const r of report.sampleRows) {
1461
2901
  const idx = String(r.rowIndex + 1).padStart(4);
1462
2902
  const inp = trunc(r.input ?? "\u2205", 36).padEnd(36);
1463
2903
  const norm = trunc(r.normalized ?? "\u2205", 36).padEnd(36);
1464
- console.log(` ${idx} ${inp} ${norm} ${r.flags.join(" ") || ""}`);
2904
+ log(` ${idx} ${inp} ${norm} ${r.flags.join(" ") || ""}`);
1465
2905
  }
1466
- console.log();
2906
+ log("");
1467
2907
  }
1468
2908
  function trunc(s, n) {
1469
2909
  if (s.length <= n) return s;
1470
2910
  return s.slice(0, n - 1) + "\u2026";
1471
2911
  }
1472
2912
 
1473
- // src/index.ts
1474
- var { version } = createRequire(import.meta.url)("../package.json");
1475
- var program = new Command();
1476
- program.name("planttaxomatcher").description("PlantTaxoMatcher CLI").version(version);
1477
- program.command("login").description("Save a personal token + server URL").option(
1478
- "--token <token>",
1479
- "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."
1480
- ).option(
1481
- "--token-stdin",
1482
- "Read the token from stdin instead of argv (e.g. `cat token.txt | planttaxomatcher login --token-stdin`).",
1483
- false
1484
- ).option("--server <url>", "API server base URL", "http://localhost:4000").option(
1485
- "--insecure",
1486
- "Allow sending the token over cleartext http to a non-loopback server (NOT recommended).",
1487
- false
1488
- ).action(async (opts) => {
1489
- assertServerTransport(opts.server, !!opts.insecure);
1490
- const token = await resolveLoginToken(opts);
1491
- if (!tokenStringSchema.safeParse(token).success) {
1492
- throw new Error("malformed token \u2014 expected ptm_<12 chars>_<32 chars>");
1493
- }
1494
- await writeCredentials({ token, server: opts.server });
1495
- console.log(kleur2.green("\u2713"), `Logged in to ${opts.server}`);
1496
- });
1497
- program.command("logout").description("Clear saved credentials").action(async () => {
1498
- await clearCredentials();
1499
- console.log(kleur2.green("\u2713"), "Logged out");
1500
- });
1501
- program.command("whoami").description("Show current user").action(async () => {
1502
- const creds = await requireCredentials();
1503
- const me = await apiClient.me(creds);
1504
- console.log(`${me.displayName} team=${me.teamId} scopes=${me.scopes.join(",")}`);
1505
- console.log(`server=${creds.server}`);
1506
- });
1507
- program.command("list").description("List recent jobs").action(async () => {
1508
- const creds = await requireCredentials();
1509
- const jobs = await apiClient.listJobs(creds);
1510
- if (jobs.length === 0) {
1511
- console.log(kleur2.gray("No jobs."));
1512
- return;
1513
- }
1514
- for (const j of jobs) {
1515
- console.log(
1516
- `${j.id} ${j.status.padEnd(10)} ${String(j.matchedRows).padStart(6)}/${String(j.totalRows).padStart(6)} matched`
1517
- );
1518
- }
1519
- });
1520
- program.command("status <jobId>").description("Show job status snapshot").action(async (jobId) => {
1521
- const creds = await requireCredentials();
1522
- const job = await apiClient.getJob(creds, jobId);
1523
- console.log(JSON.stringify(job, null, 2));
1524
- });
1525
- program.command("submit <files...>").description(
1526
- '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.'
1527
- ).option(
1528
- "--name <label>",
1529
- "Job name override (single file only; ignored when multiple files match \u2014 the file name is used)"
1530
- ).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(
1531
- "--species-level",
1532
- "Roll infraspecific accepted taxa (varieties, subspecies, forms) up to their species, so every output row sits at species level",
1533
- false
1534
- ).option(
1535
- "--ignore-author",
1536
- "Accept a unique canonical name match whose author couldn't be confirmed instead of sending it to review (an author that CONFLICTS with the matched taxon still reviews)",
1537
- false
1538
- ).option("--keep-infraspecific", "Deprecated \u2014 now the default; has no effect", false).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(
1539
- "--referential <version>",
1540
- "WCVP snapshot version to match against (default: your team's configured default snapshot, else the newest import)"
1541
- ).option("--no-watch", "Do not stream progress after submit").option("--dry-run", "Preview locally (normalize first rows + detect ID type) and confirm before uploading", false).option("--dry-run-rows <n>", "Number of rows to show in the dry-run preview table (default 10)", "10").action(async (files, opts) => {
1542
- const creds = await requireCredentials();
1543
- const inputs = await expandInputs(files);
1544
- if (inputs.length === 0) throw new Error("no input files");
1545
- if (opts.name && inputs.length > 1) {
1546
- console.log(kleur2.yellow("!"), "--name ignored for multi-file submit; using each file name as the job name");
1547
- }
1548
- if (opts.keepInfraspecific) {
1549
- console.log(
1550
- kleur2.yellow("!"),
1551
- "--keep-infraspecific is deprecated and does nothing \u2014 infraspecific taxa are kept by default. Pass --species-level to roll them up to the species."
1552
- );
1553
- }
1554
- if (inputs.length > 1) {
1555
- console.log(kleur2.cyan("\u2192"), `${inputs.length} files matched:`);
1556
- for (const f of inputs) console.log(kleur2.gray(` ${f}`));
1557
- }
1558
- if (opts.dryRun) {
1559
- const idColumn = opts.idColumn ? String(opts.idColumn) : null;
1560
- const previewRows = Number(opts.dryRunRows ?? 10);
1561
- for (const file of inputs) {
1562
- if (inputs.length > 1) console.log(kleur2.bold(`
1563
- ${file}`));
1564
- const report = await buildDryRunReport(file, {
1565
- nameColumn: String(opts.nameColumn),
1566
- idColumn,
1567
- previewRows: Number.isFinite(previewRows) ? previewRows : 10,
1568
- sampleLimit: 1e3
2913
+ // src/inputs.ts
2914
+ import { promises as fs5 } from "fs";
2915
+ var GLOB_MAGIC = /[*?[\]{}!()]/;
2916
+ async function expandInputs(patterns) {
2917
+ const files = /* @__PURE__ */ new Set();
2918
+ for (const pattern of patterns) {
2919
+ if (GLOB_MAGIC.test(pattern)) {
2920
+ let matched = false;
2921
+ for await (const file of fs5.glob(pattern)) {
2922
+ files.add(file);
2923
+ matched = true;
2924
+ }
2925
+ if (!matched) throw new CliError(`no files matched: ${pattern}`, EXIT.usage);
2926
+ } else {
2927
+ await fs5.access(pattern).catch(() => {
2928
+ throw new CliError(`file not found: ${pattern}`, EXIT.usage);
1569
2929
  });
1570
- printDryRunReport(report);
1571
- }
1572
- const proceed = await confirm({
1573
- message: inputs.length > 1 ? `Proceed with upload of ${inputs.length} files?` : "Proceed with upload?",
1574
- default: true
1575
- });
1576
- if (!proceed) {
1577
- console.log(kleur2.gray("Aborted."));
1578
- return;
2930
+ files.add(pattern);
1579
2931
  }
1580
2932
  }
2933
+ return [...files].sort();
2934
+ }
2935
+
2936
+ // src/job-config.ts
2937
+ import "zod";
2938
+ function buildJobConfig(opts) {
1581
2939
  const config = {
1582
- nameColumn: String(opts.nameColumn),
1583
- idColumn: opts.idColumn ? String(opts.idColumn) : null,
1584
- familyColumn: opts.familyColumn ? String(opts.familyColumn) : null,
1585
- genusColumn: opts.genusColumn ? String(opts.genusColumn) : null,
1586
- rankColumn: opts.rankColumn ? String(opts.rankColumn) : null,
1587
- authorColumn: opts.authorColumn ? String(opts.authorColumn) : null,
1588
- filterColumn: opts.filterColumn ? String(opts.filterColumn) : null,
1589
- filterValue: opts.filterValue != null ? String(opts.filterValue) : null,
1590
- authorMode: String(opts.authorMode ?? "prefer"),
2940
+ nameColumn: opts.nameColumn,
2941
+ idColumn: opts.idColumn ?? null,
2942
+ idType: opts.idType,
2943
+ familyColumn: opts.familyColumn ?? null,
2944
+ genusColumn: opts.genusColumn ?? null,
2945
+ rankColumn: opts.rankColumn ?? null,
2946
+ authorColumn: opts.authorColumn ?? null,
2947
+ filterColumn: opts.filterColumn ?? null,
2948
+ filterValue: opts.filterValue ?? null,
2949
+ authorMode: opts.authorMode,
1591
2950
  matchAuthors: true,
1592
- parallelism: Number(opts.parallel ?? 4),
2951
+ parallelism: parseIntegerFlag(opts.parallel, "--parallel"),
1593
2952
  allowFuzzy: true,
1594
- allowLlm: !!opts.allowLlm,
1595
- llmCostCapCents: Number(opts.llmCapCents ?? 500),
1596
- reviewMode: String(opts.reviewMode ?? "recommended"),
1597
- // Opt-in: by default an infraspecific accepted taxon is kept at the
1598
- // rank it resolved to, not rolled up to its species.
1599
- speciesLevelAcceptedOnly: !!opts.speciesLevel,
1600
- // Opt-in: an unconfirmed author still routes the match to review
1601
- // unless the caller declares the author unimportant.
1602
- acceptUnconfirmedAuthor: !!opts.ignoreAuthor,
2953
+ allowLlm: opts.allowLlm,
2954
+ llmCostCapCents: parseIntegerFlag(opts.llmCapCents, "--llm-cap-cents"),
2955
+ reviewMode: opts.reviewMode,
2956
+ speciesLevelAcceptedOnly: opts.speciesLevel,
2957
+ acceptUnconfirmedAuthor: opts.ignoreAuthor,
1603
2958
  exportConfirmedOnly: false,
1604
- forceReviewFamilies: [],
1605
- ...opts.referential ? { referentialVersion: String(opts.referential) } : {}
2959
+ referential: parseBackbone(opts.backbone),
2960
+ ...opts.referential ? { referentialVersion: opts.referential } : {},
2961
+ ...opts.area?.trim() ? { area: opts.area.trim() } : {}
1606
2962
  };
1607
- const submitted = [];
1608
- for (const file of inputs) {
1609
- const jobName = inputs.length === 1 && opts.name ? String(opts.name) : parsePath(file).name;
1610
- console.log(kleur2.cyan("\u2192"), `Uploading ${file} \u2026`);
1611
- const job = await apiClient.submitJob(creds, file, config, jobName || null);
1612
- console.log(kleur2.green("\u2713"), `Job created: ${job.id} ${kleur2.gray(jobName)}`);
1613
- console.log(` rows=${job.totalRows} uniqueQueries=${job.uniqueQueries}`);
1614
- submitted.push({ id: job.id, file });
1615
- }
1616
- if (opts.watch === false) return;
1617
- for (const s of submitted) {
1618
- if (submitted.length > 1) console.log(kleur2.bold(`
1619
- [${s.file}] ${s.id}`));
1620
- await streamJob(creds, s.id);
1621
- }
1622
- });
1623
- program.command("watch <jobId>").description("Stream NDJSON progress for a job").action(async (jobId) => {
1624
- const creds = await requireCredentials();
1625
- await streamJob(creds, jobId);
1626
- });
1627
- program.command("pause <jobId>").description("Pause a running job (worker stops between match queries)").action(async (jobId) => {
1628
- const creds = await requireCredentials();
1629
- const job = await apiClient.pauseJob(creds, jobId);
1630
- console.log(kleur2.green("\u2713"), `paused: ${job.id} (status=${job.status})`);
1631
- });
1632
- program.command("resume <jobId>").description("Resume a paused job (re-enqueues the match stage)").action(async (jobId) => {
1633
- const creds = await requireCredentials();
1634
- const job = await apiClient.resumeJob(creds, jobId);
1635
- console.log(kleur2.green("\u2713"), `resumed: ${job.id} (status=${job.status})`);
1636
- });
1637
- program.command("cancel <jobId>").description("Cancel a job. Already-matched rows are kept; pending queries stop.").action(async (jobId) => {
1638
- const creds = await requireCredentials();
1639
- const job = await apiClient.cancelJob(creds, jobId);
1640
- console.log(kleur2.green("\u2713"), `cancelled: ${job.id} (status=${job.status})`);
1641
- });
1642
- 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("--dedupe", "collapse rows that resolved to the same accepted taxon to one line", 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("--columns <list>", "comma-separated result/upload column keys to KEEP (default: all). See --list-columns").option(
1643
- "--wcvp-extra <list>",
1644
- "comma-separated extra WCVP fields to append as wcvp_<key> columns (e.g. ipni_id,powo_id). See --list-columns"
1645
- ).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) => {
1646
- const creds = await requireCredentials();
1647
- if (opts.listColumns) {
1648
- const cat = await apiClient.downloadColumns(creds, jobId);
1649
- console.log(kleur2.bold("Result columns (--columns):"));
1650
- for (const r of cat.result) console.log(` ${r.key} ${kleur2.gray(`(${r.group})`)}`);
1651
- if (cat.original.length > 0) {
1652
- console.log(kleur2.bold("\nYour upload columns (--columns):"));
1653
- for (const k of cat.original) console.log(` ${k}`);
1654
- }
1655
- console.log(kleur2.bold("\nWCVP extra fields (--wcvp-extra):"));
1656
- for (const f of cat.wcvpExtra) console.log(` ${f.key} ${kleur2.gray(`(${f.group})`)}`);
1657
- return;
1658
- }
1659
- const format = opts.format ?? "csv";
1660
- if (format !== "csv" && format !== "json" && format !== "ndjson" && format !== "xlsx") {
1661
- throw new Error(`--format must be csv, xlsx, json or ndjson (got ${String(format)})`);
1662
- }
1663
- const confirmedOnly = !!opts.confirmedOnly;
1664
- const dedupe = !!opts.dedupe;
1665
- const bundle = !!opts.bundle;
1666
- const delimiter = String(opts.delimiter ?? "comma");
1667
- if (!["comma", "semicolon", "tab", "pipe"].includes(delimiter)) {
1668
- throw new Error(`--delimiter must be comma, semicolon, tab or pipe (got ${delimiter})`);
1669
- }
1670
- const { filename, body } = await apiClient.downloadJob(creds, jobId, {
1671
- format,
1672
- confirmedOnly,
1673
- dedupe,
1674
- bundle,
1675
- ...format === "csv" && delimiter !== "comma" ? { delimiter } : {},
1676
- ...opts.columns ? { columns: String(opts.columns) } : {},
1677
- ...opts.wcvpExtra ? { wcvpExtra: String(opts.wcvpExtra) } : {}
1678
- });
1679
- const outPath = opts.output ?? filename;
1680
- const { writeFile } = await import("fs/promises");
1681
- await writeFile(outPath, body);
1682
- console.log(kleur2.green("\u2713"), `wrote ${body.length} bytes to ${outPath}`);
1683
- });
1684
- program.parseAsync(process.argv).catch((err) => {
1685
- const msg = err instanceof Error ? err.message : String(err);
1686
- console.error(kleur2.red("error:"), msg);
1687
- process.exit(1);
1688
- });
1689
- function assertServerTransport(server, insecure) {
1690
- let u;
1691
- try {
1692
- u = new URL(server);
1693
- } catch {
1694
- throw new Error(`invalid --server URL: ${server}`);
2963
+ const checked = jobConfigSchema.safeParse(config);
2964
+ if (!checked.success) {
2965
+ const issue = checked.error.issues[0];
2966
+ const field = issue?.path.join(".") ?? "config";
2967
+ throw new CliError(`invalid ${field}: ${issue?.message ?? "rejected"}`, EXIT.usage);
1695
2968
  }
1696
- if (u.protocol === "https:") return;
1697
- if (u.protocol !== "http:") {
1698
- throw new Error(`--server must use http or https (got ${u.protocol})`);
1699
- }
1700
- const host = u.hostname;
1701
- const isLoopback = host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]" || host.endsWith(".localhost");
1702
- if (!isLoopback && !insecure) {
1703
- throw new Error(
1704
- `refusing to send a token in cleartext to ${u.host}. Use an https URL, or pass --insecure to override (NOT recommended).`
1705
- );
2969
+ return config;
2970
+ }
2971
+
2972
+ // src/commands/submit.ts
2973
+ function summarize(submitted, finals, creds) {
2974
+ return submitted.map(({ file, job }) => ({
2975
+ id: job.id,
2976
+ name: job.name,
2977
+ file,
2978
+ status: finals.find((f) => f.id === job.id)?.status ?? job.status,
2979
+ totalRows: job.totalRows,
2980
+ uniqueQueries: job.uniqueQueries,
2981
+ url: jobUrl(creds, job.id)
2982
+ }));
2983
+ }
2984
+ async function checkAgainstServer(deps2, creds, config) {
2985
+ const backbone = config.referential ?? "wcvp";
2986
+ if (config.referentialVersion) {
2987
+ requireVersion(await fetchVersions(deps2, creds, backbone), backbone, config.referentialVersion);
1706
2988
  }
2989
+ if (config.area) config.area = requireArea(await deps2.api.listAreas(creds), config.area).code;
1707
2990
  }
1708
- async function readStdinLine() {
1709
- const chunks = [];
1710
- for await (const chunk of process.stdin) chunks.push(chunk);
1711
- return Buffer.concat(chunks).toString("utf8").trim();
2991
+ function jobNameFor(file, fileCount, name) {
2992
+ return fileCount === 1 && name?.trim() ? name.trim() : parsePath(file).name;
1712
2993
  }
1713
- async function resolveLoginToken(opts) {
1714
- if (opts.tokenStdin) {
1715
- const t = await readStdinLine();
1716
- if (!t) throw new Error("--token-stdin was set but stdin was empty");
1717
- return t;
2994
+ async function confirmDryRun(deps2, out, files, opts) {
2995
+ const previewRows = Number(opts.dryRunRows);
2996
+ for (const file of files) {
2997
+ if (files.length > 1) out.info(kleur9.bold(`
2998
+ ${file}`));
2999
+ const report = await buildDryRunReport(file, {
3000
+ nameColumn: opts.nameColumn,
3001
+ idColumn: opts.idColumn ?? null,
3002
+ previewRows: Number.isFinite(previewRows) ? previewRows : 10,
3003
+ sampleLimit: 1e3
3004
+ });
3005
+ printDryRunReport(report, out.info);
1718
3006
  }
1719
- if (opts.token) return opts.token.trim();
1720
- const env = process.env.PLANTTAXOMATCHER_TOKEN;
1721
- if (env && env.trim()) return env.trim();
1722
- if (!process.stdin.isTTY) {
1723
- throw new Error("no token provided. Pass --token-stdin, set PLANTTAXOMATCHER_TOKEN, or run interactively.");
3007
+ if (opts.yes) return true;
3008
+ if (!deps2.stdinIsTTY) {
3009
+ throw new CliError("--dry-run needs a confirmation", EXIT.usage, "Pass --yes to upload after the preview.");
1724
3010
  }
1725
- const entered = await password({ message: "Personal token (ptm_...)", mask: true });
1726
- return entered.trim();
3011
+ const message = files.length > 1 ? `Proceed with upload of ${files.length} files?` : "Proceed with upload?";
3012
+ return deps2.promptConfirm(message);
1727
3013
  }
1728
- var GLOB_MAGIC = /[*?[\]{}!()]/;
1729
- async function expandInputs(patterns) {
1730
- const out = /* @__PURE__ */ new Set();
1731
- for (const p of patterns) {
1732
- if (GLOB_MAGIC.test(p)) {
1733
- let matched = false;
1734
- for await (const m of fs4.glob(p)) {
1735
- out.add(m);
1736
- matched = true;
1737
- }
1738
- if (!matched) throw new Error(`no files matched: ${p}`);
1739
- } else {
1740
- await fs4.access(p).catch(() => {
1741
- throw new Error(`file not found: ${p}`);
1742
- });
1743
- out.add(p);
3014
+ function registerSubmitCommand(program, deps2) {
3015
+ program.command("submit <files...>").description(
3016
+ '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.'
3017
+ ).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(
3018
+ "--filter-column <name>",
3019
+ "Only process rows where this column matches --filter-value; others are skipped"
3020
+ ).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(
3021
+ "--id-type <type>",
3022
+ "What --id-column holds: auto (backbone id, else GBIF key for bare integers), wcvp, gbif",
3023
+ "auto"
3024
+ ).option(
3025
+ "--species-level",
3026
+ "Roll infraspecific accepted taxa (varieties, subspecies, forms) up to their species",
3027
+ false
3028
+ ).option(
3029
+ "--ignore-author",
3030
+ "Accept a unique canonical name match whose author couldn't be confirmed instead of sending it to review (a CONFLICTING author still reviews)",
3031
+ false
3032
+ ).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("--backbone <name>", "Backbone to match against: wcvp or wfo", "wcvp").option(
3033
+ "--referential <version>",
3034
+ "Backbone version to match against (default: the one `planttaxomatcher versions` marks as default)"
3035
+ ).option(
3036
+ "--area <code>",
3037
+ "WGSRPD level-3 area (botanical country, e.g. FRA): an ambiguous match is narrowed to the taxa native there. See `planttaxomatcher areas`"
3038
+ ).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) => {
3039
+ const out = createOutput(deps2.stdout, deps2.stderr, opts.json, deps2.now);
3040
+ const config = buildJobConfig(opts);
3041
+ const creds = await requireCredentials(deps2.store, deps2.env);
3042
+ await checkAgainstServer(deps2, creds, config);
3043
+ const files = await expandInputs(patterns);
3044
+ if (opts.name && files.length > 1)
3045
+ out.warn("--name ignored for a multi-file submit; each job is named after its file");
3046
+ if (opts.keepInfraspecific) {
3047
+ out.warn(
3048
+ "--keep-infraspecific is deprecated and does nothing \u2014 pass --species-level to roll up to the species"
3049
+ );
1744
3050
  }
1745
- }
1746
- return [...out].sort();
1747
- }
1748
- async function streamJob(creds, jobId) {
1749
- console.log(kleur2.cyan("\u2192"), `Streaming progress for ${jobId} \u2026`);
1750
- let lastLineWidth = 0;
1751
- for await (const evt of apiClient.streamJob(creds, jobId)) {
1752
- const t = String(evt.type ?? "");
1753
- if (t === "heartbeat") continue;
1754
- if (t === "status") {
1755
- const status = String(evt.status ?? "");
1756
- console.log(kleur2.gray("\u2022"), status);
1757
- if (status === "completed") {
1758
- console.log(kleur2.green("\u2713"), "completed");
1759
- return;
3051
+ if (files.length > 1) {
3052
+ out.info(`${kleur9.cyan("\u2192")} ${files.length} files matched:`);
3053
+ for (const file of files) out.info(kleur9.gray(` ${file}`));
3054
+ }
3055
+ if (opts.dryRun && !await confirmDryRun(deps2, out, files, opts)) {
3056
+ out.info(kleur9.gray("Aborted."));
3057
+ return;
3058
+ }
3059
+ const submitted = [];
3060
+ const finals = [];
3061
+ try {
3062
+ for (const file of files) {
3063
+ const name = jobNameFor(file, files.length, opts.name);
3064
+ out.info(`${kleur9.cyan("\u2192")} Uploading ${file} \u2026`);
3065
+ const job = await deps2.api.submitJob(creds, file, config, name || null);
3066
+ out.success(`Job created: ${job.id} ${kleur9.gray(name)}`);
3067
+ out.info(` rows=${job.totalRows} uniqueQueries=${job.uniqueQueries}`);
3068
+ submitted.push({ file, job });
1760
3069
  }
1761
- if (status === "failed" || status === "cancelled") {
1762
- console.error(kleur2.red(status));
1763
- return;
3070
+ if (opts.watch) {
3071
+ const watchOut = opts.json ? createOutput(deps2.stderr, deps2.stderr, false, deps2.now) : out;
3072
+ for (const { file, job } of submitted) {
3073
+ if (submitted.length > 1) watchOut.info(kleur9.bold(`
3074
+ [${file}] ${job.id}`));
3075
+ finals.push({ id: job.id, status: await watchJob(deps2.api, watchOut, creds, job.id) });
3076
+ }
1764
3077
  }
1765
- } else if (t === "progress") {
1766
- const p = evt.processedQueries ?? evt.processedRows ?? 0;
1767
- const total = evt.totalQueries ?? evt.totalRows ?? 0;
1768
- const phase = p === 0 && typeof evt.phase === "string" ? ` (${evt.phase}\u2026)` : "";
1769
- const line = ` progress: ${p}/${total}${phase}`;
1770
- process.stdout.write(`\r${line.padEnd(lastLineWidth)}`);
1771
- lastLineWidth = line.length;
1772
- } else if (t === "completed") {
1773
- process.stdout.write("\n");
1774
- console.log(kleur2.green("\u2713"), "completed");
1775
- return;
1776
- } else if (t === "error") {
1777
- process.stdout.write("\n");
1778
- console.error(kleur2.red("error:"), evt.message);
1779
- return;
3078
+ } finally {
3079
+ if (opts.json) out.data(summarize(submitted, finals, creds));
1780
3080
  }
3081
+ const failure = watchOutcomeError(finals);
3082
+ if (failure) throw failure;
3083
+ });
3084
+ }
3085
+
3086
+ // src/program.ts
3087
+ var EXIT_CODES_HELP = `
3088
+ Exit codes:
3089
+ 0 success
3090
+ 1 error (API or network failure)
3091
+ 2 usage error (bad flag or argument)
3092
+ 3 not signed in, or the token was rejected
3093
+ 4 a watched job ended failed or cancelled
3094
+ 5 a watched job was paused (resume it, then watch again)
3095
+
3096
+ Environment:
3097
+ PLANTTAXOMATCHER_TOKEN token to use instead of the saved login
3098
+ PLANTTAXOMATCHER_SERVER its server (default: the public server); alone, it
3099
+ must match the saved login's server`;
3100
+ function buildProgram(deps2, version2) {
3101
+ 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({
3102
+ writeOut: (text) => deps2.stdout.write(text),
3103
+ writeErr: (text) => deps2.stderr.write(text)
3104
+ });
3105
+ registerAuthCommands(program, deps2);
3106
+ registerJobCommands(program, deps2);
3107
+ registerSubmitCommand(program, deps2);
3108
+ registerCatalogCommands(program, deps2);
3109
+ registerDownloadCommand(program, deps2);
3110
+ registerSkillsCommands(program, deps2, version2);
3111
+ return program;
3112
+ }
3113
+ async function runCli(argv, deps2, version2) {
3114
+ try {
3115
+ await buildProgram(deps2, version2).parseAsync(argv, { from: "user" });
3116
+ return 0;
3117
+ } catch (err) {
3118
+ const { exitCode, message, hint } = describeError(err);
3119
+ if (message) deps2.stderr.write(`${kleur10.red("error:")} ${message}
3120
+ `);
3121
+ if (hint) deps2.stderr.write(`${kleur10.gray(hint)}
3122
+ `);
3123
+ return exitCode;
1781
3124
  }
1782
3125
  }
3126
+
3127
+ // src/index.ts
3128
+ var { version } = createRequire(import.meta.url)("../package.json");
3129
+ var deps = defaultDeps();
3130
+ await refreshSkillIfOutdated(skillPaths(deps.home, deps.env), deps.skillSource, version);
3131
+ process.exitCode = await runCli(process.argv.slice(2), deps, version);