@kaynooo/cli-doctor 0.9.0 → 0.11.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/dist/cli-bin.js +104 -34
  2. package/dist/cli.js +104 -34
  3. package/package.json +2 -2
package/dist/cli-bin.js CHANGED
@@ -481,7 +481,6 @@ var DASHES = /^-+/;
481
481
  function jsonOption(holds) {
482
482
  return { type: "boolean", description: holds === undefined ? "print JSON" : `print JSON: ${holds}` };
483
483
  }
484
-
485
484
  class Exit extends Error {
486
485
  code;
487
486
  hint;
@@ -1582,9 +1581,20 @@ var KEY_SEQUENCES = /\x1B\[[0-9;]*[A-Z~]|\x1BO[A-Z]|[\s\S]/giu;
1582
1581
  var CONTROL = /^[\x00-\x1F\x7F]/;
1583
1582
  var WORDS = /\s+/;
1584
1583
  var MAX_ROWS = 10;
1585
- var KEYS = { "\x1B[A": "up", "\x1BOA": "up", "\x1B[B": "down", "\x1BOB": "down", "\r": "enter", "\n": "enter", "\x03": "cancel", "\x1B": "cancel", "\x7F": "erase", "\b": "erase" };
1586
- function readKeys(input) {
1587
- return (input.match(KEY_SEQUENCES) ?? []).flatMap((sequence) => KEYS[sequence] ?? (CONTROL.test(sequence) ? [] : [{ text: sequence }]));
1584
+ var PASTE_START = "\x1B[200~";
1585
+ var PASTE_END = "\x1B[201~";
1586
+ var KEYS = { "\x1B[A": "up", "\x1BOA": "up", "\x1B[B": "down", "\x1BOB": "down", "\x1B[5~": "pageup", "\x1B[6~": "pagedown", "\x1B[H": "home", "\x1B[1~": "home", "\x1BOH": "home", "\x1B[F": "end", "\x1B[4~": "end", "\x1BOF": "end", "\r": "enter", "\n": "enter", "\x03": "cancel", "\x1B": "cancel", "\x7F": "erase", "\b": "erase" };
1587
+ function readKeys(input, paste = { active: false }) {
1588
+ return (input.match(KEY_SEQUENCES) ?? []).flatMap((sequence) => {
1589
+ if (sequence === PASTE_START || sequence === PASTE_END) {
1590
+ paste.active = sequence === PASTE_START;
1591
+ return [];
1592
+ }
1593
+ if (paste.active && (sequence === "\r" || sequence === `
1594
+ `))
1595
+ return [];
1596
+ return KEYS[sequence] ?? (CONTROL.test(sequence) ? [] : [{ text: sequence }]);
1597
+ });
1588
1598
  }
1589
1599
  function flip(checked, value) {
1590
1600
  const next = new Set(checked);
@@ -1615,6 +1625,22 @@ async function checklist(question, choices, { initial = new Set, min = 0, toggle
1615
1625
  });
1616
1626
  return accepted ? checked : undefined;
1617
1627
  }
1628
+ async function confirm(question, { default: fallback, ...settings } = {}) {
1629
+ let answered = fallback;
1630
+ const choices = { true: "Y/n", false: "y/N", undefined: "y/n" }[String(fallback)];
1631
+ const accepted = await ask(question, settings, (colors, state) => [state === "asking" ? asking(colors, question, "", choices) : answer(colors, question, state, answered ? "yes" : "no")], (key) => {
1632
+ if (key === "cancel")
1633
+ return "cancel";
1634
+ if (key === "enter")
1635
+ return answered === undefined ? undefined : "enter";
1636
+ const letter = typeof key === "object" ? key.text.toLowerCase() : "";
1637
+ if (letter !== "y" && letter !== "n")
1638
+ return;
1639
+ answered = letter === "y";
1640
+ return "enter";
1641
+ });
1642
+ return accepted ? answered : undefined;
1643
+ }
1618
1644
  async function input2(question, { default: fallback, mask = false, validate, ...settings } = {}) {
1619
1645
  let value = "";
1620
1646
  let invalid;
@@ -1667,6 +1693,12 @@ class List {
1667
1693
  this.cursor = (this.cursor + (key === "up" ? this.matches.length - 1 : 1)) % this.matches.length;
1668
1694
  return;
1669
1695
  }
1696
+ if (key === "home" || key === "end" || key === "pageup" || key === "pagedown") {
1697
+ const last = this.matches.length - 1;
1698
+ const to = { home: 0, end: last, pageup: this.cursor - MAX_ROWS, pagedown: this.cursor + MAX_ROWS }[key];
1699
+ this.cursor = Math.max(0, Math.min(last, to));
1700
+ return;
1701
+ }
1670
1702
  if (key === "erase")
1671
1703
  this.filter([...this.query].slice(0, -1).join(""));
1672
1704
  else if (typeof key === "object")
@@ -1708,22 +1740,29 @@ async function ask(question, { hint, terminal = process15 }, render, press) {
1708
1740
  if (!isInteractive(terminal))
1709
1741
  fail(`cannot ask "${question}" off a terminal`, 2, hint);
1710
1742
  const colors = paint(stderr);
1711
- let drawn = 0;
1743
+ let drawn = [];
1744
+ let current = "asking";
1712
1745
  let outcome;
1746
+ const columns = () => stderr.columns || 80;
1713
1747
  const draw = (state) => {
1714
- const frame = render(colors, state, { columns: stderr.columns || 80, rows: stderr.rows || 24 });
1715
- stderr.write(`${drawn > 1 ? `\x1B[${drawn - 1}A` : ""}\r\x1B[0J${frame.join(`
1748
+ const frame = render(colors, state, { columns: columns(), rows: stderr.rows || 24 });
1749
+ const rows = drawn.reduce((sum, line) => sum + Math.max(1, Math.ceil(Bun.stringWidth(line) / columns())), 0);
1750
+ stderr.write(`${rows > 1 ? `\x1B[${rows - 1}A` : ""}\r\x1B[0J${frame.join(`
1716
1751
  `)}`);
1717
- drawn = frame.length;
1752
+ drawn = frame;
1753
+ current = state;
1718
1754
  };
1755
+ const redraw = () => draw(current);
1756
+ const paste = { active: false };
1719
1757
  try {
1758
+ stderr.on?.("resize", redraw);
1720
1759
  stdin.setRawMode(true);
1721
1760
  stdin.resume();
1722
- stderr.write("\x1B[?25l");
1761
+ stderr.write("\x1B[?25l\x1B[?2004h");
1723
1762
  draw("asking");
1724
1763
  return await new Promise((resolve) => {
1725
1764
  function onData(data) {
1726
- for (const key of readKeys(data.toString())) {
1765
+ for (const key of readKeys(data.toString(), paste)) {
1727
1766
  const pressed = press(key);
1728
1767
  if (pressed !== undefined) {
1729
1768
  settle(pressed);
@@ -1745,9 +1784,10 @@ async function ask(question, { hint, terminal = process15 }, render, press) {
1745
1784
  stdin.on("end", onEnd);
1746
1785
  });
1747
1786
  } finally {
1787
+ stderr.off?.("resize", redraw);
1748
1788
  draw(outcome ?? "cancel");
1749
1789
  stderr.write(`
1750
- \x1B[?25h`);
1790
+ \x1B[?25h\x1B[?2004l`);
1751
1791
  stdin.setRawMode(false);
1752
1792
  stdin.pause();
1753
1793
  }
@@ -1755,18 +1795,19 @@ async function ask(question, { hint, terminal = process15 }, render, press) {
1755
1795
 
1756
1796
  // src/install.ts
1757
1797
  var HOOKS = "hooks";
1798
+ var PLUGIN_UNINSTALL = ["claude", "plugin", "uninstall", "cli-hooks"];
1758
1799
  var PLUGIN_INSTALL = ["claude", "plugin", "install", "cli-hooks", "--marketplace", `https://${FORGE}/kaynooo/cli.git`];
1759
1800
  var CLI_PREFIX = /^cli-/;
1760
1801
  var shortName = (name) => name.slice(name.indexOf("/") + 1).replace(CLI_PREFIX, "");
1761
1802
  function candidates2(host) {
1762
- const packages = Object.entries(TOOLS).filter(([name]) => !isInstalled(host, name)).map(([name, bins]) => {
1803
+ const packages = Object.entries(TOOLS).map(([name, bins]) => {
1763
1804
  const needs = (NEEDS[name] ?? []).map((need) => need.bin);
1764
- return { id: name, names: [shortName(name), name, `cli-${shortName(name)}`, ...bins], label: shortName(name), hint: `${bins.join(" ")}: ${ABOUT[name] ?? ""}${needs.length === 0 ? "" : `, needs ${needs.join(", ")}`}`, packages: [name] };
1805
+ return { id: name, names: [shortName(name), name, `cli-${shortName(name)}`, ...bins], label: shortName(name), hint: `${bins.join(" ")}: ${ABOUT[name] ?? ""}${needs.length === 0 ? "" : `, needs ${needs.join(", ")}`}`, packages: [name], installed: isInstalled(host, name) };
1765
1806
  });
1766
1807
  const { plugin, mod } = hooksWiring(host);
1767
- if (plugin || mod !== undefined)
1808
+ if (mod !== undefined && !plugin)
1768
1809
  return packages;
1769
- return [...packages, { id: HOOKS, names: [HOOKS, "plugin", "cli-hooks"], label: "hooks", hint: `the Claude Code plugin running guard on every tool call, needs ${HOOKS_REQUIRES}`, packages: isInstalled(host, HOOKS_REQUIRES) ? [] : [HOOKS_REQUIRES] }];
1810
+ return [...packages, { id: HOOKS, names: [HOOKS, "plugin", "cli-hooks"], label: "hooks", hint: `the Claude Code plugin running guard on every tool call, needs ${HOOKS_REQUIRES}`, packages: isInstalled(host, HOOKS_REQUIRES) ? [] : [HOOKS_REQUIRES], installed: plugin }];
1770
1811
  }
1771
1812
  function choose(pool, names) {
1772
1813
  const chosen = new Set;
@@ -1803,12 +1844,21 @@ function plan(host, picked, force) {
1803
1844
  function checksFor(packages, plugin) {
1804
1845
  return [...packages.map((name) => name.slice(name.indexOf("/") + 1)), ...plugin ? ["hooks"] : [], "needs", "completions", ...packages.includes("@kaynooo/cli-forgejo") ? ["tea"] : []];
1805
1846
  }
1806
- async function ask2(pool) {
1847
+ async function ask2(all) {
1807
1848
  if (!isInteractive())
1808
1849
  badUsage("name what to install, or all: doctor --install forgejo check hooks (doctor --install --list shows the choices)");
1809
- const choices = pool.map((each) => ({ value: each, label: each.label, hint: each.hint }));
1810
- const picked = await checklist("install which?", choices, { hint: "name them instead: doctor --install forgejo check" });
1811
- return picked === undefined ? [] : [...picked];
1850
+ const choices = all.map((each) => ({ value: each, label: each.label, hint: `${each.installed ? "installed, " : ""}${each.hint}` }));
1851
+ const picked = await checklist("installed packages", choices, { initial: new Set(all.filter((each) => each.installed)), hint: "check to install, uncheck to remove: doctor --install forgejo check names them" });
1852
+ if (picked === undefined)
1853
+ return;
1854
+ const remove = all.filter((each) => !picked.has(each) && each.installed);
1855
+ if (remove.length > 0 && await confirm(`remove ${remove.map((each) => each.label).join(", ")}?`, { default: false }) !== true)
1856
+ return { add: all.filter((each) => picked.has(each) && !each.installed), remove: [] };
1857
+ return { add: all.filter((each) => picked.has(each) && !each.installed), remove };
1858
+ }
1859
+ function removal(all, remove) {
1860
+ const keepsGuard = all.some((each) => each.id === HOOKS && each.installed && !remove.includes(each));
1861
+ return { packages: [...new Set(remove.flatMap((each) => each.id === HOOKS ? [] : each.packages))].filter((name) => !(keepsGuard && name === HOOKS_REQUIRES)), plugin: remove.some((each) => each.id === HOOKS) };
1812
1862
  }
1813
1863
  async function askWorkspace(host) {
1814
1864
  if (!isInteractive() || workspaceChosen({ home: host.home }))
@@ -1817,8 +1867,9 @@ async function askWorkspace(host) {
1817
1867
  if (folder !== undefined && folder !== "" && !saveWorkspace(folder, { home: host.home }))
1818
1868
  console.error(`could not write the workspace into ${userConfigPath("config", { home: host.home })}: it does not parse, fix it by hand`);
1819
1869
  }
1820
- async function install(host, options, recheck) {
1821
- const pool = candidates2(host);
1870
+ async function install(host, options, recheck, choosing = ask2) {
1871
+ const all = candidates2(host);
1872
+ const pool = all.filter((each) => !each.installed);
1822
1873
  const { dim, green, red, yellow } = paint(process16.stdout);
1823
1874
  if (options.list) {
1824
1875
  for (const each of pool)
@@ -1827,20 +1878,23 @@ async function install(host, options, recheck) {
1827
1878
  console.log("everything is installed");
1828
1879
  return 0;
1829
1880
  }
1830
- if (pool.length === 0) {
1881
+ if (pool.length === 0 && (options.names.length > 0 || !isInteractive())) {
1831
1882
  console.log("everything is installed");
1832
1883
  return 0;
1833
1884
  }
1834
- const unknown = options.names.filter((name) => name !== "all" && !Object.values(candidatesAll(host)).some((each) => each.names.includes(name)));
1885
+ const unknown = options.names.filter((name) => name !== "all" && !all.some((each) => each.names.includes(name)));
1835
1886
  if (unknown.length > 0)
1836
1887
  badUsage(`nothing to install is named ${unknown.join(", ")}: doctor --install --list`);
1837
- const picked = options.names.length === 0 ? await ask2(pool) : choose(pool, options.names);
1888
+ const chosen = options.names.length === 0 ? await choosing(all) : { add: choose(pool, options.names), remove: [] };
1889
+ const picked = chosen?.add ?? [];
1838
1890
  const skipped = options.names.filter((name) => name !== "all" && !pool.some((each) => each.names.includes(name)));
1839
1891
  for (const name of skipped)
1840
1892
  console.log(`${dim("already")} ${name} is installed`);
1841
- if (picked.length === 0) {
1893
+ const removing = removal(all, chosen?.remove ?? []);
1894
+ const removes = removing.packages.length > 0 || removing.plugin;
1895
+ if (picked.length === 0 && !removes) {
1842
1896
  if (skipped.length === 0)
1843
- console.log("nothing picked");
1897
+ console.log("nothing changed");
1844
1898
  return 0;
1845
1899
  }
1846
1900
  const planned = plan(host, picked, options.force);
@@ -1848,7 +1902,7 @@ async function install(host, options, recheck) {
1848
1902
  console.log(`${red("blocked")} ${each.package} needs ${each.need} (${each.why}): ${each.how}; --force installs it anyway`);
1849
1903
  for (const each of planned.warnings)
1850
1904
  console.log(`${yellow("warn ")} ${each.package}: ${each.need} is missing, ${each.why}`);
1851
- if (planned.packages.length === 0 && !planned.plugin)
1905
+ if (planned.packages.length === 0 && !planned.plugin && !removes)
1852
1906
  return 1;
1853
1907
  if (!options.dryRun && planned.packages.some((name) => name === "@kaynooo/cli-workspace" || name === "@kaynooo/cli-pkg"))
1854
1908
  await askWorkspace(host);
@@ -1857,6 +1911,11 @@ async function install(host, options, recheck) {
1857
1911
  console.log(`${options.dryRun ? dim("would ") : green("install")} ${command.join(" ")}`);
1858
1912
  if (planned.plugin)
1859
1913
  console.log(`${options.dryRun ? dim("would ") : green("install")} ${PLUGIN_INSTALL.join(" ")}`);
1914
+ const uninstall = ["bun", "remove", "-g", ...removing.packages];
1915
+ if (removing.packages.length > 0)
1916
+ console.log(`${options.dryRun ? dim("would ") : green("remove ")} ${uninstall.join(" ")}`);
1917
+ if (removing.plugin)
1918
+ console.log(`${options.dryRun ? dim("would ") : green("remove ")} ${PLUGIN_UNINSTALL.join(" ")}`);
1860
1919
  if (options.dryRun)
1861
1920
  return planned.blocked.length > 0 ? 1 : 0;
1862
1921
  if (planned.packages.length > 0) {
@@ -1875,16 +1934,27 @@ async function install(host, options, recheck) {
1875
1934
  console.error(`${red("failed")} ${PLUGIN_INSTALL.join(" ")}: exit ${ran.code}: ${firstLine(ran.stderr || ran.stdout)}`);
1876
1935
  }
1877
1936
  }
1937
+ if (removing.packages.length > 0) {
1938
+ const ran = await host.run(uninstall);
1939
+ if (ran.code !== 0) {
1940
+ console.error(`${red("failed")} bun remove -g: exit ${ran.code}: ${firstLine(ran.stderr || ran.stdout)}`);
1941
+ return 1;
1942
+ }
1943
+ }
1944
+ if (removing.plugin && host.which("claude") === null) {
1945
+ console.error(`${yellow("not run")} ${PLUGIN_UNINSTALL.join(" ")}: claude is not on PATH`);
1946
+ } else if (removing.plugin) {
1947
+ const ran = await host.run(PLUGIN_UNINSTALL);
1948
+ if (ran.code !== 0) {
1949
+ console.error(`${red("failed")} ${PLUGIN_UNINSTALL.join(" ")}: exit ${ran.code}: ${firstLine(ran.stderr || ran.stdout)}`);
1950
+ return 1;
1951
+ }
1952
+ }
1878
1953
  console.log(`
1879
1954
  after the install:`);
1880
1955
  const code = await recheck(checksFor(planned.packages, planned.plugin));
1881
1956
  return planned.blocked.length > 0 ? 1 : code;
1882
1957
  }
1883
- function candidatesAll(host) {
1884
- const open = candidates2(host);
1885
- const done = Object.entries(TOOLS).filter(([name]) => isInstalled(host, name)).map(([name, bins]) => ({ id: name, names: [shortName(name), name, `cli-${shortName(name)}`, ...bins], label: shortName(name), hint: "", packages: [name] }));
1886
- return [...open, ...done];
1887
- }
1888
1958
 
1889
1959
  // src/doctor.ts
1890
1960
  var USAGE = `doctor [--only <check>] [--quiet] [--fix | --install [<name>...|all]] [--list] [--dry-run] [--force] [--json]: checks this host's @kaynooo/cli-* install, one line per check, the command that fixes a failing one beside it, and installs the packages it picks
@@ -1901,7 +1971,7 @@ var OPTIONS = {
1901
1971
  quiet: { type: "boolean", short: "q", description: "print only the failing checks" },
1902
1972
  fix: { type: "boolean", description: "run the fix: lines doctor may run, then check again" },
1903
1973
  "dry-run": { type: "boolean", description: "with --fix or --install, show what would run without running it (alone, it implies --fix)" },
1904
- install: { type: "boolean", description: "install the packages named, or picked from a checklist: forgejo, ft, hooks, all" },
1974
+ install: { type: "boolean", description: "install the packages named, or managed from a checklist, installed ones checked and unchecking removing them: forgejo, ft, hooks, all" },
1905
1975
  list: { type: "boolean", description: "with --install, print the choices" },
1906
1976
  force: { type: "boolean", description: "with --install, install a package even though a tool it requires is missing" }
1907
1977
  };
@@ -1924,7 +1994,7 @@ function printOutcomes(outcomes, log) {
1924
1994
  log(`${marks[status]} ${command}${detail === undefined ? "" : ` ${dim(detail)}`}`);
1925
1995
  }
1926
1996
  async function main(args, host = hostHere()) {
1927
- const { values, positionals } = parse(args, USAGE, OPTIONS, { positionals: ["[name...]"], exits: EXITS, replaces: "checking each bin's version, tea's login and the hooks by hand", examples: ["doctor", ["doctor --json", "the checks for a script"], ["doctor -q", "silent unless something fails"], ["doctor --fix --dry-run", "what --fix would run"], ["doctor --install", "pick the packages to add"], ["doctor --install forgejo check hooks", "three named ones"], ["doctor --install all --dry-run", "what installing everything would run"]] });
1997
+ const { values, positionals } = parse(args, USAGE, OPTIONS, { positionals: ["[name...]"], exits: EXITS, replaces: "checking each bin's version, tea's login and the hooks by hand", examples: ["doctor", ["doctor --json", "the checks for a script"], ["doctor -q", "silent unless something fails"], ["doctor --fix --dry-run", "what --fix would run"], ["doctor --install", "check to install, uncheck to remove"], ["doctor --install forgejo check hooks", "three named ones"], ["doctor --install all --dry-run", "what installing everything would run"]] });
1928
1998
  const only = values.only ?? [];
1929
1999
  if (values.install !== true && (positionals.length > 0 || values.list === true || values.force === true))
1930
2000
  badUsage("names, --list and --force go with --install");
package/dist/cli.js CHANGED
@@ -481,7 +481,6 @@ var DASHES = /^-+/;
481
481
  function jsonOption(holds) {
482
482
  return { type: "boolean", description: holds === undefined ? "print JSON" : `print JSON: ${holds}` };
483
483
  }
484
-
485
484
  class Exit extends Error {
486
485
  code;
487
486
  hint;
@@ -1580,9 +1579,20 @@ var KEY_SEQUENCES = /\x1B\[[0-9;]*[A-Z~]|\x1BO[A-Z]|[\s\S]/giu;
1580
1579
  var CONTROL = /^[\x00-\x1F\x7F]/;
1581
1580
  var WORDS = /\s+/;
1582
1581
  var MAX_ROWS = 10;
1583
- var KEYS = { "\x1B[A": "up", "\x1BOA": "up", "\x1B[B": "down", "\x1BOB": "down", "\r": "enter", "\n": "enter", "\x03": "cancel", "\x1B": "cancel", "\x7F": "erase", "\b": "erase" };
1584
- function readKeys(input) {
1585
- return (input.match(KEY_SEQUENCES) ?? []).flatMap((sequence) => KEYS[sequence] ?? (CONTROL.test(sequence) ? [] : [{ text: sequence }]));
1582
+ var PASTE_START = "\x1B[200~";
1583
+ var PASTE_END = "\x1B[201~";
1584
+ var KEYS = { "\x1B[A": "up", "\x1BOA": "up", "\x1B[B": "down", "\x1BOB": "down", "\x1B[5~": "pageup", "\x1B[6~": "pagedown", "\x1B[H": "home", "\x1B[1~": "home", "\x1BOH": "home", "\x1B[F": "end", "\x1B[4~": "end", "\x1BOF": "end", "\r": "enter", "\n": "enter", "\x03": "cancel", "\x1B": "cancel", "\x7F": "erase", "\b": "erase" };
1585
+ function readKeys(input, paste = { active: false }) {
1586
+ return (input.match(KEY_SEQUENCES) ?? []).flatMap((sequence) => {
1587
+ if (sequence === PASTE_START || sequence === PASTE_END) {
1588
+ paste.active = sequence === PASTE_START;
1589
+ return [];
1590
+ }
1591
+ if (paste.active && (sequence === "\r" || sequence === `
1592
+ `))
1593
+ return [];
1594
+ return KEYS[sequence] ?? (CONTROL.test(sequence) ? [] : [{ text: sequence }]);
1595
+ });
1586
1596
  }
1587
1597
  function flip(checked, value) {
1588
1598
  const next = new Set(checked);
@@ -1613,6 +1623,22 @@ async function checklist(question, choices, { initial = new Set, min = 0, toggle
1613
1623
  });
1614
1624
  return accepted ? checked : undefined;
1615
1625
  }
1626
+ async function confirm(question, { default: fallback, ...settings } = {}) {
1627
+ let answered = fallback;
1628
+ const choices = { true: "Y/n", false: "y/N", undefined: "y/n" }[String(fallback)];
1629
+ const accepted = await ask(question, settings, (colors, state) => [state === "asking" ? asking(colors, question, "", choices) : answer(colors, question, state, answered ? "yes" : "no")], (key) => {
1630
+ if (key === "cancel")
1631
+ return "cancel";
1632
+ if (key === "enter")
1633
+ return answered === undefined ? undefined : "enter";
1634
+ const letter = typeof key === "object" ? key.text.toLowerCase() : "";
1635
+ if (letter !== "y" && letter !== "n")
1636
+ return;
1637
+ answered = letter === "y";
1638
+ return "enter";
1639
+ });
1640
+ return accepted ? answered : undefined;
1641
+ }
1616
1642
  async function input2(question, { default: fallback, mask = false, validate, ...settings } = {}) {
1617
1643
  let value = "";
1618
1644
  let invalid;
@@ -1665,6 +1691,12 @@ class List {
1665
1691
  this.cursor = (this.cursor + (key === "up" ? this.matches.length - 1 : 1)) % this.matches.length;
1666
1692
  return;
1667
1693
  }
1694
+ if (key === "home" || key === "end" || key === "pageup" || key === "pagedown") {
1695
+ const last = this.matches.length - 1;
1696
+ const to = { home: 0, end: last, pageup: this.cursor - MAX_ROWS, pagedown: this.cursor + MAX_ROWS }[key];
1697
+ this.cursor = Math.max(0, Math.min(last, to));
1698
+ return;
1699
+ }
1668
1700
  if (key === "erase")
1669
1701
  this.filter([...this.query].slice(0, -1).join(""));
1670
1702
  else if (typeof key === "object")
@@ -1706,22 +1738,29 @@ async function ask(question, { hint, terminal = process15 }, render, press) {
1706
1738
  if (!isInteractive(terminal))
1707
1739
  fail(`cannot ask "${question}" off a terminal`, 2, hint);
1708
1740
  const colors = paint(stderr);
1709
- let drawn = 0;
1741
+ let drawn = [];
1742
+ let current = "asking";
1710
1743
  let outcome;
1744
+ const columns = () => stderr.columns || 80;
1711
1745
  const draw = (state) => {
1712
- const frame = render(colors, state, { columns: stderr.columns || 80, rows: stderr.rows || 24 });
1713
- stderr.write(`${drawn > 1 ? `\x1B[${drawn - 1}A` : ""}\r\x1B[0J${frame.join(`
1746
+ const frame = render(colors, state, { columns: columns(), rows: stderr.rows || 24 });
1747
+ const rows = drawn.reduce((sum, line) => sum + Math.max(1, Math.ceil(Bun.stringWidth(line) / columns())), 0);
1748
+ stderr.write(`${rows > 1 ? `\x1B[${rows - 1}A` : ""}\r\x1B[0J${frame.join(`
1714
1749
  `)}`);
1715
- drawn = frame.length;
1750
+ drawn = frame;
1751
+ current = state;
1716
1752
  };
1753
+ const redraw = () => draw(current);
1754
+ const paste = { active: false };
1717
1755
  try {
1756
+ stderr.on?.("resize", redraw);
1718
1757
  stdin.setRawMode(true);
1719
1758
  stdin.resume();
1720
- stderr.write("\x1B[?25l");
1759
+ stderr.write("\x1B[?25l\x1B[?2004h");
1721
1760
  draw("asking");
1722
1761
  return await new Promise((resolve) => {
1723
1762
  function onData(data) {
1724
- for (const key of readKeys(data.toString())) {
1763
+ for (const key of readKeys(data.toString(), paste)) {
1725
1764
  const pressed = press(key);
1726
1765
  if (pressed !== undefined) {
1727
1766
  settle(pressed);
@@ -1743,9 +1782,10 @@ async function ask(question, { hint, terminal = process15 }, render, press) {
1743
1782
  stdin.on("end", onEnd);
1744
1783
  });
1745
1784
  } finally {
1785
+ stderr.off?.("resize", redraw);
1746
1786
  draw(outcome ?? "cancel");
1747
1787
  stderr.write(`
1748
- \x1B[?25h`);
1788
+ \x1B[?25h\x1B[?2004l`);
1749
1789
  stdin.setRawMode(false);
1750
1790
  stdin.pause();
1751
1791
  }
@@ -1753,18 +1793,19 @@ async function ask(question, { hint, terminal = process15 }, render, press) {
1753
1793
 
1754
1794
  // src/install.ts
1755
1795
  var HOOKS = "hooks";
1796
+ var PLUGIN_UNINSTALL = ["claude", "plugin", "uninstall", "cli-hooks"];
1756
1797
  var PLUGIN_INSTALL = ["claude", "plugin", "install", "cli-hooks", "--marketplace", `https://${FORGE}/kaynooo/cli.git`];
1757
1798
  var CLI_PREFIX = /^cli-/;
1758
1799
  var shortName = (name) => name.slice(name.indexOf("/") + 1).replace(CLI_PREFIX, "");
1759
1800
  function candidates2(host) {
1760
- const packages = Object.entries(TOOLS).filter(([name]) => !isInstalled(host, name)).map(([name, bins]) => {
1801
+ const packages = Object.entries(TOOLS).map(([name, bins]) => {
1761
1802
  const needs = (NEEDS[name] ?? []).map((need) => need.bin);
1762
- return { id: name, names: [shortName(name), name, `cli-${shortName(name)}`, ...bins], label: shortName(name), hint: `${bins.join(" ")}: ${ABOUT[name] ?? ""}${needs.length === 0 ? "" : `, needs ${needs.join(", ")}`}`, packages: [name] };
1803
+ return { id: name, names: [shortName(name), name, `cli-${shortName(name)}`, ...bins], label: shortName(name), hint: `${bins.join(" ")}: ${ABOUT[name] ?? ""}${needs.length === 0 ? "" : `, needs ${needs.join(", ")}`}`, packages: [name], installed: isInstalled(host, name) };
1763
1804
  });
1764
1805
  const { plugin, mod } = hooksWiring(host);
1765
- if (plugin || mod !== undefined)
1806
+ if (mod !== undefined && !plugin)
1766
1807
  return packages;
1767
- return [...packages, { id: HOOKS, names: [HOOKS, "plugin", "cli-hooks"], label: "hooks", hint: `the Claude Code plugin running guard on every tool call, needs ${HOOKS_REQUIRES}`, packages: isInstalled(host, HOOKS_REQUIRES) ? [] : [HOOKS_REQUIRES] }];
1808
+ return [...packages, { id: HOOKS, names: [HOOKS, "plugin", "cli-hooks"], label: "hooks", hint: `the Claude Code plugin running guard on every tool call, needs ${HOOKS_REQUIRES}`, packages: isInstalled(host, HOOKS_REQUIRES) ? [] : [HOOKS_REQUIRES], installed: plugin }];
1768
1809
  }
1769
1810
  function choose(pool, names) {
1770
1811
  const chosen = new Set;
@@ -1801,12 +1842,21 @@ function plan(host, picked, force) {
1801
1842
  function checksFor(packages, plugin) {
1802
1843
  return [...packages.map((name) => name.slice(name.indexOf("/") + 1)), ...plugin ? ["hooks"] : [], "needs", "completions", ...packages.includes("@kaynooo/cli-forgejo") ? ["tea"] : []];
1803
1844
  }
1804
- async function ask2(pool) {
1845
+ async function ask2(all) {
1805
1846
  if (!isInteractive())
1806
1847
  badUsage("name what to install, or all: doctor --install forgejo check hooks (doctor --install --list shows the choices)");
1807
- const choices = pool.map((each) => ({ value: each, label: each.label, hint: each.hint }));
1808
- const picked = await checklist("install which?", choices, { hint: "name them instead: doctor --install forgejo check" });
1809
- return picked === undefined ? [] : [...picked];
1848
+ const choices = all.map((each) => ({ value: each, label: each.label, hint: `${each.installed ? "installed, " : ""}${each.hint}` }));
1849
+ const picked = await checklist("installed packages", choices, { initial: new Set(all.filter((each) => each.installed)), hint: "check to install, uncheck to remove: doctor --install forgejo check names them" });
1850
+ if (picked === undefined)
1851
+ return;
1852
+ const remove = all.filter((each) => !picked.has(each) && each.installed);
1853
+ if (remove.length > 0 && await confirm(`remove ${remove.map((each) => each.label).join(", ")}?`, { default: false }) !== true)
1854
+ return { add: all.filter((each) => picked.has(each) && !each.installed), remove: [] };
1855
+ return { add: all.filter((each) => picked.has(each) && !each.installed), remove };
1856
+ }
1857
+ function removal(all, remove) {
1858
+ const keepsGuard = all.some((each) => each.id === HOOKS && each.installed && !remove.includes(each));
1859
+ return { packages: [...new Set(remove.flatMap((each) => each.id === HOOKS ? [] : each.packages))].filter((name) => !(keepsGuard && name === HOOKS_REQUIRES)), plugin: remove.some((each) => each.id === HOOKS) };
1810
1860
  }
1811
1861
  async function askWorkspace(host) {
1812
1862
  if (!isInteractive() || workspaceChosen({ home: host.home }))
@@ -1815,8 +1865,9 @@ async function askWorkspace(host) {
1815
1865
  if (folder !== undefined && folder !== "" && !saveWorkspace(folder, { home: host.home }))
1816
1866
  console.error(`could not write the workspace into ${userConfigPath("config", { home: host.home })}: it does not parse, fix it by hand`);
1817
1867
  }
1818
- async function install(host, options, recheck) {
1819
- const pool = candidates2(host);
1868
+ async function install(host, options, recheck, choosing = ask2) {
1869
+ const all = candidates2(host);
1870
+ const pool = all.filter((each) => !each.installed);
1820
1871
  const { dim, green, red, yellow } = paint(process16.stdout);
1821
1872
  if (options.list) {
1822
1873
  for (const each of pool)
@@ -1825,20 +1876,23 @@ async function install(host, options, recheck) {
1825
1876
  console.log("everything is installed");
1826
1877
  return 0;
1827
1878
  }
1828
- if (pool.length === 0) {
1879
+ if (pool.length === 0 && (options.names.length > 0 || !isInteractive())) {
1829
1880
  console.log("everything is installed");
1830
1881
  return 0;
1831
1882
  }
1832
- const unknown = options.names.filter((name) => name !== "all" && !Object.values(candidatesAll(host)).some((each) => each.names.includes(name)));
1883
+ const unknown = options.names.filter((name) => name !== "all" && !all.some((each) => each.names.includes(name)));
1833
1884
  if (unknown.length > 0)
1834
1885
  badUsage(`nothing to install is named ${unknown.join(", ")}: doctor --install --list`);
1835
- const picked = options.names.length === 0 ? await ask2(pool) : choose(pool, options.names);
1886
+ const chosen = options.names.length === 0 ? await choosing(all) : { add: choose(pool, options.names), remove: [] };
1887
+ const picked = chosen?.add ?? [];
1836
1888
  const skipped = options.names.filter((name) => name !== "all" && !pool.some((each) => each.names.includes(name)));
1837
1889
  for (const name of skipped)
1838
1890
  console.log(`${dim("already")} ${name} is installed`);
1839
- if (picked.length === 0) {
1891
+ const removing = removal(all, chosen?.remove ?? []);
1892
+ const removes = removing.packages.length > 0 || removing.plugin;
1893
+ if (picked.length === 0 && !removes) {
1840
1894
  if (skipped.length === 0)
1841
- console.log("nothing picked");
1895
+ console.log("nothing changed");
1842
1896
  return 0;
1843
1897
  }
1844
1898
  const planned = plan(host, picked, options.force);
@@ -1846,7 +1900,7 @@ async function install(host, options, recheck) {
1846
1900
  console.log(`${red("blocked")} ${each.package} needs ${each.need} (${each.why}): ${each.how}; --force installs it anyway`);
1847
1901
  for (const each of planned.warnings)
1848
1902
  console.log(`${yellow("warn ")} ${each.package}: ${each.need} is missing, ${each.why}`);
1849
- if (planned.packages.length === 0 && !planned.plugin)
1903
+ if (planned.packages.length === 0 && !planned.plugin && !removes)
1850
1904
  return 1;
1851
1905
  if (!options.dryRun && planned.packages.some((name) => name === "@kaynooo/cli-workspace" || name === "@kaynooo/cli-pkg"))
1852
1906
  await askWorkspace(host);
@@ -1855,6 +1909,11 @@ async function install(host, options, recheck) {
1855
1909
  console.log(`${options.dryRun ? dim("would ") : green("install")} ${command.join(" ")}`);
1856
1910
  if (planned.plugin)
1857
1911
  console.log(`${options.dryRun ? dim("would ") : green("install")} ${PLUGIN_INSTALL.join(" ")}`);
1912
+ const uninstall = ["bun", "remove", "-g", ...removing.packages];
1913
+ if (removing.packages.length > 0)
1914
+ console.log(`${options.dryRun ? dim("would ") : green("remove ")} ${uninstall.join(" ")}`);
1915
+ if (removing.plugin)
1916
+ console.log(`${options.dryRun ? dim("would ") : green("remove ")} ${PLUGIN_UNINSTALL.join(" ")}`);
1858
1917
  if (options.dryRun)
1859
1918
  return planned.blocked.length > 0 ? 1 : 0;
1860
1919
  if (planned.packages.length > 0) {
@@ -1873,16 +1932,27 @@ async function install(host, options, recheck) {
1873
1932
  console.error(`${red("failed")} ${PLUGIN_INSTALL.join(" ")}: exit ${ran.code}: ${firstLine(ran.stderr || ran.stdout)}`);
1874
1933
  }
1875
1934
  }
1935
+ if (removing.packages.length > 0) {
1936
+ const ran = await host.run(uninstall);
1937
+ if (ran.code !== 0) {
1938
+ console.error(`${red("failed")} bun remove -g: exit ${ran.code}: ${firstLine(ran.stderr || ran.stdout)}`);
1939
+ return 1;
1940
+ }
1941
+ }
1942
+ if (removing.plugin && host.which("claude") === null) {
1943
+ console.error(`${yellow("not run")} ${PLUGIN_UNINSTALL.join(" ")}: claude is not on PATH`);
1944
+ } else if (removing.plugin) {
1945
+ const ran = await host.run(PLUGIN_UNINSTALL);
1946
+ if (ran.code !== 0) {
1947
+ console.error(`${red("failed")} ${PLUGIN_UNINSTALL.join(" ")}: exit ${ran.code}: ${firstLine(ran.stderr || ran.stdout)}`);
1948
+ return 1;
1949
+ }
1950
+ }
1876
1951
  console.log(`
1877
1952
  after the install:`);
1878
1953
  const code = await recheck(checksFor(planned.packages, planned.plugin));
1879
1954
  return planned.blocked.length > 0 ? 1 : code;
1880
1955
  }
1881
- function candidatesAll(host) {
1882
- const open = candidates2(host);
1883
- const done = Object.entries(TOOLS).filter(([name]) => isInstalled(host, name)).map(([name, bins]) => ({ id: name, names: [shortName(name), name, `cli-${shortName(name)}`, ...bins], label: shortName(name), hint: "", packages: [name] }));
1884
- return [...open, ...done];
1885
- }
1886
1956
 
1887
1957
  // src/doctor.ts
1888
1958
  var USAGE = `doctor [--only <check>] [--quiet] [--fix | --install [<name>...|all]] [--list] [--dry-run] [--force] [--json]: checks this host's @kaynooo/cli-* install, one line per check, the command that fixes a failing one beside it, and installs the packages it picks
@@ -1899,7 +1969,7 @@ var OPTIONS = {
1899
1969
  quiet: { type: "boolean", short: "q", description: "print only the failing checks" },
1900
1970
  fix: { type: "boolean", description: "run the fix: lines doctor may run, then check again" },
1901
1971
  "dry-run": { type: "boolean", description: "with --fix or --install, show what would run without running it (alone, it implies --fix)" },
1902
- install: { type: "boolean", description: "install the packages named, or picked from a checklist: forgejo, ft, hooks, all" },
1972
+ install: { type: "boolean", description: "install the packages named, or managed from a checklist, installed ones checked and unchecking removing them: forgejo, ft, hooks, all" },
1903
1973
  list: { type: "boolean", description: "with --install, print the choices" },
1904
1974
  force: { type: "boolean", description: "with --install, install a package even though a tool it requires is missing" }
1905
1975
  };
@@ -1922,7 +1992,7 @@ function printOutcomes(outcomes, log) {
1922
1992
  log(`${marks[status]} ${command}${detail === undefined ? "" : ` ${dim(detail)}`}`);
1923
1993
  }
1924
1994
  async function main(args, host = hostHere()) {
1925
- const { values, positionals } = parse(args, USAGE, OPTIONS, { positionals: ["[name...]"], exits: EXITS, replaces: "checking each bin's version, tea's login and the hooks by hand", examples: ["doctor", ["doctor --json", "the checks for a script"], ["doctor -q", "silent unless something fails"], ["doctor --fix --dry-run", "what --fix would run"], ["doctor --install", "pick the packages to add"], ["doctor --install forgejo check hooks", "three named ones"], ["doctor --install all --dry-run", "what installing everything would run"]] });
1995
+ const { values, positionals } = parse(args, USAGE, OPTIONS, { positionals: ["[name...]"], exits: EXITS, replaces: "checking each bin's version, tea's login and the hooks by hand", examples: ["doctor", ["doctor --json", "the checks for a script"], ["doctor -q", "silent unless something fails"], ["doctor --fix --dry-run", "what --fix would run"], ["doctor --install", "check to install, uncheck to remove"], ["doctor --install forgejo check hooks", "three named ones"], ["doctor --install all --dry-run", "what installing everything would run"]] });
1926
1996
  const only = values.only ?? [];
1927
1997
  if (values.install !== true && (positionals.length > 0 || values.list === true || values.force === true))
1928
1998
  badUsage("names, --list and --force go with --install");
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kaynooo/cli-doctor",
3
3
  "type": "module",
4
- "version": "0.9.0",
4
+ "version": "0.11.0",
5
5
  "description": "doctor: checks the @kaynooo/cli-* install of a host: each bin's version against the registry, tea's login to Forgejo, the Claude Code hooks and the Bun floor; cli list: every bin in one line, read from its own help.",
6
6
  "author": "Kaynooo <contact@kaynooo.fr>",
7
7
  "license": "MIT",
@@ -37,6 +37,6 @@
37
37
  "doctor": "$npm_execpath src/cli.ts"
38
38
  },
39
39
  "devDependencies": {
40
- "@kaynooo/cli-kit": "0.22.0"
40
+ "@kaynooo/cli-kit": "0.24.0"
41
41
  }
42
42
  }