forgemap 0.5.0 → 0.6.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.
@@ -3,12 +3,13 @@ import { defineCommand, runMain } from "citty";
3
3
  import consola from "consola";
4
4
  import { access, mkdir, readFile, readdir, rename, rm, rmdir, stat, writeFile } from "node:fs/promises";
5
5
  import { colors, formatTree } from "consola/utils";
6
- import { dirname, isAbsolute, join, resolve } from "pathe";
6
+ import { dirname, extname, isAbsolute, join, relative, resolve } from "pathe";
7
7
  import { homedir } from "node:os";
8
8
  import { existsSync, readFileSync, realpathSync } from "node:fs";
9
9
  import { loadConfig } from "c12";
10
10
  import { createHash } from "node:crypto";
11
11
  import { spawn } from "node:child_process";
12
+ import { updateConfig } from "c12/update";
12
13
  import Fuse from "fuse.js";
13
14
  //#region src/commands/cd.ts
14
15
  /**
@@ -81,6 +82,39 @@ function findGlobalConfig() {
81
82
  if (existsSync(candidate)) return candidate;
82
83
  }
83
84
  }
85
+ /**
86
+ * Every `forgemap.config.*` a change could be written to, in resolution order:
87
+ * one per directory walking up from `start` (nearest first, mirroring the
88
+ * loader's first-basename-wins rule), then the global config. Used by the
89
+ * `forge` command to let the user pick a target when more than one exists.
90
+ */
91
+ function discoverConfigFiles(start = process.cwd()) {
92
+ const found = [];
93
+ const seen = /* @__PURE__ */ new Set();
94
+ let dir = resolve(start);
95
+ for (;;) {
96
+ for (const base of CONFIG_BASENAMES) {
97
+ const candidate = join(dir, base);
98
+ if (existsSync(candidate)) {
99
+ seen.add(candidate);
100
+ found.push({
101
+ path: candidate,
102
+ source: "walk-up"
103
+ });
104
+ break;
105
+ }
106
+ }
107
+ const parent = dirname(dir);
108
+ if (parent === dir) break;
109
+ dir = parent;
110
+ }
111
+ const global = findGlobalConfig();
112
+ if (global && !seen.has(global)) found.push({
113
+ path: global,
114
+ source: "global"
115
+ });
116
+ return found;
117
+ }
84
118
  var DEFAULT_CONFIG$1 = {
85
119
  root: ".",
86
120
  defaultForge: "github",
@@ -117,6 +151,7 @@ async function loadForgeMapConfig(options = {}) {
117
151
  }
118
152
  }
119
153
  }
154
+ if (explicit) explicit = resolve(startDir, explicit);
120
155
  const cwd = explicit ? dirname(explicit) : startDir;
