ymmv-cli 0.3.0 → 0.5.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 +29 -11
  2. package/dist/cli.js +505 -160
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -2,33 +2,51 @@
2
2
 
3
3
  **Share your dev tool-stack from the terminal.**
4
4
 
5
- Editor, OS, shell, terminal, browser, font (and more) published to a clean
6
- page at `ymmv.fyi/<handle>` in about 10 seconds.
5
+ Editor, OS, shell, terminal, theme, AI tool (and more), published to a
6
+ clean page at `ymmv.fyi/<handle>` in about 10 seconds. See a live one:
7
+ [ymmv.fyi/bardisty](https://ymmv.fyi/bardisty).
8
+
9
+ ![Running npx ymmv-cli to detect, confirm, and publish a dev stack to a live ymmv.fyi page](https://raw.githubusercontent.com/ymmv-fyi/ymmv/main/docs/demo.gif)
7
10
 
8
11
  ```sh
9
12
  npx ymmv-cli # detect your stack, confirm, go live at ymmv.fyi/<you>
10
13
  npx ymmv-cli bardisty # view someone's stack in the terminal
11
14
  ```
12
15
 
16
+ Viewing someone while you're logged in diffs their stack against yours:
17
+
18
+ ```
19
+ bardisty you
20
+ ~ Editor Zed VS Code
21
+ = Shell bash bash
22
+ ~ Theme Gruvbox Catppuccin
23
+ ~ Font Lilex JetBrains Mono
24
+
25
+ 3 differ · 1 shared — your mileage may vary
26
+ ```
27
+
13
28
  Install once for the short `ymmv` command:
14
29
 
15
30
  ```sh
16
31
  npm i -g ymmv-cli
17
32
  ```
18
33
 
19
- First run includes a one-time GitHub sign-in (the device flow you know from `gh`
20
- / `npm login`, ~10s).
34
+ First run includes a one-time GitHub sign-in. Works on macOS, Linux, Windows,
35
+ and WSL. Nothing is published until you confirm (detection only pre-fills), and
36
+ `ymmv delete` removes everything. Releases are published from GitHub Actions
37
+ via npm Trusted Publishing, with provenance.
21
38
 
22
39
  ## Commands
23
40
 
24
- - `ymmv` — detect, confirm, and publish (re-run any time to update)
25
- - `ymmv <handle>` view a profile, or diff it against yours when you're logged in
26
- - `ymmv set editor Neovim` change one value
27
- - `ymmv set --extra "Keyboard=HHKB"` add a free-form line of your own
28
- - `ymmv delete` remove your profile
29
- - `ymmv login` / `ymmv logout` — sign in / out
41
+ - `ymmv` detects, confirms, and publishes (re-run any time to update)
42
+ - `ymmv <handle>` views a profile, or diffs it against yours when you're logged in
43
+ - `ymmv set editor Neovim` changes one value
44
+ - `ymmv set --extra "Keyboard=HHKB"` adds a free-form line of your own
45
+ - `ymmv unset editor` removes one value (`ymmv set editor -` works too); `ymmv unset --extra "Keyboard"` removes an extra
46
+ - `ymmv delete` removes your profile
47
+ - `ymmv login` / `ymmv logout` sign in / out
30
48
 
31
- Every profile is open JSON too `GET https://ymmv.fyi/api/v1/u/<handle>`.
49
+ Every profile is open JSON too: `GET https://ymmv.fyi/api/v1/u/<handle>`.
32
50
 
33
51
  ## License
34
52
 
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { readFileSync } from "fs";
4
+ import { readFileSync as readFileSync2 } from "fs";
5
5
 
6
6
  // src/config.ts
7
7
  var BASE = (process.env.YMMV_API ?? "https://ymmv.fyi").replace(/\/+$/, "");
@@ -44,16 +44,22 @@ async function revokeYmmvToken(token) {
44
44
  return body.revoked === true;
45
45
  }
46
46
 
47
+ // src/commands.ts
48
+ import { readFileSync, statSync } from "fs";
49
+
47
50
  // ../shared/dist/keys.js
48
51
  var CURATED_KEYS = [
49
52
  "editor",
50
53
  "os",
51
54
  "shell",
55
+ "prompt",
52
56
  "terminal",
53
57
  "browser",
54
58
  "window-manager",
55
59
  "font",
60
+ "theme",
56
61
  "multiplexer",
62
+ "version-manager",
57
63
  "dotfiles",
58
64
  "ai-tool"
59
65
  ];
@@ -61,11 +67,14 @@ var KEY_LABELS = {
61
67
  editor: "Editor",
62
68
  os: "OS",
63
69
  shell: "Shell",
70
+ prompt: "Prompt",
64
71
  terminal: "Terminal",
65
72
  browser: "Browser",
66
73
  "window-manager": "Window Manager",
67
74
  font: "Font",
75
+ theme: "Theme",
68
76
  multiplexer: "Multiplexer",
77
+ "version-manager": "Version Manager",
69
78
  dotfiles: "Dotfiles",
70
79
  "ai-tool": "AI Tool"
71
80
  };
@@ -122,16 +131,95 @@ var TOOLS = [
122
131
  { key: "terminal", canonical: "Hyper", envTokens: ["hyper"] },
123
132
  { key: "terminal", canonical: "Rio", envTokens: ["rio"] },
124
133
  { key: "terminal", canonical: "kitty", envTokens: ["kitty"] },
134
+ // kitty refuses TERM_PROGRAM (its issue #3317); detect.ts probes KITTY_WINDOW_ID instead — token kept for the day that changes
125
135
  { key: "terminal", canonical: "Tabby", envTokens: ["tabby"] },
126
136
  { key: "terminal", canonical: "Warp", envTokens: ["warpterminal"] },
127
137
  // `warpterminal` env id ≠ typed "Warp"
138
+ { key: "terminal", canonical: "GNOME Console", envTokens: ["kgx"] },
139
+ // $TERM_PROGRAM=kgx since GNOME 45
140
+ { key: "terminal", canonical: "mintty", envTokens: ["mintty"] },
141
+ // Git Bash/MSYS2 console, ≥3.1.5
142
+ { key: "terminal", canonical: "Zed", envTokens: ["zed"] },
143
+ // Zed's built-in terminal (~0.145+)
144
+ // ── browser (envTokens mirror BROWSER_NAMES: $BROWSER basenames) ──────────
145
+ { key: "browser", canonical: "Firefox", envTokens: ["firefox"], aliases: ["ff"] },
146
+ // Hyphenated Linux binary names double as diff aliases (kde/plasma precedent): unlike `vi`,
147
+ // each unambiguously names exactly that browser, so a TYPED "google-chrome" should diff-equal
148
+ // a DETECTED "Chrome".
149
+ {
150
+ key: "browser",
151
+ canonical: "Chrome",
152
+ envTokens: ["chrome", "google-chrome"],
153
+ aliases: ["google chrome", "google-chrome"]
154
+ // NOT Chromium — distinct browser
155
+ },
156
+ {
157
+ key: "browser",
158
+ canonical: "Edge",
159
+ envTokens: ["msedge", "microsoft-edge"],
160
+ aliases: ["microsoft edge", "microsoft-edge"]
161
+ },
162
+ {
163
+ key: "browser",
164
+ canonical: "Chromium",
165
+ envTokens: ["chromium", "chromium-browser"],
166
+ aliases: ["chromium-browser"]
167
+ },
168
+ {
169
+ key: "browser",
170
+ canonical: "Brave",
171
+ envTokens: ["brave", "brave-browser"],
172
+ aliases: ["brave-browser"]
173
+ },
174
+ { key: "browser", canonical: "LibreWolf", envTokens: ["librewolf"] },
175
+ {
176
+ key: "browser",
177
+ canonical: "Vivaldi",
178
+ envTokens: ["vivaldi", "vivaldi-stable"],
179
+ aliases: ["vivaldi-stable"]
180
+ },
181
+ { key: "browser", canonical: "Zen", envTokens: ["zen", "zen-browser"], aliases: ["zen-browser"] },
182
+ { key: "browser", canonical: "qutebrowser", envTokens: ["qutebrowser"] },
183
+ // ── window-manager (envTokens mirror WM_NAMES: XDG_CURRENT_DESKTOP list entries +
184
+ // DESKTOP_SESSION basenames, lowercased). Deliberate: canonical names are what users put on
185
+ // profiles — the DE name (GNOME, KDE Plasma), not its compositor (Mutter, KWin). Compositors
186
+ // with their own socket vars (Hyprland/Sway/i3/niri) are ALSO probed directly in detect.ts;
187
+ // WMs with no env footprint at all (river, bspwm, dwm, Qtile, awesome) have no tokens — a
188
+ // dead token would only inflate the pinned detector fixtures. ──────────
189
+ { key: "window-manager", canonical: "GNOME", envTokens: ["gnome"] },
190
+ {
191
+ key: "window-manager",
192
+ canonical: "KDE Plasma",
193
+ envTokens: ["kde", "plasma"],
194
+ aliases: ["kde", "plasma"]
195
+ },
196
+ { key: "window-manager", canonical: "Hyprland", envTokens: ["hyprland"] },
197
+ { key: "window-manager", canonical: "Sway", envTokens: ["sway"] },
198
+ { key: "window-manager", canonical: "i3", envTokens: ["i3"] },
199
+ { key: "window-manager", canonical: "niri", envTokens: ["niri"] },
200
+ { key: "window-manager", canonical: "COSMIC", envTokens: ["cosmic"] },
201
+ { key: "window-manager", canonical: "Cinnamon", envTokens: ["cinnamon", "x-cinnamon"] },
202
+ { key: "window-manager", canonical: "MATE", envTokens: ["mate"] },
203
+ { key: "window-manager", canonical: "XFCE", envTokens: ["xfce"] },
204
+ { key: "window-manager", canonical: "LXQt", envTokens: ["lxqt"] },
205
+ { key: "window-manager", canonical: "Budgie", envTokens: ["budgie"] },
206
+ { key: "window-manager", canonical: "Pantheon", envTokens: ["pantheon"] },
207
+ { key: "window-manager", canonical: "Unity", envTokens: ["unity"] },
208
+ // ── prompt (presence-detected in detect.ts — entries exist for diff aliases only; fold
209
+ // already equates case/space variants, so single-word tools need no entry) ──
210
+ { key: "prompt", canonical: "Powerlevel10k", aliases: ["p10k"] },
211
+ // NOT p9k — distinct predecessor
212
+ { key: "prompt", canonical: "Oh My Posh", aliases: ["oh-my-posh", "omp"] },
213
+ // hyphens survive fold
214
+ // ── theme (never detected — aliases for spellings fold can't equate: hyphens + the é) ──
215
+ { key: "theme", canonical: "Ros\xE9 Pine", aliases: ["rose pine", "rose-pine"] },
216
+ { key: "theme", canonical: "Tokyo Night", aliases: ["tokyo-night"] },
217
+ { key: "theme", canonical: "One Dark", aliases: ["one-dark"] },
128
218
  // ── diff-only synonyms (no detector for these fields) ─────────────────────
129
- { key: "browser", canonical: "Firefox", aliases: ["ff"] },
130
- { key: "browser", canonical: "Chrome", aliases: ["google chrome"] },
131
- // NOT Chromium — distinct browser
132
- { key: "browser", canonical: "Edge", aliases: ["microsoft edge"] },
133
219
  { key: "os", canonical: "macOS", aliases: ["osx", "os x", "mac"] },
134
220
  { key: "os", canonical: "Windows", aliases: ["win"] },
221
+ { key: "os", canonical: "Arch Linux", aliases: ["arch"] },
222
+ // os-release NAME= vs what people type
135
223
  { key: "multiplexer", canonical: "GNU Screen", aliases: ["screen"] }
136
224
  // matches detector's "GNU Screen"
137
225
  ];
@@ -284,7 +372,7 @@ function parseProfile(raw) {
284
372
 
285
373
  // ../shared/dist/reserved.js
286
374
  var RESERVED_ROUTES = ["api", "login", "logout"];
287
- var CLI_VERBS = ["login", "logout", "set", "delete", "view", "help"];
375
+ var CLI_VERBS = ["login", "logout", "set", "unset", "delete", "view", "help"];
288
376
  var RESERVED = [.../* @__PURE__ */ new Set([...RESERVED_ROUTES, ...CLI_VERBS])];
289
377
  var RESERVED_SET = new Set(RESERVED);
290
378
  var HANDLE_RE = /^[a-zA-Z0-9](?:-?[a-zA-Z0-9])*$/;
@@ -440,6 +528,136 @@ async function login(deps = {}) {
440
528
  );
441
529
  }
442
530
 
531
+ // src/render.ts
532
+ var ESC = String.fromCharCode(27);
533
+ var CSI = `${ESC}[`;
534
+ var CODES = {
535
+ amber: `${CSI}93m`,
536
+ // DESIGN: amber == ANSI bright-yellow
537
+ faint: `${CSI}90m`,
538
+ bold: `${CSI}1m`,
539
+ reset: `${CSI}0m`
540
+ };
541
+ var NO_CODES = { amber: "", faint: "", bold: "", reset: "" };
542
+ function palette(color) {
543
+ return color ? CODES : NO_CODES;
544
+ }
545
+ var ESC_INTRODUCERS = `${String.fromCharCode(27)}${String.fromCharCode(155)}`;
546
+ var BEL = String.fromCharCode(7);
547
+ var ANSI_RE = new RegExp(
548
+ `[${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=><~]))`,
549
+ "g"
550
+ );
551
+ var CTRL_RE = new RegExp(
552
+ `[${String.fromCharCode(0)}-${String.fromCharCode(31)}${String.fromCharCode(127)}-${String.fromCharCode(159)}]`,
553
+ "g"
554
+ );
555
+ var BIDI_RE = new RegExp(
556
+ `[${String.fromCharCode(8234)}-${String.fromCharCode(8238)}${String.fromCharCode(8294)}-${String.fromCharCode(8297)}${String.fromCharCode(8206)}${String.fromCharCode(8207)}]`,
557
+ "g"
558
+ );
559
+ function sanitizeValue(value) {
560
+ return value.replace(ANSI_RE, "").replace(CTRL_RE, "").replace(BIDI_RE, "");
561
+ }
562
+ function useColor(env, isTTY) {
563
+ if (env.NO_COLOR !== void 0) return false;
564
+ if (env.FORCE_COLOR !== void 0) return env.FORCE_COLOR !== "0";
565
+ return isTTY;
566
+ }
567
+ function orderedEntries(profile) {
568
+ const byKey = new Map(
569
+ (profile.entries ?? []).map((e) => [e.key, e.value])
570
+ );
571
+ return CURATED_KEYS.flatMap((key) => {
572
+ const value = byKey.get(key);
573
+ return value === void 0 ? [] : [{ label: KEY_LABELS[key], value: sanitizeValue(value) }];
574
+ });
575
+ }
576
+ function renderProfile(profile, opts) {
577
+ const c = palette(opts.color);
578
+ const rows = orderedEntries(profile);
579
+ const extras = (profile.extras ?? []).map((x) => ({
580
+ label: sanitizeValue(x.label),
581
+ value: sanitizeValue(x.value)
582
+ }));
583
+ const labelW = Math.max(
584
+ 0,
585
+ ...rows.map((r) => r.label.length),
586
+ ...extras.map((x) => x.label.length)
587
+ );
588
+ const lines = ["", ` ${c.bold}${sanitizeValue(profile.handle)}${c.reset}`, ""];
589
+ for (const r of rows) {
590
+ lines.push(` ${c.faint}${r.label.padEnd(labelW)}${c.reset} ${r.value}`);
591
+ }
592
+ if (extras.length) {
593
+ lines.push("");
594
+ for (const x of extras) {
595
+ lines.push(` ${c.faint}${x.label.padEnd(labelW)}${c.reset} ${x.value}`);
596
+ }
597
+ }
598
+ lines.push("", ` ${c.faint}updated ${sanitizeValue(profile.updated_at)}${c.reset}`, "");
599
+ return lines.join("\n");
600
+ }
601
+ var MISSING = "\u2014";
602
+ function extrasBlock(extras, theirsLabel, mineLabel, c) {
603
+ if (!extras.theirs.length && !extras.mine.length) return [];
604
+ const out = ["", ` ${c.faint}extras${c.reset}`];
605
+ const line = (who, label, value) => ` ${c.faint}${who}${c.reset} ${sanitizeValue(label)} = ${sanitizeValue(value)}`;
606
+ for (const x of extras.theirs) out.push(line(theirsLabel, x.label, x.value));
607
+ for (const x of extras.mine) out.push(line(mineLabel, x.label, x.value));
608
+ return out;
609
+ }
610
+ function renderDiff(result, opts) {
611
+ const c = palette(opts.color);
612
+ const theirsLabel = sanitizeValue(opts.theirsLabel);
613
+ const mineLabel = sanitizeValue(opts.mineLabel);
614
+ const cells = result.rows.map((r) => ({
615
+ label: r.label,
616
+ theirs: r.theirs === null ? MISSING : sanitizeValue(r.theirs),
617
+ mine: r.mine === null ? MISSING : sanitizeValue(r.mine),
618
+ differ: r.status !== "same"
619
+ }));
620
+ const labelW = Math.max(3, ...cells.map((r) => r.label.length));
621
+ const theirsW = Math.max(theirsLabel.length, ...cells.map((r) => r.theirs.length));
622
+ const lines = [""];
623
+ lines.push(
624
+ ` ${c.faint}${"".padEnd(labelW)} ${theirsLabel.padEnd(theirsW)} ${mineLabel}${c.reset}`
625
+ );
626
+ for (const r of cells) {
627
+ if (!opts.color) {
628
+ const sym = r.differ ? "~" : "=";
629
+ lines.push(`${sym} ${r.label.padEnd(labelW)} ${r.theirs.padEnd(theirsW)} ${r.mine}`);
630
+ } else if (r.differ) {
631
+ lines.push(
632
+ `${c.amber}\u2022${c.reset} ${r.label.padEnd(labelW)} ${c.amber}${r.theirs.padEnd(theirsW)}${c.reset} ${c.amber}${r.mine}${c.reset}`
633
+ );
634
+ } else {
635
+ lines.push(
636
+ ` ${c.faint}${r.label.padEnd(labelW)} ${r.theirs.padEnd(theirsW)} ${r.mine}${c.reset}`
637
+ );
638
+ }
639
+ }
640
+ lines.push(...extrasBlock(result.extras, theirsLabel, mineLabel, c));
641
+ lines.push(
642
+ "",
643
+ ` ${c.faint}${result.differ} differ \xB7 ${result.shared} shared \u2014 your mileage may vary${c.reset}`,
644
+ ""
645
+ );
646
+ return lines.join("\n");
647
+ }
648
+ function nudge(color) {
649
+ const c = palette(color);
650
+ return `
651
+ ${c.amber}publish yours to diff \u2192${c.reset} run ${c.bold}ymmv${c.reset}
652
+ `;
653
+ }
654
+ function notFound(handle) {
655
+ return `
656
+ no ymmv profile for "${sanitizeValue(handle)}" yet.
657
+ publish one at ymmv.fyi with: npx ymmv-cli
658
+ `;
659
+ }
660
+
443
661
  // src/api.ts
444
662
  async function rateLimitMessage(res) {
445
663
  const retry = res.headers.get("retry-after");
@@ -470,6 +688,11 @@ async function publishProfile(profile) {
470
688
  // a mutation must never follow a redirect into a false success
471
689
  });
472
690
  let cred = await ensureLogin();
691
+ if ((cred.handle ?? "").toLowerCase() !== profile.handle.toLowerCase()) {
692
+ throw new Error(
693
+ "the stored login changed while this command was running \u2014 re-run it under the current account."
694
+ );
695
+ }
473
696
  let res = await send(cred);
474
697
  if (res.status === 401 || res.status === 409) {
475
698
  if (res.status === 401) await deleteToken();
@@ -488,7 +711,8 @@ async function publishProfile(profile) {
488
711
  throw new Error(`publish failed: ${res.status} ${await res.text()}`);
489
712
  }
490
713
  const data = await res.json();
491
- console.log(`Published ${data.handle} -> ${BASE}/${data.handle}`);
714
+ const shown = typeof data.handle === "string" ? sanitizeValue(data.handle) : profile.handle;
715
+ console.log(`Published ${shown} -> ${BASE}/${shown}`);
492
716
  }
493
717
  async function fetchProfileJson(handle) {
494
718
  const res = await fetch(`${BASE}/api/v1/u/${encodeURIComponent(handle)}`);
@@ -525,7 +749,7 @@ function firstToken(s) {
525
749
  return s.trim().split(/\s+/)[0] ?? "";
526
750
  }
527
751
  var namesFor = (key) => {
528
- const map = {};
752
+ const map = /* @__PURE__ */ Object.create(null);
529
753
  for (const tool of TOOLS) {
530
754
  if (tool.key !== key) continue;
531
755
  for (const token of tool.envTokens ?? []) map[token] = tool.canonical;
@@ -535,15 +759,44 @@ var namesFor = (key) => {
535
759
  var SHELL_NAMES = namesFor("shell");
536
760
  var EDITOR_NAMES = namesFor("editor");
537
761
  var TERM_PROGRAMS = namesFor("terminal");
538
- function detectOS(env, platform) {
539
- if (env.WSL_DISTRO_NAME) return `${env.WSL_DISTRO_NAME} (WSL)`;
762
+ var BROWSER_NAMES = namesFor("browser");
763
+ var WM_NAMES = namesFor("window-manager");
764
+ var TERM_EXACT = Object.assign(/* @__PURE__ */ Object.create(null), {
765
+ "xterm-kitty": "kitty",
766
+ "xterm-ghostty": "Ghostty",
767
+ foot: "foot",
768
+ alacritty: "Alacritty"
769
+ });
770
+ var BROWSER_DISPATCHERS = /* @__PURE__ */ new Set([
771
+ "xdg-open",
772
+ "wslview",
773
+ "sensible-browser",
774
+ "x-www-browser",
775
+ "www-browser",
776
+ "open",
777
+ "gio",
778
+ "browser.sh",
779
+ "browser.cmd"
780
+ ]);
781
+ function linuxDistro(read) {
782
+ if (!read) return void 0;
783
+ try {
784
+ const line = read("/etc/os-release").split("\n").find((l) => l.startsWith("NAME="));
785
+ const name = line?.slice("NAME=".length).trim().replace(/^["']|["']$/g, "");
786
+ return name?.trim() || void 0;
787
+ } catch {
788
+ return void 0;
789
+ }
790
+ }
791
+ function detectOS(env, platform, opts) {
792
+ if (platform === "linux" && env.WSL_DISTRO_NAME) return `${env.WSL_DISTRO_NAME} (WSL)`;
540
793
  switch (platform) {
541
794
  case "darwin":
542
795
  return "macOS";
543
796
  case "win32":
544
797
  return "Windows";
545
798
  case "linux":
546
- return "Linux";
799
+ return linuxDistro(opts.readTextFile) ?? "Linux";
547
800
  default:
548
801
  return platform;
549
802
  }
@@ -555,6 +808,9 @@ function detectShell(env, platform) {
555
808
  return SHELL_NAMES[key] ?? basename(raw);
556
809
  }
557
810
  if (platform === "win32") {
811
+ const firstSeg = env.PSModulePath?.split(";")[0]?.trim().toLowerCase() ?? "";
812
+ if (/[\\/]documents[\\/](windows)?powershell[\\/]modules$/.test(firstSeg)) return "PowerShell";
813
+ if (env.PROMPT) return env.ComSpec ? basename(env.ComSpec) : "cmd";
558
814
  if (env.PSModulePath) return "PowerShell";
559
815
  if (env.ComSpec) return basename(env.ComSpec);
560
816
  }
@@ -563,18 +819,37 @@ function detectShell(env, platform) {
563
819
  function detectTerminal(env) {
564
820
  const tp = env.TERM_PROGRAM?.trim();
565
821
  if (tp && tp.toLowerCase() !== "tmux") {
822
+ if (tp.toLowerCase() === "vscode" && env.CURSOR_TRACE_ID) return "Cursor";
566
823
  return TERM_PROGRAMS[tp.toLowerCase()] ?? tp;
567
824
  }
825
+ if (env.TERMINAL_EMULATOR === "JetBrains-JediTerm") return "JetBrains";
568
826
  if (env.WT_SESSION) return "Windows Terminal";
569
827
  if (env.KONSOLE_VERSION) return "Konsole";
570
828
  if (env.ALACRITTY_WINDOW_ID || env.ALACRITTY_SOCKET) return "Alacritty";
829
+ if (env.KITTY_WINDOW_ID || env.KITTY_PID) return "kitty";
830
+ if (env.GNOME_TERMINAL_SERVICE || env.GNOME_TERMINAL_SCREEN) return "GNOME Terminal";
831
+ if (env.TILIX_ID) return "Tilix";
832
+ if (env.TERMINATOR_UUID) return "Terminator";
833
+ if (env.ConEmuANSI || env.ConEmuPID) return "ConEmu";
834
+ if (env.XTERM_VERSION) return "xterm";
835
+ const t = env.TERM?.trim().toLowerCase();
836
+ if (t && TERM_EXACT[t]) return TERM_EXACT[t];
571
837
  return void 0;
572
838
  }
573
839
  function detectEditor(env) {
574
840
  const raw = env.VISUAL ?? env.EDITOR;
575
- if (!raw) return void 0;
576
- const bin = basename(firstToken(raw));
577
- return EDITOR_NAMES[bin.toLowerCase()] ?? bin;
841
+ if (raw) {
842
+ const bin = basename(firstToken(raw));
843
+ return EDITOR_NAMES[bin.toLowerCase()] ?? bin;
844
+ }
845
+ if (env.NVIM) return "Neovim";
846
+ if (env.VIM_TERMINAL) return "Vim";
847
+ if (env.INSIDE_EMACS) return "Emacs";
848
+ if (env.CURSOR_TRACE_ID) return "Cursor";
849
+ const tp = env.TERM_PROGRAM?.trim().toLowerCase();
850
+ if (tp === "vscode") return "VS Code";
851
+ if (tp === "zed" || env.ZED_TERM === "true") return "Zed";
852
+ return void 0;
578
853
  }
579
854
  function detectMultiplexer(env) {
580
855
  if (env.TMUX) return "tmux";
@@ -582,17 +857,92 @@ function detectMultiplexer(env) {
582
857
  if (env.STY) return "GNU Screen";
583
858
  return void 0;
584
859
  }
585
- function detectStack(env, platform) {
860
+ function detectPrompt(env) {
861
+ if (env.STARSHIP_SHELL || env.STARSHIP_SESSION_KEY) return "Starship";
862
+ if (env.POSH_SHELL || env.POSH_SESSION_ID || env.POSH_THEME) return "Oh My Posh";
863
+ if (env.P9K_TTY || env._P9K_TTY) return "Powerlevel10k";
864
+ if (env.SPACESHIP_VERSION) return "Spaceship";
865
+ if (Object.keys(env).some((k) => k.startsWith("_tide_"))) return "Tide";
866
+ return void 0;
867
+ }
868
+ function detectVersionManager(env) {
869
+ const path = env.PATH ?? "";
870
+ if (env.MISE_SHELL || env.__MISE_ORIG_PATH || /[\\/]mise[\\/]shims/i.test(path)) return "mise";
871
+ if (env.ASDF_DIR || env.ASDF_DATA_DIR || /\.asdf[\\/]shims/i.test(path)) return "asdf";
872
+ if (env.PROTO_HOME) return "proto";
873
+ if (env.VOLTA_HOME) return "Volta";
874
+ if (env.FNM_MULTISHELL_PATH || env.FNM_DIR) return "fnm";
875
+ if (env.NVM_DIR) return "nvm";
876
+ if (env.PYENV_SHELL || env.PYENV_ROOT) return "pyenv";
877
+ if (env.RBENV_SHELL || env.RBENV_ROOT) return "rbenv";
878
+ return void 0;
879
+ }
880
+ function detectWindowManager(env) {
881
+ if (env.HYPRLAND_INSTANCE_SIGNATURE) return "Hyprland";
882
+ if (env.NIRI_SOCKET) return "niri";
883
+ if (env.SWAYSOCK) return "Sway";
884
+ if (env.I3SOCK) return "i3";
885
+ for (const part of env.XDG_CURRENT_DESKTOP?.split(":") ?? []) {
886
+ const hit = WM_NAMES[part.trim().toLowerCase()];
887
+ if (hit) return hit;
888
+ }
889
+ const ds = env.DESKTOP_SESSION;
890
+ if (ds) {
891
+ const hit = WM_NAMES[basename(ds).toLowerCase()];
892
+ if (hit) return hit;
893
+ }
894
+ if (env.KDE_FULL_SESSION || env.KDE_SESSION_VERSION) return "KDE Plasma";
895
+ return void 0;
896
+ }
897
+ function detectBrowser(env) {
898
+ const raw = env.BROWSER;
899
+ if (!raw) return void 0;
900
+ const trimmed = raw.trim();
901
+ if (/^[A-Za-z]:[\\/]/.test(trimmed) || trimmed.includes("\\")) {
902
+ const bin = basename(trimmed);
903
+ if (!bin || BROWSER_DISPATCHERS.has(bin.toLowerCase())) return void 0;
904
+ return BROWSER_NAMES[bin.toLowerCase()] ?? bin;
905
+ }
906
+ for (const candidate of trimmed.split(":")) {
907
+ const bin = basename(firstToken(candidate));
908
+ if (!bin) continue;
909
+ if (BROWSER_DISPATCHERS.has(bin.toLowerCase())) continue;
910
+ const hit = BROWSER_NAMES[bin.toLowerCase()];
911
+ if (hit) return hit;
912
+ if (candidate.startsWith("/") && /\s/.test(candidate.trim())) {
913
+ const whole = basename(candidate.trim().replace(/\s+%[a-zA-Z].*$/, ""));
914
+ if (whole && !whole.includes("/")) return BROWSER_NAMES[whole.toLowerCase()] ?? whole;
915
+ }
916
+ return bin;
917
+ }
918
+ return void 0;
919
+ }
920
+ function detectAiTool(env) {
921
+ if (env.AGENT?.trim().toLowerCase() === "amp") return "Amp";
922
+ if (env.CLAUDECODE) return "Claude Code";
923
+ if (env.CODEX_THREAD_ID) return "Codex";
924
+ if (env.GEMINI_CLI) return "Gemini CLI";
925
+ if (env.OPENCODE) return "opencode";
926
+ if (env.COPILOT_CLI) return "GitHub Copilot";
927
+ if (env.CURSOR_TRACE_ID || env.CURSOR_AGENT) return "Cursor";
928
+ return void 0;
929
+ }
930
+ function detectStack(env, platform, opts = {}) {
586
931
  const out = /* @__PURE__ */ new Map();
587
932
  const set = (key, value) => {
588
933
  if (value?.trim()) out.set(key, value.trim());
589
934
  };
590
935
  try {
591
- set("os", detectOS(env, platform));
936
+ set("os", detectOS(env, platform, opts));
592
937
  set("shell", detectShell(env, platform));
938
+ set("prompt", detectPrompt(env));
593
939
  set("terminal", detectTerminal(env));
594
940
  set("editor", detectEditor(env));
595
941
  set("multiplexer", detectMultiplexer(env));
942
+ set("version-manager", detectVersionManager(env));
943
+ set("window-manager", detectWindowManager(env));
944
+ set("browser", detectBrowser(env));
945
+ set("ai-tool", detectAiTool(env));
596
946
  } catch {
597
947
  }
598
948
  return out;
@@ -616,6 +966,9 @@ function entriesFromMap(map) {
616
966
  return value ? [{ key, value }] : [];
617
967
  });
618
968
  }
969
+ function unknownEntries(existing) {
970
+ return (existing?.entries ?? []).filter((e) => !isCuratedKey(e.key));
971
+ }
619
972
  function applySet(existing, target) {
620
973
  const entries = (existing?.entries ?? []).map((e) => ({ ...e }));
621
974
  const extras = (existing?.extras ?? []).map((x) => ({ ...x }));
@@ -625,142 +978,34 @@ function applySet(existing, target) {
625
978
  if (i >= 0) entries[i] = next;
626
979
  else entries.push(next);
627
980
  } else {
628
- const i = extras.findIndex((x) => x.label.toLowerCase() === target.label.toLowerCase());
981
+ const wanted = target.label.trim().toLowerCase();
982
+ const i = extras.findIndex((x) => x.label.trim().toLowerCase() === wanted);
629
983
  const next = { label: target.label, value: target.value };
630
984
  if (i >= 0) extras[i] = next;
631
985
  else extras.push(next);
632
986
  }
633
987
  return { entries, extras };
634
988
  }
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)} ${c.amber}${r.theirs.padEnd(theirsW)}${c.reset} ${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
- }
989
+ function applyUnset(existing, target) {
990
+ const entries = existing.entries.map((e) => ({ ...e }));
991
+ const extras = existing.extras.map((x) => ({ ...x }));
992
+ if (target.kind === "curated") {
993
+ const hit2 = entries.find((e) => e.key === target.key);
994
+ if (!hit2) return { entries, extras, removed: null };
995
+ return {
996
+ entries: entries.filter((e) => e.key !== target.key),
997
+ extras,
998
+ removed: { label: hit2.key, value: hit2.value }
999
+ };
744
1000
  }
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
- `;
1001
+ const wanted = target.label.trim().toLowerCase();
1002
+ const hit = extras.find((x) => x.label.trim().toLowerCase() === wanted);
1003
+ if (!hit) return { entries, extras, removed: null };
1004
+ return {
1005
+ entries,
1006
+ extras: extras.filter((x) => x.label.trim().toLowerCase() !== wanted),
1007
+ removed: { label: hit.label, value: hit.value }
1008
+ };
764
1009
  }
765
1010
 
766
1011
  // src/commands.ts
@@ -775,6 +1020,13 @@ function requireHandle(cred) {
775
1020
  process.exitCode = 1;
776
1021
  return null;
777
1022
  }
1023
+ function assertHandleUnchanged(existing, handle) {
1024
+ if (existing && existing.handle.toLowerCase() !== handle.toLowerCase()) {
1025
+ throw new Error(
1026
+ `this login is bound to "${handle}" but your profile now lives at "${sanitizeValue(existing.handle)}" \u2014 run \`ymmv login\` to refresh, then retry.`
1027
+ );
1028
+ }
1029
+ }
778
1030
  function newProfile(handle, entries, extras) {
779
1031
  return {
780
1032
  schema_version: SCHEMA_VERSION,
@@ -794,19 +1046,49 @@ async function promptEntries(defaults, prompter) {
794
1046
  return entriesFromMap(chosen);
795
1047
  }
796
1048
  async function publish(io) {
1049
+ if (!io.interactive && !io.yes) {
1050
+ console.error("Non-interactive publish needs -y (nothing publishes unconfirmed): ymmv -y");
1051
+ process.exitCode = 1;
1052
+ return;
1053
+ }
797
1054
  const cred = await ensureLogin();
798
1055
  const handle = requireHandle(cred);
799
1056
  if (!handle) return;
800
- const detected = detectStack(process.env, process.platform);
1057
+ const detected = detectStack(process.env, process.platform, {
1058
+ readTextFile: (p) => {
1059
+ const st = statSync(p);
1060
+ if (!st.isFile() || st.size > 64 * 1024) throw new Error("not a readable os-release");
1061
+ return readFileSync(p, "utf8");
1062
+ }
1063
+ });
801
1064
  const existing = await fetchProfileJson(handle);
1065
+ assertHandleUnchanged(existing, handle);
802
1066
  const defaults = buildDefaults(existing, detected);
1067
+ const carried = unknownEntries(existing);
803
1068
  let entries = entriesFromMap(defaults);
804
1069
  const extras = existing?.extras ?? [];
805
1070
  if (io.interactive && io.prompter) {
806
1071
  entries = await promptEntries(defaults, io.prompter);
807
1072
  }
1073
+ entries = [...entries, ...carried];
808
1074
  const profile = newProfile(handle, entries, extras);
809
1075
  console.log(renderProfile(profile, { color: colorEnabled() }));
1076
+ if (carried.length > 0) {
1077
+ const s = carried.length === 1 ? "" : "s";
1078
+ console.log(`(+${carried.length} newer field${s} kept as-is \u2014 upgrade ymmv-cli to edit them)`);
1079
+ for (const e of carried) {
1080
+ console.log(` ${sanitizeValue(e.key)} = ${sanitizeValue(e.value)}`);
1081
+ }
1082
+ }
1083
+ const publishedLabels = new Set(
1084
+ entries.filter((e) => isCuratedKey(e.key)).map((e) => KEY_LABELS[e.key].toLowerCase())
1085
+ );
1086
+ for (const x of extras) {
1087
+ if (publishedLabels.has(x.label.trim().toLowerCase())) {
1088
+ const label = sanitizeValue(x.label.trim());
1089
+ console.log(`(extra "${label}" duplicates a curated field \u2014 ymmv unset --extra "${label}")`);
1090
+ }
1091
+ }
810
1092
  if (io.interactive && io.prompter && !io.yes) {
811
1093
  const go = await io.prompter.confirm(`Publish to ymmv.fyi/${handle}?`, true);
812
1094
  if (!go) {
@@ -845,6 +1127,7 @@ async function runSet(target) {
845
1127
  const handle = requireHandle(cred);
846
1128
  if (!handle) return;
847
1129
  const existing = await fetchProfileJson(handle);
1130
+ assertHandleUnchanged(existing, handle);
848
1131
  const { entries, extras } = applySet(existing, target);
849
1132
  await publishProfile(newProfile(handle, entries, extras));
850
1133
  if (target.kind === "curated") {
@@ -853,6 +1136,32 @@ async function runSet(target) {
853
1136
  console.log(`Set extra ${target.label} = ${target.value}.`);
854
1137
  }
855
1138
  }
1139
+ async function runUnset(target) {
1140
+ const cred = await ensureLogin();
1141
+ const handle = requireHandle(cred);
1142
+ if (!handle) return;
1143
+ const existing = await fetchProfileJson(handle);
1144
+ assertHandleUnchanged(existing, handle);
1145
+ if (!existing) {
1146
+ console.log("No profile yet \u2014 run `ymmv` to publish one.");
1147
+ return;
1148
+ }
1149
+ const { entries, extras, removed } = applyUnset(existing, target);
1150
+ if (!removed) {
1151
+ console.log(
1152
+ target.kind === "curated" ? `${KEY_LABELS[target.key]} is not set.` : `No extra "${target.label}".`
1153
+ );
1154
+ return;
1155
+ }
1156
+ await publishProfile(newProfile(handle, entries, extras));
1157
+ if (target.kind === "curated") {
1158
+ console.log(`Removed ${KEY_LABELS[target.key]} (was "${sanitizeValue(removed.value)}").`);
1159
+ } else {
1160
+ console.log(
1161
+ `Removed extra "${sanitizeValue(removed.label)}" (was "${sanitizeValue(removed.value)}").`
1162
+ );
1163
+ }
1164
+ }
856
1165
  async function runDelete(io) {
857
1166
  const cred = await ensureLogin();
858
1167
  const target = cred.handle ? `ymmv.fyi/${cred.handle}` : "your profile";
@@ -878,6 +1187,10 @@ async function runDelete(io) {
878
1187
  // src/prompt.ts
879
1188
  import { stdin, stdout } from "process";
880
1189
  import { createInterface } from "readline/promises";
1190
+ function promptLine(label, def) {
1191
+ const clean = def ? sanitizeValue(def) : def;
1192
+ return ` ${label}${clean ? ` [${clean}]` : ""}: `;
1193
+ }
881
1194
  function makePrompter() {
882
1195
  let rl = null;
883
1196
  const io = () => {
@@ -886,8 +1199,9 @@ function makePrompter() {
886
1199
  };
887
1200
  return {
888
1201
  async ask(label, def) {
889
- const answer = (await io().question(` ${label}${def ? ` [${def}]` : ""}: `)).trim();
890
- return answer === "" ? def ?? "" : answer;
1202
+ const clean = def ? sanitizeValue(def) : def;
1203
+ const answer = (await io().question(promptLine(label, def))).trim();
1204
+ return answer === "" ? clean ?? "" : answer;
891
1205
  },
892
1206
  async confirm(question, defYes) {
893
1207
  const answer = (await io().question(` ${question} ${defYes ? "[Y/n]" : "[y/N]"} `)).trim().toLowerCase();
@@ -902,8 +1216,18 @@ function makePrompter() {
902
1216
  }
903
1217
 
904
1218
  // 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"';
1219
+ var SET_EXTRA = 'ymmv set --extra "Label=Value"';
1220
+ var UNSET_EXTRA = 'ymmv unset --extra "Label"';
1221
+ var SET_USAGE = `usage: ymmv set <key> <value> | ${SET_EXTRA}`;
1222
+ var EXTRA_USAGE = `usage: ${SET_EXTRA}`;
1223
+ var UNSET_USAGE = `usage: ymmv unset <key> | ${UNSET_EXTRA}`;
1224
+ function invalidKeyError(head, hint) {
1225
+ return {
1226
+ kind: "error",
1227
+ message: `"${head}" is not a curated key. Valid keys: ${CURATED_KEYS.join(", ")}.
1228
+ For anything else, use: ${hint}.`
1229
+ };
1230
+ }
907
1231
  function hasYes(args) {
908
1232
  return args.includes("-y") || args.includes("--yes");
909
1233
  }
@@ -916,20 +1240,34 @@ function parseSet(rest) {
916
1240
  const label = spec.slice(0, eq).trim();
917
1241
  const value2 = spec.slice(eq + 1).trim();
918
1242
  if (!label || !value2) return { kind: "error", message: EXTRA_USAGE };
1243
+ if (value2 === "-") return { kind: "unset", target: { kind: "extra", label } };
919
1244
  return { kind: "set", target: { kind: "extra", label, value: value2 } };
920
1245
  }
921
1246
  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
- }
1247
+ if (!isCuratedKey(head)) return invalidKeyError(head, SET_EXTRA);
929
1248
  const value = rest.slice(1).join(" ").trim();
930
1249
  if (!value) return { kind: "error", message: `usage: ymmv set ${head} <value>` };
1250
+ if (value === "-") return { kind: "unset", target: { kind: "curated", key: head } };
931
1251
  return { kind: "set", target: { kind: "curated", key: head, value } };
932
1252
  }
1253
+ function parseUnset(rest) {
1254
+ const head = rest[0];
1255
+ if (head === "--extra" || head === "-e") {
1256
+ const label = rest.slice(1).join(" ").trim();
1257
+ if (!label) return { kind: "error", message: `usage: ${UNSET_EXTRA}` };
1258
+ if (label.includes("=")) {
1259
+ return {
1260
+ kind: "error",
1261
+ message: 'unset takes just the label: ymmv unset --extra "Keyboard"'
1262
+ };
1263
+ }
1264
+ return { kind: "unset", target: { kind: "extra", label } };
1265
+ }
1266
+ if (!head) return { kind: "error", message: UNSET_USAGE };
1267
+ if (!isCuratedKey(head)) return invalidKeyError(head, UNSET_EXTRA);
1268
+ if (rest.length > 1) return { kind: "error", message: `usage: ymmv unset ${head}` };
1269
+ return { kind: "unset", target: { kind: "curated", key: head } };
1270
+ }
933
1271
  function resolveArg(argv) {
934
1272
  const first = argv[0];
935
1273
  if (first === "-h" || first === "--help" || first === "help") return { kind: "help" };
@@ -940,6 +1278,7 @@ function resolveArg(argv) {
940
1278
  if (first === "logout") return { kind: "logout" };
941
1279
  if (first === "delete") return { kind: "delete", yes: hasYes(argv.slice(1)) };
942
1280
  if (first === "set") return parseSet(argv.slice(1));
1281
+ if (first === "unset") return parseUnset(argv.slice(1));
943
1282
  if (first === "view") {
944
1283
  const handle = argv[1];
945
1284
  if (!handle) return { kind: "error", message: "usage: ymmv view <handle>" };
@@ -965,16 +1304,19 @@ var HELP = `ymmv \u2014 terminal-native developer tool-stack profiles (ymmv.fyi)
965
1304
 
966
1305
  Usage:
967
1306
  ymmv detect your stack, confirm, and publish your profile
1307
+ ymmv -y publish without prompts (required when stdin isn't a TTY)
968
1308
  ymmv <handle> view a profile \u2014 logged in, see the diff vs yours
969
1309
  ymmv view <handle> explicit view (when a handle collides with a verb)
970
1310
  ymmv set <key> <value> set one curated key
971
1311
  ymmv set --extra "L=V" set a free-form extra
1312
+ ymmv unset <key> remove one curated key (ymmv set <key> - works too)
1313
+ ymmv unset --extra "L" remove a free-form extra
972
1314
  ymmv delete delete your profile (permanent)
973
1315
  ymmv login | logout GitHub device-flow auth
974
1316
  ymmv help | --version
975
1317
 
976
- Curated keys: editor, os, shell, terminal, browser, window-manager, font,
977
- multiplexer, dotfiles, ai-tool
1318
+ Curated keys: editor, os, shell, prompt, terminal, browser, window-manager,
1319
+ font, theme, multiplexer, version-manager, dotfiles, ai-tool
978
1320
 
979
1321
  Respects NO_COLOR. Point YMMV_API at a dev Worker to target one.
980
1322
  Publish your own: npx ymmv-cli`;
@@ -1002,7 +1344,7 @@ async function logout() {
1002
1344
  }
1003
1345
  function printVersion() {
1004
1346
  try {
1005
- const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
1347
+ const pkg = JSON.parse(readFileSync2(new URL("../package.json", import.meta.url), "utf8"));
1006
1348
  console.log(`ymmv-cli ${pkg.version ?? "unknown"}`);
1007
1349
  } catch {
1008
1350
  console.log("ymmv-cli (version unknown)");
@@ -1029,6 +1371,9 @@ async function main(argv) {
1029
1371
  case "set":
1030
1372
  await runSet(cmd.target);
1031
1373
  break;
1374
+ case "unset":
1375
+ await runUnset(cmd.target);
1376
+ break;
1032
1377
  case "delete":
1033
1378
  await interactive(runDelete, cmd.yes);
1034
1379
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ymmv-cli",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Publish and diff terminal-native developer tool-stack profiles at ymmv.fyi.",
5
5
  "type": "module",
6
6
  "bin": {