├── .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 | 
4 | 
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 | [](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 |
--------------------------------------------------------------------------------