vde-layout 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from "node:module";
3
- import { Command, CommanderError } from "commander";
3
+ import { defineCommand, parseArgs, renderUsage } from "citty";
4
4
  import fs from "fs-extra";
5
5
  import path from "node:path";
6
6
  import os from "node:os";
@@ -8,10 +8,11 @@ import * as YAML from "yaml";
8
8
  import { parse } from "yaml";
9
9
  import { z } from "zod";
10
10
  import { createHash } from "node:crypto";
11
- import chalk from "chalk";
11
+ import chalk, { Chalk } from "chalk";
12
12
  import { execa } from "execa";
13
13
  import { createInterface } from "node:readline/promises";
14
14
  import { stdin, stdout } from "node:process";
15
+ import stringWidth from "string-width";
15
16
 
16
17
  //#region src/utils/errors.ts
17
18
  const ErrorCodes = {
@@ -114,6 +115,21 @@ const formatters = {
114
115
  //#region src/models/schema.ts
115
116
  const WindowModeSchema = z.enum(["new-window", "current-window"]);
116
117
  const TerminalBackendSchema = z.enum(["tmux", "wezterm"]);
118
+ const SELECT_UI_MODES = ["auto", "fzf"];
119
+ const SELECT_SURFACE_MODES = [
120
+ "auto",
121
+ "inline",
122
+ "tmux-popup"
123
+ ];
124
+ const SelectUiModeSchema = z.enum(SELECT_UI_MODES);
125
+ const SelectSurfaceModeSchema = z.enum(SELECT_SURFACE_MODES);
126
+ const SelectorFzfSchema = z.object({ extraArgs: z.array(z.string().min(1)).optional() }).strict();
127
+ const SelectorDefaultsSchema = z.object({
128
+ ui: SelectUiModeSchema.optional(),
129
+ surface: SelectSurfaceModeSchema.optional(),
130
+ tmuxPopupOpts: z.string().min(1).optional(),
131
+ fzf: SelectorFzfSchema.optional()
132
+ }).strict();
117
133
  const TerminalPaneSchema = z.object({
118
134
  name: z.string().min(1),
119
135
  command: z.string().optional(),
@@ -145,7 +161,10 @@ const PresetSchema = z.object({
145
161
  backend: TerminalBackendSchema.optional()
146
162
  });
147
163
  const ConfigSchema = z.object({
148
- defaults: z.object({ windowMode: WindowModeSchema.optional() }).optional(),
164
+ defaults: z.object({
165
+ windowMode: WindowModeSchema.optional(),
166
+ selector: SelectorDefaultsSchema.optional()
167
+ }).optional(),
149
168
  presets: z.record(PresetSchema)
150
169
  });
151
170
 
@@ -248,7 +267,8 @@ const createConfigLoader = (options = {}) => {
248
267
  const candidates = [];
249
268
  const projectCandidate = findProjectConfigCandidate();
250
269
  if (projectCandidate !== null) candidates.push(projectCandidate);
251
- candidates.push(...buildDefaultSearchPaths());
270
+ const defaultSearchPathGroups = buildDefaultSearchPathGroups();
271
+ candidates.push(...flattenSearchPathGroups(defaultSearchPathGroups));
252
272
  return [...new Set(candidates)];
253
273
  };
254
274
  const loadConfig = async () => {
@@ -258,16 +278,16 @@ const createConfigLoader = (options = {}) => {
258
278
  return validateYAML(await safeReadFile(filePath));
259
279
  }
260
280
  const searchPaths = computeCachedSearchPaths();
261
- const existingPaths = await filterExistingPaths(searchPaths);
262
- if (existingPaths.length === 0) throw createConfigError("Configuration file not found", ErrorCodes.CONFIG_NOT_FOUND, { searchPaths });
281
+ const globalPaths = await resolveFirstExistingPaths(buildDefaultSearchPathGroups());
263
282
  const projectPath = findProjectConfigCandidate();
264
- const globalPaths = existingPaths.filter((filePath) => filePath !== projectPath);
283
+ const projectConfigExists = projectPath !== null ? await fs.pathExists(projectPath) : false;
284
+ if (globalPaths.length === 0 && !projectConfigExists) throw createConfigError("Configuration file not found", ErrorCodes.CONFIG_NOT_FOUND, { searchPaths });
265
285
  let mergedConfig = { presets: {} };
266
286
  for (const globalPath of globalPaths) {
267
287
  const config = validateYAML(await safeReadFile(globalPath));
268
288
  mergedConfig = mergeConfigs(mergedConfig, config, emitWarning);
269
289
  }
270
- if (projectPath !== null && await fs.pathExists(projectPath)) {
290
+ if (projectPath !== null && projectConfigExists) {
271
291
  const config = validateYAML(await safeReadFile(projectPath));
272
292
  mergedConfig = mergeConfigs(mergedConfig, config, emitWarning);
273
293
  }
@@ -287,21 +307,36 @@ const createConfigLoader = (options = {}) => {
287
307
  getSearchPaths: () => computeCachedSearchPaths()
288
308
  };
289
309
  };
290
- const buildDefaultSearchPaths = () => {
291
- const paths = [];
310
+ const buildDefaultSearchPathGroups = () => {
311
+ const pathGroups = [];
292
312
  const vdeConfigPath = process.env.VDE_CONFIG_PATH;
293
- if (vdeConfigPath !== void 0) paths.push(path.join(vdeConfigPath, "layout.yml"));
313
+ if (vdeConfigPath !== void 0) pathGroups.push([path.join(vdeConfigPath, "layout.yml")]);
294
314
  const homeDir = process.env.HOME ?? os.homedir();
295
315
  const xdgConfigHome = process.env.XDG_CONFIG_HOME ?? path.join(homeDir, ".config");
296
- paths.push(path.join(xdgConfigHome, "vde", "layout.yml"));
316
+ pathGroups.push([path.join(xdgConfigHome, "vde", "layout", "config.yml"), path.join(xdgConfigHome, "vde", "layout.yml")]);
317
+ return pathGroups.map((group) => [...new Set(group)]);
318
+ };
319
+ const flattenSearchPathGroups = (pathGroups) => {
320
+ const paths = [];
321
+ for (const group of pathGroups) paths.push(...group);
297
322
  return [...new Set(paths)];
298
323
  };
324
+ const resolveFirstExistingPaths = async (pathGroups) => {
325
+ const existingPaths = await Promise.all(pathGroups.map(async (group) => findFirstExisting(group)));
326
+ const seenPaths = /* @__PURE__ */ new Set();
327
+ const resolvedPaths = [];
328
+ for (const existingPath of existingPaths) if (existingPath !== null && !seenPaths.has(existingPath)) {
329
+ seenPaths.add(existingPath);
330
+ resolvedPaths.push(existingPath);
331
+ }
332
+ return resolvedPaths;
333
+ };
299
334
  const findProjectConfigCandidate = () => {
300
335
  let currentDir = process.cwd();
301
336
  const { root } = path.parse(currentDir);
302
337
  while (true) {
303
- const candidate = path.join(currentDir, ".vde", "layout.yml");
304
- if (fs.existsSync(candidate)) return candidate;
338
+ const candidates = [path.join(currentDir, ".vde", "layout", "config.yml"), path.join(currentDir, ".vde", "layout.yml")];
339
+ for (const candidate of candidates) if (fs.existsSync(candidate)) return candidate;
305
340
  if (currentDir === root) break;
306
341
  const parent = path.dirname(currentDir);
307
342
  if (parent === currentDir) break;
@@ -313,11 +348,6 @@ const findFirstExisting = async (paths) => {
313
348
  for (const candidate of paths) if (await fs.pathExists(candidate)) return candidate;
314
349
  return null;
315
350
  };
316
- const filterExistingPaths = async (paths) => {
317
- const existing = [];
318
- for (const candidate of paths) if (await fs.pathExists(candidate)) existing.push(candidate);
319
- return existing;
320
- };
321
351
  const safeReadFile = async (filePath) => {
322
352
  try {
323
353
  return await fs.readFile(filePath, "utf8");
@@ -339,15 +369,39 @@ const mergeConfigs = (base, override, emitWarning) => {
339
369
  const baseDefaults = base.defaults;
340
370
  const overrideDefaults = override.defaults;
341
371
  if (baseDefaults?.windowMode !== void 0 && overrideDefaults?.windowMode !== void 0 && baseDefaults.windowMode !== overrideDefaults.windowMode) emitWarning(`[vde-layout] defaults.windowMode conflict: "${baseDefaults.windowMode}" overridden by "${overrideDefaults.windowMode}"`);
372
+ const mergedSelectorDefaults = mergeSelectorDefaults({
373
+ baseSelector: baseDefaults?.selector,
374
+ overrideSelector: overrideDefaults?.selector,
375
+ emitWarning
376
+ });
342
377
  const mergedDefaults = baseDefaults !== void 0 || overrideDefaults !== void 0 ? {
343
378
  ...baseDefaults ?? {},
344
- ...overrideDefaults ?? {}
379
+ ...overrideDefaults ?? {},
380
+ ...mergedSelectorDefaults !== void 0 ? { selector: mergedSelectorDefaults } : {}
345
381
  } : void 0;
346
382
  return mergedDefaults === void 0 ? { presets: mergedPresets } : {
347
383
  defaults: mergedDefaults,
348
384
  presets: mergedPresets
349
385
  };
350
386
  };
387
+ const mergeSelectorDefaults = ({ baseSelector, overrideSelector, emitWarning }) => {
388
+ if (baseSelector === void 0 && overrideSelector === void 0) return;
389
+ if (baseSelector?.ui !== void 0 && overrideSelector?.ui !== void 0 && baseSelector.ui !== overrideSelector.ui) emitWarning(`[vde-layout] defaults.selector.ui conflict: "${baseSelector.ui}" overridden by "${overrideSelector.ui}"`);
390
+ if (baseSelector?.surface !== void 0 && overrideSelector?.surface !== void 0 && baseSelector.surface !== overrideSelector.surface) emitWarning(`[vde-layout] defaults.selector.surface conflict: "${baseSelector.surface}" overridden by "${overrideSelector.surface}"`);
391
+ if (baseSelector?.tmuxPopupOpts !== void 0 && overrideSelector?.tmuxPopupOpts !== void 0 && baseSelector.tmuxPopupOpts !== overrideSelector.tmuxPopupOpts) emitWarning(`[vde-layout] defaults.selector.tmuxPopupOpts conflict: "${baseSelector.tmuxPopupOpts}" overridden by "${overrideSelector.tmuxPopupOpts}"`);
392
+ const baseExtraArgs = baseSelector?.fzf?.extraArgs;
393
+ const overrideExtraArgs = overrideSelector?.fzf?.extraArgs;
394
+ if (Array.isArray(baseExtraArgs) && Array.isArray(overrideExtraArgs) && JSON.stringify(baseExtraArgs) !== JSON.stringify(overrideExtraArgs)) emitWarning(`[vde-layout] defaults.selector.fzf.extraArgs conflict: global value overridden by project value`);
395
+ const mergedFzf = baseSelector?.fzf !== void 0 || overrideSelector?.fzf !== void 0 ? {
396
+ ...baseSelector?.fzf ?? {},
397
+ ...overrideSelector?.fzf ?? {}
398
+ } : void 0;
399
+ return {
400
+ ...baseSelector ?? {},
401
+ ...overrideSelector ?? {},
402
+ ...mergedFzf !== void 0 ? { fzf: mergedFzf } : {}
403
+ };
404
+ };
351
405
 
352
406
  //#endregion
353
407
  //#region src/layout/preset.ts
@@ -2706,9 +2760,9 @@ const resolveWindowMode = ({ cli, preset, defaults }) => {
2706
2760
 
2707
2761
  //#endregion
2708
2762
  //#region src/cli/preset-execution.ts
2709
- const executePreset = async ({ presetName, options, presetManager, createCommandExecutor, core, logger, handleError, handlePipelineFailure, output = (line) => console.log(line), cwd = process.cwd(), env = process.env }) => {
2763
+ const executePreset = async ({ presetName, options, skipLoadConfig = false, presetManager, createCommandExecutor, core, logger, handleError, handlePipelineFailure, output = (line) => console.log(line), cwd = process.cwd(), env = process.env }) => {
2710
2764
  try {
2711
- await presetManager.loadConfig();
2765
+ if (skipLoadConfig !== true) await presetManager.loadConfig();
2712
2766
  const preset = typeof presetName === "string" && presetName.length > 0 ? presetManager.getPreset(presetName) : presetManager.getDefaultPreset();
2713
2767
  const windowModeResolution = resolveWindowModeForPreset({
2714
2768
  presetManager,
@@ -2794,57 +2848,518 @@ const resolveWindowModeForPreset = ({ presetManager, options, presetWindowMode }
2794
2848
  });
2795
2849
  };
2796
2850
 
2851
+ //#endregion
2852
+ //#region src/cli/select-args.ts
2853
+ const selectUiModes = SELECT_UI_MODES;
2854
+ const selectSurfaceModes = SELECT_SURFACE_MODES;
2855
+ const selectUiModeSet = new Set(selectUiModes);
2856
+ const selectSurfaceModeSet = new Set(selectSurfaceModes);
2857
+ const isSelectUiMode = (value) => {
2858
+ return selectUiModeSet.has(value);
2859
+ };
2860
+ const isSelectSurfaceMode = (value) => {
2861
+ return selectSurfaceModeSet.has(value);
2862
+ };
2863
+ const normalizeSelectArgs = (args) => {
2864
+ const normalized = [];
2865
+ for (let index = 0; index < args.length; index += 1) {
2866
+ const token = args[index];
2867
+ if (typeof token !== "string") continue;
2868
+ if (token === "--") {
2869
+ normalized.push(...args.slice(index));
2870
+ break;
2871
+ }
2872
+ if (typeof token === "string" && token.startsWith("--select=")) {
2873
+ const mode = token.slice(9);
2874
+ normalized.push("--select");
2875
+ if (mode.length > 0) normalized.push("--select-ui", mode);
2876
+ continue;
2877
+ }
2878
+ if (token === "--select") {
2879
+ const nextToken = args[index + 1];
2880
+ if (typeof nextToken === "string" && isSelectUiMode(nextToken)) {
2881
+ normalized.push("--select", "--select-ui", nextToken);
2882
+ index += 1;
2883
+ continue;
2884
+ }
2885
+ }
2886
+ normalized.push(token);
2887
+ }
2888
+ return normalized;
2889
+ };
2890
+ const resolveSelectUiMode = (uiValue) => {
2891
+ if (uiValue === void 0) return "auto";
2892
+ if (isSelectUiMode(uiValue)) return uiValue;
2893
+ throw new Error(`Invalid value for --select-ui: "${uiValue}". Expected one of: ${selectUiModes.join(", ")}`);
2894
+ };
2895
+ const resolveSelectSurfaceMode = (surfaceValue) => {
2896
+ if (surfaceValue === void 0) return "auto";
2897
+ if (isSelectSurfaceMode(surfaceValue)) return surfaceValue;
2898
+ throw new Error(`Invalid value for --select-surface: "${surfaceValue}". Expected one of: ${selectSurfaceModes.join(", ")}`);
2899
+ };
2900
+
2901
+ //#endregion
2902
+ //#region src/cli/preset-selector.ts
2903
+ const FZF_BINARY = "fzf";
2904
+ const FZF_CHECK_TIMEOUT_MS = 5e3;
2905
+ const MAX_PREVIEW_BASE64_LENGTH = 64 * 1024;
2906
+ const RESERVED_FZF_ARGS = new Set([
2907
+ "delimiter",
2908
+ "with-nth",
2909
+ "ansi",
2910
+ "preview",
2911
+ "preview-window",
2912
+ "tmux"
2913
+ ]);
2914
+ const selectorChalk = new Chalk({ level: 1 });
2915
+ const sanitizeTsvCell = (value) => {
2916
+ return (value ?? "").replace(/[\t\r\n]+/g, " ");
2917
+ };
2918
+ const padDisplayCell = (value, width) => {
2919
+ const paddingLength = Math.max(0, width - stringWidth(value));
2920
+ return `${value}${" ".repeat(paddingLength)}`;
2921
+ };
2922
+ const buildPresetPreviewYaml = ({ presetKey, preset }) => {
2923
+ return YAML.stringify({ presets: { [presetKey]: preset } });
2924
+ };
2925
+ const defaultCheckFzfAvailability = async () => {
2926
+ try {
2927
+ await execa(FZF_BINARY, ["--version"], { timeout: FZF_CHECK_TIMEOUT_MS });
2928
+ return true;
2929
+ } catch (error) {
2930
+ const execaError = error;
2931
+ if (execaError.code === "ENOENT" || execaError.code === "ETIMEDOUT" || execaError.code === "ERR_EXECA_TIMEOUT" || execaError.timedOut === true) return false;
2932
+ throw error;
2933
+ }
2934
+ };
2935
+ const defaultRunFzf = async ({ args, input, cwd, env }) => {
2936
+ return { stdout: (await execa(FZF_BINARY, args, {
2937
+ input,
2938
+ cwd,
2939
+ env,
2940
+ stderr: "inherit"
2941
+ })).stdout };
2942
+ };
2943
+ const ensureFzfAvailable = async (checkFzfAvailability) => {
2944
+ if (await checkFzfAvailability()) return;
2945
+ throw createEnvironmentError("fzf is required for preset selection UI", ErrorCodes.BACKEND_NOT_FOUND, {
2946
+ backend: "fzf",
2947
+ binary: FZF_BINARY
2948
+ });
2949
+ };
2950
+ const isTmuxSession = (env) => {
2951
+ return typeof env.TMUX === "string" && env.TMUX.length > 0;
2952
+ };
2953
+ const resolveSurfaceMode = ({ surfaceMode, env }) => {
2954
+ if (surfaceMode === "auto") return isTmuxSession(env) ? "tmux-popup" : "inline";
2955
+ return surfaceMode;
2956
+ };
2957
+ const validateExtraFzfArgs = (fzfExtraArgs) => {
2958
+ for (const arg of fzfExtraArgs) {
2959
+ if (typeof arg !== "string" || arg.length === 0) throw new Error("Empty value is not allowed for --fzf-arg");
2960
+ if (!arg.startsWith("--")) continue;
2961
+ const withoutPrefix = arg.slice(2);
2962
+ if (withoutPrefix.length === 0) continue;
2963
+ const optionName = withoutPrefix.split("=")[0];
2964
+ if (optionName !== void 0 && RESERVED_FZF_ARGS.has(optionName)) throw new Error(`--fzf-arg cannot override reserved fzf option: --${optionName}`);
2965
+ }
2966
+ };
2967
+ const buildFzfArgs = ({ surfaceMode, tmuxPopupOptions, fzfExtraArgs, env }) => {
2968
+ validateExtraFzfArgs(fzfExtraArgs);
2969
+ const resolvedSurfaceMode = resolveSurfaceMode({
2970
+ surfaceMode,
2971
+ env
2972
+ });
2973
+ if (resolvedSurfaceMode === "tmux-popup" && isTmuxSession(env) !== true) throw new Error("tmux popup selector surface requires running inside tmux");
2974
+ return [
2975
+ "--delimiter=\\t",
2976
+ "--ansi",
2977
+ "--with-nth=2",
2978
+ "--prompt=preset> ",
2979
+ "--layout=reverse",
2980
+ "--height=80%",
2981
+ "--border",
2982
+ "--preview=node -e 'process.stdout.write(Buffer.from(process.argv[1], \"base64\").toString(\"utf8\"))' {3}",
2983
+ "--preview-window=right,60%,border-left,wrap",
2984
+ ...resolvedSurfaceMode === "tmux-popup" ? [tmuxPopupOptions !== void 0 ? `--tmux=${tmuxPopupOptions}` : "--tmux"] : [],
2985
+ ...fzfExtraArgs
2986
+ ];
2987
+ };
2988
+ const toPresetRows = ({ presetInfos, presetManager }) => {
2989
+ const rows = presetInfos.map((presetInfo) => {
2990
+ const key = sanitizeTsvCell(presetInfo.key);
2991
+ const name = sanitizeTsvCell(presetInfo.name);
2992
+ const description = sanitizeTsvCell(presetInfo.description);
2993
+ const preset = presetManager.getPreset(presetInfo.key);
2994
+ const previewYaml = buildPresetPreviewYaml({
2995
+ presetKey: presetInfo.key,
2996
+ preset
2997
+ });
2998
+ const previewBase64 = Buffer.from(previewYaml, "utf8").toString("base64");
2999
+ if (previewBase64.length > MAX_PREVIEW_BASE64_LENGTH) throw new Error(`Preset preview is too large for fzf inline preview payload: "${presetInfo.key}" (${previewBase64.length} bytes)`);
3000
+ return {
3001
+ key,
3002
+ name,
3003
+ description,
3004
+ previewBase64
3005
+ };
3006
+ });
3007
+ const keyColumnWidth = rows.reduce((maxWidth, row) => Math.max(maxWidth, stringWidth(row.key)), 0);
3008
+ const nameColumnWidth = rows.reduce((maxWidth, row) => Math.max(maxWidth, stringWidth(row.name)), 0);
3009
+ return rows.map((row) => {
3010
+ const key = padDisplayCell(row.key, keyColumnWidth);
3011
+ const name = padDisplayCell(row.name, nameColumnWidth);
3012
+ const description = row.description.length > 0 ? row.description : selectorChalk.gray("(no description)");
3013
+ const display = `${selectorChalk.cyan(key)} ${selectorChalk.bold(name)} ${selectorChalk.dim(description)}`;
3014
+ return {
3015
+ key: row.key,
3016
+ name: row.name,
3017
+ description: row.description,
3018
+ display,
3019
+ previewBase64: row.previewBase64
3020
+ };
3021
+ });
3022
+ };
3023
+ const buildFzfInput = (rows) => {
3024
+ return rows.map((row, index) => {
3025
+ return [
3026
+ String(index),
3027
+ row.display,
3028
+ row.previewBase64
3029
+ ].join(" ");
3030
+ }).join("\n");
3031
+ };
3032
+ const parseSelectedPresetName = ({ selectedLine, rows }) => {
3033
+ const trimmed = selectedLine.trim();
3034
+ if (trimmed.length === 0) return null;
3035
+ const idCell = trimmed.split(" ")[0];
3036
+ const id = Number(idCell);
3037
+ if (!Number.isInteger(id) || id < 0 || id >= rows.length) throw new Error("Invalid selection returned from fzf");
3038
+ return rows[id]?.key ?? null;
3039
+ };
3040
+ const runFzfSelector = async ({ rows, fzfArgs, runFzf, cwd, env }) => {
3041
+ try {
3042
+ const presetName = parseSelectedPresetName({
3043
+ selectedLine: (await runFzf({
3044
+ input: buildFzfInput(rows),
3045
+ args: [...fzfArgs],
3046
+ cwd,
3047
+ env
3048
+ })).stdout,
3049
+ rows
3050
+ });
3051
+ if (presetName === null) return { status: "cancelled" };
3052
+ return {
3053
+ status: "selected",
3054
+ presetName
3055
+ };
3056
+ } catch (error) {
3057
+ if (error.exitCode === 130) return { status: "cancelled" };
3058
+ throw error;
3059
+ }
3060
+ };
3061
+ const selectPreset = async ({ uiMode, surfaceMode, tmuxPopupOptions, fzfExtraArgs = [], presetManager, logger, skipLoadConfig = false, cwd = process.cwd(), env = process.env, isInteractive = () => process.stdin.isTTY === true && process.stdout.isTTY === true && process.stderr.isTTY === true, checkFzfAvailability = defaultCheckFzfAvailability, runFzf = defaultRunFzf }) => {
3062
+ if (isInteractive() !== true) throw new Error("Preset selection requires an interactive terminal");
3063
+ await ensureFzfAvailable(checkFzfAvailability);
3064
+ if (skipLoadConfig !== true) await presetManager.loadConfig();
3065
+ const presetInfos = presetManager.listPresets();
3066
+ if (presetInfos.length === 0) throw new Error("No presets defined");
3067
+ const fzfArgs = buildFzfArgs({
3068
+ surfaceMode,
3069
+ tmuxPopupOptions,
3070
+ fzfExtraArgs,
3071
+ env
3072
+ });
3073
+ logger.debug(`Preset selection UI: ${uiMode}`);
3074
+ return runFzfSelector({
3075
+ rows: toPresetRows({
3076
+ presetInfos,
3077
+ presetManager
3078
+ }),
3079
+ fzfArgs,
3080
+ runFzf,
3081
+ cwd,
3082
+ env
3083
+ });
3084
+ };
3085
+
2797
3086
  //#endregion
2798
3087
  //#region src/cli/index.ts
3088
+ const backendValues = ["tmux", "wezterm"];
3089
+ const listCommandName = "list";
3090
+ const EXIT_CODE_CANCELLED = 130;
3091
+ const optionNamesAllowOptionLikeValue = new Set(["fzfArg", "fzf-arg"]);
3092
+ const toKebabCase = (value) => {
3093
+ return value.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);
3094
+ };
3095
+ const toOptionSpec = (kind, optionName) => {
3096
+ return {
3097
+ kind,
3098
+ allowOptionLikeValue: optionNamesAllowOptionLikeValue.has(optionName)
3099
+ };
3100
+ };
3101
+ const buildOptionSpecs = (argsDef) => {
3102
+ const longOptions = /* @__PURE__ */ new Map();
3103
+ const shortOptions = /* @__PURE__ */ new Map();
3104
+ for (const [argName, arg] of Object.entries(argsDef)) {
3105
+ if (arg.type === "positional") continue;
3106
+ const valueKind = arg.type === "boolean" ? "boolean" : "value";
3107
+ const kebabName = toKebabCase(argName);
3108
+ longOptions.set(argName, toOptionSpec(valueKind, argName));
3109
+ longOptions.set(kebabName, toOptionSpec(valueKind, kebabName));
3110
+ const aliases = "alias" in arg ? Array.isArray(arg.alias) ? arg.alias : typeof arg.alias === "string" ? [arg.alias] : [] : [];
3111
+ for (const alias of aliases) {
3112
+ if (alias.length === 1) {
3113
+ shortOptions.set(alias, toOptionSpec(valueKind, alias));
3114
+ continue;
3115
+ }
3116
+ longOptions.set(alias, toOptionSpec(valueKind, alias));
3117
+ const kebabAlias = toKebabCase(alias);
3118
+ longOptions.set(kebabAlias, toOptionSpec(valueKind, kebabAlias));
3119
+ }
3120
+ }
3121
+ return {
3122
+ longOptions,
3123
+ shortOptions
3124
+ };
3125
+ };
3126
+ const validateRawOptions = (args, optionSpecs) => {
3127
+ for (let index = 0; index < args.length; index += 1) {
3128
+ const token = args[index];
3129
+ if (typeof token !== "string") continue;
3130
+ if (token === "--") break;
3131
+ if (!token.startsWith("-") || token === "-") continue;
3132
+ if (token.startsWith("--")) {
3133
+ const value = token.slice(2);
3134
+ if (value.length === 0) continue;
3135
+ const separatorIndex = value.indexOf("=");
3136
+ const rawOptionName = separatorIndex >= 0 ? value.slice(0, separatorIndex) : value;
3137
+ const optionName = rawOptionName.startsWith("no-") ? rawOptionName.slice(3) : rawOptionName;
3138
+ const optionSpec = optionSpecs.longOptions.get(optionName);
3139
+ const kind = optionSpec?.kind;
3140
+ if (kind === void 0) throw new Error(`Unknown option: --${rawOptionName}`);
3141
+ if (kind === "value") if (separatorIndex >= 0) {
3142
+ if (value.slice(separatorIndex + 1).length === 0) throw new Error(`Missing value for option: --${optionName}`);
3143
+ } else {
3144
+ const nextToken = args[index + 1];
3145
+ if (typeof nextToken !== "string" || nextToken.length === 0) throw new Error(`Missing value for option: --${optionName}`);
3146
+ if (nextToken.startsWith("-") && optionSpec?.allowOptionLikeValue !== true) throw new Error(`Missing value for option: --${optionName}`);
3147
+ index += 1;
3148
+ }
3149
+ continue;
3150
+ }
3151
+ const shortFlags = token.slice(1);
3152
+ for (let flagIndex = 0; flagIndex < shortFlags.length; flagIndex += 1) {
3153
+ const option = shortFlags[flagIndex];
3154
+ if (typeof option !== "string" || option.length === 0) continue;
3155
+ const optionSpec = optionSpecs.shortOptions.get(option);
3156
+ const kind = optionSpec?.kind;
3157
+ if (kind === void 0) throw new Error(`Unknown option: -${option}`);
3158
+ if (kind === "value") {
3159
+ if (flagIndex < shortFlags.length - 1) break;
3160
+ const nextToken = args[index + 1];
3161
+ if (typeof nextToken !== "string" || nextToken.length === 0) throw new Error(`Missing value for option: -${option}`);
3162
+ if (nextToken.startsWith("-") && optionSpec?.allowOptionLikeValue !== true) throw new Error(`Missing value for option: -${option}`);
3163
+ index += 1;
3164
+ break;
3165
+ }
3166
+ }
3167
+ }
3168
+ };
3169
+ const getPositionals = (args) => {
3170
+ return args._.filter((value) => typeof value === "string");
3171
+ };
3172
+ const collectOptionValues = ({ args, optionNames }) => {
3173
+ const values = [];
3174
+ const optionNameSet = new Set(optionNames);
3175
+ for (let index = 0; index < args.length; index += 1) {
3176
+ const token = args[index];
3177
+ if (typeof token !== "string") continue;
3178
+ if (token === "--") break;
3179
+ if (!token.startsWith("--")) continue;
3180
+ const eqIndex = token.indexOf("=");
3181
+ const rawName = eqIndex >= 0 ? token.slice(2, eqIndex) : token.slice(2);
3182
+ if (optionNameSet.has(rawName) !== true) continue;
3183
+ if (eqIndex >= 0) {
3184
+ values.push(token.slice(eqIndex + 1));
3185
+ continue;
3186
+ }
3187
+ const nextToken = args[index + 1];
3188
+ if (typeof nextToken === "string") {
3189
+ values.push(nextToken);
3190
+ index += 1;
3191
+ }
3192
+ }
3193
+ return values;
3194
+ };
2799
3195
  const createCli = (options = {}) => {
2800
3196
  const presetManager = options.presetManager ?? createPresetManager();
2801
3197
  const createCommandExecutor = options.createCommandExecutor ?? ((opts) => {
2802
3198
  if (opts.dryRun) return createDryRunExecutor({ verbose: opts.verbose });
2803
3199
  return createRealExecutor({ verbose: opts.verbose });
2804
3200
  });
3201
+ const selectPreset$1 = options.selectPreset ?? selectPreset;
2805
3202
  const core = options.core ?? {
2806
3203
  compilePreset,
2807
3204
  compilePresetFromValue,
2808
3205
  createLayoutPlan,
2809
3206
  emitPlan
2810
3207
  };
2811
- const program = new Command();
2812
3208
  const version = loadPackageVersion(createRequire(import.meta.url));
2813
3209
  let logger = createLogger();
2814
3210
  const errorHandlers = createCliErrorHandlers({ getLogger: () => logger });
2815
- const setupProgram = () => {
2816
- program.exitOverride();
2817
- program.name("vde-layout").description("VDE (Vibrant Development Environment) Layout Manager - tmux pane layout management tool").version(version, "-v, --version", "Show version").helpOption("-h, --help", "Show help");
2818
- program.option("--verbose", "Show detailed logs", false);
2819
- program.option("--dry-run", "Display commands without executing", false);
2820
- program.option("--backend <backend>", "Select terminal backend (tmux or wezterm)");
2821
- program.option("--config <path>", "Path to configuration file");
2822
- program.option("--current-window", "Use the current tmux window for layout (kills other panes)", false);
2823
- program.option("--new-window", "Always create a new tmux window for layout", false);
2824
- program.hook("preAction", (_thisCommand, actionCommand) => {
3211
+ const rootArgsDef = {
3212
+ preset: {
3213
+ type: "positional",
3214
+ description: "Preset name (defaults to \"default\" preset when omitted)",
3215
+ required: false
3216
+ },
3217
+ verbose: {
3218
+ type: "boolean",
3219
+ description: "Show detailed logs"
3220
+ },
3221
+ dryRun: {
3222
+ type: "boolean",
3223
+ description: "Display commands without executing"
3224
+ },
3225
+ backend: {
3226
+ type: "enum",
3227
+ options: [...backendValues],
3228
+ description: "Select terminal backend (tmux or wezterm)"
3229
+ },
3230
+ config: {
3231
+ type: "string",
3232
+ valueHint: "path",
3233
+ description: "Path to configuration file"
3234
+ },
3235
+ currentWindow: {
3236
+ type: "boolean",
3237
+ description: "Use the current tmux window for layout (kills other panes)"
3238
+ },
3239
+ newWindow: {
3240
+ type: "boolean",
3241
+ description: "Always create a new tmux window for layout"
3242
+ },
3243
+ select: {
3244
+ type: "boolean",
3245
+ description: "Select preset from interactive UI"
3246
+ },
3247
+ selectUi: {
3248
+ type: "enum",
3249
+ options: [...selectUiModes],
3250
+ description: "Select preset UI backend (auto or fzf)"
3251
+ },
3252
+ selectSurface: {
3253
+ type: "enum",
3254
+ options: [...selectSurfaceModes],
3255
+ description: "Select selector surface mode (auto, inline, or tmux-popup)"
3256
+ },
3257
+ selectTmuxPopupOpts: {
3258
+ type: "string",
3259
+ valueHint: "opts",
3260
+ description: "tmux popup options used for fzf --tmux=<opts> (example: 80%,70%)"
3261
+ },
3262
+ fzfArg: {
3263
+ type: "string",
3264
+ valueHint: "arg",
3265
+ description: "Additional argument passed to fzf selector (repeatable)"
3266
+ },
3267
+ help: {
3268
+ type: "boolean",
3269
+ alias: "h",
3270
+ description: "Show help"
3271
+ },
3272
+ version: {
3273
+ type: "boolean",
3274
+ alias: "v",
3275
+ description: "Show version"
3276
+ }
3277
+ };
3278
+ const listCommand = defineCommand({ meta: {
3279
+ name: listCommandName,
3280
+ description: "List available presets"
3281
+ } });
3282
+ const rootCommand = defineCommand({
3283
+ meta: {
3284
+ name: "vde-layout",
3285
+ description: "VDE (Vibrant Development Environment) Layout Manager - tmux pane layout management tool",
3286
+ version
3287
+ },
3288
+ args: rootArgsDef,
3289
+ subCommands: { [listCommandName]: listCommand }
3290
+ });
3291
+ const optionSpecs = buildOptionSpecs(rootArgsDef);
3292
+ const run = async (args = process.argv.slice(2)) => {
3293
+ logger = createLogger();
3294
+ try {
3295
+ const normalizedArgs = normalizeSelectArgs(args);
3296
+ validateRawOptions(normalizedArgs, optionSpecs);
3297
+ const parsedArgs = parseArgs(normalizedArgs, rootArgsDef);
3298
+ const fzfCliArgs = collectOptionValues({
3299
+ args: normalizedArgs,
3300
+ optionNames: ["fzf-arg", "fzfArg"]
3301
+ });
3302
+ const positionals = getPositionals(parsedArgs);
3303
+ const headPositional = positionals[0];
3304
+ if (parsedArgs.help === true) {
3305
+ const usage = headPositional === listCommandName ? await renderUsage(listCommand, rootCommand) : await renderUsage(rootCommand);
3306
+ console.log(`${usage}\n`);
3307
+ return 0;
3308
+ }
3309
+ if (parsedArgs.version === true) {
3310
+ console.log(version);
3311
+ return 0;
3312
+ }
2825
3313
  logger = applyRuntimeOptions({
2826
- runtimeOptions: typeof actionCommand.optsWithGlobals === "function" ? actionCommand.optsWithGlobals() : program.opts(),
3314
+ runtimeOptions: {
3315
+ verbose: parsedArgs.verbose === true,
3316
+ config: typeof parsedArgs.config === "string" ? parsedArgs.config : void 0
3317
+ },
2827
3318
  createLogger,
2828
3319
  presetManager
2829
3320
  });
2830
- });
2831
- program.command("list").description("List available presets").action(async () => {
2832
- lastExitCode = await listPresets({
2833
- presetManager,
2834
- logger,
2835
- onError: errorHandlers.handleError
2836
- });
2837
- });
2838
- program.argument("[preset]", "Preset name (defaults to \"default\" preset when omitted)").action(async (presetName) => {
2839
- const opts = program.opts();
2840
- lastExitCode = await executePreset({
2841
- presetName,
3321
+ if (headPositional === listCommandName) {
3322
+ const extraArgs = positionals.slice(1);
3323
+ if (extraArgs.length > 0) throw new Error(`too many arguments for '${listCommandName}'. Expected 0 arguments but got ${extraArgs.length}.`);
3324
+ return await listPresets({
3325
+ presetManager,
3326
+ logger,
3327
+ onError: errorHandlers.handleError
3328
+ });
3329
+ }
3330
+ if (positionals.length > 1) throw new Error(`too many arguments. Expected at most 1 argument but got ${positionals.length}.`);
3331
+ if (parsedArgs.selectUi !== void 0 && parsedArgs.select !== true) throw new Error("--select-ui requires --select");
3332
+ if (parsedArgs.selectSurface !== void 0 && parsedArgs.select !== true) throw new Error("--select-surface requires --select");
3333
+ if (parsedArgs.selectTmuxPopupOpts !== void 0 && parsedArgs.select !== true) throw new Error("--select-tmux-popup-opts requires --select");
3334
+ if (fzfCliArgs.length > 0 && parsedArgs.select !== true) throw new Error("--fzf-arg requires --select");
3335
+ if (parsedArgs.select === true && typeof headPositional === "string" && headPositional.length > 0) throw new Error("Cannot use preset argument with --select");
3336
+ let resolvedPresetName = headPositional;
3337
+ let configLoaded = false;
3338
+ if (parsedArgs.select === true) {
3339
+ await presetManager.loadConfig();
3340
+ configLoaded = true;
3341
+ const selectorDefaults = presetManager.getDefaults()?.selector;
3342
+ const selection = await selectPreset$1({
3343
+ uiMode: resolveSelectUiMode(typeof parsedArgs.selectUi === "string" ? parsedArgs.selectUi : selectorDefaults?.ui),
3344
+ surfaceMode: resolveSelectSurfaceMode(typeof parsedArgs.selectSurface === "string" ? parsedArgs.selectSurface : selectorDefaults?.surface),
3345
+ tmuxPopupOptions: typeof parsedArgs.selectTmuxPopupOpts === "string" ? parsedArgs.selectTmuxPopupOpts : selectorDefaults?.tmuxPopupOpts,
3346
+ fzfExtraArgs: [...selectorDefaults?.fzf?.extraArgs ?? [], ...fzfCliArgs],
3347
+ presetManager,
3348
+ logger,
3349
+ skipLoadConfig: true
3350
+ });
3351
+ if (selection.status === "cancelled") return EXIT_CODE_CANCELLED;
3352
+ resolvedPresetName = selection.presetName;
3353
+ }
3354
+ return await executePreset({
3355
+ presetName: resolvedPresetName,
3356
+ skipLoadConfig: configLoaded,
2842
3357
  options: {
2843
- verbose: opts.verbose === true,
2844
- dryRun: opts.dryRun === true,
2845
- currentWindow: opts.currentWindow === true,
2846
- newWindow: opts.newWindow === true,
2847
- backend: opts.backend
3358
+ verbose: parsedArgs.verbose === true,
3359
+ dryRun: parsedArgs.dryRun === true,
3360
+ currentWindow: parsedArgs.currentWindow === true,
3361
+ newWindow: parsedArgs.newWindow === true,
3362
+ backend: typeof parsedArgs.backend === "string" ? parsedArgs.backend : void 0
2848
3363
  },
2849
3364
  presetManager,
2850
3365
  createCommandExecutor,
@@ -2853,20 +3368,9 @@ const createCli = (options = {}) => {
2853
3368
  handleError: errorHandlers.handleError,
2854
3369
  handlePipelineFailure: errorHandlers.handlePipelineFailure
2855
3370
  });
2856
- });
2857
- };
2858
- let lastExitCode = 0;
2859
- setupProgram();
2860
- const run = async (args = process.argv.slice(2)) => {
2861
- lastExitCode = 0;
2862
- logger = createLogger();
2863
- try {
2864
- await program.parseAsync(args, { from: "user" });
2865
3371
  } catch (error) {
2866
- if (error instanceof CommanderError) return error.exitCode;
2867
3372
  return errorHandlers.handleError(error);
2868
3373
  }
2869
- return lastExitCode;
2870
3374
  };
2871
3375
  return { run };
2872
3376
  };