386 lines
14 KiB
Python
Executable File
386 lines
14 KiB
Python
Executable File
"""
|
|
AI Chatbot to generate shell commands.
|
|
|
|
This script allows the user to ask their question in plain English and translates
|
|
that question into a command that can be run in the shell. The functionalities
|
|
include leveraging OpenAI's GPT models to generate command, verifying newly generated
|
|
commands, checking commands for any unsafe attributes, and allowing the user to
|
|
execute or modify the generated command.
|
|
|
|
This program is an implementation of an AI model used to assist users in
|
|
generating Unix/shell commands or other scripts, based on their natural language
|
|
input. The objective is to aid those users who might not remember the exact syntax
|
|
of every command or script they frequently use.
|
|
|
|
Sources:
|
|
— https://github.com/wunderwuzzi23/yolo-ai-cmdbot
|
|
"""
|
|
import os
|
|
import platform
|
|
import subprocess
|
|
import sys
|
|
|
|
import argparse
|
|
import distro
|
|
import dotenv
|
|
import openai
|
|
import pyperclip
|
|
import yaml
|
|
|
|
from termcolor import colored
|
|
from colorama import init
|
|
|
|
CONFIG_FILE = "yolo.yaml"
|
|
PROMPT_FILE = "yolo.prompt"
|
|
|
|
def read_yaml_config() -> any:
|
|
"""
|
|
Read the configuration file from the executing directory.
|
|
|
|
This function determines the execution folder (which may vary if an alias is set) in order to
|
|
find the configuration file. It reads the file and returns its content in a Python data
|
|
structure.
|
|
|
|
Returns:
|
|
The content of the configuration file. Could be dictionary, list, etc. depending on
|
|
the YAML file structure.
|
|
"""
|
|
yolo_path = os.path.abspath(__file__)
|
|
prompt_path = os.path.dirname(yolo_path)
|
|
|
|
config_file = os.path.join(prompt_path, CONFIG_FILE)
|
|
with open(config_file, 'r') as file:
|
|
return yaml.safe_load(file)
|
|
|
|
def set_openai_api_key(config):
|
|
"""
|
|
Set the OpenAI API key by attempting several methods.
|
|
|
|
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. It sets the `openai.api_key`
|
|
with the retrieved key.
|
|
|
|
Parameters:
|
|
config (dict): A dictionary containing configuration values.
|
|
It may contain `openai_api_key` as one of the keys.
|
|
"""
|
|
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>"`.
|
|
config["openai_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 openai.api_key: # Check this to avoid potential "invalid filepath" error.
|
|
home_path = os.path.expanduser("~")
|
|
openai.api_key_path = os.path.join(home_path, ".openai.apikey")
|
|
|
|
# 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 `yolo.yaml` config file, it would appear as
|
|
# `openai_apikey: <yourkey>`.
|
|
if not openai.api_key:
|
|
openai.api_key = config["openai_api_key"]
|
|
|
|
def print_config(config):
|
|
"""
|
|
Print config information.
|
|
|
|
Given an input configuration dictionary, this function prints out the
|
|
current configurations per yolo.yaml. This includes details on "model",
|
|
"temperature", "max_tokens", "safety", and "shell".
|
|
|
|
Parameters
|
|
----------
|
|
config : dict
|
|
A dictionary containing the various configuration parameters. It should have
|
|
the following keys: "model", "temperature", "max_tokens", "safety", "shell".
|
|
"""
|
|
print("Current configuration per yolo.yaml:")
|
|
print("— Model : " + str(config["model"]))
|
|
print("— Temperature : " + str(config["temperature"]))
|
|
print("— Max. Tokens : " + str(config["max_tokens"]))
|
|
print("— Safety : " + str(bool(config["safety"])))
|
|
print("— Shell : " + str(config["shell"]))
|
|
|
|
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 get_full_prompt(user_prompt, shell):
|
|
"""
|
|
Constructs a full prompt string by appending the user's prompt to a predefined prompt template
|
|
located in the PROMPT_FILE file.
|
|
|
|
The function finds the absolute path of the currently executing file, and based on this path,
|
|
identifies the directory of PROMPT_FILE. It reads this file, replaces placeholders {shell}
|
|
and {os} in the text file with a passed shell parameter and the friendly name of the operating
|
|
system respectively. The user prompt is then appended to this pre-prompt. If the resulting
|
|
prompt does not end with a question mark or a period, a question mark is added at last.
|
|
|
|
Parameters
|
|
----------
|
|
user_prompt : str
|
|
The prompt supplied by the user to be appended to the pre-prompt.
|
|
shell : str
|
|
The shell information to be inserted in the place of {shell} placeholder in PROMPT_FILE.
|
|
|
|
Returns
|
|
-------
|
|
str
|
|
The full prompt, constructed from the template prompt in PROMPT_FILE,
|
|
user-provided shell info, the OS name, and the user-supplied prompt string.
|
|
"""
|
|
yolo_path = os.path.abspath(__file__)
|
|
prompt_path = os.path.dirname(yolo_path)
|
|
|
|
## Load the prompt and prep it
|
|
prompt_file = os.path.join(prompt_path, PROMPT_FILE)
|
|
pre_prompt = open(prompt_file,"r").read()
|
|
pre_prompt = pre_prompt.replace("{shell}", shell)
|
|
pre_prompt = pre_prompt.replace("{os}", get_os_friendly_name())
|
|
prompt = pre_prompt + user_prompt
|
|
|
|
# Be nice and make it a question.
|
|
if prompt[-1:] != "?" and prompt[-1:] != ".":
|
|
prompt+="?"
|
|
|
|
return prompt
|
|
|
|
def call_open_ai(config, query):
|
|
"""
|
|
Do we have a prompt from the user?
|
|
"""
|
|
if query == "":
|
|
print ("No user prompt specified.")
|
|
sys.exit(-1)
|
|
|
|
# Load the correct prompt based on shell and OS and append the user's prompt.
|
|
prompt = get_full_prompt(query, config["shell"])
|
|
|
|
# Make the first line also the system prompt
|
|
system_prompt = prompt[1]
|
|
#print(prompt)
|
|
|
|
# Call the ChatGPT API
|
|
response = openai.ChatCompletion.create(
|
|
model=config["model"],
|
|
messages=[
|
|
{"role": "system", "content": system_prompt},
|
|
{"role": "user", "content": prompt}
|
|
],
|
|
temperature=config["temperature"],
|
|
max_tokens=config["max_tokens"],
|
|
)
|
|
|
|
return response.choices[0].message.content.strip()
|
|
|
|
def check_for_issue(response):
|
|
"""
|
|
Checks the given response for any issues and raise an error when detected.
|
|
|
|
The function checks if the supplied text response begins with any of a set of predefined
|
|
prefixes, which indicate a problem with the response. If such a prefix is found, an error
|
|
message is printed to the console in red, and the program exits with a -1 status code.
|
|
|
|
Parameters
|
|
----------
|
|
response : str
|
|
A response text string that needs to be examined for any issues.
|
|
"""
|
|
prefixes = ("sorry", "i'm sorry", "the question is not clear", "i'm", "i am")
|
|
if response.lower().startswith(prefixes):
|
|
print(colored("There was an issue: "+response, 'red'))
|
|
sys.exit(-1)
|
|
|
|
def check_for_markdown(response):
|
|
"""
|
|
Checks for the presence of markdown formatting (specifically, code snippet markdown) in the
|
|
provided response.
|
|
|
|
This function considers the presence of markdown formatting (specifically, code block
|
|
formatting marked by ```) in the `response` as an "odd corner case". If such a case is
|
|
detected, it prints an error message in red, along with the markdown-contained response, and
|
|
then terminates the program with a -1 status code.
|
|
|
|
Parameters
|
|
----------
|
|
response : str
|
|
A response text string that needs to be examined for markdown formatting.
|
|
"""
|
|
if response.count("```",2):
|
|
print(colored(
|
|
"The proposed command contains markdown, response not executed directly: \n", 'red'
|
|
) + response)
|
|
sys.exit(-1)
|
|
|
|
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.
|
|
"""
|
|
display = subprocess.check_output("echo $DISPLAY", shell=True)
|
|
|
|
return display == b'\n'
|
|
|
|
def prompt_user_input(config, response):
|
|
"""
|
|
Print the command proposal in blue and prompt the user for next action based on the safety
|
|
configuration.
|
|
|
|
The user is given options to execute, modify, or copy the command to clipboard if the safety
|
|
configuration is enabled (config["safety"] = True). If the safety configuration is off
|
|
(config["safety"] = False), the function automatically assumes an execution action ('Y' for
|
|
Yes). In a POSIX-compliant shell with no display available (checked using
|
|
`missing_posix_display()`), the 'copy to clipboard' option is omitted.
|
|
|
|
Parameters
|
|
----------
|
|
config : dict
|
|
The system configurations dictionary which contains a "safety" key
|
|
to determine user prompt options.
|
|
response : str
|
|
The proposed command which is to be printed and may be executed by the user.
|
|
"""
|
|
print("Command: " + colored(response, 'blue'))
|
|
|
|
if config["safety"]:
|
|
prompt_text = "Execute command? [Y]es [n]o [m]odify [c]opy to clipboard ==> "
|
|
|
|
if os.name == "posix" and missing_posix_display():
|
|
prompt_text = "Execute command? [Y]es [n]o [m]odify ==> "
|
|
|
|
print(prompt_text, end = '')
|
|
|
|
user_input = input()
|
|
else:
|
|
user_input = "Y"
|
|
|
|
return user_input
|
|
|
|
def evaluate_input(config, user_input, command):
|
|
"""
|
|
Evaluate the user input to either execute, modify, or copy the command.
|
|
|
|
Based on the user's response, this function takes action:
|
|
- If the user response is 'Y' or blank, the given command gets executed in the shell.
|
|
- If the user response is 'M', user can modify the command and the modified command is executed
|
|
recursively.
|
|
- If the user response is 'C', the command is copied to the clipboard.
|
|
|
|
Parameters
|
|
----------
|
|
config : dict
|
|
The system configurations dictionary. It should contain a "shell" key specifying the shell
|
|
environment.
|
|
user_input : str
|
|
The user response which determines the course of action. It can be 'Y', 'n', 'm', 'c',
|
|
or '' (empty string).
|
|
command : str
|
|
The command which is either executed, modified, or copied to clipboard.
|
|
"""
|
|
if user_input.upper() == "Y" or user_input == "":
|
|
if config["shell"] == "powershell.exe":
|
|
subprocess.run([config["shell"], "/c", command], shell=False, check=True)
|
|
else:
|
|
# Unix: /bin/bash /bin/zsh: uses -c both Ubuntu and macOS should work, others might not
|
|
subprocess.run([config["shell"], "-c", command], shell=False, check=True)
|
|
|
|
if user_input.upper() == "M":
|
|
print("Modify prompt: ", end = '')
|
|
modded_query = input()
|
|
modded_response = call_open_ai(config, modded_query)
|
|
check_for_issue(modded_response)
|
|
check_for_markdown(modded_response)
|
|
modded_user_input = prompt_user_input(config, modded_response)
|
|
print()
|
|
evaluate_input(config, modded_user_input, modded_response)
|
|
|
|
if user_input.upper() == "C":
|
|
if os.name == "posix" and missing_posix_display():
|
|
return
|
|
pyperclip.copy(command)
|
|
print("Copied command to clipboard.")
|
|
|
|
|
|
def main():
|
|
"""
|
|
Defined starting point of source code.
|
|
"""
|
|
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')
|
|
args = parser.parse_args()
|
|
|
|
# Load configuration
|
|
config = read_yaml_config()
|
|
set_openai_api_key(config)
|
|
|
|
# Process parameters
|
|
user_prompt = " ".join(args.text)
|
|
|
|
if args.safety:
|
|
config["safety"] = args.safety
|
|
|
|
# Unix based SHELL (/bin/bash, /bin/zsh), otherwise assuming it's Windows
|
|
config["shell"] = os.environ.get("SHELL", "powershell.exe")
|
|
|
|
if args.config:
|
|
print_config(config)
|
|
|
|
# Enable color output on Windows using colorama
|
|
init()
|
|
|
|
res_command = call_open_ai(config, user_prompt)
|
|
check_for_issue(res_command)
|
|
check_for_markdown(res_command)
|
|
user_input = prompt_user_input(config, res_command)
|
|
print()
|
|
evaluate_input(config, user_input, res_command)
|
|
|
|
if __name__ == "__main__":
|
|
main()
|