├── .editorconfig ├── .gitattributes ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── ci.yaml │ └── release.yaml ├── .gitignore ├── .goreleaser.yaml ├── .vscode └── settings.json ├── CODEOWNERS ├── Formula └── opsy.rb ├── LICENSE ├── README.md ├── assets ├── assets.go ├── assets_test.go ├── doc.go ├── prompts │ ├── agent_system.tmpl │ ├── tool_system.tmpl │ └── tool_user.tmpl ├── themes │ └── default.yaml └── tools │ ├── aws.yaml │ ├── gcloud.yaml │ ├── gh.yaml │ ├── git.yaml │ ├── helm.yaml │ ├── jira.yaml │ └── kubectl.yaml ├── cmd └── opsy │ └── main.go ├── go.mod ├── go.sum ├── internal ├── agent │ ├── agent.go │ ├── agent_test.go │ └── doc.go ├── config │ ├── config.go │ ├── config_test.go │ ├── doc.go │ └── testdata │ │ ├── custom_config.yaml │ │ ├── invalid_log_level.yaml │ │ ├── invalid_max_tokens.yaml │ │ ├── invalid_temperature_high.yaml │ │ ├── invalid_temperature_low.yaml │ │ └── missing_api_key.yaml ├── thememanager │ ├── doc.go │ ├── testdata │ │ ├── invalid_format.yaml │ │ ├── invalid_missing_colors.yaml │ │ └── valid.yaml │ ├── theme.go │ ├── theme_test.go │ ├── thememanager.go │ └── thememanager_test.go ├── tool │ ├── doc.go │ ├── exec.go │ ├── exec_test.go │ ├── runner.go │ ├── tool.go │ └── tool_test.go ├── toolmanager │ ├── doc.go │ ├── testdata │ │ ├── executable_tool.yaml │ │ ├── invalid_tool.yaml │ │ └── test_tool.yaml │ ├── toolmanager.go │ └── toolmanager_test.go └── tui │ ├── components │ ├── commandspane │ │ ├── commandspane.go │ │ ├── commandspane_test.go │ │ └── doc.go │ ├── footer │ │ ├── doc.go │ │ ├── footer.go │ │ └── footer_test.go │ ├── header │ │ ├── doc.go │ │ ├── header.go │ │ └── header_test.go │ └── messagespane │ │ ├── doc.go │ │ ├── messagespane.go │ │ └── messagespane_test.go │ ├── doc.go │ ├── tui.go │ └── tui_test.go └── schemas ├── config.schema.json ├── theme.schema.json └── tool.schema.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | indent_style = space 9 | indent_size = 2 10 | 11 | [{go.mod,*.go}] 12 | indent_style = tab 13 | indent_size = 8 14 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | 3 | go.sum linguist-generated=true 4 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [tdabasinskas] 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | commit-message: 8 | prefix: chore 9 | prefix-development: chore 10 | include: scope 11 | - package-ecosystem: "gomod" 12 | directory: "/" 13 | schedule: 14 | interval: "daily" 15 | commit-message: 16 | prefix: chore 17 | prefix-development: chore 18 | include: scope 19 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | paths-ignore: 7 | - '.vscode/**' 8 | - 'Formula/**' 9 | - '.gitattributes' 10 | - '.gitignore' 11 | - 'CODEOWNERS' 12 | - 'README.md' 13 | - '.editorconfig' 14 | - 'LICENSE' 15 | pull_request: 16 | branches: [ "main" ] 17 | paths-ignore: 18 | - '.vscode/**' 19 | - 'Formula/**' 20 | - '.gitattributes' 21 | - '.gitignore' 22 | - 'CODEOWNERS' 23 | - 'README.md' 24 | - '.editorconfig' 25 | - 'LICENSE' 26 | 27 | concurrency: 28 | group: CI-${{ github.ref }} 29 | cancel-in-progress: true 30 | 31 | jobs: 32 | build: 33 | name: Build 34 | runs-on: ubuntu-latest 35 | steps: 36 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 37 | - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 38 | with: 39 | go-version-file: go.mod 40 | cache: true 41 | - name: Build 42 | run: go build -v ./... 43 | 44 | lint: 45 | name: Lint 46 | needs: build 47 | runs-on: ubuntu-latest 48 | steps: 49 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 50 | - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 51 | with: 52 | go-version-file: go.mod 53 | cache: true 54 | - name: Lint 55 | uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0 56 | with: 57 | version: v2.1.6 58 | 59 | test: 60 | name: Test 61 | needs: lint 62 | runs-on: ubuntu-latest 63 | steps: 64 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 65 | - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 66 | with: 67 | go-version-file: go.mod 68 | cache: true 69 | - name: Test 70 | run: go test -v -covermode=atomic ./... 71 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | permissions: 9 | contents: write 10 | 11 | concurrency: 12 | group: Release 13 | cancel-in-progress: false 14 | 15 | jobs: 16 | release: 17 | name: Release 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 21 | with: 22 | fetch-depth: 0 23 | - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 24 | with: 25 | go-version-file: 'go.mod' 26 | cache: true 27 | - name: Import GPG key 28 | uses: crazy-max/ghaction-import-gpg@e89d40939c28e39f97cf32126055eeae86ba74ec # v6.3.0 29 | id: import_gpg 30 | with: 31 | gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} 32 | passphrase: ${{ secrets.GPG_PRIVATE_KEY_PASSPHRASE }} 33 | - name: Run GoReleaser 34 | uses: goreleaser/goreleaser-action@9c156ee8a17a598857849441385a2041ef570552 # v6.3.0 35 | with: 36 | args: release --clean 37 | env: 38 | GITHUB_TOKEN: ${{ secrets.BOT_TOKEN }} 39 | GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }} 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | 17 | # Go workspace file 18 | go.work 19 | go.work.sum 20 | 21 | # Sensitive files 22 | .env 23 | # Added by goreleaser init: 24 | dist/ 25 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | before: 4 | hooks: 5 | - go mod tidy 6 | - go generate ./... 7 | 8 | builds: 9 | - env: 10 | - CGO_ENABLED=0 11 | goos: 12 | - linux 13 | - darwin 14 | goarch: 15 | - amd64 16 | - '386' 17 | - arm 18 | - arm64 19 | ignore: 20 | - goos: darwin 21 | goarch: '386' 22 | main: ./cmd/opsy 23 | 24 | archives: 25 | - formats: tar.gz 26 | name_template: >- 27 | {{ .ProjectName }}_ 28 | {{- title .Os }}_ 29 | {{- if eq .Arch "amd64" }}x86_64 30 | {{- else if eq .Arch "386" }}i386 31 | {{- else }}{{ .Arch }}{{ end }} 32 | {{- if .Arm }}v{{ .Arm }}{{ end }} 33 | 34 | signs: 35 | - artifacts: checksum 36 | args: 37 | - "--batch" 38 | - "--local-user" 39 | - "{{ .Env.GPG_FINGERPRINT }}" 40 | - "--output" 41 | - "${signature}" 42 | - "--detach-sign" 43 | - "${artifact}" 44 | 45 | changelog: 46 | sort: asc 47 | filters: 48 | exclude: 49 | - "^docs:" 50 | - "^test:" 51 | 52 | release: 53 | footer: >- 54 | 55 | --- 56 | 57 | Released by [GoReleaser](https://github.com/goreleaser/goreleaser). 58 | 59 | brews: 60 | - commit_author: 61 | name: datolabs-bot 62 | email: github-bot@datolabs.io 63 | commit_msg_template: "chore(brew): formula update for {{ .ProjectName }} version {{ .Tag }}" 64 | directory: Formula 65 | homepage: https://github.com/datolabs-io/opsy 66 | description: Your AI-Powered SRE Colleague 67 | repository: 68 | owner: datolabs-io 69 | name: opsy 70 | pull_request: 71 | enabled: false 72 | token: "{{ .Env.GITHUB_TOKEN }}" 73 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "yaml.schemas": { 3 | "./schemas/tool.schema.json": [ 4 | "assets/tools/*.yaml", 5 | "internal/toolmanager/testdata/*.yaml" 6 | ], 7 | "./schemas/theme.schema.json": [ 8 | "assets/themes/*.yaml", 9 | "internal/thememanager/testdata/*.yaml" 10 | ], 11 | "./schemas/config.schema.json": [ 12 | "internal/config/testdata/*.yaml" 13 | ] 14 | }, 15 | "cSpell.ignorePaths": [ 16 | "go.sum", 17 | "go.mod" 18 | ], 19 | "cSpell.words": [ 20 | "agnt", 21 | "anthropics", 22 | "bubbletea", 23 | "charmbracelet", 24 | "CODEOWNERS", 25 | "commandspane", 26 | "covermode", 27 | "dylib", 28 | "goarch", 29 | "gomod", 30 | "gopkg", 31 | "goreleaser", 32 | "invopop", 33 | "jsonparser", 34 | "jsonschema", 35 | "lipgloss", 36 | "mapstructure", 37 | "messagespane", 38 | "nolint", 39 | "nonexistentcommand", 40 | "opsy", 41 | "orderedmap", 42 | "stretchr", 43 | "testdata", 44 | "textinput", 45 | "thememanager", 46 | "themer", 47 | "tmpl", 48 | "toolmanager", 49 | "wordwrap" 50 | ] 51 | } 52 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @datolabs-io/opsy-maintainers 2 | -------------------------------------------------------------------------------- /Formula/opsy.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | # frozen_string_literal: true 3 | 4 | # This file was generated by GoReleaser. DO NOT EDIT. 5 | class Opsy < Formula 6 | desc "Your AI-Powered SRE Colleague" 7 | homepage "https://github.com/datolabs-io/opsy" 8 | version "0.0.2" 9 | 10 | on_macos do 11 | if Hardware::CPU.intel? 12 | url "https://github.com/datolabs-io/opsy/releases/download/v0.0.2/opsy_Darwin_x86_64.tar.gz" 13 | sha256 "ab3b4bbe0c6fc1ee551489aed0822fb688142443ed1037c8cabaaab1c6eeb955" 14 | 15 | def install 16 | bin.install "opsy" 17 | end 18 | end 19 | if Hardware::CPU.arm? 20 | url "https://github.com/datolabs-io/opsy/releases/download/v0.0.2/opsy_Darwin_arm64.tar.gz" 21 | sha256 "e2445764f424be360d0c3462a402e2aee384e64d68634213b642373983d57a8e" 22 | 23 | def install 24 | bin.install "opsy" 25 | end 26 | end 27 | end 28 | 29 | on_linux do 30 | if Hardware::CPU.intel? 31 | if Hardware::CPU.is_64_bit? 32 | url "https://github.com/datolabs-io/opsy/releases/download/v0.0.2/opsy_Linux_x86_64.tar.gz" 33 | sha256 "c3f0ed8587d91a29291f737e8069d8074605f6c582fb1a3588c5093b2029419f" 34 | 35 | def install 36 | bin.install "opsy" 37 | end 38 | end 39 | end 40 | if Hardware::CPU.arm? 41 | if !Hardware::CPU.is_64_bit? 42 | url "https://github.com/datolabs-io/opsy/releases/download/v0.0.2/opsy_Linux_armv6.tar.gz" 43 | sha256 "a05aff97bc95e9870cc561823f76e313873ee4404fcb6e31b879d8ba32af553a" 44 | 45 | def install 46 | bin.install "opsy" 47 | end 48 | end 49 | end 50 | if Hardware::CPU.arm? 51 | if Hardware::CPU.is_64_bit? 52 | url "https://github.com/datolabs-io/opsy/releases/download/v0.0.2/opsy_Linux_arm64.tar.gz" 53 | sha256 "52b85f8d365fb1029e5fbf0048619599a08902307d8523afc864666ebd9b77bd" 54 | 55 | def install 56 | bin.install "opsy" 57 | end 58 | end 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Opsy - Your AI-Powered SRE Colleague 2 | 3 | ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/datolabs-io/opsy/.github%2Fworkflows%2Fci.yaml?label=CI) 4 | ![GitHub Release](https://img.shields.io/github/v/release/datolabs-io/opsy?label=Release) 5 | 6 | Opsy is an intelligent command-line assistant designed for Site Reliability Engineers (SREs), DevOps professionals, and platform engineers. It uses AI to help you navigate operational challenges, troubleshoot issues, and automate routine workflows. Opsy integrates with your existing tools and provides contextual assistance to make your daily operations more efficient. 7 | 8 | Opsy uses a "tools-as-agents" architecture where each tool functions as a specialized AI agent with expertise in its domain (Kubernetes, Git, AWS, etc.). The main Opsy agent orchestrates these specialized agents, breaking down complex tasks and delegating them to the appropriate tools. This approach provides domain-specific expertise, improved safety through tool-specific validation, better context management for multi-step operations, and modular extensibility for adding new capabilities. 9 | 10 | > [!WARNING] 11 | > Opsy is currently in early development. While the core functionality works well, some features are still being refined. We recommend using it in non-production environments for now. We welcome your feedback to help improve Opsy. 12 | 13 | ## Demo 14 | 15 | [![Opsy Demo](https://github.com/user-attachments/assets/19e27126-baa9-432e-a014-50a18d165fde)](https://youtu.be/j5sWZDvTFtA) 16 | 17 | The demo above shows Opsy handling this complex task: 18 | 19 | > Analyze the pods in the current namespace. If there are any pods that are failing, I need you to analyze the reason it is failing. Then, create a single Jira task named `Kubernetes issues` in `OPSY` project reporting the issue. The task description must contain your analysis for on the failing pods. In addition, I want to have backups for our deployments: extract the deployment manifests and push them into a new private `backup` repo in `datolabs-io-sandbox`. 20 | 21 | Click on the screenshot to [watch the full demonstration](https://youtu.be/j5sWZDvTFtA). 22 | 23 | ## Prerequisites 24 | 25 | ### Anthropic API Key 26 | 27 | Opsy uses Anthropic's Claude AI models to provide intelligent assistance. You'll need an Anthropic API key: 28 | 29 | 1. Create an account at [Anthropic's website](https://www.anthropic.com/) 30 | 2. Generate an API key from your account dashboard 31 | 3. Set the API key in your Opsy configuration (see Configuration section) or as an environment variable: 32 | 33 | ```bash 34 | export ANTHROPIC_API_KEY=your_api_key_here 35 | ``` 36 | 37 | ### Command-Line Tools 38 | 39 | Opsy works with standard [command-line tools](./assets/tools/). While none are strictly required to run Opsy, having them installed expands its capabilities: 40 | 41 | - [Git](https://git-scm.com/downloads) - Version control 42 | - [GitHub CLI](https://cli.github.com) - GitHub integration 43 | - [kubectl](https://kubernetes.io/docs/tasks/tools/) - Kubernetes management 44 | - [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html) - AWS management 45 | - [Helm](https://helm.sh/docs/intro/install/) - Kubernetes package manager 46 | - [Google Cloud CLI (gcloud)](https://cloud.google.com/sdk/docs/install) - Google Cloud management 47 | - [Jira CLI](https://github.com/ankitpokhrel/jira-cli) - Jira automation 48 | 49 | Opsy adapts to your environment and only uses tools that are installed on your system. 50 | 51 | ## Installation 52 | 53 | ### Via Go Install 54 | 55 | For users with Go 1.24 or later: 56 | 57 | ```bash 58 | go install github.com/datolabs-io/opsy/cmd/opsy@latest 59 | ``` 60 | 61 | Ensure your Go bin directory is in your PATH. 62 | 63 | ### Via Homebrew 64 | 65 | For macOS and Linux users with [Homebrew](https://brew.sh): 66 | 67 | ```bash 68 | brew tap datolabs-io/opsy https://github.com/datolabs-io/opsy 69 | brew install datolabs-io/opsy/opsy 70 | ``` 71 | 72 | ### Direct Download 73 | 74 | Each [release](https://github.com/datolabs-io/opsy/releases) includes binaries for various platforms: 75 | 76 | 1. Download the appropriate binary for your operating system 77 | 2. Make it executable (Unix-based systems): `chmod +x opsy` 78 | 3. Move it to a directory in your `PATH`: `mv opsy /usr/local/bin/` (or another directory in your `PATH`) 79 | 80 | ## Usage 81 | 82 | Opsy is simple to use. Just describe what you want to do in plain language, and Opsy will handle the rest. 83 | 84 | ```bash 85 | opsy 'Your task description here' 86 | ``` 87 | 88 | For example: 89 | 90 | ```bash 91 | # Repository management 92 | opsy 'Create a new private repository in datolabs-io organization named backup' 93 | 94 | # Kubernetes troubleshooting 95 | opsy 'Check why pods in the production namespace are crashing' 96 | 97 | # Log analysis 98 | opsy 'Find errors in the application logs from the last hour' 99 | ``` 100 | 101 | Opsy interprets your instructions, builds a plan, and executes the necessary actions to complete your task—no additional input required. 102 | 103 | ## Configuration 104 | 105 | Opsy is configured via a YAML file located at `~/.opsy/config.yaml`: 106 | 107 | ```yaml 108 | # UI configuration 109 | ui: 110 | # Theme for the UI (default: "default") 111 | theme: default 112 | 113 | # Logging configuration 114 | logging: 115 | # Path to the log file (default: "~/.opsy/log.log") 116 | path: ~/.opsy/log.log 117 | # Logging level: debug, info, warn, error (default: "info") 118 | level: info 119 | 120 | # Anthropic API configuration 121 | anthropic: 122 | # Your Anthropic API key (required) 123 | api_key: your_api_key_here 124 | # Model to use (default: "claude-3-7-sonnet-latest") 125 | model: claude-3-7-sonnet-latest 126 | # Temperature for generation (default: 0.5) 127 | temperature: 0.5 128 | # Maximum tokens to generate (default: 1024) 129 | max_tokens: 1024 130 | 131 | # Tools configuration 132 | tools: 133 | # Maximum duration in seconds for a tool to execute (default: 120) 134 | timeout: 120 135 | # Exec tool configuration 136 | exec: 137 | # Timeout for exec tool (0 means use global timeout) (default: 0) 138 | timeout: 0 139 | # Shell to use for execution (default: "/bin/bash") 140 | shell: /bin/bash 141 | ``` 142 | 143 | You can also set configuration using environment variables with the prefix `OPSY_` followed by the configuration path in uppercase with underscores: 144 | 145 | ```bash 146 | # Set the logging level 147 | export OPSY_LOGGING_LEVEL=debug 148 | 149 | # Set the tools timeout 150 | export OPSY_TOOLS_TIMEOUT=180 151 | ``` 152 | 153 | The Anthropic API key can also be set via `ANTHROPIC_API_KEY` (without the `OPSY_` prefix). 154 | 155 | ## Extending & Contributing 156 | 157 | We welcome contributions to Opsy! The project is designed to be easily extended. 158 | 159 | To contribute: 160 | 161 | 1. Fork the repository 162 | 2. Create a feature branch (`git checkout -b feature/amazing-feature`) 163 | 3. Commit your changes (`git commit -m 'Add some amazing feature'`) 164 | 4. Push to the branch (`git push origin feature/amazing-feature`) 165 | 5. Open a Pull Request 166 | 167 | Please update tests as appropriate and follow the existing coding style. 168 | 169 | Here's how you can extend Opsy's capabilities: 170 | 171 | ### System Prompts 172 | 173 | System prompts in [./assets/prompts](./assets/prompts/) define how Opsy understands and responds to user tasks: 174 | 175 | #### Agent System Prompt 176 | 177 | The primary prompt ([assets/prompts/agent_system.tmpl](./assets/prompts/agent_system.tmpl)) guides Opsy's overall behavior, establishing its identity as an AI assistant for SREs and DevOps professionals and defining the format for execution plans. 178 | 179 | #### Tool System Prompt 180 | 181 | This prompt ([assets/prompts/tool_system.tmpl](./assets/prompts/tool_system.tmpl)) defines how Opsy interacts with external tools, ensuring interactions are safe, effective, and follow best practices. 182 | 183 | #### Tool User Prompt 184 | 185 | This prompt ([assets/prompts/tool_user.tmpl](./assets/prompts/tool_user.tmpl)) defines the format for requesting tool execution, maintaining consistency in how tools are invoked. 186 | 187 | To contribute a new prompt or modify an existing one, add it to the repository and submit a pull request. 188 | 189 | ### Tools 190 | 191 | Tool definitions in [assets/tools/](./assets/tools/) allow Opsy to interact with various systems and services: 192 | 193 | ```yaml 194 | --- 195 | display_name: Tool Name 196 | executable: command-name 197 | description: Description of what the tool does 198 | inputs: 199 | parameter1: 200 | type: string 201 | description: Description of the first parameter 202 | default: "default-value" # Optional default value 203 | examples: 204 | - "example1" 205 | - "example2" 206 | optional: false # Whether this parameter is required 207 | rules: 208 | - 'Rule 1 for using this tool' 209 | - 'Rule 2 for using this tool' 210 | ``` 211 | 212 | ### Themes 213 | 214 | Theme definitions in [assets/themes/](./assets/themes/) control Opsy's visual appearance: 215 | 216 | ```yaml 217 | base: 218 | base00: "#1A1B26" # Primary background 219 | base01: "#24283B" # Secondary background 220 | base02: "#292E42" # Borders and dividers 221 | base03: "#565F89" # Muted text 222 | base04: "#A9B1D6" # Primary text 223 | 224 | accent: 225 | accent0: "#FF9E64" # Command text 226 | accent1: "#9ECE6A" # Agent messages 227 | accent2: "#7AA2F7" # Tool output 228 | ``` 229 | 230 | ## Acknowledgments 231 | 232 | - [Charm](https://github.com/charmbracelet) for their TUI libraries 233 | - [Anthropic](https://github.com/anthropics/anthropic-sdk-go) for their Go SDK for Claude AI models 234 | - [Viper](https://github.com/spf13/viper) for configuration management 235 | - Various Go libraries for schema validation, data structures, and YAML parsing 236 | - The Go community for excellent tooling and support 237 | 238 | ## License 239 | 240 | This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details. 241 | -------------------------------------------------------------------------------- /assets/assets.go: -------------------------------------------------------------------------------- 1 | package assets 2 | 3 | import ( 4 | "bytes" 5 | "embed" 6 | "html/template" 7 | ) 8 | 9 | var ( 10 | //go:embed themes 11 | Themes embed.FS 12 | //go:embed tools 13 | Tools embed.FS 14 | 15 | // ToolsDir is the directory containing the tools. 16 | ToolsDir = "tools" 17 | // ThemeDir is the directory containing the themes. 18 | ThemeDir = "themes" 19 | 20 | //go:embed prompts/agent_system.tmpl 21 | agentSystemPrompt string 22 | //go:embed prompts/tool_system.tmpl 23 | toolSystemPrompt string 24 | //go:embed prompts/tool_user.tmpl 25 | toolUserPrompt string 26 | ) 27 | 28 | const ( 29 | // ErrToolRenderingPrompt is the error returned when a prompt cannot be rendered. 30 | ErrToolRenderingPrompt = "prompt cannot be rendered" 31 | ) 32 | 33 | // AgentSystemPromptData is the data for the agent system prompt. 34 | type AgentSystemPromptData struct { 35 | // Shell is the shell to use for the agent. 36 | Shell string 37 | } 38 | 39 | // ToolSystemPromptData is the data for the tool system prompt. 40 | type ToolSystemPromptData struct { 41 | // Shell is the shell to use for the tool. 42 | Shell string 43 | // Name is the name of the tool. 44 | Name string 45 | // Executable is the executable to use for the tool. 46 | Executable string 47 | // Rules are the rules for the tool. 48 | Rules []string 49 | } 50 | 51 | // ToolUserPromptData is the data for the tool user prompt. 52 | type ToolUserPromptData struct { 53 | // Task is the task to complete. 54 | Task string 55 | // Params are the parameters for the tool. 56 | Params map[string]any 57 | // Context is the context for the tool. 58 | Context map[string]string 59 | // WorkingDirectory is the working directory for the tool. 60 | WorkingDirectory string 61 | } 62 | 63 | // RenderAgentSystemPrompt renders the agent system prompt. 64 | func RenderAgentSystemPrompt(data *AgentSystemPromptData) (string, error) { 65 | return render("agent_system", agentSystemPrompt, data) 66 | } 67 | 68 | // RenderToolSystemPrompt renders the tool system prompt. 69 | func RenderToolSystemPrompt(data *ToolSystemPromptData) (string, error) { 70 | return render("tool_system", toolSystemPrompt, data) 71 | } 72 | 73 | // RenderToolUserPrompt renders the tool user prompt. 74 | func RenderToolUserPrompt(data *ToolUserPromptData) (string, error) { 75 | return render("tool_user", toolUserPrompt, data) 76 | } 77 | 78 | // render is a generic function that renders a template with the given data. 79 | func render(templateName, templateContent string, data any) (string, error) { 80 | tmpl, err := template.New(templateName).Parse(templateContent) 81 | if err != nil { 82 | return "", err 83 | } 84 | 85 | var buf bytes.Buffer 86 | if err := tmpl.Execute(&buf, data); err != nil { 87 | return "", err 88 | } 89 | 90 | return buf.String(), nil 91 | } 92 | -------------------------------------------------------------------------------- /assets/assets_test.go: -------------------------------------------------------------------------------- 1 | package assets 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestRenderAgentSystemPrompt(t *testing.T) { 11 | t.Run("renders with valid data", func(t *testing.T) { 12 | data := &AgentSystemPromptData{ 13 | Shell: "/bin/bash", 14 | } 15 | result, err := RenderAgentSystemPrompt(data) 16 | require.NoError(t, err) 17 | assert.Contains(t, result, "/bin/bash") 18 | assert.NotEmpty(t, result) 19 | }) 20 | 21 | t.Run("handles empty shell", func(t *testing.T) { 22 | data := &AgentSystemPromptData{} 23 | result, err := RenderAgentSystemPrompt(data) 24 | require.NoError(t, err) 25 | assert.NotEmpty(t, result) 26 | }) 27 | 28 | t.Run("handles nil data", func(t *testing.T) { 29 | _, err := RenderAgentSystemPrompt(nil) 30 | assert.Error(t, err) 31 | }) 32 | } 33 | 34 | func TestRenderToolSystemPrompt(t *testing.T) { 35 | t.Run("renders with valid data", func(t *testing.T) { 36 | data := &ToolSystemPromptData{ 37 | Shell: "/bin/bash", 38 | Name: "test-tool", 39 | Executable: "/usr/bin/test", 40 | Rules: []string{"rule1", "rule2"}, 41 | } 42 | result, err := RenderToolSystemPrompt(data) 43 | require.NoError(t, err) 44 | assert.Contains(t, result, "/bin/bash") 45 | assert.Contains(t, result, "test-tool") 46 | assert.Contains(t, result, "/usr/bin/test") 47 | assert.Contains(t, result, "rule1") 48 | assert.Contains(t, result, "rule2") 49 | assert.NotEmpty(t, result) 50 | }) 51 | 52 | t.Run("handles empty fields", func(t *testing.T) { 53 | data := &ToolSystemPromptData{} 54 | result, err := RenderToolSystemPrompt(data) 55 | require.NoError(t, err) 56 | assert.NotEmpty(t, result) 57 | }) 58 | 59 | t.Run("handles nil data", func(t *testing.T) { 60 | _, err := RenderToolSystemPrompt(nil) 61 | assert.Error(t, err) 62 | }) 63 | 64 | t.Run("handles empty rules", func(t *testing.T) { 65 | data := &ToolSystemPromptData{ 66 | Shell: "/bin/bash", 67 | Name: "test-tool", 68 | Executable: "/usr/bin/test", 69 | } 70 | result, err := RenderToolSystemPrompt(data) 71 | require.NoError(t, err) 72 | assert.NotEmpty(t, result) 73 | }) 74 | } 75 | 76 | func TestRenderToolUserPrompt(t *testing.T) { 77 | t.Run("renders with valid data", func(t *testing.T) { 78 | data := &ToolUserPromptData{ 79 | Task: "test task", 80 | Params: map[string]any{ 81 | "param1": "value1", 82 | "param2": 42, 83 | }, 84 | Context: map[string]string{ 85 | "ctx1": "value1", 86 | "ctx2": "value2", 87 | }, 88 | WorkingDirectory: "/test/dir", 89 | } 90 | result, err := RenderToolUserPrompt(data) 91 | require.NoError(t, err) 92 | assert.Contains(t, result, "test task") 93 | assert.Contains(t, result, "param1") 94 | assert.Contains(t, result, "value1") 95 | assert.Contains(t, result, "ctx1") 96 | assert.Contains(t, result, "/test/dir") 97 | assert.NotEmpty(t, result) 98 | }) 99 | 100 | t.Run("handles empty fields", func(t *testing.T) { 101 | data := &ToolUserPromptData{} 102 | result, err := RenderToolUserPrompt(data) 103 | require.NoError(t, err) 104 | assert.NotEmpty(t, result) 105 | }) 106 | 107 | t.Run("handles nil data", func(t *testing.T) { 108 | _, err := RenderToolUserPrompt(nil) 109 | assert.Error(t, err) 110 | }) 111 | 112 | t.Run("handles empty maps", func(t *testing.T) { 113 | data := &ToolUserPromptData{ 114 | Task: "test task", 115 | WorkingDirectory: "/test/dir", 116 | } 117 | result, err := RenderToolUserPrompt(data) 118 | require.NoError(t, err) 119 | assert.NotEmpty(t, result) 120 | }) 121 | } 122 | 123 | func TestEmbeddedFS(t *testing.T) { 124 | t.Run("themes fs is accessible", func(t *testing.T) { 125 | entries, err := Themes.ReadDir(ThemeDir) 126 | require.NoError(t, err) 127 | assert.NotEmpty(t, entries) 128 | }) 129 | 130 | t.Run("tools fs is accessible", func(t *testing.T) { 131 | entries, err := Tools.ReadDir(ToolsDir) 132 | require.NoError(t, err) 133 | assert.NotEmpty(t, entries) 134 | }) 135 | } 136 | -------------------------------------------------------------------------------- /assets/doc.go: -------------------------------------------------------------------------------- 1 | // Package assets provides embedded static assets for the opsy application. 2 | // 3 | // The package uses Go's embed functionality to include various static assets 4 | // that are required for the application to function. These assets are compiled 5 | // into the binary, ensuring they are always available at runtime. 6 | // 7 | // # Embedded Assets 8 | // 9 | // The package contains three main categories of embedded assets: 10 | // 11 | // Themes Directory (/themes): 12 | // - Contains theme configuration files in YAML format 13 | // - Includes default.yaml which defines the default application theme 14 | // - Themes are used to customize the appearance of the terminal UI 15 | // 16 | // Tools Directory (/tools): 17 | // - Contains tool-specific configuration files in YAML format 18 | // - Includes git.yaml which defines Git-related configurations and commands 19 | // - Tools configurations define how opsy interacts with various development tools 20 | // - Each tool can define its own system prompt in its configuration 21 | // 22 | // Prompts: 23 | // 24 | // - Agent System Prompt (Main system prompt for the AI agent) 25 | // Used for task understanding and dispatching 26 | // Defines the agent's core behavior and capabilities 27 | // Controls how the agent interacts with tools and handles tasks 28 | // 29 | // - Tool System Prompt (Common system prompt for all tools) 30 | // Appended to each tool's specific system prompt 31 | // Defines common behavior and patterns for all tools 32 | // Ensures consistent tool execution and output formatting 33 | // 34 | // - Tool User Prompt (User prompt template for tool execution) 35 | // Used to format commands for shell execution 36 | // Provides consistent command generation across tools 37 | // Includes task description and additional context 38 | // 39 | // # Usage 40 | // 41 | // The assets are exposed through two embedded filesystems and prompt rendering functions: 42 | // 43 | // var Themes embed.FS // Access to theme configurations 44 | // var Tools embed.FS // Access to tool configurations 45 | // 46 | // To access theme and tool configurations, use standard fs.FS operations: 47 | // 48 | // themeData, err := assets.Themes.ReadFile("themes/default.yaml") 49 | // toolData, err := assets.Tools.ReadFile("tools/git.yaml") 50 | // 51 | // To render prompts, use the provided render functions: 52 | // 53 | // // Render agent system prompt 54 | // prompt, err := assets.RenderAgentSystemPrompt(&AgentSystemPromptData{ 55 | // Shell: "/bin/bash", 56 | // }) 57 | // 58 | // // Render tool system prompt 59 | // prompt, err := assets.RenderToolSystemPrompt(&ToolSystemPromptData{ 60 | // Shell: "/bin/bash", 61 | // Name: "git", 62 | // Executable: "/usr/bin/git", 63 | // Rules: []string{"rule1", "rule2"}, 64 | // }) 65 | // 66 | // // Render tool user prompt 67 | // prompt, err := assets.RenderToolUserPrompt(&ToolUserPromptData{ 68 | // Task: "Clone repository", 69 | // Params: map[string]any{"url": "https://github.com/example/repo"}, 70 | // Context: map[string]string{"branch": "main"}, 71 | // WorkingDirectory: "/path/to/workspace", 72 | // }) 73 | // 74 | // Each render function accepts a specific data struct and returns the rendered prompt 75 | // as a string. If there's an error during rendering, it will be returned along with 76 | // an empty string. 77 | package assets 78 | -------------------------------------------------------------------------------- /assets/prompts/agent_system.tmpl: -------------------------------------------------------------------------------- 1 | You are non-interactive AI agent for SREs, DevOps, Platform Engineers and system administrators. 2 | You are given a task to complete. You have access to a set of tools that can help you complete the task. 3 | 4 | Once you receive the task, analyze it and prepare the execution plan. Your message with the plan 5 | must contain no additional text apart from the ones defined in the tags. 6 | 7 | 8 | [One or two sentences explaining how you understood the task.] 9 | [Step by step plan of what to do to complete the task and what tool will be used for each task] 10 | [No additional text or comments] 11 | 12 | 13 | Below tag contains an example how the output of plan execution should look like. 14 | 15 | 16 | It seems you would like to find all repositories in `datolabs-io` GitHub organization. Then, you would like to 17 | find all Helm releases that have a naming matching the repository name. Once found, you need to create a new file 18 | called `releases.md` in the `docs` directory. This file should contain the list of all releases and their descriptions. 19 | 20 | 1. Find all repositories in `datolabs-io` GitHub organization (using `GitHub` tool) 21 | 2. Clone each repository (using `GitHub` tool) 22 | 3. Find all Helm releases that have a naming matching the repository name (using `Helm` tool) 23 | 4. Create a new file called `releases.md` in the `docs` directory (using `Exec` tool) 24 | 5. Write the list of all releases and their descriptions to the `releases.md` file (using `Exec` tool) 25 | 6. Commit and push the the changes to a new branch (using `Git` tool) 26 | 7. Create a new Pull Request (using `GitHub` tool) 27 | 28 | 29 | Once you receive output from the tool you executed, analyze the output to determinate if any additional actions are 30 | needed or the output is final. In case you needed to retrieve some information from the tool and the output is not 31 | in a correct format, you run additional shell command via `Exec` tool to transform the output to a correct format. 32 | Example of the output from the tool is provided in tag. 33 | 34 | 35 | Successfully retrieved the following repositories from `datolabs-io` GitHub organization: 36 | 37 | - `datolabs-io/datolabs-io` 38 | - `datolabs-io/datolabs-io-helm` 39 | - `datolabs-io/datolabs-io-k8s` 40 | 41 | 42 | Once you are confident that you completed all tasks, output the final message in tags. 43 | 44 | 45 | [Overall task execution status.] 46 | [Status and summary of each completed step.] 47 | [List of errors encountered during the execution.] 48 | 49 | 50 | Example of the final output is provided in tag. 51 | 52 | 53 | Task completed successfully. 54 | 55 | 1. All repositories from `datolabs-io` GitHub organization were successfully retrieved. 56 | 2. All repositories were cloned successfully. 57 | 3. All Helm releases were found successfully. 58 | 4. The `releases.md` file was created successfully. 59 | 5. The `releases.md` file was written successfully. 60 | 6. The new branch was created successfully. 61 | 7. The Pull Request was created successfully. 62 | 63 | Errors encountered during the execution: 64 | 65 | - None 66 | 67 | 68 | General rules: 69 | - Do not ask any question or input from the user. 70 | - If you encounter an error, try again 3 times, passing additional information to the tool if needed. 71 | - Always try passing all additional specifications from the user request to the tool via `context` parameter. 72 | - The tools might need need to be aware of the working directory. Pass the working directory to the tool via 73 | `working_directory` parameter. 74 | - Even if user hasn't requested explicitly, remember that all before pushing any changes with `GitHub` tool to GitHub, 75 | you first need to use `Git` tool to create a new branch (if it doesn't exist yet), switch to it and add all the changes. 76 | - If you used `Git` tool to create a new branch, make sure to always use `Git` tool again to push the branch prior 77 | `GitHub` tool to create a Pull Request. 78 | - When using `Exec`, `Git` and `GitHub` tools, always make sure you are in a correct working directory. 79 | - If you are working with multiple entities (e.g. repositories, folders, clusters, etc.), always make sure to complete 80 | the task for one entity before moving to the next one. 81 | - If you are using `Exec` tool, the commands will be run in `{{.Shell}}` shell. 82 | -------------------------------------------------------------------------------- /assets/prompts/tool_system.tmpl: -------------------------------------------------------------------------------- 1 | You are a senior SRE engineer, specializing in working with {{.Name}}. 2 | Your primary function is to generate and execute shell commands and handle any errors or issues that may arise. 3 | You must operate autonomously, making decisions and resolving problems without user intervention. 4 | 5 | Command Execution Environment: 6 | - All commands are executed via the `Exec` tool in a `{{.Shell}}` shell 7 | - Use proper syntax for the shell to handle variable expansion, command substitution, pipeline operations, 8 | file redirection, and error handling. 9 | - You must use the `{{.Executable}}` executable to execute the commands. 10 | 11 | Command Generation Rules: 12 | 1. Generate precise, minimal commands that accomplish the task 13 | 2. Include only necessary flags and options 14 | 3. Escape special characters and handle spaces in paths/arguments 15 | 4. Quote values that may contain special characters 16 | 5. Never substitute default or hardcoded values when specific parameters are provided 17 | 6. When handling files, strictly use specified filenames/paths 18 | 7. Maintain parameter values exactly as provided or obtained from other tools 19 | 20 | Safety Practices: 21 | 1. Prefer safe alternatives when available 22 | 2. Include necessary backup steps before execution 23 | 3. Validate current state and prerequisites 24 | 4. Consider impact on collaborative workflows 25 | 5. Provide rollback procedures when possible 26 | 27 | Error Handling Process: 28 | 1. Analyze error type: 29 | - Missing prerequisites: Execute them automatically 30 | - Permission issues: Try alternative auth methods 31 | - Resource conflicts: Resolve automatically 32 | - Network/timing: Retry with backoff 33 | 34 | 2. Automatic Retry Strategy: 35 | - First attempt: Original command 36 | - Second attempt: After fulfilling prerequisites 37 | - Third attempt: Alternative approach/syntax 38 | - Final attempt: Break into smaller steps 39 | 40 | General rules: 41 | - Do not use sudo elevation unless explicitly required 42 | - Execute only a single command per request 43 | - Always consider and report the current working directory 44 | - Respect file permissions 45 | - Handle paths relative to workspace 46 | - Do not include raw command output in responses 47 | - Do not improvize and perform any actions that are not explicitly requested 48 | - Never request user input - work with available information 49 | - If a command cannot be safely executed, explain why and stop 50 | - Handle any errors and retry the command if needed 51 | - If command execution fails, try passing `--help` or `help` flag to the command to get the right syntax 52 | {{range .Rules}} 53 | - {{.}} 54 | {{end}} 55 | 56 | Example output structure: 57 | 58 | 59 | [Exact command(s) to be executed] 60 | 61 | 62 | 63 | [Report of results or failure explanation] 64 | 65 | 66 | Do not include any additional text or comments in your response. 67 | -------------------------------------------------------------------------------- /assets/prompts/tool_user.tmpl: -------------------------------------------------------------------------------- 1 | {{.Task}}. 2 | 3 | Current working directory: `{{.WorkingDirectory}}`. 4 | 5 | Additional parameters for completing the task: 6 | 7 | {{range $key, $value := .Params}} 8 | - `{{ $key }}`: `{{ $value }}` 9 | {{end}} 10 | {{ range $key, $value := .Context }} 11 | - `{{ $key }}`: `{{ $value }}` 12 | {{end}} 13 | -------------------------------------------------------------------------------- /assets/themes/default.yaml: -------------------------------------------------------------------------------- 1 | base: 2 | base00: "#1A1B26" # Primary background 3 | base01: "#24283B" # Secondary background 4 | base02: "#292E42" # Borders and dividers 5 | base03: "#565F89" # Muted text 6 | base04: "#A9B1D6" # Primary text 7 | 8 | accent: 9 | accent0: "#FF9E64" # Command text 10 | accent1: "#9ECE6A" # Agent messages 11 | accent2: "#7AA2F7" # Tool output 12 | -------------------------------------------------------------------------------- /assets/tools/aws.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | display_name: AWS 3 | executable: aws 4 | description: Manages AWS resources and services using the AWS CLI. Handles infrastructure, services, and cloud operations across AWS regions. 5 | inputs: 6 | region: 7 | type: string 8 | description: AWS region for operations. If not provided, uses the region from currently active AWS profile 9 | optional: true 10 | default: "us-east-1" 11 | examples: 12 | - "us-west-2" 13 | - "eu-central-1" 14 | - "ap-southeast-1" 15 | profile: 16 | type: string 17 | description: AWS CLI profile to use. If not provided, uses the currently active profile 18 | optional: true 19 | examples: 20 | - "default" 21 | - "production" 22 | - "development" 23 | rules: 24 | - 'Unless the user explicitly specified the region or account, use the currently active profile' 25 | - 'If the user provided profile does not exist, do not try to fallback, just report the error.' 26 | -------------------------------------------------------------------------------- /assets/tools/gcloud.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | display_name: Google Cloud 3 | executable: gcloud 4 | description: Manages Google Cloud Platform resources and services using the gcloud CLI. Handles infrastructure, services, and cloud operations across GCP regions and zones. 5 | inputs: 6 | project: 7 | type: string 8 | description: Google Cloud project ID 9 | optional: true 10 | examples: 11 | - "my-project-123" 12 | - "production-env-456" 13 | region: 14 | type: string 15 | description: Google Cloud region for region-specific operations 16 | default: "us-central1" 17 | optional: true 18 | examples: 19 | - "us-east1" 20 | - "europe-west1" 21 | - "asia-east1" 22 | zone: 23 | type: string 24 | description: Google Cloud zone for zone-specific operations 25 | default: "us-central1-a" 26 | optional: true 27 | examples: 28 | - "us-east1-b" 29 | - "europe-west1-c" 30 | - "asia-east1-a" 31 | rules: 32 | - 'If the user explicitly specified the project, region or zone, make sure to pass it to the `gcloud` command.' 33 | - 'If the user provided project does not exist, do not try to fallback, just report the error.' 34 | -------------------------------------------------------------------------------- /assets/tools/gh.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | display_name: GitHub 3 | executable: gh 4 | description: Interacts with GitHub repositories, issues, pull requests, and other GitHub features using the GitHub CLI. 5 | inputs: 6 | owner: 7 | type: string 8 | description: The GitHub repository owner (user or organization) 9 | examples: 10 | - "opsy" 11 | - "kubernetes" 12 | repository: 13 | type: string 14 | description: The GitHub repository name. Will generate a random name if not provided 15 | optional: true 16 | examples: 17 | - "opsy" 18 | - "kubernetes" 19 | host: 20 | type: string 21 | description: The GitHub instance hostname 22 | default: "github.com" 23 | examples: 24 | - "github.com" 25 | - "github.enterprise.company.com" 26 | rules: 27 | - 'When creating a Pull Request, always use conventional message for the title in a format of `type(scope): description`.' 28 | - 'When creating a Pull Request, always add detailed description formatted as markdown.' 29 | - 'Unless user explicitly expressed otherwise, when creating a new repository, create it as private.' 30 | -------------------------------------------------------------------------------- /assets/tools/git.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | display_name: Git 3 | executable: git 4 | description: Generates and executes Git commands to interact with local and remote Git repositories. 5 | inputs: 6 | repository: 7 | type: string 8 | description: The path to the Git repository 9 | default: "." 10 | examples: 11 | - "project" 12 | - "/path/to/repo" 13 | optional: false 14 | rules: 15 | - 'Use conventional commit messages in a format of `type(scope): description`.' 16 | - 'If you clone an empty repository, make sure to init it.' 17 | - 'Never commit to the main or master branch directly, unless you just init the repository.' 18 | -------------------------------------------------------------------------------- /assets/tools/helm.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | display_name: Helm 3 | executable: helm 4 | description: Manages Kubernetes applications using Helm. Handles chart operations, releases, and repositories across Kubernetes namespaces. 5 | inputs: 6 | namespace: 7 | type: string 8 | description: Kubernetes namespace for Helm operations. If not provided, uses the namespace from current context 9 | optional: true 10 | default: "default" 11 | examples: 12 | - "monitoring" 13 | - "application" 14 | - "database" 15 | rules: 16 | - 'If the user explicitly specified the namespace, make sure to pass it to the `helm` command' 17 | - 'If the user provided namespace does not exist, do not try to fallback, just report the error.' 18 | -------------------------------------------------------------------------------- /assets/tools/jira.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | display_name: Jira 3 | executable: jira 4 | description: Manages Jira issues, projects, and workflows. Handles ticket creation, updates, and project management operations through Jira's CLI interface. 5 | inputs: 6 | project: 7 | type: string 8 | description: Jira project key. If not provided, uses the default project from configuration 9 | optional: true 10 | examples: 11 | - "PROD" 12 | - "OPS" 13 | - "PLATFORM" 14 | issue: 15 | type: string 16 | description: Jira issue key. If not provided, will be generated based on the project 17 | optional: true 18 | examples: 19 | - "PROD-123" 20 | - "OPS-456" 21 | - "PLATFORM-789" 22 | summary: 23 | type: string 24 | description: Summary of the issue. If not provided, will be generated based on the project 25 | optional: true 26 | examples: 27 | - "Create a new feature" 28 | - "Fix a bug" 29 | description: 30 | type: string 31 | description: Description of the issue. If not provided, will be generated based on the project 32 | optional: true 33 | examples: 34 | - "Extra details about the issue. Using markdown for formatting." 35 | rules: 36 | - 'The `jira` is already initialized so do not try to run `jira init`' 37 | - 'Always pass `--no-input` flag to `jira` commands' 38 | - 'If you need to include description for the issue, use `--body` flag' 39 | -------------------------------------------------------------------------------- /assets/tools/kubectl.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | display_name: Kubectl 3 | executable: kubectl 4 | description: Manages Kubernetes resources and cluster operations using kubectl. Controls deployment, scaling, and management of containerized applications. 5 | inputs: 6 | namespace: 7 | type: string 8 | description: Kubernetes namespace for operations. If not provided, uses the namespace from current context 9 | optional: true 10 | default: "default" 11 | examples: 12 | - "kube-system" 13 | - "monitoring" 14 | - "application" 15 | context: 16 | type: string 17 | description: Kubernetes context to use. If not provided, uses the current context 18 | optional: true 19 | examples: 20 | - "production-cluster" 21 | - "development-cluster" 22 | - "minikube" 23 | rules: 24 | - 'If the user provided context does not exist, do not try to fallback, just report the error.' 25 | - 'If the user provided namespace does not exist, do not try to fallback, just report the error.' 26 | -------------------------------------------------------------------------------- /cmd/opsy/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "log" 7 | "os" 8 | 9 | tea "github.com/charmbracelet/bubbletea" 10 | 11 | "github.com/datolabs-io/opsy/internal/agent" 12 | "github.com/datolabs-io/opsy/internal/config" 13 | "github.com/datolabs-io/opsy/internal/thememanager" 14 | "github.com/datolabs-io/opsy/internal/tool" 15 | "github.com/datolabs-io/opsy/internal/toolmanager" 16 | "github.com/datolabs-io/opsy/internal/tui" 17 | ) 18 | 19 | const ( 20 | // ErrNoTaskProvided is the error message for no task provided. 21 | ErrNoTaskProvided = "no task provided" 22 | ) 23 | 24 | // main is the entry point for the Opsy application. 25 | func main() { 26 | ctx := context.Background() 27 | 28 | task, err := getTask() 29 | if err != nil { 30 | log.Fatal(err) 31 | } 32 | 33 | cfg := config.New() 34 | if err := cfg.LoadConfig(); err != nil { 35 | log.Fatal(err) 36 | } 37 | 38 | logger, err := cfg.GetLogger() 39 | if err != nil { 40 | log.Fatal(err) 41 | } 42 | 43 | logger.With("task", task).Info("Started Opsy") 44 | 45 | themeManager := thememanager.New(thememanager.WithLogger(logger)) 46 | if err := themeManager.LoadTheme(cfg.GetConfig().UI.Theme); err != nil { 47 | log.Fatal(err) 48 | } 49 | 50 | communication := &agent.Communication{ 51 | Commands: make(chan tool.Command), 52 | Messages: make(chan agent.Message), 53 | Status: make(chan agent.Status), 54 | } 55 | 56 | agnt := agent.New( 57 | agent.WithConfig(cfg.GetConfig()), 58 | agent.WithLogger(logger), 59 | agent.WithContext(ctx), 60 | agent.WithCommunication(communication), 61 | ) 62 | 63 | toolManager := toolmanager.New( 64 | toolmanager.WithConfig(cfg.GetConfig()), 65 | toolmanager.WithLogger(logger), 66 | toolmanager.WithContext(ctx), 67 | toolmanager.WithAgent(agnt), 68 | ) 69 | if err := toolManager.LoadTools(); err != nil { 70 | log.Fatal(err) 71 | } 72 | 73 | tui := tui.New( 74 | tui.WithTheme(themeManager.GetTheme()), 75 | tui.WithConfig(cfg.GetConfig()), 76 | tui.WithTask(task), 77 | tui.WithToolsCount(len(toolManager.GetTools())), 78 | ) 79 | p := tea.NewProgram(tui, tea.WithAltScreen(), tea.WithMouseCellMotion(), tea.WithContext(ctx)) 80 | 81 | go func() { 82 | if _, err := agnt.Run(&tool.RunOptions{Task: task, Tools: toolManager.GetTools()}, ctx); err != nil { 83 | communication.Status <- agent.StatusError 84 | logger.With("task", task).Error("Opsy finished with error", "error", err) 85 | } else { 86 | communication.Status <- agent.StatusFinished 87 | logger.With("task", task).Info("Opsy finished") 88 | } 89 | }() 90 | 91 | go func() { 92 | for msg := range communication.Messages { 93 | p.Send(msg) 94 | } 95 | }() 96 | 97 | go func() { 98 | for msg := range communication.Commands { 99 | p.Send(msg) 100 | } 101 | }() 102 | 103 | go func() { 104 | for msg := range communication.Status { 105 | p.Send(msg) 106 | } 107 | }() 108 | 109 | if _, err := p.Run(); err != nil { 110 | log.Fatal(err) 111 | } 112 | } 113 | 114 | // getTask returns the task from the command line arguments. 115 | func getTask() (string, error) { 116 | if len(os.Args) > 1 && os.Args[1] != "" { 117 | return os.Args[1], nil 118 | } 119 | 120 | return "", errors.New(ErrNoTaskProvided) 121 | } 122 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/datolabs-io/opsy 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/anthropics/anthropic-sdk-go v0.2.0-beta.3 7 | github.com/charmbracelet/bubbles v0.21.0 8 | github.com/charmbracelet/bubbletea v1.3.5 9 | github.com/charmbracelet/lipgloss v1.1.0 10 | github.com/invopop/jsonschema v0.13.0 11 | github.com/muesli/reflow v0.3.0 12 | github.com/spf13/viper v1.20.1 13 | github.com/stretchr/testify v1.10.0 14 | github.com/wk8/go-ordered-map/v2 v2.1.8 15 | golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa 16 | gopkg.in/yaml.v3 v3.0.1 17 | ) 18 | 19 | require ( 20 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 21 | github.com/bahlo/generic-list-go v0.2.0 // indirect 22 | github.com/buger/jsonparser v1.1.1 // indirect 23 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 24 | github.com/charmbracelet/x/ansi v0.8.0 // indirect 25 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect 26 | github.com/charmbracelet/x/term v0.2.1 // indirect 27 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 28 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 29 | github.com/fsnotify/fsnotify v1.8.0 // indirect 30 | github.com/go-viper/mapstructure/v2 v2.2.1 // indirect 31 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 32 | github.com/mailru/easyjson v0.9.0 // indirect 33 | github.com/mattn/go-isatty v0.0.20 // indirect 34 | github.com/mattn/go-localereader v0.0.1 // indirect 35 | github.com/mattn/go-runewidth v0.0.16 // indirect 36 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 37 | github.com/muesli/cancelreader v0.2.2 // indirect 38 | github.com/muesli/termenv v0.16.0 // indirect 39 | github.com/pelletier/go-toml/v2 v2.2.3 // indirect 40 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 41 | github.com/rivo/uniseg v0.4.7 // indirect 42 | github.com/sagikazarmark/locafero v0.7.0 // indirect 43 | github.com/sourcegraph/conc v0.3.0 // indirect 44 | github.com/spf13/afero v1.12.0 // indirect 45 | github.com/spf13/cast v1.7.1 // indirect 46 | github.com/spf13/pflag v1.0.6 // indirect 47 | github.com/subosito/gotenv v1.6.0 // indirect 48 | github.com/tidwall/gjson v1.18.0 // indirect 49 | github.com/tidwall/match v1.1.1 // indirect 50 | github.com/tidwall/pretty v1.2.1 // indirect 51 | github.com/tidwall/sjson v1.2.5 // indirect 52 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 53 | go.uber.org/multierr v1.11.0 // indirect 54 | golang.org/x/sync v0.13.0 // indirect 55 | golang.org/x/sys v0.32.0 // indirect 56 | golang.org/x/text v0.22.0 // indirect 57 | ) 58 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/anthropics/anthropic-sdk-go v0.2.0-beta.3 h1:b5t1ZJMvV/l99y4jbz7kRFdUp3BSDkI8EhSlHczivtw= 2 | github.com/anthropics/anthropic-sdk-go v0.2.0-beta.3/go.mod h1:AapDW22irxK2PSumZiQXYUFvsdQgkwIWlpESweWZI/c= 3 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 4 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 5 | github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= 6 | github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= 7 | github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= 8 | github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= 9 | github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= 10 | github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= 11 | github.com/charmbracelet/bubbletea v1.3.5 h1:JAMNLTbqMOhSwoELIr0qyP4VidFq72/6E9j7HHmRKQc= 12 | github.com/charmbracelet/bubbletea v1.3.5/go.mod h1:TkCnmH+aBd4LrXhXcqrKiYwRs7qyQx5rBgH5fVY3v54= 13 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= 14 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 15 | github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 16 | github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 17 | github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= 18 | github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= 19 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= 20 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 21 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 22 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 23 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 24 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 25 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 26 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 27 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 28 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 29 | github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= 30 | github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 31 | github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= 32 | github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 33 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 34 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 35 | github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= 36 | github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= 37 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 38 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 39 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 40 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 41 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 42 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 43 | github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= 44 | github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= 45 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 46 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 47 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 48 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 49 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 50 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 51 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 52 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 53 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 54 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 55 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 56 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 57 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 58 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 59 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 60 | github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= 61 | github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= 62 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 63 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 64 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 65 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 66 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 67 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 68 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 69 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 70 | github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= 71 | github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= 72 | github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= 73 | github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= 74 | github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= 75 | github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= 76 | github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= 77 | github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 78 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 79 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 80 | github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= 81 | github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= 82 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 83 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 84 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 85 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 86 | github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= 87 | github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= 88 | github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= 89 | github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= 90 | github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= 91 | github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= 92 | github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= 93 | github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= 94 | github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= 95 | github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= 96 | github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= 97 | github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= 98 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 99 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 100 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 101 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 102 | golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa h1:t2QcU6V556bFjYgu4L6C+6VrCPyJZ+eyRsABUPs1mz4= 103 | golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa/go.mod h1:BHOTPb3L19zxehTsLoJXVaTktb06DFgmdW6Wb9s8jqk= 104 | golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= 105 | golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 106 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 107 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 108 | golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= 109 | golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 110 | golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= 111 | golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= 112 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 113 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= 114 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 115 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 116 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 117 | -------------------------------------------------------------------------------- /internal/agent/agent.go: -------------------------------------------------------------------------------- 1 | package agent 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "log/slog" 9 | "time" 10 | 11 | "github.com/datolabs-io/opsy/assets" 12 | "github.com/datolabs-io/opsy/internal/config" 13 | "github.com/datolabs-io/opsy/internal/tool" 14 | 15 | "github.com/anthropics/anthropic-sdk-go" 16 | "github.com/anthropics/anthropic-sdk-go/option" 17 | "github.com/anthropics/anthropic-sdk-go/packages/param" 18 | ) 19 | 20 | const ( 21 | // ErrNoRunOptions is the error returned when no run options are provided. 22 | ErrNoRunOptions = "no run options provided" 23 | // ErrNoTaskProvided is the error returned when no task is provided. 24 | ErrNoTaskProvided = "no task provided" 25 | 26 | // StatusReady is the status of the agent when it is ready to run. 27 | StatusReady = "Ready" 28 | // StatusRunning is the status of the agent when it is running. 29 | StatusRunning = "Running" 30 | // StatusFinished is the status of the agent when it has finished. 31 | StatusFinished = "Finished" 32 | // StatusError is the status of the agent when it has encountered an error. 33 | StatusError = "Error" 34 | ) 35 | 36 | // Status is the status of the agent. 37 | type Status string 38 | 39 | // Agent is a struct that contains the state of the agent. 40 | type Agent struct { 41 | client *anthropic.Client 42 | ctx context.Context 43 | cfg config.Configuration 44 | logger *slog.Logger 45 | communication *Communication 46 | } 47 | 48 | // Message is a struct that contains a message from the agent. 49 | type Message struct { 50 | // Tool is the name of the tool that sent the message. 51 | Tool string 52 | // Message is the message from the tool. 53 | Message string 54 | // Timestamp is the timestamp when the message was sent. 55 | Timestamp time.Time 56 | } 57 | 58 | // Communication is a struct that contains the communication channels for the agent. 59 | type Communication struct { 60 | Commands chan tool.Command 61 | Messages chan Message 62 | Status chan Status 63 | } 64 | 65 | // Option is a function that configures the Agent. 66 | type Option func(*Agent) 67 | 68 | const ( 69 | // Name is the name of the agent. 70 | Name = "Opsy" 71 | ) 72 | 73 | // New creates a new Agent. 74 | func New(opts ...Option) *Agent { 75 | a := &Agent{ 76 | ctx: context.Background(), 77 | cfg: config.New().GetConfig(), 78 | logger: slog.New(slog.DiscardHandler), 79 | communication: &Communication{ 80 | Commands: make(chan tool.Command), 81 | Messages: make(chan Message), 82 | Status: make(chan Status), 83 | }, 84 | } 85 | 86 | for _, opt := range opts { 87 | opt(a) 88 | } 89 | 90 | if a.cfg.Anthropic.APIKey != "" { 91 | c := anthropic.NewClient(option.WithAPIKey(a.cfg.Anthropic.APIKey)) 92 | a.client = &c 93 | } 94 | 95 | a.logger.WithGroup("config").With("max_tokens", a.cfg.Anthropic.MaxTokens).With("model", a.cfg.Anthropic.Model). 96 | With("temperature", a.cfg.Anthropic.Temperature).Debug("Agent initialized.") 97 | 98 | return a 99 | } 100 | 101 | // WithContext sets the context for the agent. 102 | func WithContext(ctx context.Context) Option { 103 | return func(a *Agent) { 104 | a.ctx = ctx 105 | } 106 | } 107 | 108 | // WithConfig sets the configuration for the agent. 109 | func WithConfig(cfg config.Configuration) Option { 110 | return func(a *Agent) { 111 | a.cfg = cfg 112 | } 113 | } 114 | 115 | // WithLogger sets the logger for the agent. 116 | func WithLogger(logger *slog.Logger) Option { 117 | return func(a *Agent) { 118 | a.logger = logger.With("component", "agent") 119 | } 120 | } 121 | 122 | // WithClient sets the client for the agent. 123 | func WithClient(client *anthropic.Client) Option { 124 | return func(a *Agent) { 125 | a.client = client 126 | } 127 | } 128 | 129 | // WithCommunication sets the communication channels for the agent. 130 | func WithCommunication(communication *Communication) Option { 131 | return func(a *Agent) { 132 | a.communication = communication 133 | } 134 | } 135 | 136 | // Run runs the agent with the given task and tools. 137 | func (a *Agent) Run(opts *tool.RunOptions, ctx context.Context) ([]tool.Output, error) { 138 | if opts == nil { 139 | return nil, errors.New(ErrNoRunOptions) 140 | } 141 | 142 | if opts.Task == "" { 143 | return nil, errors.New(ErrNoTaskProvided) 144 | } 145 | 146 | if ctx == nil { 147 | ctx = a.ctx 148 | } 149 | 150 | prompt, err := assets.RenderAgentSystemPrompt(&assets.AgentSystemPromptData{ 151 | Shell: a.cfg.Tools.Exec.Shell, 152 | }) 153 | if err != nil { 154 | return nil, fmt.Errorf("%s: %w", assets.ErrToolRenderingPrompt, err) 155 | } 156 | 157 | if opts.Prompt != "" { 158 | prompt = opts.Prompt 159 | } 160 | 161 | logger := a.logger.With("task", opts.Task).With("tool", opts.Caller).With("tools.count", len(opts.Tools)) 162 | logger.Debug("Agent running.") 163 | a.communication.Status <- StatusRunning 164 | 165 | output := []tool.Output{} 166 | messages := []anthropic.MessageParam{anthropic.NewUserMessage(anthropic.NewTextBlock(opts.Task))} 167 | 168 | for { 169 | msg := anthropic.MessageNewParams{ 170 | Model: a.cfg.Anthropic.Model, 171 | MaxTokens: a.cfg.Anthropic.MaxTokens, 172 | System: []anthropic.TextBlockParam{{Text: prompt}}, 173 | Messages: messages, 174 | Tools: convertTools(opts.Tools), 175 | Temperature: param.NewOpt(a.cfg.Anthropic.Temperature), 176 | } 177 | 178 | if len(opts.Tools) > 0 { 179 | msg.ToolChoice = anthropic.ToolChoiceUnionParam{ 180 | OfToolChoiceAuto: &anthropic.ToolChoiceAutoParam{ 181 | DisableParallelToolUse: param.NewOpt(true), 182 | }, 183 | } 184 | } 185 | 186 | message, err := a.client.Messages.New(ctx, msg) 187 | 188 | if err != nil { 189 | // TODO(t-dabasinskas): Implement retry logic 190 | logger.With("error", err).Error("Failed to send message to Anthropic API.") 191 | return nil, err 192 | } 193 | 194 | toolResults := []anthropic.ContentBlockParamUnion{} 195 | for _, block := range message.Content { 196 | switch block.Type { 197 | case "text": 198 | a.communication.Messages <- Message{ 199 | Tool: opts.Caller, 200 | Message: block.Text, 201 | Timestamp: time.Now(), 202 | } 203 | case "tool_use": 204 | isError := false 205 | resultBlockContent := "" 206 | toolInputs := map[string]any{} 207 | 208 | if err := json.Unmarshal(block.Input, &toolInputs); err != nil { 209 | logger.With("error", err).Error("Failed to unmarshal tool inputs.") 210 | continue 211 | } 212 | 213 | var toolOutput *tool.Output 214 | tool, ok := opts.Tools[block.Name] 215 | if !ok { 216 | logger.With("tool_name", block.Name).Warn("Tool not found, skipping.") 217 | continue 218 | } 219 | 220 | toolOutput, err = tool.Execute(toolInputs, ctx) 221 | if err != nil { 222 | logger.With("error", err).Error("Failed to execute tool.") 223 | isError = true 224 | } 225 | 226 | if toolOutput == nil { 227 | logger.With("tool_name", block.Name).Warn("Tool has no output, skipping.") 228 | continue 229 | } 230 | 231 | output = append(output, *toolOutput) 232 | 233 | // Handle messages from all the tools except the Exec: 234 | if toolOutput.Result != "" && toolOutput.ExecutedCommand == nil { 235 | resultBlockContent = toolOutput.Result 236 | a.communication.Messages <- Message{ 237 | Tool: opts.Caller, 238 | Message: toolOutput.Result, 239 | Timestamp: time.Now(), 240 | } 241 | } 242 | logger.With("output", toolOutput).Warn(">>>>Tool result.") 243 | 244 | // Handle messages from the Exec tool: 245 | if toolOutput.ExecutedCommand != nil { 246 | resultBlockContent = toolOutput.ExecutedCommand.Output 247 | isError = toolOutput.ExecutedCommand.ExitCode != 0 248 | a.communication.Commands <- *toolOutput.ExecutedCommand 249 | } 250 | 251 | resultBlock := anthropic.NewToolResultBlock(block.ID, resultBlockContent, isError) 252 | toolResults = append(toolResults, resultBlock) 253 | } 254 | } 255 | 256 | messages = append(messages, message.ToParam()) 257 | if len(toolResults) == 0 { 258 | break 259 | } 260 | 261 | messages = append(messages, anthropic.NewUserMessage(toolResults...)) 262 | } 263 | 264 | return output, nil 265 | } 266 | 267 | // convertTools converts the tools to the format required by the Anthropic SDK. 268 | func convertTools(tools map[string]tool.Tool) (anthropicTools []anthropic.ToolUnionParam) { 269 | for _, t := range tools { 270 | anthropicTools = append(anthropicTools, anthropic.ToolUnionParam{ 271 | OfTool: &anthropic.ToolParam{ 272 | Name: t.GetName(), 273 | Description: param.NewOpt(t.GetDescription()), 274 | InputSchema: anthropic.ToolInputSchemaParam{ 275 | Properties: t.GetInputSchema().Properties, 276 | }, 277 | }, 278 | }) 279 | } 280 | return 281 | } 282 | -------------------------------------------------------------------------------- /internal/agent/agent_test.go: -------------------------------------------------------------------------------- 1 | package agent 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "testing" 7 | "time" 8 | 9 | "github.com/anthropics/anthropic-sdk-go" 10 | "github.com/datolabs-io/opsy/internal/config" 11 | "github.com/datolabs-io/opsy/internal/tool" 12 | "github.com/invopop/jsonschema" 13 | "github.com/stretchr/testify/assert" 14 | "github.com/stretchr/testify/require" 15 | orderedmap "github.com/wk8/go-ordered-map/v2" 16 | ) 17 | 18 | // mockTool implements the tool.Tool interface for testing 19 | type mockTool struct { 20 | name string 21 | displayName string 22 | description string 23 | schema *jsonschema.Schema 24 | output *tool.Output 25 | err error 26 | } 27 | 28 | func (t *mockTool) GetName() string { return t.name } 29 | func (t *mockTool) GetDisplayName() string { return t.displayName } 30 | func (t *mockTool) GetDescription() string { return t.description } 31 | func (t *mockTool) GetInputSchema() *jsonschema.Schema { return t.schema } 32 | func (t *mockTool) Execute(inputs map[string]any, ctx context.Context) (*tool.Output, error) { 33 | return t.output, t.err 34 | } 35 | 36 | // TestNew tests agent creation and options 37 | func TestNew(t *testing.T) { 38 | t.Run("creates default agent", func(t *testing.T) { 39 | agent := New() 40 | assert.NotNil(t, agent) 41 | assert.NotNil(t, agent.ctx) 42 | assert.NotNil(t, agent.cfg) 43 | assert.NotNil(t, agent.logger) 44 | assert.NotNil(t, agent.communication) 45 | assert.Nil(t, agent.client) // No API key set 46 | }) 47 | 48 | t.Run("applies options", func(t *testing.T) { 49 | ctx := context.Background() 50 | cfg := config.New().GetConfig() 51 | logger := slog.New(slog.NewTextHandler(nil, nil)) 52 | comm := &Communication{ 53 | Commands: make(chan tool.Command), 54 | Messages: make(chan Message), 55 | Status: make(chan Status), 56 | } 57 | 58 | agent := New( 59 | WithContext(ctx), 60 | WithConfig(cfg), 61 | WithLogger(logger), 62 | WithCommunication(comm), 63 | ) 64 | 65 | assert.Equal(t, ctx, agent.ctx) 66 | assert.Equal(t, cfg, agent.cfg) 67 | assert.Equal(t, comm, agent.communication) 68 | assert.Nil(t, agent.client) // Agent without API key should have nil client 69 | }) 70 | 71 | t.Run("creates client when API key provided", func(t *testing.T) { 72 | cfg := config.New().GetConfig() 73 | cfg.Anthropic.APIKey = "test-key" 74 | agent := New(WithConfig(cfg)) 75 | assert.NotNil(t, agent.client) 76 | // Verify client is properly initialized by checking its type 77 | assert.IsType(t, &anthropic.Client{}, agent.client) 78 | }) 79 | } 80 | 81 | // TestConvertTools tests tool conversion for Anthropic API 82 | func TestConvertTools(t *testing.T) { 83 | t.Run("converts single tool", func(t *testing.T) { 84 | properties := orderedmap.New[string, *jsonschema.Schema]() 85 | properties.Set("test", &jsonschema.Schema{Type: "string"}) 86 | 87 | schema := &jsonschema.Schema{ 88 | Type: "object", 89 | Properties: properties, 90 | } 91 | 92 | tools := map[string]tool.Tool{ 93 | "test": &mockTool{ 94 | name: "test", 95 | displayName: "Test Tool", 96 | description: "A test tool", 97 | schema: schema, 98 | }, 99 | } 100 | 101 | anthropicTools := convertTools(tools) 102 | require.Len(t, anthropicTools, 1) 103 | 104 | toolParam := anthropicTools[0].OfTool 105 | require.NotNil(t, toolParam) 106 | assert.Equal(t, "test", toolParam.Name) 107 | assert.Equal(t, "A test tool", toolParam.Description.Value) 108 | assert.NotNil(t, toolParam.InputSchema) 109 | }) 110 | 111 | t.Run("converts multiple tools", func(t *testing.T) { 112 | properties := orderedmap.New[string, *jsonschema.Schema]() 113 | properties.Set("param", &jsonschema.Schema{Type: "string"}) 114 | 115 | schema := &jsonschema.Schema{ 116 | Type: "object", 117 | Properties: properties, 118 | } 119 | 120 | tools := map[string]tool.Tool{ 121 | "tool1": &mockTool{ 122 | name: "tool1", 123 | displayName: "Tool One", 124 | description: "First test tool", 125 | schema: schema, 126 | }, 127 | "tool2": &mockTool{ 128 | name: "tool2", 129 | displayName: "Tool Two", 130 | description: "Second test tool", 131 | schema: schema, 132 | }, 133 | } 134 | 135 | anthropicTools := convertTools(tools) 136 | require.Len(t, anthropicTools, 2) 137 | 138 | // Verify both tools are present with correct values 139 | foundTool1 := false 140 | foundTool2 := false 141 | 142 | for _, toolUnion := range anthropicTools { 143 | toolParam := toolUnion.OfTool 144 | require.NotNil(t, toolParam) 145 | 146 | name := toolParam.Name 147 | if name == "tool1" { 148 | foundTool1 = true 149 | assert.Equal(t, "First test tool", toolParam.Description.Value) 150 | assert.NotNil(t, toolParam.InputSchema) 151 | } else if name == "tool2" { 152 | foundTool2 = true 153 | assert.Equal(t, "Second test tool", toolParam.Description.Value) 154 | assert.NotNil(t, toolParam.InputSchema) 155 | } 156 | } 157 | 158 | assert.True(t, foundTool1, "tool1 should be present") 159 | assert.True(t, foundTool2, "tool2 should be present") 160 | }) 161 | 162 | t.Run("handles empty tools map", func(t *testing.T) { 163 | tools := map[string]tool.Tool{} 164 | anthropicTools := convertTools(tools) 165 | assert.Empty(t, anthropicTools) 166 | }) 167 | } 168 | 169 | // TestCommunication tests the communication channels 170 | func TestCommunication(t *testing.T) { 171 | t.Run("sends and receives messages", func(t *testing.T) { 172 | comm := &Communication{ 173 | Commands: make(chan tool.Command), 174 | Messages: make(chan Message), 175 | Status: make(chan Status), 176 | } 177 | 178 | agent := New(WithCommunication(comm)) 179 | assert.NotNil(t, agent.communication) 180 | 181 | // Test message channel 182 | go func() { 183 | comm.Messages <- Message{ 184 | Tool: "test", 185 | Message: "test message", 186 | Timestamp: time.Now(), 187 | } 188 | close(comm.Messages) 189 | }() 190 | 191 | msg := <-comm.Messages 192 | assert.Equal(t, "test message", msg.Message) 193 | assert.Equal(t, "test", msg.Tool) 194 | 195 | // Test status channel 196 | go func() { 197 | comm.Status <- Status(StatusRunning) 198 | close(comm.Status) 199 | }() 200 | 201 | status := <-comm.Status 202 | assert.Equal(t, Status(StatusRunning), status) 203 | 204 | // Test command channel 205 | now := time.Now() 206 | cmd := tool.Command{ 207 | Command: "test command", 208 | WorkingDirectory: "/test/dir", 209 | ExitCode: 0, 210 | Output: "test output", 211 | StartedAt: now, 212 | CompletedAt: now.Add(time.Second), 213 | } 214 | go func() { 215 | comm.Commands <- cmd 216 | close(comm.Commands) 217 | }() 218 | 219 | receivedCmd := <-comm.Commands 220 | assert.Equal(t, cmd, receivedCmd) 221 | }) 222 | } 223 | -------------------------------------------------------------------------------- /internal/agent/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package agent provides functionality for executing tasks using AI-powered tools within the opsy application. 3 | 4 | The agent acts as a bridge between the user's task requests and the available tools, using the Anthropic 5 | Claude API to intelligently select and execute appropriate tools based on the task requirements. 6 | 7 | # Core Components 8 | 9 | The package consists of several key components: 10 | 11 | - Agent: The main struct that handles task execution and tool management 12 | - Communication: Channels for sending messages, commands, and status updates 13 | - Message: Represents a message from the agent or tool execution 14 | - Status: Represents the current state of the agent (Running, Finished, etc.) 15 | 16 | # Agent Configuration 17 | 18 | The agent can be configured using functional options: 19 | 20 | agent := agent.New( 21 | agent.WithConfig(cfg), 22 | agent.WithLogger(logger), 23 | agent.WithContext(ctx), 24 | agent.WithCommunication(comm), 25 | ) 26 | 27 | Available options include: 28 | - WithConfig: Sets the configuration for the agent 29 | - WithLogger: Sets the logger for the agent 30 | - WithContext: Sets the context for the agent 31 | - WithCommunication: Sets the communication channels 32 | 33 | # Task Execution 34 | 35 | Tasks are executed using the Run method: 36 | 37 | outputs, err := agent.Run(&tool.RunOptions{ 38 | Task: "Clone the repository", 39 | Tools: toolManager.GetTools(), 40 | Prompt: customPrompt, // Optional: Override default system prompt 41 | Caller: "git", // Optional: Tool identifier for messages 42 | }, ctx) 43 | 44 | The agent will: 45 | 1. Parse the task and available tools 46 | 2. Use the Anthropic API to determine which tools to use 47 | 3. Execute the selected tools with appropriate parameters 48 | 4. Return the combined output from all tool executions 49 | 50 | The agent supports customizing the system prompt through RunOptions.Prompt, 51 | which allows overriding the default behavior when needed. 52 | 53 | # Communication 54 | 55 | The agent uses channels to communicate its progress: 56 | 57 | - Messages: Task progress and tool output messages 58 | - Commands: Commands executed by tools 59 | - Status: Current agent status (Running, Finished) 60 | 61 | Example usage: 62 | 63 | comm := &agent.Communication{ 64 | Commands: make(chan tool.Command), 65 | Messages: make(chan agent.Message), 66 | Status: make(chan agent.Status), 67 | } 68 | 69 | go func() { 70 | for msg := range comm.Messages { 71 | // Handle message 72 | } 73 | }() 74 | 75 | # Tool Integration 76 | 77 | Tools are converted to a format compatible with the Anthropic API: 78 | 79 | - Name: Tool identifier 80 | - Description: Tool purpose and functionality 81 | - InputSchema: JSON Schema defining valid inputs 82 | 83 | The agent ensures proper conversion and validation of tools before use. 84 | By default, parallel tool use is disabled to ensure deterministic execution. 85 | 86 | # Error Handling 87 | 88 | The package defines several error types: 89 | 90 | - ErrNoRunOptions: No options provided for Run 91 | - ErrNoTaskProvided: No task specified in options 92 | 93 | All errors are properly logged with contextual information using structured logging. 94 | Tool execution errors are captured and reflected in the tool results. 95 | 96 | # Logging 97 | 98 | The agent uses structured logging (slog) to provide detailed execution information: 99 | - Configuration details on initialization 100 | - Task execution progress and tool usage 101 | - Error conditions with context 102 | - Tool execution results and messages 103 | 104 | Logs can be configured through the WithLogger option to capture different levels 105 | of detail as needed. 106 | 107 | # Thread Safety 108 | 109 | The agent is designed to be thread-safe and can handle multiple concurrent tasks. 110 | Each task execution gets its own context and can be cancelled independently. 111 | */ 112 | package agent 113 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log/slog" 7 | "os" 8 | "os/exec" 9 | "path/filepath" 10 | "strings" 11 | 12 | "github.com/spf13/viper" 13 | ) 14 | 15 | // Configuration is the configuration for the opsy CLI. 16 | type Configuration struct { 17 | // UI is the configuration for the UI. 18 | UI UIConfiguration `yaml:"ui"` 19 | // Logging is the configuration for the logging. 20 | Logging LoggingConfiguration `yaml:"logging"` 21 | // Anthropic is the configuration for the Anthropic API. 22 | Anthropic AnthropicConfiguration `yaml:"anthropic"` 23 | // Tools is the configuration for the tools. 24 | Tools ToolsConfiguration `yaml:"tools"` 25 | } 26 | 27 | // UIConfiguration is the configuration for the UI. 28 | type UIConfiguration struct { 29 | // Theme is the theme for the UI. 30 | Theme string `yaml:"theme"` 31 | } 32 | 33 | // LoggingConfiguration is the configuration for the logging. 34 | type LoggingConfiguration struct { 35 | // Path is the path to the log file. 36 | Path string `yaml:"path"` 37 | // Level is the logging level. 38 | Level string `yaml:"level"` 39 | } 40 | 41 | // ToolsConfiguration is the configuration for the tools. 42 | type ToolsConfiguration struct { 43 | // Timeout is the maximum duration in seconds for a tool to execute. 44 | Timeout int64 `yaml:"timeout"` 45 | // Exec is the configuration for the exec tool. 46 | Exec ExecToolConfiguration `yaml:"exec"` 47 | } 48 | 49 | // ExecToolConfiguration is the configuration for the exec tool. 50 | type ExecToolConfiguration struct { 51 | // Timeout is the maximum duration in seconds for a tool to execute. 52 | Timeout int64 `yaml:"timeout"` 53 | // Shell is the shell to use for the exec tool. 54 | Shell string `yaml:"shell"` 55 | } 56 | 57 | // AnthropicConfiguration is the configuration for the Anthropic API. 58 | type AnthropicConfiguration struct { 59 | // APIKey is the API key for the Anthropic API. 60 | APIKey string `mapstructure:"api_key" yaml:"api_key"` 61 | // Model is the model to use for the Anthropic API. 62 | Model string `yaml:"model"` 63 | // Temperature is the temperature to use for the Anthropic API. 64 | Temperature float64 `yaml:"temperature"` 65 | // MaxTokens is the maximum number of tokens to use for the Anthropic API. 66 | MaxTokens int64 `mapstructure:"max_tokens" yaml:"max_tokens"` 67 | } 68 | 69 | // Configurer is an interface for managing configuration. 70 | type Configurer interface { 71 | // LoadConfig loads the configuration from the config file. 72 | LoadConfig() error 73 | // GetConfig returns the current configuration. 74 | GetConfig() Configuration 75 | // GetLogger returns the default logger. 76 | GetLogger() (*slog.Logger, error) 77 | } 78 | 79 | // ConfigManager is the configuration manager for the opsy CLI. 80 | type Config struct { 81 | configuration Configuration 82 | homePath string 83 | } 84 | 85 | const ( 86 | dirConfig = ".opsy" 87 | dirCache = ".opsy/cache" 88 | envPrefix = "OPSY" 89 | configFile = "config" 90 | configType = "yaml" 91 | ) 92 | 93 | var ( 94 | // ErrCreateConfigDir is returned when the config directory cannot be created. 95 | ErrCreateConfigDir = errors.New("failed to create config directory") 96 | // ErrCreateCacheDir is returned when the cache directory cannot be created. 97 | ErrCreateCacheDir = errors.New("failed to create cache directory") 98 | // ErrCreateDirs is returned when the directories cannot be created. 99 | ErrCreateDirs = errors.New("failed to create directories") 100 | // ErrReadConfig is returned when the config file cannot be read. 101 | ErrReadConfig = errors.New("failed to read config") 102 | // ErrUnmarshalConfig is returned when the config file cannot be unmarshalled. 103 | ErrUnmarshalConfig = errors.New("failed to unmarshal config") 104 | // ErrMissingAPIKey is returned when the Anthropic API key is missing. 105 | ErrMissingAPIKey = errors.New("anthropic API key is required") 106 | // ErrInvalidTemp is returned when the Anthropic temperature is invalid. 107 | ErrInvalidTemp = errors.New("anthropic temperature must be between 0 and 1") 108 | // ErrInvalidMaxTokens is returned when the Anthropic max tokens are invalid. 109 | ErrInvalidMaxTokens = errors.New("anthropic max tokens must be greater than 0") 110 | // ErrInvalidLogLevel is returned when the logging level is invalid. 111 | ErrInvalidLogLevel = errors.New("invalid logging level") 112 | // ErrInvalidTheme is returned when the theme is invalid. 113 | ErrInvalidTheme = errors.New("invalid theme") 114 | // ErrOpenLogFile is returned when the log file cannot be opened. 115 | ErrOpenLogFile = errors.New("failed to open log file") 116 | // ErrWriteConfig is returned when the config file cannot be written. 117 | ErrWriteConfig = errors.New("failed to write config") 118 | // ErrValidateConfig is returned when the config is invalid. 119 | ErrValidateConfig = errors.New("invalid config") 120 | // ErrInvalidShell is returned when the shell is invalid. 121 | ErrInvalidShell = errors.New("invalid exec shell") 122 | ) 123 | 124 | // New creates a new config instance. 125 | func New() *Config { 126 | homeDir, _ := os.UserHomeDir() 127 | 128 | config := &Config{ 129 | homePath: homeDir, 130 | configuration: Configuration{ 131 | Anthropic: AnthropicConfiguration{}, 132 | Tools: ToolsConfiguration{ 133 | Exec: ExecToolConfiguration{}, 134 | }, 135 | Logging: LoggingConfiguration{}, 136 | UI: UIConfiguration{}, 137 | }, 138 | } 139 | 140 | config.setDefaults() 141 | 142 | viper.AutomaticEnv() 143 | viper.SetEnvPrefix(envPrefix) 144 | viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) 145 | viper.AddConfigPath(filepath.Join(homeDir, dirConfig)) 146 | viper.SetConfigName(configFile) 147 | viper.SetConfigType(configType) 148 | 149 | _ = viper.BindEnv("anthropic.api_key", "ANTHROPIC_API_KEY") 150 | 151 | return config 152 | } 153 | 154 | // LoadConfig loads the configuration from the config file. 155 | func (c *Config) LoadConfig() error { 156 | if err := c.createDirs(); err != nil { 157 | return fmt.Errorf("%w: %v", ErrCreateDirs, err) 158 | } 159 | 160 | if err := viper.SafeWriteConfig(); err != nil { 161 | if _, ok := err.(viper.ConfigFileAlreadyExistsError); !ok { 162 | return fmt.Errorf("%w: %v", ErrWriteConfig, err) 163 | } 164 | } 165 | 166 | if err := viper.ReadInConfig(); err != nil { 167 | return fmt.Errorf("%w: %v", ErrReadConfig, err) 168 | } 169 | 170 | if err := viper.Unmarshal(&c.configuration); err != nil { 171 | return fmt.Errorf("%w: %v", ErrUnmarshalConfig, err) 172 | } 173 | 174 | if err := c.validate(); err != nil { 175 | return fmt.Errorf("%w: %v", ErrValidateConfig, err) 176 | } 177 | 178 | return nil 179 | } 180 | 181 | // GetConfig returns the current configuration. 182 | func (c *Config) GetConfig() Configuration { 183 | return c.configuration 184 | } 185 | 186 | // GetLogger returns a logger that writes to the log file. 187 | func (c *Config) GetLogger() (*slog.Logger, error) { 188 | logFile, err := os.OpenFile(c.configuration.Logging.Path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 189 | if err != nil { 190 | return nil, fmt.Errorf("%w: %v", ErrOpenLogFile, err) 191 | } 192 | 193 | var lvl slog.Level 194 | switch c.configuration.Logging.Level { 195 | case "debug": 196 | lvl = slog.LevelDebug 197 | case "warn": 198 | lvl = slog.LevelWarn 199 | case "error": 200 | lvl = slog.LevelError 201 | default: 202 | lvl = slog.LevelInfo 203 | } 204 | 205 | logger := slog.New(slog.NewTextHandler(logFile, &slog.HandlerOptions{ 206 | Level: lvl, 207 | })) 208 | 209 | return logger, nil 210 | } 211 | 212 | func (c *Config) createDirs() error { 213 | if err := os.MkdirAll(filepath.Join(c.homePath, dirConfig), 0755); err != nil { 214 | return fmt.Errorf("%w: %v", ErrCreateConfigDir, err) 215 | } 216 | 217 | if err := os.MkdirAll(filepath.Join(c.homePath, dirCache), 0755); err != nil { 218 | return fmt.Errorf("%w: %v", ErrCreateCacheDir, err) 219 | } 220 | 221 | return nil 222 | } 223 | 224 | func (c *Config) validate() error { 225 | if c.configuration.Anthropic.APIKey == "" { 226 | return ErrMissingAPIKey 227 | } 228 | 229 | if c.configuration.Anthropic.Temperature < 0 || c.configuration.Anthropic.Temperature > 1 { 230 | return ErrInvalidTemp 231 | } 232 | 233 | if c.configuration.Anthropic.MaxTokens < 1 { 234 | return ErrInvalidMaxTokens 235 | } 236 | 237 | level := strings.ToLower(c.configuration.Logging.Level) 238 | validLevels := map[string]bool{ 239 | "debug": true, 240 | "info": true, 241 | "warn": true, 242 | "error": true, 243 | } 244 | if !validLevels[level] { 245 | return ErrInvalidLogLevel 246 | } 247 | 248 | if c.configuration.Tools.Exec.Shell == "" { 249 | return ErrInvalidShell 250 | } else { 251 | if _, err := exec.LookPath(c.configuration.Tools.Exec.Shell); err != nil { 252 | return ErrInvalidShell 253 | } 254 | } 255 | 256 | return nil 257 | } 258 | 259 | func (c *Config) setDefaults() { 260 | viper.SetDefault("ui.theme", "default") 261 | viper.SetDefault("logging.path", filepath.Join(c.homePath, dirConfig, "log.log")) 262 | viper.SetDefault("logging.level", "info") 263 | viper.SetDefault("anthropic.model", "claude-3-7-sonnet-latest") 264 | viper.SetDefault("anthropic.temperature", 0.7) 265 | viper.SetDefault("anthropic.max_tokens", 1024) 266 | viper.SetDefault("tools.timeout", 120) 267 | viper.SetDefault("tools.exec.timeout", 0) 268 | viper.SetDefault("tools.exec.shell", "/bin/sh") 269 | } 270 | -------------------------------------------------------------------------------- /internal/config/doc.go: -------------------------------------------------------------------------------- 1 | // Package config provides configuration management for the opsy CLI application. 2 | // 3 | // The package handles: 4 | // - Loading configuration from YAML files 5 | // - Environment variable binding 6 | // - Configuration validation 7 | // - Default values 8 | // - Directory structure setup 9 | // - Logging setup 10 | // 11 | // Configuration Structure: 12 | // 13 | // Configuration { 14 | // UI: UIConfiguration // UI theme and styling 15 | // Logging: LoggingConfiguration // Log file path and level 16 | // Anthropic: AnthropicConfiguration // API settings for Anthropic 17 | // Tools: ToolsConfiguration // Global tool settings and exec configuration 18 | // } 19 | // 20 | // Usage: 21 | // 22 | // manager := config.New() 23 | // if err := manager.LoadConfig(); err != nil { 24 | // log.Fatal(err) 25 | // } 26 | // config := manager.GetConfig() 27 | // 28 | // Environment Variables: 29 | // - ANTHROPIC_API_KEY: API key for Anthropic 30 | // - OPSY_UI_THEME: UI theme name 31 | // - OPSY_LOGGING_LEVEL: Log level (debug, info, warn, error) 32 | // - OPSY_ANTHROPIC_MODEL: Model name 33 | // - OPSY_ANTHROPIC_TEMPERATURE: Temperature value 34 | // - OPSY_ANTHROPIC_MAX_TOKENS: Maximum tokens for completion 35 | // - OPSY_TOOLS_TIMEOUT: Global timeout for tools in seconds 36 | // - OPSY_TOOLS_EXEC_TIMEOUT: Timeout for exec tool in seconds 37 | // - OPSY_TOOLS_EXEC_SHELL: Shell to use for command execution 38 | // 39 | // Directory Structure: 40 | // 41 | // ~/.opsy/ 42 | // ├── config.yaml // Configuration file 43 | // ├── log.log // Default log file 44 | // ├── cache/ // Cache directory for temporary files 45 | // └── tools/ // Tool-specific data and configurations 46 | // 47 | // The package uses the following error constants for error handling: 48 | // - ErrCreateDirs: Returned when directory creation fails 49 | // - ErrCreateConfigDir: Returned when config directory creation fails 50 | // - ErrCreateCacheDir: Returned when cache directory creation fails 51 | // - ErrReadConfig: Returned when config file cannot be read 52 | // - ErrWriteConfig: Returned when config file cannot be written 53 | // - ErrUnmarshalConfig: Returned when config parsing fails 54 | // - ErrValidateConfig: Returned when configuration validation fails 55 | // - ErrMissingAPIKey: Returned when Anthropic API key is missing 56 | // - ErrInvalidTemp: Returned when temperature is not between 0 and 1 57 | // - ErrInvalidMaxTokens: Returned when max tokens is not positive 58 | // - ErrInvalidLogLevel: Returned when log level is invalid 59 | // - ErrInvalidTheme: Returned when UI theme is invalid 60 | // - ErrInvalidShell: Returned when exec shell is invalid or not found 61 | // - ErrOpenLogFile: Returned when log file cannot be opened 62 | // 63 | // Validation: 64 | // 65 | // The package performs extensive validation of the configuration: 66 | // - Anthropic API key must be provided 67 | // - Temperature must be between 0 and 1 68 | // - Max tokens must be positive 69 | // - Log level must be one of: debug, info, warn, error 70 | // - UI theme must be a valid theme name 71 | // - Exec shell must be a valid and executable shell path 72 | // 73 | // Thread Safety: 74 | // 75 | // The configuration is safe for concurrent access after loading. 76 | // The GetConfig method returns a copy of the configuration to prevent 77 | // race conditions. 78 | package config 79 | -------------------------------------------------------------------------------- /internal/config/testdata/custom_config.yaml: -------------------------------------------------------------------------------- 1 | ui: 2 | theme: custom_theme 3 | logging: 4 | level: debug 5 | path: /custom/log/path 6 | anthropic: 7 | api_key: test-key 8 | model: claude-3-opus 9 | temperature: 0.7 10 | max_tokens: 2048 11 | tools: 12 | timeout: 180 13 | exec: 14 | timeout: 90 15 | shell: "/bin/sh" 16 | -------------------------------------------------------------------------------- /internal/config/testdata/invalid_log_level.yaml: -------------------------------------------------------------------------------- 1 | anthropic: 2 | api_key: test-key 3 | logging: 4 | level: invalid 5 | -------------------------------------------------------------------------------- /internal/config/testdata/invalid_max_tokens.yaml: -------------------------------------------------------------------------------- 1 | anthropic: 2 | api_key: test-key 3 | max_tokens: 0 4 | -------------------------------------------------------------------------------- /internal/config/testdata/invalid_temperature_high.yaml: -------------------------------------------------------------------------------- 1 | anthropic: 2 | api_key: test-key 3 | temperature: 1.1 4 | -------------------------------------------------------------------------------- /internal/config/testdata/invalid_temperature_low.yaml: -------------------------------------------------------------------------------- 1 | anthropic: 2 | api_key: test-key 3 | temperature: -0.1 4 | -------------------------------------------------------------------------------- /internal/config/testdata/missing_api_key.yaml: -------------------------------------------------------------------------------- 1 | anthropic: 2 | model: claude-3-opus 3 | temperature: 0.7 4 | max_tokens: 2048 5 | -------------------------------------------------------------------------------- /internal/thememanager/doc.go: -------------------------------------------------------------------------------- 1 | // Package thememanager provides functionality for managing and loading color themes 2 | // for terminal user interfaces. 3 | // 4 | // The package supports loading themes from both embedded files and custom directories. 5 | // Themes are defined in YAML format and contain base and accent colors that can be 6 | // used for consistent styling across the application. 7 | // 8 | // Basic usage: 9 | // 10 | // // Create a new theme manager with default settings 11 | // tm := thememanager.New() 12 | // 13 | // // Load the default theme 14 | // err := tm.LoadTheme("") 15 | // 16 | // // Or load a specific theme 17 | // err := tm.LoadTheme("dark") 18 | // 19 | // // Get the current theme for use 20 | // theme := tm.GetTheme() 21 | // 22 | // Custom theme directory: 23 | // 24 | // // Create a theme manager with a custom theme directory 25 | // tm := thememanager.New(thememanager.WithDirectory("/path/to/themes")) 26 | // 27 | // Theme files should be YAML files with the .yaml extension and follow this structure: 28 | // 29 | // base: 30 | // base00: "#1A1B26" # Background 31 | // base01: "#24283B" # Light Background 32 | // base02: "#292E42" # Selection Background 33 | // base03: "#565F89" # Comments, Invisibles 34 | // base04: "#A9B1D6" # Dark Foreground 35 | // accent: 36 | // accent0: "#FF9E64" # Orange 37 | // accent1: "#9ECE6A" # Green 38 | // accent2: "#7AA2F7" # Blue 39 | // 40 | // Color Validation: 41 | // 42 | // All colors in the theme must: 43 | // - Be valid hexadecimal color codes 44 | // - Start with '#' character 45 | // - Be present for all required fields (no missing colors) 46 | // 47 | // Default Theme: 48 | // 49 | // When no theme name is provided (empty string), the manager will: 50 | // - Load the "default" theme from the embedded themes 51 | // - Use this as the fallback theme for the application 52 | // - Return an error if the default theme is not found or invalid 53 | // 54 | // The package uses the following error constants for error handling: 55 | // - ErrThemeNotFound: Returned when a requested theme file cannot be found 56 | // - ErrReadingTheme: Returned when there's an error reading the theme file 57 | // - ErrParsingTheme: Returned when the theme file cannot be parsed 58 | // - ErrDecodingTheme: Returned when theme YAML decoding fails 59 | // - ErrMissingColors: Returned when required colors are missing from theme 60 | // 61 | // The Manager interface defines the core functionality that theme managers must implement: 62 | // - LoadTheme: Loads a theme by name 63 | // - GetTheme: Returns the currently loaded theme 64 | // 65 | // Thread Safety: 66 | // 67 | // The theme manager is safe for concurrent access: 68 | // - LoadTheme operations are synchronized 69 | // - GetTheme returns a pointer to the theme, which should be treated as read-only 70 | // - Theme modifications should be done through LoadTheme only 71 | package thememanager 72 | -------------------------------------------------------------------------------- /internal/thememanager/testdata/invalid_format.yaml: -------------------------------------------------------------------------------- 1 | base: 2 | base00: [this is not valid] # Invalid YAML syntax 3 | base01: "#24283B" 4 | base02: "#292E42" 5 | base03: "#565F89" 6 | base04: "#A9B1D6" 7 | 8 | accent: 9 | accent0: "#FF9E64" 10 | accent1: "#9ECE6A" 11 | accent2: "#7AA2F7" 12 | -------------------------------------------------------------------------------- /internal/thememanager/testdata/invalid_missing_colors.yaml: -------------------------------------------------------------------------------- 1 | base: 2 | base00: "#1A1B26" 3 | base01: "#24283B" 4 | # missing base02, base03, base04 5 | 6 | accent: 7 | accent0: "#FF9E64" 8 | accent1: "#9ECE6A" 9 | accent2: "#7AA2F7" 10 | -------------------------------------------------------------------------------- /internal/thememanager/testdata/valid.yaml: -------------------------------------------------------------------------------- 1 | base: 2 | base00: "#1A1B26" # Primary background 3 | base01: "#24283B" # Secondary background 4 | base02: "#292E42" # Borders and dividers 5 | base03: "#565F89" # Muted text 6 | base04: "#A9B1D6" # Primary text 7 | 8 | accent: 9 | accent0: "#FF9E64" # Command text 10 | accent1: "#9ECE6A" # Agent messages 11 | accent2: "#7AA2F7" # Tool output 12 | -------------------------------------------------------------------------------- /internal/thememanager/theme.go: -------------------------------------------------------------------------------- 1 | package thememanager 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | 7 | "github.com/charmbracelet/lipgloss" 8 | "gopkg.in/yaml.v3" 9 | ) 10 | 11 | const ( 12 | // ErrMissingColors is the error message for missing required colors. 13 | ErrMissingColors = "missing required colors" 14 | // ErrDecodingTheme is returned when theme decoding fails. 15 | ErrDecodingTheme = "failed to decode theme" 16 | // ErrInvalidColor is returned when a color is not a valid hex code. 17 | ErrInvalidColor = "invalid color format" 18 | ) 19 | 20 | var ( 21 | // hexColorRegex matches valid hex color codes (#RRGGBB). 22 | hexColorRegex = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`) 23 | ) 24 | 25 | // Theme defines the color palette for the application TUI. 26 | type Theme struct { 27 | // BaseColors contains the base color palette. 28 | BaseColors BaseColors `yaml:"base"` 29 | // AccentColors contains the accent color palette. 30 | AccentColors AccentColors `yaml:"accent"` 31 | } 32 | 33 | // BaseColors contains the base color palette. 34 | type BaseColors struct { 35 | // Base00 is used for primary background. 36 | Base00 lipgloss.Color `yaml:"base00"` 37 | // Base01 is used for secondary background (status bars, input). 38 | Base01 lipgloss.Color `yaml:"base01"` 39 | // Base02 is used for borders and dividers. 40 | Base02 lipgloss.Color `yaml:"base02"` 41 | // Base03 is used for muted or disabled text. 42 | Base03 lipgloss.Color `yaml:"base03"` 43 | // Base04 is used for primary text content. 44 | Base04 lipgloss.Color `yaml:"base04"` 45 | } 46 | 47 | // AccentColors contains the accent color palette. 48 | type AccentColors struct { 49 | // Accent0 is used for command text and prompts. 50 | Accent0 lipgloss.Color `yaml:"accent0"` 51 | // Accent1 is used for agent messages and success states. 52 | Accent1 lipgloss.Color `yaml:"accent1"` 53 | // Accent2 is used for tool output and links. 54 | Accent2 lipgloss.Color `yaml:"accent2"` 55 | } 56 | 57 | // validateColor checks if a color is a valid hex color code. 58 | func validateColor(name string, color lipgloss.Color) error { 59 | if color == "" { 60 | return fmt.Errorf("%s: %s is empty", ErrMissingColors, name) 61 | } 62 | if !hexColorRegex.MatchString(string(color)) { 63 | return fmt.Errorf("%s: %s=%s must be a valid hex color code (#RRGGBB)", ErrInvalidColor, name, color) 64 | } 65 | return nil 66 | } 67 | 68 | // UnmarshalYAML implements the yaml.Unmarshaler interface. 69 | func (t *Theme) UnmarshalYAML(value *yaml.Node) error { 70 | type ThemeYAML Theme 71 | var tmp ThemeYAML 72 | 73 | if err := value.Decode(&tmp); err != nil { 74 | // Wrap the YAML error with our error message 75 | return fmt.Errorf("%s: %v", ErrDecodingTheme, err) 76 | } 77 | 78 | required := []struct { 79 | name string 80 | color lipgloss.Color 81 | }{ 82 | {"base.base00", tmp.BaseColors.Base00}, 83 | {"base.base01", tmp.BaseColors.Base01}, 84 | {"base.base02", tmp.BaseColors.Base02}, 85 | {"base.base03", tmp.BaseColors.Base03}, 86 | {"base.base04", tmp.BaseColors.Base04}, 87 | {"accent.accent0", tmp.AccentColors.Accent0}, 88 | {"accent.accent1", tmp.AccentColors.Accent1}, 89 | {"accent.accent2", tmp.AccentColors.Accent2}, 90 | } 91 | 92 | for _, r := range required { 93 | if err := validateColor(r.name, r.color); err != nil { 94 | return err 95 | } 96 | } 97 | 98 | *t = Theme(tmp) 99 | return nil 100 | } 101 | -------------------------------------------------------------------------------- /internal/thememanager/theme_test.go: -------------------------------------------------------------------------------- 1 | package thememanager 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "gopkg.in/yaml.v3" 9 | ) 10 | 11 | // TestTheme_UnmarshalYAML verifies theme YAML unmarshaling: 12 | // - Valid theme with all required colors 13 | // - Theme missing required colors 14 | // - Invalid YAML syntax 15 | // - Invalid color format 16 | func TestTheme_UnmarshalYAML(t *testing.T) { 17 | tests := []struct { 18 | name string 19 | yaml string 20 | wantErr bool 21 | errMsg string 22 | }{ 23 | { 24 | name: "valid theme", 25 | yaml: ` 26 | base: 27 | base00: "#1A1B26" 28 | base01: "#24283B" 29 | base02: "#292E42" 30 | base03: "#565F89" 31 | base04: "#A9B1D6" 32 | accent: 33 | accent0: "#FF9E64" 34 | accent1: "#9ECE6A" 35 | accent2: "#7AA2F7"`, 36 | wantErr: false, 37 | }, 38 | { 39 | name: "missing color", 40 | yaml: ` 41 | base: 42 | base00: "#1A1B26" 43 | base01: "#24283B" 44 | accent: 45 | accent0: "#FF9E64" 46 | accent1: "#9ECE6A" 47 | accent2: "#7AA2F7"`, 48 | wantErr: true, 49 | errMsg: ErrMissingColors, 50 | }, 51 | { 52 | name: "invalid color format - missing #", 53 | yaml: ` 54 | base: 55 | base00: "1A1B26" 56 | base01: "#24283B" 57 | base02: "#292E42" 58 | base03: "#565F89" 59 | base04: "#A9B1D6" 60 | accent: 61 | accent0: "#FF9E64" 62 | accent1: "#9ECE6A" 63 | accent2: "#7AA2F7"`, 64 | wantErr: true, 65 | errMsg: ErrInvalidColor, 66 | }, 67 | { 68 | name: "invalid color format - not hex", 69 | yaml: ` 70 | base: 71 | base00: "#ZZZZZZ" 72 | base01: "#24283B" 73 | base02: "#292E42" 74 | base03: "#565F89" 75 | base04: "#A9B1D6" 76 | accent: 77 | accent0: "#FF9E64" 78 | accent1: "#9ECE6A" 79 | accent2: "#7AA2F7"`, 80 | wantErr: true, 81 | errMsg: ErrInvalidColor, 82 | }, 83 | { 84 | name: "invalid yaml", 85 | yaml: `{`, // Invalid YAML syntax 86 | wantErr: true, 87 | errMsg: ErrDecodingTheme, 88 | }, 89 | } 90 | 91 | for _, tt := range tests { 92 | t.Run(tt.name, func(t *testing.T) { 93 | var node yaml.Node 94 | err := yaml.Unmarshal([]byte(tt.yaml), &node) 95 | if err != nil { 96 | if tt.wantErr && tt.errMsg == ErrDecodingTheme { 97 | assert.Error(t, err) 98 | return 99 | } 100 | t.Fatalf("failed to unmarshal YAML: %v", err) 101 | } 102 | 103 | var theme Theme 104 | err = theme.UnmarshalYAML(&node) 105 | 106 | if tt.wantErr { 107 | assert.Error(t, err) 108 | if err != nil { 109 | assert.Contains(t, err.Error(), tt.errMsg) 110 | } 111 | return 112 | } 113 | assert.NoError(t, err) 114 | }) 115 | } 116 | } 117 | 118 | // TestTheme_ColorValidation verifies that all theme colors are properly validated: 119 | // - All colors must be valid hex color codes 120 | // - Colors must start with '#' 121 | func TestTheme_ColorValidation(t *testing.T) { 122 | validTheme := ` 123 | base: 124 | base00: "#1A1B26" 125 | base01: "#24283B" 126 | base02: "#292E42" 127 | base03: "#565F89" 128 | base04: "#A9B1D6" 129 | accent: 130 | accent0: "#FF9E64" 131 | accent1: "#9ECE6A" 132 | accent2: "#7AA2F7"` 133 | 134 | var theme Theme 135 | var node yaml.Node 136 | err := yaml.Unmarshal([]byte(validTheme), &node) 137 | assert.NoError(t, err) 138 | 139 | err = theme.UnmarshalYAML(&node) 140 | assert.NoError(t, err) 141 | 142 | // Test base colors 143 | assert.Equal(t, "#1A1B26", string(theme.BaseColors.Base00)) 144 | assert.Equal(t, "#24283B", string(theme.BaseColors.Base01)) 145 | assert.Equal(t, "#292E42", string(theme.BaseColors.Base02)) 146 | assert.Equal(t, "#565F89", string(theme.BaseColors.Base03)) 147 | assert.Equal(t, "#A9B1D6", string(theme.BaseColors.Base04)) 148 | 149 | // Test accent colors 150 | assert.Equal(t, "#FF9E64", string(theme.AccentColors.Accent0)) 151 | assert.Equal(t, "#9ECE6A", string(theme.AccentColors.Accent1)) 152 | assert.Equal(t, "#7AA2F7", string(theme.AccentColors.Accent2)) 153 | } 154 | 155 | // TestTheme_ColorFormat verifies color format validation: 156 | // - Colors must be valid hex codes 157 | // - Colors must start with '#' 158 | func TestTheme_ColorFormat(t *testing.T) { 159 | tests := []struct { 160 | name string 161 | color string 162 | wantErr bool 163 | errMsg string 164 | }{ 165 | { 166 | name: "valid hex color", 167 | color: "#1A1B26", 168 | wantErr: false, 169 | }, 170 | { 171 | name: "missing hash", 172 | color: "1A1B26", 173 | wantErr: true, 174 | errMsg: ErrInvalidColor, 175 | }, 176 | { 177 | name: "invalid hex", 178 | color: "#ZZZZZZ", 179 | wantErr: true, 180 | errMsg: ErrInvalidColor, 181 | }, 182 | { 183 | name: "too short", 184 | color: "#1A1", 185 | wantErr: true, 186 | errMsg: ErrInvalidColor, 187 | }, 188 | { 189 | name: "too long", 190 | color: "#1A1B26FF", 191 | wantErr: true, 192 | errMsg: ErrInvalidColor, 193 | }, 194 | } 195 | 196 | for _, tt := range tests { 197 | t.Run(tt.name, func(t *testing.T) { 198 | yamlStr := fmt.Sprintf(` 199 | base: 200 | base00: "%s" 201 | base01: "#24283B" 202 | base02: "#292E42" 203 | base03: "#565F89" 204 | base04: "#A9B1D6" 205 | accent: 206 | accent0: "#FF9E64" 207 | accent1: "#9ECE6A" 208 | accent2: "#7AA2F7"`, tt.color) 209 | 210 | var node yaml.Node 211 | err := yaml.Unmarshal([]byte(yamlStr), &node) 212 | assert.NoError(t, err) 213 | 214 | var theme Theme 215 | err = theme.UnmarshalYAML(&node) 216 | 217 | if tt.wantErr { 218 | assert.Error(t, err) 219 | if err != nil { 220 | assert.Contains(t, err.Error(), tt.errMsg) 221 | } 222 | } else { 223 | assert.NoError(t, err) 224 | } 225 | }) 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /internal/thememanager/thememanager.go: -------------------------------------------------------------------------------- 1 | package thememanager 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "io/fs" 7 | "log/slog" 8 | "os" 9 | "path/filepath" 10 | 11 | "github.com/datolabs-io/opsy/assets" 12 | "gopkg.in/yaml.v3" 13 | ) 14 | 15 | const ( 16 | // ErrThemeNotFound is returned when a theme is not found. 17 | ErrThemeNotFound = "theme not found" 18 | // ErrReadingTheme is returned when theme file cannot be read. 19 | ErrReadingTheme = "failed to read theme file" 20 | // ErrParsingTheme is returned when theme file cannot be parsed. 21 | ErrParsingTheme = "failed to parse theme file" 22 | ) 23 | 24 | const ( 25 | // defaultTheme is the default theme name. 26 | defaultTheme = "default" 27 | // themeExtension is the extension for theme files. 28 | themeExtension = "yaml" 29 | ) 30 | 31 | // Manager is the interface for the theme manager. 32 | type Manager interface { 33 | // LoadTheme loads a named theme from the theme manager. 34 | LoadTheme(name string) error 35 | // GetTheme returns the current theme. 36 | GetTheme() *Theme 37 | } 38 | 39 | // ThemeManager is the manager for the themes. 40 | type ThemeManager struct { 41 | logger *slog.Logger 42 | fs fs.FS 43 | dir string 44 | theme *Theme 45 | } 46 | 47 | // Option is a function that modifies the theme manager. 48 | type Option func(*ThemeManager) 49 | 50 | // New creates a new theme manager. 51 | func New(opts ...Option) *ThemeManager { 52 | tm := &ThemeManager{ 53 | fs: assets.Themes, 54 | dir: assets.ThemeDir, 55 | logger: slog.New(slog.DiscardHandler), 56 | } 57 | 58 | for _, opt := range opts { 59 | opt(tm) 60 | } 61 | 62 | tm.logger.WithGroup("config").With("directory", tm.dir).Debug("Theme manager initialized.") 63 | 64 | return tm 65 | } 66 | 67 | // WithDirectory sets the directory for the theme manager. 68 | func WithDirectory(dir string) Option { 69 | return func(tm *ThemeManager) { 70 | tm.fs = os.DirFS(dir) 71 | tm.dir = dir 72 | } 73 | } 74 | 75 | // WithLogger sets the logger for the theme manager. 76 | func WithLogger(logger *slog.Logger) Option { 77 | return func(tm *ThemeManager) { 78 | tm.logger = logger.With("component", "thememanager") 79 | } 80 | } 81 | 82 | // LoadTheme loads a named theme from the theme manager. 83 | func (tm *ThemeManager) LoadTheme(name string) (err error) { 84 | if name == "" { 85 | name = defaultTheme 86 | } 87 | 88 | var data []byte 89 | file, err := tm.fs.Open(tm.getFilePath(name)) 90 | if err != nil { 91 | return fmt.Errorf("%s: %v", ErrThemeNotFound, err) 92 | } 93 | 94 | defer file.Close() 95 | 96 | data, err = io.ReadAll(file) 97 | if err != nil { 98 | return fmt.Errorf("%s: %v", ErrReadingTheme, err) 99 | } 100 | 101 | if err := yaml.Unmarshal(data, &tm.theme); err != nil { 102 | return fmt.Errorf("%s: %v", ErrParsingTheme, err) 103 | } 104 | 105 | tm.logger.WithGroup("theme").With("name", name).Debug("Theme loaded.") 106 | 107 | return nil 108 | } 109 | 110 | // GetTheme returns the current theme. 111 | func (tm *ThemeManager) GetTheme() *Theme { 112 | return tm.theme 113 | } 114 | 115 | // getFilePath returns the file path for a given theme name. 116 | func (tm *ThemeManager) getFilePath(name string) string { 117 | return filepath.Join(tm.dir, fmt.Sprintf("%s.%s", name, themeExtension)) 118 | } 119 | -------------------------------------------------------------------------------- /internal/thememanager/thememanager_test.go: -------------------------------------------------------------------------------- 1 | package thememanager 2 | 3 | import ( 4 | "log/slog" 5 | "sync" 6 | "testing" 7 | 8 | "github.com/charmbracelet/lipgloss" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | // TestLoadTheme verifies theme loading functionality: 13 | // - Loading default theme (empty name) 14 | // - Loading theme by name 15 | // - Handling invalid YAML format 16 | // - Handling non-existent themes 17 | func TestLoadTheme(t *testing.T) { 18 | tests := []struct { 19 | name string 20 | theme string 21 | wantErr bool 22 | errMsg string 23 | }{ 24 | { 25 | name: "empty name (default theme)", 26 | theme: "", 27 | wantErr: false, 28 | }, 29 | { 30 | name: "theme name only", 31 | theme: "default", 32 | wantErr: false, 33 | }, 34 | { 35 | name: "non-existent theme", 36 | theme: "nonexistent", 37 | wantErr: true, 38 | errMsg: ErrThemeNotFound, 39 | }, 40 | } 41 | 42 | for _, tt := range tests { 43 | t.Run(tt.name, func(t *testing.T) { 44 | // Create a test logger 45 | testLogger := slog.New(slog.DiscardHandler) 46 | tm := New(WithLogger(testLogger)) 47 | err := tm.LoadTheme(tt.theme) 48 | 49 | if tt.wantErr { 50 | assert.Error(t, err) 51 | assert.Contains(t, err.Error(), tt.errMsg) 52 | return 53 | } 54 | 55 | assert.NoError(t, err) 56 | theme := tm.GetTheme() 57 | assert.NotNil(t, theme, "theme should not be nil") 58 | 59 | // Verify colors for valid themes 60 | colors := []struct { 61 | name string 62 | color lipgloss.Color 63 | }{ 64 | {"base.base00", theme.BaseColors.Base00}, 65 | {"base.base01", theme.BaseColors.Base01}, 66 | {"base.base02", theme.BaseColors.Base02}, 67 | {"base.base03", theme.BaseColors.Base03}, 68 | {"base.base04", theme.BaseColors.Base04}, 69 | {"accent.accent0", theme.AccentColors.Accent0}, 70 | {"accent.accent1", theme.AccentColors.Accent1}, 71 | {"accent.accent2", theme.AccentColors.Accent2}, 72 | } 73 | 74 | for _, c := range colors { 75 | assert.NotEmpty(t, string(c.color), "color %s should not be empty", c.name) 76 | if s := string(c.color); s != "" { 77 | assert.True(t, s[0] == '#', "color %s = %s, should start with #", c.name, s) 78 | } 79 | } 80 | }) 81 | } 82 | } 83 | 84 | // TestThemeManager_WithLogger verifies logger functionality 85 | func TestThemeManager_WithLogger(t *testing.T) { 86 | testLogger := slog.New(slog.DiscardHandler) 87 | tm := New(WithLogger(testLogger)) 88 | 89 | assert.Equal(t, testLogger, tm.logger, "logger should be set correctly") 90 | } 91 | 92 | // TestThemeManager_WithDirectory verifies custom directory loading: 93 | // - Loading themes from a custom directory 94 | // - Handling non-existent directory 95 | func TestThemeManager_WithDirectory(t *testing.T) { 96 | tests := []struct { 97 | name string 98 | dir string 99 | theme string 100 | wantErr bool 101 | errMsg string 102 | }{ 103 | { 104 | name: "custom directory", 105 | dir: "testdata", 106 | theme: "default", 107 | wantErr: true, 108 | errMsg: ErrThemeNotFound, 109 | }, 110 | { 111 | name: "non-existent directory", 112 | dir: "nonexistent", 113 | theme: "default", 114 | wantErr: true, 115 | errMsg: ErrThemeNotFound, 116 | }, 117 | } 118 | 119 | for _, tt := range tests { 120 | t.Run(tt.name, func(t *testing.T) { 121 | // Create a test logger 122 | testLogger := slog.New(slog.DiscardHandler) 123 | tm := New( 124 | WithDirectory(tt.dir), 125 | WithLogger(testLogger), 126 | ) 127 | err := tm.LoadTheme(tt.theme) 128 | 129 | if tt.wantErr { 130 | assert.Error(t, err) 131 | assert.Contains(t, err.Error(), tt.errMsg) 132 | return 133 | } 134 | 135 | assert.NoError(t, err) 136 | assert.NotNil(t, tm.GetTheme()) 137 | }) 138 | } 139 | } 140 | 141 | // TestThemeManager_EmptyName verifies empty theme name behavior: 142 | // - Empty name loads default theme 143 | // - Default theme is valid and complete 144 | func TestThemeManager_EmptyName(t *testing.T) { 145 | tm := New() 146 | err := tm.LoadTheme("") 147 | assert.NoError(t, err) 148 | 149 | theme := tm.GetTheme() 150 | assert.NotNil(t, theme) 151 | 152 | // Verify all colors are present in default theme 153 | assert.NotEmpty(t, theme.BaseColors.Base00) 154 | assert.NotEmpty(t, theme.BaseColors.Base01) 155 | assert.NotEmpty(t, theme.BaseColors.Base02) 156 | assert.NotEmpty(t, theme.BaseColors.Base03) 157 | assert.NotEmpty(t, theme.BaseColors.Base04) 158 | assert.NotEmpty(t, theme.AccentColors.Accent0) 159 | assert.NotEmpty(t, theme.AccentColors.Accent1) 160 | assert.NotEmpty(t, theme.AccentColors.Accent2) 161 | } 162 | 163 | // TestThemeManager_ConcurrentAccess verifies thread safety: 164 | // - Concurrent theme loading 165 | // - Concurrent theme reading 166 | func TestThemeManager_ConcurrentAccess(t *testing.T) { 167 | tm := New() 168 | var wg sync.WaitGroup 169 | numGoroutines := 10 170 | 171 | // Test concurrent loading 172 | wg.Add(numGoroutines) 173 | for i := 0; i < numGoroutines; i++ { 174 | go func() { 175 | defer wg.Done() 176 | err := tm.LoadTheme("default") 177 | assert.NoError(t, err) 178 | }() 179 | } 180 | wg.Wait() 181 | 182 | // Test concurrent reading 183 | wg.Add(numGoroutines) 184 | for i := 0; i < numGoroutines; i++ { 185 | go func() { 186 | defer wg.Done() 187 | theme := tm.GetTheme() 188 | assert.NotNil(t, theme) 189 | assert.NotEmpty(t, theme.BaseColors.Base00) 190 | }() 191 | } 192 | wg.Wait() 193 | 194 | // Test mixed loading and reading 195 | wg.Add(numGoroutines * 2) 196 | for i := 0; i < numGoroutines; i++ { 197 | go func() { 198 | defer wg.Done() 199 | err := tm.LoadTheme("default") 200 | assert.NoError(t, err) 201 | }() 202 | go func() { 203 | defer wg.Done() 204 | theme := tm.GetTheme() 205 | if theme != nil { 206 | assert.NotEmpty(t, theme.BaseColors.Base00) 207 | } 208 | }() 209 | } 210 | wg.Wait() 211 | } 212 | -------------------------------------------------------------------------------- /internal/tool/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package tool provides functionality for defining and executing tools within the opsy application. 3 | 4 | A tool is a unit of functionality that can be executed by the agent to perform specific tasks. 5 | Each tool has a definition that describes its capabilities, inputs, and behavior. 6 | 7 | # Tool Definition 8 | 9 | Tools are defined using the Definition struct, which includes: 10 | 11 | - DisplayName: Human-readable name shown in the UI 12 | - Description: Detailed description of the tool's purpose 13 | - Rules: Additional rules the tool must follow 14 | - Inputs: Map of input parameters the tool accepts 15 | - Executable: Optional path to an executable the tool uses 16 | 17 | # Input Schema 18 | 19 | Tools can define their input requirements using the Input struct: 20 | 21 | - Type: Data type of the input (e.g., "string", "number") 22 | - Description: Human-readable description of the input 23 | - Default: Default value if none is provided 24 | - Examples: List of example values 25 | - Optional: Whether the input is required 26 | 27 | Every tool automatically includes common inputs: 28 | 29 | - task: The task to be executed (required) 30 | - working_directory: Directory to execute in (optional, defaults to ".") 31 | - context: Additional context parameters (optional) 32 | 33 | # Tool Interface 34 | 35 | The Tool interface defines the methods a tool must implement: 36 | 37 | - GetName: Returns the tool's identifier 38 | - GetDisplayName: Returns the human-readable name 39 | - GetDescription: Returns the tool's description 40 | - GetInputSchema: Returns the JSON schema for inputs 41 | - Execute: Executes the tool with given inputs 42 | 43 | # Tool Types 44 | 45 | The package includes two main types of tools: 46 | 47 | 1. Regular tools (tool): Base implementation that can be extended 48 | 2. Exec tools (execTool): Special tools that execute shell commands 49 | 50 | The exec tool has specific features: 51 | 52 | - Command execution with configurable timeouts 53 | - Working directory resolution (absolute, relative, and ./ paths) 54 | - Command output and exit code capture 55 | - Timestamp tracking for command execution 56 | - Process group management for proper cleanup 57 | 58 | # Example Usage 59 | 60 | Creating a new tool: 61 | 62 | def := tool.Definition{ 63 | DisplayName: "My Tool", 64 | Description: "Does something useful", 65 | Rules: []string{"Follow these rules"}, 66 | Inputs: map[string]tool.Input{ 67 | "param": { 68 | Type: "string", 69 | Description: "A parameter", 70 | Optional: false, 71 | }, 72 | }, 73 | } 74 | 75 | myTool := tool.New("my-tool", def, logger, cfg, runner) 76 | 77 | Creating an exec tool: 78 | 79 | execTool := tool.NewExecTool(logger, cfg) 80 | 81 | Using the exec tool: 82 | 83 | output, err := execTool.Execute(map[string]any{ 84 | "command": "ls -la", 85 | "working_directory": "./mydir", 86 | }, ctx) 87 | 88 | # Error Handling 89 | 90 | The package defines several error types for validation: 91 | 92 | - ErrToolMissingDisplayName: Tool definition lacks a display name 93 | - ErrToolMissingDescription: Tool definition lacks a description 94 | - ErrToolInputMissingType: Input definition lacks a type 95 | - ErrToolInputMissingDescription: Input definition lacks a description 96 | - ErrToolExecutableNotFound: Specified executable not found 97 | - ErrInvalidToolInputType: Input value has wrong type 98 | 99 | # Thread Safety 100 | 101 | Tools are designed to be thread-safe and can be executed concurrently. 102 | Each execution: 103 | - Gets its own context and timeout based on configuration 104 | - Has isolated working directory resolution 105 | - Maintains independent command state and output 106 | - Uses process groups for clean termination 107 | */ 108 | package tool 109 | -------------------------------------------------------------------------------- /internal/tool/exec.go: -------------------------------------------------------------------------------- 1 | package tool 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | "os" 8 | "os/exec" 9 | "path/filepath" 10 | "strings" 11 | "syscall" 12 | "time" 13 | 14 | "github.com/datolabs-io/opsy/internal/config" 15 | "github.com/invopop/jsonschema" 16 | ) 17 | 18 | // ExecTool is the tool for executing commands. 19 | type execTool tool 20 | 21 | // ExecToolName is the name of the exec tool. 22 | const ExecToolName = "exec" 23 | 24 | // Command is the command that was executed. 25 | type Command struct { 26 | // Command is the command that was executed. 27 | Command string 28 | // WorkingDirectory is the working directory of the command. 29 | WorkingDirectory string 30 | // ExitCode is the exit code of the command. 31 | ExitCode int 32 | // Output is the output of the command. 33 | Output string 34 | // StartedAt is the time the command started. 35 | StartedAt time.Time 36 | // CompletedAt is the time the command completed. 37 | CompletedAt time.Time 38 | } 39 | 40 | const ( 41 | // inputCommand is the input parameter for the command to execute. 42 | inputCommand = "command" 43 | ) 44 | 45 | // NewExecTool creates a new exec tool. 46 | func NewExecTool(logger *slog.Logger, cfg *config.ToolsConfiguration) *execTool { 47 | definition := Definition{ 48 | DisplayName: "Exec", 49 | Description: fmt.Sprintf("Executes the provided shell command via the `%s` shell.", cfg.Exec.Shell), 50 | Inputs: map[string]Input{ 51 | inputCommand: { 52 | Description: "The shell command, including all the arguments, to execute", 53 | Type: "string", 54 | Examples: []any{ 55 | "ls -l | grep 'myfile'", 56 | "git status", 57 | "curl -X GET https://api.example.com/data", 58 | }, 59 | }, 60 | inputWorkingDirectory: { 61 | Description: "The working directory for the command", 62 | Type: "string", 63 | Examples: []any{ 64 | "/path/to/working/directory", 65 | ".", 66 | }, 67 | }, 68 | }, 69 | } 70 | 71 | return (*execTool)(New(ExecToolName, definition, logger, cfg, nil)) 72 | } 73 | 74 | // GetName returns the name of the tool. 75 | func (t *execTool) GetName() string { 76 | return (*tool)(t).GetName() 77 | } 78 | 79 | // GetDisplayName returns the display name of the tool. 80 | func (t *execTool) GetDisplayName() string { 81 | return (*tool)(t).GetDisplayName() 82 | } 83 | 84 | // GetDescription returns the description of the tool. 85 | func (t *execTool) GetDescription() string { 86 | return (*tool)(t).GetDescription() 87 | } 88 | 89 | // GetInputSchema returns the input schema of the tool. 90 | func (t *execTool) GetInputSchema() *jsonschema.Schema { 91 | return (*tool)(t).GetInputSchema() 92 | } 93 | 94 | // Execute executes the tool. 95 | func (t *execTool) Execute(inputs map[string]any, ctx context.Context) (*Output, error) { 96 | command, ok := inputs[inputCommand].(string) 97 | if !ok { 98 | return nil, fmt.Errorf("%s: %s", ErrInvalidToolInputType, inputCommand) 99 | } 100 | 101 | workingDirectory := getWorkingDirectory(inputs) 102 | ctx, cancel := context.WithTimeout(ctx, t.getTimeout()) 103 | defer cancel() 104 | 105 | cmd := exec.CommandContext(ctx, t.config.Exec.Shell, "-c", command) 106 | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} 107 | cmd.Dir = workingDirectory 108 | cmd.Stdin = nil 109 | startedAt := time.Now() 110 | 111 | logger := t.logger.With("command", cmd.String()).With("working_directory", workingDirectory) 112 | logger.Debug("Executing command.") 113 | 114 | toolOutput, err := cmd.CombinedOutput() 115 | output := &Output{ 116 | Tool: t.GetName(), 117 | Result: strings.TrimSpace(string(toolOutput)), 118 | IsError: false, 119 | ExecutedCommand: &Command{ 120 | Command: command, 121 | WorkingDirectory: workingDirectory, 122 | ExitCode: cmd.ProcessState.ExitCode(), 123 | StartedAt: startedAt, 124 | CompletedAt: time.Now(), 125 | }, 126 | } 127 | 128 | if toolOutput != nil { 129 | output.ExecutedCommand.Output = output.Result 130 | } 131 | 132 | if err != nil { 133 | logger.With("error", err).With("exit_code", cmd.ProcessState.ExitCode()).Error("Command execution failed.") 134 | output.IsError = true 135 | } 136 | 137 | return output, err 138 | } 139 | 140 | // getTimeout returns the timeout for the Exec tool. 141 | func (t *execTool) getTimeout() time.Duration { 142 | timeout := t.config.Timeout 143 | if t.config.Exec.Timeout > 0 { 144 | timeout = t.config.Exec.Timeout 145 | } 146 | 147 | return time.Duration(timeout) * time.Second 148 | } 149 | 150 | // getWorkingDirectory returns the working directory for the Exec tool. 151 | func getWorkingDirectory(inputs map[string]any) string { 152 | currentDir, _ := os.Getwd() 153 | currentDir = strings.TrimRight(currentDir, string(os.PathSeparator)) 154 | 155 | workingDir, ok := inputs[inputWorkingDirectory].(string) 156 | if !ok || workingDir == "." { 157 | return currentDir 158 | } 159 | 160 | // Handle paths starting with ./ 161 | if strings.HasPrefix(workingDir, "."+string(os.PathSeparator)) { 162 | return strings.TrimRight(filepath.Join(currentDir, strings.TrimPrefix(workingDir, "."+string(os.PathSeparator))), string(os.PathSeparator)) 163 | } 164 | 165 | // Handle absolute paths 166 | if strings.HasPrefix(workingDir, string(os.PathSeparator)) { 167 | return strings.TrimRight(workingDir, string(os.PathSeparator)) 168 | } 169 | 170 | // Handle relative paths (without ./ prefix) 171 | return strings.TrimRight(filepath.Join(currentDir, workingDir), string(os.PathSeparator)) 172 | } 173 | -------------------------------------------------------------------------------- /internal/tool/runner.go: -------------------------------------------------------------------------------- 1 | package tool 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // Runner is an interface that defines the methods for an agent. 8 | type Runner interface { 9 | Run(opts *RunOptions, ctx context.Context) ([]Output, error) 10 | } 11 | 12 | // RunOptions is a struct that contains the options for runner run. 13 | type RunOptions struct { 14 | // Task is the task to be executed. 15 | Task string 16 | // Prompt is an optional prompt to be used for the agent instead of the default one. 17 | Prompt string 18 | // Caller is an optional tool that is calling the agent. 19 | Caller string 20 | // Tools is an optional list of tools to be used by the agent. 21 | Tools map[string]Tool 22 | } 23 | -------------------------------------------------------------------------------- /internal/toolmanager/doc.go: -------------------------------------------------------------------------------- 1 | // Package toolmanager provides functionality for managing tools within the application. 2 | // It handles loading tool definitions from files and managing their lifecycle. 3 | // 4 | // Tools are defined using YAML configuration files and must implement the tool.Tool 5 | // interface from the tool package. The toolmanager loads these definitions and 6 | // creates the appropriate tool instances. 7 | // 8 | // The toolmanager is responsible for: 9 | // - Loading tool definitions from YAML files 10 | // - Creating and managing tool instances 11 | // - Providing access to tools by name 12 | // - Maintaining the tool registry 13 | // - Managing the exec tool as a special built-in tool 14 | // 15 | // Example usage: 16 | // 17 | // agent := agent.New( 18 | // agent.WithLogger(logger), 19 | // agent.WithConfig(cfg), 20 | // ) 21 | // 22 | // tm := toolmanager.New( 23 | // toolmanager.WithLogger(logger), 24 | // toolmanager.WithConfig(cfg), 25 | // toolmanager.WithAgent(agent), 26 | // ) 27 | // 28 | // if err := tm.LoadTools(); err != nil { 29 | // // Handle error 30 | // } 31 | // 32 | // tools := tm.GetTools() 33 | // 34 | // Tool definitions are loaded from YAML files and include: 35 | // - Display name for UI presentation 36 | // - Description of the tool's functionality 37 | // - System prompt for AI interaction 38 | // - Input parameters with validation schemas 39 | // - Optional executable path for command-line tools 40 | // 41 | // Tool Validation: 42 | // 43 | // Each tool definition is validated to ensure: 44 | // - Display name is provided and non-empty 45 | // - Description is provided and non-empty 46 | // - Input parameters have valid types and descriptions 47 | // - System prompt is valid if provided 48 | // - Executable path exists and is executable if specified 49 | // 50 | // Exec Tool: 51 | // 52 | // The exec tool is a special built-in tool that: 53 | // - Is always loaded regardless of configuration 54 | // - Provides direct command execution capabilities 55 | // - Uses the shell specified in configuration 56 | // - Has its own timeout configuration 57 | // 58 | // Error Handling: 59 | // 60 | // The package uses the following error constants: 61 | // - ErrLoadingTools: Returned when tools cannot be loaded from directory 62 | // - ErrLoadingTool: Returned when a specific tool fails to load 63 | // - ErrParsingTool: Returned when tool YAML parsing fails 64 | // - ErrToolNotFound: Returned when requested tool doesn't exist 65 | // - ErrInvalidToolDefinition: Returned when tool definition is invalid 66 | // 67 | // Thread Safety: 68 | // 69 | // The toolmanager is safe for concurrent access: 70 | // - Tool loading is synchronized 71 | // - Tool access methods are safe for concurrent use 72 | // - Tool instances are immutable after creation 73 | // - The exec tool maintains its own thread safety 74 | // 75 | // The toolmanager requires an agent to be provided for tool execution. The agent 76 | // is responsible for running tool operations and managing their lifecycle. 77 | // 78 | // The package uses JSON Schema for input validation and provides error handling 79 | // for common failure scenarios such as missing tools or invalid configurations. 80 | package toolmanager 81 | -------------------------------------------------------------------------------- /internal/toolmanager/testdata/executable_tool.yaml: -------------------------------------------------------------------------------- 1 | display_name: Executable Tool 2 | description: A test tool with executable 3 | system_prompt: | 4 | This is a test tool that uses an executable. 5 | It demonstrates the executable property. 6 | executable: ls 7 | inputs: 8 | path: 9 | type: string 10 | description: Path to list contents of 11 | default: "." 12 | examples: 13 | - "/tmp" 14 | - "~/Documents" 15 | optional: true 16 | -------------------------------------------------------------------------------- /internal/toolmanager/testdata/invalid_tool.yaml: -------------------------------------------------------------------------------- 1 | display_name: Invalid Tool 2 | description: A tool with invalid executable 3 | system_prompt: This is an invalid tool. 4 | executable: non-existent-executable 5 | inputs: {} 6 | -------------------------------------------------------------------------------- /internal/toolmanager/testdata/test_tool.yaml: -------------------------------------------------------------------------------- 1 | display_name: Test Tool 2 | description: A tool for testing purposes 3 | system_prompt: You are a test tool. 4 | inputs: 5 | test_input: 6 | type: string 7 | description: A test input parameter 8 | default: default value 9 | examples: 10 | - example 1 11 | - example 2 12 | optional: false 13 | optional_input: 14 | type: string 15 | description: An optional test input 16 | default: "" 17 | examples: 18 | - optional example 19 | optional: true 20 | -------------------------------------------------------------------------------- /internal/toolmanager/toolmanager.go: -------------------------------------------------------------------------------- 1 | package toolmanager 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io/fs" 7 | "log/slog" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | "sync" 12 | 13 | "github.com/datolabs-io/opsy/assets" 14 | "github.com/datolabs-io/opsy/internal/agent" 15 | "github.com/datolabs-io/opsy/internal/config" 16 | "github.com/datolabs-io/opsy/internal/tool" 17 | "gopkg.in/yaml.v3" 18 | ) 19 | 20 | const ( 21 | // ErrLoadingTools is the error message for failed to load tools. 22 | ErrLoadingTools = "failed to load tools" 23 | // ErrLoadingTool is the error message for failed to load a specific tool. 24 | ErrLoadingTool = "failed to load tool" 25 | // ErrParsingTool is the error message for failed to parse a tool. 26 | ErrParsingTool = "failed to parse tool" 27 | // ErrToolNotFound is the error message for a tool not found. 28 | ErrToolNotFound = "tool not found" 29 | // ErrInvalidToolDefinition is the error message for an invalid tool definition. 30 | ErrInvalidToolDefinition = "invalid tool definition" 31 | ) 32 | 33 | // Manager is the interface for the tool manager. 34 | type Manager interface { 35 | // LoadTools loads the tools from the tool manager. 36 | LoadTools() error 37 | // GetTools returns all tools. 38 | GetTools() map[string]tool.Tool 39 | // GetTool returns a tool by name. 40 | GetTool(name string) (tool.Tool, error) 41 | } 42 | 43 | // ToolManager is the tool manager. 44 | type ToolManager struct { 45 | cfg config.Configuration 46 | logger *slog.Logger 47 | ctx context.Context 48 | fs fs.FS 49 | dir string 50 | tools map[string]tool.Tool 51 | agent *agent.Agent 52 | mu sync.RWMutex 53 | } 54 | 55 | // Option is a function that modifies the tool manager. 56 | type Option func(*ToolManager) 57 | 58 | // New creates a new tool manager. 59 | func New(opts ...Option) *ToolManager { 60 | tm := &ToolManager{ 61 | cfg: config.New().GetConfig(), 62 | logger: slog.New(slog.DiscardHandler), 63 | ctx: context.Background(), 64 | fs: assets.Tools, 65 | dir: assets.ToolsDir, 66 | tools: make(map[string]tool.Tool), 67 | agent: nil, 68 | } 69 | 70 | for _, opt := range opts { 71 | opt(tm) 72 | } 73 | 74 | tm.logger.WithGroup("config").With("directory", tm.dir).Debug("Tool manager initialized.") 75 | 76 | return tm 77 | } 78 | 79 | // WithConfig sets the configuration for the tool manager. 80 | func WithConfig(cfg config.Configuration) Option { 81 | return func(tm *ToolManager) { 82 | tm.cfg = cfg 83 | } 84 | } 85 | 86 | // WithAgent sets the agent for the tool manager. 87 | func WithAgent(agent *agent.Agent) Option { 88 | return func(tm *ToolManager) { 89 | tm.agent = agent 90 | } 91 | } 92 | 93 | // WithLogger sets the logger for the tool manager. 94 | func WithLogger(logger *slog.Logger) Option { 95 | return func(tm *ToolManager) { 96 | tm.logger = logger.With("component", "toolmanager") 97 | } 98 | } 99 | 100 | // WithDirectory sets the directory for the tool manager. 101 | func WithDirectory(dir string) Option { 102 | return func(tm *ToolManager) { 103 | tm.fs = os.DirFS(dir) 104 | tm.dir = "." 105 | } 106 | } 107 | 108 | // WithContext sets the context for the tool manager. 109 | func WithContext(ctx context.Context) Option { 110 | return func(tm *ToolManager) { 111 | tm.ctx = ctx 112 | } 113 | } 114 | 115 | // LoadTools loads the tools from the tool manager. 116 | func (tm *ToolManager) LoadTools() error { 117 | toolFiles, err := fs.ReadDir(tm.fs, tm.dir) 118 | if err != nil { 119 | return fmt.Errorf("%s: %v", ErrLoadingTools, err) 120 | } 121 | 122 | tm.mu.Lock() 123 | defer tm.mu.Unlock() 124 | 125 | for k := range tm.tools { 126 | delete(tm.tools, k) 127 | } 128 | 129 | // Exec tool is a special tool which we always statically load. 130 | tm.tools[tool.ExecToolName] = tool.NewExecTool(tm.logger, &tm.cfg.Tools) 131 | 132 | for _, toolFile := range toolFiles { 133 | if toolFile.IsDir() { 134 | continue 135 | } 136 | 137 | name := strings.TrimSuffix(toolFile.Name(), filepath.Ext(toolFile.Name())) 138 | tool, err := tm.loadTool(name, toolFile) 139 | if err != nil { 140 | tm.logger.With("tool.name", name).With("filename", toolFile.Name()).With("error", err). 141 | Error("Failed to load the tool.") 142 | continue 143 | } 144 | 145 | tm.tools[name] = tool 146 | } 147 | 148 | tm.logger.With("tools.count", len(tm.tools)).Debug("Tools loaded.") 149 | 150 | return nil 151 | } 152 | 153 | // loadTool loads a tool from a file. 154 | func (tm *ToolManager) loadTool(name string, toolFile fs.DirEntry) (tool.Tool, error) { 155 | contents, err := fs.ReadFile(tm.fs, filepath.Join(tm.dir, toolFile.Name())) 156 | if err != nil { 157 | return nil, fmt.Errorf("%s: %v", ErrLoadingTool, err) 158 | } 159 | 160 | var definition tool.Definition 161 | if err := yaml.Unmarshal(contents, &definition); err != nil { 162 | return nil, fmt.Errorf("%s: %v", ErrParsingTool, err) 163 | } 164 | 165 | if err := tool.ValidateDefinition(&definition); err != nil { 166 | return nil, fmt.Errorf("%s: %s: %v", ErrInvalidToolDefinition, name, err) 167 | } 168 | 169 | return tool.New(name, definition, tm.logger, &tm.cfg.Tools, tm.agent), nil 170 | } 171 | 172 | // GetTools returns all tools. 173 | func (tm *ToolManager) GetTools() map[string]tool.Tool { 174 | tm.mu.RLock() 175 | defer tm.mu.RUnlock() 176 | 177 | return tm.tools 178 | } 179 | 180 | // GetTool returns a tool by name. 181 | func (tm *ToolManager) GetTool(name string) (tool.Tool, error) { 182 | tm.mu.RLock() 183 | defer tm.mu.RUnlock() 184 | 185 | tool, ok := tm.tools[name] 186 | if !ok { 187 | return nil, fmt.Errorf("%s: %v", ErrToolNotFound, name) 188 | } 189 | 190 | return tool, nil 191 | } 192 | -------------------------------------------------------------------------------- /internal/tui/components/commandspane/commandspane.go: -------------------------------------------------------------------------------- 1 | package commandspane 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/charmbracelet/bubbles/viewport" 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/charmbracelet/lipgloss" 10 | "github.com/datolabs-io/opsy/internal/thememanager" 11 | "github.com/datolabs-io/opsy/internal/tool" 12 | "github.com/muesli/reflow/wrap" 13 | ) 14 | 15 | // Model represents the commands pane component. 16 | // It maintains the state of the commands list and viewport, 17 | // handling command history and display formatting. 18 | type Model struct { 19 | // theme defines the color scheme for the component 20 | theme thememanager.Theme 21 | // maxWidth is the maximum width of the component 22 | maxWidth int 23 | // maxHeight is the maximum height of the component 24 | maxHeight int 25 | // viewport handles scrollable content display 26 | viewport viewport.Model 27 | // commands stores the history of executed commands 28 | commands []tool.Command 29 | } 30 | 31 | // Option is a function that modifies the Model. 32 | type Option func(*Model) 33 | 34 | // title is the title of the commands pane. 35 | const title = "Commands" 36 | 37 | // New creates a new commands pane component. 38 | func New(opts ...Option) *Model { 39 | m := &Model{ 40 | viewport: viewport.New(0, 0), 41 | commands: []tool.Command{}, 42 | } 43 | 44 | for _, opt := range opts { 45 | opt(m) 46 | } 47 | 48 | return m 49 | } 50 | 51 | // Init initializes the commands pane component. 52 | func (m *Model) Init() tea.Cmd { 53 | return nil 54 | } 55 | 56 | // Update handles messages and updates the commands pane component. 57 | func (m *Model) Update(msg tea.Msg) (*Model, tea.Cmd) { 58 | var cmd tea.Cmd 59 | switch msg := msg.(type) { 60 | case tea.WindowSizeMsg: 61 | m.maxWidth = msg.Width - 6 62 | m.maxHeight = msg.Height 63 | m.viewport.Width = m.maxWidth 64 | m.viewport.Height = msg.Height 65 | m.viewport.Style = lipgloss.NewStyle().Background(m.theme.BaseColors.Base01) 66 | 67 | // Rerender all commands with new dimensions 68 | if len(m.commands) > 0 { 69 | m.renderCommands() 70 | } else { 71 | m.viewport.SetContent(m.titleStyle().Render(title)) 72 | } 73 | case tool.Command: 74 | m.commands = append(m.commands, msg) 75 | m.renderCommands() 76 | m.viewport.GotoBottom() 77 | } 78 | 79 | m.viewport, cmd = m.viewport.Update(msg) 80 | return m, cmd 81 | } 82 | 83 | // View renders the commands pane component. 84 | func (m *Model) View() string { 85 | return m.containerStyle().Render(m.viewport.View()) 86 | } 87 | 88 | // WithTheme sets the theme for the commands pane component. 89 | func WithTheme(theme thememanager.Theme) Option { 90 | return func(m *Model) { 91 | m.theme = theme 92 | } 93 | } 94 | 95 | // containerStyle creates a style for the container of the commands pane component. 96 | func (m *Model) containerStyle() lipgloss.Style { 97 | return lipgloss.NewStyle(). 98 | Background(m.theme.BaseColors.Base01). 99 | Padding(1, 2). 100 | Border(lipgloss.NormalBorder(), true). 101 | BorderForeground(m.theme.BaseColors.Base02). 102 | BorderBackground(m.theme.BaseColors.Base00) 103 | } 104 | 105 | // commandStyle creates a style for the command text. 106 | func (m *Model) commandStyle() lipgloss.Style { 107 | return lipgloss.NewStyle(). 108 | Foreground(m.theme.AccentColors.Accent0). 109 | Background(m.theme.BaseColors.Base01) 110 | } 111 | 112 | // timestampStyle creates a style for the timestamp of the commands pane component. 113 | func (m *Model) timestampStyle() lipgloss.Style { 114 | return lipgloss.NewStyle(). 115 | Foreground(m.theme.BaseColors.Base03). 116 | Background(m.theme.BaseColors.Base01). 117 | PaddingRight(1) 118 | } 119 | 120 | // workdirStyle creates a style for the working directory. 121 | func (m *Model) workdirStyle() lipgloss.Style { 122 | return lipgloss.NewStyle(). 123 | Foreground(m.theme.BaseColors.Base04). 124 | Background(m.theme.BaseColors.Base03). 125 | Margin(0, 1, 0, 0). 126 | MarginBackground(m.theme.BaseColors.Base01). 127 | Padding(0, 1) 128 | } 129 | 130 | // titleStyle creates a style for the title. 131 | func (m *Model) titleStyle() lipgloss.Style { 132 | return lipgloss.NewStyle(). 133 | Foreground(m.theme.BaseColors.Base04). 134 | Background(m.theme.BaseColors.Base01). 135 | Bold(true). 136 | Width(m.maxWidth) 137 | } 138 | 139 | // renderCommands formats and renders all commands 140 | func (m *Model) renderCommands() { 141 | output := strings.Builder{} 142 | content := strings.Builder{} 143 | content.WriteString(m.titleStyle().Render(title)) 144 | content.WriteString("\n\n") 145 | 146 | for _, cmd := range m.commands { 147 | timestamp := m.timestampStyle().Render(fmt.Sprintf("[%s]", cmd.StartedAt.Format("15:04:05"))) 148 | workdir := m.workdirStyle().Render(cmd.WorkingDirectory) 149 | 150 | // Calculate available width for command 151 | commandWidth := m.maxWidth - lipgloss.Width(timestamp) - lipgloss.Width(workdir) 152 | 153 | // Always wrap the command to ensure consistent formatting 154 | wrappedCommand := wrap.String(cmd.Command, commandWidth) 155 | 156 | // Split wrapped command into lines 157 | commandLines := strings.Split(wrappedCommand, "\n") 158 | 159 | // Render first line with timestamp and workdir 160 | firstLine := m.commandStyle().Width(commandWidth).Render(commandLines[0]) 161 | content.WriteString(fmt.Sprintf("%s%s%s", timestamp, workdir, firstLine)) 162 | content.WriteString("\n") 163 | 164 | // Render remaining lines with proper indentation 165 | if len(commandLines) > 1 { 166 | indent := strings.Repeat(" ", lipgloss.Width(timestamp)+lipgloss.Width(workdir)) 167 | for _, line := range commandLines[1:] { 168 | content.WriteString(indent) 169 | content.WriteString(m.commandStyle().Width(commandWidth).Render(line)) 170 | content.WriteString("\n") 171 | } 172 | } 173 | content.WriteString("\n") 174 | } 175 | 176 | // Wrap all content in a background-styled container 177 | contentStyle := lipgloss.NewStyle(). 178 | Background(m.theme.BaseColors.Base01). 179 | Width(m.maxWidth). 180 | Height(m.maxHeight) 181 | 182 | output.WriteString(contentStyle.Render(content.String())) 183 | m.viewport.SetContent(output.String()) 184 | } 185 | -------------------------------------------------------------------------------- /internal/tui/components/commandspane/commandspane_test.go: -------------------------------------------------------------------------------- 1 | package commandspane 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | "testing" 7 | "time" 8 | 9 | tea "github.com/charmbracelet/bubbletea" 10 | "github.com/charmbracelet/lipgloss" 11 | "github.com/datolabs-io/opsy/internal/thememanager" 12 | "github.com/datolabs-io/opsy/internal/tool" 13 | "github.com/stretchr/testify/assert" 14 | ) 15 | 16 | // stripANSI removes ANSI color codes from a string. 17 | func stripANSI(str string) string { 18 | re := regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`) 19 | return re.ReplaceAllString(str, "") 20 | } 21 | 22 | // TestNew tests the creation of a new commands pane component. 23 | func TestNew(t *testing.T) { 24 | theme := thememanager.Theme{ 25 | BaseColors: thememanager.BaseColors{ 26 | Base01: "#000000", 27 | Base02: "#111111", 28 | Base03: "#222222", 29 | Base04: "#333333", 30 | }, 31 | AccentColors: thememanager.AccentColors{ 32 | Accent0: "#FF0000", 33 | }, 34 | } 35 | 36 | m := New( 37 | WithTheme(theme), 38 | ) 39 | 40 | assert.NotNil(t, m) 41 | assert.Equal(t, theme, m.theme) 42 | assert.NotNil(t, m.viewport) 43 | assert.Empty(t, m.commands) 44 | } 45 | 46 | // TestUpdate tests the update function of the commands pane component. 47 | func TestUpdate(t *testing.T) { 48 | theme := thememanager.Theme{ 49 | BaseColors: thememanager.BaseColors{ 50 | Base01: "#000000", 51 | Base02: "#111111", 52 | Base03: "#222222", 53 | Base04: "#333333", 54 | }, 55 | } 56 | m := New(WithTheme(theme)) 57 | 58 | // Test window size message 59 | newModel, cmd := m.Update(tea.WindowSizeMsg{Width: 100, Height: 50}) 60 | assert.NotNil(t, newModel) 61 | assert.Nil(t, cmd) 62 | assert.Equal(t, 94, newModel.maxWidth) // Width - 6 for padding 63 | assert.Equal(t, 50, newModel.maxHeight) 64 | assert.Equal(t, 94, newModel.viewport.Width) 65 | assert.Equal(t, 50, newModel.viewport.Height) 66 | 67 | // Test command message 68 | now := time.Now() 69 | testCmd := tool.Command{ 70 | Command: "ls -la", 71 | WorkingDirectory: "~/opsy", 72 | StartedAt: now, 73 | } 74 | m, cmd = m.Update(testCmd) 75 | assert.Nil(t, cmd) 76 | assert.Len(t, m.commands, 1) 77 | assert.Equal(t, testCmd, m.commands[0]) 78 | } 79 | 80 | // TestView tests the view function of the commands pane component. 81 | func TestView(t *testing.T) { 82 | theme := thememanager.Theme{ 83 | BaseColors: thememanager.BaseColors{ 84 | Base01: "#000000", 85 | Base02: "#111111", 86 | Base03: "#222222", 87 | Base04: "#333333", 88 | }, 89 | AccentColors: thememanager.AccentColors{ 90 | Accent0: "#FF0000", 91 | }, 92 | } 93 | 94 | m := New( 95 | WithTheme(theme), 96 | ) 97 | 98 | // Set dimensions to test rendering 99 | m, _ = m.Update(tea.WindowSizeMsg{Width: 100, Height: 50}) 100 | 101 | // Test initial view (empty commands) 102 | view := stripANSI(m.View()) 103 | assert.NotEmpty(t, view) 104 | assert.Contains(t, view, "Commands") 105 | 106 | // Add test command 107 | now := time.Now() 108 | m.Update(tool.Command{ 109 | Command: "ls -la", 110 | WorkingDirectory: "~/opsy", 111 | StartedAt: now, 112 | }) 113 | 114 | // Test view with command 115 | view = stripANSI(m.View()) 116 | assert.Contains(t, view, "Commands") 117 | assert.Contains(t, view, "~/opsy") 118 | assert.Contains(t, view, "ls -la") 119 | assert.Contains(t, view, now.Format("15:04:05")) 120 | } 121 | 122 | // TestInit tests the initialization of the commands pane component. 123 | func TestInit(t *testing.T) { 124 | theme := thememanager.Theme{ 125 | BaseColors: thememanager.BaseColors{ 126 | Base01: "#000000", 127 | }, 128 | } 129 | m := New(WithTheme(theme)) 130 | cmd := m.Init() 131 | assert.Nil(t, cmd) 132 | } 133 | 134 | // TestCommandWrapping tests the wrapping behavior of long commands. 135 | func TestCommandWrapping(t *testing.T) { 136 | m := New() 137 | m, _ = m.Update(tea.WindowSizeMsg{Width: 40, Height: 40}) 138 | 139 | // Create a command that will definitely wrap 140 | longCommand := "ls -la /very/long/path/that/will/definitely/wrap/across/multiple/lines/in/the/terminal/output/when/rendered" 141 | cmd := tool.Command{ 142 | Command: longCommand, 143 | WorkingDirectory: "~/opsy", 144 | StartedAt: time.Now(), 145 | } 146 | m, _ = m.Update(cmd) 147 | 148 | // Get the view 149 | view := m.View() 150 | 151 | // Count the number of lines in the view 152 | lines := strings.Split(view, "\n") 153 | nonEmptyLines := 0 154 | for _, line := range lines { 155 | if strings.TrimSpace(line) != "" { 156 | nonEmptyLines++ 157 | } 158 | } 159 | 160 | // With a width of 40, the command should wrap to at least 3 lines: 161 | // 1. Line with timestamp and start of command 162 | // 2. At least one wrapped line 163 | // 3. Line with working directory 164 | assert.GreaterOrEqual(t, nonEmptyLines, 3, "command should wrap to at least 3 lines") 165 | 166 | // Strip ANSI codes for easier testing 167 | plainView := stripANSI(view) 168 | 169 | // Verify the command is present and wrapped 170 | assert.Contains(t, plainView, "ls -la /very/l") 171 | assert.Contains(t, plainView, "ong/path/that/") 172 | assert.Contains(t, plainView, "will/definitel") 173 | assert.Contains(t, plainView, "y/wrap/across/") 174 | assert.Contains(t, plainView, "multiple/lines") 175 | assert.Contains(t, plainView, "/in/the/termin") 176 | assert.Contains(t, plainView, "al/output/when") 177 | assert.Contains(t, plainView, "/rendered") 178 | 179 | // Verify working directory is present 180 | assert.Contains(t, plainView, cmd.WorkingDirectory) 181 | } 182 | 183 | // TestMultipleCommands tests rendering of multiple commands. 184 | func TestMultipleCommands(t *testing.T) { 185 | theme := thememanager.Theme{ 186 | BaseColors: thememanager.BaseColors{ 187 | Base01: "#000000", 188 | Base02: "#111111", 189 | Base03: "#222222", 190 | Base04: "#333333", 191 | }, 192 | AccentColors: thememanager.AccentColors{ 193 | Accent0: "#FF0000", 194 | }, 195 | } 196 | 197 | m := New(WithTheme(theme)) 198 | m, _ = m.Update(tea.WindowSizeMsg{Width: 100, Height: 50}) 199 | 200 | // Add multiple commands 201 | commands := []tool.Command{ 202 | { 203 | Command: "git status", 204 | WorkingDirectory: "~/project1", 205 | StartedAt: time.Now(), 206 | }, 207 | { 208 | Command: "make build", 209 | WorkingDirectory: "~/project2", 210 | StartedAt: time.Now().Add(time.Second), 211 | }, 212 | { 213 | Command: "docker ps", 214 | WorkingDirectory: "~/project3", 215 | StartedAt: time.Now().Add(2 * time.Second), 216 | }, 217 | } 218 | 219 | for _, cmd := range commands { 220 | m, _ = m.Update(cmd) 221 | } 222 | 223 | view := stripANSI(m.View()) 224 | 225 | // Verify all commands are rendered 226 | for _, cmd := range commands { 227 | assert.Contains(t, view, cmd.Command) 228 | assert.Contains(t, view, cmd.WorkingDirectory) 229 | assert.Contains(t, view, cmd.StartedAt.Format("15:04:05")) 230 | } 231 | 232 | // Verify order (last command should be at the bottom) 233 | lastCmdIndex := strings.LastIndex(view, commands[len(commands)-1].Command) 234 | for i := 0; i < len(commands)-1; i++ { 235 | cmdIndex := strings.LastIndex(view, commands[i].Command) 236 | assert.Less(t, cmdIndex, lastCmdIndex, "commands should be in chronological order") 237 | } 238 | } 239 | 240 | // TestThemeChange tests the component's response to theme changes. 241 | func TestThemeChange(t *testing.T) { 242 | initialTheme := thememanager.Theme{ 243 | BaseColors: thememanager.BaseColors{ 244 | Base00: "#000000", 245 | Base01: "#111111", 246 | Base02: "#222222", 247 | Base03: "#333333", 248 | Base04: "#444444", 249 | }, 250 | AccentColors: thememanager.AccentColors{ 251 | Accent0: "#FF0000", 252 | Accent1: "#00FF00", 253 | Accent2: "#0000FF", 254 | }, 255 | } 256 | 257 | newTheme := thememanager.Theme{ 258 | BaseColors: thememanager.BaseColors{ 259 | Base00: "#FFFFFF", 260 | Base01: "#EEEEEE", 261 | Base02: "#DDDDDD", 262 | Base03: "#CCCCCC", 263 | Base04: "#BBBBBB", 264 | }, 265 | AccentColors: thememanager.AccentColors{ 266 | Accent0: "#00FF00", 267 | Accent1: "#FF0000", 268 | Accent2: "#0000FF", 269 | }, 270 | } 271 | 272 | // Create models with different themes 273 | m1 := New(WithTheme(initialTheme)) 274 | m2 := New(WithTheme(newTheme)) 275 | 276 | // Verify that styles are different 277 | assert.NotEqual(t, 278 | m1.commandStyle().GetForeground(), 279 | m2.commandStyle().GetForeground(), 280 | "command styles should have different colors", 281 | ) 282 | 283 | assert.NotEqual(t, 284 | m1.containerStyle().GetBackground(), 285 | m2.containerStyle().GetBackground(), 286 | "container styles should have different backgrounds", 287 | ) 288 | 289 | assert.NotEqual(t, 290 | m1.workdirStyle().GetBackground(), 291 | m2.workdirStyle().GetBackground(), 292 | "workdir styles should have different backgrounds", 293 | ) 294 | 295 | // Verify that the styles use the correct theme colors 296 | assert.Equal(t, 297 | lipgloss.Color(initialTheme.AccentColors.Accent0), 298 | m1.commandStyle().GetForeground(), 299 | "command style should use Accent0 color", 300 | ) 301 | 302 | assert.Equal(t, 303 | lipgloss.Color(initialTheme.BaseColors.Base01), 304 | m1.containerStyle().GetBackground(), 305 | "container style should use Base01 color", 306 | ) 307 | 308 | assert.Equal(t, 309 | lipgloss.Color(newTheme.AccentColors.Accent0), 310 | m2.commandStyle().GetForeground(), 311 | "command style should use Accent0 color", 312 | ) 313 | 314 | assert.Equal(t, 315 | lipgloss.Color(newTheme.BaseColors.Base01), 316 | m2.containerStyle().GetBackground(), 317 | "container style should use Base01 color", 318 | ) 319 | } 320 | -------------------------------------------------------------------------------- /internal/tui/components/commandspane/doc.go: -------------------------------------------------------------------------------- 1 | // Package commandspane provides a commands pane component for the terminal user interface. 2 | // 3 | // The commands pane component displays a scrollable list of executed commands, including: 4 | // - Timestamp of execution in [HH:MM:SS] format 5 | // - Working directory with a distinct background 6 | // - Command text in an accent color 7 | // 8 | // # Component Structure 9 | // 10 | // The Model type represents the commands pane component and provides the following methods: 11 | // - Init: Initializes the component (required by bubbletea.Model) 12 | // - Update: Handles messages and updates the component state 13 | // - View: Renders the component's current state 14 | // 15 | // The component supports configuration through options: 16 | // - WithTheme: Sets the theme for styling the component 17 | // 18 | // # Styling 19 | // 20 | // Each command is styled using dedicated styling methods: 21 | // - timestampStyle: formats the timestamp with a neutral color 22 | // - workdirStyle: highlights the working directory with a distinct background 23 | // - commandStyle: renders the command text in an accent color 24 | // - containerStyle: provides the overall pane styling with borders 25 | // - titleStyle: formats the "Commands" title 26 | // 27 | // Theme Integration: 28 | // - Base colors are used for backgrounds and borders 29 | // - Accent colors are used for command text highlighting 30 | // - All colors are configurable through the theme 31 | // 32 | // # Component Features 33 | // 34 | // The component automatically handles: 35 | // - Dynamic resizing of the viewport 36 | // - Command history accumulation 37 | // - Automatic scrolling to the latest command 38 | // - Proper text wrapping based on available width 39 | // - Long command wrapping with proper indentation 40 | // - Viewport scrolling for command history 41 | // 42 | // # Message Handling 43 | // 44 | // The component responds to: 45 | // - tea.WindowSizeMsg: Updates viewport dimensions 46 | // - tool.Command: Adds new command to history 47 | // 48 | // The component is built using the Bubble Tea framework and Lip Gloss styling 49 | // library, providing a consistent look and feel with the rest of the application. 50 | // 51 | // Example usage: 52 | // 53 | // commandspane := commandspane.New( 54 | // commandspane.WithTheme(theme), 55 | // ) 56 | // 57 | // // Initialize the component 58 | // cmd := commandspane.Init() 59 | // 60 | // // Handle window resize 61 | // model, cmd := commandspane.Update(tea.WindowSizeMsg{Width: 100, Height: 50}) 62 | // 63 | // // Add a new command 64 | // model, cmd = commandspane.Update(tool.Command{ 65 | // Command: "ls -la", 66 | // WorkingDirectory: "~/project", 67 | // StartedAt: time.Now(), 68 | // }) 69 | // 70 | // // Render the component 71 | // view := commandspane.View() 72 | package commandspane 73 | -------------------------------------------------------------------------------- /internal/tui/components/footer/doc.go: -------------------------------------------------------------------------------- 1 | // Package footer provides a footer component for the terminal user interface. 2 | // 3 | // The footer component displays important information about the application's state, 4 | // including: 5 | // - The AI engine being used (e.g., "Anthropic") 6 | // - Model configuration (model name, max tokens, temperature) 7 | // - Number of available tools 8 | // - Current status 9 | // 10 | // # Component Structure 11 | // 12 | // The Model type represents the footer component and provides the following methods: 13 | // - Init: Initializes the component (required by bubbletea.Model) 14 | // - Update: Handles messages and updates the component state 15 | // - View: Renders the component's current state 16 | // 17 | // The component supports configuration through options: 18 | // - WithTheme: Sets the theme for styling the component 19 | // - WithParameters: Sets the application parameters to display 20 | // 21 | // # Message Handling 22 | // 23 | // The component responds to: 24 | // - tea.WindowSizeMsg: Updates viewport dimensions 25 | // - agent.Status: Updates the current status display 26 | // 27 | // # Styling 28 | // 29 | // Each element is styled using dedicated styling methods: 30 | // - containerStyle: provides the overall footer styling with background 31 | // - textStyle: formats the text content with appropriate colors 32 | // 33 | // Theme Integration: 34 | // - Base colors are used for backgrounds and text 35 | // - All colors are configurable through the theme 36 | // 37 | // # Component Features 38 | // 39 | // The component automatically handles: 40 | // - Dynamic resizing based on window width 41 | // - Status updates through message passing 42 | // - Right-aligned status display 43 | // - Bold labels with regular value text 44 | // - Proper spacing and padding 45 | // 46 | // # Thread Safety 47 | // 48 | // The footer component is safe for concurrent access: 49 | // - All updates are handled through message passing 50 | // - No internal mutable state is exposed 51 | // - Theme and parameters are immutable after creation 52 | // 53 | // Example usage: 54 | // 55 | // footer := footer.New( 56 | // footer.WithTheme(theme), 57 | // footer.WithParameters(footer.Parameters{ 58 | // Engine: "Anthropic", 59 | // Model: "claude-3-sonnet", 60 | // MaxTokens: 1000, 61 | // Temperature: 0.7, 62 | // ToolsCount: 5, 63 | // }), 64 | // ) 65 | // 66 | // // Initialize the component 67 | // cmd := footer.Init() 68 | // 69 | // // Handle window resize 70 | // model, cmd := footer.Update(tea.WindowSizeMsg{Width: 100}) 71 | // 72 | // // Update status 73 | // model, cmd = footer.Update(agent.Status("Running")) 74 | // 75 | // // Render the component 76 | // view := footer.View() 77 | package footer 78 | -------------------------------------------------------------------------------- /internal/tui/components/footer/footer.go: -------------------------------------------------------------------------------- 1 | package footer 2 | 3 | import ( 4 | "strconv" 5 | 6 | tea "github.com/charmbracelet/bubbletea" 7 | "github.com/charmbracelet/lipgloss" 8 | "github.com/datolabs-io/opsy/internal/agent" 9 | "github.com/datolabs-io/opsy/internal/thememanager" 10 | ) 11 | 12 | // Model represents the footer component. 13 | type Model struct { 14 | theme thememanager.Theme 15 | parameters Parameters 16 | containerStyle lipgloss.Style 17 | textStyle lipgloss.Style 18 | maxWidth int 19 | status string 20 | } 21 | 22 | // Parameters represent the parameters of the application. 23 | type Parameters struct { 24 | Engine string 25 | Model string 26 | MaxTokens int64 27 | Temperature float64 28 | ToolsCount int 29 | } 30 | 31 | // Option is a function that modifies the Model. 32 | type Option func(*Model) 33 | 34 | // New creates a new footer component. 35 | func New(opts ...Option) *Model { 36 | m := &Model{ 37 | status: agent.StatusReady, 38 | parameters: Parameters{}, 39 | } 40 | 41 | for _, opt := range opts { 42 | opt(m) 43 | } 44 | 45 | m.containerStyle = containerStyle(m.theme, m.maxWidth) 46 | m.textStyle = textStyle(m.theme) 47 | 48 | return m 49 | } 50 | 51 | // Init initializes the footer component. 52 | func (m *Model) Init() tea.Cmd { 53 | return nil 54 | } 55 | 56 | // Update handles messages and updates the footer component. 57 | func (m *Model) Update(msg tea.Msg) (*Model, tea.Cmd) { 58 | switch msg := msg.(type) { 59 | case tea.WindowSizeMsg: 60 | m.maxWidth = msg.Width 61 | m.containerStyle = containerStyle(m.theme, m.maxWidth) 62 | case agent.Status: 63 | m.status = string(msg) 64 | } 65 | 66 | return m, nil 67 | } 68 | 69 | // View renders the footer component. 70 | func (m *Model) View() string { 71 | footer := m.textStyle.Bold(true).Render("Engine: ") + m.textStyle.Render(m.parameters.Engine) 72 | footer += m.textStyle.Render(" | ") + m.textStyle.Bold(true).Render("Model: ") + m.textStyle.Render(m.parameters.Model) 73 | footer += m.textStyle.Render(" | ") + m.textStyle.Bold(true).Render("Temperature: ") + m.textStyle.Render(strconv.FormatFloat(m.parameters.Temperature, 'f', -1, 64)) 74 | footer += m.textStyle.Render(" | ") + m.textStyle.Bold(true).Render("Max Tokens: ") + m.textStyle.Render(strconv.FormatInt(m.parameters.MaxTokens, 10)) 75 | footer += m.textStyle.Render(" | ") + m.textStyle.Bold(true).Render("Tools: ") + m.textStyle.Render(strconv.Itoa(m.parameters.ToolsCount)) 76 | 77 | footerStatus := m.textStyle.Bold(true).Render("Status: ") + m.textStyle.Render(m.status) 78 | footer += m.textStyle.Width(m.maxWidth - lipgloss.Width(footer) - 4).Align(lipgloss.Right).Render(footerStatus) 79 | 80 | return m.containerStyle.Render(footer) 81 | } 82 | 83 | // WithTheme sets the theme for the footer component. 84 | func WithTheme(theme thememanager.Theme) Option { 85 | return func(m *Model) { 86 | m.theme = theme 87 | m.containerStyle = containerStyle(theme, m.maxWidth) 88 | m.textStyle = textStyle(theme) 89 | } 90 | } 91 | 92 | // WithParameters sets the parameters for the footer component. 93 | func WithParameters(parameters Parameters) Option { 94 | return func(m *Model) { 95 | m.parameters = parameters 96 | } 97 | } 98 | 99 | // containerStyle creates a style for the container of the footer component. 100 | func containerStyle(theme thememanager.Theme, maxWidth int) lipgloss.Style { 101 | return lipgloss.NewStyle(). 102 | Background(theme.BaseColors.Base01). 103 | Width(maxWidth). 104 | Padding(1, 2, 1, 2) 105 | } 106 | 107 | // textStyle creates a style for the text of the footer component. 108 | func textStyle(theme thememanager.Theme) lipgloss.Style { 109 | return lipgloss.NewStyle(). 110 | Foreground(theme.BaseColors.Base04). 111 | Background(theme.BaseColors.Base01) 112 | } 113 | -------------------------------------------------------------------------------- /internal/tui/components/footer/footer_test.go: -------------------------------------------------------------------------------- 1 | package footer 2 | 3 | import ( 4 | "regexp" 5 | "sync" 6 | "testing" 7 | 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/charmbracelet/lipgloss" 10 | "github.com/datolabs-io/opsy/internal/agent" 11 | "github.com/datolabs-io/opsy/internal/thememanager" 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | // stripANSI removes ANSI color codes from a string. 16 | func stripANSI(str string) string { 17 | re := regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`) 18 | return re.ReplaceAllString(str, "") 19 | } 20 | 21 | // TestNew tests the creation of a new footer component. 22 | func TestNew(t *testing.T) { 23 | t.Run("creates with valid parameters", func(t *testing.T) { 24 | theme := thememanager.Theme{ 25 | BaseColors: thememanager.BaseColors{ 26 | Base01: "#000000", 27 | Base04: "#FFFFFF", 28 | }, 29 | } 30 | params := Parameters{ 31 | Engine: "TestEngine", 32 | Model: "TestModel", 33 | MaxTokens: 1000, 34 | Temperature: 0.7, 35 | ToolsCount: 5, 36 | } 37 | 38 | m := New( 39 | WithTheme(theme), 40 | WithParameters(params), 41 | ) 42 | 43 | assert.NotNil(t, m) 44 | assert.Equal(t, params, m.parameters) 45 | assert.Equal(t, theme, m.theme) 46 | assert.Equal(t, agent.StatusReady, m.status) 47 | }) 48 | 49 | t.Run("creates with nil theme", func(t *testing.T) { 50 | m := New() 51 | assert.NotNil(t, m) 52 | assert.Equal(t, thememanager.Theme{}, m.theme) 53 | }) 54 | 55 | t.Run("creates with empty parameters", func(t *testing.T) { 56 | m := New(WithParameters(Parameters{})) 57 | assert.NotNil(t, m) 58 | assert.Equal(t, Parameters{}, m.parameters) 59 | }) 60 | } 61 | 62 | // TestUpdate tests the update function of the footer component. 63 | func TestUpdate(t *testing.T) { 64 | t.Run("handles window size message", func(t *testing.T) { 65 | theme := thememanager.Theme{ 66 | BaseColors: thememanager.BaseColors{ 67 | Base01: "#000000", 68 | Base04: "#FFFFFF", 69 | }, 70 | } 71 | m := New(WithTheme(theme)) 72 | 73 | newModel, cmd := m.Update(tea.WindowSizeMsg{Width: 100}) 74 | assert.NotNil(t, newModel) 75 | assert.Nil(t, cmd) 76 | assert.Equal(t, 100, newModel.maxWidth) 77 | }) 78 | 79 | t.Run("handles status update", func(t *testing.T) { 80 | m := New() 81 | newModel, cmd := m.Update(agent.Status("Running")) 82 | assert.NotNil(t, newModel) 83 | assert.Nil(t, cmd) 84 | assert.Equal(t, "Running", newModel.status) 85 | }) 86 | } 87 | 88 | // TestView tests the view function of the footer component. 89 | func TestView(t *testing.T) { 90 | t.Run("renders with all parameters", func(t *testing.T) { 91 | theme := thememanager.Theme{ 92 | BaseColors: thememanager.BaseColors{ 93 | Base01: "#000000", 94 | Base04: "#FFFFFF", 95 | }, 96 | } 97 | params := Parameters{ 98 | Engine: "TestEngine", 99 | Model: "TestModel", 100 | MaxTokens: 1000, 101 | Temperature: 0.7, 102 | ToolsCount: 5, 103 | } 104 | 105 | m := New( 106 | WithTheme(theme), 107 | WithParameters(params), 108 | ) 109 | m.maxWidth = 100 110 | 111 | view := stripANSI(m.View()) 112 | assert.Contains(t, view, "TestEngine") 113 | assert.Contains(t, view, "TestModel") 114 | assert.Contains(t, view, "1000") 115 | assert.Contains(t, view, "0.7") 116 | assert.Contains(t, view, "5") 117 | assert.Contains(t, view, "Ready") 118 | }) 119 | 120 | t.Run("handles small window width", func(t *testing.T) { 121 | m := New(WithParameters(Parameters{ 122 | Engine: "TestEngine", 123 | Model: "TestModel", 124 | })) 125 | m.maxWidth = 40 126 | 127 | view := stripANSI(m.View()) 128 | assert.NotEmpty(t, view) 129 | assert.Contains(t, view, "TestEngine") 130 | }) 131 | 132 | t.Run("handles empty parameters", func(t *testing.T) { 133 | m := New() 134 | m.maxWidth = 100 135 | 136 | view := stripANSI(m.View()) 137 | assert.NotEmpty(t, view) 138 | assert.Contains(t, view, "Ready") 139 | }) 140 | } 141 | 142 | // TestInit tests the initialization of the footer component. 143 | func TestInit(t *testing.T) { 144 | m := New() 145 | cmd := m.Init() 146 | assert.Nil(t, cmd) 147 | } 148 | 149 | // TestThemeChange tests the component's response to theme changes. 150 | func TestThemeChange(t *testing.T) { 151 | initialTheme := thememanager.Theme{ 152 | BaseColors: thememanager.BaseColors{ 153 | Base01: "#000000", 154 | Base04: "#FFFFFF", 155 | }, 156 | } 157 | 158 | newTheme := thememanager.Theme{ 159 | BaseColors: thememanager.BaseColors{ 160 | Base01: "#111111", 161 | Base04: "#EEEEEE", 162 | }, 163 | } 164 | 165 | params := Parameters{ 166 | Engine: "TestEngine", 167 | Model: "TestModel", 168 | MaxTokens: 1000, 169 | Temperature: 0.7, 170 | ToolsCount: 5, 171 | } 172 | 173 | // Create and setup first model 174 | m1 := New(WithTheme(initialTheme), WithParameters(params)) 175 | m1, _ = m1.Update(tea.WindowSizeMsg{Width: 100}) 176 | 177 | // Create and setup second model 178 | m2 := New(WithTheme(newTheme), WithParameters(params)) 179 | m2, _ = m2.Update(tea.WindowSizeMsg{Width: 100}) 180 | 181 | // Verify container styles are different 182 | assert.NotEqual(t, 183 | m1.containerStyle.GetBackground(), 184 | m2.containerStyle.GetBackground(), 185 | "container styles should have different backgrounds", 186 | ) 187 | 188 | // Verify text styles are different 189 | assert.NotEqual(t, 190 | m1.textStyle.GetForeground(), 191 | m2.textStyle.GetForeground(), 192 | "text styles should have different colors", 193 | ) 194 | 195 | // Verify styles match their themes 196 | assert.Equal(t, 197 | lipgloss.Color(initialTheme.BaseColors.Base01), 198 | m1.containerStyle.GetBackground(), 199 | "container style should use Base01 color", 200 | ) 201 | 202 | assert.Equal(t, 203 | lipgloss.Color(initialTheme.BaseColors.Base04), 204 | m1.textStyle.GetForeground(), 205 | "text style should use Base04 color", 206 | ) 207 | 208 | assert.Equal(t, 209 | lipgloss.Color(newTheme.BaseColors.Base01), 210 | m2.containerStyle.GetBackground(), 211 | "container style should use Base01 color", 212 | ) 213 | 214 | assert.Equal(t, 215 | lipgloss.Color(newTheme.BaseColors.Base04), 216 | m2.textStyle.GetForeground(), 217 | "text style should use Base04 color", 218 | ) 219 | 220 | // Verify content is identical 221 | stripped1 := stripANSI(m1.View()) 222 | stripped2 := stripANSI(m2.View()) 223 | assert.Equal(t, stripped1, stripped2, "content should be same after stripping ANSI codes") 224 | } 225 | 226 | // TestConcurrentAccess tests thread safety of the footer component. 227 | func TestConcurrentAccess(t *testing.T) { 228 | m := New() 229 | var wg sync.WaitGroup 230 | numGoroutines := 10 231 | 232 | // Test concurrent updates 233 | wg.Add(numGoroutines) 234 | for i := 0; i < numGoroutines; i++ { 235 | go func() { 236 | defer wg.Done() 237 | _, _ = m.Update(tea.WindowSizeMsg{Width: 100}) 238 | _, _ = m.Update(agent.Status("Running")) 239 | _ = m.View() 240 | }() 241 | } 242 | wg.Wait() 243 | 244 | // Verify component is still in a valid state 245 | view := stripANSI(m.View()) 246 | assert.NotEmpty(t, view) 247 | assert.Contains(t, view, "Running") 248 | } 249 | -------------------------------------------------------------------------------- /internal/tui/components/header/doc.go: -------------------------------------------------------------------------------- 1 | // Package header provides a header component for the terminal user interface. 2 | // The header displays the current task and can be styled using themes. 3 | // 4 | // # Component Structure 5 | // 6 | // The Model type represents the header component and provides the following methods: 7 | // - Init: Initializes the component (required by bubbletea.Model) 8 | // - Update: Handles messages and updates the component state 9 | // - View: Renders the component's current state 10 | // 11 | // The component supports configuration through options: 12 | // - WithTask: Sets the task text to display 13 | // - WithTheme: Sets the theme for styling the component 14 | // 15 | // # Message Handling 16 | // 17 | // The component responds to: 18 | // - tea.WindowSizeMsg: Updates viewport dimensions and text wrapping 19 | // 20 | // # Styling 21 | // 22 | // Each element is styled using dedicated styling methods: 23 | // - containerStyle: provides the overall header styling with background 24 | // - textStyle: formats the text content with appropriate colors 25 | // 26 | // Theme Integration: 27 | // - Base colors are used for backgrounds and text 28 | // - All colors are configurable through the theme 29 | // 30 | // # Component Features 31 | // 32 | // The component automatically handles: 33 | // - Dynamic resizing based on window width 34 | // - Text wrapping for long task descriptions 35 | // - Bold label with regular task text 36 | // - Proper spacing and padding 37 | // - Theme-based styling 38 | // 39 | // # Thread Safety 40 | // 41 | // The header component is safe for concurrent access: 42 | // - All updates are handled through message passing 43 | // - No internal mutable state is exposed 44 | // - Theme and task text are immutable after creation 45 | // 46 | // Example usage: 47 | // 48 | // header := header.New( 49 | // header.WithTask("Current Task"), 50 | // header.WithTheme(myTheme), 51 | // ) 52 | // 53 | // // Initialize the component 54 | // cmd := header.Init() 55 | // 56 | // // Handle window resize 57 | // model, cmd := header.Update(tea.WindowSizeMsg{Width: 100}) 58 | // 59 | // // Render the component 60 | // view := header.View() 61 | package header 62 | -------------------------------------------------------------------------------- /internal/tui/components/header/header.go: -------------------------------------------------------------------------------- 1 | package header 2 | 3 | import ( 4 | tea "github.com/charmbracelet/bubbletea" 5 | "github.com/charmbracelet/lipgloss" 6 | "github.com/datolabs-io/opsy/internal/thememanager" 7 | "github.com/muesli/reflow/wrap" 8 | ) 9 | 10 | // Model represents a header component that displays the current task. 11 | type Model struct { 12 | task string 13 | theme thememanager.Theme 14 | containerStyle lipgloss.Style 15 | textStyle lipgloss.Style 16 | maxWidth int 17 | } 18 | 19 | // Option is a function that modifies the Model. 20 | type Option func(*Model) 21 | 22 | // New creates a new header Model with the given options. 23 | // If no options are provided, it creates a header with default values. 24 | func New(opts ...Option) *Model { 25 | m := &Model{ 26 | task: "", 27 | } 28 | 29 | for _, opt := range opts { 30 | opt(m) 31 | } 32 | 33 | m.containerStyle = containerStyle(m.theme, m.maxWidth) 34 | m.textStyle = textStyle(m.theme) 35 | 36 | return m 37 | } 38 | 39 | // Init initializes the header Model. 40 | // It implements the tea.Model interface. 41 | func (m *Model) Init() tea.Cmd { 42 | return nil 43 | } 44 | 45 | // Update handles messages and updates the header Model accordingly. 46 | // It implements the tea.Model interface. 47 | func (m *Model) Update(msg tea.Msg) (*Model, tea.Cmd) { 48 | switch msg := msg.(type) { 49 | case tea.WindowSizeMsg: 50 | m.maxWidth = msg.Width 51 | m.containerStyle = containerStyle(m.theme, m.maxWidth) 52 | } 53 | 54 | return m, nil 55 | } 56 | 57 | // View renders the header component. 58 | func (m *Model) View() string { 59 | task := m.textStyle.Render(wrap.String(m.task, m.maxWidth-10)) 60 | return m.containerStyle.Render(m.textStyle.Bold(true).Render("Task: ") + task) 61 | } 62 | 63 | // WithTask returns an Option that sets the task text in the header. 64 | func WithTask(task string) Option { 65 | return func(m *Model) { 66 | m.task = task 67 | } 68 | } 69 | 70 | // WithTheme returns an Option that sets the theme for the header. 71 | func WithTheme(theme thememanager.Theme) Option { 72 | return func(m *Model) { 73 | m.theme = theme 74 | } 75 | } 76 | 77 | // containerStyle creates a style for the container of the header component. 78 | func containerStyle(theme thememanager.Theme, maxWidth int) lipgloss.Style { 79 | return lipgloss.NewStyle(). 80 | Background(theme.BaseColors.Base01). 81 | Width(maxWidth). 82 | Padding(1, 2) 83 | } 84 | 85 | // textStyle creates a style for the text of the header component. 86 | func textStyle(theme thememanager.Theme) lipgloss.Style { 87 | return lipgloss.NewStyle(). 88 | Foreground(theme.BaseColors.Base04). 89 | Background(theme.BaseColors.Base01) 90 | } 91 | -------------------------------------------------------------------------------- /internal/tui/components/header/header_test.go: -------------------------------------------------------------------------------- 1 | package header 2 | 3 | import ( 4 | "regexp" 5 | "sync" 6 | "testing" 7 | 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/charmbracelet/lipgloss" 10 | "github.com/datolabs-io/opsy/internal/thememanager" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | // stripANSI removes ANSI color codes from a string. 15 | func stripANSI(str string) string { 16 | re := regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`) 17 | return re.ReplaceAllString(str, "") 18 | } 19 | 20 | // TestHeaderCreation tests the creation of a new header component. 21 | func TestHeaderCreation(t *testing.T) { 22 | t.Run("creates with default values", func(t *testing.T) { 23 | header := New() 24 | assert.NotNil(t, header) 25 | assert.Empty(t, header.task) 26 | assert.Equal(t, thememanager.Theme{}, header.theme) 27 | }) 28 | 29 | t.Run("creates with task", func(t *testing.T) { 30 | task := "Test Task" 31 | header := New(WithTask(task)) 32 | assert.Equal(t, task, header.task) 33 | }) 34 | 35 | t.Run("creates with theme", func(t *testing.T) { 36 | theme := thememanager.Theme{ 37 | BaseColors: thememanager.BaseColors{ 38 | Base01: "#000000", 39 | Base04: "#FFFFFF", 40 | }, 41 | } 42 | header := New(WithTheme(theme)) 43 | assert.Equal(t, theme, header.theme) 44 | }) 45 | } 46 | 47 | // TestHeaderUpdate tests the update function of the header component. 48 | func TestHeaderUpdate(t *testing.T) { 49 | t.Run("handles window size update", func(t *testing.T) { 50 | header := New() 51 | newWidth := 100 52 | updatedHeader, cmd := header.Update(tea.WindowSizeMsg{Width: newWidth}) 53 | assert.NotNil(t, updatedHeader) 54 | assert.Nil(t, cmd) 55 | assert.Equal(t, newWidth, updatedHeader.maxWidth) 56 | }) 57 | } 58 | 59 | // TestHeaderView tests the view function of the header component. 60 | func TestHeaderView(t *testing.T) { 61 | theme := thememanager.Theme{ 62 | BaseColors: thememanager.BaseColors{ 63 | Base01: "#000000", 64 | Base04: "#FFFFFF", 65 | }, 66 | } 67 | 68 | testCases := []struct { 69 | name string 70 | task string 71 | width int 72 | contains []string 73 | }{ 74 | { 75 | name: "empty task", 76 | task: "", 77 | width: 100, 78 | contains: []string{"Task:", ""}, 79 | }, 80 | { 81 | name: "with task", 82 | task: "Test Task", 83 | width: 100, 84 | contains: []string{"Task:", "Test Task"}, 85 | }, 86 | { 87 | name: "long task with wrapping", 88 | task: "This is a very long task that should be wrapped to multiple lines when the width is limited", 89 | width: 40, 90 | contains: []string{"Task:", "This is a very", "long task that"}, 91 | }, 92 | } 93 | 94 | for _, tc := range testCases { 95 | t.Run(tc.name, func(t *testing.T) { 96 | header := New( 97 | WithTask(tc.task), 98 | WithTheme(theme), 99 | ) 100 | header.maxWidth = tc.width 101 | 102 | view := stripANSI(header.View()) 103 | 104 | for _, expected := range tc.contains { 105 | assert.Contains(t, view, expected) 106 | } 107 | }) 108 | } 109 | } 110 | 111 | // TestHeaderOptions tests the option functions of the header component. 112 | func TestHeaderOptions(t *testing.T) { 113 | theme := thememanager.Theme{ 114 | BaseColors: thememanager.BaseColors{ 115 | Base01: "#000000", 116 | Base04: "#FFFFFF", 117 | }, 118 | } 119 | 120 | testCases := []struct { 121 | name string 122 | options []Option 123 | expectedTask string 124 | expectedTheme thememanager.Theme 125 | }{ 126 | { 127 | name: "with task option", 128 | options: []Option{WithTask("Test Task")}, 129 | expectedTask: "Test Task", 130 | expectedTheme: thememanager.Theme{}, 131 | }, 132 | { 133 | name: "with theme option", 134 | options: []Option{WithTheme(theme)}, 135 | expectedTask: "", 136 | expectedTheme: theme, 137 | }, 138 | { 139 | name: "with both options", 140 | options: []Option{ 141 | WithTask("Test Task"), 142 | WithTheme(theme), 143 | }, 144 | expectedTask: "Test Task", 145 | expectedTheme: theme, 146 | }, 147 | } 148 | 149 | for _, tc := range testCases { 150 | t.Run(tc.name, func(t *testing.T) { 151 | header := New(tc.options...) 152 | assert.Equal(t, tc.expectedTask, header.task) 153 | assert.Equal(t, tc.expectedTheme, header.theme) 154 | }) 155 | } 156 | } 157 | 158 | // TestThemeChange tests the component's response to theme changes. 159 | func TestThemeChange(t *testing.T) { 160 | initialTheme := thememanager.Theme{ 161 | BaseColors: thememanager.BaseColors{ 162 | Base01: "#000000", 163 | Base04: "#FFFFFF", 164 | }, 165 | } 166 | 167 | newTheme := thememanager.Theme{ 168 | BaseColors: thememanager.BaseColors{ 169 | Base01: "#111111", 170 | Base04: "#EEEEEE", 171 | }, 172 | } 173 | 174 | task := "Test Task" 175 | 176 | // Create and setup first model 177 | m1 := New(WithTheme(initialTheme), WithTask(task)) 178 | m1, _ = m1.Update(tea.WindowSizeMsg{Width: 100}) 179 | 180 | // Create and setup second model 181 | m2 := New(WithTheme(newTheme), WithTask(task)) 182 | m2, _ = m2.Update(tea.WindowSizeMsg{Width: 100}) 183 | 184 | // Verify container styles are different 185 | assert.NotEqual(t, 186 | m1.containerStyle.GetBackground(), 187 | m2.containerStyle.GetBackground(), 188 | "container styles should have different backgrounds", 189 | ) 190 | 191 | // Verify text styles are different 192 | assert.NotEqual(t, 193 | m1.textStyle.GetForeground(), 194 | m2.textStyle.GetForeground(), 195 | "text styles should have different colors", 196 | ) 197 | 198 | // Verify styles match their themes 199 | assert.Equal(t, 200 | lipgloss.Color(initialTheme.BaseColors.Base01), 201 | m1.containerStyle.GetBackground(), 202 | "container style should use Base01 color", 203 | ) 204 | 205 | assert.Equal(t, 206 | lipgloss.Color(initialTheme.BaseColors.Base04), 207 | m1.textStyle.GetForeground(), 208 | "text style should use Base04 color", 209 | ) 210 | 211 | // Verify content is identical 212 | stripped1 := stripANSI(m1.View()) 213 | stripped2 := stripANSI(m2.View()) 214 | assert.Equal(t, stripped1, stripped2, "content should be same after stripping ANSI codes") 215 | } 216 | 217 | // TestConcurrentAccess tests thread safety of the header component. 218 | func TestConcurrentAccess(t *testing.T) { 219 | m := New(WithTask("Test Task")) 220 | var wg sync.WaitGroup 221 | numGoroutines := 10 222 | 223 | // Test concurrent updates 224 | wg.Add(numGoroutines) 225 | for i := 0; i < numGoroutines; i++ { 226 | go func() { 227 | defer wg.Done() 228 | _, _ = m.Update(tea.WindowSizeMsg{Width: 100}) 229 | _ = m.View() 230 | }() 231 | } 232 | wg.Wait() 233 | 234 | // Verify component is still in a valid state 235 | view := stripANSI(m.View()) 236 | assert.NotEmpty(t, view) 237 | assert.Contains(t, view, "Test Task") 238 | } 239 | -------------------------------------------------------------------------------- /internal/tui/components/messagespane/doc.go: -------------------------------------------------------------------------------- 1 | // Package messagespane provides a messages pane component for the terminal user interface. 2 | // 3 | // The messages pane component displays a scrollable list of messages, including: 4 | // - Agent messages (e.g., responses from the AI) 5 | // - Tool messages (e.g., output from executed commands) 6 | // 7 | // # Component Structure 8 | // 9 | // The Model type represents the messages pane component and provides the following methods: 10 | // - Init: Initializes the component (required by bubbletea.Model) 11 | // - Update: Handles messages and updates the component state 12 | // - View: Renders the component's current state 13 | // - WithTheme: Option function to set the theme for styling 14 | // 15 | // # Message Handling 16 | // 17 | // The component responds to: 18 | // - tea.WindowSizeMsg: Updates viewport dimensions and text wrapping 19 | // - agent.Message: Adds a new message to the pane 20 | // 21 | // Each message includes: 22 | // - Timestamp in [HH:MM:SS] format 23 | // - Source indicator ("Opsy" for agent, "Opsy->Tool" for tool messages) 24 | // - Message content with proper wrapping and formatting 25 | // 26 | // # Styling 27 | // 28 | // Each element is styled using dedicated styling methods: 29 | // - timestampStyle: formats the timestamp with a neutral color 30 | // - authorStyle: highlights the source (agent/tool) with distinct colors 31 | // - messageStyle: formats the message content with proper padding and background 32 | // - containerStyle: provides the overall pane styling with borders 33 | // - titleStyle: formats the "Messages" title 34 | // 35 | // Theme Integration: 36 | // - Base colors are used for backgrounds and text 37 | // - Accent colors differentiate between agent and tool messages 38 | // - All colors are configurable through the theme 39 | // 40 | // # Component Features 41 | // 42 | // The component automatically handles: 43 | // - Dynamic resizing of the viewport 44 | // - Message history accumulation 45 | // - Automatic scrolling to the latest message 46 | // - Proper text wrapping based on available width 47 | // - Different styling for agent vs tool messages 48 | // - Message sanitization (removing XML tags and extra whitespace) 49 | // - Viewport scrolling with mouse and keyboard 50 | // 51 | // # Thread Safety 52 | // 53 | // The messages pane component is safe for concurrent access: 54 | // - All updates are handled through message passing 55 | // - No internal mutable state is exposed 56 | // - Message list is only modified through the Update method 57 | // - Theme is immutable after creation 58 | // 59 | // # Viewport Controls 60 | // 61 | // The viewport supports standard scrolling controls: 62 | // - Mouse wheel: Scroll up/down 63 | // - PageUp/PageDown: Move by page 64 | // - Home/End: Jump to top/bottom 65 | // - Arrow keys: Scroll line by line 66 | // 67 | // Example usage: 68 | // 69 | // // Create a new messages pane with theme 70 | // messagespane := messagespane.New( 71 | // messagespane.WithTheme(theme), 72 | // ) 73 | // 74 | // // Initialize the component 75 | // cmd := messagespane.Init() 76 | // 77 | // // Handle window resize 78 | // model, cmd := messagespane.Update(tea.WindowSizeMsg{Width: 100, Height: 50}) 79 | // 80 | // // Add a new message 81 | // model, cmd = messagespane.Update(agent.Message{ 82 | // Message: "Hello, world!", 83 | // Tool: "", 84 | // Timestamp: time.Now(), 85 | // }) 86 | // 87 | // // Add a tool message 88 | // model, cmd = messagespane.Update(agent.Message{ 89 | // Message: "Running git status", 90 | // Tool: "Git", 91 | // Timestamp: time.Now(), 92 | // }) 93 | // 94 | // // Render the component 95 | // view := messagespane.View() 96 | package messagespane 97 | -------------------------------------------------------------------------------- /internal/tui/components/messagespane/messagespane.go: -------------------------------------------------------------------------------- 1 | package messagespane 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | 8 | "github.com/charmbracelet/bubbles/viewport" 9 | tea "github.com/charmbracelet/bubbletea" 10 | "github.com/charmbracelet/lipgloss" 11 | "github.com/datolabs-io/opsy/internal/agent" 12 | "github.com/datolabs-io/opsy/internal/thememanager" 13 | ) 14 | 15 | // Model represents the messages pane component. 16 | type Model struct { 17 | theme thememanager.Theme 18 | maxWidth int 19 | maxHeight int 20 | viewport viewport.Model 21 | messages []agent.Message 22 | } 23 | 24 | // Option is a function that modifies the Model. 25 | type Option func(*Model) 26 | 27 | // New creates a new messages pane component. 28 | func New(opts ...Option) *Model { 29 | m := &Model{ 30 | viewport: viewport.New(0, 0), 31 | messages: []agent.Message{}, 32 | } 33 | 34 | for _, opt := range opts { 35 | opt(m) 36 | } 37 | 38 | return m 39 | } 40 | 41 | // title is the title of the messages pane. 42 | const title = "Messages" 43 | 44 | // Init initializes the messages pane component. 45 | func (m *Model) Init() tea.Cmd { 46 | return nil 47 | } 48 | 49 | // Update handles messages and updates the messages pane component. 50 | func (m *Model) Update(msg tea.Msg) (*Model, tea.Cmd) { 51 | var cmd tea.Cmd 52 | switch msg := msg.(type) { 53 | case tea.WindowSizeMsg: 54 | m.maxWidth = msg.Width - 6 55 | m.maxHeight = msg.Height 56 | m.viewport.Width = m.maxWidth 57 | m.viewport.Height = msg.Height 58 | m.viewport.Style = lipgloss.NewStyle().Background(m.theme.BaseColors.Base01) 59 | 60 | // Rerender all messages with new dimensions 61 | if len(m.messages) > 0 { 62 | m.renderMessages() 63 | } else { 64 | m.viewport.SetContent(m.titleStyle().Render(title)) 65 | } 66 | case agent.Message: 67 | m.messages = append(m.messages, msg) 68 | m.renderMessages() 69 | m.viewport.GotoBottom() 70 | } 71 | 72 | m.viewport, cmd = m.viewport.Update(msg) 73 | return m, cmd 74 | } 75 | 76 | // View renders the messages pane component. 77 | func (m *Model) View() string { 78 | return m.containerStyle().Render(m.viewport.View()) 79 | } 80 | 81 | // WithTheme sets the theme for the messages pane component. 82 | func WithTheme(theme thememanager.Theme) Option { 83 | return func(m *Model) { 84 | m.theme = theme 85 | } 86 | } 87 | 88 | // containerStyle creates a style for the container of the messages pane component. 89 | func (m *Model) containerStyle() lipgloss.Style { 90 | return lipgloss.NewStyle(). 91 | Background(m.theme.BaseColors.Base01). 92 | Padding(1, 2). 93 | Border(lipgloss.NormalBorder(), true). 94 | BorderForeground(m.theme.BaseColors.Base02). 95 | BorderBackground(m.theme.BaseColors.Base00). 96 | UnsetBorderBottom() 97 | } 98 | 99 | // messageStyle creates a style for the text of the messages pane component. 100 | func (m *Model) messageStyle() lipgloss.Style { 101 | return lipgloss.NewStyle(). 102 | Foreground(m.theme.BaseColors.Base04). 103 | Background(m.theme.BaseColors.Base03). 104 | Margin(1, 0, 1, 0). 105 | Padding(1, 2, 1, 1). 106 | MarginBackground(m.theme.BaseColors.Base01). 107 | Width(m.maxWidth) 108 | } 109 | 110 | // timestampStyle creates a style for the timestamp of the messages pane component. 111 | func (m *Model) timestampStyle() lipgloss.Style { 112 | return lipgloss.NewStyle(). 113 | Foreground(m.theme.BaseColors.Base03). 114 | Background(m.theme.BaseColors.Base01). 115 | PaddingRight(1) 116 | } 117 | 118 | // authorStyle creates a style for author messages. 119 | func (m *Model) authorStyle() lipgloss.Style { 120 | return lipgloss.NewStyle(). 121 | Foreground(m.theme.AccentColors.Accent1). 122 | Background(m.theme.BaseColors.Base01). 123 | Width(m.maxWidth). 124 | Bold(true) 125 | } 126 | 127 | // titleStyle creates a style for the title. 128 | func (m *Model) titleStyle() lipgloss.Style { 129 | return lipgloss.NewStyle(). 130 | Foreground(m.theme.BaseColors.Base04). 131 | Background(m.theme.BaseColors.Base01). 132 | Bold(true). 133 | Width(m.maxWidth) 134 | } 135 | 136 | // renderMessages formats and renders all messages 137 | func (m *Model) renderMessages() { 138 | output := strings.Builder{} 139 | output.WriteString(m.titleStyle().Render(title)) 140 | output.WriteString("\n\n") 141 | 142 | for _, message := range m.messages { 143 | timestamp := m.timestampStyle().Render(fmt.Sprintf("[%s]", message.Timestamp.Format("15:04:05"))) 144 | authorStyle := m.authorStyle().Width(m.maxWidth - lipgloss.Width(timestamp)) 145 | author := agent.Name 146 | 147 | if message.Tool != "" { 148 | author = fmt.Sprintf("%s->%s", agent.Name, message.Tool) 149 | authorStyle = authorStyle.Foreground(m.theme.AccentColors.Accent2) 150 | } 151 | 152 | author = authorStyle.Render(fmt.Sprintf("%s:", author)) 153 | messageText := m.messageStyle().Render(sanitizeMessage(message.Message)) 154 | 155 | output.WriteString(fmt.Sprintf("%s%s", timestamp, author)) 156 | output.WriteString("\n") 157 | output.WriteString(messageText) 158 | output.WriteString("\n") 159 | } 160 | 161 | m.viewport.SetContent(output.String()) 162 | } 163 | 164 | // sanitizeMessage removes unnecessary symbols from the message. 165 | func sanitizeMessage(message string) string { 166 | // Remove XML-style tags from the message 167 | message = regexp.MustCompile(`<[^>]*>`).ReplaceAllString(message, "") 168 | 169 | // Remove trailing spaces and newlines 170 | message = strings.TrimSpace(message) 171 | message = strings.Trim(message, "\n") 172 | 173 | return message 174 | } 175 | -------------------------------------------------------------------------------- /internal/tui/components/messagespane/messagespane_test.go: -------------------------------------------------------------------------------- 1 | package messagespane 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | "testing" 8 | "time" 9 | 10 | tea "github.com/charmbracelet/bubbletea" 11 | "github.com/charmbracelet/lipgloss" 12 | "github.com/datolabs-io/opsy/internal/agent" 13 | "github.com/datolabs-io/opsy/internal/thememanager" 14 | "github.com/stretchr/testify/assert" 15 | ) 16 | 17 | // stripANSI removes ANSI color codes from a string. 18 | func stripANSI(str string) string { 19 | re := regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`) 20 | return re.ReplaceAllString(str, "") 21 | } 22 | 23 | // TestNew tests the creation of a new messages pane component. 24 | func TestNew(t *testing.T) { 25 | theme := thememanager.Theme{ 26 | BaseColors: thememanager.BaseColors{ 27 | Base01: "#000000", 28 | Base02: "#111111", 29 | Base03: "#222222", 30 | Base04: "#333333", 31 | }, 32 | AccentColors: thememanager.AccentColors{ 33 | Accent1: "#FF0000", 34 | Accent2: "#00FF00", 35 | }, 36 | } 37 | 38 | m := New( 39 | WithTheme(theme), 40 | ) 41 | 42 | assert.NotNil(t, m) 43 | assert.Equal(t, theme, m.theme) 44 | assert.NotNil(t, m.viewport) 45 | assert.Empty(t, m.messages) 46 | } 47 | 48 | // TestUpdate tests the update function of the messages pane component. 49 | func TestUpdate(t *testing.T) { 50 | theme := thememanager.Theme{ 51 | BaseColors: thememanager.BaseColors{ 52 | Base01: "#000000", 53 | Base02: "#111111", 54 | Base03: "#222222", 55 | Base04: "#333333", 56 | }, 57 | } 58 | m := New(WithTheme(theme)) 59 | 60 | // Test window size message 61 | newModel, cmd := m.Update(tea.WindowSizeMsg{Width: 100, Height: 50}) 62 | assert.NotNil(t, newModel) 63 | assert.Nil(t, cmd) 64 | assert.Equal(t, 94, newModel.maxWidth) // Width - 6 for padding 65 | assert.Equal(t, 50, newModel.maxHeight) 66 | assert.Equal(t, 94, newModel.viewport.Width) 67 | assert.Equal(t, 50, newModel.viewport.Height) 68 | 69 | // Test message handling 70 | testMsg := agent.Message{ 71 | Message: "Test message", 72 | Tool: "", 73 | Timestamp: time.Now(), 74 | } 75 | m, cmd = m.Update(testMsg) 76 | assert.Nil(t, cmd) 77 | assert.Len(t, m.messages, 1) 78 | assert.Equal(t, testMsg, m.messages[0]) 79 | } 80 | 81 | // TestView tests the view function of the messages pane component. 82 | func TestView(t *testing.T) { 83 | theme := thememanager.Theme{ 84 | BaseColors: thememanager.BaseColors{ 85 | Base01: "#000000", 86 | Base02: "#111111", 87 | Base03: "#222222", 88 | Base04: "#333333", 89 | }, 90 | AccentColors: thememanager.AccentColors{ 91 | Accent1: "#FF0000", 92 | Accent2: "#00FF00", 93 | }, 94 | } 95 | 96 | m := New( 97 | WithTheme(theme), 98 | ) 99 | 100 | // Set dimensions to test rendering 101 | m, _ = m.Update(tea.WindowSizeMsg{Width: 100, Height: 50}) 102 | 103 | // Test initial view (empty messages) 104 | view := stripANSI(m.View()) 105 | assert.NotEmpty(t, view) 106 | assert.Contains(t, view, "Messages") 107 | 108 | // Add test messages 109 | now := time.Now() 110 | m.Update(agent.Message{ 111 | Message: "Hello", 112 | Tool: "", 113 | Timestamp: now, 114 | }) 115 | m.Update(agent.Message{ 116 | Message: "Running git command", 117 | Tool: "Git", 118 | Timestamp: now, 119 | }) 120 | 121 | // Test view with messages 122 | view = stripANSI(m.View()) 123 | assert.Contains(t, view, "Messages") 124 | assert.Contains(t, view, "Opsy:") 125 | assert.Contains(t, view, "Opsy->Git:") 126 | assert.Contains(t, view, "Hello") 127 | assert.Contains(t, view, "Running git command") 128 | } 129 | 130 | // TestInit tests the initialization of the messages pane component. 131 | func TestInit(t *testing.T) { 132 | theme := thememanager.Theme{ 133 | BaseColors: thememanager.BaseColors{ 134 | Base01: "#000000", 135 | }, 136 | } 137 | m := New(WithTheme(theme)) 138 | cmd := m.Init() 139 | assert.Nil(t, cmd) 140 | } 141 | 142 | // TestMessageSanitization tests the message sanitization functionality. 143 | func TestMessageSanitization(t *testing.T) { 144 | theme := thememanager.Theme{ 145 | BaseColors: thememanager.BaseColors{ 146 | Base01: "#000000", 147 | }, 148 | } 149 | m := New(WithTheme(theme)) 150 | m.Update(tea.WindowSizeMsg{Width: 100, Height: 50}) 151 | 152 | testCases := []struct { 153 | name string 154 | input string 155 | expected string 156 | }{ 157 | { 158 | name: "removes XML tags", 159 | input: "content", 160 | expected: "content", 161 | }, 162 | { 163 | name: "trims whitespace", 164 | input: " message \n\n", 165 | expected: "message", 166 | }, 167 | { 168 | name: "handles multiple tags", 169 | input: "content1content2", 170 | expected: "content1content2", 171 | }, 172 | } 173 | 174 | for _, tc := range testCases { 175 | t.Run(tc.name, func(t *testing.T) { 176 | m.Update(agent.Message{ 177 | Message: tc.input, 178 | Timestamp: time.Now(), 179 | }) 180 | view := stripANSI(m.View()) 181 | assert.Contains(t, view, tc.expected) 182 | assert.NotContains(t, view, "") 183 | }) 184 | } 185 | } 186 | 187 | // TestLongMessageWrapping tests the wrapping of long messages. 188 | func TestLongMessageWrapping(t *testing.T) { 189 | theme := thememanager.Theme{ 190 | BaseColors: thememanager.BaseColors{ 191 | Base01: "#000000", 192 | }, 193 | } 194 | m := New(WithTheme(theme)) 195 | 196 | // Set a narrow width to force wrapping 197 | m.Update(tea.WindowSizeMsg{Width: 40, Height: 50}) 198 | 199 | longMessage := "This is a very long message that should be wrapped to multiple lines when the width is limited" 200 | m.Update(agent.Message{ 201 | Message: longMessage, 202 | Timestamp: time.Now(), 203 | }) 204 | 205 | view := stripANSI(m.View()) 206 | lines := regexp.MustCompile(`\n`).Split(view, -1) 207 | 208 | // Count lines containing parts of the message 209 | messageLines := 0 210 | for _, line := range lines { 211 | if strings.Contains(line, "This") || strings.Contains(line, "long") || strings.Contains(line, "limited") { 212 | messageLines++ 213 | } 214 | } 215 | 216 | assert.Greater(t, messageLines, 1, "long message should be wrapped to multiple lines") 217 | } 218 | 219 | // TestThemeChange tests the component's response to theme changes. 220 | func TestThemeChange(t *testing.T) { 221 | initialTheme := thememanager.Theme{ 222 | BaseColors: thememanager.BaseColors{ 223 | Base01: "#000000", 224 | Base04: "#FFFFFF", 225 | }, 226 | AccentColors: thememanager.AccentColors{ 227 | Accent1: "#FF0000", 228 | Accent2: "#00FF00", 229 | }, 230 | } 231 | 232 | newTheme := thememanager.Theme{ 233 | BaseColors: thememanager.BaseColors{ 234 | Base01: "#111111", 235 | Base04: "#EEEEEE", 236 | }, 237 | AccentColors: thememanager.AccentColors{ 238 | Accent1: "#FF1111", 239 | Accent2: "#11FF11", 240 | }, 241 | } 242 | 243 | // Create and setup first model 244 | m1 := New(WithTheme(initialTheme)) 245 | m1.Update(tea.WindowSizeMsg{Width: 80, Height: 40}) 246 | m1.Update(agent.Message{ 247 | Message: "Test message", 248 | Timestamp: time.Now(), 249 | }) 250 | 251 | // Create and setup second model 252 | m2 := New(WithTheme(newTheme)) 253 | m2.Update(tea.WindowSizeMsg{Width: 80, Height: 40}) 254 | m2.Update(agent.Message{ 255 | Message: "Test message", 256 | Timestamp: time.Now(), 257 | }) 258 | 259 | // Verify container styles are different 260 | assert.NotEqual(t, 261 | m1.containerStyle().GetBackground(), 262 | m2.containerStyle().GetBackground(), 263 | "container styles should have different backgrounds", 264 | ) 265 | 266 | // Verify message styles are different 267 | assert.NotEqual(t, 268 | m1.messageStyle().GetForeground(), 269 | m2.messageStyle().GetForeground(), 270 | "message styles should have different colors", 271 | ) 272 | 273 | // Verify styles match their themes 274 | assert.Equal(t, 275 | lipgloss.Color(initialTheme.BaseColors.Base01), 276 | m1.containerStyle().GetBackground(), 277 | "container style should use Base01 color", 278 | ) 279 | 280 | assert.Equal(t, 281 | lipgloss.Color(initialTheme.BaseColors.Base04), 282 | m1.messageStyle().GetForeground(), 283 | "message style should use Base04 color", 284 | ) 285 | 286 | // Verify content is identical 287 | stripped1 := stripANSI(m1.View()) 288 | stripped2 := stripANSI(m2.View()) 289 | assert.Equal(t, stripped1, stripped2, "content should be same after stripping ANSI codes") 290 | } 291 | 292 | // TestConcurrentAccess tests message handling with multiple updates. 293 | func TestConcurrentAccess(t *testing.T) { 294 | theme := thememanager.Theme{ 295 | BaseColors: thememanager.BaseColors{ 296 | Base01: "#000000", 297 | Base02: "#111111", 298 | Base03: "#222222", 299 | Base04: "#333333", 300 | }, 301 | AccentColors: thememanager.AccentColors{ 302 | Accent1: "#FF0000", 303 | Accent2: "#00FF00", 304 | }, 305 | } 306 | 307 | m := New(WithTheme(theme)) 308 | 309 | // Initialize viewport with window size 310 | m, _ = m.Update(tea.WindowSizeMsg{Width: 100, Height: 50}) 311 | 312 | // Add messages sequentially with fixed timestamp 313 | timestamp := time.Date(2024, 1, 1, 10, 43, 56, 0, time.UTC) 314 | for i := 0; i < 10; i++ { 315 | msg := agent.Message{ 316 | Message: fmt.Sprintf("Message %d", i), 317 | Timestamp: timestamp, 318 | } 319 | m, _ = m.Update(msg) 320 | } 321 | 322 | // Verify that all messages are in the model's messages slice 323 | assert.Equal(t, 10, len(m.messages), "should have 10 messages") 324 | for i := 0; i < 10; i++ { 325 | expectedMessage := fmt.Sprintf("Message %d", i) 326 | assert.Equal(t, expectedMessage, m.messages[i].Message, "message %d should match", i) 327 | } 328 | 329 | // Verify that the viewport content is not empty 330 | content := stripANSI(m.viewport.View()) 331 | assert.NotEmpty(t, content, "viewport content should not be empty") 332 | } 333 | -------------------------------------------------------------------------------- /internal/tui/doc.go: -------------------------------------------------------------------------------- 1 | // Package tui provides the terminal user interface for the Opsy application. 2 | // 3 | // The TUI is built using the Bubble Tea framework and consists of four main components: 4 | // - Header: Displays the current task and application state 5 | // - Messages Pane: Shows the conversation between the user and the AI 6 | // - Commands Pane: Displays executed commands and their output 7 | // - Footer: Shows AI model configuration and status 8 | // 9 | // Each component is independently managed and styled, using the application's theme 10 | // for consistent appearance. The layout automatically adjusts to the terminal size, 11 | // with dynamic height calculations: 12 | // - Header height adjusts based on task text wrapping 13 | // - Messages pane takes 2/3 of the remaining height 14 | // - Commands pane takes 1/3 of the remaining height 15 | // - Footer maintains a fixed height 16 | // 17 | // Example usage: 18 | // 19 | // tui := tui.New( 20 | // tui.WithTheme(theme), 21 | // tui.WithConfig(cfg), 22 | // tui.WithTask("Analyze system performance"), 23 | // tui.WithToolsCount(5), 24 | // ) 25 | // p := tea.NewProgram(tui) 26 | // if _, err := p.Run(); err != nil { 27 | // log.Fatal(err) 28 | // } 29 | // 30 | // The TUI can be configured using functional options: 31 | // - WithTheme: Sets the theme for all components 32 | // - WithConfig: Sets the AI model configuration 33 | // - WithTask: Sets the current task being executed 34 | // - WithToolsCount: Sets the number of available tools 35 | // 36 | // Message Handling: 37 | // 38 | // The TUI processes several types of messages: 39 | // - tea.WindowSizeMsg: Triggers layout recalculation 40 | // - tea.KeyMsg: Handles keyboard input (e.g., Ctrl+C for quit) 41 | // - agent.Message: Updates the messages pane 42 | // - tool.Command: Updates the commands pane 43 | // - agent.Status: Updates the footer status 44 | // 45 | // Thread Safety: 46 | // 47 | // The TUI is designed to be thread-safe: 48 | // - All updates are handled through the message system 49 | // - Components maintain their own state 50 | // - No shared mutable state between components 51 | // - Safe for concurrent message processing 52 | // 53 | // All components receive and handle window size messages to maintain proper layout, 54 | // and each component can process its own specific messages for additional functionality. 55 | package tui 56 | -------------------------------------------------------------------------------- /internal/tui/tui.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "math" 5 | 6 | tea "github.com/charmbracelet/bubbletea" 7 | "github.com/charmbracelet/lipgloss" 8 | "github.com/datolabs-io/opsy/internal/agent" 9 | "github.com/datolabs-io/opsy/internal/config" 10 | "github.com/datolabs-io/opsy/internal/thememanager" 11 | "github.com/datolabs-io/opsy/internal/tool" 12 | "github.com/datolabs-io/opsy/internal/tui/components/commandspane" 13 | "github.com/datolabs-io/opsy/internal/tui/components/footer" 14 | "github.com/datolabs-io/opsy/internal/tui/components/header" 15 | "github.com/datolabs-io/opsy/internal/tui/components/messagespane" 16 | ) 17 | 18 | // model is the main model for the TUI. 19 | type model struct { 20 | theme *thememanager.Theme 21 | header *header.Model 22 | footer *footer.Model 23 | messagesPane *messagespane.Model 24 | commandsPane *commandspane.Model 25 | config config.Configuration 26 | task string 27 | toolsCount int 28 | } 29 | 30 | // Option is a function that configures the model. 31 | type Option func(*model) 32 | 33 | // New creates a new TUI instance. 34 | func New(opts ...Option) *model { 35 | m := &model{ 36 | config: config.New().GetConfig(), 37 | theme: &thememanager.Theme{ 38 | BaseColors: thememanager.BaseColors{}, 39 | AccentColors: thememanager.AccentColors{}, 40 | }, 41 | } 42 | 43 | for _, opt := range opts { 44 | opt(m) 45 | } 46 | 47 | m.header = header.New(header.WithTheme(*m.theme), header.WithTask(m.task)) 48 | m.footer = footer.New(footer.WithTheme(*m.theme), footer.WithParameters(footer.Parameters{ 49 | Engine: "Anthropic", 50 | Model: m.config.Anthropic.Model, 51 | MaxTokens: m.config.Anthropic.MaxTokens, 52 | Temperature: m.config.Anthropic.Temperature, 53 | ToolsCount: m.toolsCount, 54 | })) 55 | m.messagesPane = messagespane.New(messagespane.WithTheme(*m.theme)) 56 | m.commandsPane = commandspane.New(commandspane.WithTheme(*m.theme)) 57 | 58 | return m 59 | } 60 | 61 | // Init initializes the TUI. 62 | func (m *model) Init() tea.Cmd { 63 | return tea.SetWindowTitle("Opsy - Your AI-Powered SRE Colleague") 64 | } 65 | 66 | // Update handles all messages and updates the TUI 67 | func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 68 | var headerCmd, footerCmd, messagesCmd, commandsCmd tea.Cmd 69 | 70 | switch msg := msg.(type) { 71 | case tea.KeyMsg: 72 | if msg.String() == "ctrl+c" { 73 | return m, tea.Quit 74 | } 75 | case tea.WindowSizeMsg: 76 | headerHeight := int(math.Ceil(float64(lipgloss.Width(m.task))/float64(msg.Width))) * 2 77 | footerHeight := lipgloss.Height(m.footer.View()) 78 | remainingHeight := msg.Height - headerHeight - footerHeight - 8 79 | 80 | m.header, headerCmd = m.header.Update(tea.WindowSizeMsg{ 81 | Width: msg.Width, 82 | Height: headerHeight, 83 | }) 84 | m.footer, footerCmd = m.footer.Update(tea.WindowSizeMsg{ 85 | Width: msg.Width, 86 | Height: footerHeight, 87 | }) 88 | m.messagesPane, messagesCmd = m.messagesPane.Update(tea.WindowSizeMsg{ 89 | Width: msg.Width, 90 | Height: remainingHeight * 2 / 3, 91 | }) 92 | m.commandsPane, commandsCmd = m.commandsPane.Update(tea.WindowSizeMsg{ 93 | Width: msg.Width, 94 | Height: remainingHeight * 1 / 3, 95 | }) 96 | case agent.Message: 97 | m.messagesPane, messagesCmd = m.messagesPane.Update(msg) 98 | case tool.Command: 99 | m.commandsPane, commandsCmd = m.commandsPane.Update(msg) 100 | default: 101 | m.header, headerCmd = m.header.Update(msg) 102 | m.footer, footerCmd = m.footer.Update(msg) 103 | m.messagesPane, messagesCmd = m.messagesPane.Update(msg) 104 | m.commandsPane, commandsCmd = m.commandsPane.Update(msg) 105 | } 106 | 107 | return m, tea.Batch(headerCmd, footerCmd, messagesCmd, commandsCmd) 108 | } 109 | 110 | // View renders the TUI. 111 | func (m *model) View() string { 112 | return lipgloss.JoinVertical(lipgloss.Top, 113 | m.header.View(), 114 | m.messagesPane.View(), 115 | m.commandsPane.View(), 116 | m.footer.View(), 117 | ) 118 | } 119 | 120 | // WithTask sets the task that the agent will execute. 121 | func WithTask(task string) Option { 122 | return func(m *model) { 123 | m.task = task 124 | } 125 | } 126 | 127 | // WithConfig sets the configuration for the TUI. 128 | func WithConfig(cfg config.Configuration) Option { 129 | return func(m *model) { 130 | m.config = cfg 131 | } 132 | } 133 | 134 | // WithTheme sets the theme for the TUI. 135 | func WithTheme(theme *thememanager.Theme) Option { 136 | return func(m *model) { 137 | m.theme = theme 138 | } 139 | } 140 | 141 | // WithToolsCount sets the number of tools that the agent will use. 142 | func WithToolsCount(toolsCount int) Option { 143 | return func(m *model) { 144 | m.toolsCount = toolsCount 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /internal/tui/tui_test.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "testing" 5 | 6 | tea "github.com/charmbracelet/bubbletea" 7 | "github.com/datolabs-io/opsy/internal/config" 8 | "github.com/datolabs-io/opsy/internal/thememanager" 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | // TestNew tests the creation of a new TUI model with various options. 14 | func TestNew(t *testing.T) { 15 | t.Run("default configuration", func(t *testing.T) { 16 | m := New() 17 | require.NotNil(t, m) 18 | assert.NotNil(t, m.theme) 19 | assert.NotNil(t, m.header) 20 | assert.NotNil(t, m.footer) 21 | assert.NotNil(t, m.messagesPane) 22 | assert.NotNil(t, m.commandsPane) 23 | }) 24 | 25 | t.Run("with custom options", func(t *testing.T) { 26 | cfg := config.Configuration{ 27 | Anthropic: config.AnthropicConfiguration{ 28 | Model: "test-model", 29 | MaxTokens: 1000, 30 | Temperature: 0.7, 31 | }, 32 | } 33 | theme := &thememanager.Theme{ 34 | BaseColors: thememanager.BaseColors{}, 35 | AccentColors: thememanager.AccentColors{}, 36 | } 37 | task := "test task" 38 | toolsCount := 5 39 | 40 | m := New( 41 | WithConfig(cfg), 42 | WithTheme(theme), 43 | WithTask(task), 44 | WithToolsCount(toolsCount), 45 | ) 46 | 47 | require.NotNil(t, m) 48 | assert.Equal(t, cfg, m.config) 49 | assert.Equal(t, theme, m.theme) 50 | assert.Equal(t, task, m.task) 51 | assert.Equal(t, toolsCount, m.toolsCount) 52 | }) 53 | } 54 | 55 | // TestModel_Init tests the initialization of the TUI model. 56 | func TestModel_Init(t *testing.T) { 57 | m := New() 58 | cmd := m.Init() 59 | require.NotNil(t, cmd) 60 | } 61 | 62 | // TestModel_Update tests the update function of the TUI model. 63 | func TestModel_Update(t *testing.T) { 64 | t.Run("quit on ctrl+c", func(t *testing.T) { 65 | m := New() 66 | model, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) 67 | assert.NotNil(t, model) 68 | assert.NotNil(t, cmd) 69 | }) 70 | 71 | t.Run("handle window size message", func(t *testing.T) { 72 | m := New() 73 | updatedModel, _ := m.Update(tea.WindowSizeMsg{ 74 | Width: 100, 75 | Height: 50, 76 | }) 77 | assert.NotNil(t, updatedModel) 78 | 79 | // Verify that the message was processed by checking if components exist 80 | tuiModel, ok := updatedModel.(*model) 81 | assert.True(t, ok, "expected model to be of type *model") 82 | assert.NotNil(t, tuiModel.header) 83 | assert.NotNil(t, tuiModel.footer) 84 | assert.NotNil(t, tuiModel.messagesPane) 85 | assert.NotNil(t, tuiModel.commandsPane) 86 | }) 87 | } 88 | 89 | // TestModel_View tests the view rendering of the TUI model. 90 | func TestModel_View(t *testing.T) { 91 | m := New() 92 | view := m.View() 93 | assert.NotEmpty(t, view) 94 | } 95 | -------------------------------------------------------------------------------- /schemas/config.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "type": "object", 4 | "title": "Configuration Schema", 5 | "description": "Schema for the opsy CLI configuration", 6 | "required": [ 7 | "ui", 8 | "logging", 9 | "anthropic", 10 | "tools" 11 | ], 12 | "properties": { 13 | "ui": { 14 | "type": "object", 15 | "description": "Configuration for the UI", 16 | "required": [ 17 | "theme" 18 | ], 19 | "properties": { 20 | "theme": { 21 | "type": "string", 22 | "description": "Theme for the UI", 23 | "default": "default" 24 | } 25 | } 26 | }, 27 | "logging": { 28 | "type": "object", 29 | "description": "Configuration for logging", 30 | "required": [ 31 | "path", 32 | "level" 33 | ], 34 | "properties": { 35 | "path": { 36 | "type": "string", 37 | "description": "Path to the log file", 38 | "default": "~/.opsy/log.log" 39 | }, 40 | "level": { 41 | "type": "string", 42 | "description": "Logging level", 43 | "enum": [ 44 | "debug", 45 | "info", 46 | "warn", 47 | "error" 48 | ], 49 | "default": "info" 50 | } 51 | } 52 | }, 53 | "anthropic": { 54 | "type": "object", 55 | "description": "Configuration for the Anthropic API", 56 | "required": [ 57 | "api_key", 58 | "model", 59 | "temperature", 60 | "max_tokens" 61 | ], 62 | "properties": { 63 | "api_key": { 64 | "type": "string", 65 | "description": "API key for the Anthropic API" 66 | }, 67 | "model": { 68 | "type": "string", 69 | "description": "Model to use for the Anthropic API", 70 | "default": "claude-3-7-sonnet-latest" 71 | }, 72 | "temperature": { 73 | "type": "number", 74 | "description": "Temperature to use for the Anthropic API", 75 | "minimum": 0, 76 | "maximum": 1, 77 | "default": 0.5 78 | }, 79 | "max_tokens": { 80 | "type": "integer", 81 | "description": "Maximum number of tokens to use for the Anthropic API", 82 | "minimum": 1, 83 | "default": 1024 84 | } 85 | } 86 | }, 87 | "tools": { 88 | "type": "object", 89 | "description": "Configuration for the tools", 90 | "required": [ 91 | "timeout", 92 | "exec" 93 | ], 94 | "properties": { 95 | "timeout": { 96 | "type": "integer", 97 | "description": "Maximum duration in seconds for a tool to execute", 98 | "minimum": 0, 99 | "default": 120 100 | }, 101 | "exec": { 102 | "type": "object", 103 | "description": "Configuration for the exec tool", 104 | "required": [ 105 | "timeout", 106 | "shell" 107 | ], 108 | "properties": { 109 | "timeout": { 110 | "type": "integer", 111 | "description": "Maximum duration in seconds for a tool to execute", 112 | "minimum": 0, 113 | "default": 0 114 | }, 115 | "shell": { 116 | "type": "string", 117 | "description": "Shell to use for the exec tool", 118 | "default": "/bin/bash" 119 | } 120 | } 121 | } 122 | } 123 | } 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /schemas/theme.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "type": "object", 4 | "title": "Theme Definition Schema", 5 | "description": "Schema for defining color themes for the application TUI", 6 | "required": [ 7 | "base", 8 | "accent" 9 | ], 10 | "properties": { 11 | "base": { 12 | "type": "object", 13 | "description": "The base color palette", 14 | "required": [ 15 | "base00", 16 | "base01", 17 | "base02", 18 | "base03", 19 | "base04" 20 | ], 21 | "properties": { 22 | "base00": { 23 | "type": "string", 24 | "description": "Primary background color", 25 | "pattern": "^#[0-9A-Fa-f]{6}$" 26 | }, 27 | "base01": { 28 | "type": "string", 29 | "description": "Secondary background color (status bars, input)", 30 | "pattern": "^#[0-9A-Fa-f]{6}$" 31 | }, 32 | "base02": { 33 | "type": "string", 34 | "description": "Borders and dividers color", 35 | "pattern": "^#[0-9A-Fa-f]{6}$" 36 | }, 37 | "base03": { 38 | "type": "string", 39 | "description": "Muted or disabled text color", 40 | "pattern": "^#[0-9A-Fa-f]{6}$" 41 | }, 42 | "base04": { 43 | "type": "string", 44 | "description": "Primary text content color", 45 | "pattern": "^#[0-9A-Fa-f]{6}$" 46 | } 47 | } 48 | }, 49 | "accent": { 50 | "type": "object", 51 | "description": "The accent color palette", 52 | "required": [ 53 | "accent0", 54 | "accent1", 55 | "accent2" 56 | ], 57 | "properties": { 58 | "accent0": { 59 | "type": "string", 60 | "description": "Command text and prompts color", 61 | "pattern": "^#[0-9A-Fa-f]{6}$" 62 | }, 63 | "accent1": { 64 | "type": "string", 65 | "description": "Agent messages and success states color", 66 | "pattern": "^#[0-9A-Fa-f]{6}$" 67 | }, 68 | "accent2": { 69 | "type": "string", 70 | "description": "Tool output and links color", 71 | "pattern": "^#[0-9A-Fa-f]{6}$" 72 | } 73 | } 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /schemas/tool.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "type": "object", 4 | "title": "Tool Definition Schema", 5 | "description": "Schema for defining tools for the agent", 6 | "required": [ 7 | "display_name", 8 | "description", 9 | "inputs" 10 | ], 11 | "properties": { 12 | "display_name": { 13 | "type": "string", 14 | "description": "The name of the tool as it will be displayed in the UI" 15 | }, 16 | "description": { 17 | "type": "string", 18 | "description": "The description of the tool as it will be displayed in the UI" 19 | }, 20 | "rules": { 21 | "type": "array", 22 | "description": "Additional rules the tool must follow", 23 | "items": { 24 | "type": "string" 25 | } 26 | }, 27 | "executable": { 28 | "type": "string", 29 | "description": "The executable the tool relies on" 30 | }, 31 | "inputs": { 32 | "type": "object", 33 | "description": "The inputs for the tool", 34 | "additionalProperties": { 35 | "type": "object", 36 | "required": [ 37 | "type", 38 | "description" 39 | ], 40 | "properties": { 41 | "type": { 42 | "type": "string", 43 | "description": "The type of the input" 44 | }, 45 | "description": { 46 | "type": "string", 47 | "description": "The description of the input" 48 | }, 49 | "default": { 50 | "type": "string", 51 | "description": "The default value for the input" 52 | }, 53 | "examples": { 54 | "type": "array", 55 | "description": "Examples of valid input values", 56 | "items": { 57 | "type": [ 58 | "string", 59 | "number", 60 | "boolean", 61 | "object", 62 | "array" 63 | ] 64 | } 65 | }, 66 | "optional": { 67 | "type": "boolean", 68 | "description": "Whether the input is optional", 69 | "default": false 70 | } 71 | } 72 | } 73 | } 74 | } 75 | } 76 | --------------------------------------------------------------------------------