diff --git a/README.md b/README.md index 403e4cf..e1944db 100644 --- a/README.md +++ b/README.md @@ -1013,6 +1013,7 @@ python3 cli/doc_scraper.py --config configs/godot.json - **[docs/LARGE_DOCUMENTATION.md](docs/LARGE_DOCUMENTATION.md)** - Handle 10K-40K+ page docs - **[ASYNC_SUPPORT.md](ASYNC_SUPPORT.md)** - Async mode guide (2-3x faster scraping) - **[docs/ENHANCEMENT.md](docs/ENHANCEMENT.md)** - AI enhancement guide +- **[docs/TERMINAL_SELECTION.md](docs/TERMINAL_SELECTION.md)** - Configure terminal app for local enhancement - **[docs/UPLOAD_GUIDE.md](docs/UPLOAD_GUIDE.md)** - How to upload skills to Claude - **[docs/MCP_SETUP.md](docs/MCP_SETUP.md)** - MCP integration setup diff --git a/cli/enhance_skill_local.py b/cli/enhance_skill_local.py index 8b4ab7e..49d7f77 100644 --- a/cli/enhance_skill_local.py +++ b/cli/enhance_skill_local.py @@ -7,6 +7,15 @@ No API key needed - uses your existing Claude Code Max plan! Usage: python3 cli/enhance_skill_local.py output/steam-inventory/ python3 cli/enhance_skill_local.py output/react/ + +Terminal Selection: + The script automatically detects which terminal app to use: + 1. SKILL_SEEKER_TERMINAL env var (highest priority) + Example: export SKILL_SEEKER_TERMINAL="Ghostty" + 2. TERM_PROGRAM env var (current terminal) + 3. Terminal.app (fallback) + + Supported terminals: Ghostty, iTerm, Terminal, WezTerm """ import os @@ -23,6 +32,55 @@ from cli.constants import LOCAL_CONTENT_LIMIT, LOCAL_PREVIEW_LIMIT from cli.utils import read_reference_files +def detect_terminal_app(): + """Detect which terminal app to use with cascading priority. + + Priority order: + 1. SKILL_SEEKER_TERMINAL environment variable (explicit user preference) + 2. TERM_PROGRAM environment variable (inherit current terminal) + 3. Terminal.app (fallback default) + + Returns: + tuple: (terminal_app_name, detection_method) + - terminal_app_name (str): Name of terminal app to launch (e.g., "Ghostty", "Terminal") + - detection_method (str): How the terminal was detected (for logging) + + Examples: + >>> os.environ['SKILL_SEEKER_TERMINAL'] = 'Ghostty' + >>> detect_terminal_app() + ('Ghostty', 'SKILL_SEEKER_TERMINAL') + + >>> os.environ['TERM_PROGRAM'] = 'iTerm.app' + >>> detect_terminal_app() + ('iTerm', 'TERM_PROGRAM') + """ + # Map TERM_PROGRAM values to macOS app names + TERMINAL_MAP = { + 'Apple_Terminal': 'Terminal', + 'iTerm.app': 'iTerm', + 'ghostty': 'Ghostty', + 'WezTerm': 'WezTerm', + } + + # Priority 1: Check SKILL_SEEKER_TERMINAL env var (explicit preference) + preferred_terminal = os.environ.get('SKILL_SEEKER_TERMINAL', '').strip() + if preferred_terminal: + return preferred_terminal, 'SKILL_SEEKER_TERMINAL' + + # Priority 2: Check TERM_PROGRAM (inherit current terminal) + term_program = os.environ.get('TERM_PROGRAM', '').strip() + if term_program and term_program in TERMINAL_MAP: + return TERMINAL_MAP[term_program], 'TERM_PROGRAM' + + # Priority 3: Fallback to Terminal.app + if term_program: + # TERM_PROGRAM is set but unknown + return 'Terminal', f'unknown TERM_PROGRAM ({term_program})' + else: + # No TERM_PROGRAM set + return 'Terminal', 'default' + + class LocalSkillEnhancer: def __init__(self, skill_dir): self.skill_dir = Path(skill_dir) @@ -177,11 +235,24 @@ rm {prompt_file} # Launch in new terminal (macOS specific) if sys.platform == 'darwin': - # macOS Terminal - simple approach + # Detect which terminal app to use + terminal_app, detection_method = detect_terminal_app() + + # Show detection info + if detection_method == 'SKILL_SEEKER_TERMINAL': + print(f" Using terminal: {terminal_app} (from SKILL_SEEKER_TERMINAL)") + elif detection_method == 'TERM_PROGRAM': + print(f" Using terminal: {terminal_app} (inherited from current terminal)") + elif detection_method.startswith('unknown TERM_PROGRAM'): + print(f"⚠️ {detection_method}") + print(f" → Using Terminal.app as fallback") + else: + print(f" Using terminal: {terminal_app} (default)") + try: - subprocess.Popen(['open', '-a', 'Terminal', script_file]) + subprocess.Popen(['open', '-a', terminal_app, script_file]) except Exception as e: - print(f"⚠️ Error launching terminal: {e}") + print(f"⚠️ Error launching {terminal_app}: {e}") print(f"\nManually run: {script_file}") return False else: diff --git a/docs/TERMINAL_SELECTION.md b/docs/TERMINAL_SELECTION.md new file mode 100644 index 0000000..dad3c4c --- /dev/null +++ b/docs/TERMINAL_SELECTION.md @@ -0,0 +1,94 @@ +# Terminal Selection Guide + +When using `--enhance-local`, Skill Seeker opens a new terminal window to run Claude Code. This guide explains how to control which terminal app is used. + +## Priority Order + +The script automatically detects which terminal to use in this order: + +1. **`SKILL_SEEKER_TERMINAL` environment variable** (highest priority) +2. **`TERM_PROGRAM` environment variable** (inherit current terminal) +3. **Terminal.app** (fallback default) + +## Setting Your Preferred Terminal + +### Option 1: Set Environment Variable (Recommended) + +Add this to your shell config (`~/.zshrc` or `~/.bashrc`): + +```bash +# For Ghostty users +export SKILL_SEEKER_TERMINAL="Ghostty" + +# For iTerm users +export SKILL_SEEKER_TERMINAL="iTerm" + +# For WezTerm users +export SKILL_SEEKER_TERMINAL="WezTerm" +``` + +Then reload your shell: +```bash +source ~/.zshrc # or source ~/.bashrc +``` + +### Option 2: Set Per-Session + +Set the variable before running the command: + +```bash +SKILL_SEEKER_TERMINAL="Ghostty" python3 cli/doc_scraper.py --config configs/react.json --enhance-local +``` + +### Option 3: Inherit Current Terminal (Automatic) + +If you run the script from Ghostty, iTerm2, or WezTerm, it will automatically open the enhancement in the same terminal app. + +**Note:** IDE terminals (VS Code, Zed, JetBrains) use unique `TERM_PROGRAM` values, so they fall back to Terminal.app unless you set `SKILL_SEEKER_TERMINAL`. + +## Supported Terminals + +- **Ghostty** (`ghostty`) +- **iTerm2** (`iTerm.app`) +- **Terminal.app** (`Apple_Terminal`) +- **WezTerm** (`WezTerm`) + +## Example Output + +When terminal detection works: +``` +🚀 Launching Claude Code in new terminal... + Using terminal: Ghostty (from SKILL_SEEKER_TERMINAL) +``` + +When running from an IDE terminal: +``` +🚀 Launching Claude Code in new terminal... +⚠️ unknown TERM_PROGRAM (zed) + → Using Terminal.app as fallback +``` + +**Tip:** Set `SKILL_SEEKER_TERMINAL` to avoid the fallback behavior. + +## Troubleshooting + +**Q: The wrong terminal opens even though I set `SKILL_SEEKER_TERMINAL`** + +A: Make sure you reloaded your shell after editing `~/.zshrc`: +```bash +source ~/.zshrc +``` + +**Q: I want to use a different terminal temporarily** + +A: Set the variable inline: +```bash +SKILL_SEEKER_TERMINAL="iTerm" python3 cli/doc_scraper.py --enhance-local ... +``` + +**Q: Can I use a custom terminal app?** + +A: Yes! Just use the app name as it appears in `/Applications/`: +```bash +export SKILL_SEEKER_TERMINAL="Alacritty" +``` diff --git a/tests/test_terminal_detection.py b/tests/test_terminal_detection.py new file mode 100644 index 0000000..a59545a --- /dev/null +++ b/tests/test_terminal_detection.py @@ -0,0 +1,330 @@ +""" +Tests for terminal detection functionality in enhance_skill_local.py + +This module tests the detect_terminal_app() function and terminal launching logic +to ensure correct terminal selection across different environments. +""" + +import unittest +import os +import sys +from unittest.mock import patch, MagicMock +from pathlib import Path + +# Add parent directory to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from cli.enhance_skill_local import detect_terminal_app, LocalSkillEnhancer + + +class TestDetectTerminalApp(unittest.TestCase): + """Test the detect_terminal_app() function.""" + + original_skill_seeker: str | None = None + original_term_program: str | None = None + + def setUp(self): + """Save original environment variables.""" + self.original_skill_seeker = os.environ.get('SKILL_SEEKER_TERMINAL') + self.original_term_program = os.environ.get('TERM_PROGRAM') + + def tearDown(self): + """Restore original environment variables.""" + # Remove test env vars + if 'SKILL_SEEKER_TERMINAL' in os.environ: + del os.environ['SKILL_SEEKER_TERMINAL'] + if 'TERM_PROGRAM' in os.environ: + del os.environ['TERM_PROGRAM'] + + # Restore originals if they existed + if self.original_skill_seeker is not None: + os.environ['SKILL_SEEKER_TERMINAL'] = self.original_skill_seeker + if self.original_term_program is not None: + os.environ['TERM_PROGRAM'] = self.original_term_program + + # HIGH PRIORITY TESTS + + def test_detect_terminal_with_skill_seeker_env(self): + """Test that SKILL_SEEKER_TERMINAL env var takes highest priority.""" + os.environ['SKILL_SEEKER_TERMINAL'] = 'Ghostty' + + terminal_app, detection_method = detect_terminal_app() + + self.assertEqual(terminal_app, 'Ghostty') + self.assertEqual(detection_method, 'SKILL_SEEKER_TERMINAL') + + def test_detect_terminal_with_term_program_known(self): + """Test detection from TERM_PROGRAM with known terminal (iTerm).""" + # Ensure SKILL_SEEKER_TERMINAL is not set + if 'SKILL_SEEKER_TERMINAL' in os.environ: + del os.environ['SKILL_SEEKER_TERMINAL'] + + os.environ['TERM_PROGRAM'] = 'iTerm.app' + + terminal_app, detection_method = detect_terminal_app() + + self.assertEqual(terminal_app, 'iTerm') + self.assertEqual(detection_method, 'TERM_PROGRAM') + + def test_detect_terminal_with_term_program_ghostty(self): + """Test detection from TERM_PROGRAM with Ghostty terminal.""" + if 'SKILL_SEEKER_TERMINAL' in os.environ: + del os.environ['SKILL_SEEKER_TERMINAL'] + + os.environ['TERM_PROGRAM'] = 'ghostty' + + terminal_app, detection_method = detect_terminal_app() + + self.assertEqual(terminal_app, 'Ghostty') + self.assertEqual(detection_method, 'TERM_PROGRAM') + + def test_detect_terminal_with_term_program_apple_terminal(self): + """Test detection from TERM_PROGRAM with Apple Terminal.""" + if 'SKILL_SEEKER_TERMINAL' in os.environ: + del os.environ['SKILL_SEEKER_TERMINAL'] + + os.environ['TERM_PROGRAM'] = 'Apple_Terminal' + + terminal_app, detection_method = detect_terminal_app() + + self.assertEqual(terminal_app, 'Terminal') + self.assertEqual(detection_method, 'TERM_PROGRAM') + + def test_detect_terminal_with_term_program_wezterm(self): + """Test detection from TERM_PROGRAM with WezTerm.""" + if 'SKILL_SEEKER_TERMINAL' in os.environ: + del os.environ['SKILL_SEEKER_TERMINAL'] + + os.environ['TERM_PROGRAM'] = 'WezTerm' + + terminal_app, detection_method = detect_terminal_app() + + self.assertEqual(terminal_app, 'WezTerm') + self.assertEqual(detection_method, 'TERM_PROGRAM') + + def test_detect_terminal_with_term_program_unknown(self): + """Test fallback behavior when TERM_PROGRAM is unknown (e.g., IDE terminals).""" + if 'SKILL_SEEKER_TERMINAL' in os.environ: + del os.environ['SKILL_SEEKER_TERMINAL'] + + os.environ['TERM_PROGRAM'] = 'zed' + + terminal_app, detection_method = detect_terminal_app() + + self.assertEqual(terminal_app, 'Terminal') + self.assertEqual(detection_method, 'unknown TERM_PROGRAM (zed)') + + def test_detect_terminal_default_fallback(self): + """Test default fallback when no environment variables are set.""" + # Remove both env vars + if 'SKILL_SEEKER_TERMINAL' in os.environ: + del os.environ['SKILL_SEEKER_TERMINAL'] + if 'TERM_PROGRAM' in os.environ: + del os.environ['TERM_PROGRAM'] + + terminal_app, detection_method = detect_terminal_app() + + self.assertEqual(terminal_app, 'Terminal') + self.assertEqual(detection_method, 'default') + + def test_detect_terminal_priority_order(self): + """Test that SKILL_SEEKER_TERMINAL takes priority over TERM_PROGRAM.""" + os.environ['SKILL_SEEKER_TERMINAL'] = 'Ghostty' + os.environ['TERM_PROGRAM'] = 'iTerm.app' + + terminal_app, detection_method = detect_terminal_app() + + # SKILL_SEEKER_TERMINAL should win + self.assertEqual(terminal_app, 'Ghostty') + self.assertEqual(detection_method, 'SKILL_SEEKER_TERMINAL') + + @patch('subprocess.Popen') + def test_subprocess_popen_called_with_correct_args(self, mock_popen): + """Test that subprocess.Popen is called with correct arguments on macOS.""" + # Only test on macOS + if sys.platform != 'darwin': + self.skipTest("This test only runs on macOS") + + # Setup + os.environ['SKILL_SEEKER_TERMINAL'] = 'Ghostty' + + # Create a test skill directory with minimal setup + import tempfile + with tempfile.TemporaryDirectory() as tmpdir: + skill_dir = Path(tmpdir) / 'test_skill' + skill_dir.mkdir() + + # Create references directory (required by LocalSkillEnhancer) + (skill_dir / 'references').mkdir() + (skill_dir / 'references' / 'test.md').write_text('# Test') + + # Create SKILL.md (required) + (skill_dir / 'SKILL.md').write_text('---\nname: test\n---\n# Test') + + # Mock Popen to prevent actual terminal launch + mock_popen.return_value = MagicMock() + + # Run enhancer + enhancer = LocalSkillEnhancer(skill_dir) + result = enhancer.run() + + # Verify Popen was called + self.assertTrue(mock_popen.called) + + # Verify call arguments + call_args = mock_popen.call_args[0][0] + self.assertEqual(call_args[0], 'open') + self.assertEqual(call_args[1], '-a') + self.assertEqual(call_args[2], 'Ghostty') + # call_args[3] should be the script file path + self.assertTrue(call_args[3].endswith('.sh')) + + # MEDIUM PRIORITY TESTS + + def test_detect_terminal_whitespace_handling(self): + """Test that whitespace is stripped from environment variables.""" + os.environ['SKILL_SEEKER_TERMINAL'] = ' Ghostty ' + + terminal_app, detection_method = detect_terminal_app() + + self.assertEqual(terminal_app, 'Ghostty') + self.assertEqual(detection_method, 'SKILL_SEEKER_TERMINAL') + + def test_detect_terminal_empty_string_env_vars(self): + """Test that empty string env vars fall through to next priority.""" + os.environ['SKILL_SEEKER_TERMINAL'] = '' + os.environ['TERM_PROGRAM'] = 'iTerm.app' + + terminal_app, detection_method = detect_terminal_app() + + # Should skip empty SKILL_SEEKER_TERMINAL and use TERM_PROGRAM + self.assertEqual(terminal_app, 'iTerm') + self.assertEqual(detection_method, 'TERM_PROGRAM') + + def test_detect_terminal_empty_string_both_vars(self): + """Test that empty strings on both vars falls back to default.""" + os.environ['SKILL_SEEKER_TERMINAL'] = '' + os.environ['TERM_PROGRAM'] = '' + + terminal_app, detection_method = detect_terminal_app() + + # Should fall back to default + self.assertEqual(terminal_app, 'Terminal') + # Empty TERM_PROGRAM should be treated as not set + self.assertEqual(detection_method, 'default') + + @patch('subprocess.Popen') + def test_terminal_launch_error_handling(self, mock_popen): + """Test error handling when terminal launch fails.""" + # Only test on macOS + if sys.platform != 'darwin': + self.skipTest("This test only runs on macOS") + + # Setup Popen to raise exception + mock_popen.side_effect = Exception("Terminal not found") + + import tempfile + with tempfile.TemporaryDirectory() as tmpdir: + skill_dir = Path(tmpdir) / 'test_skill' + skill_dir.mkdir() + (skill_dir / 'references').mkdir() + (skill_dir / 'references' / 'test.md').write_text('# Test') + (skill_dir / 'SKILL.md').write_text('---\nname: test\n---\n# Test') + + enhancer = LocalSkillEnhancer(skill_dir) + + # Capture stdout to check error message + from io import StringIO + captured_output = StringIO() + old_stdout = sys.stdout + sys.stdout = captured_output + + result = enhancer.run() + + # Restore stdout + sys.stdout = old_stdout + + # Should return False on error + self.assertFalse(result) + + # Should print error message + output = captured_output.getvalue() + self.assertIn('Error launching', output) + + def test_output_message_unknown_terminal(self): + """Test that unknown terminal prints warning message.""" + if sys.platform != 'darwin': + self.skipTest("This test only runs on macOS") + + os.environ['TERM_PROGRAM'] = 'vscode' + if 'SKILL_SEEKER_TERMINAL' in os.environ: + del os.environ['SKILL_SEEKER_TERMINAL'] + + import tempfile + with tempfile.TemporaryDirectory() as tmpdir: + skill_dir = Path(tmpdir) / 'test_skill' + skill_dir.mkdir() + (skill_dir / 'references').mkdir() + (skill_dir / 'references' / 'test.md').write_text('# Test') + (skill_dir / 'SKILL.md').write_text('---\nname: test\n---\n# Test') + + enhancer = LocalSkillEnhancer(skill_dir) + + # Capture stdout + from io import StringIO + captured_output = StringIO() + old_stdout = sys.stdout + sys.stdout = captured_output + + # Mock Popen to prevent actual launch + with patch('subprocess.Popen') as mock_popen: + mock_popen.return_value = MagicMock() + enhancer.run() + + # Restore stdout + sys.stdout = old_stdout + + output = captured_output.getvalue() + + # Should contain warning about unknown terminal + self.assertIn('⚠️', output) + self.assertIn('unknown TERM_PROGRAM', output) + self.assertIn('vscode', output) + self.assertIn('Using Terminal.app as fallback', output) + + +class TestTerminalMapCompleteness(unittest.TestCase): + """Test that TERMINAL_MAP covers all documented terminals.""" + + def test_terminal_map_has_all_documented_terminals(self): + """Verify TERMINAL_MAP contains all terminals mentioned in documentation.""" + from cli.enhance_skill_local import detect_terminal_app + + # Get the TERMINAL_MAP from the function's scope + # We need to test this indirectly by checking each known terminal + + known_terminals = [ + ('Apple_Terminal', 'Terminal'), + ('iTerm.app', 'iTerm'), + ('ghostty', 'Ghostty'), + ('WezTerm', 'WezTerm'), + ] + + for term_program_value, expected_app_name in known_terminals: + # Set TERM_PROGRAM and verify detection + os.environ['TERM_PROGRAM'] = term_program_value + if 'SKILL_SEEKER_TERMINAL' in os.environ: + del os.environ['SKILL_SEEKER_TERMINAL'] + + terminal_app, detection_method = detect_terminal_app() + + self.assertEqual( + terminal_app, + expected_app_name, + f"TERM_PROGRAM='{term_program_value}' should map to '{expected_app_name}'" + ) + self.assertEqual(detection_method, 'TERM_PROGRAM') + + +if __name__ == '__main__': + unittest.main()