121
156
  const { config, configFile } = await loadConfig({
122
157
  name: "forgemap",
@@ -1335,146 +1370,6 @@ async function installRcBlock(shell, label, lines, legacyLabels = []) {
1335
1370
  };
1336
1371
  }
1337
1372
  //#endregion
1338
- //#region src/commands/completion.ts
1339
- var SUBCOMMANDS = [
1340
- "clone",
1341
- "import",
1342
- "cleanup",
1343
- "delete",
1344
- "cd",
1345
- "path",
1346
- "open",
1347
- "search",
1348
- "pick",
1349
- "status",
1350
- "sync",
1351
- "validate",
1352
- "shell-init",
1353
- "completion",
1354
- "config"
1355
- ];
1356
- var SLUG_COMMANDS = [
1357
- "clone",
1358
- "cd",
1359
- "path",
1360
- "open",
1361
- "search",
1362
- "pick",
1363
- "delete"
1364
- ];
1365
- function renderBash() {
1366
- return `# forgemap bash completion — drop into your ~/.bashrc:
1367
- # eval "$(forgemap completion bash)"
1368
- _forgemap_completion() {
1369
- local cur prev cmd words
1370
- COMPREPLY=()
1371
- cur="\${COMP_WORDS[COMP_CWORD]}"
1372
- cmd="\${COMP_WORDS[1]}"
1373
-
1374
- if [ "$COMP_CWORD" = "1" ]; then
1375
- COMPREPLY=( $(compgen -W "${SUBCOMMANDS.join(" ")}" -- "$cur") )
1376
- return
1377
- fi
1378
-
1379
- case "$cmd" in
1380
- ${SLUG_COMMANDS.join("|")})
1381
- local slugs
1382
- slugs=$(forgemap search '' --format slug 2>/dev/null)
1383
- COMPREPLY=( $(compgen -W "$slugs" -- "$cur") )
1384
- ;;
1385
- esac
1386
- }
1387
- complete -F _forgemap_completion forgemap
1388
- `;
1389
- }
1390
- function renderZsh() {
1391
- return `# forgemap zsh completion — drop into your ~/.zshrc:
1392
- # eval "$(forgemap completion zsh)"
1393
- _forgemap() {
1394
- local context state line
1395
- local -a subcommands slug_cmds
1396
- subcommands=(${SUBCOMMANDS.map((s) => `'${s}'`).join(" ")})
1397
- slug_cmds=(${SLUG_COMMANDS.map((s) => `'${s}'`).join(" ")})
1398
-
1399
- _arguments -C \\
1400
- '1: :->cmd' \\
1401
- '*::arg:->args'
1402
-
1403
- case "$state" in
1404
- cmd) _describe 'forgemap subcommand' subcommands ;;
1405
- args)
1406
- if (( $slug_cmds[(I)$words[1]] )); then
1407
- local -a slugs
1408
- slugs=("\${(@f)$(forgemap search '' --format slug 2>/dev/null)}")
1409
- _describe 'slug' slugs
1410
- fi
1411
- ;;
1412
- esac
1413
- }
1414
- compdef _forgemap forgemap
1415
- `;
1416
- }
1417
- function renderFish$1() {
1418
- const slugCmdsList = SLUG_COMMANDS.map((s) => `"${s}"`).join(" ");
1419
- return `# forgemap fish completion — drop into your ~/.config/fish/config.fish:
1420
- # forgemap completion fish | source
1421
-
1422
- # Subcommands (depth 1).
1423
- complete -c forgemap -f -n '__fish_use_subcommand' -a '${SUBCOMMANDS.join(" ")}'
1424
-
1425
- # Slugs (depth 2) for commands that take one.
1426
- function __forgemap_needs_slug
1427
- set -l tokens (commandline -opc)
1428
- set -l slug_cmds ${slugCmdsList}
1429
- if test (count $tokens) -ge 2; and contains $tokens[2] $slug_cmds
1430
- return 0
1431
- end
1432
- return 1
1433
- end
1434
-
1435
- complete -c forgemap -f -n '__forgemap_needs_slug' \\
1436
- -a '(forgemap search "" --format slug 2>/dev/null)'
1437
- `;
1438
- }
1439
- var completionCommand = defineCommand({
1440
- meta: {
1441
- name: "completion",
1442
- description: "Print a shell completion script. Source via `eval \"$(forgemap completion)\"`."
1443
- },
1444
- args: {
1445
- shell: {
1446
- type: "positional",
1447
- description: `Shell flavor (${SUPPORTED_SHELLS.join(", ")}). Auto-detected from $SHELL if omitted.`,
1448
- required: false
1449
- },
1450
- install: {
1451
- type: "boolean",
1452
- description: "Append the completion loader to your shell's rc file (idempotent) instead of printing",
1453
- default: false
1454
- }
1455
- },
1456
- async run({ args }) {
1457
- const requested = args.shell ?? detectShell();
1458
- if (!SUPPORTED_SHELLS.includes(requested)) {
1459
- consola.error(`Unsupported shell "${requested}". Supported: ${SUPPORTED_SHELLS.join(", ")}.`);
1460
- process.exitCode = 1;
1461
- return;
1462
- }
1463
- if (args.install) {
1464
- const { status, rcFile } = await installRcBlock(requested, "completion", [requested === "fish" ? "forgemap completion fish | source" : `eval "$(forgemap completion ${requested})"`]);
1465
- if (status === "present") consola.info(`forgemap completion already present in ${rcFile}.`);
1466
- else {
1467
- const verb = status === "updated" ? "Updated" : "Added";
1468
- consola.success(`${verb} forgemap completion in ${rcFile}.`);
1469
- consola.info(`Run \`source ${rcFile}\` or restart your shell to activate it.`);
1470
- }
1471
- return;
1472
- }
1473
- const out = requested === "fish" ? renderFish$1() : requested === "zsh" ? renderZsh() : renderBash();
1474
- process.stdout.write(out);
1475
- }
1476
- });
1477
- //#endregion
1478
1373
  //#region src/config/write.ts
1479
1374
  var HEADER = `/**
1480
1375
  * forgemap configuration.
@@ -1729,66 +1624,687 @@ var deleteCommand = defineCommand({
1729
1624
  }
1730
1625
  });
1731
1626
  //#endregion
1732
- //#region src/repos/import.ts
1733
- async function listDirs(path) {
1734
- try {
1735
- return (await readdir(path, { withFileTypes: true })).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name);
1736
- } catch (error) {
1737
- if (error.code === "ENOENT") return [];
1738
- throw error;
1627
+ //#region src/config/forges.ts
1628
+ /** Every forge `type` the config schema accepts, in prompt/display order. */
1629
+ var FORGE_TYPES = [
1630
+ "github",
1631
+ "gitlab",
1632
+ "gitea",
1633
+ "codeberg",
1634
+ "git"
1635
+ ];
1636
+ /** Canonical host per forge type, offered as the host prompt's default. The
1637
+ * self-hosted flavors (`gitea`, plain `git`) have no universal host, so none
1638
+ * is suggested for them. */
1639
+ var DEFAULT_HOSTS = {
1640
+ github: "github.com",
1641
+ gitlab: "gitlab.com",
1642
+ codeberg: "codeberg.org"
1643
+ };
1644
+ /** Git clone protocols, in prompt order (`ssh` is the schema default). */
1645
+ var GIT_PROTOCOLS = ["ssh", "https"];
1646
+ /** Reject empty / whitespace-only keys; any other string is a valid map key.
1647
+ * Returns an error message, or `null` when the key is acceptable. */
1648
+ function validateForgeKey(raw) {
1649
+ if (raw.trim().length === 0) return "Forge key must not be empty.";
1650
+ return null;
1651
+ }
1652
+ /** Whether `value` is one of the schema's forge types (narrows a raw flag). */
1653
+ function isForgeType(value) {
1654
+ return FORGE_TYPES.includes(value);
1655
+ }
1656
+ /** Whether `value` is a supported git protocol. */
1657
+ function isGitProtocol(value) {
1658
+ return GIT_PROTOCOLS.includes(value);
1659
+ }
1660
+ /** Build a `ForgeConfig` from collected input, keeping `protocol` only when it
1661
+ * is the non-default (`https`) git protocol. */
1662
+ function buildForge(input) {
1663
+ if (input.type === "git") {
1664
+ const forge = {
1665
+ type: "git",
1666
+ host: input.host,
1667
+ dir: input.dir
1668
+ };
1669
+ if (input.protocol === "https") forge.protocol = "https";
1670
+ return forge;
1739
1671
  }
1672
+ return {
1673
+ type: input.type,
1674
+ host: input.host,
1675
+ dir: input.dir
1676
+ };
1677
+ }
1678
+ function addForge(config, key, forge) {
1679
+ if (!config.forges) config.forges = {};
1680
+ config.forges[key] = forge;
1681
+ }
1682
+ function removeForge(config, key) {
1683
+ if (config.forges) delete config.forges[key];
1684
+ }
1685
+ function setDefaultForge(config, key) {
1686
+ config.defaultForge = key;
1740
1687
  }
1688
+ /** Apply a partial change to an existing forge in place. Clears `protocol`
1689
+ * whenever the resulting type is not `git`, since it is meaningless there. */
1690
+ function editForge(config, key, patch) {
1691
+ const forge = config.forges?.[key];
1692
+ if (!forge) return;
1693
+ if (patch.type !== void 0) forge.type = patch.type;
1694
+ if (patch.host !== void 0) forge.host = patch.host;
1695
+ if (patch.dir !== void 0) forge.dir = patch.dir;
1696
+ if (forge.type !== "git") delete forge.protocol;
1697
+ else if (patch.protocol === null) delete forge.protocol;
1698
+ else if (patch.protocol !== void 0) forge.protocol = patch.protocol;
1699
+ }
1700
+ //#endregion
1701
+ //#region src/config/mutate.ts
1741
1702
  /**
1742
- * Structure-driven depth-3 walk of `<path>/<serverDir>/<owner>/<repo>`.
1743
- * Unlike `scanRepos`, this is config-free: every top-level directory is a
1744
- * candidate server dir, and the names are discovered rather than configured.
1703
+ * Apply an in-place mutation to a `forgemap.config.*` file, preserving its
1704
+ * formatting and comments.
1705
+ *
1706
+ * `.ts`/`.mts`/`.js`/… are round-tripped through c12's `updateConfig`, which
1707
+ * parses the module with magicast and edits the exported object literal — it
1708
+ * transparently unwraps a `defineForgeMapConfig(...)` call. Plain `.json`
1709
+ * configs, which magicast/updateConfig refuse, are read, mutated and written
1710
+ * back directly.
1711
+ *
1712
+ * Rejects when the source can't be edited safely (e.g. forges built dynamically
1713
+ * rather than declared as a literal); callers surface that as a manual-edit
1714
+ * fallback rather than crashing.
1745
1715
  */
1746
- async function discoverForgemapLayout(path) {
1747
- const repos = [];
1748
- for (const serverDir of await listDirs(path)) {
1749
- const serverPath = join(path, serverDir);
1750
- for (const owner of await listDirs(serverPath)) {
1751
- const ownerPath = join(serverPath, owner);
1752
- for (const repo of await listDirs(ownerPath)) repos.push({
1753
- serverDir,
1754
- owner,
1755
- repo,
1756
- localPath: join(ownerPath, repo)
1757
- });
1716
+ async function mutateConfigFile(path, mutate) {
1717
+ if (extname(path) === ".json") {
1718
+ const current = JSON.parse(await readFile(path, "utf8"));
1719
+ mutate(current);
1720
+ await writeFile(path, `${JSON.stringify(current, null, 2)}\n`, "utf8");
1721
+ return;
1722
+ }
1723
+ await updateConfig({
1724
+ cwd: dirname(path),
1725
+ configFile: "forgemap.config",
1726
+ onUpdate: (config) => {
1727
+ mutate(config);
1758
1728
  }
1729
+ });
1730
+ }
1731
+ //#endregion
1732
+ //#region src/repos/picker.ts
1733
+ /**
1734
+ * Show the interactive repo picker and return the chosen local path
1735
+ * (undefined when the user cancels).
1736
+ *
1737
+ * `$(forgemap pick)` / `$(forgemap path <q>)` captures stdout, so the
1738
+ * interactive TUI must not go there. consola/clack writes the UI to stdout AND
1739
+ * reads stdout.rows/columns for layout — but a captured stdout is a pipe (no
1740
+ * rows → nothing renders). So for the duration of the prompt: route stdout
1741
+ * writes to stderr (the real TTY) and borrow stderr's dimensions, then
1742
+ * restore. stdout stays clean for the chosen path only.
1743
+ *
1744
+ * Callers must check {@link canPrompt} first — without a TTY on stdin there is
1745
+ * nobody to answer.
1746
+ */
1747
+ async function promptRepoChoice(candidates) {
1748
+ const out = process.stdout;
1749
+ const realWrite = out.write;
1750
+ const saved = {
1751
+ rows: Object.getOwnPropertyDescriptor(out, "rows"),
1752
+ columns: Object.getOwnPropertyDescriptor(out, "columns"),
1753
+ isTTY: Object.getOwnPropertyDescriptor(out, "isTTY")
1754
+ };
1755
+ const fake = (key, value) => {
1756
+ Object.defineProperty(out, key, {
1757
+ configurable: true,
1758
+ value
1759
+ });
1760
+ };
1761
+ const restore = (key) => {
1762
+ if (saved[key]) Object.defineProperty(out, key, saved[key]);
1763
+ else delete out[key];
1764
+ };
1765
+ out.write = process.stderr.write.bind(process.stderr);
1766
+ fake("rows", process.stderr.rows ?? 24);
1767
+ fake("columns", process.stderr.columns ?? 80);
1768
+ fake("isTTY", true);
1769
+ let choice;
1770
+ try {
1771
+ choice = await consola.prompt("Select a repo", {
1772
+ type: "select",
1773
+ options: candidates.map((r) => ({
1774
+ label: `${colors.gray(`${r.forgeName}:`)}${r.slug}`,
1775
+ value: r.localPath,
1776
+ hint: r.localPath
1777
+ }))
1778
+ });
1779
+ } finally {
1780
+ out.write = realWrite;
1781
+ restore("rows");
1782
+ restore("columns");
1783
+ restore("isTTY");
1759
1784
  }
1760
- return repos;
1785
+ return typeof choice === "string" && choice ? choice : void 0;
1761
1786
  }
1762
- function forgeTypeForHost(host) {
1763
- return host === "github.com" ? "github" : "git";
1787
+ /** Whether an interactive prompt can be shown at all. */
1788
+ function canPrompt() {
1789
+ return Boolean(process.stdin.isTTY);
1790
+ }
1791
+ //#endregion
1792
+ //#region src/commands/forge/shared.ts
1793
+ /** Whether interactive prompts can be shown (a TTY on stdin to answer them). */
1794
+ function interactive() {
1795
+ return canPrompt();
1796
+ }
1797
+ /** Prompt for free text. Returns the raw string, or `null` when cancelled. */
1798
+ async function promptText(message, placeholder) {
1799
+ const answer = await consola.prompt(message, {
1800
+ type: "text",
1801
+ placeholder,
1802
+ cancel: "null"
1803
+ });
1804
+ return typeof answer === "string" ? answer : null;
1805
+ }
1806
+ /** Prompt to pick one of `options`. Returns the value, or `null` when cancelled. */
1807
+ async function promptSelect(message, options) {
1808
+ const answer = await consola.prompt(message, {
1809
+ type: "select",
1810
+ options: [...options],
1811
+ cancel: "null"
1812
+ });
1813
+ return typeof answer === "string" && answer ? answer : null;
1814
+ }
1815
+ /** Yes/no confirmation. Returns `false` when declined or cancelled. */
1816
+ async function confirmChange(message) {
1817
+ return await consola.prompt(message, {
1818
+ type: "confirm",
1819
+ cancel: "null"
1820
+ }) === true;
1764
1821
  }
1765
1822
  /**
1766
- * Derive a `root` + one forge per server dir from the analyzed reports.
1767
- * Host (and therefore type) come from the dominant origin host of the repos
1768
- * under each server dir.
1823
+ * Choose which config file `add` writes to.
1824
+ * - `--config <path>` always wins (created when it does not exist).
1825
+ * - otherwise discover candidates: one → use it; several with a TTY → present a
1826
+ * select (the final step before confirming); several without a TTY → nearest.
1827
+ * - nothing discovered → a fresh `forgemap.config.ts` in the cwd.
1828
+ *
1829
+ * Returns `null` when the user cancels the select.
1769
1830
  */
1770
- function deriveConfig(reports, path) {
1771
- const forges = {};
1772
- const counts = /* @__PURE__ */ new Map();
1773
- const byServer = /* @__PURE__ */ new Map();
1774
- for (const report of reports) {
1775
- const list = byServer.get(report.repo.serverDir);
1776
- if (list) list.push(report);
1777
- else byServer.set(report.repo.serverDir, [report]);
1778
- }
1779
- for (const [serverDir, group] of byServer) {
1780
- counts.set(serverDir, group.length);
1781
- const hostTally = /* @__PURE__ */ new Map();
1782
- for (const report of group) if (report.originHost) hostTally.set(report.originHost, (hostTally.get(report.originHost) ?? 0) + 1);
1783
- const host = [...hostTally.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? "";
1784
- forges[serverDir] = {
1785
- type: host ? forgeTypeForHost(host) : "git",
1786
- host,
1787
- dir: serverDir
1831
+ async function resolveAddTarget(explicit) {
1832
+ if (explicit) {
1833
+ const path = resolve(process.cwd(), explicit);
1834
+ return {
1835
+ path,
1836
+ create: !existsSync(path)
1788
1837
  };
1789
1838
  }
1790
- return {
1791
- root: path,
1839
+ const candidates = discoverConfigFiles();
1840
+ if (candidates.length === 0) return {
1841
+ path: join(process.cwd(), "forgemap.config.ts"),
1842
+ create: true
1843
+ };
1844
+ if (candidates.length === 1 || !interactive()) return {
1845
+ path: candidates[0].path,
1846
+ create: false
1847
+ };
1848
+ const choice = await consola.prompt("Which config file should this change be written to?", {
1849
+ type: "select",
1850
+ options: candidates.map((c) => ({
1851
+ label: relative(process.cwd(), c.path) || c.path,
1852
+ value: c.path,
1853
+ hint: c.source
1854
+ })),
1855
+ cancel: "null"
1856
+ });
1857
+ if (typeof choice !== "string" || !choice) {
1858
+ consola.info("Aborted — nothing changed.");
1859
+ return null;
1860
+ }
1861
+ return {
1862
+ path: choice,
1863
+ create: false
1864
+ };
1865
+ }
1866
+ /**
1867
+ * The config file `edit`/`remove` operate on — the forge already lives in a real
1868
+ * file, so `--config` or the resolved config file is used. Prints an error and
1869
+ * returns `null` when only the built-in defaults are in effect (no file).
1870
+ */
1871
+ function existingConfigFile(loaded, explicit) {
1872
+ if (explicit) return resolve(process.cwd(), explicit);
1873
+ if (!loaded.configFile) {
1874
+ consola.error("No forgemap config file found. Run `forgemap config init` or `forgemap forge add` first.");
1875
+ return null;
1876
+ }
1877
+ return loaded.configFile;
1878
+ }
1879
+ /**
1880
+ * Round-trip `mutate` into `path`; on failure (a config too dynamic to rewrite)
1881
+ * report it and print the change for manual application instead of crashing.
1882
+ * Returns whether the file was updated.
1883
+ */
1884
+ async function applyChange(path, mutate, manualHint) {
1885
+ try {
1886
+ await mutateConfigFile(path, mutate);
1887
+ return true;
1888
+ } catch (error) {
1889
+ consola.error(`Could not update ${path} automatically: ${error.message}`);
1890
+ consola.info("Apply this change by hand instead:");
1891
+ manualHint();
1892
+ return false;
1893
+ }
1894
+ }
1895
+ /** Print a forge as a `forgemap.config` block (the manual-edit fallback). */
1896
+ function printManualForge(key, forge) {
1897
+ consola.log(` ${key}: {`);
1898
+ consola.log(` type: '${forge.type}',`);
1899
+ consola.log(` host: '${forge.host}',`);
1900
+ consola.log(` dir: '${forge.dir}'${forge.protocol ? "," : ""}`);
1901
+ if (forge.protocol) consola.log(` protocol: '${forge.protocol}'`);
1902
+ consola.log(" }");
1903
+ }
1904
+ //#endregion
1905
+ //#region src/commands/forge/add.ts
1906
+ var forgeAddCommand = defineCommand({
1907
+ meta: {
1908
+ name: "add",
1909
+ description: "Add a forge to the config (prompts for anything not passed)"
1910
+ },
1911
+ args: {
1912
+ key: {
1913
+ type: "positional",
1914
+ required: false,
1915
+ description: "Forge key, e.g. github or work"
1916
+ },
1917
+ type: {
1918
+ type: "string",
1919
+ description: `Forge type (${FORGE_TYPES.join(", ")})`
1920
+ },
1921
+ host: {
1922
+ type: "string",
1923
+ description: "Forge host, e.g. github.com"
1924
+ },
1925
+ dir: {
1926
+ type: "string",
1927
+ description: "Directory under root, e.g. comGithub"
1928
+ },
1929
+ protocol: {
1930
+ type: "string",
1931
+ description: `Clone protocol for type=git (${GIT_PROTOCOLS.join(", ")})`
1932
+ },
1933
+ default: {
1934
+ type: "boolean",
1935
+ description: "Set this forge as the default",
1936
+ default: false
1937
+ },
1938
+ config: {
1939
+ type: "string",
1940
+ description: "Path to the forgemap config file to modify"
1941
+ },
1942
+ yes: {
1943
+ type: "boolean",
1944
+ description: "Skip the confirmation prompt",
1945
+ default: false
1946
+ }
1947
+ },
1948
+ async run({ args }) {
1949
+ const loaded = await loadForgeMapConfig({ configFile: args.config });
1950
+ const tty = interactive();
1951
+ let key = typeof args.key === "string" ? args.key.trim() : "";
1952
+ if (!key && tty) {
1953
+ const answer = await promptText("Forge key (e.g. github, work):");
1954
+ if (answer === null) return abort$2();
1955
+ key = answer.trim();
1956
+ }
1957
+ const keyError = validateForgeKey(key);
1958
+ if (keyError) return fail$2(keyError);
1959
+ if (loaded.configFile && key in loaded.config.forges) return fail$2(`Forge "${key}" already exists. Use \`forgemap forge edit ${key}\` to change it.`);
1960
+ let type;
1961
+ if (typeof args.type === "string") {
1962
+ if (!isForgeType(args.type)) return fail$2(invalidType$1(args.type));
1963
+ type = args.type;
1964
+ } else if (tty) {
1965
+ const answer = await promptSelect("Forge type:", FORGE_TYPES);
1966
+ if (answer === null || !isForgeType(answer)) return abort$2();
1967
+ type = answer;
1968
+ }
1969
+ if (!type) return fail$2("Missing forge type. Pass --type.");
1970
+ const suggestedHost = DEFAULT_HOSTS[type] ?? "";
1971
+ let host = typeof args.host === "string" ? args.host.trim() : "";
1972
+ if (!host && tty) {
1973
+ const answer = await promptText("Host:", suggestedHost);
1974
+ if (answer === null) return abort$2();
1975
+ host = answer.trim() || suggestedHost;
1976
+ } else if (!host) host = suggestedHost;
1977
+ if (!host) return fail$2("Missing host. Pass --host.");
1978
+ let dir = typeof args.dir === "string" ? args.dir.trim() : "";
1979
+ if (!dir && tty) {
1980
+ const answer = await promptText("Directory (under root):");
1981
+ if (answer === null) return abort$2();
1982
+ dir = answer.trim();
1983
+ }
1984
+ if (!dir) return fail$2("Missing directory. Pass --dir.");
1985
+ let protocol;
1986
+ if (type === "git") {
1987
+ if (typeof args.protocol === "string") {
1988
+ if (!isGitProtocol(args.protocol)) return fail$2(invalidProtocol$1(args.protocol));
1989
+ protocol = args.protocol;
1990
+ } else if (tty) {
1991
+ const answer = await promptSelect("Clone protocol:", GIT_PROTOCOLS);
1992
+ if (answer !== null && isGitProtocol(answer)) protocol = answer;
1993
+ }
1994
+ }
1995
+ let makeDefault = args.default === true;
1996
+ if (!loaded.configFile) makeDefault = true;
1997
+ else if (!makeDefault && tty) makeDefault = await confirmChange(`Set "${key}" as the default forge?`);
1998
+ const forge = buildForge({
1999
+ type,
2000
+ host,
2001
+ dir,
2002
+ protocol
2003
+ });
2004
+ const target = await resolveAddTarget(args.config);
2005
+ if (!target) return;
2006
+ consola.info(`Add forge "${key}" (${type} → ${host}) into ${target.create ? "new " : ""}${target.path}`);
2007
+ if (tty && !args.yes && !await confirmChange("Apply this change?")) return abort$2();
2008
+ if (target.create) {
2009
+ const written = await writeConfigFile({
2010
+ root: loaded.config.root,
2011
+ defaultForge: key,
2012
+ forges: { [key]: forge }
2013
+ }, { outDir: dirname(target.path) });
2014
+ if (!written) return fail$2(`${target.path} already exists.`);
2015
+ consola.success(`Added forge "${key}" — wrote ${written.path}`);
2016
+ return;
2017
+ }
2018
+ if (await applyChange(target.path, (c) => {
2019
+ addForge(c, key, forge);
2020
+ if (makeDefault) setDefaultForge(c, key);
2021
+ }, () => printManualForge(key, forge))) consola.success(`Added forge "${key}" to ${target.path}`);
2022
+ else process.exitCode = 1;
2023
+ }
2024
+ });
2025
+ function fail$2(message) {
2026
+ consola.error(message);
2027
+ process.exitCode = 1;
2028
+ }
2029
+ function abort$2() {
2030
+ consola.info("Aborted — nothing changed.");
2031
+ }
2032
+ function invalidType$1(value) {
2033
+ return `Invalid type "${value}". Expected one of: ${FORGE_TYPES.join(", ")}.`;
2034
+ }
2035
+ function invalidProtocol$1(value) {
2036
+ return `Invalid protocol "${value}". Expected one of: ${GIT_PROTOCOLS.join(", ")}.`;
2037
+ }
2038
+ //#endregion
2039
+ //#region src/commands/forge/edit.ts
2040
+ var forgeEditCommand = defineCommand({
2041
+ meta: {
2042
+ name: "edit",
2043
+ description: "Edit an existing forge (prompts for fields when none are passed)"
2044
+ },
2045
+ args: {
2046
+ key: {
2047
+ type: "positional",
2048
+ required: false,
2049
+ description: "Forge key to edit"
2050
+ },
2051
+ type: {
2052
+ type: "string",
2053
+ description: `New forge type (${FORGE_TYPES.join(", ")})`
2054
+ },
2055
+ host: {
2056
+ type: "string",
2057
+ description: "New host"
2058
+ },
2059
+ dir: {
2060
+ type: "string",
2061
+ description: "New directory under root"
2062
+ },
2063
+ protocol: {
2064
+ type: "string",
2065
+ description: `New clone protocol for type=git (${GIT_PROTOCOLS.join(", ")})`
2066
+ },
2067
+ config: {
2068
+ type: "string",
2069
+ description: "Path to the forgemap config file to modify"
2070
+ },
2071
+ yes: {
2072
+ type: "boolean",
2073
+ description: "Skip the confirmation prompt",
2074
+ default: false
2075
+ }
2076
+ },
2077
+ async run({ args }) {
2078
+ const loaded = await loadForgeMapConfig({ configFile: args.config });
2079
+ const tty = interactive();
2080
+ const file = existingConfigFile(loaded, args.config);
2081
+ if (!file) {
2082
+ process.exitCode = 1;
2083
+ return;
2084
+ }
2085
+ const forges = loaded.config.forges;
2086
+ let key = typeof args.key === "string" ? args.key.trim() : "";
2087
+ if (!key && tty) {
2088
+ const answer = await promptSelect("Which forge should be edited?", Object.keys(forges));
2089
+ if (answer === null) return abort$1();
2090
+ key = answer;
2091
+ }
2092
+ if (!key) return fail$1("Missing forge key. Pass it as an argument.");
2093
+ const current = forges[key];
2094
+ if (!current) return fail$1(`No forge "${key}" in ${file}. Configured: ${Object.keys(forges).join(", ")}.`);
2095
+ const currentProtocol = current.type === "git" ? current.protocol : void 0;
2096
+ const patch = {};
2097
+ if (typeof args.type === "string") {
2098
+ if (!isForgeType(args.type)) return fail$1(invalidType(args.type));
2099
+ patch.type = args.type;
2100
+ } else if (tty) {
2101
+ const answer = await promptSelect(`Type (current: ${current.type}):`, FORGE_TYPES);
2102
+ if (answer === null) return abort$1();
2103
+ if (isForgeType(answer)) patch.type = answer;
2104
+ }
2105
+ const resultType = patch.type ?? current.type;
2106
+ if (typeof args.host === "string") patch.host = args.host.trim();
2107
+ else if (tty) {
2108
+ const answer = await promptText(`Host (current: ${current.host}):`, current.host);
2109
+ if (answer === null) return abort$1();
2110
+ if (answer.trim()) patch.host = answer.trim();
2111
+ }
2112
+ if (typeof args.dir === "string") patch.dir = args.dir.trim();
2113
+ else if (tty) {
2114
+ const answer = await promptText(`Directory (current: ${current.dir}):`, current.dir);
2115
+ if (answer === null) return abort$1();
2116
+ if (answer.trim()) patch.dir = answer.trim();
2117
+ }
2118
+ if (resultType === "git") {
2119
+ if (typeof args.protocol === "string") {
2120
+ if (!isGitProtocol(args.protocol)) return fail$1(invalidProtocol(args.protocol));
2121
+ patch.protocol = args.protocol;
2122
+ } else if (tty) {
2123
+ const answer = await promptSelect("Clone protocol:", GIT_PROTOCOLS);
2124
+ if (answer !== null && isGitProtocol(answer)) patch.protocol = answer;
2125
+ }
2126
+ }
2127
+ if (!hasChanges(patch)) return fail$1("Nothing to change. Pass --type, --host, --dir or --protocol.");
2128
+ consola.info(`Edit forge "${key}" in ${file}`);
2129
+ if (tty && !args.yes && !await confirmChange("Apply this change?")) return abort$1();
2130
+ const merged = mergeForge(current, currentProtocol, patch, resultType);
2131
+ if (await applyChange(file, (c) => editForge(c, key, patch), () => {
2132
+ consola.log(`Update the "${key}" entry to:`);
2133
+ printManualForge(key, merged);
2134
+ })) consola.success(`Edited forge "${key}" in ${file}`);
2135
+ else process.exitCode = 1;
2136
+ }
2137
+ });
2138
+ function hasChanges(patch) {
2139
+ return patch.type !== void 0 || patch.host !== void 0 || patch.dir !== void 0 || patch.protocol !== void 0;
2140
+ }
2141
+ function mergeForge(current, currentProtocol, patch, resultType) {
2142
+ const protocol = resultType === "git" ? patch.protocol ?? currentProtocol : void 0;
2143
+ return {
2144
+ type: resultType,
2145
+ host: patch.host ?? current.host,
2146
+ dir: patch.dir ?? current.dir,
2147
+ ...protocol ? { protocol } : {}
2148
+ };
2149
+ }
2150
+ function fail$1(message) {
2151
+ consola.error(message);
2152
+ process.exitCode = 1;
2153
+ }
2154
+ function abort$1() {
2155
+ consola.info("Aborted — nothing changed.");
2156
+ }
2157
+ function invalidType(value) {
2158
+ return `Invalid type "${value}". Expected one of: ${FORGE_TYPES.join(", ")}.`;
2159
+ }
2160
+ function invalidProtocol(value) {
2161
+ return `Invalid protocol "${value}". Expected one of: ${GIT_PROTOCOLS.join(", ")}.`;
2162
+ }
2163
+ //#endregion
2164
+ //#region src/commands/forge/remove.ts
2165
+ var LEAVE_UNSET = "— leave unset —";
2166
+ var forgeRemoveCommand = defineCommand({
2167
+ meta: {
2168
+ name: "remove",
2169
+ description: "Remove a forge from the config"
2170
+ },
2171
+ args: {
2172
+ key: {
2173
+ type: "positional",
2174
+ required: false,
2175
+ description: "Forge key to remove"
2176
+ },
2177
+ default: {
2178
+ type: "string",
2179
+ description: "When removing the default forge, reassign the default to this"
2180
+ },
2181
+ config: {
2182
+ type: "string",
2183
+ description: "Path to the forgemap config file to modify"
2184
+ },
2185
+ yes: {
2186
+ type: "boolean",
2187
+ description: "Skip the confirmation prompt",
2188
+ default: false
2189
+ }
2190
+ },
2191
+ async run({ args }) {
2192
+ const loaded = await loadForgeMapConfig({ configFile: args.config });
2193
+ const tty = interactive();
2194
+ const file = existingConfigFile(loaded, args.config);
2195
+ if (!file) {
2196
+ process.exitCode = 1;
2197
+ return;
2198
+ }
2199
+ const forges = loaded.config.forges;
2200
+ let key = typeof args.key === "string" ? args.key.trim() : "";
2201
+ if (!key && tty) {
2202
+ const answer = await promptSelect("Which forge should be removed?", Object.keys(forges));
2203
+ if (answer === null) return abort();
2204
+ key = answer;
2205
+ }
2206
+ if (!key) return fail("Missing forge key. Pass it as an argument.");
2207
+ if (!(key in forges)) return fail(`No forge "${key}" in ${file}. Configured: ${Object.keys(forges).join(", ")}.`);
2208
+ const remaining = Object.keys(forges).filter((k) => k !== key);
2209
+ let newDefault;
2210
+ if (loaded.config.defaultForge === key && remaining.length > 0) if (typeof args.default === "string") {
2211
+ if (!remaining.includes(args.default)) return fail(`Cannot set default to "${args.default}" — not a remaining forge (${remaining.join(", ")}).`);
2212
+ newDefault = args.default;
2213
+ } else if (tty) {
2214
+ const answer = await promptSelect(`"${key}" is the default forge. Pick a new default:`, [...remaining, LEAVE_UNSET]);
2215
+ if (answer === null) return abort();
2216
+ if (answer !== LEAVE_UNSET) newDefault = answer;
2217
+ } else consola.warn(`Removing the default forge "${key}"; defaultForge now points at a missing forge. Pass --default to reassign it.`);
2218
+ consola.info(`Remove forge "${key}" from ${file}${newDefault ? ` (new default: "${newDefault}")` : ""}`);
2219
+ if (tty && !args.yes && !await confirmChange("Apply this change?")) return abort();
2220
+ if (await applyChange(file, (c) => {
2221
+ removeForge(c, key);
2222
+ if (newDefault) setDefaultForge(c, newDefault);
2223
+ }, () => consola.log(`Remove the "${key}" entry from \`forges\` in ${file}.`))) consola.success(`Removed forge "${key}" from ${file}`);
2224
+ else process.exitCode = 1;
2225
+ }
2226
+ });
2227
+ function fail(message) {
2228
+ consola.error(message);
2229
+ process.exitCode = 1;
2230
+ }
2231
+ function abort() {
2232
+ consola.info("Aborted — nothing changed.");
2233
+ }
2234
+ //#endregion
2235
+ //#region src/commands/forge/index.ts
2236
+ var forgeCommand = defineCommand({
2237
+ meta: {
2238
+ name: "forge",
2239
+ description: "Add, remove or edit forges in the config"
2240
+ },
2241
+ subCommands: {
2242
+ add: forgeAddCommand,
2243
+ remove: forgeRemoveCommand,
2244
+ edit: forgeEditCommand
2245
+ }
2246
+ });
2247
+ //#endregion
2248
+ //#region src/repos/import.ts
2249
+ async function listDirs(path) {
2250
+ try {
2251
+ return (await readdir(path, { withFileTypes: true })).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name);
2252
+ } catch (error) {
2253
+ if (error.code === "ENOENT") return [];
2254
+ throw error;
2255
+ }
2256
+ }
2257
+ /**
2258
+ * Structure-driven depth-3 walk of `<path>/<serverDir>/<owner>/<repo>`.
2259
+ * Unlike `scanRepos`, this is config-free: every top-level directory is a
2260
+ * candidate server dir, and the names are discovered rather than configured.
2261
+ */
2262
+ async function discoverForgemapLayout(path) {
2263
+ const repos = [];
2264
+ for (const serverDir of await listDirs(path)) {
2265
+ const serverPath = join(path, serverDir);
2266
+ for (const owner of await listDirs(serverPath)) {
2267
+ const ownerPath = join(serverPath, owner);
2268
+ for (const repo of await listDirs(ownerPath)) repos.push({
2269
+ serverDir,
2270
+ owner,
2271
+ repo,
2272
+ localPath: join(ownerPath, repo)
2273
+ });
2274
+ }
2275
+ }
2276
+ return repos;
2277
+ }
2278
+ function forgeTypeForHost(host) {
2279
+ return host === "github.com" ? "github" : "git";
2280
+ }
2281
+ /**
2282
+ * Derive a `root` + one forge per server dir from the analyzed reports.
2283
+ * Host (and therefore type) come from the dominant origin host of the repos
2284
+ * under each server dir.
2285
+ */
2286
+ function deriveConfig(reports, path) {
2287
+ const forges = {};
2288
+ const counts = /* @__PURE__ */ new Map();
2289
+ const byServer = /* @__PURE__ */ new Map();
2290
+ for (const report of reports) {
2291
+ const list = byServer.get(report.repo.serverDir);
2292
+ if (list) list.push(report);
2293
+ else byServer.set(report.repo.serverDir, [report]);
2294
+ }
2295
+ for (const [serverDir, group] of byServer) {
2296
+ counts.set(serverDir, group.length);
2297
+ const hostTally = /* @__PURE__ */ new Map();
2298
+ for (const report of group) if (report.originHost) hostTally.set(report.originHost, (hostTally.get(report.originHost) ?? 0) + 1);
2299
+ const host = [...hostTally.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? "";
2300
+ forges[serverDir] = {
2301
+ type: host ? forgeTypeForHost(host) : "git",
2302
+ host,
2303
+ dir: serverDir
2304
+ };
2305
+ }
2306
+ return {
2307
+ root: path,
1792
2308
  defaultForge: Object.keys(forges).slice().sort((a, b) => {
1793
2309
  const aGh = forges[a].type === "github" ? 1 : 0;
1794
2310
  const bGh = forges[b].type === "github" ? 1 : 0;
@@ -2419,7 +2935,7 @@ var infoCommand = defineCommand({
2419
2935
  async run({ args }) {
2420
2936
  const binary = resolveBinary(process.argv[1]);
2421
2937
  const info = {
2422
- version: "0.5.0",
2938
+ version: "0.6.0",
2423
2939
  build: detectBuild(binary.resolved),
2424
2940
  binary,
2425
2941
  node: process.version,
@@ -2433,89 +2949,176 @@ var infoCommand = defineCommand({
2433
2949
  }
2434
2950
  });
2435
2951
  //#endregion
2436
- //#region src/repos/match.ts
2952
+ //#region src/repos/filter.ts
2953
+ var FLAG = "--filter";
2437
2954
  /**
2438
- * The one Fuse configuration every fuzzy lookup shares — `search`, `pick`
2439
- * and the fuzzy slug fallback in `path`/`open`. Keeping it in one place is
2440
- * what makes a query rank identically no matter which command runs it.
2955
+ * Shared `--filter` option for the commands that enumerate repos
2956
+ * (`status`, `sync`, `list`), so the flag reads identically everywhere.
2441
2957
  */
2442
- var REPO_FUSE_OPTIONS = {
2443
- keys: [
2444
- "slug",
2445
- "owner",
2446
- "repo"
2447
- ],
2448
- threshold: .3,
2449
- ignoreLocation: true
2958
+ var filterArg = {
2959
+ type: "string",
2960
+ description: "Restrict to repos whose owner or forge name matches. Repeatable; a repo passes if it matches any value."
2450
2961
  };
2451
- function createRepoFuse(repos) {
2452
- return new Fuse(repos, REPO_FUSE_OPTIONS);
2453
- }
2454
- /** Fuzzy-match `query` against scanned repos, best match first. */
2455
- function matchRepos(repos, query, limit) {
2456
- return createRepoFuse(repos).search(query, limit ? { limit } : void 0).map((r) => r.item);
2457
- }
2458
- //#endregion
2459
- //#region src/repos/picker.ts
2460
2962
  /**
2461
- * Show the interactive repo picker and return the chosen local path
2462
- * (undefined when the user cancels).
2463
- *
2464
- * `$(forgemap pick)` / `$(forgemap path <q>)` captures stdout, so the
2465
- * interactive TUI must not go there. consola/clack writes the UI to stdout AND
2466
- * reads stdout.rows/columns for layout — but a captured stdout is a pipe (no
2467
- * rows → nothing renders). So for the duration of the prompt: route stdout
2468
- * writes to stderr (the real TTY) and borrow stderr's dimensions, then
2469
- * restore. stdout stays clean for the chosen path only.
2963
+ * Recover every `--filter` occurrence from the raw argv.
2470
2964
  *
2471
- * Callers must check {@link canPrompt} first without a TTY on stdin there is
2472
- * nobody to answer.
2965
+ * citty (0.2.2) parses through `node:util` `parseArgs` and never sets
2966
+ * `multiple: true`, so Node keeps only the **last** value of a repeated option:
2967
+ * `--filter a --filter b` reaches `args.filter` as `'b'`, silently dropping
2968
+ * `a`. The raw argv is the only place the full list survives.
2473
2969
  */
2474
- async function promptRepoChoice(candidates) {
2475
- const out = process.stdout;
2476
- const realWrite = out.write;
2477
- const saved = {
2478
- rows: Object.getOwnPropertyDescriptor(out, "rows"),
2479
- columns: Object.getOwnPropertyDescriptor(out, "columns"),
2480
- isTTY: Object.getOwnPropertyDescriptor(out, "isTTY")
2481
- };
2482
- const fake = (key, value) => {
2483
- Object.defineProperty(out, key, {
2484
- configurable: true,
2485
- value
2486
- });
2487
- };
2488
- const restore = (key) => {
2489
- if (saved[key]) Object.defineProperty(out, key, saved[key]);
2490
- else delete out[key];
2491
- };
2492
- out.write = process.stderr.write.bind(process.stderr);
2493
- fake("rows", process.stderr.rows ?? 24);
2494
- fake("columns", process.stderr.columns ?? 80);
2495
- fake("isTTY", true);
2496
- let choice;
2497
- try {
2498
- choice = await consola.prompt("Select a repo", {
2499
- type: "select",
2500
- options: candidates.map((r) => ({
2501
- label: `${colors.gray(`${r.forgeName}:`)}${r.slug}`,
2502
- value: r.localPath,
2503
- hint: r.localPath
2504
- }))
2505
- });
2506
- } finally {
2507
- out.write = realWrite;
2508
- restore("rows");
2509
- restore("columns");
2510
- restore("isTTY");
2970
+ function collectFilterArgs(rawArgs) {
2971
+ const values = [];
2972
+ for (let i = 0; i < rawArgs.length; i++) {
2973
+ const arg = rawArgs[i];
2974
+ if (arg === "--") break;
2975
+ if (arg === FLAG) {
2976
+ const next = rawArgs[i + 1];
2977
+ if (next !== void 0 && !next.startsWith("-")) {
2978
+ values.push(next);
2979
+ i++;
2980
+ }
2981
+ continue;
2982
+ }
2983
+ if (arg.startsWith(`${FLAG}=`)) values.push(arg.slice(9));
2511
2984
  }
2512
- return typeof choice === "string" && choice ? choice : void 0;
2985
+ return values;
2513
2986
  }
2514
- /** Whether an interactive prompt can be shown at all. */
2515
- function canPrompt() {
2516
- return Boolean(process.stdin.isTTY);
2987
+ /**
2988
+ * Normalize the shapes a filter value arrives in — absent, a single string, or
2989
+ * a list — into a list, dropping blanks (`--filter ''`).
2990
+ */
2991
+ function normalizeFilters(value) {
2992
+ if (value === void 0) return [];
2993
+ return (Array.isArray(value) ? value : [value]).map((v) => v.trim()).filter((v) => v.length > 0);
2994
+ }
2995
+ /**
2996
+ * The filter values for a run: the raw argv wins, since it is the only shape
2997
+ * that survives repetition. Fall back to the parsed value when the argv carries
2998
+ * no `--filter` at all, which is how the command is driven programmatically
2999
+ * (and in tests), where `rawArgs` may be empty.
3000
+ */
3001
+ function resolveFilters(rawArgs, value) {
3002
+ const fromRawArgs = collectFilterArgs(rawArgs);
3003
+ return normalizeFilters(fromRawArgs.length > 0 ? fromRawArgs : value);
3004
+ }
3005
+ /**
3006
+ * Keep the repos matching any of `filters` (OR-combined). A value matches when
3007
+ * it equals the repo's owner or its forge name, compared case-insensitively —
3008
+ * forge and owner names are case-preserving but not case-significant. An empty
3009
+ * filter list is a no-op, so an unused flag never narrows the output.
3010
+ */
3011
+ function filterRepos(repos, filters) {
3012
+ if (filters.length === 0) return repos;
3013
+ const wanted = new Set(filters.map((f) => f.toLowerCase()));
3014
+ return repos.filter((r) => wanted.has(r.owner.toLowerCase()) || wanted.has(r.forgeName.toLowerCase()));
2517
3015
  }
2518
3016
  //#endregion
3017
+ //#region src/repos/match.ts
3018
+ /**
3019
+ * The one Fuse configuration every fuzzy lookup shares — `list`, `pick`
3020
+ * and the fuzzy slug fallback in `path`/`open`. Keeping it in one place is
3021
+ * what makes a query rank identically no matter which command runs it.
3022
+ */
3023
+ var REPO_FUSE_OPTIONS = {
3024
+ keys: [
3025
+ "slug",
3026
+ "owner",
3027
+ "repo"
3028
+ ],
3029
+ threshold: .3,
3030
+ ignoreLocation: true
3031
+ };
3032
+ function createRepoFuse(repos) {
3033
+ return new Fuse(repos, REPO_FUSE_OPTIONS);
3034
+ }
3035
+ /** Fuzzy-match `query` against scanned repos, best match first. */
3036
+ function matchRepos(repos, query, limit) {
3037
+ return createRepoFuse(repos).search(query, limit ? { limit } : void 0).map((r) => r.item);
3038
+ }
3039
+ //#endregion
3040
+ //#region src/commands/list.ts
3041
+ function renderTree$1(repos) {
3042
+ const byForge = /* @__PURE__ */ new Map();
3043
+ for (const r of repos) {
3044
+ let owners = byForge.get(r.forgeName);
3045
+ if (!owners) {
3046
+ owners = /* @__PURE__ */ new Map();
3047
+ byForge.set(r.forgeName, owners);
3048
+ }
3049
+ const list = owners.get(r.owner);
3050
+ if (list) list.push(r);
3051
+ else owners.set(r.owner, [r]);
3052
+ }
3053
+ return formatTree(Array.from(byForge, ([forge, owners]) => ({
3054
+ text: colors.bold(forge),
3055
+ children: Array.from(owners, ([owner, items]) => ({
3056
+ text: owner,
3057
+ children: items.map((r) => ({ text: `${colors.cyan(r.repo)} ${colors.dim(r.localPath)}` }))
3058
+ }))
3059
+ })));
3060
+ }
3061
+ var listCommand = defineCommand({
3062
+ meta: {
3063
+ name: "list",
3064
+ description: "List cloned repos; with a query, fuzzy-match by owner/repo and print matches"
3065
+ },
3066
+ args: {
3067
+ query: {
3068
+ type: "positional",
3069
+ description: "Optional search term (matched fuzzily against <owner>/<repo>). Omit to list every repo.",
3070
+ required: false
3071
+ },
3072
+ format: {
3073
+ type: "string",
3074
+ description: "Output format: auto (default), pretty, path, or slug. auto picks pretty in a TTY, path when piped.",
3075
+ default: "auto"
3076
+ },
3077
+ filter: filterArg,
3078
+ limit: {
3079
+ type: "string",
3080
+ description: "Maximum number of matches to print (default: unlimited)"
3081
+ },
3082
+ config: {
3083
+ type: "string",
3084
+ description: "Path to forgemap.config.ts (overrides walk-up discovery)"
3085
+ }
3086
+ },
3087
+ async run({ args, rawArgs }) {
3088
+ const loaded = await loadForgeMapConfig({ configFile: args.config });
3089
+ const configDir = loaded.configFile ? dirname(loaded.configFile) : loaded.cwd;
3090
+ const repos = filterRepos(await scanRepos({
3091
+ config: loaded.config,
3092
+ configDir
3093
+ }), resolveFilters(rawArgs, args.filter));
3094
+ const limit = args.limit ? Number.parseInt(args.limit, 10) : void 0;
3095
+ const query = args.query ?? "";
3096
+ const items = matchRepos(repos, query, limit);
3097
+ const allowed = [
3098
+ "auto",
3099
+ "pretty",
3100
+ "path",
3101
+ "slug"
3102
+ ];
3103
+ if (!allowed.includes(args.format)) {
3104
+ consola.error(`Invalid --format value "${args.format}". Allowed: ${allowed.join(", ")}.`);
3105
+ process.exitCode = 1;
3106
+ return;
3107
+ }
3108
+ const requested = args.format;
3109
+ const format = requested === "auto" ? process.stdout.isTTY ? "pretty" : "path" : requested;
3110
+ if (items.length === 0) {
3111
+ if (format === "pretty") consola.info(query ? `No matches for "${query}".` : "No repos found.");
3112
+ return;
3113
+ }
3114
+ if (format === "pretty") {
3115
+ process.stdout.write(`${renderTree$1(items)}\n`);
3116
+ return;
3117
+ }
3118
+ for (const item of items) process.stdout.write(`${format === "slug" ? item.slug : item.localPath}\n`);
3119
+ }
3120
+ });
3121
+ //#endregion
2519
3122
  //#region src/slug/locate.ts
2520
3123
  /**
2521
3124
  * Turn user input into a repo location.
@@ -2712,7 +3315,7 @@ var pickCommand = defineCommand({
2712
3315
  return;
2713
3316
  }
2714
3317
  if (!canPrompt()) {
2715
- consola.error("pick requires an interactive terminal. Use `forgemap search` for non-interactive output.");
3318
+ consola.error("pick requires an interactive terminal. Use `forgemap list` for non-interactive output.");
2716
3319
  process.exitCode = 1;
2717
3320
  return;
2718
3321
  }
@@ -2721,152 +3324,6 @@ var pickCommand = defineCommand({
2721
3324
  }
2722
3325
  });
2723
3326
  //#endregion
2724
- //#region src/repos/filter.ts
2725
- var FLAG = "--filter";
2726
- /**
2727
- * Shared `--filter` option for the commands that enumerate repos
2728
- * (`status`, `sync`, `search`), so the flag reads identically everywhere.
2729
- */
2730
- var filterArg = {
2731
- type: "string",
2732
- description: "Restrict to repos whose owner or forge name matches. Repeatable; a repo passes if it matches any value."
2733
- };
2734
- /**
2735
- * Recover every `--filter` occurrence from the raw argv.
2736
- *
2737
- * citty (0.2.2) parses through `node:util` `parseArgs` and never sets
2738
- * `multiple: true`, so Node keeps only the **last** value of a repeated option:
2739
- * `--filter a --filter b` reaches `args.filter` as `'b'`, silently dropping
2740
- * `a`. The raw argv is the only place the full list survives.
2741
- */
2742
- function collectFilterArgs(rawArgs) {
2743
- const values = [];
2744
- for (let i = 0; i < rawArgs.length; i++) {
2745
- const arg = rawArgs[i];
2746
- if (arg === "--") break;
2747
- if (arg === FLAG) {
2748
- const next = rawArgs[i + 1];
2749
- if (next !== void 0 && !next.startsWith("-")) {
2750
- values.push(next);
2751
- i++;
2752
- }
2753
- continue;
2754
- }
2755
- if (arg.startsWith(`${FLAG}=`)) values.push(arg.slice(9));
2756
- }
2757
- return values;
2758
- }
2759
- /**
2760
- * Normalize the shapes a filter value arrives in — absent, a single string, or
2761
- * a list — into a list, dropping blanks (`--filter ''`).
2762
- */
2763
- function normalizeFilters(value) {
2764
- if (value === void 0) return [];
2765
- return (Array.isArray(value) ? value : [value]).map((v) => v.trim()).filter((v) => v.length > 0);
2766
- }
2767
- /**
2768
- * The filter values for a run: the raw argv wins, since it is the only shape
2769
- * that survives repetition. Fall back to the parsed value when the argv carries
2770
- * no `--filter` at all, which is how the command is driven programmatically
2771
- * (and in tests), where `rawArgs` may be empty.
2772
- */
2773
- function resolveFilters(rawArgs, value) {
2774
- const fromRawArgs = collectFilterArgs(rawArgs);
2775
- return normalizeFilters(fromRawArgs.length > 0 ? fromRawArgs : value);
2776
- }
2777
- /**
2778
- * Keep the repos matching any of `filters` (OR-combined). A value matches when
2779
- * it equals the repo's owner or its forge name, compared case-insensitively —
2780
- * forge and owner names are case-preserving but not case-significant. An empty
2781
- * filter list is a no-op, so an unused flag never narrows the output.
2782
- */
2783
- function filterRepos(repos, filters) {
2784
- if (filters.length === 0) return repos;
2785
- const wanted = new Set(filters.map((f) => f.toLowerCase()));
2786
- return repos.filter((r) => wanted.has(r.owner.toLowerCase()) || wanted.has(r.forgeName.toLowerCase()));
2787
- }
2788
- //#endregion
2789
- //#region src/commands/search.ts
2790
- function renderTree$1(repos) {
2791
- const byForge = /* @__PURE__ */ new Map();
2792
- for (const r of repos) {
2793
- let owners = byForge.get(r.forgeName);
2794
- if (!owners) {
2795
- owners = /* @__PURE__ */ new Map();
2796
- byForge.set(r.forgeName, owners);
2797
- }
2798
- const list = owners.get(r.owner);
2799
- if (list) list.push(r);
2800
- else owners.set(r.owner, [r]);
2801
- }
2802
- return formatTree(Array.from(byForge, ([forge, owners]) => ({
2803
- text: colors.bold(forge),
2804
- children: Array.from(owners, ([owner, items]) => ({
2805
- text: owner,
2806
- children: items.map((r) => ({ text: `${colors.cyan(r.repo)} ${colors.dim(r.localPath)}` }))
2807
- }))
2808
- })));
2809
- }
2810
- var searchCommand = defineCommand({
2811
- meta: {
2812
- name: "search",
2813
- description: "Fuzzy-search cloned repos by owner/repo and print matching repos"
2814
- },
2815
- args: {
2816
- query: {
2817
- type: "positional",
2818
- description: "Search term (matched fuzzily against <owner>/<repo>)",
2819
- required: true
2820
- },
2821
- format: {
2822
- type: "string",
2823
- description: "Output format: auto (default), pretty, path, or slug. auto picks pretty in a TTY, path when piped.",
2824
- default: "auto"
2825
- },
2826
- filter: filterArg,
2827
- limit: {
2828
- type: "string",
2829
- description: "Maximum number of matches to print (default: unlimited)"
2830
- },
2831
- config: {
2832
- type: "string",
2833
- description: "Path to forgemap.config.ts (overrides walk-up discovery)"
2834
- }
2835
- },
2836
- async run({ args, rawArgs }) {
2837
- const loaded = await loadForgeMapConfig({ configFile: args.config });
2838
- const configDir = loaded.configFile ? dirname(loaded.configFile) : loaded.cwd;
2839
- const repos = filterRepos(await scanRepos({
2840
- config: loaded.config,
2841
- configDir
2842
- }), resolveFilters(rawArgs, args.filter));
2843
- const limit = args.limit ? Number.parseInt(args.limit, 10) : void 0;
2844
- const items = matchRepos(repos, args.query, limit);
2845
- const allowed = [
2846
- "auto",
2847
- "pretty",
2848
- "path",
2849
- "slug"
2850
- ];
2851
- if (!allowed.includes(args.format)) {
2852
- consola.error(`Invalid --format value "${args.format}". Allowed: ${allowed.join(", ")}.`);
2853
- process.exitCode = 1;
2854
- return;
2855
- }
2856
- const requested = args.format;
2857
- const format = requested === "auto" ? process.stdout.isTTY ? "pretty" : "path" : requested;
2858
- if (items.length === 0) {
2859
- if (format === "pretty") consola.info(`No matches for "${args.query}".`);
2860
- return;
2861
- }
2862
- if (format === "pretty") {
2863
- process.stdout.write(`${renderTree$1(items)}\n`);
2864
- return;
2865
- }
2866
- for (const item of items) process.stdout.write(`${format === "slug" ? item.slug : item.localPath}\n`);
2867
- }
2868
- });
2869
- //#endregion
2870
3327
  //#region src/commands/shell-init.ts
2871
3328
  /** Append (idempotently) a loader that, plus completion, sets up the shell so
2872
3329
  * the user only has to re-source their rc file. */
@@ -2896,7 +3353,7 @@ ${name}() {
2896
3353
  target=$(command forgemap pick) || return $?
2897
3354
  else
2898
3355
  local matches
2899
- matches=$(command forgemap search "$1" --format path)
3356
+ matches=$(command forgemap list "$1" --format path)
2900
3357
  local count
2901
3358
  count=$(printf '%s' "$matches" | grep -c '^/' || true)
2902
3359
  if [ "$count" = "1" ]; then
@@ -2915,7 +3372,7 @@ ${name}() {
2915
3372
  }
2916
3373
  `;
2917
3374
  }
2918
- function renderFish(name) {
3375
+ function renderFish$1(name) {
2919
3376
  return `# forgemap shell integration — drop into your ~/.config/fish/config.fish:
2920
3377
  # forgemap shell-init fish | source
2921
3378
 
@@ -2926,7 +3383,7 @@ function ${name} --description "forgemap with cd interception"
2926
3383
  if test (count $argv) -eq 0
2927
3384
  set target (command forgemap pick); or return $status
2928
3385
  else
2929
- set matches (command forgemap search $argv[1] --format path)
3386
+ set matches (command forgemap list $argv[1] --format path)
2930
3387
  set count (count $matches)
2931
3388
  if test $count -eq 1
2932
3389
  set target $matches[1]
@@ -2978,7 +3435,7 @@ var shellInitCommand = defineCommand({
2978
3435
  await install(requested, name);
2979
3436
  return;
2980
3437
  }
2981
- const out = requested === "fish" ? renderFish(name) : renderPosix(name);
3438
+ const out = requested === "fish" ? renderFish$1(name) : renderPosix(name);
2982
3439
  process.stdout.write(out);
2983
3440
  }
2984
3441
  });
@@ -3326,12 +3783,297 @@ function severitySymbol(severity) {
3326
3783
  if (severity === "warn") return colors.yellow("!");
3327
3784
  return colors.red("✗");
3328
3785
  }
3786
+ var validateCommand = defineCommand({
3787
+ meta: {
3788
+ name: "validate",
3789
+ description: "Preflight: check the config schema, required CLI tools, and root directory"
3790
+ },
3791
+ args: {
3792
+ json: {
3793
+ type: "boolean",
3794
+ description: "Emit a machine-readable JSON report",
3795
+ default: false
3796
+ },
3797
+ config: {
3798
+ type: "string",
3799
+ description: "Path to forgemap.config.ts (overrides walk-up discovery)"
3800
+ }
3801
+ },
3802
+ async run({ args }) {
3803
+ const loaded = await loadForgeMapConfig({ configFile: args.config });
3804
+ const configDir = loaded.configFile ? dirname(loaded.configFile) : loaded.cwd;
3805
+ const checks = await runChecks(loaded.config, configDir);
3806
+ const ok = checks.every((c) => c.severity !== "fail");
3807
+ if (args.json) process.stdout.write(`${JSON.stringify({
3808
+ ok,
3809
+ checks
3810
+ }, null, 2)}\n`);
3811
+ else {
3812
+ for (const c of checks) process.stdout.write(`${severitySymbol(c.severity)} ${c.name.padEnd(22)} ${colors.dim(c.message)}\n`);
3813
+ process.stdout.write(`\n${ok ? colors.green("All checks passed.") : colors.red("Validation failed.")}\n`);
3814
+ }
3815
+ if (!ok) {
3816
+ process.exitCode = 1;
3817
+ return;
3818
+ }
3819
+ if (!loaded.configFile) consola.warn("No forgemap.config.ts found — using built-in defaults. Run `forgemap config init` to materialize one.");
3820
+ }
3821
+ });
3822
+ //#endregion
3823
+ //#region src/commands/completion.ts
3824
+ var SLUG_COMMANDS = [
3825
+ "clone",
3826
+ "cd",
3827
+ "path",
3828
+ "open",
3829
+ "list",
3830
+ "pick",
3831
+ "delete"
3832
+ ];
3833
+ var STATIC_FLAG_VALUES = {
3834
+ list: { "--format": [
3835
+ "auto",
3836
+ "pretty",
3837
+ "path",
3838
+ "slug"
3839
+ ] },
3840
+ status: { "--format": ["pretty", "json"] },
3841
+ import: {
3842
+ "--format": ["pretty", "json"],
3843
+ "--type": ["forgemap"]
3844
+ }
3845
+ };
3846
+ var SHELL_POSITIONAL = /* @__PURE__ */ new Set(["completion", "shell-init"]);
3847
+ /** Every forgemap command declares `args` as a plain object literal (or omits
3848
+ * it, like `config`), never a thunk — so no `Resolvable` unwrapping is needed. */
3849
+ function argsOf(cmd) {
3850
+ return cmd.args ?? {};
3851
+ }
3852
+ /** Flag names a command exposes, derived from its `defineCommand` args so a new
3853
+ * flag surfaces in completion automatically. Positionals are handled
3854
+ * separately; negatable booleans (those with a `negativeDescription`) also get
3855
+ * their `--no-<flag>` form. */
3856
+ function flagsOf(cmd) {
3857
+ const flags = [];
3858
+ for (const [name, def] of Object.entries(argsOf(cmd))) {
3859
+ if (def.type === "positional") continue;
3860
+ flags.push(`--${name}`);
3861
+ if (def.type === "boolean" && def.negativeDescription) flags.push(`--no-${name}`);
3862
+ }
3863
+ return flags;
3864
+ }
3865
+ /** The ordered subcommand registry, mirroring `rootCommand.subCommands` in
3866
+ * cli.ts. It is kept here rather than imported from cli.ts because that would
3867
+ * form a cycle (cli → completion → cli). Built lazily so the self-reference to
3868
+ * `completionCommand` resolves after the module finishes initializing. */
3869
+ function commandSpecs() {
3870
+ return [
3871
+ ["clone", cloneCommand],
3872
+ ["import", importCommand],
3873
+ ["cleanup", cleanupCommand],
3874
+ ["delete", deleteCommand],
3875
+ ["cd", cdCommand],
3876
+ ["path", pathCommand],
3877
+ ["open", openCommand],
3878
+ ["list", listCommand],
3879
+ ["pick", pickCommand],
3880
+ ["status", statusCommand],
3881
+ ["sync", syncCommand],
3882
+ ["validate", validateCommand],
3883
+ ["info", infoCommand],
3884
+ ["completion", completionCommand],
3885
+ ["shell-init", shellInitCommand],
3886
+ ["config", configCommand],
3887
+ ["forge", forgeCommand]
3888
+ ].map(([name, cmd]) => ({
3889
+ name,
3890
+ flags: flagsOf(cmd),
3891
+ flagValues: STATIC_FLAG_VALUES[name] ?? {},
3892
+ positionalValues: SHELL_POSITIONAL.has(name) ? [...SUPPORTED_SHELLS] : [],
3893
+ slugs: SLUG_COMMANDS.includes(name)
3894
+ }));
3895
+ }
3896
+ function flagValuePairs(specs) {
3897
+ return specs.flatMap((s) => Object.entries(s.flagValues).map(([flag, values]) => [
3898
+ s.name,
3899
+ flag,
3900
+ values
3901
+ ]));
3902
+ }
3903
+ function renderBash(specs) {
3904
+ const names = specs.map((s) => s.name).join(" ");
3905
+ const valueArms = flagValuePairs(specs).map(([cmd, flag, values]) => ` ${cmd}:${flag}) COMPREPLY=( $(compgen -W "${values.join(" ")}" -- "$cur") ); return ;;`).join("\n");
3906
+ const flagArms = specs.filter((s) => s.flags.length > 0).map((s) => ` ${s.name}) flags="${s.flags.join(" ")}" ;;`).join("\n");
3907
+ const slugCmds = specs.filter((s) => s.slugs).map((s) => s.name);
3908
+ return `# forgemap bash completion — drop into your ~/.bashrc:
3909
+ # eval "$(forgemap completion bash)"
3910
+ _forgemap_completion() {
3911
+ local cur prev cmd flags
3912
+ COMPREPLY=()
3913
+ cur="\${COMP_WORDS[COMP_CWORD]}"
3914
+ prev="\${COMP_WORDS[COMP_CWORD-1]}"
3915
+ cmd="\${COMP_WORDS[1]}"
3916
+
3917
+ if [ "$COMP_CWORD" = "1" ]; then
3918
+ COMPREPLY=( $(compgen -W "${names}" -- "$cur") )
3919
+ return
3920
+ fi
3921
+
3922
+ # Values for flags with a fixed set (e.g. --format), keyed by "<cmd>:<flag>".
3923
+ case "$cmd:$prev" in
3924
+ ${valueArms}
3925
+ esac
3926
+
3927
+ # Flag names for the current subcommand.
3928
+ if [[ "$cur" == -* ]]; then
3929
+ case "$cmd" in
3930
+ ${flagArms}
3931
+ esac
3932
+ COMPREPLY=( $(compgen -W "$flags" -- "$cur") )
3933
+ return
3934
+ fi
3935
+
3936
+ # Positional values (repo slugs, or a shell name).
3937
+ case "$cmd" in
3938
+ ${[slugCmds.length > 0 ? ` ${slugCmds.join("|")})
3939
+ local slugs
3940
+ slugs=$(forgemap list --format slug 2>/dev/null)
3941
+ COMPREPLY=( $(compgen -W "$slugs" -- "$cur") )
3942
+ ;;` : "", ...specs.filter((s) => s.positionalValues.length > 0).map((s) => ` ${s.name}) COMPREPLY=( $(compgen -W "${s.positionalValues.join(" ")}" -- "$cur") ) ;;`)].filter(Boolean).join("\n")}
3943
+ esac
3944
+ }
3945
+ complete -F _forgemap_completion forgemap
3946
+ `;
3947
+ }
3948
+ function renderZsh(specs) {
3949
+ return `# forgemap zsh completion — drop into your ~/.zshrc:
3950
+ # eval "$(forgemap completion zsh)"
3951
+ _forgemap() {
3952
+ local -a subcommands
3953
+ subcommands=(${specs.map((s) => `'${s.name}'`).join(" ")})
3954
+ local cmd="\${words[2]}"
3955
+ local prev="\${words[CURRENT-1]}"
3956
+ local cur="\${words[CURRENT]}"
3957
+
3958
+ if (( CURRENT == 2 )); then
3959
+ _describe 'forgemap subcommand' subcommands
3960
+ return
3961
+ fi
3962
+
3963
+ # Values for flags with a fixed set (e.g. --format), keyed by "<cmd>:<flag>".
3964
+ case "$cmd:$prev" in
3965
+ ${flagValuePairs(specs).map(([cmd, flag, values]) => ` ${cmd}:${flag}) compadd ${values.join(" ")}; return ;;`).join("\n")}
3966
+ esac
3967
+
3968
+ # Flag names for the current subcommand.
3969
+ if [[ "$cur" == -* ]]; then
3970
+ case "$cmd" in
3971
+ ${specs.filter((s) => s.flags.length > 0).map((s) => ` ${s.name}) compadd -- ${s.flags.join(" ")}; return ;;`).join("\n")}
3972
+ esac
3973
+ return
3974
+ fi
3975
+
3976
+ # Positional values (repo slugs, or a shell name).
3977
+ case "$cmd" in
3978
+ ${specs.filter((s) => s.slugs).map((s) => s.name).join("|")})
3979
+ local -a slugs
3980
+ slugs=("\${(@f)$(forgemap list --format slug 2>/dev/null)}")
3981
+ _describe 'slug' slugs
3982
+ ;;
3983
+ ${specs.filter((s) => s.positionalValues.length > 0).map((s) => ` ${s.name}) compadd ${s.positionalValues.join(" ")} ;;`).join("\n")}
3984
+ esac
3985
+ }
3986
+ compdef _forgemap forgemap
3987
+ `;
3988
+ }
3989
+ function renderFish(specs) {
3990
+ const names = specs.map((s) => s.name).join(" ");
3991
+ const flagLines = specs.flatMap((s) => s.flags.map((flag) => {
3992
+ const long = flag.replace(/^--/, "");
3993
+ const values = s.flagValues[flag];
3994
+ const valuePart = values ? ` -x -a '${values.join(" ")}'` : "";
3995
+ return `complete -c forgemap -n '__fish_seen_subcommand_from ${s.name}' -l ${long}${valuePart}`;
3996
+ })).join("\n");
3997
+ const shellCmds = specs.filter((s) => s.positionalValues.length > 0);
3998
+ return `# forgemap fish completion — drop into your ~/.config/fish/config.fish:
3999
+ # forgemap completion fish | source
4000
+
4001
+ # Subcommands (depth 1).
4002
+ complete -c forgemap -f -n '__fish_use_subcommand' -a '${names}'
4003
+
4004
+ # Flags per subcommand (with fixed value sets where applicable).
4005
+ ${flagLines}
4006
+
4007
+ # Shell flavor for completion / shell-init (depth 2).
4008
+ function __forgemap_needs_shell
4009
+ set -l tokens (commandline -opc)
4010
+ set -l shell_cmds ${shellCmds.map((s) => `"${s.name}"`).join(" ")}
4011
+ if test (count $tokens) -ge 2; and contains $tokens[2] $shell_cmds
4012
+ return 0
4013
+ end
4014
+ return 1
4015
+ end
4016
+ complete -c forgemap -f -n '__forgemap_needs_shell' -a '${shellCmds[0]?.positionalValues.join(" ") ?? ""}'
4017
+
4018
+ # Slugs (depth 2) for commands that take one.
4019
+ function __forgemap_needs_slug
4020
+ set -l tokens (commandline -opc)
4021
+ set -l slug_cmds ${specs.filter((s) => s.slugs).map((s) => `"${s.name}"`).join(" ")}
4022
+ if test (count $tokens) -ge 2; and contains $tokens[2] $slug_cmds
4023
+ return 0
4024
+ end
4025
+ return 1
4026
+ end
4027
+
4028
+ complete -c forgemap -f -n '__forgemap_needs_slug' \\
4029
+ -a '(forgemap list --format slug 2>/dev/null)'
4030
+ `;
4031
+ }
4032
+ var completionCommand = defineCommand({
4033
+ meta: {
4034
+ name: "completion",
4035
+ description: "Print a shell completion script. Source via `eval \"$(forgemap completion)\"`."
4036
+ },
4037
+ args: {
4038
+ shell: {
4039
+ type: "positional",
4040
+ description: `Shell flavor (${SUPPORTED_SHELLS.join(", ")}). Auto-detected from $SHELL if omitted.`,
4041
+ required: false
4042
+ },
4043
+ install: {
4044
+ type: "boolean",
4045
+ description: "Append the completion loader to your shell's rc file (idempotent) instead of printing",
4046
+ default: false
4047
+ }
4048
+ },
4049
+ async run({ args }) {
4050
+ const requested = args.shell ?? detectShell();
4051
+ if (!SUPPORTED_SHELLS.includes(requested)) {
4052
+ consola.error(`Unsupported shell "${requested}". Supported: ${SUPPORTED_SHELLS.join(", ")}.`);
4053
+ process.exitCode = 1;
4054
+ return;
4055
+ }
4056
+ if (args.install) {
4057
+ const { status, rcFile } = await installRcBlock(requested, "completion", [requested === "fish" ? "forgemap completion fish | source" : `eval "$(forgemap completion ${requested})"`]);
4058
+ if (status === "present") consola.info(`forgemap completion already present in ${rcFile}.`);
4059
+ else {
4060
+ const verb = status === "updated" ? "Updated" : "Added";
4061
+ consola.success(`${verb} forgemap completion in ${rcFile}.`);
4062
+ consola.info(`Run \`source ${rcFile}\` or restart your shell to activate it.`);
4063
+ }
4064
+ return;
4065
+ }
4066
+ const specs = commandSpecs();
4067
+ const out = requested === "fish" ? renderFish(specs) : requested === "zsh" ? renderZsh(specs) : renderBash(specs);
4068
+ process.stdout.write(out);
4069
+ }
4070
+ });
3329
4071
  //#endregion
3330
4072
  //#region src/bin/forgemap.ts
3331
4073
  runMain(defineCommand({
3332
4074
  meta: {
3333
4075
  name: "forgemap",
3334
- version: "0.5.0",
4076
+ version: "0.6.0",
3335
4077
  description: "Manage a local repo layout of the form <root>/<forge.dir>/<owner>/<repo>"
3336
4078
  },
3337
4079
  subCommands: {
@@ -3342,50 +4084,16 @@ runMain(defineCommand({
3342
4084
  cd: cdCommand,
3343
4085
  path: pathCommand,
3344
4086
  open: openCommand,
3345
- search: searchCommand,
4087
+ list: listCommand,
3346
4088
  pick: pickCommand,
3347
4089
  status: statusCommand,
3348
4090
  sync: syncCommand,
3349
- validate: defineCommand({
3350
- meta: {
3351
- name: "validate",
3352
- description: "Preflight: check the config schema, required CLI tools, and root directory"
3353
- },
3354
- args: {
3355
- json: {
3356
- type: "boolean",
3357
- description: "Emit a machine-readable JSON report",
3358
- default: false
3359
- },
3360
- config: {
3361
- type: "string",
3362
- description: "Path to forgemap.config.ts (overrides walk-up discovery)"
3363
- }
3364
- },
3365
- async run({ args }) {
3366
- const loaded = await loadForgeMapConfig({ configFile: args.config });
3367
- const configDir = loaded.configFile ? dirname(loaded.configFile) : loaded.cwd;
3368
- const checks = await runChecks(loaded.config, configDir);
3369
- const ok = checks.every((c) => c.severity !== "fail");
3370
- if (args.json) process.stdout.write(`${JSON.stringify({
3371
- ok,
3372
- checks
3373
- }, null, 2)}\n`);
3374
- else {
3375
- for (const c of checks) process.stdout.write(`${severitySymbol(c.severity)} ${c.name.padEnd(22)} ${colors.dim(c.message)}\n`);
3376
- process.stdout.write(`\n${ok ? colors.green("All checks passed.") : colors.red("Validation failed.")}\n`);
3377
- }
3378
- if (!ok) {
3379
- process.exitCode = 1;
3380
- return;
3381
- }
3382
- if (!loaded.configFile) consola.warn("No forgemap.config.ts found — using built-in defaults. Run `forgemap config init` to materialize one.");
3383
- }
3384
- }),
4091
+ validate: validateCommand,
3385
4092
  info: infoCommand,
3386
4093
  completion: completionCommand,
3387
4094
  "shell-init": shellInitCommand,
3388
- config: configCommand
4095
+ config: configCommand,
4096
+ forge: forgeCommand
3389
4097
  }
3390
4098
  }));
3391
4099
  //#endregion