Created
December 15, 2025 17:21
-
-
Save umputun/0093c01389d7a7be55ccf47cad5d3467 to your computer and use it in GitHub Desktop.
Interactive CLI for running Claude Code in batch mode on selected project directories.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """claude-ask.py - run claude code batch mode on selected project directory. | |
| uses fzf to select project from configured directories. | |
| usage: | |
| claude-ask.py [options] [prompt...] | |
| options: | |
| --refresh force rebuild project cache | |
| --no-follow-symlinks don't follow symlinks when indexing (use with --refresh to rebuild) | |
| --clear-history clear prompt history | |
| --help show help message | |
| prompt... optional prompt text (skips interactive input) | |
| """ | |
| import argparse | |
| import os | |
| import shutil | |
| import subprocess | |
| import sys | |
| import threading | |
| import time | |
| from pathlib import Path | |
| DEV_DIRS = [Path.home() / "dev.work", Path.home() / "dev.umputun"] | |
| CACHE_FILE = Path.home() / ".cache" / "claude-ask-repos.txt" | |
| HISTORY_FILE = Path.home() / ".cache" / "claude-ask-history" | |
| CACHE_MAX_AGE = 259200 # 72 hours | |
| def check_deps() -> None: | |
| """verify required tools are installed, install fzf/glow if missing.""" | |
| # claude is required and cannot be auto-installed | |
| if not shutil.which("claude"): | |
| print("error: claude not found", file=sys.stderr) | |
| sys.exit(1) | |
| # install fzf and glow via brew if missing | |
| for cmd in ["fzf", "glow"]: | |
| if not shutil.which(cmd): | |
| if not shutil.which("brew"): | |
| print(f"error: {cmd} not found and brew not available", file=sys.stderr) | |
| sys.exit(1) | |
| print(f"installing {cmd}...") | |
| result = subprocess.run(["brew", "install", cmd]) | |
| if result.returncode != 0: | |
| print(f"error: failed to install {cmd}", file=sys.stderr) | |
| sys.exit(1) | |
| def needs_refresh(force: bool, cache_file: Path = CACHE_FILE, max_age: int = CACHE_MAX_AGE) -> bool: | |
| """check if cache needs refresh.""" | |
| if force: | |
| return True | |
| if not cache_file.exists(): | |
| return True | |
| cache_age = time.time() - cache_file.stat().st_mtime | |
| return cache_age > max_age | |
| def build_cache(follow_symlinks: bool = True) -> None: | |
| """find git repos and write to cache file.""" | |
| # spinner animation in background thread | |
| stop_spinner = threading.Event() | |
| def spinner(): | |
| frames = "◐◓◑◒" | |
| i = 0 | |
| while not stop_spinner.is_set(): | |
| print(f"\r\033[38;5;208m{frames[i]} indexing...\033[0m", end="", flush=True) | |
| i = (i + 1) % len(frames) | |
| time.sleep(0.1) | |
| spinner_thread = threading.Thread(target=spinner, daemon=True) | |
| spinner_thread.start() | |
| CACHE_FILE.parent.mkdir(parents=True, exist_ok=True) | |
| repos = [] | |
| for dev_dir in DEV_DIRS: | |
| if not dev_dir.is_dir(): | |
| continue | |
| for root, dirs, _ in os.walk(dev_dir, followlinks=follow_symlinks): | |
| if ".git" in dirs: | |
| repos.append(root) | |
| dirs.remove(".git") # don't descend into .git | |
| repos.sort() | |
| CACHE_FILE.write_text("\n".join(repos) + "\n" if repos else "") | |
| stop_spinner.set() | |
| spinner_thread.join(timeout=0.2) | |
| print("\r\033[K", end="") | |
| def select_project() -> str: | |
| """run fzf to select a project.""" | |
| try: | |
| cache_content = CACHE_FILE.read_text() | |
| except FileNotFoundError: | |
| print("error: cache file not found, run with --refresh", file=sys.stderr) | |
| sys.exit(1) | |
| result = subprocess.run( | |
| ["fzf", "--prompt=select project: "], | |
| input=cache_content, | |
| capture_output=True, | |
| text=True, | |
| ) | |
| if result.returncode != 0 or not result.stdout.strip(): | |
| print("no project selected", file=sys.stderr) | |
| sys.exit(1) | |
| return result.stdout.strip() | |
| def setup_readline() -> bool: | |
| """setup readline with history support, returns True if available.""" | |
| try: | |
| import readline | |
| HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True) | |
| if HISTORY_FILE.exists(): | |
| readline.read_history_file(HISTORY_FILE) | |
| return True | |
| except (ImportError, OSError): | |
| return False | |
| def save_history() -> None: | |
| """save readline history to file.""" | |
| try: | |
| import readline | |
| readline.write_history_file(HISTORY_FILE) | |
| except (ImportError, OSError): | |
| pass | |
| def get_prompt(project: str, cli_prompt: str) -> str: | |
| """get user prompt - from cli args or interactive input.""" | |
| if cli_prompt: | |
| print(f"{project} > {cli_prompt}") | |
| return cli_prompt | |
| has_readline = setup_readline() | |
| print(f"\033[36m{project}\033[0m \033[90m(:e for multiline)\033[0m") | |
| prompt_str = "> " | |
| last_interrupt = 0.0 | |
| while True: | |
| try: | |
| user_input = input(prompt_str) | |
| except KeyboardInterrupt: | |
| now = time.time() | |
| if now - last_interrupt < 0.5: | |
| print() | |
| sys.exit(130) | |
| last_interrupt = now | |
| print("\r\033[K", end="", flush=True) | |
| continue | |
| except EOFError: | |
| print() | |
| sys.exit(0) | |
| break | |
| if user_input in (":e", "!"): | |
| print("\033[36menter multiline\033[0m \033[90m(end with --- or Ctrl+D)\033[0m") | |
| lines = [] | |
| try: | |
| import readline | |
| # remove :e/! from history and remember length | |
| if readline.get_current_history_length() > 0: | |
| readline.remove_history_item(readline.get_current_history_length() - 1) | |
| hist_len_before = readline.get_current_history_length() | |
| except ImportError: | |
| readline = None | |
| hist_len_before = None | |
| try: | |
| while True: | |
| line = input() | |
| if line == "---": | |
| break | |
| lines.append(line) | |
| except EOFError: | |
| pass | |
| # remove individual lines from history and add combined prompt | |
| user_input = "\n".join(lines) | |
| if readline is not None and hist_len_before is not None and user_input.strip(): | |
| try: | |
| # remove lines added during multiline input (in reverse order) | |
| for _ in range(readline.get_current_history_length() - hist_len_before): | |
| readline.remove_history_item(readline.get_current_history_length() - 1) | |
| # add combined prompt as single history entry | |
| readline.add_history(user_input) | |
| except ValueError: | |
| pass | |
| if not user_input.strip(): | |
| print("no prompt entered", file=sys.stderr) | |
| sys.exit(1) | |
| # save to history | |
| if has_readline and user_input.strip() and user_input not in (":e", "!"): | |
| save_history() | |
| return user_input | |
| def run_claude(project: str, prompt: str) -> None: | |
| """run claude in batch mode and render output with glow.""" | |
| full_prompt = f"explore this codebase if necessary and answer: {prompt}" | |
| print() | |
| # spinner animation in background thread | |
| stop_spinner = threading.Event() | |
| def spinner(): | |
| frames = "◐◓◑◒" | |
| i = 0 | |
| while not stop_spinner.is_set(): | |
| print(f"\r\033[38;5;208m{frames[i]} working...\033[0m", end="", flush=True) | |
| i = (i + 1) % len(frames) | |
| time.sleep(0.1) | |
| spinner_thread = threading.Thread(target=spinner, daemon=True) | |
| spinner_thread.start() | |
| # run claude | |
| result = subprocess.run( | |
| ["claude", "--print", full_prompt], | |
| cwd=project, | |
| capture_output=True, | |
| text=True, | |
| ) | |
| # stop spinner and clear line | |
| stop_spinner.set() | |
| spinner_thread.join(timeout=0.2) | |
| print("\r\033[K", end="") | |
| if result.returncode != 0: | |
| print(f"error: claude failed: {result.stderr.strip()}", file=sys.stderr) | |
| sys.exit(1) | |
| output = result.stdout | |
| if not output.strip(): | |
| print("error: no output from claude", file=sys.stderr) | |
| sys.exit(1) | |
| # pipe to glow, fall back to raw output if glow fails | |
| glow_result = subprocess.run(["glow", "-s", "dracula"], input=output, text=True) | |
| if glow_result.returncode != 0: | |
| print(output) | |
| def run_tests() -> None: | |
| """run unit tests.""" | |
| import tempfile | |
| import unittest | |
| class TestClaude(unittest.TestCase): | |
| def test_needs_refresh_force(self): | |
| # force=True always returns True | |
| with tempfile.NamedTemporaryFile() as f: | |
| self.assertTrue(needs_refresh(True, Path(f.name))) | |
| def test_needs_refresh_no_file(self): | |
| # missing file returns True | |
| self.assertTrue(needs_refresh(False, Path("/nonexistent/file"))) | |
| def test_needs_refresh_fresh_file(self): | |
| # fresh file returns False | |
| with tempfile.NamedTemporaryFile() as f: | |
| self.assertFalse(needs_refresh(False, Path(f.name), max_age=3600)) | |
| def test_needs_refresh_stale_file(self): | |
| # stale file returns True (max_age=0 makes any file stale) | |
| with tempfile.NamedTemporaryFile() as f: | |
| self.assertTrue(needs_refresh(False, Path(f.name), max_age=0)) | |
| def test_build_cache_creates_file(self): | |
| # build_cache creates cache file with mocked directories | |
| from unittest.mock import patch | |
| with tempfile.TemporaryDirectory() as d: | |
| test_cache = Path(d) / "test-cache.txt" | |
| test_dev_dir = Path(d) / "dev" | |
| test_dev_dir.mkdir() | |
| # create a fake git repo | |
| (test_dev_dir / "project1" / ".git").mkdir(parents=True) | |
| with patch("__main__.CACHE_FILE", test_cache), \ | |
| patch("__main__.DEV_DIRS", [test_dev_dir]): | |
| build_cache() | |
| self.assertTrue(test_cache.exists()) | |
| content = test_cache.read_text() | |
| self.assertIn("project1", content) | |
| def test_get_prompt_cli(self): | |
| # cli prompt returns as-is (with output suppressed) | |
| import io | |
| from contextlib import redirect_stdout | |
| with redirect_stdout(io.StringIO()): | |
| result = get_prompt("/test/project", "my prompt") | |
| self.assertEqual(result, "my prompt") | |
| loader = unittest.TestLoader() | |
| suite = loader.loadTestsFromTestCase(TestClaude) | |
| runner = unittest.TextTestRunner(verbosity=2) | |
| result = runner.run(suite) | |
| sys.exit(0 if result.wasSuccessful() else 1) | |
| def main() -> None: | |
| parser = argparse.ArgumentParser( | |
| description="run claude code batch mode on selected project" | |
| ) | |
| parser.add_argument("--refresh", action="store_true", help="force rebuild project cache") | |
| parser.add_argument("--no-follow-symlinks", action="store_true", help="don't follow symlinks when indexing") | |
| parser.add_argument("--clear-history", action="store_true", help="clear prompt history") | |
| parser.add_argument("--test", action="store_true", help="run unit tests") | |
| parser.add_argument("prompt", nargs="*", help="prompt text (optional)") | |
| args = parser.parse_args() | |
| if args.test: | |
| run_tests() | |
| return | |
| if args.clear_history: | |
| if HISTORY_FILE.exists(): | |
| HISTORY_FILE.unlink() | |
| print("history cleared") | |
| else: | |
| print("no history to clear") | |
| return | |
| check_deps() | |
| follow_symlinks = not args.no_follow_symlinks | |
| if needs_refresh(args.refresh): | |
| build_cache(follow_symlinks=follow_symlinks) | |
| project = select_project() | |
| cli_prompt = " ".join(args.prompt) | |
| user_prompt = get_prompt(project, cli_prompt) | |
| run_claude(project, user_prompt) | |
| if __name__ == "__main__": | |
| try: | |
| main() | |
| except KeyboardInterrupt: | |
| print("\r\033[K", end="") # clear line | |
| sys.exit(130) # standard exit code for SIGINT |
Author
curl: (6) Could not resolve host: chmod
curl: (3) URL rejected: Bad hostname
&& is missing in command
curl -o ~/.local/bin/claude-ask https://gist.githubusercontent.com/umputun/0093c01389d7a7be55ccf47cad5d3467/raw/claude-ask.py && chmod +x ~/.local/bin/claude-ask
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Quickly ask Claude Code questions about any of your projects: select a repo with fzf, type your question, get a nicely rendered markdown response. Ideal for exploring codebases, getting quick answers, or code analysis without opening a full Claude Code session.
Installation
Make sure ~/.local/bin is in your PATH.
Features:
Setup
Edit these constants at the top of the script:
DEV_DIRSdirectories to scan for git reposCACHE_FILEcache locationHISTORY_FILEprompt historyCACHE_MAX_AGEcache lifetime in secondsRequirements: claude CLI, macOS with Homebrew (fzf and glow auto-installed if missing)
Kitty Terminal Integration
Add to your kitty.conf for quick access via
Cmd+/:map cmd+/ launch --type=os-window --cwd=current zsh -ic "claude-ask.py; echo; read -sk1 '?press any key to close'"