ymmv-cli 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +1 -0
  2. package/dist/cli.js +233 -140
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -25,6 +25,7 @@ First run includes a one-time GitHub sign-in (the device flow you know from `gh`
25
25
  - `ymmv <handle>` — view a profile, or diff it against yours when you're logged in
26
26
  - `ymmv set editor Neovim` — change one value
27
27
  - `ymmv set --extra "Keyboard=HHKB"` — add a free-form line of your own
28
+ - `ymmv unset editor` — remove one value (`ymmv set editor -` works too); `ymmv unset --extra "Keyboard"` removes an extra
28
29
  - `ymmv delete` — remove your profile
29
30
  - `ymmv login` / `ymmv logout` — sign in / out
30
31
 
package/dist/cli.js CHANGED
@@ -284,7 +284,7 @@ function parseProfile(raw) {
284
284
 
285
285
  // ../shared/dist/reserved.js
286
286
  var RESERVED_ROUTES = ["api", "login", "logout"];
287
- var CLI_VERBS = ["login", "logout", "set", "delete", "view", "help"];
287
+ var CLI_VERBS = ["login", "logout", "set", "unset", "delete", "view", "help"];
288
288
  var RESERVED = [.../* @__PURE__ */ new Set([...RESERVED_ROUTES, ...CLI_VERBS])];
289
289
  var RESERVED_SET = new Set(RESERVED);
290
290
  var HANDLE_RE = /^[a-zA-Z0-9](?:-?[a-zA-Z0-9])*$/;
@@ -440,6 +440,136 @@ async function login(deps = {}) {
440
440
  );
441
441
  }
442
442
 
443
+ // src/render.ts
444
+ var ESC = String.fromCharCode(27);
445
+ var CSI = `${ESC}[`;
446
+ var CODES = {
447
+ amber: `${CSI}93m`,
448
+ // DESIGN: amber == ANSI bright-yellow
449
+ faint: `${CSI}90m`,
450
+ bold: `${CSI}1m`,
451
+ reset: `${CSI}0m`
452
+ };
453
+ var NO_CODES = { amber: "", faint: "", bold: "", reset: "" };
454
+ function palette(color) {
455
+ return color ? CODES : NO_CODES;
456
+ }
457
+ var ESC_INTRODUCERS = `${String.fromCharCode(27)}${String.fromCharCode(155)}`;
458
+ var BEL = String.fromCharCode(7);
459
+ var ANSI_RE = new RegExp(
460
+ `[${ESC_INTRODUCERS}][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d/#&.:=?%@~_]*)*)?${BEL})|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))`,
461
+ "g"
462
+ );
463
+ var CTRL_RE = new RegExp(
464
+ `[${String.fromCharCode(0)}-${String.fromCharCode(31)}${String.fromCharCode(127)}-${String.fromCharCode(159)}]`,
465
+ "g"
466
+ );
467
+ var BIDI_RE = new RegExp(
468
+ `[${String.fromCharCode(8234)}-${String.fromCharCode(8238)}${String.fromCharCode(8294)}-${String.fromCharCode(8297)}${String.fromCharCode(8206)}${String.fromCharCode(8207)}]`,
469
+ "g"
470
+ );
471
+ function sanitizeValue(value) {
472
+ return value.replace(ANSI_RE, "").replace(CTRL_RE, "").replace(BIDI_RE, "");
473
+ }
474
+ function useColor(env, isTTY) {
475
+ if (env.NO_COLOR !== void 0) return false;
476
+ if (env.FORCE_COLOR !== void 0) return env.FORCE_COLOR !== "0";
477
+ return isTTY;
478
+ }
479
+ function orderedEntries(profile) {
480
+ const byKey = new Map(
481
+ (profile.entries ?? []).map((e) => [e.key, e.value])
482
+ );
483
+ return CURATED_KEYS.flatMap((key) => {
484
+ const value = byKey.get(key);
485
+ return value === void 0 ? [] : [{ label: KEY_LABELS[key], value: sanitizeValue(value) }];
486
+ });
487
+ }
488
+ function renderProfile(profile, opts) {
489
+ const c = palette(opts.color);
490
+ const rows = orderedEntries(profile);
491
+ const extras = (profile.extras ?? []).map((x) => ({
492
+ label: sanitizeValue(x.label),
493
+ value: sanitizeValue(x.value)
494
+ }));
495
+ const labelW = Math.max(
496
+ 0,
497
+ ...rows.map((r) => r.label.length),
498
+ ...extras.map((x) => x.label.length)
499
+ );
500
+ const lines = ["", ` ${c.bold}${sanitizeValue(profile.handle)}${c.reset}`, ""];
501
+ for (const r of rows) {
502
+ lines.push(` ${c.faint}${r.label.padEnd(labelW)}${c.reset} ${r.value}`);
503
+ }
504
+ if (extras.length) {
505
+ lines.push("");
506
+ for (const x of extras) {
507
+ lines.push(` ${c.faint}${x.label.padEnd(labelW)}${c.reset} ${x.value}`);
508
+ }
509
+ }
510
+ lines.push("", ` ${c.faint}updated ${sanitizeValue(profile.updated_at)}${c.reset}`, "");
511
+ return lines.join("\n");
512
+ }
513
+ var MISSING = "\u2014";
514
+ function extrasBlock(extras, theirsLabel, mineLabel, c) {
515
+ if (!extras.theirs.length && !extras.mine.length) return [];
516
+ const out = ["", ` ${c.faint}extras${c.reset}`];
517
+ const line = (who, label, value) => ` ${c.faint}${who}${c.reset} ${sanitizeValue(label)} = ${sanitizeValue(value)}`;
518
+ for (const x of extras.theirs) out.push(line(theirsLabel, x.label, x.value));
519
+ for (const x of extras.mine) out.push(line(mineLabel, x.label, x.value));
520
+ return out;
521
+ }
522
+ function renderDiff(result, opts) {
523
+ const c = palette(opts.color);
524
+ const theirsLabel = sanitizeValue(opts.theirsLabel);
525
+ const mineLabel = sanitizeValue(opts.mineLabel);
526
+ const cells = result.rows.map((r) => ({
527
+ label: r.label,
528
+ theirs: r.theirs === null ? MISSING : sanitizeValue(r.theirs),
529
+ mine: r.mine === null ? MISSING : sanitizeValue(r.mine),
530
+ differ: r.status !== "same"
531
+ }));
532
+ const labelW = Math.max(3, ...cells.map((r) => r.label.length));
533
+ const theirsW = Math.max(theirsLabel.length, ...cells.map((r) => r.theirs.length));
534
+ const lines = [""];
535
+ lines.push(
536
+ ` ${c.faint}${"".padEnd(labelW)} ${theirsLabel.padEnd(theirsW)} ${mineLabel}${c.reset}`
537
+ );
538
+ for (const r of cells) {
539
+ if (!opts.color) {
540
+ const sym = r.differ ? "~" : "=";
541
+ lines.push(`${sym} ${r.label.padEnd(labelW)} ${r.theirs.padEnd(theirsW)} ${r.mine}`);
542
+ } else if (r.differ) {
543
+ lines.push(
544
+ `${c.amber}\u2022${c.reset} ${r.label.padEnd(labelW)} ${c.amber}${r.theirs.padEnd(theirsW)}${c.reset} ${c.amber}${r.mine}${c.reset}`
545
+ );
546
+ } else {
547
+ lines.push(
548
+ ` ${c.faint}${r.label.padEnd(labelW)} ${r.theirs.padEnd(theirsW)} ${r.mine}${c.reset}`
549
+ );
550
+ }
551
+ }
552
+ lines.push(...extrasBlock(result.extras, theirsLabel, mineLabel, c));
553
+ lines.push(
554
+ "",
555
+ ` ${c.faint}${result.differ} differ \xB7 ${result.shared} shared \u2014 your mileage may vary${c.reset}`,
556
+ ""
557
+ );
558
+ return lines.join("\n");
559
+ }
560
+ function nudge(color) {
561
+ const c = palette(color);
562
+ return `
563
+ ${c.amber}publish yours to diff \u2192${c.reset} run ${c.bold}ymmv${c.reset}
564
+ `;
565
+ }
566
+ function notFound(handle) {
567
+ return `
568
+ no ymmv profile for "${sanitizeValue(handle)}" yet.
569
+ publish one at ymmv.fyi with: npx ymmv-cli
570
+ `;
571
+ }
572
+
443
573
  // src/api.ts
444
574
  async function rateLimitMessage(res) {
445
575
  const retry = res.headers.get("retry-after");
@@ -470,6 +600,11 @@ async function publishProfile(profile) {
470
600
  // a mutation must never follow a redirect into a false success
471
601
  });
472
602
  let cred = await ensureLogin();
603
+ if ((cred.handle ?? "").toLowerCase() !== profile.handle.toLowerCase()) {
604
+ throw new Error(
605
+ "the stored login changed while this command was running \u2014 re-run it under the current account."
606
+ );
607
+ }
473
608
  let res = await send(cred);
474
609
  if (res.status === 401 || res.status === 409) {
475
610
  if (res.status === 401) await deleteToken();
@@ -488,7 +623,8 @@ async function publishProfile(profile) {
488
623
  throw new Error(`publish failed: ${res.status} ${await res.text()}`);
489
624
  }
490
625
  const data = await res.json();
491
- console.log(`Published ${data.handle} -> ${BASE}/${data.handle}`);
626
+ const shown = typeof data.handle === "string" ? sanitizeValue(data.handle) : profile.handle;
627
+ console.log(`Published ${shown} -> ${BASE}/${shown}`);
492
628
  }
493
629
  async function fetchProfileJson(handle) {
494
630
  const res = await fetch(`${BASE}/api/v1/u/${encodeURIComponent(handle)}`);
@@ -625,142 +761,34 @@ function applySet(existing, target) {
625
761
  if (i >= 0) entries[i] = next;
626
762
  else entries.push(next);
627
763
  } else {
628
- const i = extras.findIndex((x) => x.label.toLowerCase() === target.label.toLowerCase());
764
+ const wanted = target.label.trim().toLowerCase();
765
+ const i = extras.findIndex((x) => x.label.trim().toLowerCase() === wanted);
629
766
  const next = { label: target.label, value: target.value };
630
767
  if (i >= 0) extras[i] = next;
631
768
  else extras.push(next);
632
769
  }
633
770
  return { entries, extras };
634
771
  }
635
-
636
- // src/render.ts
637
- var ESC = String.fromCharCode(27);
638
- var CSI = `${ESC}[`;
639
- var CODES = {
640
- amber: `${CSI}93m`,
641
- // DESIGN: amber == ANSI bright-yellow
642
- faint: `${CSI}90m`,
643
- bold: `${CSI}1m`,
644
- reset: `${CSI}0m`
645
- };
646
- var NO_CODES = { amber: "", faint: "", bold: "", reset: "" };
647
- function palette(color) {
648
- return color ? CODES : NO_CODES;
649
- }
650
- var ESC_INTRODUCERS = `${String.fromCharCode(27)}${String.fromCharCode(155)}`;
651
- var BEL = String.fromCharCode(7);
652
- var ANSI_RE = new RegExp(
653
- `[${ESC_INTRODUCERS}][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d/#&.:=?%@~_]*)*)?${BEL})|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))`,
654
- "g"
655
- );
656
- var CTRL_RE = new RegExp(
657
- `[${String.fromCharCode(0)}-${String.fromCharCode(31)}${String.fromCharCode(127)}-${String.fromCharCode(159)}]`,
658
- "g"
659
- );
660
- var BIDI_RE = new RegExp(
661
- `[${String.fromCharCode(8234)}-${String.fromCharCode(8238)}${String.fromCharCode(8294)}-${String.fromCharCode(8297)}${String.fromCharCode(8206)}${String.fromCharCode(8207)}]`,
662
- "g"
663
- );
664
- function sanitizeValue(value) {
665
- return value.replace(ANSI_RE, "").replace(CTRL_RE, "").replace(BIDI_RE, "");
666
- }
667
- function useColor(env, isTTY) {
668
- if (env.NO_COLOR !== void 0) return false;
669
- if (env.FORCE_COLOR !== void 0) return env.FORCE_COLOR !== "0";
670
- return isTTY;
671
- }
672
- function orderedEntries(profile) {
673
- const byKey = new Map(
674
- (profile.entries ?? []).map((e) => [e.key, e.value])
675
- );
676
- return CURATED_KEYS.flatMap((key) => {
677
- const value = byKey.get(key);
678
- return value === void 0 ? [] : [{ label: KEY_LABELS[key], value: sanitizeValue(value) }];
679
- });
680
- }
681
- function renderProfile(profile, opts) {
682
- const c = palette(opts.color);
683
- const rows = orderedEntries(profile);
684
- const extras = (profile.extras ?? []).map((x) => ({
685
- label: sanitizeValue(x.label),
686
- value: sanitizeValue(x.value)
687
- }));
688
- const labelW = Math.max(
689
- 0,
690
- ...rows.map((r) => r.label.length),
691
- ...extras.map((x) => x.label.length)
692
- );
693
- const lines = ["", ` ${c.bold}${sanitizeValue(profile.handle)}${c.reset}`, ""];
694
- for (const r of rows) {
695
- lines.push(` ${c.faint}${r.label.padEnd(labelW)}${c.reset} ${r.value}`);
696
- }
697
- if (extras.length) {
698
- lines.push("");
699
- for (const x of extras) {
700
- lines.push(` ${c.faint}${x.label.padEnd(labelW)}${c.reset} ${x.value}`);
701
- }
702
- }
703
- lines.push("", ` ${c.faint}updated ${sanitizeValue(profile.updated_at)}${c.reset}`, "");
704
- return lines.join("\n");
705
- }
706
- var MISSING = "\u2014";
707
- function extrasBlock(extras, theirsLabel, mineLabel, c) {
708
- if (!extras.theirs.length && !extras.mine.length) return [];
709
- const out = ["", ` ${c.faint}extras${c.reset}`];
710
- const line = (who, label, value) => ` ${c.faint}${who}${c.reset} ${sanitizeValue(label)} = ${sanitizeValue(value)}`;
711
- for (const x of extras.theirs) out.push(line(theirsLabel, x.label, x.value));
712
- for (const x of extras.mine) out.push(line(mineLabel, x.label, x.value));
713
- return out;
714
- }
715
- function renderDiff(result, opts) {
716
- const c = palette(opts.color);
717
- const theirsLabel = sanitizeValue(opts.theirsLabel);
718
- const mineLabel = sanitizeValue(opts.mineLabel);
719
- const cells = result.rows.map((r) => ({
720
- label: r.label,
721
- theirs: r.theirs === null ? MISSING : sanitizeValue(r.theirs),
722
- mine: r.mine === null ? MISSING : sanitizeValue(r.mine),
723
- differ: r.status !== "same"
724
- }));
725
- const labelW = Math.max(3, ...cells.map((r) => r.label.length));
726
- const theirsW = Math.max(theirsLabel.length, ...cells.map((r) => r.theirs.length));
727
- const lines = [""];
728
- lines.push(
729
- ` ${c.faint}${"".padEnd(labelW)} ${theirsLabel.padEnd(theirsW)} ${mineLabel}${c.reset}`
730
- );
731
- for (const r of cells) {
732
- if (!opts.color) {
733
- const sym = r.differ ? "~" : "=";
734
- lines.push(`${sym} ${r.label.padEnd(labelW)} ${r.theirs.padEnd(theirsW)} ${r.mine}`);
735
- } else if (r.differ) {
736
- lines.push(
737
- `${c.amber}\u2022${c.reset} ${r.label.padEnd(labelW)} ${r.theirs.padEnd(theirsW)} ${c.amber}${r.mine}${c.reset}`
738
- );
739
- } else {
740
- lines.push(
741
- ` ${c.faint}${r.label.padEnd(labelW)} ${r.theirs.padEnd(theirsW)} ${r.mine}${c.reset}`
742
- );
743
- }
772
+ function applyUnset(existing, target) {
773
+ const entries = existing.entries.map((e) => ({ ...e }));
774
+ const extras = existing.extras.map((x) => ({ ...x }));
775
+ if (target.kind === "curated") {
776
+ const hit2 = entries.find((e) => e.key === target.key);
777
+ if (!hit2) return { entries, extras, removed: null };
778
+ return {
779
+ entries: entries.filter((e) => e.key !== target.key),
780
+ extras,
781
+ removed: { label: hit2.key, value: hit2.value }
782
+ };
744
783
  }
745
- lines.push(...extrasBlock(result.extras, theirsLabel, mineLabel, c));
746
- lines.push(
747
- "",
748
- ` ${c.faint}${result.differ} differ \xB7 ${result.shared} shared \u2014 your mileage may vary${c.reset}`,
749
- ""
750
- );
751
- return lines.join("\n");
752
- }
753
- function nudge(color) {
754
- const c = palette(color);
755
- return `
756
- ${c.amber}publish yours to diff \u2192${c.reset} run ${c.bold}ymmv${c.reset}
757
- `;
758
- }
759
- function notFound(handle) {
760
- return `
761
- no ymmv profile for "${sanitizeValue(handle)}" yet.
762
- publish one at ymmv.fyi with: npx ymmv-cli
763
- `;
784
+ const wanted = target.label.trim().toLowerCase();
785
+ const hit = extras.find((x) => x.label.trim().toLowerCase() === wanted);
786
+ if (!hit) return { entries, extras, removed: null };
787
+ return {
788
+ entries,
789
+ extras: extras.filter((x) => x.label.trim().toLowerCase() !== wanted),
790
+ removed: { label: hit.label, value: hit.value }
791
+ };
764
792
  }
765
793
 
766
794
  // src/commands.ts
@@ -775,6 +803,13 @@ function requireHandle(cred) {
775
803
  process.exitCode = 1;
776
804
  return null;
777
805
  }
806
+ function assertHandleUnchanged(existing, handle) {
807
+ if (existing && existing.handle.toLowerCase() !== handle.toLowerCase()) {
808
+ throw new Error(
809
+ `this login is bound to "${handle}" but your profile now lives at "${sanitizeValue(existing.handle)}" \u2014 run \`ymmv login\` to refresh, then retry.`
810
+ );
811
+ }
812
+ }
778
813
  function newProfile(handle, entries, extras) {
779
814
  return {
780
815
  schema_version: SCHEMA_VERSION,
@@ -799,6 +834,7 @@ async function publish(io) {
799
834
  if (!handle) return;
800
835
  const detected = detectStack(process.env, process.platform);
801
836
  const existing = await fetchProfileJson(handle);
837
+ assertHandleUnchanged(existing, handle);
802
838
  const defaults = buildDefaults(existing, detected);
803
839
  let entries = entriesFromMap(defaults);
804
840
  const extras = existing?.extras ?? [];
@@ -845,6 +881,7 @@ async function runSet(target) {
845
881
  const handle = requireHandle(cred);
846
882
  if (!handle) return;
847
883
  const existing = await fetchProfileJson(handle);
884
+ assertHandleUnchanged(existing, handle);
848
885
  const { entries, extras } = applySet(existing, target);
849
886
  await publishProfile(newProfile(handle, entries, extras));
850
887
  if (target.kind === "curated") {
@@ -853,6 +890,32 @@ async function runSet(target) {
853
890
  console.log(`Set extra ${target.label} = ${target.value}.`);
854
891
  }
855
892
  }
893
+ async function runUnset(target) {
894
+ const cred = await ensureLogin();
895
+ const handle = requireHandle(cred);
896
+ if (!handle) return;
897
+ const existing = await fetchProfileJson(handle);
898
+ assertHandleUnchanged(existing, handle);
899
+ if (!existing) {
900
+ console.log("No profile yet \u2014 run `ymmv` to publish one.");
901
+ return;
902
+ }
903
+ const { entries, extras, removed } = applyUnset(existing, target);
904
+ if (!removed) {
905
+ console.log(
906
+ target.kind === "curated" ? `${KEY_LABELS[target.key]} is not set.` : `No extra "${target.label}".`
907
+ );
908
+ return;
909
+ }
910
+ await publishProfile(newProfile(handle, entries, extras));
911
+ if (target.kind === "curated") {
912
+ console.log(`Removed ${KEY_LABELS[target.key]} (was "${sanitizeValue(removed.value)}").`);
913
+ } else {
914
+ console.log(
915
+ `Removed extra "${sanitizeValue(removed.label)}" (was "${sanitizeValue(removed.value)}").`
916
+ );
917
+ }
918
+ }
856
919
  async function runDelete(io) {
857
920
  const cred = await ensureLogin();
858
921
  const target = cred.handle ? `ymmv.fyi/${cred.handle}` : "your profile";
@@ -902,8 +965,18 @@ function makePrompter() {
902
965
  }
903
966
 
904
967
  // src/resolve.ts
905
- var SET_USAGE = 'usage: ymmv set <key> <value> | ymmv set --extra "Label=Value"';
906
- var EXTRA_USAGE = 'usage: ymmv set --extra "Label=Value"';
968
+ var SET_EXTRA = 'ymmv set --extra "Label=Value"';
969
+ var UNSET_EXTRA = 'ymmv unset --extra "Label"';
970
+ var SET_USAGE = `usage: ymmv set <key> <value> | ${SET_EXTRA}`;
971
+ var EXTRA_USAGE = `usage: ${SET_EXTRA}`;
972
+ var UNSET_USAGE = `usage: ymmv unset <key> | ${UNSET_EXTRA}`;
973
+ function invalidKeyError(head, hint) {
974
+ return {
975
+ kind: "error",
976
+ message: `"${head}" is not a curated key. Valid keys: ${CURATED_KEYS.join(", ")}.
977
+ For anything else, use: ${hint}.`
978
+ };
979
+ }
907
980
  function hasYes(args) {
908
981
  return args.includes("-y") || args.includes("--yes");
909
982
  }
@@ -916,20 +989,34 @@ function parseSet(rest) {
916
989
  const label = spec.slice(0, eq).trim();
917
990
  const value2 = spec.slice(eq + 1).trim();
918
991
  if (!label || !value2) return { kind: "error", message: EXTRA_USAGE };
992
+ if (value2 === "-") return { kind: "unset", target: { kind: "extra", label } };
919
993
  return { kind: "set", target: { kind: "extra", label, value: value2 } };
920
994
  }
921
995
  if (!head) return { kind: "error", message: SET_USAGE };
922
- if (!isCuratedKey(head)) {
923
- return {
924
- kind: "error",
925
- message: `"${head}" is not a curated key. Valid keys: ${CURATED_KEYS.join(", ")}.
926
- For anything else, use: ymmv set --extra "Label=Value".`
927
- };
928
- }
996
+ if (!isCuratedKey(head)) return invalidKeyError(head, SET_EXTRA);
929
997
  const value = rest.slice(1).join(" ").trim();
930
998
  if (!value) return { kind: "error", message: `usage: ymmv set ${head} <value>` };
999
+ if (value === "-") return { kind: "unset", target: { kind: "curated", key: head } };
931
1000
  return { kind: "set", target: { kind: "curated", key: head, value } };
932
1001
  }
1002
+ function parseUnset(rest) {
1003
+ const head = rest[0];
1004
+ if (head === "--extra" || head === "-e") {
1005
+ const label = rest.slice(1).join(" ").trim();
1006
+ if (!label) return { kind: "error", message: `usage: ${UNSET_EXTRA}` };
1007
+ if (label.includes("=")) {
1008
+ return {
1009
+ kind: "error",
1010
+ message: 'unset takes just the label: ymmv unset --extra "Keyboard"'
1011
+ };
1012
+ }
1013
+ return { kind: "unset", target: { kind: "extra", label } };
1014
+ }
1015
+ if (!head) return { kind: "error", message: UNSET_USAGE };
1016
+ if (!isCuratedKey(head)) return invalidKeyError(head, UNSET_EXTRA);
1017
+ if (rest.length > 1) return { kind: "error", message: `usage: ymmv unset ${head}` };
1018
+ return { kind: "unset", target: { kind: "curated", key: head } };
1019
+ }
933
1020
  function resolveArg(argv) {
934
1021
  const first = argv[0];
935
1022
  if (first === "-h" || first === "--help" || first === "help") return { kind: "help" };
@@ -940,6 +1027,7 @@ function resolveArg(argv) {
940
1027
  if (first === "logout") return { kind: "logout" };
941
1028
  if (first === "delete") return { kind: "delete", yes: hasYes(argv.slice(1)) };
942
1029
  if (first === "set") return parseSet(argv.slice(1));
1030
+ if (first === "unset") return parseUnset(argv.slice(1));
943
1031
  if (first === "view") {
944
1032
  const handle = argv[1];
945
1033
  if (!handle) return { kind: "error", message: "usage: ymmv view <handle>" };
@@ -969,6 +1057,8 @@ Usage:
969
1057
  ymmv view <handle> explicit view (when a handle collides with a verb)
970
1058
  ymmv set <key> <value> set one curated key
971
1059
  ymmv set --extra "L=V" set a free-form extra
1060
+ ymmv unset <key> remove one curated key (ymmv set <key> - works too)
1061
+ ymmv unset --extra "L" remove a free-form extra
972
1062
  ymmv delete delete your profile (permanent)
973
1063
  ymmv login | logout GitHub device-flow auth
974
1064
  ymmv help | --version
@@ -1029,6 +1119,9 @@ async function main(argv) {
1029
1119
  case "set":
1030
1120
  await runSet(cmd.target);
1031
1121
  break;
1122
+ case "unset":
1123
+ await runUnset(cmd.target);
1124
+ break;
1032
1125
  case "delete":
1033
1126
  await interactive(runDelete, cmd.yes);
1034
1127
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ymmv-cli",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Publish and diff terminal-native developer tool-stack profiles at ymmv.fyi.",
5
5
  "type": "module",
6
6
  "bin": {