diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4c3060d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# v1.0.0 +2025-10-21 +- add the shell history feature +- add the logging feature +- refactor the codebase for better maintainability \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 21f4a1b..0eaddd6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,96 +4,176 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -AutoTerminal is a smart terminal tool based on large language models (LLM) that converts natural language into terminal commands to improve work efficiency. +AutoTerminal is an LLM-powered terminal assistant that converts natural language into shell commands. It's a Python CLI tool that uses OpenAI-compatible APIs to generate and execute terminal commands based on user input, with context awareness from command history and current directory contents. -## Code Architecture +## Development Commands +### Package Management +```bash +# Install dependencies (development mode) +uv sync + +# Install package locally for testing +pip install --user -e . + +# Uninstall +pip uninstall autoterminal ``` -autoterminal/ -├── __init__.py # Package initialization -├── main.py # Main program entry point -├── config/ # Configuration management module -│ ├── __init__.py # Package initialization -│ ├── loader.py # Configuration loader -│ └── manager.py # Configuration manager -├── llm/ # LLM related modules -│ ├── __init__.py # Package initialization -│ └── client.py # LLM client -├── history/ # Command history management module -│ ├── __init__.py # Package initialization -│ └── history.py # History manager -├── utils/ # Utility functions -│ ├── __init__.py # Package initialization -│ └── helpers.py # Helper functions + +### Running the Tool +```bash +# Using uv run (development) +uv run python autoterminal/main.py "your command request" + +# After installation +at "your command request" + +# With history context +at --history-count 5 "command based on previous context" + +# Command recommendation mode (no input) +at ``` +### Building and Distribution +```bash +# Build distribution packages +python -m build + +# Upload to PyPI (requires twine) +twine upload dist/* +``` + +## Architecture + +### Core Flow +1. **User Input** → CLI argument parsing (`main.py`) +2. **Configuration Loading** → ConfigLoader/ConfigManager retrieve user settings from `~/.autoterminal/config.json` +3. **Context Gathering** → HistoryManager fetches recent commands + glob current directory contents +4. **LLM Generation** → LLMClient sends prompt with context to OpenAI-compatible API +5. **Command Execution** → User confirms, then command executes via `os.system()` +6. **History Persistence** → Executed command saved to `~/.autoterminal/history.json` + ### Key Components -1. **Main Entry Point** (`autoterminal/main.py`): - - Parses command-line arguments - - Loads configuration - - Initializes LLM client - - Generates and executes commands - - Manages command history +**autoterminal/main.py** +- Entry point for `at` command +- Argument parsing and orchestration +- Two modes: command generation (with user input) and recommendation mode (without input) +- Uses `glob.glob("*")` to gather current directory context -2. **Configuration Management** (`autoterminal/config/`): - - `loader.py`: Loads configuration from file - - `manager.py`: Manages configuration saving, validation, and initialization +**autoterminal/llm/client.py** +- `LLMClient` class wraps OpenAI API +- `generate_command()` constructs prompts with history and directory context +- Two prompt modes: default (user command) and recommendation (auto-suggest) +- System prompt includes recent command history and current directory files -3. **LLM Integration** (`autoterminal/llm/client.py`): - - Wraps OpenAI API client - - Generates terminal commands from natural language input - - Incorporates context from command history and current directory +**autoterminal/config/** +- `ConfigLoader`: Reads from `~/.autoterminal/config.json` +- `ConfigManager`: Interactive setup wizard, validation, and persistence +- Required fields: `api_key`, `base_url`, `model` +- Optional: `max_history`, `default_prompt`, `recommendation_prompt` -4. **Command History** (`autoterminal/history/history.py`): - - Manages command history storage and retrieval - - Provides context for LLM generation +**autoterminal/history/** +- `HistoryManager`: Persists commands to `~/.autoterminal/history.json` +- Stores: `timestamp`, `user_input`, `generated_command`, `executed` flag +- `get_last_executed_command()` prevents duplicate recommendations -5. **Utilities** (`autoterminal/utils/helpers.py`): - - Provides helper functions like command cleaning +**autoterminal/utils/helpers.py** +- `clean_command()`: Strips quotes and whitespace from LLM output +- `get_shell_history(count)`: Reads shell history from `~/.bash_history` or `~/.zsh_history` + - Filters sensitive commands (password, key, token, etc.) + - Handles zsh extended history format + - Deduplicates commands (keeps last occurrence) + - Returns up to N most recent commands -## Common Development Commands +**autoterminal/utils/logger.py** +- Configures `loguru` for structured logging +- Console output to stderr (colored, configurable via `AUTOTERMINAL_LOG_LEVEL` env var) +- File output to `~/.autoterminal/autoterminal.log` (rotation: 10MB, retention: 7 days) +- Logging levels: ERROR for console (default, production mode), DEBUG for file +- Enable verbose console logging: `AUTOTERMINAL_LOG_LEVEL=INFO` or `DEBUG` +- Disable file logging: `AUTOTERMINAL_FILE_LOG=false` -### Installation +### Configuration Storage +All user data lives in `~/.autoterminal/`: +- `config.json`: API credentials and settings +- `history.json`: Command execution history +- `autoterminal.log`: Application logs (rotated at 10MB, compressed) -Using uv (development mode): -```bash -uv sync -``` +### Installation Mechanism +`pyproject.toml` defines: +- Entry point: `at = "autoterminal.main:main"` +- Dependencies: `openai>=1.0.0`, `loguru>=0.7.0` +- Python requirement: `>=3.10` +- Build system: setuptools -Using pip (user installation): -```bash -pip install --user . -``` +## Important Implementation Details -### Running the Application +### Context-Aware Command Generation +The LLM receives four critical context inputs: +1. **Command History**: Last N commands from AutoTerminal (user input + generated command pairs) +2. **Directory Contents**: Output of `glob.glob("*")` in current working directory +3. **Shell History**: Last 20 commands from user's shell history (bash/zsh) via `get_shell_history()` +4. **Last Executed Command**: Used in recommendation mode to avoid repeats -Using uv run: -```bash -uv run python autoterminal/main.py "list all files in current directory" -``` +### Two Operating Modes +1. **Command Generation Mode** (`at "do something"`): + - Uses `default_prompt` from config + - Generates command from user's natural language request -After installation, use the `at` command: -```bash -at "list all files in current directory" -``` +2. **Recommendation Mode** (`at` with no args): + - Uses `recommendation_prompt` from config + - Analyzes context to suggest next likely command + - Returns empty string if context insufficient + - Special logic to avoid recommending `echo` commands for listing files -Using history context: -```bash -at --history-count 5 "based on previous commands, delete all .txt files" -``` +### Safety Mechanism +Commands are always displayed before execution with "Press Enter to execute..." prompt. User must explicitly confirm (Enter key) before execution via `os.system()`. -### Development +### Command Cleaning +LLM output is processed through `clean_command()` to remove: +- Leading/trailing quotes (single or double) +- Excess whitespace +- Prevents common LLM wrapping artifacts -The project uses setuptools for packaging and distribution. Entry point is defined in both `pyproject.toml` and `setup.py`. +## Development Notes -## Key Features +### When modifying prompts: +- System prompts are in `config/manager.py` defaults and configurable via `config.json` +- Recommendation prompt explicitly instructs against `echo` for file listing +- Context is appended to system prompt, not injected into user message -- LLM-based intelligent command generation -- Secure command execution mechanism (requires user confirmation) -- Flexible configuration management -- Chinese language support -- Support for multiple LLM models (OpenAI GPT series and compatible APIs) -- Command history tracking and context awareness -- Current directory content context awareness -- Configurable history size \ No newline at end of file +### When working with history: +- History is capped at `max_history` entries (default: 10) +- Stored in reverse chronological order +- `get_recent_history()` returns oldest-to-newest slice for context + +### When extending LLM support: +- Client uses `openai` package with custom `base_url` +- Compatible with any OpenAI API-compatible service +- Temperature fixed at 0.1, max_tokens at 100 for deterministic short outputs + +### Configuration initialization: +- First run triggers interactive setup wizard +- Config validation checks for all required keys +- Command-line args (`--api-key`, `--base-url`, `--model`) override config file + +### Logging and Debugging: +- All modules use centralized `loguru` logger from `autoterminal.utils.logger` +- **Production mode**: Console only shows ERROR messages (default) +- **Debug mode**: `AUTOTERMINAL_LOG_LEVEL=DEBUG at "command"` shows all logs +- File logs: Always DEBUG level at `~/.autoterminal/autoterminal.log` (unless disabled) +- View logs: `tail -f ~/.autoterminal/autoterminal.log` +- Key events logged: config loading, LLM calls, command execution, history updates, shell history reading + +### Shell History Integration: +- `get_shell_history()` automatically detects bash/zsh history files +- Detection strategy: + 1. Tries `$HISTFILE` environment variable + 2. Detects `$SHELL` to determine shell type (zsh/bash) + 3. Prioritizes history files based on detected shell + 4. Falls back to common locations (`~/.bash_history`, `~/.zsh_history`, etc.) +- Sensitive keyword filtering prevents leaking credentials +- Shell history provides additional context beyond AutoTerminal's own history +- Failure to read shell history is non-fatal (returns empty list with warning) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..59ee69d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Adam Veldhousen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. \ No newline at end of file diff --git a/autoterminal/__init__.py b/autoterminal/__init__.py index 232c36d..a831d64 100644 --- a/autoterminal/__init__.py +++ b/autoterminal/__init__.py @@ -2,4 +2,4 @@ from . import config, llm, utils from .history import HistoryManager from .main import main -__all__ = ['config', 'llm', 'utils', 'HistoryManager', 'main'] \ No newline at end of file +__all__ = ['config', 'llm', 'utils', 'HistoryManager', 'main'] diff --git a/autoterminal/config/loader.py b/autoterminal/config/loader.py index 4fbfa35..99bab01 100644 --- a/autoterminal/config/loader.py +++ b/autoterminal/config/loader.py @@ -1,10 +1,12 @@ import os import json from typing import Dict, Optional, Any +from autoterminal.utils.logger import logger + class ConfigLoader: """配置加载器,支持从文件加载配置""" - + def __init__(self, config_file: str = None): if config_file is None: # 从用户主目录下的.autoterminal目录中加载配置文件 @@ -13,17 +15,22 @@ class ConfigLoader: self.config_file = os.path.join(config_dir, "config.json") else: self.config_file = config_file - + def load_from_file(self) -> Dict: """从配置文件加载配置""" if os.path.exists(self.config_file): try: + logger.debug(f"从文件加载配置: {self.config_file}") with open(self.config_file, 'r', encoding='utf-8') as f: - return json.load(f) + config = json.load(f) + logger.info("配置文件加载成功") + return config except Exception as e: - print(f"警告: 无法读取配置文件 {self.config_file}: {e}") + logger.error(f"无法读取配置文件 {self.config_file}: {e}") + else: + logger.debug(f"配置文件不存在: {self.config_file}") return {} - + def get_config(self) -> Dict: """获取配置""" return self.load_from_file() diff --git a/autoterminal/config/manager.py b/autoterminal/config/manager.py index 54f191f..3577f57 100644 --- a/autoterminal/config/manager.py +++ b/autoterminal/config/manager.py @@ -1,10 +1,12 @@ import os import json from typing import Dict, Any +from autoterminal.utils.logger import logger + class ConfigManager: """配置管理器,支持配置的保存和验证""" - + def __init__(self, config_file: str = None): if config_file is None: # 将配置文件存储在用户主目录下的.autoterminal目录中 @@ -15,7 +17,7 @@ class ConfigManager: self.config_file = os.path.join(config_dir, "config.json") else: self.config_file = config_file - + self.required_keys = ['api_key', 'base_url', 'model'] self.default_config = { 'base_url': 'https://api.openai.com/v1', @@ -23,34 +25,40 @@ class ConfigManager: 'default_prompt': '你现在是一个终端助手,用户输入想要生成的命令,你来输出一个命令,不要任何多余的文本!', 'max_history': 10 } - + def save_config(self, config: Dict[str, Any]) -> bool: """保存配置到文件""" try: # 确保目录存在 - os.makedirs(os.path.dirname(self.config_file) if os.path.dirname(self.config_file) else '.', exist_ok=True) - + os.makedirs( + os.path.dirname( + self.config_file) if os.path.dirname( + self.config_file) else '.', + exist_ok=True) + + logger.debug(f"保存配置到文件: {self.config_file}") with open(self.config_file, 'w', encoding='utf-8') as f: json.dump(config, f, indent=2, ensure_ascii=False) + logger.info("配置文件保存成功") return True except Exception as e: - print(f"错误: 无法保存配置文件 {self.config_file}: {e}") + logger.error(f"无法保存配置文件 {self.config_file}: {e}") return False - + def validate_config(self, config: Dict[str, Any]) -> bool: """验证配置是否完整""" for key in self.required_keys: if not config.get(key): return False return True - + def initialize_config(self) -> Dict[str, Any]: """初始化配置向导""" print("欢迎使用AutoTerminal配置向导!") print("请提供以下信息以完成配置:") - + config = self.default_config.copy() - + # 获取API密钥 try: api_key = input("请输入您的API密钥: ").strip() @@ -64,10 +72,11 @@ class ConfigManager: except Exception as e: print(f"错误: 无法读取API密钥输入: {e}") return {} - + # 获取Base URL try: - base_url = input(f"请输入Base URL (默认: {self.default_config['base_url']}): ").strip() + base_url = input( + f"请输入Base URL (默认: {self.default_config['base_url']}): ").strip() if base_url: config['base_url'] = base_url except EOFError: @@ -75,7 +84,7 @@ class ConfigManager: return {} except Exception as e: print(f"警告: 无法读取Base URL输入: {e}") - + # 获取模型名称 try: model = input(f"请输入模型名称 (默认: {self.default_config['model']}): ").strip() @@ -86,7 +95,7 @@ class ConfigManager: return {} except Exception as e: print(f"警告: 无法读取模型名称输入: {e}") - + # 保存配置 if self.save_config(config): print(f"配置已保存到 {self.config_file}") @@ -94,7 +103,7 @@ class ConfigManager: else: print("配置保存失败") return {} - + def get_or_create_config(self) -> Dict[str, Any]: """获取现有配置或创建新配置""" # 尝试从文件加载配置 @@ -109,7 +118,7 @@ class ConfigManager: else: print("现有配置不完整") except Exception as e: - print(f"警告: 无法读取配置文件 {self.config_file}: {e}") - + logger.warning(f"无法读取配置文件 {self.config_file}: {e}") + # 如果配置不存在或不完整,启动初始化向导 return self.initialize_config() diff --git a/autoterminal/history.py b/autoterminal/history.py deleted file mode 100644 index ce5854a..0000000 --- a/autoterminal/history.py +++ /dev/null @@ -1,73 +0,0 @@ -import os -import json -from typing import List, Dict, Any -from datetime import datetime - -class HistoryManager: - """历史命令管理器,用于记录和检索命令历史""" - - def __init__(self, history_file: str = None, max_history: int = 10): - if history_file is None: - # 将历史文件存储在用户主目录下的.autoterminal目录中 - home_dir = os.path.expanduser("~") - config_dir = os.path.join(home_dir, ".autoterminal") - self.history_file = os.path.join(config_dir, "history.json") - else: - self.history_file = history_file - - self.max_history = max_history - self.history = self.load_history() - - def load_history(self) -> List[Dict[str, Any]]: - """从历史文件加载命令历史""" - if os.path.exists(self.history_file): - try: - with open(self.history_file, 'r', encoding='utf-8') as f: - return json.load(f) - except Exception as e: - print(f"警告: 无法读取历史文件 {self.history_file}: {e}") - return [] - - def save_history(self) -> bool: - """保存命令历史到文件""" - try: - # 确保目录存在 - os.makedirs(os.path.dirname(self.history_file) if os.path.dirname(self.history_file) else '.', exist_ok=True) - - with open(self.history_file, 'w', encoding='utf-8') as f: - json.dump(self.history, f, indent=2, ensure_ascii=False) - return True - except Exception as e: - print(f"错误: 无法保存历史文件 {self.history_file}: {e}") - return False - - def add_command(self, user_input: str, generated_command: str, executed: bool = True) -> None: - """添加命令到历史记录""" - entry = { - "timestamp": datetime.now().isoformat(), - "user_input": user_input, - "generated_command": generated_command, - "executed": executed - } - - self.history.append(entry) - - # 保持历史记录在最大数量限制内 - if len(self.history) > self.max_history: - self.history = self.history[-self.max_history:] - - # 保存到文件 - self.save_history() - - def get_recent_history(self, count: int = None) -> List[Dict[str, Any]]: - """获取最近的命令历史""" - if count is None: - count = self.max_history - - return self.history[-count:] if self.history else [] - - def get_last_command(self) -> Dict[str, Any]: - """获取最后一条命令""" - if self.history: - return self.history[-1] - return {} \ No newline at end of file diff --git a/autoterminal/history/__init__.py b/autoterminal/history/__init__.py index 759fb41..813a2ae 100644 --- a/autoterminal/history/__init__.py +++ b/autoterminal/history/__init__.py @@ -1,4 +1,4 @@ # History module initialization from .history import HistoryManager -__all__ = ['HistoryManager'] \ No newline at end of file +__all__ = ['HistoryManager'] diff --git a/autoterminal/history/history.py b/autoterminal/history/history.py index 95cd118..a9311df 100644 --- a/autoterminal/history/history.py +++ b/autoterminal/history/history.py @@ -2,10 +2,12 @@ import os import json from typing import List, Dict, Any from datetime import datetime +from autoterminal.utils.logger import logger + class HistoryManager: """历史命令管理器,用于记录和检索命令历史""" - + def __init__(self, history_file: str = None, max_history: int = 10): if history_file is None: # 将历史文件存储在用户主目录下的.autoterminal目录中 @@ -14,67 +16,84 @@ class HistoryManager: self.history_file = os.path.join(config_dir, "history.json") else: self.history_file = history_file - + self.max_history = max_history self.history = self.load_history() - + def load_history(self) -> List[Dict[str, Any]]: """从历史文件加载命令历史""" if os.path.exists(self.history_file): try: + logger.debug(f"从文件加载历史: {self.history_file}") with open(self.history_file, 'r', encoding='utf-8') as f: - return json.load(f) + history = json.load(f) + logger.info(f"加载了 {len(history)} 条历史记录") + return history except Exception as e: - print(f"警告: 无法读取历史文件 {self.history_file}: {e}") + logger.error(f"无法读取历史文件 {self.history_file}: {e}") + else: + logger.debug(f"历史文件不存在: {self.history_file}") return [] - + def save_history(self) -> bool: """保存命令历史到文件""" try: # 确保目录存在 - os.makedirs(os.path.dirname(self.history_file) if os.path.dirname(self.history_file) else '.', exist_ok=True) - + os.makedirs( + os.path.dirname( + self.history_file) if os.path.dirname( + self.history_file) else '.', + exist_ok=True) + + logger.debug(f"保存历史到文件: {self.history_file}") with open(self.history_file, 'w', encoding='utf-8') as f: json.dump(self.history, f, indent=2, ensure_ascii=False) + logger.debug("历史文件保存成功") return True except Exception as e: - print(f"错误: 无法保存历史文件 {self.history_file}: {e}") + logger.error(f"无法保存历史文件 {self.history_file}: {e}") return False - - def add_command(self, user_input: str, generated_command: str, executed: bool = True) -> None: + + def add_command( + self, + user_input: str, + generated_command: str, + executed: bool = True) -> None: """添加命令到历史记录""" + logger.debug(f"添加命令到历史: {generated_command}") entry = { "timestamp": datetime.now().isoformat(), "user_input": user_input, "generated_command": generated_command, "executed": executed } - + self.history.append(entry) - + # 保持历史记录在最大数量限制内 if len(self.history) > self.max_history: self.history = self.history[-self.max_history:] - + logger.debug(f"历史记录已截断到 {self.max_history} 条") + # 保存到文件 self.save_history() - + def get_last_executed_command(self) -> str: """获取最后一条已执行的命令""" for entry in reversed(self.history): if entry.get("executed", False): return entry.get("generated_command", "") return "" - + def get_recent_history(self, count: int = None) -> List[Dict[str, Any]]: """获取最近的命令历史""" if count is None: count = self.max_history - + return self.history[-count:] if self.history else [] - + def get_last_command(self) -> Dict[str, Any]: """获取最后一条命令""" if self.history: return self.history[-1] - return {} \ No newline at end of file + return {} diff --git a/autoterminal/llm/client.py b/autoterminal/llm/client.py index 22353fa..e404c16 100644 --- a/autoterminal/llm/client.py +++ b/autoterminal/llm/client.py @@ -1,65 +1,86 @@ from openai import OpenAI from typing import Dict, Any, Optional, List import os +from autoterminal.utils.logger import logger + class LLMClient: """LLM客户端,封装OpenAI API调用""" - + def __init__(self, config: Dict[str, Any]): self.config = config + logger.info("初始化 LLM 客户端") + logger.debug(f"使用模型: {config.get('model')}, Base URL: {config.get('base_url')}") self.client = OpenAI( api_key=config.get('api_key'), base_url=config.get('base_url') ) - - def generate_command(self, user_input: str, prompt: Optional[str] = None, - history: Optional[List[Dict[str, Any]]] = None, - current_dir_content: Optional[List[str]] = None, - last_executed_command: str = "") -> str: + + def generate_command(self, user_input: str, prompt: Optional[str] = None, + history: Optional[List[Dict[str, Any]]] = None, + current_dir_content: Optional[List[str]] = None, + shell_history: Optional[List[str]] = None, + last_executed_command: str = "") -> str: """根据用户输入生成命令""" # 根据用户输入是否为空选择不同的提示词 if not user_input: if not prompt: - prompt = self.config.get('recommendation_prompt', '你现在是一个终端助手,根据上下文自动推荐命令:当用户没有输入时,基于最近执行的命令历史和当前目录内容,智能推荐最可能需要的终端命令(仅当有明确上下文线索时);当用户输入命令需求时,生成对应命令。仅输出纯命令文本,不要任何解释或多余内容!') + prompt = self.config.get( + 'recommendation_prompt', + '你现在是一个终端助手,根据上下文自动推荐命令:当用户没有输入时,基于最近执行的命令历史和当前目录内容,智能推荐最可能需要的终端命令(仅当有明确上下文线索时);当用户输入命令需求时,生成对应命令。仅输出纯命令文本,不要任何解释或多余内容!') else: if not prompt: - prompt = self.config.get('default_prompt', '你现在是一个终端助手,用户输入想要生成的命令,你来输出一个命令,不要任何多余的文本!') - + prompt = self.config.get( + 'default_prompt', + '你现在是一个终端助手,用户输入想要生成的命令,你来输出一个命令,不要任何多余的文本!') + # 构建系统提示,包含上下文信息 system_prompt = prompt - + # 添加历史命令上下文 if history: history_context = "\n最近执行的命令历史:\n" for i, entry in enumerate(reversed(history), 1): history_context += f"{i}. 用户输入: {entry.get('user_input', '')} -> 生成命令: {entry.get('generated_command', '')}\n" system_prompt += history_context - + # 添加当前目录内容上下文 if current_dir_content: dir_context = "\n当前目录下的文件和文件夹:\n" + "\n".join(current_dir_content) system_prompt += dir_context - + + # 添加系统 Shell 历史上下文 + if shell_history: + shell_context = "\n系统Shell最近执行的命令:\n" + for i, cmd in enumerate(shell_history, 1): + shell_context += f"{i}. {cmd}\n" + system_prompt += shell_context + # 当用户输入为空时,使用特殊的提示来触发推荐模式 if not user_input: user_content = f"根据提供的上下文信息,推荐一个最可能需要的终端命令(仅当有明确的上下文线索时)。如果上下文信息不足以确定一个有用的命令,则返回空。请直接返回一个可执行的终端命令,不要包含任何解释或其他文本。例如:ls -la 或 git status。特别注意:不要使用echo命令来列出文件,应该使用ls命令。推荐命令时请考虑最近执行的命令历史,避免重复推荐相同的命令。最后执行的命令是: {last_executed_command}。如果当前目录有pyproject.toml或setup.py文件,可以考虑使用pip list查看已安装的包。" else: user_content = user_input - + messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_content} ] - + try: + logger.info(f"调用 LLM 生成命令,用户输入: '{user_input if user_input else '(推荐模式)'}'") + logger.debug(f"系统提示长度: {len(system_prompt)} 字符") + response = self.client.chat.completions.create( model=self.config.get('model'), messages=messages, temperature=0.1, max_tokens=100 ) - + command = response.choices[0].message.content.strip() + logger.info(f"LLM 返回命令: '{command}'") return command except Exception as e: + logger.error(f"LLM调用失败: {str(e)}") raise Exception(f"LLM调用失败: {str(e)}") diff --git a/autoterminal/main.py b/autoterminal/main.py index 0b78258..152d6c8 100644 --- a/autoterminal/main.py +++ b/autoterminal/main.py @@ -9,11 +9,15 @@ import glob from autoterminal.config.loader import ConfigLoader from autoterminal.config.manager import ConfigManager from autoterminal.llm.client import LLMClient -from autoterminal.utils.helpers import clean_command +from autoterminal.utils.helpers import clean_command, get_shell_history from autoterminal.history import HistoryManager +from autoterminal.utils.logger import logger + def main(): """主程序入口""" + logger.info("AutoTerminal 启动") + # 解析命令行参数 parser = argparse.ArgumentParser(description='AutoTerminal - 智能终端工具') parser.add_argument('user_input', nargs='*', help='用户输入的自然语言命令') @@ -21,16 +25,18 @@ def main(): parser.add_argument('--base-url', help='Base URL') parser.add_argument('--model', help='模型名称') parser.add_argument('--history-count', type=int, help='历史命令数量') - + args = parser.parse_args() - + # 合并用户输入 user_input = ' '.join(args.user_input).strip() - + logger.debug(f"用户输入: '{user_input}'") + # 加载配置 + logger.debug("加载配置文件") config_loader = ConfigLoader() config = config_loader.get_config() - + # 命令行参数优先级最高 if args.api_key: config['api_key'] = args.api_key @@ -38,121 +44,134 @@ def main(): config['base_url'] = args.base_url if args.model: config['model'] = args.model - + # 获取历史命令数量配置 history_count = args.history_count or config.get('max_history', 10) - + # 如果配置不完整,使用配置管理器初始化 config_manager = ConfigManager() if not all([config.get('api_key'), config.get('base_url'), config.get('model')]): config = config_manager.get_or_create_config() if not config: - print("错误: 缺少必要的配置参数,请通过命令行参数或配置文件提供API密钥、Base URL和模型名称。") + logger.error("缺少必要的配置参数,请通过命令行参数或配置文件提供API密钥、Base URL和模型名称。") return 1 - + # 如果有命令行参数输入,直接处理 if user_input: # 初始化历史管理器 history_manager = HistoryManager(max_history=history_count) - + # 获取历史命令 history = history_manager.get_recent_history(history_count) - + # 获取当前目录内容 try: current_dir_content = glob.glob("*") except Exception as e: - print(f"警告: 无法获取当前目录内容: {e}") + logger.warning(f"无法获取当前目录内容: {e}") current_dir_content = [] - + + # 获取系统 Shell 历史 + shell_history = get_shell_history() # 使用默认值 20 + # 初始化LLM客户端 try: llm_client = LLMClient(config) except Exception as e: - print(f"LLM客户端初始化失败: {e}") + logger.error(f"LLM客户端初始化失败: {e}") return 1 - + # 调用LLM生成命令 try: generated_command = llm_client.generate_command( user_input=user_input, history=history, - current_dir_content=current_dir_content + current_dir_content=current_dir_content, + shell_history=shell_history ) cleaned_command = clean_command(generated_command) - + # 优化输出格式 print(f"\033[1;32m$\033[0m {cleaned_command}") print("\033[1;37mPress Enter to execute...\033[0m") - + # 等待用户回车确认执行 try: input() - + # 在用户的环境中执行命令 + logger.info(f"执行命令: {cleaned_command}") os.system(cleaned_command) - + # 记录到历史 history_manager.add_command(user_input, cleaned_command) + logger.debug("命令已添加到历史记录") except EOFError: print("\n输入已取消。") return 0 except Exception as exec_e: - print(f"命令执行失败: {exec_e}") + logger.error(f"命令执行失败: {exec_e}") return 1 - + except Exception as e: - print(f"命令生成失败: {e}") + logger.error(f"命令生成失败: {e}") return 1 - + return 0 else: # 处理空输入情况 - 生成基于上下文的推荐命令 history_manager = HistoryManager(max_history=history_count) history = history_manager.get_recent_history(history_count) - + try: current_dir_content = glob.glob("*") except Exception as e: - print(f"警告: 无法获取当前目录内容: {e}") + logger.warning(f"无法获取当前目录内容: {e}") current_dir_content = [] - + + # 获取系统 Shell 历史 + shell_history = get_shell_history() # 使用默认值 20 + try: llm_client = LLMClient(config) except Exception as e: - print(f"LLM客户端初始化失败: {e}") + logger.error(f"LLM客户端初始化失败: {e}") return 1 - + # 获取最后执行的命令以避免重复推荐 last_executed_command = history_manager.get_last_executed_command() - + try: recommendation = llm_client.generate_command( user_input="", history=history, current_dir_content=current_dir_content, + shell_history=shell_history, last_executed_command=last_executed_command ) cleaned_recommendation = clean_command(recommendation) - + if cleaned_recommendation.strip(): print(f"\033[1;34m💡 建议命令:\033[0m {cleaned_recommendation}") print("\033[1;37mPress Enter to execute, or Ctrl+C to cancel...\033[0m") try: input() + logger.info(f"执行推荐命令: {cleaned_recommendation}") os.system(cleaned_recommendation) history_manager.add_command("自动推荐", cleaned_recommendation) + logger.debug("推荐命令已添加到历史记录") except EOFError: print("\n输入已取消。") return 0 except Exception as exec_e: - print(f"命令执行失败: {exec_e}") + logger.error(f"命令执行失败: {exec_e}") return 1 else: print("没有找到相关的命令建议。") except Exception as e: - print(f"命令推荐生成失败: {e}") + logger.error(f"命令推荐生成失败: {e}") return 1 + if __name__ == "__main__": sys.exit(main()) diff --git a/autoterminal/utils/helpers.py b/autoterminal/utils/helpers.py index 503e409..5c5af5b 100644 --- a/autoterminal/utils/helpers.py +++ b/autoterminal/utils/helpers.py @@ -1,3 +1,8 @@ +import os +from typing import List +from autoterminal.utils.logger import logger + + def clean_command(command: str) -> str: """清理命令字符串""" # 移除可能的引号和多余空格 @@ -7,3 +12,96 @@ def clean_command(command: str) -> str: if command.startswith("'") and command.endswith("'"): command = command[1:-1] return command.strip() + + +def get_shell_history(count: int = 20) -> List[str]: + """ + 获取系统 Shell 历史命令 + + Args: + count: 获取最近的命令数量 + + Returns: + 最近执行的 Shell 命令列表 + """ + history_commands = [] + + try: + # 尝试从环境变量获取历史文件路径 + histfile = os.getenv('HISTFILE') + + # 如果没有 HISTFILE,根据 SHELL 推断 + if not histfile or not os.path.exists(histfile): + home_dir = os.path.expanduser("~") + shell = os.getenv('SHELL', '') + + # 根据当前 Shell 类型优先尝试对应的历史文件 + possible_files = [] + if 'zsh' in shell: + possible_files = [ + os.path.join(home_dir, ".zsh_history"), + os.path.join(home_dir, ".zhistory"), + os.path.join(home_dir, ".bash_history"), + ] + else: # bash 或其他 + possible_files = [ + os.path.join(home_dir, ".bash_history"), + os.path.join(home_dir, ".zsh_history"), + os.path.join(home_dir, ".zhistory"), + ] + + for file_path in possible_files: + if os.path.exists(file_path): + histfile = file_path + break + + if histfile and os.path.exists(histfile): + logger.debug(f"读取 Shell 历史文件: {histfile}") + + with open(histfile, 'r', encoding='utf-8', errors='ignore') as f: + lines = f.readlines() + + # 过滤和清理命令 + for line in lines: + line = line.strip() + + # 跳过空行 + if not line: + continue + + # 处理 zsh 扩展历史格式 (: timestamp:duration;command) + if line.startswith(':'): + parts = line.split(';', 1) + if len(parts) > 1: + line = parts[1].strip() + + # 过滤敏感命令(包含密码、密钥等) + sensitive_keywords = [ + 'password', + 'passwd', + 'secret', + 'key', + 'token', + 'api_key', + 'api-key'] + if any(keyword in line.lower() for keyword in sensitive_keywords): + continue + + # 过滤重复命令(保持顺序,只保留最后一次出现) + if line in history_commands: + history_commands.remove(line) + + history_commands.append(line) + + # 返回最近的 N 条命令 + result = history_commands[-count:] if len( + history_commands) > count else history_commands + logger.debug(f"成功获取 {len(result)} 条 Shell 历史命令") + return result + else: + logger.warning("未找到 Shell 历史文件") + return [] + + except Exception as e: + logger.warning(f"获取 Shell 历史失败: {e}") + return [] diff --git a/autoterminal/utils/logger.py b/autoterminal/utils/logger.py new file mode 100644 index 0000000..a35c407 --- /dev/null +++ b/autoterminal/utils/logger.py @@ -0,0 +1,38 @@ +import os +import sys +from loguru import logger + +# 移除默认的 handler +logger.remove() + +# 获取日志级别(从环境变量或默认为 ERROR,正式使用时只显示错误) +log_level = os.getenv("AUTOTERMINAL_LOG_LEVEL", "ERROR") + +# 添加控制台输出(stderr)- 默认只显示错误 +logger.add( + sys.stderr, + format="{level}: {message}", + level=log_level, + colorize=True +) + +# 添加文件输出(可选,存储在 ~/.autoterminal/ 目录) +enable_file_logging = os.getenv("AUTOTERMINAL_FILE_LOG", "true").lower() != "false" +if enable_file_logging: + home_dir = os.path.expanduser("~") + log_dir = os.path.join(home_dir, ".autoterminal") + os.makedirs(log_dir, exist_ok=True) + log_file = os.path.join(log_dir, "autoterminal.log") + + logger.add( + log_file, + format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}", + level="DEBUG", # 文件记录所有级别的日志 + rotation="10 MB", # 日志文件达到 10MB 时轮转 + retention="7 days", # 保留最近 7 天的日志 + compression="zip", # 压缩旧日志 + encoding="utf-8" + ) + +# 导出 logger 供其他模块使用 +__all__ = ["logger"] diff --git a/pyproject.toml b/pyproject.toml index 570ba82..b63f2ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "autoterminal" -version = "0.1.1" +version = "1.0.1" description = "智能终端工具,基于LLM将自然语言转换为终端命令(create by claude 4 sonnet)" readme = "README.md" requires-python = ">=3.10" @@ -13,9 +13,9 @@ authors = [ {name = "wds2dxh", email = "wdsnpshy@163.com"} ] license = {text = "MIT"} -keywords = ["terminal", "ai", "llm", "command-line", "automation"] -classifiers = [ - "Development Status :: 4 - Beta", +keywords = ["terminal", "ai", "llm", "command-line", "automation", "autoterminal"] +classifiers = [ + "Development Status :: 6 - Mature", "Intended Audience :: Developers", "Intended Audience :: System Administrators", "License :: OSI Approved :: MIT License", @@ -28,7 +28,8 @@ classifiers = [ "Topic :: Utilities", ] dependencies = [ - "openai>=1.0.0" + "openai>=1.0.0", + "loguru>=0.7.0" ] [project.urls] diff --git a/setup.py b/setup.py deleted file mode 100644 index 7e8561b..0000000 --- a/setup.py +++ /dev/null @@ -1,37 +0,0 @@ -from setuptools import setup, find_packages - -setup( - name="autoterminal", - version="0.1.1", - description="智能终端工具,基于LLM将自然语言转换为终端命令(create by claude 4 sonnet)", - long_description=open("README.md").read(), - long_description_content_type="text/markdown", - author="wds", - author_email="wdsnpshy@163.com", - url="http://cloud-home.dxh-wds.top:20100/w/AutoTerminal", - license="MIT", - packages=find_packages(), - install_requires=[ - "openai>=1.0.0", - ], - entry_points={ - 'console_scripts': [ - 'at=autoterminal.main:main', - ], - }, - python_requires='>=3.10', - classifiers=[ - "Development Status :: 4 - Beta", - "Intended Audience :: Developers", - "Intended Audience :: System Administrators", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Topic :: System :: Systems Administration", - "Topic :: Utilities", - ], - keywords=["terminal", "ai", "llm", "command-line", "automation"], -) diff --git a/uv.lock b/uv.lock index 4868e17..68ab633 100644 --- a/uv.lock +++ b/uv.lock @@ -31,6 +31,7 @@ name = "autoterminal" version = "0.1.1" source = { editable = "." } dependencies = [ + { name = "loguru" }, { name = "openai" }, ] @@ -40,7 +41,10 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "openai", specifier = ">=1.0.0" }] +requires-dist = [ + { name = "loguru", specifier = ">=0.7.0" }, + { name = "openai", specifier = ">=1.0.0" }, +] [package.metadata.requires-dev] dev = [{ name = "twine", specifier = ">=6.1.0" }] @@ -449,6 +453,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d3/32/da7f44bcb1105d3e88a0b74ebdca50c59121d2ddf71c9e34ba47df7f3a56/keyring-25.6.0-py3-none-any.whl", hash = "sha256:552a3f7af126ece7ed5c89753650eec89c7eaae8617d0aa4d9ad2b75111266bd", size = 39085, upload-time = "2024-12-25T15:26:44.377Z" }, ] +[[package]] +name = "loguru" +version = "0.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "win32-setctime", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -816,6 +833,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, ] +[[package]] +name = "win32-setctime" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, +] + [[package]] name = "zipp" version = "3.23.0"