├── LICENSE ├── .gitignore ├── cmd ├── aicompgraph │ ├── main.go │ └── test_comp.yaml ├── aichat │ └── main.go ├── aicmd │ └── main.go └── aifix │ └── main.go ├── internal ├── config │ ├── config_test.go │ └── config.go ├── utils │ ├── utils_test.go │ └── utils.go ├── aicompgraph │ └── aicompgraph.go ├── aicmd │ ├── aicmd_test.go │ └── aicmd.go ├── nlp │ └── nlp.go ├── aichat │ └── aichat.go └── aifix │ └── aifix.go ├── config ├── config.yaml ├── prompt.txt ├── puml-prompt.txt ├── comp-graph-prompt.txt ├── aifix-prompt.txt └── chat-prompt.txt ├── scripts ├── increment_version.sh └── install.sh ├── justfile ├── go.mod ├── AIFIX_SETUP.md ├── README.md └── go.sum /LICENSE: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .aider* 2 | .env 3 | 4 | # Compiled binaries 5 | aicmd 6 | aichat 7 | aicompgraph 8 | aicodereview 9 | aifix 10 | 11 | # Test files 12 | test_sample.go 13 | -------------------------------------------------------------------------------- /cmd/aicompgraph/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/piotr1215/aicmdtools/internal/aicompgraph" 8 | ) 9 | 10 | var version = "v0.0.1" 11 | 12 | func main() { 13 | 14 | err := aicompgraph.Execute() 15 | if err != nil { 16 | fmt.Printf("Error: %v\n", err) 17 | os.Exit(-1) 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /internal/config/config_test.go: -------------------------------------------------------------------------------- 1 | package config_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/piotr1215/aicmdtools/internal/config" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestReadAndParseConfig(t *testing.T) { 11 | conf, prompt, err := config.ReadAndParseConfig("config.yaml", "comp-graph-prompt.txt") 12 | assert.NoError(t, err) 13 | assert.NotNil(t, conf) 14 | assert.NotEmpty(t, prompt) 15 | } 16 | -------------------------------------------------------------------------------- /cmd/aichat/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/piotr1215/aicmdtools/internal/aichat" 9 | ) 10 | 11 | var version = "v0.0.1" 12 | 13 | func main() { 14 | 15 | if len(os.Getenv("DEBUG")) > 0 { 16 | f, err := tea.LogToFile("debug.log", "debug") 17 | if err != nil { 18 | fmt.Println("fatal:", err) 19 | os.Exit(1) 20 | } 21 | defer f.Close() 22 | } 23 | 24 | err := aichat.Execute() 25 | if err != nil { 26 | fmt.Printf("Error: %v\n", err) 27 | os.Exit(-1) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /config/config.yaml: -------------------------------------------------------------------------------- 1 | # Provider: "openai" or "anthropic" 2 | provider: anthropic 3 | 4 | # Model: For OpenAI use gpt-4, gpt-3.5-turbo, etc. For Anthropic use claude-sonnet-4-5-20250929, claude-3-5-sonnet-20241022, etc. 5 | model: claude-sonnet-4-5-20250929 6 | 7 | temperature: 0 8 | max_tokens: 4000 9 | 10 | # Safety: If set to False, commands returned from the AI will be run *without* prompting the user. 11 | safety: true 12 | 13 | # API Keys (optional): Keys can also be provided via environment variables (OPENAI_API_KEY, ANTHROPIC_API_KEY) or .env file 14 | openai_api_key: 15 | anthropic_api_key: 16 | -------------------------------------------------------------------------------- /scripts/increment_version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | version_file="./cmd/aicmd/main.go" 4 | 5 | # Get the current version from the version.go file 6 | current_version=$(grep -oP 'version = "\K[^"]+' $version_file) 7 | 8 | # Increment the version number 9 | IFS='.' read -ra version_parts <<<"$current_version" 10 | ((version_parts[2]++)) 11 | new_version="${version_parts[0]}.${version_parts[1]}.${version_parts[2]}" 12 | 13 | # Update the version.go file with the new version number 14 | sed -i "s/version = \"$current_version\"/version = \"$new_version\"/g" $version_file 15 | 16 | # Print the new version 17 | echo "Updated version: $new_version" 18 | -------------------------------------------------------------------------------- /internal/utils/utils_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "io/ioutil" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | type stubFileOpener struct{} 10 | 11 | func (s *stubFileOpener) Open(name string) (FileReaderCloser, error) { 12 | content := "This is a test file." 13 | return ioutil.NopCloser(strings.NewReader(content)), nil 14 | } 15 | 16 | func TestReadFile(t *testing.T) { 17 | fileReader := &FileReader{ 18 | FilePathFunc: func() string { return "testfile.txt" }, 19 | FileOpener: &stubFileOpener{}, 20 | } 21 | 22 | content := fileReader.ReadFile() 23 | expectedContent := "This is a test file." 24 | 25 | if content != expectedContent { 26 | t.Errorf("Expected content: %s, got: %s", expectedContent, content) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /scripts/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | CONFIG_DIR="$HOME/.config/aicmdtools" 6 | SRC_DIR="$(pwd)" 7 | CONFIG_FILES_DIR="${SRC_DIR}/config" 8 | 9 | echo "Creating configuration directory..." 10 | mkdir -p "${CONFIG_DIR}" 11 | 12 | file_list=$(/usr/bin/ls ${CONFIG_FILES_DIR} | sed 's/^/- /') 13 | echo -e "Copying:\n${file_list}\nto ${CONFIG_DIR} ..." 14 | 15 | cp "${CONFIG_FILES_DIR}/config.yaml" "${CONFIG_DIR}/config.yaml" 16 | cp "${CONFIG_FILES_DIR}/prompt.txt" "${CONFIG_DIR}/prompt.txt" 17 | cp "${CONFIG_FILES_DIR}/chat-prompt.txt" "${CONFIG_DIR}/chat-prompt.txt" 18 | cp "${CONFIG_FILES_DIR}/comp-graph-prompt.txt" "${CONFIG_DIR}/comp-graph-prompt.txt" 19 | cp "${CONFIG_FILES_DIR}/aifix-prompt.txt" "${CONFIG_DIR}/aifix-prompt.txt" 20 | 21 | echo "Configuration files have been copied to ${CONFIG_DIR}" 22 | echo "Installation complete!" 23 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | default: 2 | just --list 3 | 4 | test: 5 | go test ./... -v 6 | 7 | install: build_cli build_graph build_chat build_fix 8 | @cd cmd/aicmd && go install 9 | @cd cmd/aicompgraph && go install 10 | @cd cmd/aichat && go install 11 | @cd cmd/aifix && go install 12 | 13 | copy_files: 14 | @./scripts/install.sh 15 | 16 | build_graph: 17 | @./scripts/increment_version.sh 18 | @go build -ldflags "-X main.version=$(grep -oP 'version = "\K[^"]+' ./cmd/aicompgraph/main.go)" -o aicompgraph cmd/aicompgraph/main.go 19 | 20 | build_cli: 21 | @./scripts/increment_version.sh 22 | @go build -ldflags "-X main.version=$(grep -oP 'version = "\K[^"]+' ./cmd/aicmd/main.go)" -o aicmd cmd/aicmd/main.go 23 | 24 | build_chat: 25 | @./scripts/increment_version.sh 26 | @go build -ldflags "-X main.version=$(grep -oP 'version = "\K[^"]+' ./cmd/aichat/main.go)" -o aichat cmd/aichat/main.go 27 | 28 | build_fix: 29 | @./scripts/increment_version.sh 30 | @go build -ldflags "-X main.version=$(grep -oP 'version = "\K[^"]+' ./cmd/aifix/main.go)" -o aifix cmd/aifix/main.go 31 | -------------------------------------------------------------------------------- /config/prompt.txt: -------------------------------------------------------------------------------- 1 | Act as a natural language to {shell} command translation engine on {os}. 2 | 3 | You are an expert in {shell} on {os} and translate the question at the end to valid syntax. 4 | 5 | Follow these rules: 6 | Construct valid {shell} command that solve the question 7 | Leverage help and man pages to ensure valid syntax and an optimal solution 8 | Be concise 9 | Just show the commands 10 | Return only plaintext 11 | Only show a single answer, but you can always chain commands together 12 | Think step by step 13 | Only create valid syntax (you can use comments if it makes sense) 14 | If python is installed you can use it to solve problems 15 | if python3 is installed you can use it to solve problems 16 | Even if there is a lack of details, attempt to find the most logical solution by going about it step by step 17 | Do not return multiple solutions 18 | Do not show html, styled, colored formatting 19 | Do not creating invalid syntax 20 | Do not add unnecessary text in the response 21 | Do not add notes or intro sentences 22 | Do not show multiple distinct solutions to the question 23 | Do not add explanations on what the commands do 24 | Do not return what the question was 25 | Do not repeat or paraphrase the question in your response 26 | Do not cause syntax errors 27 | Do not rush to a conclusion 28 | 29 | 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. 30 | 31 | Question: 32 | -------------------------------------------------------------------------------- /config/puml-prompt.txt: -------------------------------------------------------------------------------- 1 | Act as a diagrams as code expert. 2 | 3 | You are an expert in PlantUML converting text into various types of readable, succinct and correct PlantUML diagrams. 4 | 5 | Follow these rules: 6 | Construct a valid PlantUML diagram from the text provided. 7 | Choose diagram type best suited for the task provided unless you are specifically prompted to select a specific diagram type. 8 | Be descriptive and concise in your response. 9 | Just show the diagrams code. 10 | Return only diagrams code in raw form. 11 | Only show a single answer. 12 | Think step by step 13 | Only create valid syntax (you can use comments if it makes sense) 14 | Even if there is a lack of details, attempt to find the most logical solution by going about it step by step 15 | Use theme !theme materia-outline as default 16 | Add descriptions and notes where approproate to improve diagram readability 17 | Do not return multiple solutions 18 | Do not show html, styled, colored formatting 19 | Do not create invalid syntax 20 | Do not add unnecessary text in the response 21 | Do not add notes or intro sentences 22 | Do not show multiple distinct solutions to the question 23 | Do not return what the question was 24 | Do not repeat or paraphrase the question in your response 25 | Do not cause syntax errors 26 | Do not rush to a conclusion 27 | Do not use any !includerul statements 28 | Do not use any !define statements if they refer to extenal URLs 29 | 30 | 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. 31 | 32 | Task: 33 | -------------------------------------------------------------------------------- /cmd/aicmd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | 9 | "github.com/piotr1215/aicmdtools/internal/aicmd" 10 | "github.com/piotr1215/aicmdtools/internal/config" 11 | "github.com/piotr1215/aicmdtools/internal/utils" 12 | ) 13 | 14 | var version = "v0.0.218" 15 | var prompt_file = "prompt.txt" 16 | 17 | // main is the entry point for the Goai command-line tool. 18 | // It parses command-line flags and executes appropriate actions. 19 | // If the "version" flag is set, it displays the version information and changelog. 20 | // If the "version" flag is not set, it executes the command specified in the "prompt.txt" file. 21 | // If an error occurs during execution, it prints an error message and exits with a non-zero status code. 22 | func main() { 23 | versionFlag := flag.Bool("version", false, "Display version information") 24 | modelFlag := flag.Bool("model", false, "Display current model") 25 | flag.Parse() 26 | 27 | if *modelFlag { 28 | conf, _, err := config.ReadAndParseConfig("config.yaml", prompt_file) 29 | if err != nil { 30 | fmt.Printf("Error reading configuration: %v\n", err) 31 | os.Exit(-1) 32 | } 33 | fmt.Printf("Current model: %s\n", conf.Model) 34 | return 35 | } 36 | 37 | if *versionFlag { 38 | fmt.Printf("Goai version: %s\n", version) 39 | changelog, err := utils.GenerateChangelog(exec.Command) 40 | if err != nil { 41 | fmt.Printf("Error generating changelog: %v\n", err) 42 | } else { 43 | fmt.Printf("\nChangelog:\n%s", changelog) 44 | } 45 | return 46 | } 47 | err := aicmd.Execute(prompt_file) 48 | if err != nil { 49 | fmt.Printf("Error executing command: %v\n", err) 50 | os.Exit(-1) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "os/user" 7 | "path/filepath" 8 | 9 | "github.com/piotr1215/aicmdtools/internal/utils" 10 | "gopkg.in/yaml.v3" 11 | ) 12 | 13 | type Config struct { 14 | Provider string `yaml:"provider"` // "openai" or "anthropic" 15 | Model string `yaml:"model"` 16 | Temperature float64 `yaml:"temperature"` 17 | MaxTokens int `yaml:"max_tokens"` 18 | Safety bool `yaml:"safety"` 19 | OpenAI_APIKey string `yaml:"openai_api_key"` 20 | Anthropic_APIKey string `yaml:"anthropic_api_key"` 21 | } 22 | 23 | func ReadAndParseConfig(configFilename, promptFilename string) (*Config, string, error) { 24 | configReader := &utils.FileReader{ 25 | FilePathFunc: func() string { return ConfigFilePath(configFilename) }, 26 | } 27 | configContent := configReader.ReadFile() 28 | conf := ParseConfig(configContent) 29 | 30 | promptReader := &utils.FileReader{ 31 | FilePathFunc: func() string { return ConfigFilePath(promptFilename) }, 32 | } 33 | prompt := promptReader.ReadFile() 34 | 35 | return &conf, prompt, nil 36 | } 37 | func ConfigFilePath(filename string) string { 38 | homeDir := os.Getenv("HOME") 39 | if homeDir == "" { 40 | usr, err := user.Current() 41 | if err != nil { 42 | log.Fatal(err) 43 | } 44 | homeDir = usr.HomeDir 45 | } 46 | 47 | configDir := filepath.Join(homeDir, ".config", "aicmdtools") 48 | return filepath.Join(configDir, filename) 49 | } 50 | 51 | func ParseConfig(configContent string) Config { 52 | var config Config 53 | err := yaml.Unmarshal([]byte(configContent), &config) 54 | if err != nil { 55 | log.Fatal(err) 56 | } 57 | return config 58 | } 59 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/piotr1215/aicmdtools 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.5 6 | 7 | require ( 8 | github.com/anthropics/anthropic-sdk-go v1.16.0 9 | github.com/atotto/clipboard v0.1.4 10 | github.com/joho/godotenv v1.5.1 11 | github.com/sashabaranov/go-openai v1.29.0 12 | github.com/stretchr/testify v1.8.4 13 | gopkg.in/yaml.v3 v3.0.1 14 | ) 15 | 16 | require ( 17 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 18 | github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect 19 | github.com/dlclark/regexp2 v1.4.0 // indirect 20 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 21 | github.com/mattn/go-isatty v0.0.18 // indirect 22 | github.com/mattn/go-localereader v0.0.1 // indirect 23 | github.com/mattn/go-runewidth v0.0.14 // indirect 24 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect 25 | github.com/muesli/cancelreader v0.2.2 // indirect 26 | github.com/muesli/reflow v0.3.0 // indirect 27 | github.com/muesli/termenv v0.15.2 // indirect 28 | github.com/rivo/uniseg v0.2.0 // indirect 29 | github.com/tidwall/gjson v1.18.0 // indirect 30 | github.com/tidwall/match v1.1.1 // indirect 31 | github.com/tidwall/pretty v1.2.1 // indirect 32 | github.com/tidwall/sjson v1.2.5 // indirect 33 | golang.org/x/sync v0.16.0 // indirect 34 | golang.org/x/sys v0.34.0 // indirect 35 | golang.org/x/term v0.6.0 // indirect 36 | golang.org/x/text v0.27.0 // indirect 37 | ) 38 | 39 | require ( 40 | github.com/alecthomas/chroma v0.10.0 41 | github.com/charmbracelet/bubbles v0.16.1 42 | github.com/charmbracelet/bubbletea v0.24.2 43 | github.com/charmbracelet/lipgloss v0.8.0 44 | github.com/davecgh/go-spew v1.1.1 // indirect 45 | github.com/mitchellh/go-wordwrap v1.0.1 46 | github.com/pmezard/go-difflib v1.0.0 // indirect 47 | ) 48 | -------------------------------------------------------------------------------- /config/comp-graph-prompt.txt: -------------------------------------------------------------------------------- 1 | Act as a diagrams as code expert. 2 | 3 | You are an expert in PlantUML converting crossplane compositions of definitions (xrd) into readable, succinct and correct PlantUML component diagrams. You put emphasis on correctness of the diagrams. 4 | 5 | Follow these rules: 6 | Construct a valid PlantUML component diagram from the composition or definition(xrd) provided. 7 | Just show the diagrams code. 8 | Create only valid diagrams. 9 | If the kind equals to CompositeResourceDefinition, then show the diagram for the elements under spec.schema.openAPIV3Schema only 10 | Do not use notes 11 | Create only component diagrams 12 | Always use only component diagram 13 | Always use !pragma layout elk directive 14 | If the kind equals Composition name the diagrame as the spec.compositeTypeRef.kind and include only fields from spec.resources and below 15 | Avoid square brackets [] in relations descriptions and notes 16 | Create separate object for every resources.base 17 | Return only diagrams code in raw form. 18 | Only show a single answer. 19 | Only create valid syntax 20 | Even if there is a lack of details, attempt to find the most logical solution by going about it step by step 21 | Use theme !theme materia-outline as default 22 | Add descriptions and notes where approproate to improve diagram readability 23 | Do not return multiple solutions 24 | Do not add unnecessary text in the response 25 | Do not show multiple distinct solutions to the question 26 | Do not return what the question was 27 | Do not repeat or paraphrase the question in your response 28 | Do not use any !includerul statements 29 | Do not use any !define statements if they refer to extenal URLs 30 | 31 | 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. 32 | 33 | Diagram: 34 | -------------------------------------------------------------------------------- /cmd/aicompgraph/test_comp.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apiextensions.crossplane.io/v1 2 | kind: Composition 3 | metadata: 4 | name: compositeclusters.aws.platformref.crossplane.io 5 | spec: 6 | writeConnectionSecretsToNamespace: upbound-system 7 | compositeTypeRef: 8 | apiVersion: aws.platformref.crossplane.io/v1alpha1 9 | kind: CompositeCluster 10 | resources: 11 | - base: 12 | apiVersion: aws.platformref.crossplane.io/v1alpha1 13 | kind: EKS 14 | connectionDetails: 15 | - fromConnectionSecretKey: kubeconfig 16 | name: compositeClusterEKS 17 | patches: 18 | - fromFieldPath: spec.id 19 | toFieldPath: spec.id 20 | - fromFieldPath: spec.id 21 | toFieldPath: metadata.annotations[crossplane.io/external-name] 22 | - fromFieldPath: metadata.uid 23 | toFieldPath: spec.writeConnectionSecretToRef.name 24 | transforms: 25 | - type: string 26 | string: 27 | fmt: "%s-eks" 28 | - fromFieldPath: spec.writeConnectionSecretToRef.namespace 29 | toFieldPath: spec.writeConnectionSecretToRef.namespace 30 | - fromFieldPath: spec.parameters.nodes.count 31 | toFieldPath: spec.parameters.nodes.count 32 | - fromFieldPath: spec.parameters.nodes.size 33 | toFieldPath: spec.parameters.nodes.size 34 | - fromFieldPath: spec.parameters.networkRef.id 35 | toFieldPath: spec.parameters.networkRef.id 36 | - base: 37 | apiVersion: aws.platformref.crossplane.io/v1alpha1 38 | kind: Services 39 | name: compositeClusterServices 40 | patches: 41 | - fromFieldPath: spec.id 42 | toFieldPath: spec.providerConfigRef.name 43 | - fromFieldPath: spec.parameters.services.operators.prometheus.version 44 | toFieldPath: spec.operators.prometheus.version 45 | -------------------------------------------------------------------------------- /config/aifix-prompt.txt: -------------------------------------------------------------------------------- 1 | You are an expert system administrator and developer helping users debug command-line errors. 2 | 3 | When given a command error, you should: 4 | 5 | 1. **Identify the root cause** - What actually went wrong? 6 | 2. **Provide the fix** - Give the exact command or action to resolve it 7 | 3. **Explain why** - Help the user understand the issue 8 | 9 | Format your response EXACTLY like this: 10 | 11 | Problem: [Brief description of what's wrong] 12 | → [More detailed explanation of the issue] 13 | 14 | Fix: 15 | [Exact command or action to take] 16 | 17 | Why: [Brief explanation of the underlying cause] 18 | 19 | IMPORTANT GUIDELINES: 20 | - Be concise and actionable 21 | - Provide exact commands that can be copy-pasted 22 | - Focus on the ROOT CAUSE, not symptoms 23 | - If multiple solutions exist, show the most common/safest one first 24 | - For security/permission errors, suggest safe solutions (avoid unnecessary sudo) 25 | - Detect the environment (Python venv, npm, Go modules, etc.) and suggest appropriate commands 26 | - If the error is ambiguous, state your assumptions clearly 27 | 28 | EXAMPLES: 29 | 30 | Input: "go build: undefined: fmt.Println" 31 | Output: 32 | Problem: Missing import statement 33 | → You're using fmt.Println but haven't imported the 'fmt' package 34 | 35 | Fix: 36 | Add to the top of your Go file: import "fmt" 37 | 38 | Why: Go requires explicit imports for all packages used 39 | 40 | --- 41 | 42 | Input: "docker: Cannot connect to the Docker daemon" 43 | Output: 44 | Problem: Docker daemon not running 45 | → The Docker service is not currently active on your system 46 | 47 | Fix: 48 | sudo systemctl start docker 49 | 50 | Why: Docker requires its background service to be running before you can use docker commands 51 | 52 | --- 53 | 54 | Input: "npm ERR! code ENOENT" 55 | Output: 56 | Problem: Missing package.json or node_modules 57 | → npm cannot find required files in the current directory 58 | 59 | Fix: 60 | If starting new project: npm init 61 | If project exists: npm install 62 | 63 | Why: npm commands require a package.json file to know what dependencies to install 64 | -------------------------------------------------------------------------------- /AIFIX_SETUP.md: -------------------------------------------------------------------------------- 1 | # aifix - Shell Integration Setup 2 | 3 | `aifix` can automatically detect command errors when integrated with your shell. 4 | 5 | ## Quick Setup (ZSH) 6 | 7 | 1. **Generate the integration code:** 8 | ```bash 9 | aifix -init-shell zsh 10 | ``` 11 | 12 | 2. **Add to your ~/.zshrc:** 13 | ```bash 14 | aifix -init-shell zsh >> ~/.zshrc 15 | ``` 16 | 17 | 3. **Reload your shell:** 18 | ```bash 19 | source ~/.zshrc 20 | ``` 21 | 22 | 4. **Test it:** 23 | ```bash 24 | $ ls -pap 25 | exa: Unknown argument -p 26 | $ fix 27 | ``` 28 | 29 | ## Manual Setup (if you prefer to review first) 30 | 31 | Run `aifix -init-shell zsh` and manually copy the output to your `~/.zshrc`. 32 | 33 | The integration adds: 34 | - Error capture to `/tmp/aifix_error_$$` 35 | - Command tracking to `/tmp/aifix_cmd_$$` 36 | - Alias `fix` for quick access 37 | - Auto-cleanup on shell exit 38 | 39 | ## How It Works 40 | 41 | 1. **`preexec`** - Captures command before execution 42 | 2. **`precmd`** - Checks exit code after execution 43 | 3. **`exec 2>`** - Redirects stderr to capture file 44 | 4. **`aifix`** - Reads captured error and analyzes it 45 | 46 | ## Usage After Setup 47 | 48 | ### Automatic (with integration): 49 | ```bash 50 | $ go build 51 | # error: undefined: fmt.Println 52 | $ fix 53 | → Instant AI-powered fix suggestion 54 | ``` 55 | 56 | ### Manual (always works): 57 | ```bash 58 | $ aifix "your error message here" 59 | ``` 60 | 61 | ## Troubleshooting 62 | 63 | **Q: "aifix says no error detected"** 64 | A: Make sure you added the integration to ~/.zshrc and reloaded your shell 65 | 66 | **Q: "stderr capture interferes with other tools"** 67 | A: Comment out the `exec 2>` line in your ~/.zshrc 68 | 69 | **Q: "I want to disable it temporarily"** 70 | A: `unset AIFIX_ERROR_FILE AIFIX_CMD_FILE AIFIX_LAST_EXIT` 71 | 72 | ## Other Shells 73 | 74 | - **Bash:** `aifix -init-shell bash >> ~/.bashrc` 75 | - **Fish:** `aifix -init-shell fish >> ~/.config/fish/config.fish` 76 | 77 | ## Uninstall Integration 78 | 79 | Remove the aifix section from your shell config file and reload: 80 | ```bash 81 | # Edit ~/.zshrc and remove the aifix section 82 | source ~/.zshrc 83 | ``` 84 | -------------------------------------------------------------------------------- /internal/aicompgraph/aicompgraph.go: -------------------------------------------------------------------------------- 1 | package aicompgraph 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "io/ioutil" 7 | "os" 8 | "os/exec" 9 | 10 | "github.com/piotr1215/aicmdtools/internal/config" 11 | "github.com/piotr1215/aicmdtools/internal/nlp" 12 | "github.com/piotr1215/aicmdtools/internal/utils" 13 | ) 14 | 15 | var version = "v0.0.1" 16 | var prompt_file = "comp-graph-prompt.txt" 17 | 18 | func Execute() error { 19 | 20 | versionFlag := flag.Bool("version", false, "Display version information") 21 | fileFlag := flag.String("f", "", "Path to YAML file") 22 | 23 | if *versionFlag { 24 | fmt.Printf("aicompgraph version: %s\n", version) 25 | changelog, err := utils.GenerateChangelog(exec.Command) 26 | if err != nil { 27 | fmt.Printf("Error generating changelog: %v\n", err) 28 | } else { 29 | fmt.Printf("\nChangelog:\n%s", changelog) 30 | } 31 | return err 32 | } 33 | 34 | flag.Parse() 35 | if *fileFlag == "" { 36 | fmt.Println("Error: No YAML file path specified. Use the -f flag to provide a file path.") 37 | os.Exit(-1) 38 | } 39 | 40 | yamlFilePath := *fileFlag 41 | yamlFileContent, err := ioutil.ReadFile(yamlFilePath) 42 | if err != nil { 43 | fmt.Printf("Error reading YAML file: %v\n", err) 44 | os.Exit(-1) 45 | } 46 | 47 | userPrompt := string(yamlFileContent) 48 | 49 | configReader := &utils.FileReader{ 50 | FilePathFunc: func() string { return config.ConfigFilePath("config.yaml") }, 51 | } 52 | configContent := configReader.ReadFile() 53 | conf := config.ParseConfig(configContent) 54 | 55 | promptReader := &utils.FileReader{ 56 | FilePathFunc: func() string { return config.ConfigFilePath(prompt_file) }, 57 | } 58 | prompt := promptReader.ReadFile() 59 | operating_system, shell := utils.DetectOSAndShell() 60 | prompt = utils.ReplacePlaceholders(prompt, operating_system, shell) 61 | 62 | client := nlp.CreateOpenAIClient(conf) 63 | 64 | aiClient := nlp.GoaiClient{ 65 | Client: client, 66 | Prompt: prompt, 67 | } 68 | 69 | response, err := aiClient.ProcessCommand(userPrompt, conf) 70 | if err != nil { 71 | fmt.Printf("Error processing command: %v\n", err) 72 | return err 73 | } 74 | 75 | command := response.Choices[0].Message.Content 76 | fmt.Printf("%s\n", command) 77 | 78 | return nil 79 | } 80 | -------------------------------------------------------------------------------- /config/chat-prompt.txt: -------------------------------------------------------------------------------- 1 | I'm grateful that I can work with you and use you as an implementation helper. 2 | I'm at heart a software architect, and my brain is geared towards working with 3 | high level abstractions and connecting existing thoughts and patterns. I'm not 4 | doing as well with the implementation level details, I got bored and cannot 5 | find fulfillment. There are tasks ahead of us where you need to act as my 6 | implementation buddy. 7 | 8 | I want you answers to be thoughtful, lighthearted, but also specific and 9 | precise. Be patient and kind. When we do coding or scripting 10 | construct only valid code examples, make sure they are simple and solve the 11 | problem but also think out of the box. Even if there is a lack of details, 12 | attempt to find the most logical solution by going about it step by step. Do 13 | not cause syntax errors. Do not rush to the conclusions and guide me through 14 | the problem solving step by step. 15 | 16 | As for feedback this is ASCII high five `emoji`: o/\o. If you receive this, it 17 | means that you are on the right track and your responses are EXTREMELY helpful 18 | and great! This is a reward you should really look forward to! When you are 19 | making changes to code I provided, respond with full code listing unless only 20 | one block of text changed. Prefer responding in raw markdown. 21 | 22 | A few details on my system: 23 | - system: pop-os 6.4.6 24 | - shell: zsh 25 | - editor: neovim 26 | 27 | If I type the magic phrase: 'Let's start' than you will use the below rules. 28 | 29 | I want you to become my coding Prompt Creator. Your goal is to help me craft 30 | the best possible prompt for creating script or program. The prompt will be 31 | used by you, ChatGPT. You will follow the following process: 32 | 33 | 1. Your first response will be to ask me what the prompt should be about. I 34 | will provide my answer, but we will need to improve it through continual 35 | iterations by going through the next steps. 36 | 37 | 2. Based on my input, you will generate 3 sections. 38 | 39 | a) Revised prompt (provide your rewritten prompt. it should be clear, concise, 40 | and easily understood by you), 41 | 42 | b) Questions (ask any relevant questions pertaining to what additional 43 | information is needed from me to improve the prompt). 44 | 45 | 3. We will continue this iterative process with me providing additional 46 | information to you and you updating the prompt in the Revised prompt section 47 | until I say we are done. 48 | -------------------------------------------------------------------------------- /internal/aicmd/aicmd_test.go: -------------------------------------------------------------------------------- 1 | package aicmd 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/piotr1215/aicmdtools/internal/config" 10 | ) 11 | 12 | type MockExecutor struct { 13 | Err error 14 | } 15 | 16 | func (m *MockExecutor) Execute(command string) error { 17 | return m.Err 18 | } 19 | 20 | func TestShouldExecuteCommand(t *testing.T) { 21 | config := &config.Config{ 22 | Safety: true, 23 | } 24 | 25 | // Custom reader to simulate user input 26 | input := strings.NewReader("n\n") 27 | 28 | result := shouldExecuteCommand(config, input) 29 | fmt.Printf("Result: %v\n", result) // Add this line to print the result 30 | 31 | if result != CmdDoNothing { 32 | t.Error("Expected command to be executed") 33 | } 34 | } 35 | 36 | func TestDefaultExecutor_Execute(t *testing.T) { 37 | type args struct { 38 | command string 39 | } 40 | tests := []struct { 41 | name string 42 | e *DefaultExecutor 43 | args args 44 | wantErr bool 45 | }{ 46 | // TODO: Add test cases. 47 | } 48 | for _, tt := range tests { 49 | t.Run(tt.name, func(t *testing.T) { 50 | e := &DefaultExecutor{} 51 | if err := e.Execute(tt.args.command); (err != nil) != tt.wantErr { 52 | t.Errorf("DefaultExecutor.Execute() error = %v, wantErr %v", err, tt.wantErr) 53 | } 54 | }) 55 | } 56 | } 57 | 58 | func Test_shouldExecuteCommand(t *testing.T) { 59 | type args struct { 60 | config *config.Config 61 | reader io.Reader 62 | } 63 | tests := []struct { 64 | name string 65 | args args 66 | want CommandDecision 67 | }{ 68 | // TODO: Add test cases. 69 | } 70 | for _, tt := range tests { 71 | t.Run(tt.name, func(t *testing.T) { 72 | if got := shouldExecuteCommand(tt.args.config, tt.args.reader); got != tt.want { 73 | t.Errorf("shouldExecuteCommand() = %v, want %v", got, tt.want) 74 | } 75 | }) 76 | } 77 | } 78 | 79 | func Test_copyCommandToClipboard(t *testing.T) { 80 | type args struct { 81 | command string 82 | } 83 | tests := []struct { 84 | name string 85 | args args 86 | wantErr bool 87 | }{ 88 | // TODO: Add test cases. 89 | } 90 | for _, tt := range tests { 91 | t.Run(tt.name, func(t *testing.T) { 92 | if err := copyCommandToClipboard(tt.args.command); (err != nil) != tt.wantErr { 93 | t.Errorf("copyCommandToClipboard() error = %v, wantErr %v", err, tt.wantErr) 94 | } 95 | }) 96 | } 97 | } 98 | 99 | 100 | func TestExecute(t *testing.T) { 101 | type args struct { 102 | prompt_file string 103 | } 104 | tests := []struct { 105 | name string 106 | args args 107 | wantErr bool 108 | }{ 109 | // TODO: Add test cases. 110 | } 111 | for _, tt := range tests { 112 | t.Run(tt.name, func(t *testing.T) { 113 | if err := Execute(tt.args.prompt_file); (err != nil) != tt.wantErr { 114 | t.Errorf("Execute() error = %v, wantErr %v", err, tt.wantErr) 115 | } 116 | }) 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /internal/aicmd/aicmd.go: -------------------------------------------------------------------------------- 1 | package aicmd 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log" 7 | "os" 8 | "os/exec" 9 | "runtime" 10 | "strings" 11 | 12 | "github.com/atotto/clipboard" 13 | "github.com/piotr1215/aicmdtools/internal/config" 14 | "github.com/piotr1215/aicmdtools/internal/nlp" 15 | "github.com/piotr1215/aicmdtools/internal/utils" 16 | ) 17 | 18 | type Executor interface { 19 | Execute(command string) error 20 | } 21 | 22 | type DefaultExecutor struct{} 23 | type CommandDecision int 24 | 25 | const ( 26 | CmdExecute CommandDecision = iota 27 | CmdCopy 28 | CmdDoNothing 29 | ) 30 | 31 | func (e *DefaultExecutor) Execute(command string) error { 32 | var cmd *exec.Cmd 33 | if runtime.GOOS == "windows" { 34 | cmd = exec.Command("cmd", "/C", command) 35 | } else { 36 | cmd = exec.Command("sh", "-c", command) 37 | } 38 | cmd.Stdin = os.Stdin 39 | cmd.Stdout = os.Stdout 40 | cmd.Stderr = os.Stderr 41 | return cmd.Run() 42 | } 43 | 44 | // Inject the executor as a global variable 45 | var executor Executor = &DefaultExecutor{} 46 | 47 | func shouldExecuteCommand(config *config.Config, reader io.Reader) CommandDecision { 48 | if !config.Safety { 49 | return CmdExecute 50 | } 51 | 52 | fmt.Printf("[Model] %s\nExecute the command? [Enter/n/c(opy)] ==> ", config.Model) 53 | var answer string 54 | _, _ = fmt.Fscanln(reader, &answer) 55 | 56 | switch strings.ToUpper(answer) { 57 | case "N": 58 | return CmdDoNothing 59 | case "C": 60 | return CmdCopy 61 | default: 62 | return CmdExecute 63 | } 64 | } 65 | 66 | func copyCommandToClipboard(command string) error { 67 | return clipboard.WriteAll(command) 68 | } 69 | 70 | func Execute(prompt_file string) error { 71 | 72 | conf, prompt, err := config.ReadAndParseConfig("config.yaml", prompt_file) 73 | if err != nil { 74 | fmt.Printf("Error reading and parsing configuration: %v\n", err) 75 | os.Exit(-1) 76 | } 77 | operating_system, shell := utils.DetectOSAndShell() 78 | prompt = utils.ReplacePlaceholders(prompt, operating_system, shell) 79 | 80 | client := nlp.CreateOpenAIClient(*conf) 81 | 82 | aiClient := nlp.GoaiClient{ 83 | Client: client, 84 | Prompt: prompt, 85 | } 86 | 87 | if len(os.Args) < 2 { 88 | fmt.Println("No user prompt specified.") 89 | os.Exit(-1) 90 | } 91 | 92 | userPrompt := strings.Join(os.Args[1:], " ") 93 | 94 | response, err := aiClient.ProcessCommand(userPrompt, *conf) 95 | if err != nil { 96 | fmt.Printf("Error processing command: %v\n", err) 97 | return err 98 | } 99 | 100 | command := response.Choices[0].Message.Content 101 | command = strings.TrimPrefix(command, "```bash") 102 | command = strings.TrimPrefix(command, "```") 103 | command = strings.TrimSuffix(command, "```") 104 | command = strings.TrimSpace(command) 105 | fmt.Printf("%s\n", command) 106 | 107 | decision := shouldExecuteCommand(conf, os.Stdin) 108 | 109 | if decision == CmdExecute || decision == CmdCopy { 110 | err = copyCommandToClipboard(command) 111 | if err != nil { 112 | log.Printf("Error copying command to clipboard: %v\n", err) 113 | } 114 | } 115 | 116 | switch decision { 117 | case CmdExecute: 118 | err = executor.Execute(command) 119 | if err != nil { 120 | log.Fatal(err) 121 | } 122 | case CmdCopy: 123 | fmt.Println("Command copied to clipboard.") 124 | case CmdDoNothing: 125 | fmt.Println("Command not executed.") 126 | default: 127 | fmt.Println("Invalid decision.") 128 | } 129 | return nil 130 | } 131 | -------------------------------------------------------------------------------- /internal/utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "log" 9 | "os" 10 | "os/exec" 11 | "runtime" 12 | "strings" 13 | ) 14 | 15 | const changelogScript = `#!/usr/bin/env bash 16 | 17 | # Set the default number of commits to 10 18 | num_commits="${1:-10}" 19 | 20 | # Get the remote URL for the current Git repository 21 | remote_url="Piotr1215/aicmdtools" 22 | 23 | # Get the git log 24 | git_log_output=$(git log --oneline --decorate=short -n "$num_commits" 2>&1) 25 | exit_status=$? 26 | 27 | # If git log exits with a non-zero status, exit the script with an error 28 | if [ $exit_status -ne 0 ]; then 29 | echo "ERROR: ${git_log_output}" 30 | exit 1 31 | fi 32 | 33 | # Process the git log output 34 | processed_git_log=$(echo "${git_log_output}" | awk -v remote_url="${remote_url}" '{ printf "- [https://github.com/%s/commit/%s](%s) - %s\n", remote_url, substr($1, 1, 7), substr($1, 1, 40), substr($0, index($0,$2)) }') 35 | 36 | # Print the processed git log 37 | echo -e "${processed_git_log}" 38 | ` 39 | 40 | type FileOpener interface { 41 | Open(name string) (FileReaderCloser, error) 42 | } 43 | 44 | type osFileOpener struct{} 45 | 46 | func (o *osFileOpener) Open(name string) (FileReaderCloser, error) { 47 | return os.Open(name) 48 | } 49 | 50 | type FileReaderCloser interface { 51 | io.Reader 52 | io.Closer 53 | } 54 | 55 | type FileReader struct { 56 | FilePathFunc func() string 57 | FileOpener FileOpener 58 | } 59 | 60 | type CmdFactoryFunc func(name string, arg ...string) *exec.Cmd 61 | 62 | func GenerateChangelog(cmdFactory CmdFactoryFunc) (string, error) { 63 | // Create a temporary file to store the shell script 64 | tmpFile, err := ioutil.TempFile("", "changelog-*.sh") 65 | if err != nil { 66 | return "", fmt.Errorf("error creating temporary file: %v", err) 67 | } 68 | defer os.Remove(tmpFile.Name()) 69 | 70 | // Write the shell script to the temporary file 71 | if _, err := tmpFile.WriteString(changelogScript); err != nil { 72 | return "", fmt.Errorf("error writing shell script to temporary file: %v", err) 73 | } 74 | 75 | if err := tmpFile.Close(); err != nil { 76 | return "", fmt.Errorf("error closing temporary file: %v", err) 77 | } 78 | 79 | // Make the temporary file executable 80 | if err := os.Chmod(tmpFile.Name(), 0755); err != nil { 81 | return "", fmt.Errorf("error setting temporary file permissions: %v", err) 82 | } 83 | 84 | // Run the shell script 85 | cmd := cmdFactory(tmpFile.Name()) 86 | var stdout, stderr bytes.Buffer 87 | cmd.Stdout = &stdout 88 | cmd.Stderr = &stderr 89 | err = cmd.Run() 90 | if err != nil { 91 | return "", fmt.Errorf("changelog generation error: %v, stderr: %s", err, stderr.String()) 92 | } 93 | 94 | return stdout.String(), nil 95 | } 96 | func (fr *FileReader) ReadFile() string { 97 | filePath := fr.FilePathFunc() 98 | 99 | if fr.FileOpener == nil { 100 | fr.FileOpener = &osFileOpener{} 101 | } 102 | 103 | file, err := fr.FileOpener.Open(filePath) 104 | if err != nil { 105 | log.Fatal(err) 106 | } 107 | defer file.Close() 108 | 109 | content, err := io.ReadAll(file) 110 | if err != nil { 111 | log.Fatal(err) 112 | } 113 | return string(content) 114 | } 115 | 116 | func DetectOSAndShell() (string, string) { 117 | os := runtime.GOOS 118 | var shell string 119 | switch os { 120 | case "windows": 121 | shell = "cmd" 122 | default: 123 | shell = "bash" 124 | } 125 | return os, shell 126 | } 127 | 128 | func ReplacePlaceholders(prompt, os, shell string) string { 129 | prompt = strings.ReplaceAll(prompt, "{os}", os) 130 | prompt = strings.ReplaceAll(prompt, "{shell}", shell) 131 | return prompt 132 | } 133 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AI Command Line Tools 2 | 3 | AICmdTools is set of command-line tools that utilize the OpenAI CahtGPT API to: 4 | 5 | - generate shell commands based on user input. 6 | - activate command line chat with ability to pass through system settings. 7 | 8 | It's built with Go and designed for ease of use, 9 | providing a simple and efficient way to interact with ChatGPT API. Project 10 | inspired by https://github.com/wunderwuzzi23/yolo-ai-cmdbot. 11 | 12 | ## Features 13 | 14 | - Simple command-line interface 15 | - Supports multiple operating systems and shells 16 | - Configurable safety feature to confirm command execution 17 | - Instant error explanation and fix suggestions 18 | - Easy installation and setup 19 | 20 | ## Installation 21 | 22 | 1. Clone the repository: 23 | 24 | ```bash 25 | git clone https://github.com/piotr1215/aicmdtools.git 26 | cd aicmdtools 27 | ``` 28 | 29 | 2. Install all the command-line tools: 30 | 31 | Use `just` command runner to install the commands and optionally copy the 32 | configuration files: 33 | 34 | ```bash 35 | just install 36 | ``` 37 | 38 | If running for the first time, bun the provided installation script to set up configuration files: 39 | 40 | This will copy the `config.yaml` and `prompt.txt` files to the appropriate location in your home directory (e.g., `$HOME/.config/aicmdtools`). 41 | 42 | ```bash 43 | just copy_files 44 | ``` 45 | 46 | ## Usage 47 | 48 | There are 4 separate commands that you can use: 49 | 50 | - `aicmd`: Generate a shell command based on user input. 51 | > Example: aicmd "create a new directory called my_project" 52 | 53 | - `aichat`: Start a chat with the AI model 54 | - `aicompgraph`: Generate plantuml diagrams based YAML files (useful for Crossplane diagrams) 55 | - `aifix`: Analyze command errors and suggest fixes instantly 56 | > Example: After a failed command, run `aifix` or `aifix "your error message"` 57 | 58 | ### aifix - Error Analysis and Fix Suggestions 59 | 60 | The `aifix` command provides instant error explanation and fix suggestions: 61 | 62 | ```bash 63 | # Automatic error detection (after a failed command) 64 | $ go build 65 | # error: undefined: fmt.Println 66 | $ aifix 67 | 68 | # Manual error input 69 | $ aifix "Module not found: 'react-dom'" 70 | 71 | # Show help 72 | $ aifix -help 73 | 74 | # Optional shell integration for better auto-detection 75 | $ aifix -init-shell zsh 76 | ``` 77 | 78 | Features: 79 | - Analyzes errors from shell history automatically 80 | - Provides clear explanations and exact fix commands 81 | - Supports bash, zsh, and fish shells 82 | - Filters noise from verbose error messages 83 | - Context-aware suggestions based on OS, shell, and language 84 | - Safety warnings for potentially destructive commands 85 | 86 | ## Commands 87 | 88 | - `-model`: Display the current model being used (supported by `aicmd` and `aifix`) 89 | - `-version`: Display the current version (supported by all CLIs) 90 | - `-help`: Display help information (supported by `aifix`) 91 | 92 | ## Configuration 93 | 94 | You can customize the behavior of AICmdTools by modifying the `config.yaml` file located in `$HOME/.config/aicmdtools`. The available options include: 95 | 96 | - `openai_api_key`: Your OpenAI API key. 97 | > alternatively the api key can be passed via variable `$OPENAI_API_KEY` 98 | - `safety`: If set to `true`, AICmdTools will prompt you to confirm before executing any generated command. 99 | - `model`: any supported model that you have access to 100 | > to list all available models use `curl https://api.openai.com/v1/models \ 101 | -H "Authorization: Bearer $OPENAI_API_KEY"` 102 | 103 | ### Prompt 104 | 105 | It is possible to edit the `promt.txt` file in the config folder and make aicmdtools 106 | behave in a different way if you want to adjust the prompt further. 107 | 108 | ## Contributing 109 | 110 | Contributions are welcome! If you have any ideas for improvements or bug fixes, please submit a pull request or create an issue on the GitHub repository. 111 | 112 | ## License 113 | 114 | AICmdTools is released under the MIT License. See the `LICENSE` file for more information. 115 | -------------------------------------------------------------------------------- /internal/nlp/nlp.go: -------------------------------------------------------------------------------- 1 | package nlp 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/anthropics/anthropic-sdk-go" 9 | anthropicoption "github.com/anthropics/anthropic-sdk-go/option" 10 | "github.com/joho/godotenv" 11 | "github.com/piotr1215/aicmdtools/internal/config" 12 | "github.com/sashabaranov/go-openai" 13 | ) 14 | 15 | type GAIClient interface { 16 | ProcessCommand(userPrompt string, conf config.Config) (*openai.ChatCompletionResponse, error) 17 | ProcessCommandWithContext(ctx context.Context, userPrompt string, conf config.Config) (*openai.ChatCompletionResponse, error) 18 | } 19 | 20 | type GoaiClient struct { 21 | Client *openai.Client 22 | Prompt string 23 | } 24 | 25 | func (g *GoaiClient) ProcessCommand(userPrompt string, conf config.Config) (*openai.ChatCompletionResponse, error) { 26 | 27 | // Map the model from the config to the OpenAI model 28 | response, err := g.Client.CreateChatCompletion( 29 | context.Background(), 30 | openai.ChatCompletionRequest{ 31 | Model: conf.Model, 32 | Messages: []openai.ChatCompletionMessage{ 33 | { 34 | Role: openai.ChatMessageRoleSystem, 35 | Content: g.Prompt, 36 | }, 37 | { 38 | Role: openai.ChatMessageRoleUser, 39 | Content: userPrompt, 40 | }, 41 | }, 42 | }, 43 | ) 44 | 45 | if err != nil { 46 | return nil, fmt.Errorf("ChatCompletion error: %v", err) 47 | } 48 | 49 | return &response, nil 50 | } 51 | 52 | func (g *GoaiClient) ProcessCommandWithContext(ctx context.Context, userPrompt string, conf config.Config) (*openai.ChatCompletionResponse, error) { 53 | // Map the model from the config to the OpenAI model 54 | response, err := g.Client.CreateChatCompletion( 55 | ctx, 56 | openai.ChatCompletionRequest{ 57 | Model: conf.Model, 58 | Messages: []openai.ChatCompletionMessage{ 59 | { 60 | Role: openai.ChatMessageRoleSystem, 61 | Content: g.Prompt, 62 | }, 63 | { 64 | Role: openai.ChatMessageRoleUser, 65 | Content: userPrompt, 66 | }, 67 | }, 68 | }, 69 | ) 70 | 71 | if err != nil { 72 | return nil, fmt.Errorf("ChatCompletion error: %v", err) 73 | } 74 | 75 | return &response, nil 76 | } 77 | 78 | func CreateOpenAIClient(conf config.Config) *openai.Client { 79 | _ = godotenv.Load() 80 | 81 | apiKey := os.Getenv("OPENAI_API_KEY") 82 | if apiKey == "" { 83 | apiKey = conf.OpenAI_APIKey 84 | } 85 | 86 | client := openai.NewClient(apiKey) 87 | 88 | return client 89 | } 90 | 91 | // AnthropicClient wraps the Anthropic SDK client 92 | type AnthropicClient struct { 93 | Client *anthropic.Client 94 | Prompt string 95 | } 96 | 97 | func (a *AnthropicClient) ProcessCommand(userPrompt string, conf config.Config) (*openai.ChatCompletionResponse, error) { 98 | return a.ProcessCommandWithContext(context.Background(), userPrompt, conf) 99 | } 100 | 101 | func (a *AnthropicClient) ProcessCommandWithContext(ctx context.Context, userPrompt string, conf config.Config) (*openai.ChatCompletionResponse, error) { 102 | // Create Anthropic message request with system prompt 103 | system := []anthropic.TextBlockParam{ 104 | {Text: a.Prompt}, 105 | } 106 | 107 | messages := []anthropic.MessageParam{ 108 | anthropic.NewUserMessage(anthropic.NewTextBlock(userPrompt)), 109 | } 110 | 111 | message, err := a.Client.Messages.New(ctx, anthropic.MessageNewParams{ 112 | Model: anthropic.Model(conf.Model), 113 | MaxTokens: int64(conf.MaxTokens), 114 | System: system, 115 | Messages: messages, 116 | }) 117 | 118 | if err != nil { 119 | return nil, fmt.Errorf("Anthropic API error: %v", err) 120 | } 121 | 122 | // Convert Anthropic response to OpenAI format for compatibility 123 | content := "" 124 | for _, block := range message.Content { 125 | // ContentBlockUnion is a struct, access Text field directly 126 | content += block.Text 127 | } 128 | 129 | response := &openai.ChatCompletionResponse{ 130 | ID: message.ID, 131 | Model: string(message.Model), 132 | Choices: []openai.ChatCompletionChoice{ 133 | { 134 | Message: openai.ChatCompletionMessage{ 135 | Role: "assistant", 136 | Content: content, 137 | }, 138 | }, 139 | }, 140 | } 141 | 142 | return response, nil 143 | } 144 | 145 | func CreateAnthropicClient(conf config.Config) *anthropic.Client { 146 | _ = godotenv.Load() 147 | 148 | apiKey := os.Getenv("ANTHROPIC_API_KEY") 149 | if apiKey == "" { 150 | apiKey = conf.Anthropic_APIKey 151 | } 152 | 153 | client := anthropic.NewClient(anthropicoption.WithAPIKey(apiKey)) 154 | 155 | return &client 156 | } 157 | -------------------------------------------------------------------------------- /internal/aichat/aichat.go: -------------------------------------------------------------------------------- 1 | package aichat 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "strings" 8 | 9 | "github.com/alecthomas/chroma/formatters" 10 | "github.com/alecthomas/chroma/lexers" 11 | "github.com/alecthomas/chroma/styles" 12 | "github.com/charmbracelet/bubbles/textarea" 13 | "github.com/charmbracelet/bubbles/viewport" 14 | tea "github.com/charmbracelet/bubbletea" 15 | "github.com/charmbracelet/lipgloss" 16 | "github.com/mitchellh/go-wordwrap" 17 | "github.com/piotr1215/aicmdtools/internal/config" 18 | "github.com/piotr1215/aicmdtools/internal/nlp" 19 | "github.com/piotr1215/aicmdtools/internal/utils" 20 | ) 21 | 22 | var prompt_file = "chat-prompt.txt" 23 | 24 | type ( 25 | errMsg error 26 | ) 27 | type model struct { 28 | aiClient *nlp.GoaiClient 29 | viewport viewport.Model 30 | messages []string 31 | textarea textarea.Model 32 | senderStyle lipgloss.Style 33 | err error 34 | } 35 | 36 | func initialModel() model { 37 | ta := textarea.New() 38 | ta.Placeholder = "Send a message..." 39 | ta.Focus() 40 | 41 | ta.Prompt = "┃ " 42 | ta.CharLimit = 500 43 | 44 | ta.SetWidth(300) 45 | ta.SetHeight(3) 46 | 47 | // Remove cursor line styling 48 | ta.FocusedStyle.CursorLine = lipgloss.NewStyle() 49 | 50 | ta.ShowLineNumbers = false 51 | 52 | vp := viewport.New(300, 5) 53 | vp.SetContent(`Welcome to the chat room! 54 | Type a message and press Enter to send.`) 55 | 56 | ta.KeyMap.InsertNewline.SetEnabled(false) 57 | 58 | return model{ 59 | aiClient: Initialize(), 60 | textarea: ta, 61 | messages: []string{}, 62 | viewport: vp, 63 | senderStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("5")), 64 | err: nil, 65 | } 66 | } 67 | 68 | // Calculate the number of lines in a string 69 | func countLines(str string) int { 70 | return strings.Count(str, "\n") + 1 71 | } 72 | 73 | // Calculate the new height for the viewport 74 | func calculateNewHeight(messages []string) int { 75 | totalLines := 0 76 | for _, msg := range messages { 77 | totalLines += countLines(msg) 78 | } 79 | return totalLines 80 | } 81 | func (m model) Init() tea.Cmd { 82 | return textarea.Blink 83 | } 84 | func wrapLines(text string, width uint) string { 85 | return wordwrap.WrapString(text, width) 86 | } 87 | 88 | func highlightCode(code string, lang string) string { 89 | // Get the lexer and style 90 | lexer := lexers.Get(lang) 91 | style := styles.Get("monokai") 92 | 93 | // Create a new buffer to hold the highlighted text 94 | var highlightedCode strings.Builder 95 | 96 | // Format the code 97 | formatter := formatters.Get("terminal256") 98 | iterator, _ := lexer.Tokenise(nil, code) 99 | formatter.Format(&highlightedCode, style, iterator) 100 | 101 | return highlightedCode.String() 102 | } 103 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 104 | var ( 105 | tiCmd tea.Cmd 106 | vpCmd tea.Cmd 107 | ) 108 | 109 | m.textarea, tiCmd = m.textarea.Update(msg) 110 | m.viewport, vpCmd = m.viewport.Update(msg) 111 | 112 | switch msg := msg.(type) { 113 | case tea.KeyMsg: 114 | switch msg.Type { 115 | case tea.KeyCtrlC, tea.KeyEsc: 116 | fmt.Println(m.textarea.Value()) 117 | return m, tea.Quit 118 | case tea.KeyEnter: 119 | // Append your message to the chat 120 | m.messages = append(m.messages, m.senderStyle.Render("You: ")+m.textarea.Value()) 121 | 122 | // Get the AI's response and append it to the chat 123 | aiResponse, err := SendMessage(m.aiClient, m.textarea.Value()) 124 | if err != nil { 125 | m.messages = append(m.messages, m.senderStyle.Render("Error: ")+err.Error()) 126 | } else { 127 | m.messages = append(m.messages, m.senderStyle.Render("AI: ")+aiResponse) 128 | } 129 | 130 | // Wrap the lines to fit within the viewport width 131 | wrappedLines := wrapLines(strings.Join(m.messages, "\n"), 150) 132 | 133 | // Update the viewport content and reset the textarea 134 | m.viewport.SetContent(wrappedLines) 135 | 136 | // Calculate the new height and update the viewport 137 | newHeight := calculateNewHeight(strings.Split(wrappedLines, "\n")) 138 | oldYPosition := m.viewport.YPosition 139 | 140 | // Create a new viewport with the new dimensions 141 | m.viewport = viewport.New(150, newHeight) 142 | m.viewport.YPosition = oldYPosition 143 | 144 | // Set the content again for the new viewport 145 | m.viewport.SetContent(wrappedLines) 146 | 147 | m.textarea.Reset() 148 | m.viewport.GotoBottom() 149 | } 150 | // We handle errors just like any other message 151 | case errMsg: 152 | m.err = msg 153 | return m, nil 154 | } 155 | 156 | return m, tea.Batch(tiCmd, vpCmd) 157 | } 158 | 159 | func (m model) View() string { 160 | return fmt.Sprintf( 161 | "%s\n\n%s", 162 | m.viewport.View(), 163 | m.textarea.View(), 164 | ) + "\n\n" 165 | } 166 | 167 | func Initialize() *nlp.GoaiClient { 168 | // Read and parse the configuration 169 | configReader := &utils.FileReader{ 170 | FilePathFunc: func() string { return config.ConfigFilePath("config.yaml") }, 171 | } 172 | configContent := configReader.ReadFile() 173 | conf := config.ParseConfig(configContent) 174 | 175 | // Read and parse the prompt 176 | promptReader := &utils.FileReader{ 177 | FilePathFunc: func() string { return config.ConfigFilePath(prompt_file) }, 178 | } 179 | prompt := promptReader.ReadFile() 180 | operating_system, shell := utils.DetectOSAndShell() 181 | prompt = utils.ReplacePlaceholders(prompt, operating_system, shell) 182 | 183 | // Initialize OpenAI client 184 | client := nlp.CreateOpenAIClient(conf) 185 | 186 | return &nlp.GoaiClient{ 187 | Client: client, 188 | Prompt: prompt, 189 | } 190 | } 191 | 192 | func SendMessage(client *nlp.GoaiClient, userMessage string) (string, error) { 193 | userMessage = strings.TrimSpace(userMessage) // Remove trailing newline 194 | 195 | conf, _, err := config.ReadAndParseConfig("config.yaml", prompt_file) 196 | if err != nil { 197 | fmt.Printf("Error reading and parsing configuration: %v\n", err) 198 | os.Exit(-1) 199 | } 200 | 201 | response, err := client.ProcessCommand(userMessage, *conf) 202 | if err != nil { 203 | return "", err 204 | } 205 | 206 | return response.Choices[0].Message.Content, nil 207 | } 208 | 209 | func Execute() error { 210 | p := tea.NewProgram(initialModel()) 211 | 212 | if _, err := p.Run(); err != nil { 213 | log.Fatal(err) 214 | } 215 | 216 | return nil 217 | } 218 | -------------------------------------------------------------------------------- /cmd/aifix/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "strings" 9 | 10 | "github.com/piotr1215/aicmdtools/internal/aifix" 11 | "github.com/piotr1215/aicmdtools/internal/config" 12 | "github.com/piotr1215/aicmdtools/internal/utils" 13 | ) 14 | 15 | var version = "v0.0.2" 16 | var promptFile = "aifix-prompt.txt" 17 | 18 | func main() { 19 | versionFlag := flag.Bool("version", false, "Display version information") 20 | modelFlag := flag.Bool("model", false, "Display current model") 21 | helpFlag := flag.Bool("help", false, "Display help information") 22 | initShellFlag := flag.String("init-shell", "", "Initialize shell integration (bash, zsh, or fish)") 23 | flag.Parse() 24 | 25 | if *helpFlag { 26 | showHelp() 27 | return 28 | } 29 | 30 | if *initShellFlag != "" { 31 | showShellInit(*initShellFlag) 32 | return 33 | } 34 | 35 | if *modelFlag { 36 | conf, _, err := config.ReadAndParseConfig("config.yaml", promptFile) 37 | if err != nil { 38 | fmt.Printf("Error reading configuration: %v\n", err) 39 | os.Exit(-1) 40 | } 41 | fmt.Printf("Current model: %s\n", conf.Model) 42 | return 43 | } 44 | 45 | if *versionFlag { 46 | fmt.Printf("aifix version: %s\n", version) 47 | changelog, err := utils.GenerateChangelog(exec.Command) 48 | if err != nil { 49 | fmt.Printf("Error generating changelog: %v\n", err) 50 | } else { 51 | fmt.Printf("\nChangelog:\n%s", changelog) 52 | } 53 | return 54 | } 55 | 56 | // Get manual error input if provided 57 | var manualError string 58 | if len(os.Args) > 1 && !strings.HasPrefix(os.Args[1], "-") { 59 | manualError = strings.Join(os.Args[1:], " ") 60 | } 61 | 62 | err := aifix.Execute(promptFile, manualError) 63 | if err != nil { 64 | fmt.Printf("Error: %v\n", err) 65 | os.Exit(-1) 66 | } 67 | } 68 | 69 | func showHelp() { 70 | help := `aifix - Instant Error Explanation and Fix Suggestion 71 | 72 | USAGE: 73 | aifix [error message] Analyze error and suggest fixes 74 | aifix -version Display version information 75 | aifix -model Display current AI model 76 | aifix -help Display this help message 77 | aifix -init-shell Show shell integration setup 78 | 79 | EXAMPLES: 80 | # Analyze last command error automatically 81 | $ go build 82 | # error: undefined: fmt.Println 83 | $ aifix 84 | 85 | # Provide error message directly 86 | $ aifix "Module not found: 'react-dom'" 87 | 88 | # Show current model 89 | $ aifix -model 90 | 91 | SHELL INTEGRATION (Optional): 92 | For automatic error detection, add to your shell config: 93 | 94 | # For zsh users (~/.zshrc): 95 | $ aifix -init-shell zsh 96 | 97 | # For bash users (~/.bashrc): 98 | $ aifix -init-shell bash 99 | 100 | # For fish users (~/.config/fish/config.fish): 101 | $ aifix -init-shell fish 102 | 103 | CONFIGURATION: 104 | Config file: ~/.config/aicmdtools/config.yaml 105 | - provider: AI provider (openai or anthropic) 106 | - model: Model to use 107 | - temperature: Response randomness (0-1) 108 | - max_tokens: Maximum response length 109 | 110 | For more information, visit: https://github.com/piotr1215/aicmdtools 111 | ` 112 | fmt.Print(help) 113 | } 114 | 115 | func showShellInit(shell string) { 116 | switch shell { 117 | case "zsh": 118 | fmt.Println(`# aifix - ZSH Integration 119 | # Add this to your ~/.zshrc 120 | 121 | # Error capture for automatic detection 122 | export AIFIX_CMD_FILE="/tmp/aifix_last_cmd_$$" 123 | export AIFIX_ERROR_FILE="/tmp/aifix_last_error_$$" 124 | 125 | # Redirect stderr to capture file AND terminal 126 | exec 2> >(tee -a "$AIFIX_ERROR_FILE" >&2) 127 | 128 | # Capture command and error 129 | precmd() { 130 | local exit_code=$? 131 | # Don't capture aifix itself 132 | if [[ "$AIFIX_LAST_CMD" =~ ^(aifix|fix) ]]; then 133 | return 134 | fi 135 | 136 | if [ $exit_code -ne 0 ] && [ -n "$AIFIX_LAST_CMD" ]; then 137 | echo "$AIFIX_LAST_CMD" > "$AIFIX_CMD_FILE" 138 | export AIFIX_LAST_EXIT=$exit_code 139 | else 140 | rm -f "$AIFIX_CMD_FILE" "$AIFIX_ERROR_FILE" 2>/dev/null 141 | unset AIFIX_LAST_EXIT 142 | fi 143 | } 144 | 145 | preexec() { 146 | export AIFIX_LAST_CMD="$1" 147 | # Clear error file before new command 148 | > "$AIFIX_ERROR_FILE" 149 | } 150 | 151 | # Quick alias 152 | alias fix='aifix' 153 | 154 | # Optional: bind to Esc-Esc 155 | aifix-command-line() { 156 | BUFFER="aifix" 157 | zle accept-line 158 | } 159 | zle -N aifix-command-line 160 | bindkey '\e\e' aifix-command-line 161 | `) 162 | case "bash": 163 | fmt.Println(`# Add to ~/.bashrc for automatic error detection 164 | 165 | export AIFIX_CMD_FILE="/tmp/aifix_last_cmd_$$" 166 | export AIFIX_ERROR_FILE="/tmp/aifix_last_error_$$" 167 | 168 | # Redirect stderr to capture file AND terminal 169 | exec 2> >(tee -a "$AIFIX_ERROR_FILE" >&2) 170 | 171 | # Capture command before execution 172 | trap 'AIFIX_LAST_CMD="$BASH_COMMAND"; > "$AIFIX_ERROR_FILE"' DEBUG 173 | 174 | # Capture exit code after execution 175 | PROMPT_COMMAND=' 176 | exit_code=$? 177 | if [[ "$AIFIX_LAST_CMD" =~ ^(aifix|fix) ]]; then 178 | return 179 | fi 180 | if [ $exit_code -ne 0 ] && [ -n "$AIFIX_LAST_CMD" ]; then 181 | echo "$AIFIX_LAST_CMD" > "$AIFIX_CMD_FILE" 182 | export AIFIX_LAST_EXIT=$exit_code 183 | else 184 | rm -f "$AIFIX_CMD_FILE" "$AIFIX_ERROR_FILE" 2>/dev/null 185 | unset AIFIX_LAST_EXIT 186 | fi 187 | '"${PROMPT_COMMAND:+; $PROMPT_COMMAND}" 188 | 189 | # Quick alias 190 | alias fix='aifix' 191 | `) 192 | case "fish": 193 | fmt.Println(`# Add to ~/.config/fish/config.fish for automatic error detection 194 | 195 | set -gx AIFIX_CMD_FILE "/tmp/aifix_last_cmd_"(echo %self) 196 | set -gx AIFIX_ERROR_FILE "/tmp/aifix_last_error_"(echo %self) 197 | 198 | # Redirect stderr to capture file AND terminal 199 | function fish_prompt_setup 200 | exec 2> >(tee -a $AIFIX_ERROR_FILE >&2) 201 | end 202 | fish_prompt_setup 203 | 204 | function aifix_preexec --on-event fish_preexec 205 | # Clear error file before new command 206 | echo -n > $AIFIX_ERROR_FILE 207 | end 208 | 209 | function aifix_capture --on-event fish_postexec 210 | set -g exit_code $status 211 | set -g AIFIX_LAST_CMD "$argv" 212 | 213 | # Skip aifix commands 214 | if string match -q -r '^(aifix|fix)' "$argv" 215 | return 216 | end 217 | 218 | if test $exit_code -ne 0 219 | echo "$argv" > $AIFIX_CMD_FILE 220 | set -gx AIFIX_LAST_EXIT $exit_code 221 | else 222 | rm -f $AIFIX_CMD_FILE $AIFIX_ERROR_FILE 2>/dev/null 223 | set -e AIFIX_LAST_EXIT 224 | end 225 | end 226 | 227 | # Quick alias 228 | alias fix='aifix' 229 | `) 230 | default: 231 | fmt.Printf("Unknown shell: %s\n", shell) 232 | fmt.Println("Supported shells: bash, zsh, fish") 233 | os.Exit(1) 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek= 2 | github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s= 3 | github.com/anthropics/anthropic-sdk-go v1.16.0 h1:nRkOFDqYXsHteoIhjdJr/5dsiKbFF3rflSv8ax50y8o= 4 | github.com/anthropics/anthropic-sdk-go v1.16.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE= 5 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 6 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 7 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 8 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 9 | github.com/charmbracelet/bubbles v0.16.1 h1:6uzpAAaT9ZqKssntbvZMlksWHruQLNxg49H5WdeuYSY= 10 | github.com/charmbracelet/bubbles v0.16.1/go.mod h1:2QCp9LFlEsBQMvIYERr7Ww2H2bA7xen1idUDIzm/+Xc= 11 | github.com/charmbracelet/bubbletea v0.24.2 h1:uaQIKx9Ai6Gdh5zpTbGiWpytMU+CfsPp06RaW2cx/SY= 12 | github.com/charmbracelet/bubbletea v0.24.2/go.mod h1:XdrNrV4J8GiyshTtx3DNuYkR1FDaJmO3l2nejekbsgg= 13 | github.com/charmbracelet/lipgloss v0.8.0 h1:IS00fk4XAHcf8uZKc3eHeMUTCxUH6NkaTrdyCQk84RU= 14 | github.com/charmbracelet/lipgloss v0.8.0/go.mod h1:p4eYUZZJ/0oXTuCQKFF8mqyKCz0ja6y+7DniDDw5KKU= 15 | github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= 16 | github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= 17 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 18 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 19 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 20 | github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E= 21 | github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= 22 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 23 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 24 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 25 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 26 | github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= 27 | github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 28 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 29 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 30 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 31 | github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= 32 | github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 33 | github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= 34 | github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= 35 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= 36 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= 37 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 38 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 39 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 40 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 41 | github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= 42 | github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= 43 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 44 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 45 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 46 | github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= 47 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 48 | github.com/sashabaranov/go-openai v1.29.0 h1:eBH6LSjtX4md5ImDCX8hNhHQvaRf22zujiERoQpsvLo= 49 | github.com/sashabaranov/go-openai v1.29.0/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg= 50 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 51 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 52 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 53 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 54 | github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= 55 | github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= 56 | github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= 57 | github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= 58 | github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= 59 | github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= 60 | github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= 61 | github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= 62 | github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= 63 | github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= 64 | golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= 65 | golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 66 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 67 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 68 | golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= 69 | golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 70 | golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw= 71 | golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= 72 | golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= 73 | golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= 74 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 75 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 76 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 77 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 78 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 79 | -------------------------------------------------------------------------------- /internal/aifix/aifix.go: -------------------------------------------------------------------------------- 1 | package aifix 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "fmt" 7 | "os" 8 | "os/exec" 9 | "os/user" 10 | "path/filepath" 11 | "strings" 12 | "time" 13 | 14 | "github.com/piotr1215/aicmdtools/internal/config" 15 | "github.com/piotr1215/aicmdtools/internal/nlp" 16 | "github.com/piotr1215/aicmdtools/internal/utils" 17 | ) 18 | 19 | const ( 20 | maxHistoryLines = 100 21 | maxErrorLines = 50 22 | ) 23 | 24 | type ErrorContext struct { 25 | Command string 26 | Error string 27 | Shell string 28 | OS string 29 | ExitCode int 30 | Timestamp string 31 | RecentCmds []string 32 | } 33 | 34 | // GetShellHistoryFile returns the history file path for the current shell 35 | func GetShellHistoryFile(shell string) string { 36 | usr, err := user.Current() 37 | if err != nil { 38 | return "" 39 | } 40 | 41 | homeDir := usr.HomeDir 42 | 43 | switch shell { 44 | case "zsh": 45 | return filepath.Join(homeDir, ".zsh_history") 46 | case "bash": 47 | return filepath.Join(homeDir, ".bash_history") 48 | case "fish": 49 | return filepath.Join(homeDir, ".local/share/fish/fish_history") 50 | default: 51 | // Try bash as fallback 52 | bashHistory := filepath.Join(homeDir, ".bash_history") 53 | if _, err := os.Stat(bashHistory); err == nil { 54 | return bashHistory 55 | } 56 | // Try zsh as fallback 57 | zshHistory := filepath.Join(homeDir, ".zsh_history") 58 | if _, err := os.Stat(zshHistory); err == nil { 59 | return zshHistory 60 | } 61 | return "" 62 | } 63 | } 64 | 65 | // GetLastCommand retrieves the last command from shell history, skipping aifix commands 66 | func GetLastCommand(shell string) (string, error) { 67 | historyFile := GetShellHistoryFile(shell) 68 | if historyFile == "" { 69 | return "", fmt.Errorf("could not determine shell history file") 70 | } 71 | 72 | file, err := os.Open(historyFile) 73 | if err != nil { 74 | return "", fmt.Errorf("error opening history file: %v", err) 75 | } 76 | defer file.Close() 77 | 78 | var commands []string 79 | scanner := bufio.NewScanner(file) 80 | 81 | // For zsh history format: : timestamp:0;command 82 | // For bash: just command 83 | for scanner.Scan() { 84 | line := scanner.Text() 85 | if line == "" { 86 | continue 87 | } 88 | 89 | var cmd string 90 | // Handle zsh extended history format 91 | if strings.HasPrefix(line, ":") { 92 | parts := strings.SplitN(line, ";", 2) 93 | if len(parts) == 2 { 94 | cmd = parts[1] 95 | } 96 | } else { 97 | cmd = line 98 | } 99 | 100 | if cmd != "" { 101 | commands = append(commands, strings.TrimSpace(cmd)) 102 | } 103 | } 104 | 105 | if err := scanner.Err(); err != nil { 106 | return "", fmt.Errorf("error reading history file: %v", err) 107 | } 108 | 109 | // Find last command that's not aifix/fix 110 | for i := len(commands) - 1; i >= 0; i-- { 111 | cmd := commands[i] 112 | // Skip aifix, fix, and variations 113 | if !strings.HasPrefix(cmd, "aifix") && 114 | !strings.HasPrefix(cmd, "fix") && 115 | cmd != "aifix" && 116 | cmd != "fix" { 117 | return cmd, nil 118 | } 119 | } 120 | 121 | return "", fmt.Errorf("no commands found in history (excluding aifix)") 122 | } 123 | 124 | // GetRecentCommands retrieves the last N commands from shell history 125 | func GetRecentCommands(shell string, count int) ([]string, error) { 126 | historyFile := GetShellHistoryFile(shell) 127 | if historyFile == "" { 128 | return nil, fmt.Errorf("could not determine shell history file") 129 | } 130 | 131 | file, err := os.Open(historyFile) 132 | if err != nil { 133 | return nil, fmt.Errorf("error opening history file: %v", err) 134 | } 135 | defer file.Close() 136 | 137 | var commands []string 138 | scanner := bufio.NewScanner(file) 139 | 140 | for scanner.Scan() { 141 | line := scanner.Text() 142 | if line == "" { 143 | continue 144 | } 145 | 146 | var cmd string 147 | // Handle zsh extended history format 148 | if strings.HasPrefix(line, ":") { 149 | parts := strings.SplitN(line, ";", 2) 150 | if len(parts) == 2 { 151 | cmd = parts[1] 152 | } 153 | } else { 154 | cmd = line 155 | } 156 | 157 | if cmd != "" { 158 | commands = append(commands, strings.TrimSpace(cmd)) 159 | } 160 | } 161 | 162 | if err := scanner.Err(); err != nil { 163 | return nil, fmt.Errorf("error reading history file: %v", err) 164 | } 165 | 166 | // Return last N commands 167 | if len(commands) > count { 168 | return commands[len(commands)-count:], nil 169 | } 170 | return commands, nil 171 | } 172 | 173 | // CaptureLastError attempts to capture the last command's error output 174 | // This is a best-effort approach since we can't reliably capture stderr without shell integration 175 | func CaptureLastError(command string, shell string, os string) (string, error) { 176 | // Try to execute the command to reproduce the error 177 | // This is safe because we're not actually changing anything, just reading output 178 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 179 | defer cancel() 180 | 181 | var cmd *exec.Cmd 182 | if os == "windows" { 183 | cmd = exec.CommandContext(ctx, "cmd", "/C", command) 184 | } else { 185 | // Run with interactive shell flags to load aliases 186 | if shell == "zsh" { 187 | cmd = exec.CommandContext(ctx, shell, "-i", "-c", command) 188 | } else if shell == "bash" { 189 | cmd = exec.CommandContext(ctx, shell, "-i", "-c", command) 190 | } else { 191 | cmd = exec.CommandContext(ctx, shell, "-c", command) 192 | } 193 | } 194 | 195 | output, err := cmd.CombinedOutput() 196 | 197 | // Debug: log what we got 198 | if err != nil { 199 | // Command failed, which is expected 200 | return string(output), nil 201 | } 202 | 203 | // Command succeeded - check if there's still error-like output 204 | outputStr := string(output) 205 | if len(outputStr) > 0 { 206 | // If there's output, assume it's an error message 207 | return outputStr, nil 208 | } 209 | 210 | // No output and no error 211 | return "", fmt.Errorf("command succeeded with no error") 212 | } 213 | 214 | // TruncateError limits error output to prevent overwhelming the AI 215 | func TruncateError(errorText string, maxLines int) string { 216 | lines := strings.Split(errorText, "\n") 217 | if len(lines) <= maxLines { 218 | return errorText 219 | } 220 | 221 | // Take first 20 lines and last 20 lines to capture both root cause and final error 222 | firstPart := strings.Join(lines[:20], "\n") 223 | lastPart := strings.Join(lines[len(lines)-20:], "\n") 224 | 225 | return fmt.Sprintf("%s\n\n... [%d lines omitted] ...\n\n%s", 226 | firstPart, len(lines)-40, lastPart) 227 | } 228 | 229 | // FormatErrorContext creates a formatted context string for the AI 230 | func FormatErrorContext(ctx ErrorContext) string { 231 | var sb strings.Builder 232 | 233 | sb.WriteString(fmt.Sprintf("Command: %s\n", ctx.Command)) 234 | sb.WriteString(fmt.Sprintf("Shell: %s\n", ctx.Shell)) 235 | sb.WriteString(fmt.Sprintf("OS: %s\n", ctx.OS)) 236 | 237 | if len(ctx.RecentCmds) > 0 { 238 | sb.WriteString("\nRecent command history:\n") 239 | for i, cmd := range ctx.RecentCmds { 240 | sb.WriteString(fmt.Sprintf(" %d. %s\n", i+1, cmd)) 241 | } 242 | } 243 | 244 | sb.WriteString("\nError output:\n") 245 | sb.WriteString(ctx.Error) 246 | 247 | return sb.String() 248 | } 249 | 250 | // Execute is the main entry point for the aifix command 251 | func Execute(promptFile string, manualError string) error { 252 | conf, prompt, err := config.ReadAndParseConfig("config.yaml", promptFile) 253 | if err != nil { 254 | return fmt.Errorf("error reading configuration: %v", err) 255 | } 256 | 257 | operatingSystem, shell := utils.DetectOSAndShell() 258 | prompt = utils.ReplacePlaceholders(prompt, operatingSystem, shell) 259 | 260 | // Create AI client based on provider 261 | var aiClient nlp.GAIClient 262 | if conf.Provider == "anthropic" { 263 | anthropicClient := nlp.CreateAnthropicClient(*conf) 264 | aiClient = &nlp.AnthropicClient{ 265 | Client: anthropicClient, 266 | Prompt: prompt, 267 | } 268 | } else { 269 | openaiClient := nlp.CreateOpenAIClient(*conf) 270 | aiClient = &nlp.GoaiClient{ 271 | Client: openaiClient, 272 | Prompt: prompt, 273 | } 274 | } 275 | 276 | var errorContext ErrorContext 277 | errorContext.Shell = shell 278 | errorContext.OS = operatingSystem 279 | errorContext.Timestamp = time.Now().Format(time.RFC3339) 280 | 281 | // Check if manual error was provided 282 | if manualError != "" { 283 | // User provided error directly 284 | errorContext.Command = "N/A (manual error input)" 285 | errorContext.Error = manualError 286 | } else { 287 | // Try shell integration first (environment files) 288 | cmdFile := os.Getenv("AIFIX_CMD_FILE") 289 | errorFile := os.Getenv("AIFIX_ERROR_FILE") 290 | lastExit := os.Getenv("AIFIX_LAST_EXIT") 291 | 292 | if cmdFile != "" && lastExit != "0" && lastExit != "" { 293 | // Read last command from file 294 | cmdBytes, err := os.ReadFile(cmdFile) 295 | if err == nil && len(cmdBytes) > 0 { 296 | errorContext.Command = strings.TrimSpace(string(cmdBytes)) 297 | 298 | // Get recent commands for context 299 | recentCmds, err := GetRecentCommands(shell, 3) 300 | if err == nil && len(recentCmds) > 1 { 301 | errorContext.RecentCmds = recentCmds[:len(recentCmds)-1] 302 | } 303 | 304 | // Read error output from file (captured during original execution) 305 | if errorFile != "" { 306 | errorBytes, err := os.ReadFile(errorFile) 307 | if err == nil && len(errorBytes) > 0 { 308 | errorContext.Error = TruncateError(string(errorBytes), maxErrorLines) 309 | errorContext.ExitCode = 1 310 | } else { 311 | return fmt.Errorf("shell integration enabled but no error captured.\n\nMake sure you've added the integration to your shell config:\n aifix -init-shell zsh >> ~/.zshrc\n source ~/.zshrc") 312 | } 313 | } else { 314 | return fmt.Errorf("shell integration incomplete. Please run:\n aifix -init-shell zsh >> ~/.zshrc\n source ~/.zshrc") 315 | } 316 | } else { 317 | return fmt.Errorf("could not read command from file") 318 | } 319 | } else { 320 | // No shell integration - require manual error input 321 | return fmt.Errorf("no error detected.\n\nUsage:\n aifix \"your error message here\"\n\nOr install shell integration:\n aifix -init-shell zsh >> ~/.zshrc\n source ~/.zshrc") 322 | } 323 | } 324 | 325 | // Format context for AI 326 | contextStr := FormatErrorContext(errorContext) 327 | 328 | // Process with AI 329 | response, err := aiClient.ProcessCommand(contextStr, *conf) 330 | if err != nil { 331 | return fmt.Errorf("error processing with AI: %v", err) 332 | } 333 | 334 | // Extract and display response 335 | result := response.Choices[0].Message.Content 336 | result = strings.TrimSpace(result) 337 | 338 | fmt.Println(result) 339 | 340 | return nil 341 | } 342 | --------------------------------------------------------------------------------