refactor: agents and skills move to a client-neutral source that renders per client
`.claude/agents/` was the source of truth, which made every role Claude-Code shaped. Adding a second client meant rewriting each role in that client's syntax and maintaining both copies — the drift this scaffold exists to prevent, one layer up. Roles and skills now live under `.agents/` and render into each registered client. `.claude/agents/`, `.claude/skills/` and `.codex/agents/` are generated; `scripts/sync-agent-integrations.py --check` fails on drift and belongs in CI. The role metadata is portable rather than vendor-named: `reasoning_tier` (deep/balanced/fast/vision), `capabilities`, `mutation`, `invocation`, and an optional `preload_skills`. A client manifest maps those to native syntax and must declare what it cannot express — `codex.yaml` declares `tier_policy: unsupported` and its adapters say so in the file, rather than the tier silently evaporating and leaving the repository to believe it was enforced. The port is behaviour-preserving where it should be and a fix where it should not. Every instruction body is byte-identical — the whole diff to `.claude/agents/` is 18 added lines and zero deletions. What changed is frontmatter that was missing: - four agents (`code-reviewer`, `tdd-guardian`, `dependency-audit`, `pr-creator`) declared no `tools:` and therefore inherited the ENTIRE tool pool, so three review-only agents could edit and write the code they were reviewing. All eight now declare capabilities explicitly. - the six read-only roles gain a non-editing permission mode, so the constraint is enforced by the client rather than by the prompt asking nicely. - `mutation` is now explicit, which records the two roles that genuinely need to write: `pr-creator` (external-write — it pushes a branch and opens a PR) and `dependency-audit` (workspace-write — package managers rewrite lockfiles). `pr-creator` keeps `shell` because opening a PR needs it, but it is now the only agent here with a write mutation and a declared reason for it, instead of one of four with unlimited access by omission.
This commit is contained in:
@@ -0,0 +1,686 @@
|
||||
---
|
||||
name: go-development
|
||||
description: Go development patterns with testing, error handling, and idiomatic practices. Use when writing Go code.
|
||||
---
|
||||
|
||||
# Go Development Skill
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
project/
|
||||
├── cmd/
|
||||
│ └── server/
|
||||
│ └── main.go # Application entry point
|
||||
├── internal/
|
||||
│ ├── domain/ # Business logic
|
||||
│ │ ├── user.go
|
||||
│ │ └── user_test.go
|
||||
│ ├── repository/ # Data access
|
||||
│ │ ├── user_repo.go
|
||||
│ │ └── user_repo_test.go
|
||||
│ ├── service/ # Application services
|
||||
│ │ ├── user_service.go
|
||||
│ │ └── user_service_test.go
|
||||
│ └── handler/ # HTTP handlers
|
||||
│ ├── user_handler.go
|
||||
│ └── user_handler_test.go
|
||||
├── pkg/ # Public packages
|
||||
│ └── validation/
|
||||
├── go.mod
|
||||
├── go.sum
|
||||
└── Makefile
|
||||
```
|
||||
|
||||
## Testing Patterns
|
||||
|
||||
### Table-Driven Tests
|
||||
```go
|
||||
package user
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateEmail(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
email string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid email",
|
||||
email: "[email protected]",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing @ symbol",
|
||||
email: "userexample.com",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty email",
|
||||
email: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "missing domain",
|
||||
email: "user@",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateEmail(tt.email)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ValidateEmail(%q) error = %v, wantErr %v",
|
||||
tt.email, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Test Fixtures with Helper Functions
|
||||
```go
|
||||
package service_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"myapp/internal/domain"
|
||||
)
|
||||
|
||||
// Test helper - creates valid user with optional overrides
|
||||
func newTestUser(t *testing.T, opts ...func(*domain.User)) *domain.User {
|
||||
t.Helper()
|
||||
|
||||
user := &domain.User{
|
||||
ID: "user-123",
|
||||
Email: "[email protected]",
|
||||
Name: "Test User",
|
||||
Role: domain.RoleUser,
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(user)
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
// Option functions for customization
|
||||
func withEmail(email string) func(*domain.User) {
|
||||
return func(u *domain.User) {
|
||||
u.Email = email
|
||||
}
|
||||
}
|
||||
|
||||
func withRole(role domain.Role) func(*domain.User) {
|
||||
return func(u *domain.User) {
|
||||
u.Role = role
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserService_CreateUser(t *testing.T) {
|
||||
t.Run("creates user with valid data", func(t *testing.T) {
|
||||
user := newTestUser(t)
|
||||
|
||||
svc := NewUserService(mockRepo)
|
||||
result, err := svc.CreateUser(user)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if result.ID == "" {
|
||||
t.Error("expected user to have an ID")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects user with invalid email", func(t *testing.T) {
|
||||
user := newTestUser(t, withEmail("invalid-email"))
|
||||
|
||||
svc := NewUserService(mockRepo)
|
||||
_, err := svc.CreateUser(user)
|
||||
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid email")
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Mocking with Interfaces
|
||||
```go
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"myapp/internal/domain"
|
||||
)
|
||||
|
||||
// Repository interface for dependency injection
|
||||
type UserRepository interface {
|
||||
GetByID(ctx context.Context, id string) (*domain.User, error)
|
||||
Create(ctx context.Context, user *domain.User) error
|
||||
Update(ctx context.Context, user *domain.User) error
|
||||
}
|
||||
|
||||
// Service with injected dependencies
|
||||
type UserService struct {
|
||||
repo UserRepository
|
||||
}
|
||||
|
||||
func NewUserService(repo UserRepository) *UserService {
|
||||
return &UserService{repo: repo}
|
||||
}
|
||||
|
||||
// Test file
|
||||
package service_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"myapp/internal/domain"
|
||||
"myapp/internal/service"
|
||||
)
|
||||
|
||||
// Mock implementation
|
||||
type mockUserRepo struct {
|
||||
users map[string]*domain.User
|
||||
err error
|
||||
}
|
||||
|
||||
func newMockUserRepo() *mockUserRepo {
|
||||
return &mockUserRepo{
|
||||
users: make(map[string]*domain.User),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) GetByID(ctx context.Context, id string) (*domain.User, error) {
|
||||
if m.err != nil {
|
||||
return nil, m.err
|
||||
}
|
||||
user, ok := m.users[id]
|
||||
if !ok {
|
||||
return nil, errors.New("user not found")
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) Create(ctx context.Context, user *domain.User) error {
|
||||
if m.err != nil {
|
||||
return m.err
|
||||
}
|
||||
m.users[user.ID] = user
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) Update(ctx context.Context, user *domain.User) error {
|
||||
if m.err != nil {
|
||||
return m.err
|
||||
}
|
||||
m.users[user.ID] = user
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Custom Error Types
|
||||
```go
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Sentinel errors for comparison
|
||||
var (
|
||||
ErrNotFound = errors.New("not found")
|
||||
ErrUnauthorized = errors.New("unauthorized")
|
||||
ErrInvalidInput = errors.New("invalid input")
|
||||
)
|
||||
|
||||
// Structured error with context
|
||||
type ValidationError struct {
|
||||
Field string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *ValidationError) Error() string {
|
||||
return fmt.Sprintf("validation error on %s: %s", e.Field, e.Message)
|
||||
}
|
||||
|
||||
// Error wrapping
|
||||
func GetUser(ctx context.Context, id string) (*User, error) {
|
||||
user, err := repo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting user %s: %w", id, err)
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// Error checking
|
||||
func handleError(err error) {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
// Handle not found
|
||||
}
|
||||
|
||||
var validationErr *ValidationError
|
||||
if errors.As(err, &validationErr) {
|
||||
// Handle validation error
|
||||
fmt.Printf("Field %s: %s\n", validationErr.Field, validationErr.Message)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Result Pattern (Optional)
|
||||
```go
|
||||
package result
|
||||
|
||||
// Result type for explicit error handling
|
||||
type Result[T any] struct {
|
||||
value T
|
||||
err error
|
||||
}
|
||||
|
||||
func Ok[T any](value T) Result[T] {
|
||||
return Result[T]{value: value}
|
||||
}
|
||||
|
||||
func Err[T any](err error) Result[T] {
|
||||
return Result[T]{err: err}
|
||||
}
|
||||
|
||||
func (r Result[T]) IsOk() bool {
|
||||
return r.err == nil
|
||||
}
|
||||
|
||||
func (r Result[T]) IsErr() bool {
|
||||
return r.err != nil
|
||||
}
|
||||
|
||||
func (r Result[T]) Unwrap() (T, error) {
|
||||
return r.value, r.err
|
||||
}
|
||||
|
||||
func (r Result[T]) UnwrapOr(defaultValue T) T {
|
||||
if r.err != nil {
|
||||
return defaultValue
|
||||
}
|
||||
return r.value
|
||||
}
|
||||
|
||||
// Usage
|
||||
func ProcessPayment(amount float64) Result[*Receipt] {
|
||||
if amount <= 0 {
|
||||
return Err[*Receipt](ErrInvalidInput)
|
||||
}
|
||||
|
||||
receipt := &Receipt{Amount: amount}
|
||||
return Ok(receipt)
|
||||
}
|
||||
```
|
||||
|
||||
## HTTP Handlers
|
||||
|
||||
### Chi Router Pattern
|
||||
```go
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"myapp/internal/service"
|
||||
)
|
||||
|
||||
type UserHandler struct {
|
||||
userService *service.UserService
|
||||
}
|
||||
|
||||
func NewUserHandler(userService *service.UserService) *UserHandler {
|
||||
return &UserHandler{userService: userService}
|
||||
}
|
||||
|
||||
func (h *UserHandler) Routes() chi.Router {
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.Recoverer)
|
||||
|
||||
r.Get("/", h.ListUsers)
|
||||
r.Post("/", h.CreateUser)
|
||||
r.Get("/{userID}", h.GetUser)
|
||||
r.Put("/{userID}", h.UpdateUser)
|
||||
r.Delete("/{userID}", h.DeleteUser)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
|
||||
userID := chi.URLParam(r, "userID")
|
||||
|
||||
user, err := h.userService.GetUser(r.Context(), userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrNotFound) {
|
||||
http.Error(w, "User not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, user)
|
||||
}
|
||||
|
||||
func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
var req CreateUserRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := req.Validate(); err != nil {
|
||||
respondJSON(w, http.StatusBadRequest, map[string]string{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.userService.CreateUser(r.Context(), req.ToUser())
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to create user", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusCreated, user)
|
||||
}
|
||||
|
||||
func respondJSON(w http.ResponseWriter, status int, data any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
```
|
||||
|
||||
### Request Validation
|
||||
```go
|
||||
package handler
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
)
|
||||
|
||||
var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
|
||||
|
||||
type CreateUserRequest struct {
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (r *CreateUserRequest) Validate() error {
|
||||
if r.Email == "" {
|
||||
return &domain.ValidationError{
|
||||
Field: "email",
|
||||
Message: "email is required",
|
||||
}
|
||||
}
|
||||
|
||||
if !emailRegex.MatchString(r.Email) {
|
||||
return &domain.ValidationError{
|
||||
Field: "email",
|
||||
Message: "invalid email format",
|
||||
}
|
||||
}
|
||||
|
||||
if r.Name == "" {
|
||||
return &domain.ValidationError{
|
||||
Field: "name",
|
||||
Message: "name is required",
|
||||
}
|
||||
}
|
||||
|
||||
if len(r.Name) > 100 {
|
||||
return &domain.ValidationError{
|
||||
Field: "name",
|
||||
Message: "name must be 100 characters or less",
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *CreateUserRequest) ToUser() *domain.User {
|
||||
return &domain.User{
|
||||
Email: r.Email,
|
||||
Name: r.Name,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Context Usage
|
||||
|
||||
```go
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Context with timeout
|
||||
func (s *UserService) ProcessOrder(ctx context.Context, orderID string) error {
|
||||
// Create a timeout context
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Check context before expensive operations
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
// Process order...
|
||||
return s.repo.UpdateOrder(ctx, orderID)
|
||||
}
|
||||
|
||||
// Context values for request-scoped data
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
userIDKey contextKey = "userID"
|
||||
requestIDKey contextKey = "requestID"
|
||||
)
|
||||
|
||||
func WithUserID(ctx context.Context, userID string) context.Context {
|
||||
return context.WithValue(ctx, userIDKey, userID)
|
||||
}
|
||||
|
||||
func UserIDFromContext(ctx context.Context) (string, bool) {
|
||||
userID, ok := ctx.Value(userIDKey).(string)
|
||||
return userID, ok
|
||||
}
|
||||
```
|
||||
|
||||
## Concurrency Patterns
|
||||
|
||||
### Worker Pool
|
||||
```go
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Job func(ctx context.Context) error
|
||||
|
||||
type Pool struct {
|
||||
workers int
|
||||
jobs chan Job
|
||||
results chan error
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
func NewPool(workers int) *Pool {
|
||||
return &Pool{
|
||||
workers: workers,
|
||||
jobs: make(chan Job, workers*2),
|
||||
results: make(chan error, workers*2),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Pool) Start(ctx context.Context) {
|
||||
for i := 0; i < p.workers; i++ {
|
||||
p.wg.Add(1)
|
||||
go p.worker(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Pool) worker(ctx context.Context) {
|
||||
defer p.wg.Done()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case job, ok := <-p.jobs:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := job(ctx); err != nil {
|
||||
p.results <- err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Pool) Submit(job Job) {
|
||||
p.jobs <- job
|
||||
}
|
||||
|
||||
func (p *Pool) Close() {
|
||||
close(p.jobs)
|
||||
p.wg.Wait()
|
||||
close(p.results)
|
||||
}
|
||||
```
|
||||
|
||||
### Error Group
|
||||
```go
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
func (s *OrderService) ProcessBatch(ctx context.Context, orderIDs []string) error {
|
||||
g, ctx := errgroup.WithContext(ctx)
|
||||
|
||||
// Limit concurrency
|
||||
g.SetLimit(10)
|
||||
|
||||
for _, orderID := range orderIDs {
|
||||
orderID := orderID // Capture for goroutine
|
||||
|
||||
g.Go(func() error {
|
||||
return s.ProcessOrder(ctx, orderID)
|
||||
})
|
||||
}
|
||||
|
||||
return g.Wait()
|
||||
}
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Testing
|
||||
go test ./... # Run all tests
|
||||
go test -v ./... # Verbose output
|
||||
go test -race ./... # Race detection
|
||||
go test -cover ./... # Coverage summary
|
||||
go test -coverprofile=coverage.out ./... # Coverage file
|
||||
go tool cover -html=coverage.out # HTML coverage report
|
||||
|
||||
# Linting
|
||||
go vet ./... # Built-in checks
|
||||
golangci-lint run # Comprehensive linting
|
||||
|
||||
# Building
|
||||
go build -o bin/server ./cmd/server # Build binary
|
||||
go build -ldflags="-s -w" -o bin/server ./cmd/server # Smaller binary
|
||||
|
||||
# Dependencies
|
||||
go mod tidy # Clean up dependencies
|
||||
go mod verify # Verify dependencies
|
||||
|
||||
# Code generation
|
||||
go generate ./... # Run generate directives
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
```go
|
||||
// ❌ BAD - Returning nil error with nil value
|
||||
func GetUser(id string) (*User, error) {
|
||||
user := db.Find(id)
|
||||
return user, nil // user might be nil!
|
||||
}
|
||||
|
||||
// ✅ GOOD - Explicit error for not found
|
||||
func GetUser(id string) (*User, error) {
|
||||
user := db.Find(id)
|
||||
if user == nil {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// ❌ BAD - Ignoring errors
|
||||
result, _ := SomeFunction()
|
||||
|
||||
// ✅ GOOD - Handle or propagate errors
|
||||
result, err := SomeFunction()
|
||||
if err != nil {
|
||||
return fmt.Errorf("calling SomeFunction: %w", err)
|
||||
}
|
||||
|
||||
// ❌ BAD - Naked goroutine without error handling
|
||||
go processItem(item)
|
||||
|
||||
// ✅ GOOD - Error handling with channels or error groups
|
||||
g, ctx := errgroup.WithContext(ctx)
|
||||
g.Go(func() error {
|
||||
return processItem(ctx, item)
|
||||
})
|
||||
if err := g.Wait(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// ❌ BAD - Mutex embedded in struct
|
||||
type Service struct {
|
||||
sync.Mutex
|
||||
data map[string]string
|
||||
}
|
||||
|
||||
// ✅ GOOD - Private mutex
|
||||
type Service struct {
|
||||
mu sync.Mutex
|
||||
data map[string]string
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user