From b171ee5ecfe04a67a664cd5be4813e80cda60169 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 13 Feb 2026 15:35:02 -0800 Subject: [PATCH] Add non-interactive mode to claude-search CLI Add --no-interactive/-n flag that prints results and exits without the V/E/Q interactive prompt, making claude-search usable in piped, scripted, and Claude Code session contexts. Also adds --help/-h and --max-results/-m flags via argparse. Closes #44 Co-Authored-By: Claude Opus 4.6 --- src/search_cli.py | 95 ++++++++++++------ tests/test_search_cli.py | 209 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 275 insertions(+), 29 deletions(-) create mode 100644 tests/test_search_cli.py diff --git a/src/search_cli.py b/src/search_cli.py index ac83723..389e9f6 100644 --- a/src/search_cli.py +++ b/src/search_cli.py @@ -4,7 +4,7 @@ This is used when running `claude-search` from the command line. """ -import sys +import argparse # Handle both package and direct execution imports try: @@ -18,37 +18,70 @@ from extract_claude_logs import ClaudeConversationExtractor +def parse_args(argv=None): + """Parse command-line arguments.""" + parser = argparse.ArgumentParser( + prog="claude-search", + description="Search Claude Code conversation transcripts.", + ) + parser.add_argument( + "words", + nargs="*", + default=[], + help="Search terms (joined with spaces)", + ) + parser.add_argument( + "-n", "--no-interactive", + action="store_true", + default=False, + help="Print results and exit without interactive prompts", + ) + parser.add_argument( + "-m", "--max-results", + type=int, + default=20, + help="Maximum number of results to return (default: 20)", + ) + + args = parser.parse_args(argv) + args.search_term = " ".join(args.words) + return args + + def main(): """Main entry point for CLI search.""" - # Get search term from stdin or arguments - if len(sys.argv) > 1: - # Search term provided as argument - search_term = " ".join(sys.argv[1:]) - else: - # Prompt for search term + args = parse_args() + + search_term = args.search_term + + # If no search term from args, prompt interactively + if not search_term: + if args.no_interactive: + print("āŒ No search term provided") + return try: search_term = input("šŸ” Enter search term: ").strip() except (EOFError, KeyboardInterrupt): print("\nšŸ‘‹ Search cancelled") return - + if not search_term: print("āŒ No search term provided") return - + print(f"\nšŸ” Searching for: '{search_term}'") print("=" * 60) - + # Initialize searcher searcher = ConversationSearcher() smart_searcher = create_smart_searcher(searcher) - + # Perform search - results = smart_searcher.search(search_term, max_results=20) - + results = smart_searcher.search(search_term, max_results=args.max_results) + if results: print(f"\nāœ… Found {len(results)} results across conversations:\n") - + # Group by file by_file = {} for result in results: @@ -56,31 +89,35 @@ def main(): if fname not in by_file: by_file[fname] = [] by_file[fname].append(result) - + # Display results sessions = [] session_paths = [] extractor = ClaudeConversationExtractor() all_sessions = extractor.find_sessions() - + for i, (fname, file_results) in enumerate(by_file.items(), 1): session_id = fname.replace('.jsonl', '') sessions.append((fname, session_id)) - + # Find the actual file path for session_path in all_sessions: if session_path.name == fname: session_paths.append(session_path) break - + print(f"{i}. Session {session_id[:8]}... ({len(file_results)} matches)") - + # Show first match preview first = file_results[0] preview = first.matched_content[:150].replace('\n', ' ') print(f" {first.speaker}: {preview}...") print() - + + # In non-interactive mode, stop here + if args.no_interactive: + return + # Offer to view or extract conversations if session_paths: print("\n" + "=" * 60) @@ -88,16 +125,16 @@ def main(): print(" V. VIEW a conversation") print(" E. EXTRACT all conversations") print(" Q. QUIT") - + try: choice = input("\nYour choice: ").strip().upper() - + if choice == 'V': # View conversation if len(session_paths) == 1: # Only one result, view it directly extractor.display_conversation(session_paths[0]) - + # After viewing, offer to extract extract_choice = input("\nšŸ“¤ Extract this conversation? (y/N): ").strip().lower() if extract_choice == 'y': @@ -110,12 +147,12 @@ def main(): print("\nSelect conversation to view:") for i, (fname, sid) in enumerate(sessions, 1): print(f" {i}. {sid[:8]}...") - + try: view_num = int(input("\nEnter number (1-{}): ".format(len(sessions)))) if 1 <= view_num <= len(session_paths): extractor.display_conversation(session_paths[view_num - 1]) - + # After viewing, offer to extract extract_choice = input("\nšŸ“¤ Extract this conversation? (y/N): ").strip().lower() if extract_choice == 'y': @@ -125,7 +162,7 @@ def main(): print(f"āœ… Saved: {output.name}") except (ValueError, IndexError): print("āŒ Invalid selection") - + elif choice == 'E': # Extract all found conversations for i, (session_path, (fname, sid)) in enumerate(zip(session_paths, sessions), 1): @@ -134,10 +171,10 @@ def main(): if conversation: output = extractor.save_as_markdown(conversation, sid) print(f"āœ… Saved: {output.name}") - + elif choice == 'Q': print("\nšŸ‘‹ Goodbye!") - + except (EOFError, KeyboardInterrupt): print("\nšŸ‘‹ Search cancelled") else: @@ -149,4 +186,4 @@ def main(): if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/tests/test_search_cli.py b/tests/test_search_cli.py new file mode 100644 index 0000000..c53bdd8 --- /dev/null +++ b/tests/test_search_cli.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +""" +Tests for search_cli.py non-interactive mode and argument parsing. + +Covers: +- --no-interactive / -n flag skips the V/E/Q prompt +- --help / -h prints usage and exits +- --max-results / -m controls result limit +- Existing interactive behavior is preserved when no flags given +""" + +import json +import sys +import tempfile +import unittest +from io import StringIO +from pathlib import Path +from unittest.mock import Mock, patch, MagicMock + +# Add parent directory to path before local imports +sys.path.append(str(Path(__file__).parent.parent)) + +from search_cli import main, parse_args # noqa: E402 + + +class TestParseArgs(unittest.TestCase): + """Test argument parsing.""" + + def test_search_term_positional(self): + args = parse_args(["Rules Foundation"]) + self.assertEqual(args.search_term, "Rules Foundation") + + def test_search_term_multiple_words(self): + args = parse_args(["Rules", "Foundation", "email"]) + self.assertEqual(args.search_term, "Rules Foundation email") + + def test_no_interactive_long_flag(self): + args = parse_args(["--no-interactive", "query"]) + self.assertTrue(args.no_interactive) + + def test_no_interactive_short_flag(self): + args = parse_args(["-n", "query"]) + self.assertTrue(args.no_interactive) + + def test_max_results_long_flag(self): + args = parse_args(["--max-results", "5", "query"]) + self.assertEqual(args.max_results, 5) + + def test_max_results_short_flag(self): + args = parse_args(["-m", "10", "query"]) + self.assertEqual(args.max_results, 10) + + def test_default_max_results(self): + args = parse_args(["query"]) + self.assertEqual(args.max_results, 20) + + def test_default_interactive(self): + args = parse_args(["query"]) + self.assertFalse(args.no_interactive) + + def test_no_args_returns_empty_search_term(self): + args = parse_args([]) + self.assertEqual(args.search_term, "") + + +class TestNonInteractiveMode(unittest.TestCase): + """Test that --no-interactive skips the V/E/Q prompt.""" + + @patch("search_cli.create_smart_searcher") + @patch("search_cli.ConversationSearcher") + @patch("search_cli.ClaudeConversationExtractor") + def test_no_interactive_skips_prompt( + self, mock_extractor_cls, mock_searcher_cls, mock_smart + ): + """With --no-interactive, main() should never call input().""" + mock_result = Mock() + mock_result.file_path = Path("/test/session.jsonl") + mock_result.matched_content = "test match content" + mock_result.speaker = "human" + + mock_smart_instance = Mock() + mock_smart_instance.search.return_value = [mock_result] + mock_smart.return_value = mock_smart_instance + + mock_extractor = Mock() + mock_extractor.find_sessions.return_value = [ + Path("/test/session.jsonl") + ] + mock_extractor_cls.return_value = mock_extractor + + with patch("sys.argv", ["claude-search", "-n", "test query"]): + with patch("sys.stdout", new_callable=StringIO) as mock_stdout: + with patch("builtins.input") as mock_input: + main() + mock_input.assert_not_called() + + @patch("search_cli.create_smart_searcher") + @patch("search_cli.ConversationSearcher") + @patch("search_cli.ClaudeConversationExtractor") + def test_no_interactive_prints_results( + self, mock_extractor_cls, mock_searcher_cls, mock_smart + ): + """With --no-interactive, results should still be printed.""" + mock_result = Mock() + mock_result.file_path = Path("/test/session.jsonl") + mock_result.matched_content = "Rules Foundation email" + mock_result.speaker = "human" + + mock_smart_instance = Mock() + mock_smart_instance.search.return_value = [mock_result] + mock_smart.return_value = mock_smart_instance + + mock_extractor = Mock() + mock_extractor.find_sessions.return_value = [ + Path("/test/session.jsonl") + ] + mock_extractor_cls.return_value = mock_extractor + + with patch("sys.argv", ["claude-search", "-n", "Rules Foundation"]): + with patch("sys.stdout", new_callable=StringIO) as mock_stdout: + main() + output = mock_stdout.getvalue() + self.assertIn("Found", output) + self.assertIn("session", output.lower()) + + @patch("search_cli.create_smart_searcher") + @patch("search_cli.ConversationSearcher") + def test_no_interactive_exits_cleanly_no_results( + self, mock_searcher_cls, mock_smart + ): + """With --no-interactive and no results, exits without prompting.""" + mock_smart_instance = Mock() + mock_smart_instance.search.return_value = [] + mock_smart.return_value = mock_smart_instance + + with patch("sys.argv", ["claude-search", "-n", "nonexistent query"]): + with patch("sys.stdout", new_callable=StringIO): + with patch("builtins.input") as mock_input: + main() + mock_input.assert_not_called() + + +class TestMaxResults(unittest.TestCase): + """Test --max-results flag.""" + + @patch("search_cli.create_smart_searcher") + @patch("search_cli.ConversationSearcher") + def test_max_results_passed_to_search( + self, mock_searcher_cls, mock_smart + ): + """--max-results should be forwarded to the searcher.""" + mock_smart_instance = Mock() + mock_smart_instance.search.return_value = [] + mock_smart.return_value = mock_smart_instance + + with patch("sys.argv", ["claude-search", "-n", "-m", "5", "query"]): + with patch("sys.stdout", new_callable=StringIO): + main() + mock_smart_instance.search.assert_called_once_with( + "query", max_results=5 + ) + + +class TestHelpFlag(unittest.TestCase): + """Test --help flag.""" + + def test_help_prints_usage_and_exits(self): + """--help should print usage info and exit.""" + with patch("sys.argv", ["claude-search", "--help"]): + with self.assertRaises(SystemExit) as ctx: + parse_args(["--help"]) + self.assertEqual(ctx.exception.code, 0) + + +class TestInteractiveModePreserved(unittest.TestCase): + """Ensure existing interactive behavior still works without flags.""" + + @patch("search_cli.create_smart_searcher") + @patch("search_cli.ConversationSearcher") + @patch("search_cli.ClaudeConversationExtractor") + def test_without_flag_prompts_user( + self, mock_extractor_cls, mock_searcher_cls, mock_smart + ): + """Without --no-interactive, the V/E/Q menu should appear.""" + mock_result = Mock() + mock_result.file_path = Path("/test/session.jsonl") + mock_result.matched_content = "test match" + mock_result.speaker = "human" + + mock_smart_instance = Mock() + mock_smart_instance.search.return_value = [mock_result] + mock_smart.return_value = mock_smart_instance + + mock_extractor = Mock() + mock_extractor.find_sessions.return_value = [ + Path("/test/session.jsonl") + ] + mock_extractor_cls.return_value = mock_extractor + + with patch("sys.argv", ["claude-search", "test query"]): + with patch("sys.stdout", new_callable=StringIO): + with patch("builtins.input", side_effect=EOFError): + # input() should be called (and we simulate EOF) + main() + # If we get here without error, the prompt was attempted + + +if __name__ == "__main__": + unittest.main()