Initial commit

This commit is contained in:
2025-04-09 09:34:15 +02:00
commit c19fb93ec5
47 changed files with 5174 additions and 0 deletions
+100
View File
@@ -0,0 +1,100 @@
# Edison
Edison is an AI-powered command-line tool that translates natural language queries into shell commands. It leverages OpenAI's GPT models to generate accurate and useful commands based on your descriptions.
## Features
- Translate natural language to shell commands
- Support for multiple shells (bash, zsh, powershell)
- Safety checks for potentially dangerous commands
- Command modification and clipboard support
- Configurable via YAML file
- Rich terminal UI with syntax highlighting
- Interactive shell mode with command history
- Command explanations
- Progress indicators and spinners
## Installation
```bash
# Clone the repository
git clone https://github.com/yourusername/edison.git
cd edison
# Install the package
pip install -e .
```
## Usage
```bash
# Basic usage
edison how to list all files in the current directory
# Enable safety mode (confirmation before execution)
edison -s how to find all python files recursively
# Print current configuration
edison -c
# Enable verbose logging
edison -v how to check disk space
# Use rich terminal UI
edison -r how to find large files
# Get command explanations
edison -e how to compress a directory
# Start interactive shell mode
edison -i
```
## Interactive Shell
Edison includes an interactive shell mode that provides a more user-friendly experience:
```bash
# Start interactive shell
edison -i
```
In interactive mode, you can:
- Type natural language queries
- Navigate command history with up/down arrows
- Get auto-suggestions based on previous commands
- Execute, modify, copy, or get explanations for commands
- Use special commands like `!help` and `!exit`
## Rich Terminal UI
The rich terminal UI provides:
- Syntax highlighting for commands
- Colorful output
- Progress indicators during API calls
- Command explanations with detailed breakdowns
- Success/error messages with appropriate styling
## Configuration
Edison can be configured via the `edison.yaml` file. Here's an example configuration:
```yaml
model: gpt-3.5-turbo
temperature: 0
max_tokens: 500
safety: true
```
## API Key
Edison requires an OpenAI API key to function. You can provide it in one of the following ways:
1. Environment variable: `OPENAI_API_KEY="your-api-key"`
2. `.env` file in the same directory as the script
3. `.openai.apikey` file in your home directory
4. In the `edison.yaml` configuration file: `openai_api_key: "your-api-key"`
## License
MIT
View File
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env python3
"""
Entry point for running the edison package as a module.
"""
from edison.cli import main
if __name__ == "__main__":
main()
+128
View File
@@ -0,0 +1,128 @@
"""
Command-line interface for the Edison application.
"""
import argparse
import logging
from colorama import init
from edison.config import config_manager
from edison.core import api_client
from edison.ui import console, interactive
from edison.utils import logging_utils, os_utils
# Logger will be initialized after parsing args
logger = None
def parse_arguments():
"""
Parse command line arguments.
Returns:
argparse.Namespace: The parsed arguments.
"""
parser = argparse.ArgumentParser(
description='AI bot that translates your question to a command.'
)
parser.add_argument('text', nargs='*',
help='A sequence of strings')
parser.add_argument("-s", "--safety", action='store_true',
help='Enable safety mode (only useful when safety is off)')
parser.add_argument("-c", "--config", action='store_true',
help='Print current configuration')
parser.add_argument("-v", "--verbose", action='store_true',
help='Enable verbose logging (DEBUG level)')
parser.add_argument("-i", "--interactive", action='store_true',
help='Start interactive shell mode')
parser.add_argument("-e", "--explain", action='store_true',
help='Explain the generated command')
parser.add_argument("--no-streaming", action='store_true',
help='Disable streaming output for command generation')
parser.add_argument("--no-rich", action='store_true',
help='Disable rich text formatting')
parser.add_argument("--theme", type=str,
help='Set syntax highlighting theme (e.g., monokai, github-dark)')
return parser.parse_args()
def main():
"""
Main entry point for the application.
"""
# Parse command line arguments
args = parse_arguments()
# Initialize logging based on the verbose flag
global logger
logger = logging_utils.setup_logging(verbose=args.verbose)
try:
# Load configuration
config = config_manager.load_config()
logger.debug("Configuration loaded")
# Process command-line arguments
if args.safety:
config["safety"] = args.safety
# Set streaming and UI options based on command-line arguments
if args.no_streaming:
config["streaming"] = False
# Initialize UI config if not present
if "ui" not in config:
config["ui"] = {}
# Set rich formatting option
if args.no_rich:
config["ui"]["rich_formatting"] = False
# Set theme if specified
if args.theme:
config["ui"]["theme"] = args.theme
# Set default shell if not specified
if "shell" not in config:
config["shell"] = os_utils.get_default_shell()
# Print configuration if requested
if args.config:
config_manager.print_config(config)
return
# Enable color output on Windows using colorama
init()
# Initialize API client
try:
client = api_client.create_client(config)
logger.debug("API client initialized")
# Start interactive mode if requested
if args.interactive:
interactive.interactive_mode(client, config)
return
# Check if we have a query
if not args.text:
print("No query provided. Use -i for interactive mode.")
return
# Join the text arguments
user_prompt = " ".join(args.text)
# Generate command with or without streaming based on config
if config.get("streaming", True):
# Use streaming version
console.handle_command_execution_streaming(client, config, user_prompt, explain=args.explain)
else:
# Use non-streaming version
command = api_client.generate_command(client, config, user_prompt)
logger.debug("Command generated")
console.handle_command_execution(client, config, command, explain=args.explain)
except Exception as e:
logger.error(f"Error: {e}")
print(f"Error: {e}")
except Exception as e:
logger.error(f"Unhandled exception: {e}")
print(f"An unexpected error occurred: {e}")
if __name__ == "__main__":
main()
View File
+60
View File
@@ -0,0 +1,60 @@
"""
Configuration management for the Edison application.
"""
import os
import yaml
import logging
logger = logging.getLogger(__name__)
def get_config_path():
"""
Get the path to the configuration file.
Returns:
str: The path to the configuration file.
"""
script_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
return os.path.join(script_dir, "edison.yaml")
def load_config():
"""
Load configuration from file.
Returns:
dict: The loaded configuration.
"""
config_path = get_config_path()
try:
with open(config_path, 'r') as file:
config = yaml.safe_load(file)
logger.debug(f"Configuration loaded from {config_path}")
return config
except Exception as e:
logger.error(f"Error loading configuration: {e}")
return {}
def print_config(config):
"""
Print configuration information.
Args:
config (dict): The configuration dictionary.
"""
print("Current configuration per edison.yaml:")
print("— Model : " + str(config.get("model", "Not specified")))
print("— Temperature : " + str(config.get("temperature", "Not specified")))
print("— Max. Tokens : " + str(config.get("max_tokens", "Not specified")))
print("— Safety : " + str(bool(config.get("safety", False))))
print("— Streaming : " + str(bool(config.get("streaming", True))))
print("— Show Prefix : " + str(bool(config.get("show_generating_prefix", True))))
print("— Shell : " + str(config.get("shell", "Not specified")))
# Print UI configuration if available
ui_config = config.get("ui", {})
if ui_config:
print("\nUI Configuration:")
print("— Rich Formatting : " + str(bool(ui_config.get("rich_formatting", True))))
print("— Theme : " + str(ui_config.get("theme", "monokai")))
print("— Command Style : " + str(ui_config.get("command_style", "panel")))
print("— Structured Expl.: " + str(bool(ui_config.get("structured_explanations", True))))
View File
+218
View File
@@ -0,0 +1,218 @@
"""
OpenAI API client for the Edison application.
"""
import os
import time
import logging
import dotenv
from openai import OpenAI
from edison.core import prompt_manager
from edison.utils import logging_utils
logger = logging.getLogger(__name__)
def get_api_key(config):
"""
Get the OpenAI API key from various sources.
This function first tries to grab the OpenAI API key from environment variables,
if not found, it then looks for the key in the `.openai.apikey` in the home directory,
and lastly, it will look in the provided config dictionary.
Args:
config (dict): A dictionary containing configuration values.
It may contain `openai_api_key` as one of the keys.
Returns:
str: The OpenAI API key
"""
dotenv.load_dotenv()
# Method 1: Read API key from environment variable
# The user can set their OpenAI API key by creating a ".env" file in the same
# directory as this script or by exporting it to their environment variables.
# The file or environment variable should contain the line `OPENAI_API_KEY="<yourkey>"`.
api_key = os.getenv("OPENAI_API_KEY")
# Method 2: Read API key from a file in the home directory
# The user can also place a file named ".openai.apikey" in their home directory,
# which includes the API key in raw format. This method might be deprecated in future versions.
if not api_key:
home_path = os.path.expanduser("~")
api_key_path = os.path.join(home_path, ".openai.apikey")
if os.path.exists(api_key_path):
with open(api_key_path, 'r') as f:
api_key = f.read().strip()
# Method 3: Read API key from the provided config dictionary
# The final method to set the API key is by providing it in the 'config' dictionary under the
# key 'openai_api_key'. For instance, in a `edison.yaml` config file, it would appear as
# `openai_apikey: <yourkey>`.
if not api_key:
api_key = config.get("openai_api_key")
if not api_key:
logger.error("No OpenAI API key found. Please set it in your environment, .env file, or config.")
raise ValueError("No OpenAI API key found")
return api_key
def create_client(config):
"""
Create and initialize an OpenAI client.
Args:
config (dict): The configuration dictionary.
Returns:
OpenAI: An initialized OpenAI client.
"""
api_key = get_api_key(config)
return OpenAI(api_key=api_key)
def call_api(client, config, query):
"""
Call the OpenAI API with the given query.
Args:
client (OpenAI): The OpenAI client instance.
config (dict): Configuration dictionary containing model and parameters.
query (str): The user's query string.
Returns:
str: The generated command as a string.
"""
if not query:
logger.error("No user prompt specified.")
raise ValueError("No user prompt specified")
# Load the correct prompt based on shell and OS and append the user's prompt
prompt = prompt_manager.get_full_prompt(query, config.get("shell", "bash"))
# Extract the system prompt from the first line
system_prompt = prompt.split('\n')[0] if '\n' in prompt else prompt
try:
# Use the modern API pattern
response = client.chat.completions.create(
model=config.get("model", "gpt-4o-mini"),
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt}
],
temperature=config.get("temperature", 0),
max_tokens=config.get("max_tokens", 500),
)
# Extract the content from the new response structure
return response.choices[0].message.content.strip()
except Exception as e:
logger.error(f"Error calling OpenAI API: {str(e)}")
raise
def call_api_streaming(client, config, query, callback):
"""
Call the OpenAI API with streaming enabled.
Args:
client (OpenAI): The OpenAI client instance.
config (dict): Configuration dictionary containing model and parameters.
query (str): The user's query string.
callback (callable): Function to call with each token as it arrives.
Returns:
str: The complete generated command as a string.
"""
if not query:
logger.error("No user prompt specified.")
raise ValueError("No user prompt specified")
# Load the correct prompt based on shell and OS and append the user's prompt
prompt = prompt_manager.get_full_prompt(query, config.get("shell", "bash"))
# Extract the system prompt from the first line
system_prompt = prompt.split('\n')[0] if '\n' in prompt else prompt
try:
# Use the streaming API pattern
response = client.chat.completions.create(
model=config.get("model", "gpt-4o-mini"),
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt}
],
temperature=config.get("temperature", 0),
max_tokens=config.get("max_tokens", 500),
stream=True # Enable streaming
)
# Initialize an empty string to collect the full response
full_response = ""
# Process the streaming response
for chunk in response:
if hasattr(chunk.choices[0].delta, 'content'):
content = chunk.choices[0].delta.content
if content:
# Call the callback with the new content
callback(content)
# Append to the full response
full_response += content
return full_response.strip()
except Exception as e:
logger.error(f"Error calling OpenAI API: {str(e)}")
raise
def generate_command_streaming(client, config, query, callback, max_retries=3):
"""
Generate a command using the OpenAI API with streaming and retry logic.
Args:
client (OpenAI): The OpenAI client instance.
config (dict): Configuration dictionary containing model and parameters.
query (str): The user's query string.
callback (callable): Function to call with each token as it arrives.
max_retries (int): Maximum number of retry attempts.
Returns:
str: The generated command as a string.
"""
for attempt in range(max_retries):
try:
return call_api_streaming(client, config, query, callback)
except Exception as e:
if "rate limit" in str(e).lower() and attempt < max_retries - 1:
wait_time = 2 ** attempt # Exponential backoff
logger.warning(f"Rate limited. Retrying in {wait_time} seconds...")
time.sleep(wait_time)
else:
logger.error(f"Error after {attempt+1} attempts: {str(e)}")
raise
def generate_command(client, config, query, max_retries=3):
"""
Generate a command using the OpenAI API with retry logic.
Args:
client (OpenAI): The OpenAI client instance.
config (dict): Configuration dictionary containing model and parameters.
query (str): The user's query string.
max_retries (int): Maximum number of retry attempts.
Returns:
str: The generated command as a string.
"""
for attempt in range(max_retries):
try:
return call_api(client, config, query)
except Exception as e:
if "rate limit" in str(e).lower() and attempt < max_retries - 1:
wait_time = 2 ** attempt # Exponential backoff
logger.warning(f"Rate limited. Retrying in {wait_time} seconds...")
time.sleep(wait_time)
else:
logger.error(f"Error after {attempt+1} attempts: {str(e)}")
raise
+39
View File
@@ -0,0 +1,39 @@
"""
Command execution for the Edison application.
"""
import subprocess
import logging
from edison.utils import validation
logger = logging.getLogger(__name__)
def execute_command(shell, command):
"""
Execute a shell command.
Args:
shell (str): The shell to use.
command (str): The command to execute.
Returns:
subprocess.CompletedProcess: The result of the command execution.
Raises:
subprocess.CalledProcessError: If the command execution fails.
"""
if validation.is_dangerous_command(command):
logger.warning(f"Potentially dangerous command detected: {command}")
# We still allow execution but log a warning
try:
if shell == "powershell.exe":
result = subprocess.run([shell, "/c", command], shell=False, check=True)
else:
# Unix: /bin/bash /bin/zsh: uses -c both Ubuntu and macOS should work, others might not
result = subprocess.run([shell, "-c", command], shell=False, check=True)
logger.debug(f"Command executed successfully: {command}")
return result
except subprocess.CalledProcessError as e:
logger.error(f"Command execution failed: {e}")
raise
+54
View File
@@ -0,0 +1,54 @@
"""
Prompt management for the Edison application.
"""
import os
import logging
from edison.utils import os_utils
logger = logging.getLogger(__name__)
def get_prompt_template_path():
"""
Get the path to the prompt template file.
Returns:
str: The path to the prompt template file.
"""
script_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
return os.path.join(script_dir, "edison.prompt")
def get_full_prompt(user_prompt, shell):
"""
Construct a full prompt from the template and user input.
Args:
user_prompt (str): The user's prompt.
shell (str): The shell to use.
Returns:
str: The full prompt.
"""
# Get the path to the prompt template
prompt_file = get_prompt_template_path()
try:
# Load the prompt template
with open(prompt_file, "r") as f:
pre_prompt = f.read()
# Replace placeholders
pre_prompt = pre_prompt.replace("{shell}", shell)
pre_prompt = pre_prompt.replace("{os}", os_utils.get_os_friendly_name())
# Append the user prompt
prompt = pre_prompt + user_prompt
# Make it a question if it's not already
if prompt[-1:] != "?" and prompt[-1:] != ".":
prompt += "?"
return prompt
except Exception as e:
logger.error(f"Error loading prompt template: {e}")
# Fallback to a simple prompt
return f"Act as a natural language to {shell} command translation engine on {os_utils.get_os_friendly_name()}. {user_prompt}"
+31
View File
@@ -0,0 +1,31 @@
Act as a natural language to {shell} command translation engine on {os}.
You are an expert in {shell} on {os} and translate the question at the end to valid syntax.
Follow these rules:
Construct valid {shell} command that solve the question
Leverage help and man pages to ensure valid syntax and an optimal solution
Be concise
Just show the commands
Return only plaintext
Only show a single answer, but you can always chain commands together
Think step by step
Only create valid syntax (you can use comments if it makes sense)
If python is installed you can use it to solve problems
if python3 is installed you can use it to solve problems
Even if there is a lack of details, attempt to find the most logical solution by going about it step by step
Do not return multiple solutions
Do not show html, styled, colored formatting
Do not creating invalid syntax
Do not add unnecessary text in the response
Do not add notes or intro sentences
Do not show multiple distinct solutions to the question
Do not add explanations on what the commands do
Do not return what the question was
Do not repeat or paraphrase the question in your response
Do not cause syntax errors
Do not rush to a conclusion
Follow all of the above rules. This is important you MUST follow the above rules. There are no exceptions to these rules. You must always follow them. No exceptions.
Question:
+31
View File
@@ -0,0 +1,31 @@
model: gpt-4o-mini # Default model for command generation
temperature: 0
max_tokens: 500
# Safety: If set to False, commands returned from the AI will be run *without* prompting the user.
safety: True
# Streaming: If set to True, command generation will be displayed in real-time as tokens arrive.
# Set to False for traditional non-streaming behavior.
streaming: True
# Show a "Generating command: " prefix during streaming
# Set to False to remove the prefix and only show the command
show_generating_prefix: True
# UI Configuration: Controls the appearance and behavior of the user interface
ui:
# Enable rich text formatting for command explanations and output
rich_formatting: True
# Theme for syntax highlighting (options: monokai, github-dark, solarized-dark, etc.)
theme: "monokai"
# Command display style (options: simple, highlighted, panel)
command_style: "panel"
# Structured explanations: Generate more structured command explanations
structured_explanations: True
# Open AI API Key (optional): The key can aso be provided via environment variable (OPENAI_API_KEY), .env, or ~/.openai.apikey file
openai_api_key:
View File
+245
View File
@@ -0,0 +1,245 @@
"""
Console UI components for the Edison application.
"""
import logging
import pyperclip
from termcolor import colored
from edison.core import api_client, command_executor
from edison.utils import os_utils, validation, markdown_utils
logger = logging.getLogger(__name__)
def handle_streaming_output(token):
"""
Handle a token from the streaming API response.
Args:
token (str): A token from the streaming response.
"""
# Print the token without a newline and flush immediately
print(token, end='', flush=True)
def print_command(command, config=None):
"""
Print a command to the console.
Args:
command (str): The command to print.
config (dict, optional): Configuration dictionary.
"""
if config and config.get("ui", {}).get("rich_formatting", True):
command_style = config.get("ui", {}).get("command_style", "panel")
theme = config.get("ui", {}).get("theme", "monokai")
shell = config.get("shell", "bash")
if command_style == "panel":
# Use rich panel style
from edison.utils.markdown_utils import print_command_rich
print_command_rich(command, shell=shell, theme=theme)
else:
# Use simple highlighting
print("Command: " + colored(command, 'blue'))
else:
# Use traditional styling
print("Command: " + colored(command, 'blue'))
def prompt_user_input(config, response):
"""
Prompt the user for input on what to do with the generated command.
Args:
config (dict): The configuration dictionary.
response (str): The generated command.
Returns:
str: The user's input.
"""
print_command(response, config)
if config.get("safety", True):
prompt_text = "Execute command? [Y]es [n]o [m]odify (prompt/command) [c]opy to clipboard ==> "
if os_utils.missing_posix_display():
prompt_text = "Execute command? [Y]es [n]o [m]odify (prompt/command) ==> "
print(prompt_text, end='')
user_input = input()
else:
user_input = "Y"
return user_input
def handle_command_execution_streaming(client, config, query, explain=False):
"""
Handle the execution of a command with streaming output.
Args:
client (OpenAI): The OpenAI client.
config (dict): The configuration dictionary.
query (str): The user's query.
explain (bool): Whether to explain the command.
"""
# Check if we should show the "Generating command: " prefix
if config.get("show_generating_prefix", True):
print(colored("Generating command: ", 'yellow'), end='', flush=True)
try:
# Call the streaming API
command = api_client.generate_command_streaming(client, config, query, handle_streaming_output)
# Print a newline after streaming is complete
print()
# Continue with the regular command execution flow
handle_command_execution(client, config, command, explain)
except KeyboardInterrupt:
print("\nCommand generation cancelled.")
return
except Exception as e:
logger.error(f"Error generating command: {e}")
print(colored(f"\nError generating command: {e}", 'red'))
def handle_command_execution(client, config, command, explain=False):
"""
Handle the execution of a command based on user input.
Args:
client (OpenAI): The OpenAI client.
config (dict): The configuration dictionary.
command (str): The command to execute.
explain (bool): Whether to explain the command.
"""
# Check for issues in the response
if validation.check_for_issue(command):
print(colored("There was an issue: " + command, 'red'))
return
# Check for markdown in the response
if validation.check_for_markdown(command):
print(colored(
"The proposed command contains markdown, response not executed directly: \n", 'red'
) + command)
return
# Get explanation if requested
if explain:
try:
from edison.ui.interactive import get_command_explanation
# Get UI configuration
ui_config = config.get("ui", {})
structured = ui_config.get("structured_explanations", True)
use_rich = ui_config.get("rich_formatting", True)
# Generate explanation
explanation = get_command_explanation(client, command, structured=structured)
if use_rich:
print() # Add a blank line before the explanation
# Format the explanation with rich formatting
formatted_explanation = markdown_utils.format_command_explanation(explanation, use_rich=True)
print(formatted_explanation)
else:
print(colored("\nExplanation:", "green", attrs=["bold"]))
# Format the explanation with terminal-friendly markdown
formatted_explanation = markdown_utils.format_command_explanation(explanation)
print(formatted_explanation)
print()
except Exception as e:
logger.error(f"Error generating explanation: {e}")
print(colored(f"Error generating explanation: {e}", 'red'))
# Get user input
user_input = prompt_user_input(config, command)
print()
# Handle user input
handle_user_input(client, config, user_input, command)
def handle_user_input(client, config, user_input, command):
"""
Handle user input for command execution.
Args:
client (OpenAI): The OpenAI client.
config (dict): The configuration dictionary.
user_input (str): The user's input.
command (str): The command to execute.
"""
if user_input.upper() == "Y" or user_input == "":
try:
command_executor.execute_command(config.get("shell", os_utils.get_default_shell()), command)
except Exception as e:
logger.error(f"Error executing command: {e}")
print(colored(f"Error executing command: {e}", 'red'))
elif user_input.upper() == "M":
print("Modify [p]rompt or [c]ommand? [c] ==> ", end='')
mod_choice = input().lower()
if mod_choice in ["p", "prompt"]:
# Modify the prompt (natural language description)
print("Modify prompt: ", end='')
modded_query = input()
if not modded_query.strip():
print(colored("Empty prompt. Command execution cancelled.", "yellow"))
return
# Use streaming if enabled in config
if config.get("streaming", True):
try:
handle_command_execution_streaming(client, config, modded_query)
except Exception as e:
logger.error(f"Error generating modified command: {e}")
print(colored(f"Error generating modified command: {e}", 'red'))
else:
try:
modded_response = api_client.generate_command(client, config, modded_query)
handle_command_execution(client, config, modded_response)
except Exception as e:
logger.error(f"Error generating modified command: {e}")
print(colored(f"Error generating modified command: {e}", 'red'))
else:
# Modify the command directly
print("Modify command: ", end='')
# Pre-fill with current command
import readline
readline.set_startup_hook(lambda: readline.insert_text(command))
try:
modded_command = input()
finally:
readline.set_startup_hook()
if not modded_command.strip():
print(colored("Empty command. Command execution cancelled.", "yellow"))
return
# Print the modified command with styling
print_command(modded_command, config)
# Ask for confirmation
print("Execute modified command? [Y/n] ==> ", end='')
confirm = input().lower()
if confirm in ["n", "no"]:
print(colored("Command execution cancelled.", "yellow"))
return
# Execute the modified command
try:
command_executor.execute_command(config.get("shell", os_utils.get_default_shell()), modded_command)
except Exception as e:
logger.error(f"Error executing modified command: {e}")
print(colored(f"Error executing modified command: {e}", 'red'))
elif user_input.upper() == "C":
if os_utils.missing_posix_display():
return
try:
pyperclip.copy(command)
print("Copied command to clipboard.")
except Exception as e:
logger.error(f"Error copying to clipboard: {e}")
print(colored(f"Error copying to clipboard: {e}", 'red'))
+340
View File
@@ -0,0 +1,340 @@
"""
Interactive shell for the Edison application.
"""
import logging
import os
import sys
from termcolor import colored
from prompt_toolkit import PromptSession
from prompt_toolkit.history import FileHistory
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
from prompt_toolkit.lexers import PygmentsLexer
from pygments.lexers.shell import BashLexer
from edison.core import api_client, command_executor
from edison.utils import os_utils, markdown_utils
logger = logging.getLogger(__name__)
def handle_streaming_output_interactive(token):
"""
Handle a token from the streaming API response in interactive mode.
Args:
token (str): A token from the streaming response.
"""
# Print the token without a newline and flush immediately
print(token, end='', flush=True)
def get_command_explanation(client, command, structured=False):
"""
Get an explanation for a command.
Args:
client: The OpenAI client.
command (str): The command to explain.
structured (bool): Whether to use structured explanation format.
Returns:
str: The explanation of the command.
"""
try:
system_prompt = """Explain this shell command in simple terms, breaking down each part:"""
if structured:
system_prompt = """
Explain this shell command in a structured format with these sections:
## Overview
A brief overview of what the command does
## Command Breakdown
Break down each part of the command with explanations
## Options/Flags
Explain any options or flags used
## Examples
Provide 1-2 simple examples of variations
## Cautions
Note any potential issues or cautions
"""
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": command}
]
)
return response.choices[0].message.content
except Exception as e:
logger.error(f"Error getting command explanation: {e}")
return "Could not generate explanation."
def interactive_mode(client, config):
"""
Start an interactive shell.
Args:
client: The OpenAI client.
config (dict): The configuration dictionary.
"""
# Create history file path
history_path = os.path.expanduser("~/.edison_history")
history_dir = os.path.dirname(history_path)
os.makedirs(history_dir, exist_ok=True)
# Create prompt session
history = FileHistory(history_path)
session = PromptSession(
history=history,
auto_suggest=AutoSuggestFromHistory(),
lexer=PygmentsLexer(BashLexer)
)
# Print welcome message
print(colored("Edison Interactive Shell", "blue", attrs=["bold"]))
print("Type your queries in natural language.")
print("Type '!help' for help, '!exit' or '!quit' to exit, or press Ctrl+D to exit.")
while True:
try:
# Get user input
query = session.prompt("edison> ")
# Handle special commands
if query.strip().lower() in ["!exit", "!quit", "exit", "quit"]:
break
elif query.strip() == "!help":
show_help()
continue
elif not query.strip():
continue
# Generate command
try:
# Use streaming if enabled in config
if config.get("streaming", True):
# Check if we should show the "Generating command: " prefix
if config.get("show_generating_prefix", True):
print(colored("Generating command: ", "yellow"), end='', flush=True)
try:
command = api_client.generate_command_streaming(
client, config, query, handle_streaming_output_interactive
)
print() # Add a newline after streaming is complete
except KeyboardInterrupt:
print("\nCommand generation cancelled.")
continue
else:
print(colored("Generating command...", "yellow"))
command = api_client.generate_command(client, config, query)
# Print command with appropriate styling based on config
ui_config = config.get("ui", {})
if ui_config.get("rich_formatting", True):
command_style = ui_config.get("command_style", "panel")
theme = ui_config.get("theme", "monokai")
shell = config.get("shell", "bash")
if command_style == "panel":
# Use rich panel style
from edison.utils.markdown_utils import print_command_rich
print_command_rich(command, shell=shell, theme=theme)
else:
# Use simple highlighting
print("Command: " + colored(command, 'blue'))
else:
# Use traditional styling
print("Command: " + colored(command, 'blue'))
# Ask user what to do with the same prompt as non-interactive mode
prompt_text = "Execute command? [Y]es [n]o [m]odify (prompt/command) [c]opy to clipboard [x]plain ==> "
if os_utils.missing_posix_display():
prompt_text = "Execute command? [Y]es [n]o [m]odify (prompt/command) [x]plain ==> "
action = session.prompt(prompt_text)
if action.lower() in ["y", "yes", "e", "execute", ""]:
# Execute command
shell = config.get("shell", os.environ.get("SHELL", "bash"))
try:
command_executor.execute_command(shell, command)
except Exception as e:
logger.error(f"Error executing command: {e}")
print(colored(f"Error executing command: {e}", 'red'))
elif action.lower() in ["m", "modify"]:
# Ask whether to modify prompt or command
mod_choice = session.prompt("Modify [p]rompt or [c]ommand? [c] ==> ")
if mod_choice.lower() in ["p", "prompt"]:
# Modify the prompt (natural language description)
new_prompt = session.prompt("Modified prompt: ")
if not new_prompt.strip():
print(colored("Empty prompt. Command execution cancelled.", "yellow"))
continue
# Generate new command from modified prompt
print(colored("Generating new command from modified prompt...", "yellow"))
# Use streaming if enabled
if config.get("streaming", True):
# Check if we should show the "Generating command: " prefix
if config.get("show_generating_prefix", True):
print(colored("Generating command: ", "yellow"), end='', flush=True)
try:
new_command = api_client.generate_command_streaming(
client, config, new_prompt, handle_streaming_output_interactive
)
print() # Add a newline after streaming is complete
except KeyboardInterrupt:
print("\nCommand generation cancelled.")
continue
else:
new_command = api_client.generate_command(client, config, new_prompt)
# Print the new command with styling
ui_config = config.get("ui", {})
if ui_config.get("rich_formatting", True):
command_style = ui_config.get("command_style", "panel")
theme = ui_config.get("theme", "monokai")
shell = config.get("shell", "bash")
if command_style == "panel":
# Use rich panel style
from edison.utils.markdown_utils import print_command_rich
print_command_rich(new_command, shell=shell, theme=theme)
else:
# Use simple highlighting
print("Command: " + colored(new_command, 'blue'))
else:
# Use traditional styling
print("Command: " + colored(new_command, 'blue'))
# Ask for confirmation
confirm = session.prompt("Execute this command? [Y/n] ")
if confirm.lower() in ["n", "no"]:
print(colored("Command execution cancelled.", "yellow"))
continue
# Execute the new command
shell = config.get("shell", os.environ.get("SHELL", "bash"))
try:
command_executor.execute_command(shell, new_command)
except Exception as e:
logger.error(f"Error executing command: {e}")
print(colored(f"Error executing command: {e}", 'red'))
else:
# Modify the command directly
modified_command = session.prompt("Modified command: ", default=command)
if not modified_command.strip():
print(colored("Empty command. Command execution cancelled.", "yellow"))
continue
# Print the modified command with styling
ui_config = config.get("ui", {})
if ui_config.get("rich_formatting", True):
command_style = ui_config.get("command_style", "panel")
theme = ui_config.get("theme", "monokai")
shell = config.get("shell", "bash")
if command_style == "panel":
# Use rich panel style
from edison.utils.markdown_utils import print_command_rich
print_command_rich(modified_command, shell=shell, theme=theme)
else:
# Use simple highlighting
print("Command: " + colored(modified_command, 'blue'))
else:
# Use traditional styling
print("Command: " + colored(modified_command, 'blue'))
# Ask for confirmation
confirm = session.prompt("Execute modified command? [Y/n] ")
if confirm.lower() in ["n", "no"]:
print(colored("Command execution cancelled.", "yellow"))
continue
# Execute the modified command
shell = config.get("shell", os.environ.get("SHELL", "bash"))
try:
command_executor.execute_command(shell, modified_command)
except Exception as e:
logger.error(f"Error executing command: {e}")
print(colored(f"Error executing command: {e}", 'red'))
elif action.lower() in ["c", "copy"]:
# Copy command to clipboard
if not os_utils.missing_posix_display():
try:
import pyperclip
pyperclip.copy(command)
print(colored("Command copied to clipboard.", "green"))
except Exception as e:
print(colored(f"Error copying to clipboard: {e}", "red"))
elif action.lower() in ["x", "explain"]:
# Explain command
print(colored("Generating explanation...", "yellow"))
# Get UI configuration
ui_config = config.get("ui", {})
structured = ui_config.get("structured_explanations", True)
use_rich = ui_config.get("rich_formatting", True)
# Generate explanation
explanation = get_command_explanation(client, command, structured=structured)
if use_rich:
print() # Add a blank line before the explanation
# Format the explanation with rich formatting
formatted_explanation = markdown_utils.format_command_explanation(explanation, use_rich=True)
print(formatted_explanation)
else:
print(colored("\nExplanation:", "green", attrs=["bold"]))
# Format the explanation with terminal-friendly markdown
formatted_explanation = markdown_utils.format_command_explanation(explanation)
print(formatted_explanation)
elif action.lower() in ["n", "no", "s", "skip"]:
# Skip
print(colored("Command skipped.", "yellow"))
else:
# Skip if input not recognized
print(colored("Command skipped - input not recognized.", "yellow"))
except Exception as e:
logger.error(f"Error: {e}")
print(colored(f"Error: {e}", "red"))
except KeyboardInterrupt:
continue
except EOFError:
break
print("Goodbye!")
def show_help():
"""
Show help information.
"""
help_text = """
Edison Interactive Shell Help
Commands:
!help - Show this help message
!exit, !quit - Exit the interactive shell
exit, quit - Exit the interactive shell
Ctrl+D - Exit the interactive shell
Actions:
y, yes, <enter> - Execute the generated command
n, no - Skip the command
m, modify - Modify the prompt or command before executing
When selected, you'll be asked whether to modify:
- [p]rompt: Change the natural language description
- [c]ommand: Directly edit the generated command
c, copy - Copy the command to clipboard
x, explain - Get an explanation of the command
"""
print(help_text)
+4
View File
@@ -0,0 +1,4 @@
"""
Utility functions for the Edison application.
"""
from . import markdown_utils
+71
View File
@@ -0,0 +1,71 @@
"""
Logging utilities for the Edison application.
"""
import logging
import os
import sys
def setup_logging(verbose=False):
"""
Configure logging for the application.
Args:
verbose: If True, enable DEBUG level messages. If False, show only WARNING and above.
Returns:
logging.Logger: The configured logger.
"""
# Create logs directory if it doesn't exist
script_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
logs_dir = os.path.join(script_dir, "logs")
os.makedirs(logs_dir, exist_ok=True)
# Determine log levels based on verbose flag
# Always save INFO+ logs to file, but console output depends on verbose flag
file_level = logging.INFO
console_level = logging.DEBUG if verbose else logging.WARNING
# Configure file and console handlers with different levels
file_handler = logging.FileHandler(os.path.join(logs_dir, 'edison.log'))
file_handler.setLevel(file_level)
console_handler = logging.StreamHandler(sys.stderr)
console_handler.setLevel(console_level)
# We need to set the root logger level to the lowest of our handlers
root_level = logging.DEBUG if verbose else logging.INFO
# Basic config with handlers
logging.basicConfig(
level=root_level,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[file_handler, console_handler]
)
# Always disable httpx and urllib3 debug logs unless in verbose mode
if not verbose:
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("urllib3").setLevel(logging.WARNING)
# Get the main application logger
logger = logging.getLogger('edison')
# Log setup confirmation at appropriate level
if verbose:
logger.debug("Verbose logging enabled (DEBUG level)")
else:
logger.info("Standard logging configuration applied (INFO to file, WARNING to console)")
return logger
def get_logger(name):
"""
Get a logger for the specified name.
Args:
name: The name of the logger.
Returns:
logging.Logger: The logger.
"""
return logging.getLogger(name)
+216
View File
@@ -0,0 +1,216 @@
"""
Markdown formatting utilities for terminal display.
"""
import re
import io
import sys
from termcolor import colored
from rich.console import Console
from rich.markdown import Markdown
from rich.syntax import Syntax
from rich.panel import Panel
def convert_markdown_to_terminal(text):
"""
Convert markdown formatting to terminal-friendly colored text.
Args:
text (str): Markdown text to convert
Returns:
str: Terminal-formatted text with ANSI color codes
"""
if not text:
return text
# Handle code blocks with triple backticks
text = re.sub(
r'```(?:\w+)?\n(.*?)\n```',
lambda m: '\n' + colored(m.group(1), 'white', attrs=['bold', 'dark']) + '\n',
text,
flags=re.DOTALL
)
# Handle inline code with single backticks
text = re.sub(
r'`([^`]+)`',
lambda m: colored(m.group(1), 'white', attrs=['bold']),
text
)
# Handle bold text with ** or __
text = re.sub(
r'\*\*([^*]+)\*\*|__([^_]+)__',
lambda m: colored(m.group(1) or m.group(2), attrs=['bold']),
text
)
# Handle italic text with * or _
text = re.sub(
r'\*([^*]+)\*|_([^_]+)_',
lambda m: colored(m.group(1) or m.group(2), attrs=['underline']),
text
)
# Handle headers
text = re.sub(
r'^#{1,6}\s+(.+)$',
lambda m: colored(m.group(1), 'cyan', attrs=['bold']),
text,
flags=re.MULTILINE
)
# Handle unordered lists
text = re.sub(
r'^(\s*[-*•]\s+)(.+)$',
lambda m: m.group(1) + colored(m.group(2), 'white'),
text,
flags=re.MULTILINE
)
# Handle ordered lists
text = re.sub(
r'^(\s*\d+\.\s+)(.+)$',
lambda m: m.group(1) + colored(m.group(2), 'white'),
text,
flags=re.MULTILINE
)
# Handle links [text](url) - keep only the text part
text = re.sub(
r'\[([^\]]+)\]\([^)]+\)',
lambda m: colored(m.group(1), 'blue', attrs=['underline']),
text
)
# Handle horizontal rules
text = re.sub(
r'^-{3,}$|^\*{3,}$|^_{3,}$',
lambda m: colored('-' * 50, 'white', attrs=['dark']),
text,
flags=re.MULTILINE
)
# Handle blockquotes
text = re.sub(
r'^>\s+(.+)$',
lambda m: colored('', 'cyan') + colored(m.group(1), 'cyan'),
text,
flags=re.MULTILINE
)
return text
def format_command_explanation(explanation, use_rich=False):
"""
Format a command explanation with appropriate terminal styling.
Args:
explanation (str): The explanation text in markdown format
use_rich (bool): Whether to use rich formatting
Returns:
str: Formatted explanation with terminal-friendly styling
"""
if use_rich:
return format_command_explanation_rich(explanation)
# First convert markdown to terminal format
formatted_text = convert_markdown_to_terminal(explanation)
# Add additional formatting for common command explanation patterns
# Highlight command parts and common technical terms
formatted_text = re.sub(
r'\b(command|option|flag|argument|parameter|switch)\b',
lambda m: colored(m.group(0), 'yellow'),
formatted_text,
flags=re.IGNORECASE
)
# Highlight file paths and patterns
# More specific pattern to avoid matching things like "Unix/Linux"
formatted_text = re.sub(
r'(?:^|\s)(/(?:[\w.-]+/?)+)(?=\s|$|[,.;:])', # Path must start with / and have at least one directory component
lambda m: colored(m.group(1), 'green'),
formatted_text
)
# Highlight command names and utilities
# Common Unix/Linux commands
formatted_text = re.sub(
r'\b(ls|grep|find|awk|sed|cat|cp|mv|rm|mkdir|chmod|chown|ps|kill|top|df|du|tar|gzip|ssh|scp|curl|wget)\b',
lambda m: colored(m.group(0), 'green'),
formatted_text
)
# Highlight "This command" starts of sentences
formatted_text = re.sub(
r'(^|[.!?]\s+)(This command|The command)',
lambda m: m.group(1) + colored(m.group(2), 'cyan', attrs=['bold']),
formatted_text
)
return formatted_text
def format_command_explanation_rich(explanation):
"""
Format a command explanation with rich formatting.
Args:
explanation (str): The explanation text in markdown format
Returns:
str: Rich-formatted explanation as a string
"""
# Capture the rich output as a string
str_io = io.StringIO()
console = Console(file=str_io, width=80)
# Create a markdown object
md = Markdown(explanation)
# Render in a panel with a title
panel = Panel(md, title="Command Explanation", border_style="green")
console.print(panel)
return str_io.getvalue()
def format_command_rich(command, shell="bash", theme="monokai"):
"""
Format a command with syntax highlighting using Rich.
Args:
command (str): The command to format
shell (str): The shell language for syntax highlighting
theme (str): The color theme to use
Returns:
str: Rich-formatted command as a string
"""
# Capture the rich output as a string
str_io = io.StringIO()
console = Console(file=str_io, width=80)
# Create a syntax object
syntax = Syntax(command, shell, theme=theme, word_wrap=True)
# Render in a panel with a title
panel = Panel(syntax, title="Command", border_style="blue")
console.print(panel)
return str_io.getvalue()
def print_command_rich(command, shell="bash", theme="monokai"):
"""
Print a command with syntax highlighting using Rich.
Args:
command (str): The command to print
shell (str): The shell language for syntax highlighting
theme (str): The color theme to use
"""
console = Console()
syntax = Syntax(command, shell, theme=theme, word_wrap=True)
panel = Panel(syntax, title="Command", border_style="blue")
console.print(panel)
+59
View File
@@ -0,0 +1,59 @@
"""
Operating system utilities for the Edison application.
"""
import os
import platform
import subprocess
import distro
def get_os_friendly_name():
"""
Returns a friendly name of the user's operating system.
The function retrieves the current system platform name using the `platform.system()` function.
For Linux, it appends the distribution name retrieved from `distro.name(pretty=True)` to give a
more descriptive representation. For Darwin (Apple's macOS), it appends "macOS" to "Darwin" to
make the output clearer to the user.
Returns:
str: A friendly name for the user's operating system. It will be one of the following:
- "Linux/<distribution name>"
- "Darwin/macOS"
- The system string returned by `platform.system()` if it's not Linux or Darwin.
"""
os_name = platform.system()
if os_name == "Linux":
os_name = "Linux/" + distro.name(pretty=True)
elif os_name == "Darwin":
os_name = "Darwin/macOS"
return os_name
def missing_posix_display():
"""
Checks if the DISPLAY environment variable is set in a POSIX-compliant shell.
This function runs a shell subprocess that outputs the value of the DISPLAY environment
variable. It then checks if this value is unset (i.e., equals a newline 'b'\\n'') in the
current shell environment. If the DISPLAY variable is unset, the function returns `True`
indicating a "missing" display; otherwise, it returns `False`.
Returns:
bool: `True` if the DISPLAY environment variable is unset or empty, `False` otherwise.
"""
if os.name != "posix":
return False
display = subprocess.check_output("echo $DISPLAY", shell=True)
return display == b'\n'
def get_default_shell():
"""
Get the default shell for the current operating system.
Returns:
str: The default shell.
"""
# Unix based SHELL (/bin/bash, /bin/zsh), otherwise assuming it's Windows
return os.environ.get("SHELL", "powershell.exe")
+60
View File
@@ -0,0 +1,60 @@
"""
Validation utilities for the Edison application.
"""
import re
import logging
logger = logging.getLogger(__name__)
def is_dangerous_command(command):
"""
Check if a command contains potentially dangerous operations.
Args:
command (str): The command to check.
Returns:
bool: True if the command is potentially dangerous, False otherwise.
"""
dangerous_patterns = [
r"rm\s+-rf\s+/", # Remove recursively from root
r"sudo\s+rm", # Sudo remove
r"chmod\s+777", # Chmod 777 (too permissive)
r">\s+/dev/", # Redirect to device
r">\s+/etc/", # Redirect to system config
r"mkfs", # Format filesystem
r"dd\s+if=", # Disk destroyer
r":(){:\|:};:", # Fork bomb
]
for pattern in dangerous_patterns:
if re.search(pattern, command, re.IGNORECASE):
logger.warning(f"Potentially dangerous command detected: {command}")
return True
return False
def check_for_issue(response):
"""
Checks the given response for any issues.
Args:
response (str): The response to check.
Returns:
bool: True if there's an issue, False otherwise.
"""
prefixes = ("sorry", "i'm sorry", "the question is not clear", "i'm", "i am")
return response.lower().startswith(prefixes)
def check_for_markdown(response):
"""
Checks for the presence of markdown formatting in the response.
Args:
response (str): The response to check.
Returns:
bool: True if markdown is detected, False otherwise.
"""
return response.count("```", 2)