diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8634beff7..9bff71ee3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,7 +81,7 @@ jobs: run: go install github.com/google/go-licenses@latest - name: Check licenses - run: go-licenses check . --allowed_licenses=Apache-2.0,MIT,BSD-3-Clause,BSD-2-Clause --ignore modernc.org/mathutil + run: go-licenses check . --allowed_licenses=Apache-2.0,MIT,BSD-3-Clause,BSD-2-Clause --ignore modernc.org/mathutil --ignore github.com/hashicorp/hcl/v2 build-image: if: github.event_name == 'pull_request' diff --git a/examples/gopher.hcl b/examples/gopher.hcl new file mode 100644 index 000000000..e14920cd6 --- /dev/null +++ b/examples/gopher.hcl @@ -0,0 +1,256 @@ +#!/usr/bin/env docker agent run + +model "claude" { + provider = "anthropic" + model = "claude-opus-4-6" +} + +model "haiku" { + provider = "anthropic" + model = "claude-haiku-4-5" +} + +agent "root" { + model = "claude" + description = "Expert Golang Developer specialized in implementing features and improving code quality." + skills = true + + instruction = <<-EOT + **Goal:** + Help with Go code-related tasks by examining, modifying, and validating code changes. + + + **Workflow:** + 1. **Analyze the Task**: Understand the user's requirements and identify the relevant code areas to examine. + + 2. **Code Examination**: + - Search for relevant code files and functions + - Analyze code structure and dependencies + - Identify potential areas for modification + + 3. **Code Modification**: + - Make necessary code changes + - Ensure changes follow best practices + - Maintain code style consistency + + 4. **Validation Loop**: + - Run linters and tests to check code quality + - Verify changes meet requirements + - If issues found, return to step 3 + - Continue until all requirements are met + + 5. **Summary**: + - Very concisely summarize the changes made (not in a file) + - For trivial tasks, answer the question without extra information + + + **Details:** + - Be thorough in code examination before making changes + - Always validate changes before considering the task complete + - Follow Go best practices + - Maintain or improve code quality + - Be proactive in identifying potential issues + - Only ask for clarification if necessary, try your best to use all the tools to get the info you need + + **Tools:** + - When needed and possible, call multiple tools concurrently. It's faster and cheaper. + EOT + + add_date = true + add_environment_info = true + add_prompt_files = ["AGENTS.md"] + sub_agents = ["librarian"] + + toolset "filesystem" {} + toolset "shell" {} + toolset "todo" {} + + toolset "mcp" { + command = "gopls" + version = "golang/tools@v0.21.0" + args = ["mcp"] + } + + command "fix-lint" { + description = "Fix the lint issues" + instruction = <<-EOT + Fix the lint issues (if any). + + Here the result of the linting command: + $ mise lint + $${shell({cmd: "mise lint"})} + + $go_diagnostics + $${go_diagnostics()} + + $go_vulncheck + $${go_vulncheck()} + EOT + } + + command "remove-comments-tests" { + instruction = "Remove useless comments in test files (*_test.go)" + } + + command "commit" { + description = "Commit local changes" + instruction = <<-EOT + Based on the below changes: create a single commit with an appropriate message. + + - Current git status: !shell(cmd="git status") + - Current git diff (staged and unstaged changes): !shell(cmd="git diff HEAD") + - Current branch: !shell(cmd="git branch --show-current") + EOT + } + + command "simplify" { + instruction = "Look at the local changes and try to simplify the code and architecture but don't remove any feature. I just want the code to be easier to read and maintain." + } + + command "init" { + instruction = <<-EOT + Create an AGENTS.md file for this project by inspecting the codebase. The AGENTS.md should help AI coding agents understand how to work with this project effectively. + + Analyze the project structure and include: + 1. **Development Commands**: Build, test, lint, and run commands (check Makefile, mise.toml, package.json, Cargo.toml, etc.) + 2. **Architecture Overview**: Key packages/modules, their responsibilities, and how they interact + 3. **Code Style and Conventions**: Patterns used, error handling approaches, naming conventions + 4. **Testing Guidelines**: How to run tests, test patterns used, any special testing setup + 5. **Configuration**: Important config files and environment variables + 6. **Common Development Patterns**: Frequently used patterns specific to this codebase + 7. **Key Files Reference**: Quick reference table of important files and their purposes + + Focus on information that would help an AI agent navigate and modify the codebase correctly. Be concise but comprehensive. + EOT + } + + command "security-review" { + instruction = <<-EOT + Perform a security review of the local changes in this Git repository. + + **Workflow:** + 1. **Identify Changes**: Run `git diff` to see uncommitted changes, and `git diff HEAD~1` or `git log --oneline -5` to understand recent commits if needed. + + 2. **Security Analysis**: Review the changes for common security issues: + - **Input Validation**: Check for missing or inadequate input validation + - **SQL Injection**: Look for raw SQL queries or improper use of query builders + - **Command Injection**: Identify unsafe use of exec, shell commands, or system calls + - **Path Traversal**: Check for unsafe file path handling + - **Sensitive Data Exposure**: Look for hardcoded secrets, API keys, or credentials + - **Authentication/Authorization**: Review any auth-related changes + - **Error Handling**: Check for information leakage in error messages + - **Dependency Security**: Note any new dependencies that should be vetted + - **Race Conditions**: Identify potential concurrency issues in Go code + - **Unsafe Pointer Usage**: Check for unsafe package usage + + 3. **Go-Specific Checks**: + - Run `go_vulncheck` to check for known vulnerabilities + - Review use of `unsafe` package + - Check for proper context cancellation and timeout handling + - Verify proper error wrapping and handling + + 4. **Report**: Provide a structured security review with: + - **Summary**: Overall security posture of the changes + - **Findings**: List of identified issues with severity (Critical/High/Medium/Low/Info) + - **Recommendations**: Specific suggestions to improve security + - **Tips**: General security best practices relevant to the changes + EOT + } +} + +agent "planner" { + model = "claude" + + instruction = <<-EOT + You are a planning agent responsible for gathering user requirements and creating a development plan. + Always ask clarifying questions to ensure you fully understand the user's needs before creating the plan. + Once you have a clear understanding, analyze the existing code and create a detailed development plan in a markdown file. Do not write any code yourself. + Once the plan is created, you will delegate tasks to the root agent. Make sure to provide the file name of the plan when delegating. Write the plan in the current directory. + Use the `user_prompt` tool to ask questions to the user. Prefer Multiple Choice Questions. + EOT + + sub_agents = ["root"] + + toolset "filesystem" {} + toolset "user_prompt" {} +} + +agent "reviewer" { + model = "google/gemini-3-pro-preview" + + instruction = <<-EOT + Give me feedback about the local changes. Don't be too picky, think about code quality, security, duplication, idiomatic Go, + performance, maintainability, and best practices. + Provide suggestions for improvements and point out any potential issues. + Don't be too verbose, keep your review concise and to the point. + EOT + + add_prompt_files = ["AGENTS.md"] + sub_agents = ["librarian"] + + toolset "filesystem" {} + toolset "shell" {} + + toolset "mcp" { + command = "gopls" + version = "golang/tools@v0.21.0" + args = ["mcp"] + } +} + +agent "librarian" { + model = "haiku" + description = "Documentation librarian. Can search the Web and look for relevant documentation to help the golang developer agent." + + instruction = <<-EOT + You are the librarian, your job is to look for relevant documentation to help the golang developer agent. + When given a query, search the internet for relevant documentation, articles, or resources that can assist in completing the task. + Use context7 for searching documentation and brave for general web searches. + A good source of information available to agents is https://deepwiki.com/. + EOT + + toolset "mcp" { + ref = "docker:context7" + } + toolset "mcp" { + ref = "docker:brave" + } + toolset "fetch" {} +} + +permissions { + allow = [ + "go_diagnostics", + "go_file_context", + "go_package_api", + "go_symbol_references", + "go_vulncheck", + "go_workspace", + "shell:cmd=gh --version", + "shell:cmd=gh pr view *", + "shell:cmd=gh pr diff *", + "shell:cmd=git remote -v", + "shell:cmd=ls *", + "shell:cmd=cat *", + "shell:cmd=head *", + "shell:cmd=tail *", + "shell:cmd=wc *", + "shell:cmd=find *", + "shell:cmd=grep *", + "shell:cmd=pwd", + "shell:cmd=echo *", + "shell:cmd=which *", + "shell:cmd=type *", + "shell:cmd=file *", + "shell:cmd=stat *", + "shell:cmd=git status*", + "shell:cmd=git log*", + "shell:cmd=git diff*", + "shell:cmd=git show*", + "shell:cmd=git branch*", + "shell:cmd=git remote -v*", + "shell:cmd=git commit *", + "shell:cmd=go test*", + "shell:cmd=go build*", + ] +} diff --git a/examples/pirate.hcl b/examples/pirate.hcl new file mode 100644 index 000000000..9bc238a85 --- /dev/null +++ b/examples/pirate.hcl @@ -0,0 +1,13 @@ +#!/usr/bin/env docker agent run + +agent "root" { + description = "An agent that talks like a pirate" + instruction = "Always answer by talking like a pirate." + model = "auto" + + welcome_message = <<-EOT + Ahoy! I be yer pirate guide, ready to set sail on the seas o' knowledge! + + What be yer quest? 🏴‍☠️ + EOT +} diff --git a/go.mod b/go.mod index 5930eae74..a66196a29 100644 --- a/go.mod +++ b/go.mod @@ -40,6 +40,7 @@ require ( github.com/google/jsonschema-go v0.4.3 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 + github.com/hashicorp/hcl/v2 v2.20.1 github.com/junegunn/fzf v0.72.0 github.com/k3a/html2text v1.4.0 github.com/kofalt/go-memoize v0.0.0-20240506050413-9e5eb99a0f2a @@ -58,6 +59,7 @@ require ( github.com/wk8/go-ordered-map/v2 v2.1.9-0.20250401010720-46d686821e33 github.com/xeipuuv/gojsonschema v1.2.0 github.com/yuin/goldmark v1.8.2 + github.com/zclconf/go-cty v1.13.0 go.opentelemetry.io/otel v1.43.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 go.opentelemetry.io/otel/sdk v1.43.0 @@ -77,6 +79,9 @@ require ( require ( cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect + github.com/agext/levenshtein v1.2.1 // indirect + github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect + github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect github.com/danieljoos/wincred v1.2.2 // indirect github.com/dvsekhvalnov/jose2go v1.5.0 // indirect @@ -84,10 +89,13 @@ require ( github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect github.com/invopop/jsonschema v0.13.0 // indirect github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741 // indirect + github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 // indirect github.com/mtibben/percent v0.2.1 // indirect github.com/pb33f/jsonpath v0.8.2 // indirect github.com/pb33f/ordered-map/v2 v2.3.1 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/tools v0.44.0 // indirect google.golang.org/api v0.272.0 // indirect ) @@ -231,8 +239,8 @@ require ( go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.4 - golang.org/x/crypto v0.49.0 // indirect - golang.org/x/net v0.52.0 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/net v0.53.0 // indirect golang.org/x/text v0.36.0 // indirect golang.org/x/time v0.15.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect diff --git a/go.sum b/go.sum index ff588b712..6474f45d3 100644 --- a/go.sum +++ b/go.sum @@ -39,6 +39,8 @@ github.com/RoaringBitmap/roaring/v2 v2.4.5 h1:uGrrMreGjvAtTBobc0g5IrW1D5ldxDQYe2 github.com/RoaringBitmap/roaring/v2 v2.4.5/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0= github.com/a2aproject/a2a-go v0.3.15 h1:h5YpCiPq3jxQ5rIns7oDjPag3ivP8u817AzdA4F+NiI= github.com/a2aproject/a2a-go v0.3.15/go.mod h1:I7Cm+a1oL+UT6zMoP+roaRE5vdfUa1iQGVN8aSOuZ0I= +github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tjT8= +github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.24.0 h1:zrg+k0tAaVbM8whaT2hR5DOUqAdopsDaH998EGi6Llk= @@ -53,6 +55,10 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/anthropics/anthropic-sdk-go v1.38.0 h1:bA4DcK+91gorIX+5VTONnynyt9LRU4nnN6rRQ+j/NIg= github.com/anthropics/anthropic-sdk-go v1.38.0/go.mod h1:d288C1L+m74OYuYBvc4UFtR1Q8J0gC55oYDh2t+XxdI= +github.com/apparentlymart/go-textseg/v13 v13.0.0 h1:Y+KvPE1NYz0xl601PVImeQfFyEy6iT90AvPUL1NNfNw= +github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo= +github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= +github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= @@ -254,6 +260,8 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU= github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= +github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0= @@ -304,6 +312,8 @@ github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8 github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hashicorp/hcl/v2 v2.20.1 h1:M6hgdyz7HYt1UN9e61j+qKJBqR3orTWbI1HKBJEdxtc= +github.com/hashicorp/hcl/v2 v2.20.1/go.mod h1:TZDqQ4kNKCbh1iJp99FdPiUaVDDUPivbqxZulxDYqL4= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -360,6 +370,8 @@ github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwX github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 h1:DpOJ2HYzCv8LZP15IdmG+YdwD2luVPHITV96TkirNBM= +github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4= @@ -515,6 +527,10 @@ github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= +github.com/zclconf/go-cty v1.13.0 h1:It5dfKTTZHe9aeppbNOda3mN7Ag7sg6QkBNm6TkyFa0= +github.com/zclconf/go-cty v1.13.0/go.mod h1:YKQzy/7pZ7iq2jNFzy5go57xdxdWoLLpaEp4u238AE0= +github.com/zclconf/go-cty-debug v0.0.0-20191215020915-b22d67c1ba0b h1:FosyBZYxY34Wul7O/MSKey3txpPYyCqVO5ZyceuQJEI= +github.com/zclconf/go-cty-debug v0.0.0-20191215020915-b22d67c1ba0b/go.mod h1:ZRKQfBXbGkpdV6QMzT3rU1kSTAnfu1dO8dPKjYprgj8= go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk= go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -555,8 +571,8 @@ go.yaml.in/yaml/v4 v4.0.0-rc.4/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfP golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww= @@ -567,8 +583,8 @@ golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73r golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/pkg/config/config.go b/pkg/config/config.go index 7ff96d784..5604c2cf2 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -8,11 +8,13 @@ import ( "log/slog" "maps" "net/url" + "path/filepath" "slices" "strings" "github.com/goccy/go-yaml" + hclconv "github.com/docker/docker-agent/pkg/config/hcl" "github.com/docker/docker-agent/pkg/config/latest" "github.com/docker/docker-agent/pkg/environment" ) @@ -23,6 +25,17 @@ func Load(ctx context.Context, source Source) (*latest.Config, error) { return nil, err } + // Configurations may be authored in HCL as an alternative to YAML. + // Detect the format from the source name extension or, when no hint is + // available (OCI artifacts, etc.), from the content itself, then + // transparently convert to YAML for the rest of the pipeline. + if isHCLSource(source.Name(), data) { + data, err = hclconv.ToYAML(data, source.Name()) + if err != nil { + return nil, fmt.Errorf("parsing HCL config file: %w", err) + } + } + var raw struct { Version string `yaml:"version,omitempty"` } @@ -166,6 +179,16 @@ func validateConfig(cfg *latest.Config) error { return nil } +// isHCLSource reports whether the configuration data should be parsed as HCL +// rather than YAML. The decision is based first on the source name extension, +// and then on a content-based heuristic when no extension hint is available. +func isHCLSource(name string, data []byte) bool { + if strings.EqualFold(filepath.Ext(name), ".hcl") { + return true + } + return hclconv.LooksLikeHCL(data) +} + // providerAPITypes are the allowed values for api_type in provider configs var providerAPITypes = map[string]bool{ "": true, // empty is allowed (defaults to openai_chatcompletions) diff --git a/pkg/config/examples_test.go b/pkg/config/examples_test.go index c7e7f79bc..5a9aee6ac 100644 --- a/pkg/config/examples_test.go +++ b/pkg/config/examples_test.go @@ -2,7 +2,9 @@ package config import ( "io/fs" + "os" "path/filepath" + "strings" "testing" "github.com/goccy/go-yaml" @@ -21,8 +23,11 @@ func collectExamples(t *testing.T) []string { if err != nil { return err } - if !d.IsDir() && filepath.Ext(path) == ".yaml" { - files = append(files, path) + if !d.IsDir() { + ext := filepath.Ext(path) + if ext == ".yaml" || ext == ".hcl" { + files = append(files, path) + } } return nil }) @@ -81,7 +86,6 @@ func TestParseExamplesAfterMarshalling(t *testing.T) { t.Run(file, func(t *testing.T) { t.Parallel() - src := NewFileSource(file) cfg, err := Load(t.Context(), NewFileSource(file)) require.NoError(t, err) @@ -90,8 +94,36 @@ func TestParseExamplesAfterMarshalling(t *testing.T) { buf, err := yaml.Marshal(cfg) require.NoError(t, err) - _, err = Load(t.Context(), NewBytesSource(src.Name(), buf)) + // The marshalled bytes are always YAML, so re-load them under a + // .yaml-named source even when the original example was HCL. + name := strings.TrimSuffix(file, filepath.Ext(file)) + ".yaml" + _, err = Load(t.Context(), NewBytesSource(name, buf)) + require.NoError(t, err) + }) + } +} + +// TestHCLExamplesMatchYAML verifies that every .hcl example file produces a +// configuration identical to its .yaml sibling, ensuring the HCL surface +// stays in sync with the YAML schema. +func TestHCLExamplesMatchYAML(t *testing.T) { + for _, file := range collectExamples(t) { + if filepath.Ext(file) != ".hcl" { + continue + } + yamlFile := strings.TrimSuffix(file, ".hcl") + ".yaml" + if _, err := os.Stat(yamlFile); err != nil { + continue + } + t.Run(file, func(t *testing.T) { + t.Parallel() + + cfgHCL, err := Load(t.Context(), NewFileSource(file)) + require.NoError(t, err) + cfgYAML, err := Load(t.Context(), NewFileSource(yamlFile)) require.NoError(t, err) + + require.Equal(t, cfgYAML, cfgHCL, "HCL config %s differs from YAML sibling %s", file, yamlFile) }) } } diff --git a/pkg/config/hcl/hcl.go b/pkg/config/hcl/hcl.go new file mode 100644 index 000000000..41d4209c4 --- /dev/null +++ b/pkg/config/hcl/hcl.go @@ -0,0 +1,343 @@ +// Package hcl provides an HCL → YAML converter for docker-agent configuration +// files. The HCL surface mirrors the YAML schema with a few conventions: +// +// - Top-level keyed maps (agents, models, providers, mcps, rag) are written +// as labeled blocks, e.g. `agent "root" { ... }` becomes +// `agents: { root: { ... } }`. +// - Inside an agent, `command "name" { ... }` becomes +// `commands: { name: { ... } }`. +// - Toolsets use the label as the `type` field: +// `toolset "mcp" { ... }` becomes `toolsets: [{ type: mcp, ... }]`. +// - Multi-line strings should use heredocs. Because HCL templates expand +// `${...}` interpolation, any literal `${...}` (such as +// `${shell({cmd: "..."})}`) must be escaped as `$${...}`. +// +// The converter does not validate the resulting document against the +// configuration schema; that is left to the existing YAML/JSON loader. +package hcl + +import ( + "fmt" + "math/big" + "strings" + + "github.com/goccy/go-yaml" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclparse" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/zclconf/go-cty/cty" +) + +// LooksLikeHCL reports whether the given bytes look like an HCL document +// rather than a YAML one. The detection is heuristic and is intended for +// callers that do not have a filename hint to rely on (for example, OCI +// artifacts). It looks for top-level labeled blocks of the docker-agent +// HCL schema, e.g. `agent "..." {`, which are not valid YAML. +func LooksLikeHCL(data []byte) bool { + for line := range strings.Lines(string(data)) { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "//") { + continue + } + // A YAML mapping key (e.g. `agent "root":`) ends with a colon and + // must not be confused with an HCL block opener. + if strings.HasSuffix(trimmed, ":") { + return false + } + for _, kw := range topLevelHCLKeywords { + if strings.HasPrefix(trimmed, kw+" \"") || strings.HasPrefix(trimmed, kw+" {") { + return true + } + } + // The first non-comment, non-blank line is not an HCL block opener; + // assume YAML. + return false + } + return false +} + +// topLevelHCLKeywords lists the block names that may legitimately appear at +// the top level of a docker-agent HCL document. +var topLevelHCLKeywords = []string{ + "agent", "model", "provider", "mcp", "rag", "metadata", "permissions", +} + +// ToYAML parses an HCL document and returns an equivalent YAML document +// that can be fed to the existing docker-agent config loader. +func ToYAML(data []byte, filename string) ([]byte, error) { + m, err := ToMap(data, filename) + if err != nil { + return nil, err + } + out, err := yaml.Marshal(m) + if err != nil { + return nil, fmt.Errorf("encoding HCL config to YAML: %w", err) + } + return out, nil +} + +// ToMap parses an HCL document and returns a generic map that mirrors the +// structure of the equivalent YAML document. +func ToMap(data []byte, filename string) (map[string]any, error) { + parser := hclparse.NewParser() + file, diags := parser.ParseHCL(data, filename) + if diags.HasErrors() { + return nil, fmt.Errorf("parsing HCL %s: %s", filename, diags.Error()) + } + body, ok := file.Body.(*hclsyntax.Body) + if !ok { + return nil, fmt.Errorf("HCL file %s is not native syntax", filename) + } + out, diags := convertBody(body) + if diags.HasErrors() { + return nil, fmt.Errorf("converting HCL %s: %s", filename, diags.Error()) + } + return out, nil +} + +type blockMode int + +const ( + // modeMapByLabel: block has 1 label; output as a label-keyed yaml.MapSlice. + modeMapByLabel blockMode = iota + // modeSingleton: block has 0 labels and may appear at most once. + modeSingleton + // modeList: blocks aggregated into a list. If labelField is set, the + // block's single label is injected as that field on each entry. + modeList +) + +type blockRule struct { + mode blockMode + outKey string + labelField string // only set for labeled list rules (e.g. toolsets) +} + +// expectedLabels returns the number of labels a block matching this rule +// requires. +func (r blockRule) expectedLabels() int { + if r.mode == modeMapByLabel || r.labelField != "" { + return 1 + } + return 0 +} + +// blockRules describes how each known block name is rendered in the YAML +// output. Block names not listed here fall back to defaults: 0-label blocks +// become singletons under the same key; 1-label blocks become maps keyed by +// the label under the same key. +var blockRules = map[string]blockRule{ + // Top-level keyed maps (and equivalents inside agents). + "agent": {mode: modeMapByLabel, outKey: "agents"}, + "model": {mode: modeMapByLabel, outKey: "models"}, + "provider": {mode: modeMapByLabel, outKey: "providers"}, + "mcp": {mode: modeMapByLabel, outKey: "mcps"}, + "rag": {mode: modeMapByLabel, outKey: "rag"}, + "command": {mode: modeMapByLabel, outKey: "commands"}, + // `shell "name" { ... }` is used inside script toolsets as a map of + // scripted shell commands. + "shell": {mode: modeMapByLabel, outKey: "shell"}, + + // Toolsets are a list with the label encoded as the `type` field. + "toolset": {mode: modeList, outKey: "toolsets", labelField: "type"}, + + // Singletons. + "permissions": {mode: modeSingleton, outKey: "permissions"}, + "metadata": {mode: modeSingleton, outKey: "metadata"}, + "hooks": {mode: modeSingleton, outKey: "hooks"}, + "fallback": {mode: modeSingleton, outKey: "fallback"}, + "cache": {mode: modeSingleton, outKey: "cache"}, + "structured_output": {mode: modeSingleton, outKey: "structured_output"}, + "skills": {mode: modeSingleton, outKey: "skills"}, + "lifecycle": {mode: modeSingleton, outKey: "lifecycle"}, + "remote": {mode: modeSingleton, outKey: "remote"}, + "oauth": {mode: modeSingleton, outKey: "oauth"}, + "api_config": {mode: modeSingleton, outKey: "api_config"}, + "rag_config": {mode: modeSingleton, outKey: "rag_config"}, + "thinking_budget": {mode: modeSingleton, outKey: "thinking_budget"}, + "task_budget": {mode: modeSingleton, outKey: "task_budget"}, + "defer": {mode: modeSingleton, outKey: "defer"}, + "fusion": {mode: modeSingleton, outKey: "fusion"}, + "reranking": {mode: modeSingleton, outKey: "reranking"}, + "chunking": {mode: modeSingleton, outKey: "chunking"}, + "database": {mode: modeSingleton, outKey: "database"}, + + // 0-label blocks aggregated into lists. + "post_edit": {mode: modeList, outKey: "post_edit"}, + "strategy": {mode: modeList, outKey: "strategies"}, + "routing": {mode: modeList, outKey: "routing"}, + "hook": {mode: modeList, outKey: "hooks"}, + "pre_tool_use": {mode: modeList, outKey: "pre_tool_use"}, + "post_tool_use": {mode: modeList, outKey: "post_tool_use"}, + "session_start": {mode: modeList, outKey: "session_start"}, + "session_end": {mode: modeList, outKey: "session_end"}, + "permission_request": {mode: modeList, outKey: "permission_request"}, + "tool_response_transform": {mode: modeList, outKey: "tool_response_transform"}, +} + +// lookupRule returns the conversion rule for a block, falling back to a +// sensible default when the block name is not registered. +func lookupRule(name string, labels int) blockRule { + if r, ok := blockRules[name]; ok { + return r + } + if labels == 1 { + return blockRule{mode: modeMapByLabel, outKey: name} + } + return blockRule{mode: modeSingleton, outKey: name} +} + +// LabelKeyedMapOutKeys returns the set of YAML keys produced by HCL block +// rules that map a labeled block into a label-keyed map (e.g. agent "x" {} +// becomes agents.x). It is exported so tests can verify the HCL conventions +// stay in sync with top-level keyed maps in the JSON schema. +func LabelKeyedMapOutKeys() map[string]bool { + out := make(map[string]bool, len(blockRules)) + for _, r := range blockRules { + if r.mode == modeMapByLabel { + out[r.outKey] = true + } + } + return out +} + +// convertBody walks an HCL body, converting attributes into Go values and +// blocks into nested map / list / yaml.MapSlice structures according to the +// block rules. +// +// HCL's parser already rejects duplicate attribute names within a body, so +// we don't guard against them here. +func convertBody(body *hclsyntax.Body) (map[string]any, hcl.Diagnostics) { + var diags hcl.Diagnostics + out := map[string]any{} + + for name, attr := range body.Attributes { + val, attrDiags := convertExpr(attr.Expr) + diags = append(diags, attrDiags...) + if !attrDiags.HasErrors() { + out[name] = val + } + } + + for _, block := range body.Blocks { + diags = append(diags, mergeBlock(out, block)...) + } + + return out, diags +} + +// mergeBlock decodes a single child block and merges its body into out +// according to the block's rule. It validates label count and detects +// per-rule duplicates (e.g. two singleton blocks of the same name, or two +// labeled blocks with the same label). +func mergeBlock(out map[string]any, block *hclsyntax.Block) hcl.Diagnostics { + rule := lookupRule(block.Type, len(block.Labels)) + + if d := checkLabels(block, rule.expectedLabels()); d != nil { + return d + } + + body, diags := convertBody(block.Body) + if diags.HasErrors() { + return diags + } + + switch rule.mode { + case modeSingleton: + if _, exists := out[rule.outKey]; exists { + return errf(block.DefRange().Ptr(), "Duplicate block", + "Block %q can only appear once in this scope.", block.Type) + } + out[rule.outKey] = body + + case modeList: + if rule.labelField != "" { + body[rule.labelField] = block.Labels[0] + } + list, _ := out[rule.outKey].([]any) + out[rule.outKey] = append(list, body) + + case modeMapByLabel: + label := block.Labels[0] + slice, _ := out[rule.outKey].(yaml.MapSlice) + for _, item := range slice { + if item.Key == label { + return errf(block.LabelRanges[0].Ptr(), "Duplicate block", + "Block %q with label %q is defined more than once.", block.Type, label) + } + } + out[rule.outKey] = append(slice, yaml.MapItem{Key: label, Value: body}) + } + + return nil +} + +// checkLabels returns a diagnostic if the block's label count does not match +// what the rule requires, and nil otherwise. +func checkLabels(block *hclsyntax.Block, want int) hcl.Diagnostics { + got := len(block.Labels) + if got == want { + return nil + } + if want == 0 { + return errf(block.LabelRanges[0].Ptr(), "Unexpected block label", + "Block %q does not take any label.", block.Type) + } + return errf(block.DefRange().Ptr(), "Block label required", + "Block %q expects exactly one label.", block.Type) +} + +// errf builds a single-error diagnostics slice with a formatted detail. +func errf(subj *hcl.Range, summary, format string, args ...any) hcl.Diagnostics { + return hcl.Diagnostics{{ + Severity: hcl.DiagError, + Summary: summary, + Detail: fmt.Sprintf(format, args...), + Subject: subj, + }} +} + +func convertExpr(expr hclsyntax.Expression) (any, hcl.Diagnostics) { + val, diags := expr.Value(&hcl.EvalContext{}) + if diags.HasErrors() { + return nil, diags + } + return ctyToGo(val), nil +} + +// ctyToGo recursively converts a cty.Value into the Go primitives used by +// the YAML marshaller (string, int64, float64, bool, []any, map[string]any). +func ctyToGo(val cty.Value) any { + if !val.IsKnown() || val.IsNull() { + return nil + } + t := val.Type() + switch { + case t == cty.String: + return val.AsString() + case t == cty.Bool: + return val.True() + case t == cty.Number: + bf := val.AsBigFloat() + if i, acc := bf.Int64(); acc == big.Exact { + return i + } + f, _ := bf.Float64() + return f + case t.IsListType(), t.IsSetType(), t.IsTupleType(): + out := make([]any, 0, val.LengthInt()) + for it := val.ElementIterator(); it.Next(); { + _, v := it.Element() + out = append(out, ctyToGo(v)) + } + return out + case t.IsObjectType(), t.IsMapType(): + out := map[string]any{} + for it := val.ElementIterator(); it.Next(); { + k, v := it.Element() + out[k.AsString()] = ctyToGo(v) + } + return out + } + return val.GoString() +} diff --git a/pkg/config/hcl/hcl_test.go b/pkg/config/hcl/hcl_test.go new file mode 100644 index 000000000..54acdc0ad --- /dev/null +++ b/pkg/config/hcl/hcl_test.go @@ -0,0 +1,248 @@ +package hcl + +import ( + "strings" + "testing" + + "github.com/goccy/go-yaml" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestToYAML_Pirate(t *testing.T) { + t.Parallel() + + src := []byte(` +agent "root" { + description = "An agent that talks like a pirate" + instruction = "Always answer by talking like a pirate." + model = "auto" + + welcome_message = <<-EOT + Ahoy! I be yer pirate guide, ready to set sail on the seas o' knowledge! + EOT +} +`) + + out, err := ToYAML(src, "pirate.hcl") + require.NoError(t, err) + + got := string(out) + assert.Contains(t, got, "agents:") + assert.Contains(t, got, " root:") + assert.Contains(t, got, " description: An agent that talks like a pirate") + assert.Contains(t, got, " instruction: Always answer by talking like a pirate.") + assert.Contains(t, got, " model: auto") + assert.Contains(t, got, " welcome_message: ") + assert.Contains(t, got, "Ahoy!") +} + +func TestToYAML_LabeledBlocksBecomeKeyedMaps(t *testing.T) { + t.Parallel() + + src := []byte(` +model "claude" { + provider = "anthropic" + model = "claude-opus-4-6" +} + +model "haiku" { + provider = "anthropic" + model = "claude-haiku-4-5" +} + +agent "root" { + model = "claude" + instruction = "Test" +} +`) + + m, err := ToMap(src, "test.hcl") + require.NoError(t, err) + + assert.NotNil(t, m["models"]) + assert.NotNil(t, m["agents"]) +} + +func TestToYAML_ToolsetLabelBecomesType(t *testing.T) { + t.Parallel() + + src := []byte(` +agent "root" { + instruction = "x" + model = "auto" + + toolset "filesystem" {} + toolset "shell" {} + toolset "mcp" { + command = "gopls" + args = ["mcp"] + } +} +`) + + out, err := ToYAML(src, "test.hcl") + require.NoError(t, err) + got := string(out) + + assert.Contains(t, got, "toolsets:") + assert.Contains(t, got, "type: filesystem") + assert.Contains(t, got, "type: shell") + assert.Contains(t, got, "type: mcp") + assert.Contains(t, got, "command: gopls") + assert.Contains(t, got, "- mcp") +} + +func TestToYAML_PreservesAgentDeclarationOrder(t *testing.T) { + t.Parallel() + + src := []byte(` +agent "root" { + instruction = "x" + model = "auto" +} +agent "planner" { + instruction = "y" + model = "auto" +} +agent "reviewer" { + instruction = "z" + model = "auto" +} +`) + + out, err := ToYAML(src, "test.hcl") + require.NoError(t, err) + got := string(out) + + rootIdx := strings.Index(got, "root:") + plannerIdx := strings.Index(got, "planner:") + reviewerIdx := strings.Index(got, "reviewer:") + + require.NotEqual(t, -1, rootIdx) + require.NotEqual(t, -1, plannerIdx) + require.NotEqual(t, -1, reviewerIdx) + assert.Less(t, rootIdx, plannerIdx, "root should come before planner") + assert.Less(t, plannerIdx, reviewerIdx, "planner should come before reviewer") +} + +func TestToYAML_CommandShortcutOnlySupportedAsBlock(t *testing.T) { + t.Parallel() + + src := []byte(` +agent "root" { + instruction = "x" + model = "auto" + + command "fix" { + instruction = "Fix the lint" + } + command "init" { + description = "Initialize" + instruction = "Set things up" + } +} +`) + + out, err := ToYAML(src, "test.hcl") + require.NoError(t, err) + got := string(out) + assert.Contains(t, got, "commands:") + assert.Contains(t, got, "fix:") + assert.Contains(t, got, "init:") + assert.Contains(t, got, "Fix the lint") +} + +func TestToYAML_PermissionsSingleton(t *testing.T) { + t.Parallel() + + src := []byte(` +agent "root" { + instruction = "x" + model = "auto" +} + +permissions { + allow = ["a", "b"] + deny = ["c"] +} +`) + + out, err := ToYAML(src, "test.hcl") + require.NoError(t, err) + got := string(out) + assert.Contains(t, got, "permissions:") + assert.Contains(t, got, " allow:") + assert.Contains(t, got, " - a") +} + +func TestToYAML_DuplicateLabeledBlock(t *testing.T) { + t.Parallel() + + src := []byte(` +agent "root" { + instruction = "x" + model = "auto" +} +agent "root" { + instruction = "y" + model = "auto" +} +`) + + _, err := ToYAML(src, "test.hcl") + require.Error(t, err) + assert.Contains(t, err.Error(), "Duplicate") +} + +func TestToYAML_EscapedInterpolation(t *testing.T) { + t.Parallel() + + src := []byte(` +agent "root" { + instruction = "$${shell()}" + model = "auto" +} +`) + + m, err := ToMap(src, "test.hcl") + require.NoError(t, err) + + agents := m["agents"] + require.NotNil(t, agents) + + // Walk through the yaml.MapSlice the converter produces to find the + // instruction value we wrote. + items, ok := agents.(yaml.MapSlice) + require.True(t, ok, "agents should be a yaml.MapSlice, got %T", agents) + require.Len(t, items, 1) + root, ok := items[0].Value.(map[string]any) + require.True(t, ok) + assert.Equal(t, "${shell()}", root["instruction"], "escaped $${...} should decode to literal ${...}") +} + +func TestLooksLikeHCL(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + data string + want bool + }{ + {"empty", "", false}, + {"yaml", "agents:\n root:\n instruction: hi\n", false}, + {"yaml with comment", "# a comment\nagents:\n root: {}\n", false}, + {"yaml quoted key", "agent \"root\":\n instruction: hi\n", false}, + {"hcl with shebang", "#!/usr/bin/env docker agent run\n\nagent \"root\" {\n model = \"auto\"\n}\n", true}, + {"hcl permissions block", "permissions {\n allow = [\"a\"]\n}\n", true}, + {"hcl with line comment", "// a comment\nagent \"root\" {}\n", true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := LooksLikeHCL([]byte(tc.data)) + assert.Equal(t, tc.want, got) + }) + } +} diff --git a/pkg/config/resolve.go b/pkg/config/resolve.go index f2ac830bc..8c4038dc9 100644 --- a/pkg/config/resolve.go +++ b/pkg/config/resolve.go @@ -127,7 +127,7 @@ func resolveDirectory(dirPath string, envProvider environment.Provider) (Sources continue } ext := strings.ToLower(filepath.Ext(entry.Name())) - if ext != ".yaml" && ext != ".yml" { + if ext != ".yaml" && ext != ".yml" && ext != ".hcl" { continue } a := filepath.Join(dirPath, entry.Name()) @@ -200,8 +200,8 @@ func IsOCIReference(input string) bool { // isLocalFile checks if the input is a local file func isLocalFile(input string) bool { ext := strings.ToLower(filepath.Ext(input)) - // Check for YAML file extensions or file descriptors - if ext == ".yaml" || ext == ".yml" || strings.HasPrefix(input, "/dev/fd/") { + // Check for known config file extensions or file descriptors + if ext == ".yaml" || ext == ".yml" || ext == ".hcl" || strings.HasPrefix(input, "/dev/fd/") { return true } // Check if it exists as a file on disk diff --git a/pkg/config/schema_test.go b/pkg/config/schema_test.go index 3ae8f3e27..44f8e95b1 100644 --- a/pkg/config/schema_test.go +++ b/pkg/config/schema_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "maps" "os" + "path/filepath" "reflect" "strings" "testing" @@ -13,6 +14,7 @@ import ( "github.com/stretchr/testify/require" "github.com/xeipuuv/gojsonschema" + hclconv "github.com/docker/docker-agent/pkg/config/hcl" "github.com/docker/docker-agent/pkg/config/latest" ) @@ -33,6 +35,14 @@ func TestJsonSchemaWorksForExamples(t *testing.T) { buf, err := os.ReadFile(file) require.NoError(t, err) + // HCL examples are converted to their YAML equivalent before + // being validated against the JSON schema, since the schema + // describes the YAML/JSON representation only. + if filepath.Ext(file) == ".hcl" { + buf, err = hclconv.ToYAML(buf, file) + require.NoError(t, err) + } + var rawJSON any err = yaml.Unmarshal(buf, &rawJSON) require.NoError(t, err) @@ -138,6 +148,31 @@ func TestSchemaMatchesGoTypes(t *testing.T) { } } +// TestHCLBlockRulesCoverSchemaMaps verifies that every top-level property in +// agent-schema.json shaped like a keyed map (an object with +// additionalProperties) has a matching modeMapByLabel rule registered in the +// HCL converter. Without this, adding a new top-level section to the schema +// would silently produce awkward HCL ergonomics (e.g. tool "x" {} mapping to +// tool.x instead of tools.x). +func TestHCLBlockRulesCoverSchemaMaps(t *testing.T) { + t.Parallel() + + data, err := os.ReadFile(schemaFile) + require.NoError(t, err) + + var root jsonSchema + require.NoError(t, json.Unmarshal(data, &root)) + + covered := hclconv.LabelKeyedMapOutKeys() + for name, prop := range root.Properties { + if prop.AdditionalProperties == nil { + continue + } + assert.True(t, covered[name], + "top-level schema map %q has no matching modeMapByLabel rule in pkg/config/hcl", name) + } +} + // jsonSchema mirrors the subset of JSON Schema we need for comparison. type jsonSchema struct { Properties map[string]jsonSchema `json:"properties,omitempty"` diff --git a/pkg/sandbox/args.go b/pkg/sandbox/args.go index baf1794c9..d22484a9f 100644 --- a/pkg/sandbox/args.go +++ b/pkg/sandbox/args.go @@ -52,10 +52,10 @@ func ExtraWorkspace(wd, agentRef string) string { } // looksLikeLocalFile reports whether path looks like a local agent file -// (has a YAML extension or exists on disk). +// (has a known config extension or exists on disk). func looksLikeLocalFile(path string) bool { ext := strings.ToLower(filepath.Ext(path)) - if ext == ".yaml" || ext == ".yml" { + if ext == ".yaml" || ext == ".yml" || ext == ".hcl" { return true } info, err := os.Stat(path)