Skip to content

Instantly share code, notes, and snippets.

@umputun
Created December 15, 2025 17:21
Show Gist options
  • Select an option

  • Save umputun/0093c01389d7a7be55ccf47cad5d3467 to your computer and use it in GitHub Desktop.

Select an option

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.
#!/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
@umputun
Copy link
Author

umputun commented Dec 15, 2025

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

# Download the script
curl -o ~/.local/bin/claude-ask https://gist.githubusercontent.com/umputun/0093c01389d7a7be55ccf47cad5d3467/raw/claude-ask.py &&  chmod +x ~/.local/bin/claude-ask

# Or clone and symlink
git clone https://gist.github.com/0093c01389d7a7be55ccf47cad5d3467.git claude-ask
ln -s $(pwd)/claude-ask/claude-ask.py ~/.local/bin/cldq

Make sure ~/.local/bin is in your PATH.

Features:

  • fzf-powered project selection from configured dev directories
  • Readline history with multiline input support (:e or !)
  • Auto-installs fzf/glow via Homebrew if missing
  • Renders Claude's markdown output with glow
  • 72-hour cache for fast project discovery
  • --refresh to rebuild cache, --no-follow-symlinks option
  • --clear-history to reset prompt history

Setup

Edit these constants at the top of the script:

  • DEV_DIRS directories to scan for git repos
  • CACHE_FILE cache location
  • HISTORY_FILE prompt history
  • CACHE_MAX_AGE cache lifetime in seconds

Requirements: 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'"

@sivachok
Copy link

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