diff --git a/CONFIG/yazi/keymap.toml b/CONFIG/yazi/keymap.toml index df6299d..d995c4d 100644 --- a/CONFIG/yazi/keymap.toml +++ b/CONFIG/yazi/keymap.toml @@ -36,3 +36,21 @@ desc = "Maximize or restore the preview pane" on = "C" run = "plugin ouch" desc = "Compress with ouch" + +[[mgr.prepend_keymap]] +on = [ "c", "a" ] +run = 'shell --block -- catai -T %s | nvim -R -c "set bt=nofile ft=xml" -' +desc = "catai: Dump selected files/folders into Neovim" + +[[mgr.prepend_keymap]] +on = [ "c", "x" ] +run = """\ + shell --block -- zsh -c \"args=(); for p in \\\"\\$@\\\"; do args+=(-x \\\"\\$p\\\"); done; catai -T \ + \\\"\\${args[@]}\\\" | nvim -R -c \\\"set bt=nofile ft=xml\\\" -\" -- %s\ + """ +desc = "catai: Dump current dir EXCEPT selected items" + +[[mgr.prepend_keymap]] +on = [ "c", "d" ] +run = 'shell --block -- catai -T -d | nvim -R -c "set bt=nofile ft=xml" -' +desc = "catai: Dump git dirty files into Neovim" diff --git a/CONFIG/zsh/.zshrc b/CONFIG/zsh/.zshrc index 0188232..a985e83 100644 --- a/CONFIG/zsh/.zshrc +++ b/CONFIG/zsh/.zshrc @@ -13,14 +13,16 @@ export SAVEHIST=200000 alias ls='ls --color=auto -Ah' alias ..='cd ..' alias ...='cd ../..' -catai () { - rg -l --hidden --glob '!.git/*' --sort path '^' -- "$@" | while IFS= read -r f; do - printf "\n── %s ──\n" "$f" - echo '```' - cat "$f" - echo '```' - done +catai() { + if [[ " $* " == *" -i "* || " $* " == *" -y "* ]]; then + local cmd + cmd="$(command catai --print-cmd "$@")" && [[ -n "$cmd" ]] && print -z "$cmd" + else + command catai "$@" + fi } +alias cataid='catai -d' +alias cataiy='catai -y' alias sioyek="QT_QPA_PLATFORM=xcb sioyek" # --- basic keybinds --- diff --git a/HOME/.local/bin/catai b/HOME/.local/bin/catai new file mode 100755 index 0000000..abe853c --- /dev/null +++ b/HOME/.local/bin/catai @@ -0,0 +1,352 @@ +#!/usr/bin/env python3.14 +""" +catai: Collect project tree & files in XML tags for LLM context. + +Requires Python >= 3.14 (PEP 649/749 deferred annotations, PEP 695 type statements). +""" + +import argparse +import os +import shlex +import shutil +import subprocess +import sys +import tempfile +from collections.abc import Iterable, Sequence +from dataclasses import dataclass +from pathlib import Path + +type GlobList = list[str] +type PathList = list[str] + +DEFAULT_IGNORES: tuple[str, ...] = ( + "!.git/*", + "!.env*", + "!*lock*", + "!*.lock", + "!*.min.*", +) + +EXAMPLES = """ +Examples: + catai # Dump entire current repo + catai src/ # Dump everything inside src/ + catai src -x legacy # Everything in src/ except src/legacy/ + catai src -x '*.test.ts' -x dist # Exclude multiple patterns + catai src '!legacy' '!*.spec.js' # Exclude using '!' shorthand + catai -T src/main.rs # Single file without + catai -i src -x vendor # Pick files in fzf + catai -y # Pick files/folders in Yazi + catai -d # Only git-modified & untracked files +""" + + +@dataclass(slots=True, frozen=True) +class Config: + paths: PathList + rg_globs: GlobList + dirty: bool + no_tree: bool + interactive: bool + yazi: bool + print_cmd: bool + + +def expand_exclude(pattern: str) -> GlobList: + """Expand a user exclusion pattern into ripgrep glob flags.""" + clean = pattern.lstrip("!").rstrip("/") + if not clean: + return [] + # Normalize absolute paths or leading './' so ripgrep globs match properly + try: + p = Path(clean) + if p.is_absolute(): + clean = str(p.relative_to(Path.cwd())) + elif clean.startswith("./"): + clean = clean[2:] + except ValueError: + pass + return [ + f"--glob=!{clean}", + f"--glob=!{clean}/**", + f"--glob=!**/{clean}", + f"--glob=!**/{clean}/**", + ] + + +def parse_args(argv: Sequence[str] | None = None) -> Config: + parser = argparse.ArgumentParser( + prog="catai", + description="Collect project tree and files in XML tags for LLM context.", + epilog=EXAMPLES, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "targets", + nargs="*", + help="Files, directories, or '!exclude' globs", + ) + parser.add_argument( + "-x", + "--exclude", + action="append", + default=[], + metavar="PATTERN", + help="Exclude folder or file glob (repeatable)", + ) + parser.add_argument( + "-d", + "--dirty", + action="store_true", + help="Only git modified & untracked files", + ) + parser.add_argument( + "-T", + "--no-tree", + action="store_true", + help="Skip header", + ) + parser.add_argument( + "-i", + "--interactive", + action="store_true", + help="Interactive fzf file picker", + ) + parser.add_argument( + "-y", + "--yazi", + action="store_true", + help="Interactive Yazi file/folder chooser", + ) + parser.add_argument( + "--print-cmd", + action="store_true", + help="Print selected command instead of executing (used by zsh print -z)", + ) + + ns = parser.parse_args(argv) + + rg_globs: GlobList = [f"--glob={g}" for g in DEFAULT_IGNORES] + for ex in ns.exclude: + rg_globs.extend(expand_exclude(ex)) + + paths: PathList = [] + for target in ns.targets: + match target: + case s if s.startswith("!"): + rg_globs.extend(expand_exclude(s[1:])) + case s: + paths.append(s) + + return Config( + paths=paths, + rg_globs=rg_globs, + dirty=ns.dirty, + no_tree=ns.no_tree, + interactive=ns.interactive, + yazi=ns.yazi, + print_cmd=ns.print_cmd, + ) + + +def run_lines(cmd: Sequence[str]) -> list[str]: + """Run a command and return non-empty stdout lines.""" + res = subprocess.run(cmd, capture_output=True, text=True, check=False) + return [line for line in res.stdout.splitlines() if line] + + +def render_tree(file_paths: Iterable[str]) -> str: + """Pure-Python directory tree renderer (no external 'tree' binary required).""" + type TreeDict = dict[str, TreeDict] + root: TreeDict = {} + + for raw_path in sorted(file_paths): + parts = Path(raw_path).parts + curr = root + for part in parts: + if part in (".", ""): + continue + curr = curr.setdefault(part, {}) + + lines: list[str] = ["."] + + def walk(node: TreeDict, prefix: str = "") -> None: + items = list(node.items()) + for idx, (name, children) in enumerate(items): + is_last = idx == len(items) - 1 + connector = "└── " if is_last else "├── " + lines.append(f"{prefix}{connector}{name}") + if children: + extension = " " if is_last else "│ " + walk(children, prefix + extension) + + walk(root) + return "\n".join(lines) + + +def pick_with_fzf(cfg: Config) -> PathList: + rg_cmd = [ + "rg", + "--files", + "--hidden", + "--sort", + "path", + *cfg.rg_globs, + "--", + *cfg.paths, + ] + all_files = run_lines(rg_cmd) + if not all_files: + return [] + + preview = "bat --color=always {} 2>/dev/null || cat {}" + proc = subprocess.run( + ["fzf", "-m", "--preview", preview], + input="\n".join(all_files), + stdout=subprocess.PIPE, + text=True, + check=False, + ) + return [line for line in proc.stdout.splitlines() if line] + + +def pick_with_yazi(cfg: Config) -> PathList: + with tempfile.NamedTemporaryFile(prefix="yazi-chooser.", delete=False) as tmp: + tmp_path = Path(tmp.name) + + try: + subprocess.run(["yazi", f"--chooser-file={tmp_path}", *cfg.paths], check=False) + if not tmp_path.exists() or tmp_path.stat().st_size == 0: + return [] + raw_picked = [ + line for line in tmp_path.read_text(encoding="utf-8").splitlines() if line + ] + cwd = Path.cwd() + rel_picked: PathList = [] + for p in raw_picked: + path_obj = Path(p) + try: + rel_picked.append(str(path_obj.relative_to(cwd))) + except ValueError: + rel_picked.append(str(path_obj)) + return rel_picked + finally: + tmp_path.unlink(missing_ok=True) + + +def collect_files(cfg: Config, target_paths: PathList) -> tuple[PathList, PathList]: + """Return (tree_files, matched_non_empty_text_files).""" + cwd = Path.cwd().resolve() + rel_targets: PathList = [] + for p in target_paths: + resolved = Path(p).resolve() + try: + rel = str(resolved.relative_to(cwd)) + rel_targets.append("." if rel == "" else rel) + except ValueError: + rel_targets.append(os.path.relpath(resolved, cwd)) + + if cfg.dirty: + dirty_cmd = [ + "git", + "ls-files", + "-m", + "-o", + "--exclude-standard", + "--", + *rel_targets, + ] + candidates = run_lines(dirty_cmd) + if not candidates: + return [], [] + else: + candidates = rel_targets + + # Non-empty, non-binary files via ripgrep '^' + rg_content_cmd = [ + "rg", + "-l", + "--hidden", + "--sort", + "path", + *cfg.rg_globs, + "^", + "--", + *candidates, + ] + matched_files = run_lines(rg_content_cmd) + + # Single-file edge case: `rg -l '^' single_file.txt` prints the filename, + # Ensure it is always included if non-empty + if cfg.no_tree or not matched_files: + return [], matched_files + + rg_tree_cmd = [ + "rg", + "--files", + "--hidden", + "--sort", + "path", + *cfg.rg_globs, + "--", + *rel_targets, + ] + tree_files = run_lines(rg_tree_cmd) + return tree_files, matched_files + + +def dump_output(tree_files: PathList, files: PathList, show_tree: bool) -> None: + out = sys.stdout + if show_tree and tree_files: + out.write("\n") + out.write(render_tree(tree_files)) + out.write("\n\n\n") + + for filepath in files: + path = Path(filepath) + out.write(f'\n') + out.flush() + try: + with path.open("rb") as f: + shutil.copyfileobj(f, sys.stdout.buffer) + # Ensure trailing newline before closing tag + if path.stat().st_size > 0: + with path.open("rb") as f: + f.seek(-1, os.SEEK_END) + if f.read(1) != b"\n": + out.write("\n") + except OSError as exc: + sys.stderr.write(f"Error reading {filepath}: {exc}\n") + out.write("\n\n") + + +def main() -> int: + cfg = parse_args() + + # Handle interactive pickers (-i / -y) + if cfg.interactive or cfg.yazi: + picked = pick_with_yazi(cfg) if cfg.yazi else pick_with_fzf(cfg) + if not picked: + return 1 + if cfg.print_cmd: + print(f"catai {shlex.join(picked)}") + return 0 + tree_files, files = collect_files(cfg, picked) + else: + tree_files, files = collect_files(cfg, cfg.paths) + + if not files: + msg = ( + "No dirty or untracked files found." + if cfg.dirty + else "No matching text files found." + ) + sys.stderr.write(f"{msg}\n") + return 1 + + dump_output(tree_files, files, show_tree=not cfg.no_tree) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/endos.py b/endos.py index 7489d6c..005185a 100755 --- a/endos.py +++ b/endos.py @@ -280,6 +280,7 @@ def install_zsh(overwrite: bool, reinstall: bool) -> None: run("chsh -s /usr/bin/zsh".split()) helper_maybe_copy(CFG_SRC, CFG_TGT, "starship.toml", overwrite, symlink=True) helper_maybe_copy(HOME_SRC, HOME_TGT, ".zshenv", overwrite, symlink=True) + helper_maybe_copy(HOME_SRC, HOME_TGT, ".local/bin/catai", overwrite, symlink=True) def install_tmux(overwrite: bool, reinstall: bool) -> None: