@plantnet/planttaxomatcher 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -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.
@@ -296,15 +306,56 @@ var jobPublicAccessUpdateSchema = z4.object({
296
306
  var jobRenameSchema = z4.object({
297
307
  name: z4.string().trim().min(1).max(200)
298
308
  });
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
- });
309
+ var jobStreamPhaseSchema = z4.enum(["loading", "parsing", "matching"]);
310
+ var nonNegativeInt = z4.number().int().nonnegative();
311
+ var throttledProviderSchema = z4.object({
312
+ /** Provider id: `gbif`, `tnrs`, `gnverifier`, `plantnet`, `openrouter`. */
313
+ provider: z4.string(),
314
+ /** Calls waiting for that provider's limiter right now. */
315
+ waiting: nonNegativeInt,
316
+ /** How long the job has been waiting on it without a break. */
317
+ waitedMs: nonNegativeInt
318
+ });
319
+ var frameBase = { jobId: z4.string().optional(), timestamp: z4.string().optional() };
320
+ var jobStreamEventSchema = z4.discriminatedUnion("type", [
321
+ /** A status change, or the snapshot a stream opens with. */
322
+ z4.object({
323
+ ...frameBase,
324
+ type: z4.literal("status"),
325
+ status: jobStatusSchema,
326
+ phase: jobStreamPhaseSchema.optional(),
327
+ snapshot: z4.string().optional(),
328
+ processedRows: nonNegativeInt.optional(),
329
+ totalRows: nonNegativeInt.optional(),
330
+ processedQueries: nonNegativeInt.optional(),
331
+ totalQueries: nonNegativeInt.optional()
332
+ }),
333
+ /** Live counters for the whole job, a few times a second at most. */
334
+ z4.object({
335
+ ...frameBase,
336
+ type: z4.literal("progress"),
337
+ phase: jobStreamPhaseSchema,
338
+ processedQueries: nonNegativeInt,
339
+ totalQueries: nonNegativeInt,
340
+ matched: nonNegativeInt,
341
+ ambiguous: nonNegativeInt,
342
+ errors: nonNegativeInt,
343
+ review: nonNegativeInt,
344
+ processed: nonNegativeInt
345
+ }),
346
+ /**
347
+ * External providers are holding the match stage back: their rate
348
+ * limiter, a retry backoff after a 429 / 5xx, or the in-flight cap. Repeated while it
349
+ * lasts and simply not sent once it stops, so a reader should let the
350
+ * state lapse a few seconds after the last one.
351
+ */
352
+ z4.object({ ...frameBase, type: z4.literal("throttle"), providers: z4.array(throttledProviderSchema).min(1) }),
353
+ /** Progress of a post-match verification plugin run. */
354
+ z4.object({ ...frameBase, type: z4.literal("plugin"), pluginId: z4.string() }).passthrough(),
355
+ z4.object({ ...frameBase, type: z4.literal("completed"), status: z4.enum(["completed", "cancelled"]) }),
356
+ z4.object({ ...frameBase, type: z4.literal("error"), message: z4.string() }),
357
+ z4.object({ ...frameBase, type: z4.literal("heartbeat") })
358
+ ]);
308
359
  var taxonIdentifierRefSchema = z4.object({
309
360
  namespace: z4.string(),
310
361
  value: z4.string()
@@ -356,6 +407,16 @@ var statusCountsSchema = z4.object({
356
407
  pending: z4.number().int().nonnegative()
357
408
  });
358
409
  var layerCountsSchema = z4.record(z4.string(), z4.number().int().nonnegative());
410
+ var rowSortSchema = z4.enum([
411
+ "rowIndex",
412
+ "inputName",
413
+ "matchStatus",
414
+ "grade",
415
+ "confidence",
416
+ "acceptedName",
417
+ "family"
418
+ ]);
419
+ var rowOrderSchema = z4.enum(["asc", "desc"]);
359
420
  var jobRowsPageSchema = z4.object({
360
421
  rows: z4.array(jobRowSummarySchema),
361
422
  /** Row count matching the CURRENT filter (not the whole job) — drives
@@ -363,6 +424,9 @@ var jobRowsPageSchema = z4.object({
363
424
  total: z4.number().int().nonnegative(),
364
425
  offset: z4.number().int().nonnegative(),
365
426
  limit: z4.number().int().positive(),
427
+ /** Opaque keyset cursor for the page after this one, in the requested sort
428
+ * + order; null on the last page. Pass it back as `cursor`. */
429
+ nextCursor: z4.string().nullable(),
366
430
  /** Per-grade row counts for the whole job, independent of the current
367
431
  * filter. Used by the SPA to show "B (12)" next to each grade chip. */
368
432
  gradeCounts: gradeCountsSchema,
@@ -578,7 +642,7 @@ var reviewChatStreamEventSchema = z4.discriminatedUnion("type", [
578
642
  /** Terminal failure — a human-readable reason. */
579
643
  z4.object({ type: z4.literal("error"), error: z4.string() })
580
644
  ]);
581
- var replayableLayerSchema = z4.enum(["L1", "L2", "L3", "L4", "L5", "L6", "L7"]);
645
+ var replayableLayerSchema = z4.enum(["L1", "L1.5", "L2", "L3", "L4", "L5", "L6", "L7"]);
582
646
  var replayLayerBodySchema = z4.object({ layer: replayableLayerSchema });
583
647
  var replayCandidateSchema = z4.object({
584
648
  scientificName: z4.string(),
@@ -626,9 +690,50 @@ var replayLayerResultSchema = z4.object({
626
690
  attempts: z4.array(replayAttemptSchema)
627
691
  });
628
692
 
629
- // ../shared/src/schemas/auth.ts
693
+ // ../shared/src/schemas/live-match.ts
630
694
  import { z as z5 } from "zod";
631
- var tokenScopeSchema = z5.enum([
695
+ var liveMatchBodySchema = z5.object({
696
+ name: z5.string().trim().min(3).max(300),
697
+ referential: z5.enum(["wcvp", "wfo"]).default("wcvp"),
698
+ referentialVersion: z5.string().min(1),
699
+ /** WGSRPD Level-3 area code narrowing an ambiguous outcome (null = off). */
700
+ area: z5.string().nullable().optional(),
701
+ speciesLevelAcceptedOnly: z5.boolean().default(false),
702
+ acceptUnconfirmedAuthor: z5.boolean().default(false)
703
+ });
704
+ var liveMatchCandidateSchema = matchCandidateSummarySchema.omit({ id: true, attemptId: true });
705
+ var liveMatchAttemptSchema = z5.object({
706
+ layer: z5.string(),
707
+ provider: z5.string(),
708
+ status: z5.string(),
709
+ durationMs: z5.number().int().nonnegative(),
710
+ query: z5.unknown(),
711
+ rawResponseSummary: z5.unknown().nullable()
712
+ });
713
+ var liveMatchResultSchema = z5.object({
714
+ query: z5.object({
715
+ input: z5.string(),
716
+ canonical: z5.string(),
717
+ authorship: z5.string().nullable(),
718
+ parser: z5.enum(["gnparser", "fallback"])
719
+ }),
720
+ matchStatus: matchStatusSchema,
721
+ evidenceType: evidenceTypeSchema.nullable(),
722
+ grade: gradeSchema.nullable(),
723
+ confidence: z5.number().nullable(),
724
+ /** Layer that settled the outcome (null for no_match). */
725
+ decidedBy: z5.string().nullable(),
726
+ flags: z5.array(z5.string()),
727
+ /** Index into `candidates` of the pick a job would have selected. */
728
+ selectedIndex: z5.number().int().nonnegative().nullable(),
729
+ candidates: z5.array(liveMatchCandidateSchema),
730
+ attempts: z5.array(liveMatchAttemptSchema),
731
+ durationMs: z5.number().int().nonnegative()
732
+ });
733
+
734
+ // ../shared/src/schemas/auth.ts
735
+ import { z as z6 } from "zod";
736
+ var tokenScopeSchema = z6.enum([
632
737
  "submit:job",
633
738
  "read:job",
634
739
  "cancel:job",
@@ -640,83 +745,87 @@ var tokenScopeSchema = z5.enum([
640
745
  "admin:teams"
641
746
  ]);
642
747
  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(),
748
+ var tokenStringSchema = z6.string().regex(/^ptm_[a-zA-Z0-9]{12}_[a-zA-Z0-9]{32}$/, "Malformed token");
749
+ var meSchema = z6.object({
750
+ userId: z6.string().uuid(),
751
+ teamId: z6.string().uuid(),
752
+ displayName: z6.string(),
753
+ scopes: z6.array(tokenScopeSchema),
754
+ tokenLabel: z6.string().nullable(),
650
755
  /** DB id of the token that authenticated this request — matches a row's
651
756
  * `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()
757
+ tokenId: z6.string().uuid().nullable(),
758
+ /** Where this deployment's web app lives, for links to a job page. Absent from older servers. */
759
+ appUrl: z6.string().optional()
760
+ });
761
+ var tokenCreateBodySchema = z6.object({
762
+ label: z6.string().min(1).max(120),
763
+ scopes: z6.array(tokenScopeSchema).min(1),
764
+ expiresAt: z6.string().datetime().optional()
765
+ });
766
+ var tokenRenameSchema = z6.object({
767
+ label: z6.string().trim().min(1).max(120)
768
+ });
769
+ var tokenSummarySchema = z6.object({
770
+ id: z6.string().uuid(),
771
+ /** Team the token belongs to — `admin:teams` callers list every team's tokens. */
772
+ teamId: z6.string().uuid(),
773
+ tokenIdPrefix: z6.string(),
774
+ label: z6.string(),
775
+ displayName: z6.string(),
776
+ scopes: z6.array(tokenScopeSchema),
777
+ createdAt: z6.string().datetime(),
778
+ lastUsedAt: z6.string().datetime().nullable(),
779
+ expiresAt: z6.string().datetime().nullable()
671
780
  });
672
781
  var tokenCreatedSchema = tokenSummarySchema.extend({
673
782
  tokenString: tokenStringSchema
674
783
  });
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)
784
+ var teamSummarySchema = z6.object({
785
+ id: z6.string().uuid(),
786
+ name: z6.string(),
787
+ createdAt: z6.string().datetime()
788
+ });
789
+ var teamCreateBodySchema = z6.object({
790
+ name: z6.string().min(1).max(120),
791
+ initialUserDisplayName: z6.string().min(1).max(120).default("Admin"),
792
+ initialToken: z6.object({
793
+ label: z6.string().min(1).max(120),
794
+ scopes: z6.array(tokenScopeSchema).min(1)
686
795
  }).optional()
687
796
  });
688
797
  var teamCreatedSchema = teamSummarySchema.extend({
689
- initialUser: z5.object({
690
- id: z5.string().uuid(),
691
- displayName: z5.string()
798
+ initialUser: z6.object({
799
+ id: z6.string().uuid(),
800
+ displayName: z6.string()
692
801
  }),
693
802
  initialToken: tokenCreatedSchema.nullable()
694
803
  });
695
- var setupStatusSchema = z5.object({
696
- needsSetup: z5.boolean()
804
+ var setupStatusSchema = z6.object({
805
+ needsSetup: z6.boolean()
697
806
  });
698
- var setupBodySchema = z5.object({
699
- teamName: z5.string().min(1).max(120),
700
- displayName: z5.string().min(1).max(120).default("Admin")
807
+ var setupBodySchema = z6.object({
808
+ teamName: z6.string().min(1).max(120),
809
+ displayName: z6.string().min(1).max(120).default("Admin")
701
810
  });
702
- var setupResultSchema = z5.object({
811
+ var setupResultSchema = z6.object({
703
812
  team: teamSummarySchema,
704
- user: z5.object({ id: z5.string().uuid(), displayName: z5.string() }),
813
+ user: z6.object({ id: z6.string().uuid(), displayName: z6.string() }),
705
814
  tokenString: tokenStringSchema,
706
- scopes: z5.array(tokenScopeSchema)
815
+ scopes: z6.array(tokenScopeSchema)
707
816
  });
708
- var teamLlmSettingsSchema = z5.object({
709
- keyConfigured: z5.boolean(),
710
- keyHint: z5.string().nullable(),
711
- lightModel: z5.string(),
712
- heavyModel: z5.string()
817
+ var teamLlmSettingsSchema = z6.object({
818
+ keyConfigured: z6.boolean(),
819
+ keyHint: z6.string().nullable(),
820
+ lightModel: z6.string(),
821
+ heavyModel: z6.string()
713
822
  });
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()
823
+ var teamLlmUpdateSchema = z6.object({
824
+ openrouterApiKey: z6.string().max(400).nullable().optional(),
825
+ lightModel: z6.string().min(1).max(200).optional(),
826
+ heavyModel: z6.string().min(1).max(200).optional()
718
827
  });
719
- var wcvpImportStatusSchema = z5.enum([
828
+ var wcvpImportStatusSchema = z6.enum([
720
829
  "queued",
721
830
  "downloading",
722
831
  "importing",
@@ -724,68 +833,73 @@ var wcvpImportStatusSchema = z5.enum([
724
833
  "failed",
725
834
  "cancelled"
726
835
  ]);
727
- var wcvpImportRunSchema = z5.object({
728
- id: z5.string().uuid(),
729
- version: z5.string(),
730
- sourceUrl: z5.string(),
836
+ var wcvpImportRunSchema = z6.object({
837
+ id: z6.string().uuid(),
838
+ version: z6.string(),
839
+ sourceUrl: z6.string(),
731
840
  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),
841
+ forceOverwrite: z6.boolean(),
842
+ bytesDownloaded: z6.number().int().nonnegative(),
843
+ totalBytes: z6.number().int().nonnegative().nullable(),
844
+ insertedCount: z6.number().int().nonnegative(),
845
+ recordCount: z6.number().int().nonnegative().nullable(),
846
+ message: z6.string().nullable(),
847
+ createdAt: z6.string().datetime(),
848
+ startedAt: z6.string().datetime().nullable(),
849
+ completedAt: z6.string().datetime().nullable()
850
+ });
851
+ var wcvpImportCreateBodySchema = z6.object({
852
+ version: z6.string().min(1).max(120),
744
853
  /** Defaults to the Kew SFTP URL when omitted, so the admin doesn't have
745
854
  * 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()
855
+ url: z6.string().url().optional(),
856
+ force: z6.boolean().optional()
857
+ });
858
+ var adminHealthErrorSchema = z6.object({
859
+ id: z6.string().uuid(),
860
+ action: z6.string(),
861
+ entityType: z6.string(),
862
+ entityId: z6.string().nullable(),
863
+ timestamp: z6.string().datetime(),
864
+ metadata: z6.unknown().nullable()
865
+ });
866
+ var queueDepthSchema = z6.object({
867
+ waiting: z6.number().int().nonnegative(),
868
+ active: z6.number().int().nonnegative(),
869
+ delayed: z6.number().int().nonnegative(),
870
+ completed: z6.number().int().nonnegative(),
871
+ failed: z6.number().int().nonnegative(),
872
+ paused: z6.number().int().nonnegative()
873
+ });
874
+ var adminQueueSchema = z6.object({
875
+ queueDepth: queueDepthSchema,
876
+ workerCount: z6.number().int().nonnegative()
877
+ });
878
+ var adminHealthSchema = z6.object({
879
+ queueDepth: queueDepthSchema,
880
+ workerCount: z6.number().int().nonnegative(),
881
+ lastErrors: z6.array(adminHealthErrorSchema),
882
+ rateLimitBudget: z6.object({
883
+ max: z6.number().int().nonnegative(),
884
+ timeWindowSeconds: z6.number().int().nonnegative()
771
885
  }),
772
- snapshotVersion: z5.string().nullable(),
773
- snapshotImportedAt: z5.string().datetime().nullable(),
886
+ snapshotVersion: z6.string().nullable(),
887
+ snapshotImportedAt: z6.string().datetime().nullable(),
774
888
  /**
775
889
  * Number of cached accepted-name mappings (L0.5 match-history rows) for the
776
890
  * caller's team — the size of the team accepted-name cache.
777
891
  */
778
- teamHistorySize: z5.number().int().nonnegative(),
892
+ teamHistorySize: z6.number().int().nonnegative(),
779
893
  /**
780
894
  * Overall (all-teams) tally of which cascade layer produced the winning
781
895
  * candidate, across every matched query. `layer` is the raw attempt layer
782
896
  * (`L1`…`L7`, `L0.5`, `OVERRIDE`); `count` is the number of matched queries
783
897
  * that layer resolved. Descending by count.
784
898
  */
785
- layerUsage: z5.array(
786
- z5.object({
787
- layer: z5.string(),
788
- count: z5.number().int().nonnegative()
899
+ layerUsage: z6.array(
900
+ z6.object({
901
+ layer: z6.string(),
902
+ count: z6.number().int().nonnegative()
789
903
  })
790
904
  ),
791
905
  /**
@@ -795,59 +909,108 @@ var adminHealthSchema = z5.object({
795
909
  * Lets the health page surface each external source's usage, hit/miss/error
796
910
  * breakdown, latency, and recency — so a misbehaving provider is visible.
797
911
  */
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()
912
+ externalProviders: z6.array(
913
+ z6.object({
914
+ provider: z6.string(),
915
+ total: z6.number().int().nonnegative(),
916
+ hits: z6.number().int().nonnegative(),
917
+ misses: z6.number().int().nonnegative(),
918
+ errors: z6.number().int().nonnegative(),
919
+ avgDurationMs: z6.number().int().nonnegative(),
920
+ lastUsedAt: z6.string().datetime().nullable()
807
921
  })
808
922
  )
809
923
  });
810
- var matchHistoryEntrySchema = z5.object({
811
- id: z5.string().uuid(),
924
+ var matchHistoryEntrySchema = z6.object({
925
+ id: z6.string().uuid(),
812
926
  /** 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(),
927
+ normalizedInput: z6.string(),
928
+ acceptedName: z6.string(),
929
+ acceptedIdentifier: z6.object({ namespace: z6.string(), value: z6.string() }).nullable(),
816
930
  /** Resolved from the backbone snapshot at read time (null when the stored
817
931
  * identifier no longer resolves): authorship + family of the accepted taxon,
818
932
  * 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(),
933
+ acceptedAuthorship: z6.string().nullable(),
934
+ family: z6.string().nullable(),
935
+ acceptedIdentifiers: z6.array(z6.object({ namespace: z6.string(), value: z6.string() })),
936
+ targetUrl: z6.string().nullable(),
937
+ targetReferential: z6.string(),
938
+ referentialVersion: z6.string(),
939
+ independentUserAcceptanceCount: z6.number().int().nonnegative(),
940
+ rejectionCount: z6.number().int().nonnegative(),
941
+ blacklisted: z6.boolean(),
942
+ needsReconfirmation: z6.boolean(),
829
943
  /** Currently usable as a cache hit (not blacklisted, acceptances > rejections). */
830
- active: z5.boolean(),
944
+ active: z6.boolean(),
831
945
  /** Auto-accept (Grade A): acceptances ≥ the team's promotion threshold. */
832
- promoted: z5.boolean(),
946
+ promoted: z6.boolean(),
833
947
  /** Label of the token that last reviewed this mapping (falls back to the
834
948
  * 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()
949
+ lastReviewedBy: z6.string().nullable(),
950
+ firstSeenAt: z6.string().datetime(),
951
+ lastAcceptedAt: z6.string().datetime().nullable()
838
952
  });
839
- var teamCacheResponseSchema = z5.object({
953
+ var teamCacheResponseSchema = z6.object({
840
954
  /** Distinct-user acceptances needed to promote a hit to Grade A. */
841
- promotionThreshold: z5.number().int().positive(),
955
+ promotionThreshold: z6.number().int().positive(),
842
956
  /** Total entries in the team cache (before the limit). */
843
- total: z5.number().int().nonnegative(),
844
- entries: z5.array(matchHistoryEntrySchema)
957
+ total: z6.number().int().nonnegative(),
958
+ entries: z6.array(matchHistoryEntrySchema)
959
+ });
960
+
961
+ // ../shared/src/schemas/device-auth.ts
962
+ import { z as z7 } from "zod";
963
+ var SLOW_DOWN_STEP_SECONDS = 5;
964
+ var deviceAuthorizationStatusSchema = z7.enum(["pending", "approved", "denied", "consumed"]);
965
+ var deviceAuthorizationCreateSchema = z7.object({
966
+ /** Shown to the approver so they recognise their own machine (the CLI sends its hostname). */
967
+ clientName: z7.string().trim().min(1).max(100)
968
+ });
969
+ var deviceAuthorizationCreatedSchema = z7.object({
970
+ deviceCode: z7.string(),
971
+ userCode: z7.string(),
972
+ verificationUri: z7.string(),
973
+ verificationUriComplete: z7.string(),
974
+ expiresIn: z7.number().int().positive(),
975
+ interval: z7.number().int().positive()
976
+ });
977
+ var deviceAuthorizationSchema = z7.object({
978
+ userCode: z7.string(),
979
+ clientName: z7.string(),
980
+ clientIp: z7.string().nullable(),
981
+ status: deviceAuthorizationStatusSchema,
982
+ scopes: z7.array(tokenScopeSchema),
983
+ createdAt: z7.string().datetime(),
984
+ expiresAt: z7.string().datetime()
985
+ });
986
+ var deviceAuthorizationDecisionSchema = z7.object({
987
+ status: z7.enum(["approved", "denied"])
988
+ });
989
+ var deviceTokenRequestSchema = z7.object({
990
+ deviceCode: z7.string().min(1).max(200)
991
+ });
992
+ var deviceTokenSchema = z7.object({
993
+ token: z7.string(),
994
+ label: z7.string(),
995
+ scopes: z7.array(tokenScopeSchema),
996
+ expiresAt: z7.string().datetime()
997
+ });
998
+ var deviceTokenErrorCodeSchema = z7.enum([
999
+ "authorization_pending",
1000
+ "slow_down",
1001
+ "expired_token",
1002
+ "access_denied"
1003
+ ]);
1004
+ var deviceTokenErrorSchema = z7.object({
1005
+ error: deviceTokenErrorCodeSchema,
1006
+ /** Seconds to wait before the next poll, sent with `slow_down`. */
1007
+ interval: z7.number().int().positive().optional()
845
1008
  });
846
1009
 
847
1010
  // ../shared/src/schemas/backbone.ts
848
- import { z as z6 } from "zod";
849
- var backboneSchema = z6.enum(["wcvp", "wfo"]);
850
- var wfoImportStatusSchema = z6.enum([
1011
+ import { z as z8 } from "zod";
1012
+ var backboneSchema = z8.enum(["wcvp", "wfo"]);
1013
+ var wfoImportStatusSchema = z8.enum([
851
1014
  "queued",
852
1015
  "downloading",
853
1016
  "importing",
@@ -855,72 +1018,85 @@ var wfoImportStatusSchema = z6.enum([
855
1018
  "failed",
856
1019
  "cancelled"
857
1020
  ]);
858
- var wfoImportRunSchema = z6.object({
859
- id: z6.string().uuid(),
860
- version: z6.string(),
861
- sourceUrl: z6.string(),
1021
+ var wfoImportRunSchema = z8.object({
1022
+ id: z8.string().uuid(),
1023
+ version: z8.string(),
1024
+ sourceUrl: z8.string(),
862
1025
  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),
1026
+ forceOverwrite: z8.boolean(),
1027
+ bytesDownloaded: z8.number().int().nonnegative(),
1028
+ totalBytes: z8.number().int().nonnegative().nullable(),
1029
+ insertedCount: z8.number().int().nonnegative(),
1030
+ recordCount: z8.number().int().nonnegative().nullable(),
1031
+ message: z8.string().nullable(),
1032
+ createdAt: z8.string().datetime(),
1033
+ startedAt: z8.string().datetime().nullable(),
1034
+ completedAt: z8.string().datetime().nullable()
1035
+ });
1036
+ var wfoImportCreateBodySchema = z8.object({
1037
+ version: z8.string().min(1).max(120),
875
1038
  /** Direct download URL (from the Zenodo version listing). Required for WFO
876
1039
  * 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(),
1040
+ url: z8.string().url().optional(),
1041
+ force: z8.boolean().optional()
1042
+ });
1043
+ var snapshotKindSchema = z8.enum(["official", "derived"]);
1044
+ var wfoSnapshotSummarySchema = z8.object({
1045
+ version: z8.string(),
1046
+ recordCount: z8.number().int().nonnegative(),
1047
+ importedAt: z8.string().datetime(),
1048
+ isLatest: z8.boolean(),
886
1049
  kind: snapshotKindSchema,
887
- baseVersion: z6.string().nullable(),
888
- label: z6.string().nullable(),
889
- ownerTeamId: z6.string().nullable()
1050
+ baseVersion: z8.string().nullable(),
1051
+ label: z8.string().nullable(),
1052
+ ownerTeamId: z8.string().nullable()
890
1053
  });
891
- var wfoVersionSchema = z6.object({
1054
+ var wfoVersionSchema = z8.object({
892
1055
  /** Release label, e.g. "2025-12". */
893
- version: z6.string(),
1056
+ version: z8.string(),
894
1057
  /** Zenodo record id for this version. */
895
- recordId: z6.string(),
1058
+ recordId: z8.string(),
896
1059
  /** The plant-list archive filename inside the record. */
897
- fileName: z6.string(),
1060
+ fileName: z8.string(),
898
1061
  /** 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({
1062
+ url: z8.string().url(),
1063
+ sizeBytes: z8.number().int().nonnegative().nullable(),
1064
+ publishedAt: z8.string().nullable(),
1065
+ isLatest: z8.boolean()
1066
+ });
1067
+ var wfoVersionListSchema = z8.array(wfoVersionSchema);
1068
+ var wcvpVersionSchema = z8.object({
1069
+ /** Snapshot label the import uses, e.g. "wcvp-v15". */
1070
+ version: z8.string(),
1071
+ /** Kew release number, e.g. 15. */
1072
+ release: z8.number().int().positive(),
1073
+ /** Direct download URL. */
1074
+ url: z8.string().url(),
1075
+ /** Size as Kew's directory index prints it, e.g. "85M". */
1076
+ sizeLabel: z8.string().nullable(),
1077
+ /** Last-modified timestamp from the index, "YYYY-MM-DD HH:MM". */
1078
+ publishedAt: z8.string().nullable()
1079
+ });
1080
+ var wcvpVersionListSchema = z8.array(wcvpVersionSchema);
1081
+ var backboneKeySchema = z8.enum(["wcvp", "wfo"]);
1082
+ var backboneDefaultsSchema = z8.object({
1083
+ wcvp: z8.string().nullable(),
1084
+ wfo: z8.string().nullable()
1085
+ });
1086
+ var backboneDefaultUpdateSchema = z8.object({
911
1087
  backbone: backboneKeySchema,
912
- version: z6.string().min(1).nullable()
1088
+ version: z8.string().min(1).nullable()
913
1089
  });
914
1090
  var DERIVED_SLUG_RE = /^[a-z0-9][a-z0-9-]{1,40}$/;
915
1091
  var MAX_BASE_VERSION_LENGTH = 120;
916
1092
  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()
1093
+ var derivedUploadMetaSchema = z8.object({
1094
+ baseVersion: z8.string().min(1).max(MAX_BASE_VERSION_LENGTH),
1095
+ slug: z8.string().regex(DERIVED_SLUG_RE, "slug: lowercase letters, digits and dashes (2\u201341 chars)"),
1096
+ label: z8.string().trim().min(1).max(120),
1097
+ notes: z8.string().trim().max(2e3).optional()
922
1098
  });
923
- var derivedUploadStatusSchema = z6.enum([
1099
+ var derivedUploadStatusSchema = z8.enum([
924
1100
  "staging",
925
1101
  "ready",
926
1102
  "invalid",
@@ -929,23 +1105,23 @@ var derivedUploadStatusSchema = z6.enum([
929
1105
  "failed",
930
1106
  "discarded"
931
1107
  ]);
932
- var derivedIssueSeveritySchema = z6.enum(["error", "warning"]);
933
- var derivedIssueSchema = z6.object({
934
- code: z6.string(),
1108
+ var derivedIssueSeveritySchema = z8.enum(["error", "warning"]);
1109
+ var derivedIssueSchema = z8.object({
1110
+ code: z8.string(),
935
1111
  severity: derivedIssueSeveritySchema,
936
- count: z6.number().int().nonnegative(),
1112
+ count: z8.number().int().nonnegative(),
937
1113
  /** 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()
1114
+ detail: z8.string().nullable(),
1115
+ samples: z8.array(
1116
+ z8.object({
1117
+ taxonId: z8.string().nullable(),
1118
+ name: z8.string().nullable(),
1119
+ detail: z8.string().nullable(),
1120
+ line: z8.number().int().nullable()
945
1121
  })
946
1122
  )
947
1123
  });
948
- var derivedDiffFieldSchema = z6.enum([
1124
+ var derivedDiffFieldSchema = z8.enum([
949
1125
  "canonical_name",
950
1126
  "scientific_name",
951
1127
  "authorship",
@@ -956,77 +1132,77 @@ var derivedDiffFieldSchema = z6.enum([
956
1132
  "family"
957
1133
  ]);
958
1134
  var DERIVED_DIFF_FIELDS = derivedDiffFieldSchema.options;
959
- var diffSampleSchema = z6.object({ taxonId: z6.string(), name: z6.string().nullable() });
1135
+ var diffSampleSchema = z8.object({ taxonId: z8.string(), name: z8.string().nullable() });
960
1136
  var changedSampleSchema = diffSampleSchema.extend({
961
- changes: z6.array(
962
- z6.object({ field: derivedDiffFieldSchema, before: z6.string().nullable(), after: z6.string().nullable() })
1137
+ changes: z8.array(
1138
+ z8.object({ field: derivedDiffFieldSchema, before: z8.string().nullable(), after: z8.string().nullable() })
963
1139
  )
964
1140
  });
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)
1141
+ var derivedReportSchema = z8.object({
1142
+ rowsRead: z8.number().int().nonnegative(),
1143
+ rowsStaged: z8.number().int().nonnegative(),
1144
+ rowsRejected: z8.number().int().nonnegative(),
1145
+ baseRowCount: z8.number().int().nonnegative(),
1146
+ errors: z8.number().int().nonnegative(),
1147
+ warnings: z8.number().int().nonnegative(),
1148
+ issues: z8.array(derivedIssueSchema),
1149
+ diff: z8.object({
1150
+ added: z8.object({ count: z8.number().int().nonnegative(), samples: z8.array(diffSampleSchema) }),
1151
+ removed: z8.object({ count: z8.number().int().nonnegative(), samples: z8.array(diffSampleSchema) }),
1152
+ changed: z8.object({
1153
+ count: z8.number().int().nonnegative(),
1154
+ byField: z8.record(z8.string(), z8.number().int().nonnegative()),
1155
+ samples: z8.array(changedSampleSchema)
980
1156
  }),
981
- unchanged: z6.number().int().nonnegative()
1157
+ unchanged: z8.number().int().nonnegative()
982
1158
  })
983
1159
  });
984
- var derivedUploadSchema = z6.object({
985
- id: z6.string().uuid(),
1160
+ var derivedUploadSchema = z8.object({
1161
+ id: z8.string().uuid(),
986
1162
  backbone: backboneSchema,
987
- baseVersion: z6.string(),
988
- slug: z6.string(),
989
- label: z6.string(),
990
- notes: z6.string().nullable(),
1163
+ baseVersion: z8.string(),
1164
+ slug: z8.string(),
1165
+ label: z8.string(),
1166
+ notes: z8.string().nullable(),
991
1167
  /** 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(),
1168
+ version: z8.string(),
1169
+ originalFilename: z8.string().nullable(),
1170
+ sha256: z8.string(),
1171
+ sizeBytes: z8.number().int().nonnegative(),
996
1172
  status: derivedUploadStatusSchema,
997
- rowsRead: z6.number().int().nonnegative(),
1173
+ rowsRead: z8.number().int().nonnegative(),
998
1174
  report: derivedReportSchema.nullable(),
999
- message: z6.string().nullable(),
1000
- createdAt: z6.string().datetime(),
1001
- expiresAt: z6.string().datetime(),
1002
- importedVersion: z6.string().nullable()
1175
+ message: z8.string().nullable(),
1176
+ createdAt: z8.string().datetime(),
1177
+ expiresAt: z8.string().datetime(),
1178
+ importedVersion: z8.string().nullable()
1003
1179
  });
1004
- var derivedConfirmBodySchema = z6.object({
1180
+ var derivedConfirmBodySchema = z8.object({
1005
1181
  /** Acknowledge warnings. Never overrides errors. */
1006
- force: z6.boolean().optional()
1182
+ force: z8.boolean().optional()
1007
1183
  });
1008
1184
 
1009
1185
  // ../shared/src/schemas/openrouter.ts
1010
- import { z as z7 } from "zod";
1011
- var openRouterModelSchema = z7.object({
1186
+ import { z as z9 } from "zod";
1187
+ var openRouterModelSchema = z9.object({
1012
1188
  /** Full model id, e.g. `anthropic/claude-opus-4.8` or the alias
1013
1189
  * `~anthropic/claude-haiku-latest`. Used verbatim as the OpenRouter model. */
1014
- id: z7.string(),
1190
+ id: z9.string(),
1015
1191
  /** Human label from OpenRouter (falls back to the id). */
1016
- name: z7.string(),
1192
+ name: z9.string(),
1017
1193
  /** Author slug (the part before `/`), with any leading `~` stripped. */
1018
- author: z7.string(),
1194
+ author: z9.string(),
1019
1195
  /** Unix seconds the model was published; used to rank "latest". */
1020
- created: z7.number(),
1196
+ created: z9.number(),
1021
1197
  /** Context window in tokens, when OpenRouter reports it. */
1022
- contextLength: z7.number().nullable(),
1198
+ contextLength: z9.number().nullable(),
1023
1199
  /** USD price per PROMPT token (input). Null when OpenRouter doesn't report a
1024
1200
  * numeric price. 0 = free. */
1025
- promptPriceUsd: z7.number().nullable(),
1201
+ promptPriceUsd: z9.number().nullable(),
1026
1202
  /** USD price per COMPLETION token (output). */
1027
- completionPriceUsd: z7.number().nullable(),
1203
+ completionPriceUsd: z9.number().nullable(),
1028
1204
  /** An auto-updating `…-latest` pointer (e.g. `~anthropic/claude-haiku-latest`). */
1029
- isAlias: z7.boolean()
1205
+ isAlias: z9.boolean()
1030
1206
  });
1031
1207
 
1032
1208
  // ../shared/src/normalize.ts
@@ -1155,184 +1331,1175 @@ function detectIdTypeDistribution(samples, opts) {
1155
1331
  }
1156
1332
 
1157
1333
  // ../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({
1334
+ import { z as z10 } from "zod";
1335
+ var columnMappingSchema = z10.object({
1336
+ nameColumn: z10.string().nullable(),
1337
+ idColumn: z10.string().nullable(),
1338
+ familyColumn: z10.string().nullable(),
1339
+ genusColumn: z10.string().nullable(),
1340
+ rankColumn: z10.string().nullable(),
1341
+ authorColumn: z10.string().nullable()
1342
+ });
1343
+ var detectColumnsBodySchema = z10.object({
1344
+ headers: z10.array(z10.string().min(1)).min(1).max(200)
1345
+ });
1346
+ var detectColumnsResponseSchema = z10.object({
1171
1347
  mapping: columnMappingSchema,
1172
- usedLlm: z8.boolean()
1348
+ usedLlm: z10.boolean()
1173
1349
  });
1174
1350
 
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) {
1351
+ // ../shared/src/env-flag.ts
1352
+ var FALSY = /* @__PURE__ */ new Set(["false", "0", "no", "off"]);
1353
+ function parseEnvFlag(raw, fallback) {
1354
+ if (raw === void 0 || raw.trim() === "") return fallback;
1355
+ return !FALSY.has(raw.trim().toLowerCase());
1356
+ }
1357
+
1358
+ // ../shared/src/public-server.ts
1359
+ var DEFAULT_SERVER_URL = "https://planttaxomatcher.plantnet.org";
1360
+
1361
+ // src/errors.ts
1362
+ import { CommanderError } from "commander";
1363
+ var EXIT = {
1364
+ ok: 0,
1365
+ error: 1,
1366
+ usage: 2,
1367
+ auth: 3,
1368
+ jobFailed: 4,
1369
+ jobPaused: 5
1370
+ };
1371
+ var LOGIN_HINT = "Run `planttaxomatcher login`, or set PLANTTAXOMATCHER_TOKEN.";
1372
+ var CliError = class extends Error {
1373
+ constructor(message, exitCode = EXIT.error, hint) {
1181
1374
  super(message);
1375
+ this.exitCode = exitCode;
1376
+ this.hint = hint;
1377
+ }
1378
+ exitCode;
1379
+ hint;
1380
+ };
1381
+ var ApiError = class extends Error {
1382
+ constructor(status, body) {
1383
+ super(describeApiFailure(status, body));
1182
1384
  this.status = status;
1183
1385
  this.body = body;
1184
1386
  }
1185
1387
  status;
1186
1388
  body;
1187
1389
  };
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);
1390
+ function describeApiFailure(status, body) {
1391
+ const parsed = body && typeof body === "object" ? body : {};
1392
+ const text = typeof body === "string" ? body.trim() : "";
1393
+ const code = typeof parsed.error === "string" ? parsed.error : "";
1394
+ const message = typeof parsed.message === "string" ? parsed.message : "";
1395
+ if (code === "missing_scope" && Array.isArray(parsed.required)) {
1396
+ return `HTTP ${status}: this token lacks the ${parsed.required.join(", ")} scope`;
1203
1397
  }
1204
- return parsed;
1398
+ if (text.startsWith("<"))
1399
+ return `HTTP ${status}: the server answered with a web page, not the API \u2014 check the server URL`;
1400
+ const detail = message || code || text.slice(0, 200);
1401
+ return detail ? `HTTP ${status}: ${detail}` : `HTTP ${status}`;
1402
+ }
1403
+ function describeError(err) {
1404
+ if (err instanceof CommanderError) {
1405
+ return { exitCode: err.exitCode === 0 ? EXIT.ok : EXIT.usage, message: null };
1406
+ }
1407
+ if (err instanceof CliError) {
1408
+ return { exitCode: err.exitCode, message: err.message, ...err.hint ? { hint: err.hint } : {} };
1409
+ }
1410
+ if (err instanceof ApiError) {
1411
+ if (err.status === 401) return { exitCode: EXIT.auth, message: err.message, hint: LOGIN_HINT };
1412
+ return { exitCode: EXIT.error, message: err.message };
1413
+ }
1414
+ return { exitCode: EXIT.error, message: err instanceof Error ? err.message : String(err) };
1415
+ }
1416
+ function unreachable(server, err) {
1417
+ const cause = err instanceof Error && err.cause instanceof Error ? err.cause : err;
1418
+ const code = cause?.code;
1419
+ const reason = code ?? (cause instanceof Error ? cause.message : String(cause));
1420
+ return new CliError(`cannot reach ${server}: ${reason}`);
1421
+ }
1422
+
1423
+ // src/ndjson.ts
1424
+ async function* parseNdjson(chunks) {
1425
+ const decoder = new TextDecoder("utf-8");
1426
+ let buffered = "";
1427
+ for await (const chunk of chunks) {
1428
+ buffered += typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
1429
+ let newline;
1430
+ while ((newline = buffered.indexOf("\n")) >= 0) {
1431
+ const frame = parseLine(buffered.slice(0, newline));
1432
+ buffered = buffered.slice(newline + 1);
1433
+ if (frame) yield frame;
1434
+ }
1435
+ }
1436
+ const last = parseLine(buffered + decoder.decode());
1437
+ if (last) yield last;
1438
+ }
1439
+ function parseLine(line) {
1440
+ const trimmed = line.trim();
1441
+ if (!trimmed) return null;
1442
+ try {
1443
+ const value = JSON.parse(trimmed);
1444
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
1445
+ } catch {
1446
+ return null;
1447
+ }
1448
+ }
1449
+
1450
+ // src/api-client.ts
1451
+ var MIME_BY_EXTENSION = {
1452
+ json: "application/json",
1453
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
1454
+ xls: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
1455
+ };
1456
+ function uploadMimeType(filePath) {
1457
+ const extension = filePath.toLowerCase().split(".").pop() ?? "";
1458
+ return MIME_BY_EXTENSION[extension] ?? "text/csv";
1459
+ }
1460
+ function downloadFilename(contentDisposition, jobId, opts) {
1461
+ const match = /filename="?([^";]+)"?/.exec(contentDisposition ?? "");
1462
+ const suggested = match?.[1] ? basename(match[1].replaceAll("\\", "/")) : "";
1463
+ if (suggested && suggested !== "." && suggested !== "..") return suggested;
1464
+ return `planttaxomatcher_${jobId}.${opts.bundle ? "zip" : opts.format}`;
1205
1465
  }
1206
- function safeJson(s) {
1466
+ function downloadQuery(opts) {
1467
+ const params = new URLSearchParams({ format: opts.format });
1468
+ if (opts.confirmedOnly) params.set("confirmedOnly", "true");
1469
+ if (opts.dedupe) params.set("dedupe", "true");
1470
+ if (opts.bundle) params.set("bundle", "true");
1471
+ if (opts.delimiter) params.set("delimiter", opts.delimiter);
1472
+ if (opts.columns) params.set("columns", opts.columns);
1473
+ if (opts.wcvpExtra) params.set("wcvpExtra", opts.wcvpExtra);
1474
+ return params;
1475
+ }
1476
+ function notAnApi(server, path) {
1477
+ return new CliError(`${server} did not answer ${path} like a PlantTaxoMatcher API \u2014 check the server URL`);
1478
+ }
1479
+ function safeJson(text) {
1207
1480
  try {
1208
- return JSON.parse(s);
1481
+ return JSON.parse(text);
1209
1482
  } catch {
1210
1483
  return null;
1211
1484
  }
1212
1485
  }
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}` }
1486
+ function createApiClient(options = {}) {
1487
+ const dispatcher = options.dispatcher ? { dispatcher: options.dispatcher } : {};
1488
+ async function send(target, path, init = {}) {
1489
+ const headers = target.token ? { authorization: `Bearer ${target.token}` } : {};
1490
+ if (typeof init.body === "string") headers["content-type"] = "application/json";
1491
+ try {
1492
+ return await fetch(new URL(path, target.server), {
1493
+ method: init.method ?? "GET",
1494
+ headers,
1495
+ ...init.body !== void 0 ? { body: init.body } : {},
1496
+ ...dispatcher
1497
+ });
1498
+ } catch (err) {
1499
+ throw unreachable(target.server, err);
1500
+ }
1501
+ }
1502
+ async function call(creds, path, init = {}) {
1503
+ const res = await send(creds, path, {
1504
+ ...init.method ? { method: init.method } : {},
1505
+ ...init.json !== void 0 ? { body: JSON.stringify(init.json) } : {}
1234
1506
  });
1235
1507
  const text = await res.text();
1236
1508
  const parsed = text ? safeJson(text) : null;
1237
- if (!res.ok) throw new ApiError(`HTTP ${res.status}`, res.status, parsed ?? text);
1509
+ if (!res.ok) throw new ApiError(res.status, parsed ?? text);
1510
+ if (parsed === null) throw notAnApi(creds.server, path);
1238
1511
  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
- }
1512
+ }
1513
+ async function failWith(res) {
1514
+ const text = await res.text().catch(() => "");
1515
+ throw new ApiError(res.status, (text && safeJson(text)) ?? text);
1516
+ }
1517
+ return {
1518
+ async startDeviceAuthorization(server, clientName) {
1519
+ let created;
1520
+ try {
1521
+ created = await call({ server }, "/v1/device-authorizations", {
1522
+ method: "POST",
1523
+ json: { clientName }
1524
+ });
1525
+ } catch (err) {
1526
+ if (err instanceof ApiError && err.status === 404) {
1527
+ throw new CliError(
1528
+ `${server} does not offer browser sign-in`,
1529
+ EXIT.usage,
1530
+ "Pass a personal token instead: `planttaxomatcher login --token-stdin`."
1531
+ );
1285
1532
  }
1533
+ throw err;
1286
1534
  }
1287
- } finally {
1288
- await reader.cancel().catch(() => {
1535
+ const checked = deviceAuthorizationCreatedSchema.safeParse(created);
1536
+ if (!checked.success) throw notAnApi(server, "/v1/device-authorizations");
1537
+ return checked.data;
1538
+ },
1539
+ /** One poll: the token once approved, else the RFC 8628 reason to keep waiting or stop. */
1540
+ async pollDeviceToken(server, deviceCode) {
1541
+ const res = await send({ server }, "/v1/device-tokens", {
1542
+ method: "POST",
1543
+ body: JSON.stringify({ deviceCode })
1289
1544
  });
1545
+ const text = await res.text();
1546
+ const parsed = text ? safeJson(text) : null;
1547
+ if (res.status === 400) {
1548
+ const refusal = deviceTokenErrorSchema.safeParse(parsed);
1549
+ if (refusal.success) return refusal.data;
1550
+ }
1551
+ if (!res.ok) throw new ApiError(res.status, parsed ?? text);
1552
+ const token = deviceTokenSchema.safeParse(parsed);
1553
+ if (!token.success) throw notAnApi(server, "/v1/device-tokens");
1554
+ return token.data;
1555
+ },
1556
+ async me(c) {
1557
+ const checked = meSchema.safeParse(await call(c, "/v1/me"));
1558
+ if (!checked.success) throw notAnApi(c.server, "/v1/me");
1559
+ return checked.data;
1560
+ },
1561
+ listJobs(c, opts = {}) {
1562
+ const params = new URLSearchParams();
1563
+ if (opts.limit !== void 0) params.set("limit", String(opts.limit));
1564
+ if (opts.status) params.set("status", opts.status);
1565
+ const query = params.size > 0 ? `?${params}` : "";
1566
+ return call(c, `/v1/jobs${query}`);
1567
+ },
1568
+ getJob: (c, id) => call(c, `/v1/jobs/${encodeURIComponent(id)}`),
1569
+ downloadColumns: (c, id) => call(c, `/v1/jobs/${encodeURIComponent(id)}/download/columns`),
1570
+ pauseJob: (c, id) => call(c, `/v1/jobs/${encodeURIComponent(id)}/pause`, { method: "POST" }),
1571
+ resumeJob: (c, id) => call(c, `/v1/jobs/${encodeURIComponent(id)}/resume`, { method: "POST" }),
1572
+ cancelJob: (c, id) => call(c, `/v1/jobs/${encodeURIComponent(id)}/cancel`, { method: "POST" }),
1573
+ async submitJob(c, filePath, config, name) {
1574
+ const form = new FormData();
1575
+ form.set("config", JSON.stringify(config));
1576
+ if (name) form.set("name", name);
1577
+ form.set("file", await openAsBlob(filePath, { type: uploadMimeType(filePath) }), basename(filePath));
1578
+ const res = await send(c, "/v1/jobs", { method: "POST", body: form });
1579
+ if (!res.ok) return failWith(res);
1580
+ return await res.json();
1581
+ },
1582
+ async downloadJob(c, id, opts) {
1583
+ const res = await send(c, `/v1/jobs/${encodeURIComponent(id)}/download?${downloadQuery(opts)}`);
1584
+ if (!res.ok) return failWith(res);
1585
+ const filename = downloadFilename(res.headers.get("content-disposition"), id, opts);
1586
+ return { filename, body: Buffer.from(await res.arrayBuffer()) };
1587
+ },
1588
+ async *streamJob(c, id) {
1589
+ const res = await send(c, `/v1/jobs/${encodeURIComponent(id)}/stream`);
1590
+ if (!res.ok || !res.body) return failWith(res);
1591
+ const reader = res.body.getReader();
1592
+ const chunks = {
1593
+ async *[Symbol.asyncIterator]() {
1594
+ for (; ; ) {
1595
+ const { done, value } = await reader.read();
1596
+ if (done) return;
1597
+ yield value;
1598
+ }
1599
+ }
1600
+ };
1601
+ try {
1602
+ yield* parseNdjson(chunks);
1603
+ } finally {
1604
+ await reader.cancel().catch(() => {
1605
+ });
1606
+ }
1290
1607
  }
1291
- }
1292
- };
1608
+ };
1609
+ }
1293
1610
 
1294
1611
  // src/config.ts
1295
- import { promises as fs2 } from "fs";
1612
+ import { promises as fs } from "fs";
1296
1613
  import { homedir } from "os";
1297
1614
  import { join } from "path";
1298
- var CONFIG_DIR = join(homedir(), ".config", "planttaxomatcher");
1299
- var CONFIG_FILE = join(CONFIG_DIR, "credentials");
1300
- async function readCredentials() {
1615
+
1616
+ // src/transport.ts
1617
+ function isLoopback(hostname2) {
1618
+ return hostname2 === "localhost" || hostname2 === "127.0.0.1" || hostname2 === "[::1]" || hostname2 === "::1" || hostname2.endsWith(".localhost");
1619
+ }
1620
+ function assertServerTransport(server, options = { allowInsecure: false }) {
1621
+ let url;
1301
1622
  try {
1302
- const raw = await fs2.readFile(CONFIG_FILE, "utf8");
1303
- return JSON.parse(raw);
1623
+ url = new URL(server);
1624
+ } catch {
1625
+ throw new CliError(`invalid server URL: ${server}`, EXIT.usage);
1626
+ }
1627
+ if (url.protocol === "https:") return;
1628
+ if (url.protocol !== "http:") {
1629
+ throw new CliError(`server must use http or https (got ${url.protocol})`, EXIT.usage);
1630
+ }
1631
+ if (!isLoopback(url.hostname) && !options.allowInsecure) {
1632
+ const override = options.insecureFlag ? ", or pass --insecure to override (NOT recommended)" : "";
1633
+ throw new CliError(
1634
+ `refusing to send a token in cleartext to ${url.host}. Use an https URL${override}.`,
1635
+ EXIT.usage
1636
+ );
1637
+ }
1638
+ }
1639
+
1640
+ // src/config.ts
1641
+ function defaultConfigDir() {
1642
+ return join(homedir(), ".config", "planttaxomatcher");
1643
+ }
1644
+ function createCredentialStore(dir = defaultConfigDir()) {
1645
+ const file = join(dir, "credentials");
1646
+ return {
1647
+ file,
1648
+ async read() {
1649
+ try {
1650
+ return JSON.parse(await fs.readFile(file, "utf8"));
1651
+ } catch (err) {
1652
+ if (err.code === "ENOENT") return null;
1653
+ throw err;
1654
+ }
1655
+ },
1656
+ async write(creds) {
1657
+ await fs.mkdir(dir, { recursive: true, mode: 448 });
1658
+ await fs.writeFile(file, JSON.stringify(creds, null, 2), { mode: 384 });
1659
+ await fs.chmod(file, 384);
1660
+ },
1661
+ async clear() {
1662
+ await fs.rm(file, { force: true });
1663
+ }
1664
+ };
1665
+ }
1666
+ function envValue(env2, key) {
1667
+ const value = env2[key]?.trim();
1668
+ return value ? value : void 0;
1669
+ }
1670
+ function serverFromEnv(env2) {
1671
+ return envValue(env2, "PLANTTAXOMATCHER_SERVER");
1672
+ }
1673
+ function tokenFromEnv(env2) {
1674
+ return envValue(env2, "PLANTTAXOMATCHER_TOKEN");
1675
+ }
1676
+ function sameServer(a, b) {
1677
+ const normalize = (url) => url.trim().replace(/\/+$/, "").toLowerCase();
1678
+ return normalize(a) === normalize(b);
1679
+ }
1680
+ function resolveCredentials(stored, env2) {
1681
+ const envToken = tokenFromEnv(env2);
1682
+ const envServer = serverFromEnv(env2);
1683
+ if (envToken) return { token: envToken, server: envServer ?? DEFAULT_SERVER_URL };
1684
+ if (!stored) return null;
1685
+ if (envServer && !sameServer(envServer, stored.server)) {
1686
+ throw new CliError(
1687
+ `PLANTTAXOMATCHER_SERVER (${envServer}) is not the server of the saved login (${stored.server})`,
1688
+ EXIT.usage,
1689
+ "Set PLANTTAXOMATCHER_TOKEN for that server too, or run `planttaxomatcher login --server <url>`."
1690
+ );
1691
+ }
1692
+ return stored;
1693
+ }
1694
+ async function requireCredentials(store, env2) {
1695
+ const envServer = serverFromEnv(env2);
1696
+ if (envServer) assertServerTransport(envServer, { allowInsecure: false });
1697
+ const creds = resolveCredentials(await store.read(), env2);
1698
+ if (!creds) throw new CliError("not signed in", EXIT.auth, LOGIN_HINT);
1699
+ return creds;
1700
+ }
1701
+
1702
+ // src/skills/paths.ts
1703
+ import { join as join2 } from "path";
1704
+ var SKILL_NAME = "planttaxomatcher";
1705
+ var MARKER_FILE = ".planttaxomatcher-managed";
1706
+ var AGENTS = ["claude", "codex", "opencode", "pi"];
1707
+ var SKILL_DIRS = ["claude", "agents", "opencode", "pi"];
1708
+ var env = (value) => value?.trim() ? value.trim() : void 0;
1709
+ function skillPaths(home, vars) {
1710
+ const dataHome = env(vars.XDG_DATA_HOME) ?? join2(home, ".local", "share");
1711
+ const configHome = env(vars.XDG_CONFIG_HOME) ?? join2(home, ".config");
1712
+ const claudeHome = env(vars.CLAUDE_CONFIG_DIR) ?? join2(home, ".claude");
1713
+ return {
1714
+ canonical: join2(dataHome, "planttaxomatcher", "skills", SKILL_NAME),
1715
+ entries: {
1716
+ claude: join2(claudeHome, "skills", SKILL_NAME),
1717
+ agents: join2(home, ".agents", "skills", SKILL_NAME),
1718
+ opencode: join2(configHome, "opencode", "skills", SKILL_NAME),
1719
+ pi: join2(home, ".pi", "agent", "skills", SKILL_NAME)
1720
+ },
1721
+ agentHomes: {
1722
+ claude: claudeHome,
1723
+ codex: join2(home, ".codex"),
1724
+ opencode: join2(configHome, "opencode"),
1725
+ pi: join2(home, ".pi")
1726
+ }
1727
+ };
1728
+ }
1729
+ var READS = {
1730
+ claude: ["claude"],
1731
+ codex: ["agents"],
1732
+ opencode: ["opencode", "agents", "claude"],
1733
+ pi: ["pi", "agents"]
1734
+ };
1735
+ function linkPlan(agents, alreadyLinked) {
1736
+ const plan = /* @__PURE__ */ new Set();
1737
+ if (agents.includes("claude")) plan.add("claude");
1738
+ if (agents.includes("codex") || agents.includes("pi")) plan.add("agents");
1739
+ const openCodeCovered = ["claude", "agents"].some(
1740
+ (dir) => plan.has(dir) || alreadyLinked.has(dir)
1741
+ );
1742
+ if (agents.includes("opencode") && !openCodeCovered) plan.add("opencode");
1743
+ return [...plan];
1744
+ }
1745
+ function parseAgents(value) {
1746
+ const names = value.split(",").map((name) => name.trim().toLowerCase()).filter(Boolean);
1747
+ if (names.includes("all")) return "all";
1748
+ return names.length > 0 && names.every((name) => AGENTS.includes(name)) ? names : null;
1749
+ }
1750
+ function isNewerVersion(candidate, current) {
1751
+ const parse = (v) => v.split(/[.+-]/).slice(0, 3).map((n) => Number.parseInt(n, 10) || 0);
1752
+ const [a, b] = [parse(candidate), parse(current)];
1753
+ for (let i = 0; i < 3; i++) {
1754
+ if (a[i] !== b[i]) return a[i] > b[i];
1755
+ }
1756
+ return false;
1757
+ }
1758
+
1759
+ // src/deps.ts
1760
+ async function readAllStdin() {
1761
+ const chunks = [];
1762
+ for await (const chunk of process.stdin) chunks.push(chunk);
1763
+ return Buffer.concat(chunks).toString("utf8").trim();
1764
+ }
1765
+ function browserCommand(url) {
1766
+ if (process.platform === "darwin") return ["open", [url]];
1767
+ if (process.platform === "win32") return ["rundll32", ["url.dll,FileProtocolHandler", url]];
1768
+ return ["xdg-open", [url]];
1769
+ }
1770
+ function openInBrowser(url, env2) {
1771
+ if (env2.SSH_CONNECTION || env2.SSH_TTY) return Promise.resolve(false);
1772
+ const [command, args] = browserCommand(url);
1773
+ return new Promise((resolve2) => {
1774
+ const child = spawn(command, args, { stdio: "ignore", detached: true });
1775
+ child.once("error", () => resolve2(false));
1776
+ child.once("spawn", () => {
1777
+ child.unref();
1778
+ resolve2(true);
1779
+ });
1780
+ });
1781
+ }
1782
+ function defaultDeps() {
1783
+ return {
1784
+ api: createApiClient(),
1785
+ store: createCredentialStore(),
1786
+ env: process.env,
1787
+ stdout: process.stdout,
1788
+ stderr: process.stderr,
1789
+ stdinIsTTY: !!process.stdin.isTTY,
1790
+ readStdin: readAllStdin,
1791
+ promptConfirm: (message) => confirm({ message, default: true }),
1792
+ openBrowser: (url) => openInBrowser(url, process.env),
1793
+ promptAgents: (detected) => checkbox({
1794
+ message: "Install the skill for",
1795
+ choices: AGENTS.map((agent) => ({ value: agent, checked: detected.includes(agent) }))
1796
+ }),
1797
+ hostname,
1798
+ home: homedir2(),
1799
+ // `src/` in development and the bundled `dist/` both sit next to `skills/`.
1800
+ skillSource: fileURLToPath(new URL("../skills/planttaxomatcher", import.meta.url)),
1801
+ sleep: (ms) => sleep(ms),
1802
+ now: Date.now
1803
+ };
1804
+ }
1805
+
1806
+ // src/program.ts
1807
+ import { Command } from "commander";
1808
+ import kleur9 from "kleur";
1809
+
1810
+ // src/output.ts
1811
+ import kleur from "kleur";
1812
+ function createOutput(stdout, stderr, json, now = Date.now) {
1813
+ const human = json ? stderr : stdout;
1814
+ const line = (text) => human.write(`${text}
1815
+ `);
1816
+ return {
1817
+ json,
1818
+ info: line,
1819
+ success: (message) => line(`${kleur.green("\u2713")} ${message}`),
1820
+ warn: (message) => stderr.write(`${kleur.yellow("!")} ${message}
1821
+ `),
1822
+ data: (value) => stdout.write(`${JSON.stringify(value)}
1823
+ `),
1824
+ progress: createProgressWriter(human, now)
1825
+ };
1826
+ }
1827
+ var NON_TTY_PROGRESS_INTERVAL_MS = 5e3;
1828
+ function createProgressWriter(sink, now, intervalMs = NON_TTY_PROGRESS_INTERVAL_MS) {
1829
+ let width = 0;
1830
+ let lastEmittedAt = null;
1831
+ let pending = null;
1832
+ if (sink.isTTY) {
1833
+ return {
1834
+ update(line) {
1835
+ sink.write(`\r${line.padEnd(width)}`);
1836
+ width = line.length;
1837
+ },
1838
+ done() {
1839
+ if (width > 0) sink.write("\n");
1840
+ width = 0;
1841
+ }
1842
+ };
1843
+ }
1844
+ return {
1845
+ update(line) {
1846
+ const at = now();
1847
+ if (lastEmittedAt !== null && at - lastEmittedAt < intervalMs) {
1848
+ pending = line;
1849
+ return;
1850
+ }
1851
+ sink.write(`${line}
1852
+ `);
1853
+ lastEmittedAt = at;
1854
+ pending = null;
1855
+ },
1856
+ done() {
1857
+ if (pending !== null) sink.write(`${pending}
1858
+ `);
1859
+ pending = null;
1860
+ lastEmittedAt = null;
1861
+ }
1862
+ };
1863
+ }
1864
+
1865
+ // src/device-login.ts
1866
+ import kleur2 from "kleur";
1867
+ var RESTART_HINT = "Run `planttaxomatcher login` again.";
1868
+ var CLIENT_NAME_MAX = 100;
1869
+ function isTransient(err) {
1870
+ if (err instanceof ApiError) return err.status === 429 || err.status >= 500;
1871
+ return err instanceof CliError && err.message.startsWith("cannot reach");
1872
+ }
1873
+ async function pollOnce(deps2, server, deviceCode) {
1874
+ try {
1875
+ return await deps2.api.pollDeviceToken(server, deviceCode);
1304
1876
  } catch (err) {
1305
- if (err.code === "ENOENT") return null;
1877
+ if (isTransient(err)) return { error: "slow_down" };
1306
1878
  throw err;
1307
1879
  }
1308
1880
  }
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 });
1881
+ async function deviceLogin(deps2, out, server, options) {
1882
+ const clientName = deps2.hostname().trim().slice(0, CLIENT_NAME_MAX) || "unknown";
1883
+ const request = await deps2.api.startDeviceAuthorization(server, clientName);
1884
+ out.info(`To sign in, open this page and approve the code ${kleur2.bold(request.userCode)}:`);
1885
+ out.info(` ${kleur2.cyan(request.verificationUriComplete)}`);
1886
+ if (options.openBrowser && await deps2.openBrowser(request.verificationUriComplete)) {
1887
+ out.info(kleur2.gray("Opened it in your browser."));
1888
+ }
1889
+ out.info(kleur2.gray("Waiting for approval\u2026"));
1890
+ const deadline = deps2.now() + request.expiresIn * 1e3;
1891
+ let interval = request.interval;
1892
+ while (deps2.now() < deadline) {
1893
+ await deps2.sleep(interval * 1e3);
1894
+ const answer = await pollOnce(deps2, server, request.deviceCode);
1895
+ if ("token" in answer) return answer;
1896
+ if (answer.error === "slow_down") interval = answer.interval ?? interval + SLOW_DOWN_STEP_SECONDS;
1897
+ else if (answer.error === "access_denied")
1898
+ throw new CliError("the sign-in was denied in the browser", EXIT.auth);
1899
+ else if (answer.error === "expired_token") break;
1900
+ }
1901
+ throw new CliError("the sign-in code expired before it was approved", EXIT.auth, RESTART_HINT);
1902
+ }
1903
+
1904
+ // src/commands/auth.ts
1905
+ async function suppliedToken(opts, deps2) {
1906
+ if (opts.tokenStdin) {
1907
+ const token = (await deps2.readStdin()).trim();
1908
+ if (!token) throw new CliError("--token-stdin was set but stdin was empty", EXIT.usage);
1909
+ return token;
1910
+ }
1911
+ if (opts.token) return opts.token.trim();
1912
+ return tokenFromEnv(deps2.env) ?? null;
1913
+ }
1914
+ async function browserToken(deps2, out, server, opts) {
1915
+ if (parseEnvFlag(deps2.env.CI, false)) {
1916
+ throw new CliError(
1917
+ "no token given, and a browser sign-in cannot be approved in CI",
1918
+ EXIT.usage,
1919
+ "Set PLANTTAXOMATCHER_TOKEN, or pipe one to `planttaxomatcher login --token-stdin`."
1920
+ );
1921
+ }
1922
+ return (await deviceLogin(deps2, out, server, { openBrowser: opts.browser !== false })).token;
1312
1923
  }
1313
- async function clearCredentials() {
1924
+ async function verifyToken(deps2, server, token) {
1314
1925
  try {
1315
- await fs2.unlink(CONFIG_FILE);
1926
+ return await deps2.api.me({ server, token });
1316
1927
  } catch (err) {
1317
- if (err.code !== "ENOENT") throw err;
1928
+ if (err instanceof ApiError && err.status === 401) {
1929
+ throw new CliError(`${server} rejected this token (${err.message})`, EXIT.auth);
1930
+ }
1931
+ if (err instanceof ApiError && err.status === 403) return null;
1932
+ throw err;
1318
1933
  }
1319
1934
  }
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>");
1935
+ function registerAuthCommands(program, deps2) {
1936
+ program.command("login").description(
1937
+ "Sign in through the browser (approve a code on the web app), or save a personal token you pass in"
1938
+ ).option(
1939
+ "--token <token>",
1940
+ "Personal token (ptm_...). Avoid on shared hosts: it is visible in shell history and the process list. Prefer --token-stdin or PLANTTAXOMATCHER_TOKEN."
1941
+ ).option(
1942
+ "--token-stdin",
1943
+ "Read the token from stdin (e.g. `cat token.txt | planttaxomatcher login --token-stdin`)",
1944
+ false
1945
+ ).option("--server <url>", `API server URL (default: $PLANTTAXOMATCHER_SERVER, else ${DEFAULT_SERVER_URL})`).option(
1946
+ "--insecure",
1947
+ "Allow sending the token over cleartext http to a non-loopback server (NOT recommended)",
1948
+ false
1949
+ ).option("--no-browser", "Print the sign-in link without opening a browser").option("--json", "Print the result as JSON", false).action(async (opts) => {
1950
+ const out = createOutput(deps2.stdout, deps2.stderr, !!opts.json, deps2.now);
1951
+ const server = opts.server ?? serverFromEnv(deps2.env) ?? DEFAULT_SERVER_URL;
1952
+ assertServerTransport(server, { allowInsecure: !!opts.insecure, insecureFlag: true });
1953
+ const token = await suppliedToken(opts, deps2) ?? await browserToken(deps2, out, server, opts);
1954
+ if (!tokenStringSchema.safeParse(token).success) {
1955
+ throw new CliError("malformed token \u2014 expected ptm_<12 chars>_<32 chars>", EXIT.usage);
1956
+ }
1957
+ const me = await verifyToken(deps2, server, token);
1958
+ await deps2.store.write({ token, server, ...me?.appUrl ? { appUrl: me.appUrl } : {} });
1959
+ if (out.json) {
1960
+ out.data({ server, displayName: me?.displayName ?? null, scopes: me?.scopes ?? null });
1961
+ return;
1962
+ }
1963
+ out.success(me ? `Signed in to ${server} as ${me.displayName}` : `Signed in to ${server}`);
1964
+ if (!me) out.warn("this token cannot read jobs (no read:job scope)");
1965
+ });
1966
+ program.command("logout").description("Forget the saved token").action(async () => {
1967
+ await deps2.store.clear();
1968
+ createOutput(deps2.stdout, deps2.stderr, false).success("Logged out");
1969
+ });
1970
+ program.command("whoami").description("Show who the current token belongs to").option("--json", "Print the result as JSON", false).action(async (opts) => {
1971
+ const out = createOutput(deps2.stdout, deps2.stderr, !!opts.json, deps2.now);
1972
+ const creds = await requireCredentials(deps2.store, deps2.env);
1973
+ const me = await deps2.api.me(creds);
1974
+ if (out.json) {
1975
+ out.data({ ...me, server: creds.server });
1976
+ return;
1977
+ }
1978
+ out.info(`${me.displayName} team=${me.teamId} scopes=${me.scopes.join(",")}`);
1979
+ out.info(`server=${creds.server}`);
1980
+ });
1981
+ }
1982
+
1983
+ // src/commands/download.ts
1984
+ import { writeFile } from "fs/promises";
1985
+ import kleur3 from "kleur";
1986
+ var FORMATS = ["csv", "xlsx", "json", "ndjson"];
1987
+ var DELIMITERS = ["comma", "semicolon", "tab", "pipe"];
1988
+ function toDownloadOptions(opts) {
1989
+ const format = opts.format;
1990
+ if (!FORMATS.includes(format)) {
1991
+ throw new CliError(`--format must be ${FORMATS.join(", ")} (got ${opts.format})`, EXIT.usage);
1324
1992
  }
1325
- return creds;
1993
+ const delimiter = opts.delimiter;
1994
+ if (!DELIMITERS.includes(delimiter)) {
1995
+ throw new CliError(`--delimiter must be ${DELIMITERS.join(", ")} (got ${opts.delimiter})`, EXIT.usage);
1996
+ }
1997
+ return {
1998
+ format,
1999
+ confirmedOnly: opts.confirmedOnly,
2000
+ dedupe: opts.dedupe,
2001
+ bundle: opts.bundle,
2002
+ ...format === "csv" && delimiter !== "comma" ? { delimiter } : {},
2003
+ ...opts.columns ? { columns: opts.columns } : {},
2004
+ ...opts.wcvpExtra ? { wcvpExtra: opts.wcvpExtra } : {}
2005
+ };
2006
+ }
2007
+ function printColumns(out, columns) {
2008
+ out.info(kleur3.bold("Result columns (--columns):"));
2009
+ for (const column of columns.result) out.info(` ${column.key} ${kleur3.gray(`(${column.group})`)}`);
2010
+ if (columns.original.length > 0) {
2011
+ out.info(kleur3.bold("\nYour upload columns (--columns):"));
2012
+ for (const key of columns.original) out.info(` ${key}`);
2013
+ }
2014
+ out.info(kleur3.bold("\nWCVP extra fields (--wcvp-extra):"));
2015
+ for (const field of columns.wcvpExtra) out.info(` ${field.key} ${kleur3.gray(`(${field.group})`)}`);
2016
+ }
2017
+ function registerDownloadCommand(program, deps2) {
2018
+ program.command("download <jobId>").description("Download a job export (CSV, XLSX, JSON or NDJSON; optionally zipped with NOTICE.md)").option("--format <fmt>", FORMATS.join(" | "), "csv").option("--confirmed-only", "Only matched rows that are accepted / not pending", false).option("--dedupe", "Collapse rows that resolved to the same accepted taxon to one line", false).option("--delimiter <sep>", `CSV separator: ${DELIMITERS.join(" | ")} (csv only)`, "comma").option("--bundle", "Wrap in a ZIP with NOTICE.md citing the WCVP snapshot and providers", false).option(
2019
+ "--columns <list>",
2020
+ "Comma-separated result/upload column keys to keep (default: all). See --list-columns"
2021
+ ).option(
2022
+ "--wcvp-extra <list>",
2023
+ "Comma-separated extra WCVP fields appended as wcvp_<key> columns (e.g. ipni_id,powo_id). See --list-columns"
2024
+ ).option("--list-columns", "Print the columns available for this job and exit", false).option(
2025
+ "--output <path>",
2026
+ "Write to this path (default: the server-provided file name in the current directory)"
2027
+ ).option("--json", "Print the result (or --list-columns) as JSON", false).action(async (jobId, opts) => {
2028
+ const out = createOutput(deps2.stdout, deps2.stderr, opts.json, deps2.now);
2029
+ const download = toDownloadOptions(opts);
2030
+ const creds = await requireCredentials(deps2.store, deps2.env);
2031
+ if (opts.listColumns) {
2032
+ const columns = await deps2.api.downloadColumns(creds, jobId);
2033
+ if (out.json) return out.data(columns);
2034
+ return printColumns(out, columns);
2035
+ }
2036
+ const { filename, body } = await deps2.api.downloadJob(creds, jobId, download);
2037
+ const path = opts.output ?? filename;
2038
+ await writeFile(path, body);
2039
+ if (out.json) return out.data({ path, bytes: body.length, format: download.format });
2040
+ out.success(`wrote ${body.length} bytes to ${path}`);
2041
+ });
2042
+ }
2043
+
2044
+ // src/commands/jobs.ts
2045
+ import kleur5 from "kleur";
2046
+
2047
+ // src/flags.ts
2048
+ function parseIntegerFlag(value, flag, range = {}) {
2049
+ const parsed = Number(value);
2050
+ const { min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER } = range;
2051
+ if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
2052
+ const bounds = range.min !== void 0 || range.max !== void 0 ? ` between ${min} and ${max}` : "";
2053
+ throw new CliError(`${flag} must be an integer${bounds} (got "${value}")`, EXIT.usage);
2054
+ }
2055
+ return parsed;
2056
+ }
2057
+
2058
+ // src/watch.ts
2059
+ import kleur4 from "kleur";
2060
+ var STOP = /* @__PURE__ */ new Set(["completed", "failed", "cancelled", "paused"]);
2061
+ function isStop(status) {
2062
+ return typeof status === "string" && STOP.has(status);
2063
+ }
2064
+ function stopStatusOf(frame) {
2065
+ if (frame.type === "status" || frame.type === "completed") return isStop(frame.status) ? frame.status : null;
2066
+ if (frame.type === "error") return "failed";
2067
+ return null;
2068
+ }
2069
+ function progressLine(frame) {
2070
+ const processed = Number(frame.processedQueries ?? frame.processedRows ?? 0);
2071
+ const total = Number(frame.totalQueries ?? frame.totalRows ?? 0);
2072
+ const phase = processed === 0 && typeof frame.phase === "string" ? ` (${frame.phase}\u2026)` : "";
2073
+ return ` progress: ${processed}/${total}${phase}`;
2074
+ }
2075
+ function render(out, frame) {
2076
+ if (frame.type === "progress") {
2077
+ out.progress.update(progressLine(frame));
2078
+ return;
2079
+ }
2080
+ if (frame.type === "status") {
2081
+ out.progress.done();
2082
+ out.info(`${kleur4.gray("\u2022")} ${String(frame.status)}`);
2083
+ } else if (frame.type === "error") {
2084
+ out.progress.done();
2085
+ out.info(`${kleur4.red("error:")} ${String(frame.message ?? "job failed")}`);
2086
+ }
2087
+ }
2088
+ async function watchJob(api, out, creds, jobId) {
2089
+ out.progress.done();
2090
+ if (!out.json) out.info(`${kleur4.cyan("\u2192")} Streaming progress for ${jobId} \u2026`);
2091
+ for await (const frame of api.streamJob(creds, jobId)) {
2092
+ if (frame.type === "heartbeat") continue;
2093
+ const status = stopStatusOf(frame);
2094
+ if (out.json) out.data(frame);
2095
+ else if (!(status && frame.type === "status")) render(out, frame);
2096
+ if (status) return finish(out, status);
2097
+ }
2098
+ out.progress.done();
2099
+ const job = await api.getJob(creds, jobId);
2100
+ if (isStop(job.status)) return finish(out, job.status);
2101
+ throw new CliError(
2102
+ `the progress stream closed while job ${jobId} was still ${job.status}`,
2103
+ void 0,
2104
+ `Run \`planttaxomatcher watch ${jobId}\` to follow it again.`
2105
+ );
2106
+ }
2107
+ function finish(out, status) {
2108
+ out.progress.done();
2109
+ if (!out.json) {
2110
+ if (status === "completed") out.success("completed");
2111
+ else if (status === "paused") out.info(kleur4.yellow("paused \u2014 resume it, then watch again"));
2112
+ else out.info(kleur4.red(`\u2717 ${status}`));
2113
+ }
2114
+ return status;
1326
2115
  }
1327
2116
 
2117
+ // src/commands/jobs.ts
2118
+ function jobUrl(creds, jobId) {
2119
+ return new URL(`/jobs/${encodeURIComponent(jobId)}`, creds.appUrl ?? creds.server).toString();
2120
+ }
2121
+ function watchOutcomeError(results) {
2122
+ const stopped = results.filter((r) => r.status !== "completed");
2123
+ if (stopped.length === 0) return null;
2124
+ const list = stopped.map((r) => `${r.id} (${r.status})`).join(", ");
2125
+ const onlyPaused = stopped.every((r) => r.status === "paused");
2126
+ return new CliError(
2127
+ onlyPaused ? `job paused: ${list}` : `job did not complete: ${list}`,
2128
+ onlyPaused ? EXIT.jobPaused : EXIT.jobFailed,
2129
+ onlyPaused ? "Run `planttaxomatcher resume <jobId>`, then `planttaxomatcher watch <jobId>`." : void 0
2130
+ );
2131
+ }
2132
+ function listLine(job) {
2133
+ const matched = `${String(job.matchedRows).padStart(6)}/${String(job.totalRows).padStart(6)} matched`;
2134
+ return `${job.id} ${job.status.padEnd(10)} ${matched} ${kleur5.gray(job.name ?? "")}`;
2135
+ }
2136
+ var JOB_ACTIONS = {
2137
+ pause: { description: "Pause a running job (the worker stops between match queries)", past: "paused" },
2138
+ resume: { description: "Resume a paused job", past: "resumed" },
2139
+ cancel: { description: "Cancel a job. Rows already matched are kept; pending queries stop.", past: "cancelled" }
2140
+ };
2141
+ function runAction(deps2, action, creds, jobId) {
2142
+ if (action === "pause") return deps2.api.pauseJob(creds, jobId);
2143
+ if (action === "resume") return deps2.api.resumeJob(creds, jobId);
2144
+ return deps2.api.cancelJob(creds, jobId);
2145
+ }
2146
+ function registerJobCommands(program, deps2) {
2147
+ program.command("list").description("List recent jobs, newest first").option("--limit <n>", "How many jobs to show (1-100)", "20").option("--status <status>", `Only jobs in this status: ${jobStatusSchema.options.join(", ")}`).option("--json", "Print the jobs as a JSON array", false).action(async (opts) => {
2148
+ const out = createOutput(deps2.stdout, deps2.stderr, !!opts.json, deps2.now);
2149
+ const status = opts.status === void 0 ? void 0 : jobStatusSchema.safeParse(opts.status);
2150
+ if (status && !status.success) {
2151
+ throw new CliError(`--status must be one of ${jobStatusSchema.options.join(", ")}`, EXIT.usage);
2152
+ }
2153
+ const limit = parseIntegerFlag(opts.limit, "--limit", { min: 1, max: 100 });
2154
+ const creds = await requireCredentials(deps2.store, deps2.env);
2155
+ const jobs = await deps2.api.listJobs(creds, { limit, ...status ? { status: status.data } : {} });
2156
+ if (out.json) return out.data(jobs);
2157
+ if (jobs.length === 0) return out.info(kleur5.gray("No jobs."));
2158
+ for (const job of jobs) out.info(listLine(job));
2159
+ });
2160
+ program.command("status <jobId>").description("Print a job snapshot as JSON (counts, status, timestamps)").option("--json", "Accepted for symmetry: the output is always JSON", false).action(async (jobId) => {
2161
+ const creds = await requireCredentials(deps2.store, deps2.env);
2162
+ const job = await deps2.api.getJob(creds, jobId);
2163
+ deps2.stdout.write(`${JSON.stringify({ ...job, url: jobUrl(creds, job.id) }, null, 2)}
2164
+ `);
2165
+ });
2166
+ program.command("watch <jobId>").description("Follow a job until it stops. Exits 4 if it fails or is cancelled, 5 if it is paused.").option("--json", "Echo each progress frame as one NDJSON line", false).action(async (jobId, opts) => {
2167
+ const out = createOutput(deps2.stdout, deps2.stderr, !!opts.json, deps2.now);
2168
+ const creds = await requireCredentials(deps2.store, deps2.env);
2169
+ const status = await watchJob(deps2.api, out, creds, jobId);
2170
+ const failure = watchOutcomeError([{ id: jobId, status }]);
2171
+ if (failure) throw failure;
2172
+ });
2173
+ for (const [action, { description, past }] of Object.entries(JOB_ACTIONS)) {
2174
+ program.command(`${action} <jobId>`).description(description).option("--json", "Print the updated job as JSON", false).action(async (jobId, opts) => {
2175
+ const out = createOutput(deps2.stdout, deps2.stderr, !!opts.json, deps2.now);
2176
+ const creds = await requireCredentials(deps2.store, deps2.env);
2177
+ const job = await runAction(deps2, action, creds, jobId);
2178
+ if (out.json) return out.data(job);
2179
+ out.success(`${past}: ${job.id} (status=${job.status})`);
2180
+ });
2181
+ }
2182
+ }
2183
+
2184
+ // src/commands/skills.ts
2185
+ import kleur6 from "kleur";
2186
+
2187
+ // src/skills/manage.ts
2188
+ import { promises as fs3 } from "fs";
2189
+ import { dirname as dirname2, resolve } from "path";
2190
+
2191
+ // src/skills/content.ts
2192
+ import { createHash } from "crypto";
2193
+ import { promises as fs2 } from "fs";
2194
+ import { dirname, join as join3, relative } from "path";
2195
+ async function walk(dir, root = dir) {
2196
+ const found = [];
2197
+ for (const entry of await fs2.readdir(dir, { withFileTypes: true })) {
2198
+ const path = join3(dir, entry.name);
2199
+ if (entry.isDirectory()) found.push(...await walk(path, root));
2200
+ else if (entry.isFile() && entry.name !== MARKER_FILE) found.push(relative(root, path));
2201
+ }
2202
+ return found.sort();
2203
+ }
2204
+ async function renderSkill(sourceDir, version2) {
2205
+ const files = /* @__PURE__ */ new Map();
2206
+ for (const path of await walk(sourceDir)) {
2207
+ const text = await fs2.readFile(join3(sourceDir, path), "utf8");
2208
+ files.set(path, text.replaceAll("{{version}}", version2));
2209
+ }
2210
+ return files;
2211
+ }
2212
+ function hashFiles(files) {
2213
+ const hash = createHash("sha256");
2214
+ for (const [path, text] of [...files].sort(([a], [b]) => a.localeCompare(b))) {
2215
+ hash.update(path).update("\0").update(text).update("\0");
2216
+ }
2217
+ return hash.digest("hex");
2218
+ }
2219
+ async function readSkillDir(dir) {
2220
+ const files = /* @__PURE__ */ new Map();
2221
+ for (const path of await walk(dir)) files.set(path, await fs2.readFile(join3(dir, path), "utf8"));
2222
+ return files;
2223
+ }
2224
+ async function readMarker(dir) {
2225
+ try {
2226
+ const parsed = JSON.parse(await fs2.readFile(join3(dir, MARKER_FILE), "utf8"));
2227
+ return typeof parsed.version === "string" && typeof parsed.hash === "string" ? { version: parsed.version, hash: parsed.hash } : null;
2228
+ } catch {
2229
+ return null;
2230
+ }
2231
+ }
2232
+ async function writeSkillDir(dir, files, version2) {
2233
+ const staging = join3(dirname(dirname(dir)), `.planttaxomatcher-staging-${process.pid}-${Date.now()}`);
2234
+ try {
2235
+ for (const [path, text] of files) {
2236
+ await fs2.mkdir(dirname(join3(staging, path)), { recursive: true });
2237
+ await fs2.writeFile(join3(staging, path), text);
2238
+ }
2239
+ const marker = { version: version2, hash: hashFiles(files) };
2240
+ await fs2.writeFile(join3(staging, MARKER_FILE), `${JSON.stringify(marker, null, 2)}
2241
+ `);
2242
+ await fs2.rm(dir, { recursive: true, force: true });
2243
+ await fs2.mkdir(dirname(dir), { recursive: true });
2244
+ await fs2.rename(staging, dir);
2245
+ } finally {
2246
+ await fs2.rm(staging, { recursive: true, force: true });
2247
+ }
2248
+ }
2249
+ async function isPristine(dir) {
2250
+ const marker = await readMarker(dir);
2251
+ return !!marker && hashFiles(await readSkillDir(dir)) === marker.hash;
2252
+ }
2253
+
2254
+ // src/skills/manage.ts
2255
+ var symlink = (target, path) => fs3.symlink(target, path, "junction");
2256
+ function samePath(a, b) {
2257
+ const norm = (p) => resolve(p.replace(/^\\\\\?\\/, ""));
2258
+ return process.platform === "win32" ? norm(a).toLowerCase() === norm(b).toLowerCase() : norm(a) === norm(b);
2259
+ }
2260
+ var OUR_LAYOUT = /[/\\]planttaxomatcher[/\\]skills[/\\]planttaxomatcher$/;
2261
+ async function exists(path) {
2262
+ return fs3.stat(path).then(
2263
+ () => true,
2264
+ () => false
2265
+ );
2266
+ }
2267
+ async function classify(entry, canonical) {
2268
+ const stat = await fs3.lstat(entry).catch(() => null);
2269
+ if (!stat) return { kind: "missing" };
2270
+ if (stat.isSymbolicLink()) {
2271
+ const target = resolve(dirname2(entry), await fs3.readlink(entry));
2272
+ if (samePath(target, canonical)) return await exists(canonical) ? { kind: "link" } : { kind: "broken" };
2273
+ return OUR_LAYOUT.test(target) || await readMarker(target) ? { kind: "stale" } : { kind: "foreign" };
2274
+ }
2275
+ if (!stat.isDirectory()) return { kind: "foreign" };
2276
+ const marker = await readMarker(entry);
2277
+ return marker ? { kind: "copy", version: marker.version, pristine: await isPristine(entry) } : { kind: "foreign" };
2278
+ }
2279
+ async function classifyAll(paths) {
2280
+ const states = {};
2281
+ for (const dir of SKILL_DIRS) states[dir] = await classify(paths.entries[dir], paths.canonical);
2282
+ return states;
2283
+ }
2284
+ var isOurs = (state) => state.kind === "link" || state.kind === "copy" || state.kind === "stale";
2285
+ async function detectAgents(paths) {
2286
+ const found = [];
2287
+ for (const agent of AGENTS) if (await exists(paths.agentHomes[agent])) found.push(agent);
2288
+ return found;
2289
+ }
2290
+ async function writeCanonical(paths, files, version2, force) {
2291
+ const state = await fs3.lstat(paths.canonical).catch(() => null);
2292
+ if (state && !force) {
2293
+ const marker = await readMarker(paths.canonical);
2294
+ if (marker && isNewerVersion(marker.version, version2)) {
2295
+ throw new CliError(
2296
+ `the installed skill (${marker.version}) is newer than this CLI (${version2})`,
2297
+ EXIT.usage,
2298
+ "Run the newer CLI (`npx -y @plantnet/planttaxomatcher@latest skills install`), or pass --force."
2299
+ );
2300
+ }
2301
+ if (!marker || !await isPristine(paths.canonical)) {
2302
+ throw new CliError(
2303
+ `${paths.canonical} was edited or is not ours`,
2304
+ EXIT.usage,
2305
+ "Pass --force to replace it with the skill shipped with this CLI."
2306
+ );
2307
+ }
2308
+ }
2309
+ await writeSkillDir(paths.canonical, files, version2);
2310
+ }
2311
+ async function place(paths, dir, state, files, version2, options) {
2312
+ const entry = paths.entries[dir];
2313
+ if (state.kind === "foreign") return "skipped-foreign";
2314
+ if (state.kind === "link") return "kept";
2315
+ if (state.kind === "copy") {
2316
+ if (!state.pristine && !options.force) return "skipped-edited";
2317
+ await writeSkillDir(entry, files, version2);
2318
+ return "refreshed";
2319
+ }
2320
+ if (state.kind === "broken" || state.kind === "stale") await fs3.unlink(entry);
2321
+ await fs3.mkdir(dirname2(entry), { recursive: true });
2322
+ try {
2323
+ await options.link(paths.canonical, entry);
2324
+ return "linked";
2325
+ } catch {
2326
+ await writeSkillDir(entry, files, version2);
2327
+ return "copied";
2328
+ }
2329
+ }
2330
+ async function installSkill(paths, source, version2, agents, options = {}) {
2331
+ const opts = { force: !!options.force, link: options.link ?? symlink };
2332
+ const files = await renderSkill(source, version2);
2333
+ await writeCanonical(paths, files, version2, opts.force);
2334
+ const states = await classifyAll(paths);
2335
+ const linked = new Set(SKILL_DIRS.filter((dir) => isOurs(states[dir])));
2336
+ const planned = new Set(linkPlan(agents, linked));
2337
+ const entries = [];
2338
+ for (const dir of SKILL_DIRS) {
2339
+ const state = states[dir];
2340
+ if (!planned.has(dir) && !(state.kind === "copy" && state.pristine)) continue;
2341
+ entries.push({ dir, path: paths.entries[dir], action: await place(paths, dir, state, files, version2, opts) });
2342
+ }
2343
+ return { canonical: paths.canonical, entries };
2344
+ }
2345
+ async function uninstallSkill(paths, options = {}) {
2346
+ const entries = [];
2347
+ for (const [dir, state] of Object.entries(await classifyAll(paths))) {
2348
+ const path = paths.entries[dir];
2349
+ if (state.kind === "link" || state.kind === "broken" || state.kind === "stale") {
2350
+ await fs3.unlink(path);
2351
+ entries.push({ dir, path, action: "removed" });
2352
+ } else if (state.kind === "copy") {
2353
+ const removable = state.pristine || options.force;
2354
+ if (removable) await fs3.rm(path, { recursive: true, force: true });
2355
+ entries.push({ dir, path, action: removable ? "removed" : "skipped-edited" });
2356
+ }
2357
+ }
2358
+ const marker = await readMarker(paths.canonical);
2359
+ if (marker && (options.force || await isPristine(paths.canonical))) {
2360
+ await fs3.rm(paths.canonical, { recursive: true, force: true });
2361
+ }
2362
+ return { canonical: paths.canonical, entries };
2363
+ }
2364
+ async function skillStatus(paths) {
2365
+ const states = await classifyAll(paths);
2366
+ const marker = await readMarker(paths.canonical);
2367
+ const installed = new Set(await detectAgents(paths));
2368
+ return {
2369
+ canonical: {
2370
+ path: paths.canonical,
2371
+ version: marker?.version ?? null,
2372
+ pristine: marker ? await isPristine(paths.canonical) : false
2373
+ },
2374
+ entries: SKILL_DIRS.map((dir) => ({ dir, path: paths.entries[dir], ...states[dir] })),
2375
+ agents: AGENTS.map((agent) => {
2376
+ const loads = READS[agent].find((dir) => states[dir].kind !== "missing") ?? null;
2377
+ return { agent, installed: installed.has(agent), loads, state: loads ? states[loads].kind : "missing" };
2378
+ })
2379
+ };
2380
+ }
2381
+ async function refreshSkillIfOutdated(paths, source, version2) {
2382
+ try {
2383
+ const marker = await readMarker(paths.canonical);
2384
+ if (!marker || !isNewerVersion(version2, marker.version) || !await isPristine(paths.canonical)) return false;
2385
+ const files = await renderSkill(source, version2);
2386
+ await writeSkillDir(paths.canonical, files, version2);
2387
+ for (const [dir, state] of Object.entries(await classifyAll(paths))) {
2388
+ if (state.kind === "copy" && state.pristine) await writeSkillDir(paths.entries[dir], files, version2);
2389
+ }
2390
+ return true;
2391
+ } catch {
2392
+ return false;
2393
+ }
2394
+ }
2395
+
2396
+ // src/commands/skills.ts
2397
+ var AGENT_NAMES = { claude: "Claude Code", codex: "Codex", opencode: "OpenCode", pi: "pi" };
2398
+ var SERVES = {
2399
+ claude: "Claude Code (and OpenCode)",
2400
+ agents: "Codex, pi (and OpenCode)",
2401
+ opencode: "OpenCode",
2402
+ pi: "pi"
2403
+ };
2404
+ var ACTION_TEXT = {
2405
+ linked: "linked",
2406
+ copied: "copied (links are not available here)",
2407
+ kept: "already linked",
2408
+ refreshed: "copy refreshed",
2409
+ removed: "removed",
2410
+ "skipped-foreign": "left alone: a skill of the same name that is not ours",
2411
+ "skipped-edited": "left alone: edited by hand (use --force)"
2412
+ };
2413
+ var RELOAD_HINTS = {
2414
+ opencode: "Restart OpenCode to load it.",
2415
+ pi: "In pi, run /reload."
2416
+ };
2417
+ async function chooseAgents(deps2, requested, detected) {
2418
+ if (requested !== void 0) {
2419
+ const parsed = parseAgents(requested);
2420
+ if (!parsed) throw new CliError(`--agent takes ${AGENTS.join(", ")} or all (got "${requested}")`, EXIT.usage);
2421
+ return parsed === "all" ? [...AGENTS] : parsed;
2422
+ }
2423
+ if (deps2.stdinIsTTY) return deps2.promptAgents(detected);
2424
+ if (detected.length === 0) {
2425
+ throw new CliError(
2426
+ "no supported agent found in your home directory",
2427
+ EXIT.usage,
2428
+ `Pass --agent with any of ${AGENTS.join(", ")}, or all.`
2429
+ );
2430
+ }
2431
+ return detected;
2432
+ }
2433
+ function tilde(path, home) {
2434
+ return path === home || path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
2435
+ }
2436
+ function printEntries(out, result, home) {
2437
+ for (const { dir, path, action } of result.entries) {
2438
+ const line = `${SERVES[dir]}: ${ACTION_TEXT[action]} ${kleur6.gray(tilde(path, home))}`;
2439
+ if (action.startsWith("skipped")) out.warn(line);
2440
+ else out.success(line);
2441
+ }
2442
+ }
2443
+ function registerSkillsCommands(program, deps2, version2) {
2444
+ const skills = program.command("skills").description("Add the PlantTaxoMatcher skill to your AI coding agents (Claude Code, Codex, OpenCode, pi)");
2445
+ const paths = () => skillPaths(deps2.home, deps2.env);
2446
+ skills.command("install").description(
2447
+ "Install the skill for your user. The files live in one CLI-owned folder; each agent only gets a link to it."
2448
+ ).option(
2449
+ "--agent <list>",
2450
+ `Comma-separated: ${AGENTS.join(", ")}, or all (default: the agents found, or a prompt)`
2451
+ ).option("--force", "Replace an installed skill even if it was edited by hand", false).option("--json", "Print the result as JSON", false).action(async (opts) => {
2452
+ const out = createOutput(deps2.stdout, deps2.stderr, opts.json, deps2.now);
2453
+ const agents = await chooseAgents(deps2, opts.agent, await detectAgents(paths()));
2454
+ if (agents.length === 0) throw new CliError("no agent selected", EXIT.usage);
2455
+ const result = await installSkill(paths(), deps2.skillSource, version2, agents, { force: opts.force });
2456
+ if (out.json) return out.data({ version: version2, agents, ...result });
2457
+ out.info(
2458
+ `${kleur6.bold("planttaxomatcher")} skill ${version2} \u2192 ${kleur6.gray(tilde(result.canonical, deps2.home))}`
2459
+ );
2460
+ printEntries(out, result, deps2.home);
2461
+ for (const agent of agents) if (RELOAD_HINTS[agent]) out.info(kleur6.gray(RELOAD_HINTS[agent]));
2462
+ out.info(kleur6.gray(`Try it: ask your agent to "match the plant names in my CSV with PlantTaxoMatcher".`));
2463
+ });
2464
+ skills.command("uninstall").description("Remove the links and copies this CLI installed, then its own copy of the skill").option("--force", "Also remove copies edited by hand", false).option("--json", "Print the result as JSON", false).action(async (opts) => {
2465
+ const out = createOutput(deps2.stdout, deps2.stderr, opts.json, deps2.now);
2466
+ const result = await uninstallSkill(paths(), { force: opts.force });
2467
+ if (out.json) return out.data(result);
2468
+ if (result.entries.length === 0) out.info(kleur6.gray("No installed skill links found."));
2469
+ printEntries(out, result, deps2.home);
2470
+ });
2471
+ skills.command("status").description("Show where the skill is installed and which copy each agent loads").option("--json", "Print the status as JSON", false).action(async (opts) => {
2472
+ const out = createOutput(deps2.stdout, deps2.stderr, opts.json, deps2.now);
2473
+ const status = await skillStatus(paths());
2474
+ if (out.json) return out.data({ cliVersion: version2, ...status });
2475
+ const { canonical } = status;
2476
+ out.info(
2477
+ canonical.version ? `Skill ${canonical.version}${canonical.pristine ? "" : " (edited)"} at ${kleur6.gray(tilde(canonical.path, deps2.home))}` : kleur6.gray("Not installed. Run `planttaxomatcher skills install`.")
2478
+ );
2479
+ for (const entry of status.entries) {
2480
+ if (entry.kind !== "missing")
2481
+ out.info(` ${entry.kind.padEnd(8)} ${kleur6.gray(tilde(entry.path, deps2.home))}`);
2482
+ }
2483
+ for (const { agent, installed, loads, state } of status.agents) {
2484
+ const entry = status.entries.find((e) => e.dir === loads);
2485
+ const where = entry ? `${state} in ${tilde(entry.path, deps2.home)}` : "no skill";
2486
+ out.info(` ${AGENT_NAMES[agent].padEnd(12)} ${installed ? where : kleur6.gray(`not found (${where})`)}`);
2487
+ }
2488
+ });
2489
+ }
2490
+
2491
+ // src/commands/submit.ts
2492
+ import { parse as parsePath } from "path";
2493
+ import kleur8 from "kleur";
2494
+
1328
2495
  // src/dry-run.ts
1329
- import { promises as fs3, createReadStream } from "fs";
2496
+ import { promises as fs4, createReadStream } from "fs";
1330
2497
  import Papa from "papaparse";
1331
- import kleur from "kleur";
2498
+ import kleur7 from "kleur";
1332
2499
  async function readSample(file, sampleLimit) {
1333
2500
  const lower = file.toLowerCase();
1334
2501
  if (lower.endsWith(".json")) {
1335
- const text = await fs3.readFile(file, "utf8");
2502
+ const text = await fs4.readFile(file, "utf8");
1336
2503
  const arr = JSON.parse(text);
1337
2504
  if (!Array.isArray(arr)) throw new Error("JSON file must be an array of row objects");
1338
2505
  const out = [];
@@ -1346,13 +2513,13 @@ async function readSample(file, sampleLimit) {
1346
2513
  }
1347
2514
  return out;
1348
2515
  }
1349
- return await new Promise((resolve, reject) => {
2516
+ return await new Promise((resolve2, reject) => {
1350
2517
  const out = [];
1351
2518
  let done = false;
1352
- const finish = () => {
2519
+ const finish2 = () => {
1353
2520
  if (done) return;
1354
2521
  done = true;
1355
- resolve(out);
2522
+ resolve2(out);
1356
2523
  };
1357
2524
  const stream = createReadStream(file);
1358
2525
  const parseStream = Papa.parse(Papa.NODE_STREAM_INPUT, {
@@ -1370,10 +2537,10 @@ async function readSample(file, sampleLimit) {
1370
2537
  out.push(o);
1371
2538
  if (out.length >= sampleLimit) {
1372
2539
  stream.destroy();
1373
- finish();
2540
+ finish2();
1374
2541
  }
1375
2542
  });
1376
- parseStream.on("end", finish);
2543
+ parseStream.on("end", finish2);
1377
2544
  stream.pipe(parseStream);
1378
2545
  });
1379
2546
  }
@@ -1391,7 +2558,7 @@ async function buildDryRunReport(file, opts) {
1391
2558
  };
1392
2559
  }
1393
2560
  if (!(opts.nameColumn in rows[0])) {
1394
- throw new Error(`name column "${opts.nameColumn}" not present in file header`);
2561
+ throw new CliError(`name column "${opts.nameColumn}" not present in file header`, EXIT.usage);
1395
2562
  }
1396
2563
  let nullNameCount = 0;
1397
2564
  let qualifierFlagCount = 0;
@@ -1431,352 +2598,241 @@ async function buildDryRunReport(file, opts) {
1431
2598
  idTypeDetection
1432
2599
  };
1433
2600
  }
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`
2601
+ function printDryRunReport(report, log) {
2602
+ log("");
2603
+ log(kleur7.bold("Dry-run preview"));
2604
+ log(
2605
+ ` Read ${kleur7.cyan(report.rowsRead)} rows \xB7 ${kleur7.yellow(report.nullNameCount)} with no usable name \xB7 ${kleur7.yellow(report.qualifierFlagCount)} with qualifier flags`
1439
2606
  );
1440
2607
  if (report.idTypeDetection) {
1441
2608
  const d = report.idTypeDetection;
1442
2609
  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
- );
2610
+ log(` ID-column detection: ${kleur7.green(d.dominant ?? "\u2014")} (${pct}% of ${d.sampleSize} sampled)`);
1446
2611
  if (d.minorityExamples.length > 0) {
1447
- console.log(` Minority examples:`);
2612
+ log(` Minority examples:`);
1448
2613
  for (const m of d.minorityExamples) {
1449
- console.log(
1450
- ` row ${m.rowIndex + 1}: ${kleur.gray(m.value)} \u2192 ${kleur.dim(m.type)}`
1451
- );
2614
+ log(` row ${m.rowIndex + 1}: ${kleur7.gray(m.value)} \u2192 ${kleur7.dim(m.type)}`);
1452
2615
  }
1453
2616
  }
1454
2617
  }
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
- );
2618
+ log("");
2619
+ log(kleur7.bold("First rows after normalization:"));
2620
+ log(` ${"#".padStart(4)} ${"input".padEnd(36)} ${"normalized".padEnd(36)} flags`);
1460
2621
  for (const r of report.sampleRows) {
1461
2622
  const idx = String(r.rowIndex + 1).padStart(4);
1462
2623
  const inp = trunc(r.input ?? "\u2205", 36).padEnd(36);
1463
2624
  const norm = trunc(r.normalized ?? "\u2205", 36).padEnd(36);
1464
- console.log(` ${idx} ${inp} ${norm} ${r.flags.join(" ") || ""}`);
2625
+ log(` ${idx} ${inp} ${norm} ${r.flags.join(" ") || ""}`);
1465
2626
  }
1466
- console.log();
2627
+ log("");
1467
2628
  }
1468
2629
  function trunc(s, n) {
1469
2630
  if (s.length <= n) return s;
1470
2631
  return s.slice(0, n - 1) + "\u2026";
1471
2632
  }
1472
2633
 
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
2634
+ // src/inputs.ts
2635
+ import { promises as fs5 } from "fs";
2636
+ var GLOB_MAGIC = /[*?[\]{}!()]/;
2637
+ async function expandInputs(patterns) {
2638
+ const files = /* @__PURE__ */ new Set();
2639
+ for (const pattern of patterns) {
2640
+ if (GLOB_MAGIC.test(pattern)) {
2641
+ let matched = false;
2642
+ for await (const file of fs5.glob(pattern)) {
2643
+ files.add(file);
2644
+ matched = true;
2645
+ }
2646
+ if (!matched) throw new CliError(`no files matched: ${pattern}`, EXIT.usage);
2647
+ } else {
2648
+ await fs5.access(pattern).catch(() => {
2649
+ throw new CliError(`file not found: ${pattern}`, EXIT.usage);
1569
2650
  });
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;
2651
+ files.add(pattern);
1579
2652
  }
1580
2653
  }
2654
+ return [...files].sort();
2655
+ }
2656
+
2657
+ // src/job-config.ts
2658
+ import "zod";
2659
+ function buildJobConfig(opts) {
1581
2660
  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"),
2661
+ nameColumn: opts.nameColumn,
2662
+ idColumn: opts.idColumn ?? null,
2663
+ idType: opts.idType,
2664
+ familyColumn: opts.familyColumn ?? null,
2665
+ genusColumn: opts.genusColumn ?? null,
2666
+ rankColumn: opts.rankColumn ?? null,
2667
+ authorColumn: opts.authorColumn ?? null,
2668
+ filterColumn: opts.filterColumn ?? null,
2669
+ filterValue: opts.filterValue ?? null,
2670
+ authorMode: opts.authorMode,
1591
2671
  matchAuthors: true,
1592
- parallelism: Number(opts.parallel ?? 4),
2672
+ parallelism: parseIntegerFlag(opts.parallel, "--parallel"),
1593
2673
  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,
2674
+ allowLlm: opts.allowLlm,
2675
+ llmCostCapCents: parseIntegerFlag(opts.llmCapCents, "--llm-cap-cents"),
2676
+ reviewMode: opts.reviewMode,
2677
+ speciesLevelAcceptedOnly: opts.speciesLevel,
2678
+ acceptUnconfirmedAuthor: opts.ignoreAuthor,
1603
2679
  exportConfirmedOnly: false,
1604
- forceReviewFamilies: [],
1605
- ...opts.referential ? { referentialVersion: String(opts.referential) } : {}
2680
+ ...opts.referential ? { referentialVersion: opts.referential } : {}
1606
2681
  };
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}`);
1695
- }
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
- );
2682
+ const checked = jobConfigSchema.safeParse(config);
2683
+ if (!checked.success) {
2684
+ const issue = checked.error.issues[0];
2685
+ const field = issue?.path.join(".") ?? "config";
2686
+ throw new CliError(`invalid ${field}: ${issue?.message ?? "rejected"}`, EXIT.usage);
1706
2687
  }
2688
+ return config;
1707
2689
  }
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();
2690
+
2691
+ // src/commands/submit.ts
2692
+ function summarize(submitted, finals, creds) {
2693
+ return submitted.map(({ file, job }) => ({
2694
+ id: job.id,
2695
+ name: job.name,
2696
+ file,
2697
+ status: finals.find((f) => f.id === job.id)?.status ?? job.status,
2698
+ totalRows: job.totalRows,
2699
+ uniqueQueries: job.uniqueQueries,
2700
+ url: jobUrl(creds, job.id)
2701
+ }));
1712
2702
  }
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;
2703
+ function jobNameFor(file, fileCount, name) {
2704
+ return fileCount === 1 && name?.trim() ? name.trim() : parsePath(file).name;
2705
+ }
2706
+ async function confirmDryRun(deps2, out, files, opts) {
2707
+ const previewRows = Number(opts.dryRunRows);
2708
+ for (const file of files) {
2709
+ if (files.length > 1) out.info(kleur8.bold(`
2710
+ ${file}`));
2711
+ const report = await buildDryRunReport(file, {
2712
+ nameColumn: opts.nameColumn,
2713
+ idColumn: opts.idColumn ?? null,
2714
+ previewRows: Number.isFinite(previewRows) ? previewRows : 10,
2715
+ sampleLimit: 1e3
2716
+ });
2717
+ printDryRunReport(report, out.info);
1718
2718
  }
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.");
2719
+ if (opts.yes) return true;
2720
+ if (!deps2.stdinIsTTY) {
2721
+ throw new CliError("--dry-run needs a confirmation", EXIT.usage, "Pass --yes to upload after the preview.");
1724
2722
  }
1725
- const entered = await password({ message: "Personal token (ptm_...)", mask: true });
1726
- return entered.trim();
2723
+ const message = files.length > 1 ? `Proceed with upload of ${files.length} files?` : "Proceed with upload?";
2724
+ return deps2.promptConfirm(message);
1727
2725
  }
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);
2726
+ function registerSubmitCommand(program, deps2) {
2727
+ program.command("submit <files...>").description(
2728
+ 'Submit CSV/XLSX/JSON files for matching, one job per file (named after the file). Accepts shell-expanded paths or quoted globs such as "data/*.csv". Follows progress until the jobs stop unless --no-watch; exits 4 if one fails, 5 if one is paused.'
2729
+ ).option("--name <label>", "Job name (single file only; with several files each job is named after its file)").requiredOption("--name-column <name>", "Column holding the scientific name").option("--id-column <name>", "Column holding a known ID").option("--family-column <name>", "Column holding family").option("--genus-column <name>", "Column holding genus").option("--rank-column <name>", "Column holding rank").option("--author-column <name>", "Column holding authorship").option(
2730
+ "--filter-column <name>",
2731
+ "Only process rows where this column matches --filter-value; others are skipped"
2732
+ ).option("--filter-value <value>", "Value the --filter-column must equal (trimmed, case-insensitive)").option("--author-mode <mode>", "ignore|prefer|strict", "prefer").option("--parallel <n>", "Per-job parallelism (1-10)", "4").option("--review-mode <mode>", "off|recommended|strict", "recommended").option(
2733
+ "--id-type <type>",
2734
+ "What --id-column holds: auto (backbone id, else GBIF key for bare integers), wcvp, gbif",
2735
+ "auto"
2736
+ ).option(
2737
+ "--species-level",
2738
+ "Roll infraspecific accepted taxa (varieties, subspecies, forms) up to their species",
2739
+ false
2740
+ ).option(
2741
+ "--ignore-author",
2742
+ "Accept a unique canonical name match whose author couldn't be confirmed instead of sending it to review (a CONFLICTING author still reviews)",
2743
+ false
2744
+ ).option("--keep-infraspecific", "Deprecated \u2014 now the default; has no effect", false).option("--allow-llm", "Allow the Layer 7 LLM to break ties between competing matches", false).option("--llm-cap-cents <cents>", "Max LLM spend in cents", "500").option(
2745
+ "--referential <version>",
2746
+ "WCVP snapshot version to match against (default: your team's default snapshot, else the newest import)"
2747
+ ).option("--no-watch", "Return as soon as the jobs are created").option("--dry-run", "Preview the first rows locally, then confirm before uploading", false).option("--dry-run-rows <n>", "Rows to show in the dry-run preview", "10").option("-y, --yes", "Skip the --dry-run confirmation (needed without a terminal)", false).option("--json", "Print the created jobs as a JSON array (with --watch: once they end)", false).action(async (patterns, opts) => {
2748
+ const out = createOutput(deps2.stdout, deps2.stderr, opts.json, deps2.now);
2749
+ const config = buildJobConfig(opts);
2750
+ const creds = await requireCredentials(deps2.store, deps2.env);
2751
+ const files = await expandInputs(patterns);
2752
+ if (opts.name && files.length > 1)
2753
+ out.warn("--name ignored for a multi-file submit; each job is named after its file");
2754
+ if (opts.keepInfraspecific) {
2755
+ out.warn(
2756
+ "--keep-infraspecific is deprecated and does nothing \u2014 pass --species-level to roll up to the species"
2757
+ );
1744
2758
  }
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;
2759
+ if (files.length > 1) {
2760
+ out.info(`${kleur8.cyan("\u2192")} ${files.length} files matched:`);
2761
+ for (const file of files) out.info(kleur8.gray(` ${file}`));
2762
+ }
2763
+ if (opts.dryRun && !await confirmDryRun(deps2, out, files, opts)) {
2764
+ out.info(kleur8.gray("Aborted."));
2765
+ return;
2766
+ }
2767
+ const submitted = [];
2768
+ const finals = [];
2769
+ try {
2770
+ for (const file of files) {
2771
+ const name = jobNameFor(file, files.length, opts.name);
2772
+ out.info(`${kleur8.cyan("\u2192")} Uploading ${file} \u2026`);
2773
+ const job = await deps2.api.submitJob(creds, file, config, name || null);
2774
+ out.success(`Job created: ${job.id} ${kleur8.gray(name)}`);
2775
+ out.info(` rows=${job.totalRows} uniqueQueries=${job.uniqueQueries}`);
2776
+ submitted.push({ file, job });
1760
2777
  }
1761
- if (status === "failed" || status === "cancelled") {
1762
- console.error(kleur2.red(status));
1763
- return;
2778
+ if (opts.watch) {
2779
+ const watchOut = opts.json ? createOutput(deps2.stderr, deps2.stderr, false, deps2.now) : out;
2780
+ for (const { file, job } of submitted) {
2781
+ if (submitted.length > 1) watchOut.info(kleur8.bold(`
2782
+ [${file}] ${job.id}`));
2783
+ finals.push({ id: job.id, status: await watchJob(deps2.api, watchOut, creds, job.id) });
2784
+ }
1764
2785
  }
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;
2786
+ } finally {
2787
+ if (opts.json) out.data(summarize(submitted, finals, creds));
1780
2788
  }
2789
+ const failure = watchOutcomeError(finals);
2790
+ if (failure) throw failure;
2791
+ });
2792
+ }
2793
+
2794
+ // src/program.ts
2795
+ var EXIT_CODES_HELP = `
2796
+ Exit codes:
2797
+ 0 success
2798
+ 1 error (API or network failure)
2799
+ 2 usage error (bad flag or argument)
2800
+ 3 not signed in, or the token was rejected
2801
+ 4 a watched job ended failed or cancelled
2802
+ 5 a watched job was paused (resume it, then watch again)
2803
+
2804
+ Environment:
2805
+ PLANTTAXOMATCHER_TOKEN token to use instead of the saved login
2806
+ PLANTTAXOMATCHER_SERVER its server (default: the public server); alone, it
2807
+ must match the saved login's server`;
2808
+ function buildProgram(deps2, version2) {
2809
+ const program = new Command().name("planttaxomatcher").description("Reconcile plant names against WCVP with the PlantTaxoMatcher API").version(version2).addHelpText("after", EXIT_CODES_HELP).exitOverride().configureOutput({
2810
+ writeOut: (text) => deps2.stdout.write(text),
2811
+ writeErr: (text) => deps2.stderr.write(text)
2812
+ });
2813
+ registerAuthCommands(program, deps2);
2814
+ registerJobCommands(program, deps2);
2815
+ registerSubmitCommand(program, deps2);
2816
+ registerDownloadCommand(program, deps2);
2817
+ registerSkillsCommands(program, deps2, version2);
2818
+ return program;
2819
+ }
2820
+ async function runCli(argv, deps2, version2) {
2821
+ try {
2822
+ await buildProgram(deps2, version2).parseAsync(argv, { from: "user" });
2823
+ return 0;
2824
+ } catch (err) {
2825
+ const { exitCode, message, hint } = describeError(err);
2826
+ if (message) deps2.stderr.write(`${kleur9.red("error:")} ${message}
2827
+ `);
2828
+ if (hint) deps2.stderr.write(`${kleur9.gray(hint)}
2829
+ `);
2830
+ return exitCode;
1781
2831
  }
1782
2832
  }
2833
+
2834
+ // src/index.ts
2835
+ var { version } = createRequire(import.meta.url)("../package.json");
2836
+ var deps = defaultDeps();
2837
+ await refreshSkillIfOutdated(skillPaths(deps.home, deps.env), deps.skillSource, version);
2838
+ process.exitCode = await runCli(process.argv.slice(2), deps, version);