# AI Agent Development Guide

kubectl-inspired CLI for Dynatrace (dashboards, workflows, SLOs, etc). Go + Cobra framework.

**Pattern**: `dtctl <verb> <resource> [flags]`

## Quick Start

1. Read [docs/dev/API_DESIGN.md](docs/dev/API_DESIGN.md) Design Principles (lines 17-110)
2. Check [IMPLEMENTATION_STATUS.md](docs/dev/IMPLEMENTATION_STATUS.md) for feature matrix
3. Copy patterns from `pkg/resources/slo/` or `pkg/resources/workflow/`

## Architecture

```text
cmd/          # Cobra commands (get, describe, create, delete, apply, exec, ctx, doctor, commands)
pkg/
  ├── client/    # HTTP client (auth, retry, rate limiting, pagination)
  ├── config/    # Multi-context config (~/.config/dtctl/config, keyring tokens)
  ├── resources/ # Resource handlers — thin CLI wrappers that delegate to sdk/api/
  ├── output/    # Formatters (table, JSON, YAML, charts, agent envelope, color control)
  └── exec/      # DQL query execution
sdk/            # Separate Go module (github.com/dynatrace-oss/dtctl/sdk)
  ├── api/         # Typed API wrappers (one package per Dynatrace API surface)
  ├── httpclient/  # HTTP client, response helpers, pagination, typed errors
  ├── auth/        # Token type detection
  ├── urls/        # Environment URL validation/normalization
  ├── credstore/   # OS keyring and file-based credential storage
  └── agentmode/   # AI agent environment detection
```

**SDK delegation pattern**: CLI resource handlers in `pkg/resources/` import types from `sdk/api/` (often via type aliases) and delegate HTTP calls to SDK functions. The SDK contains **no file I/O, no CLI concerns, no display logic**. File reading (e.g., `ReadFileOrStdin`, `ParseInputFromFile`) stays in `pkg/resources/`.

## Agent Output Mode

dtctl supports `--agent` / `-A` to wrap all output in a structured JSON envelope for AI agents:

```json
{"ok": true, "result": [...], "context": {"verb": "get", "resource": "workflow", "suggestions": [...]}}
```

- **Auto-detected** in AI agent environments (opt out with `--no-agent`)
- Implies `--plain` (no colors, no interactive prompts)
- Errors are also structured: `{"ok": false, "error": {"code": "not_found", "message": "..."}}`
- Implementation: `pkg/output/agent.go` (`AgentPrinter`, `Response`, `PrintError`)
- Per-command context enrichment via `enrichAgent()` helper in `cmd/root.go`
- **Command catalog**: `dtctl commands --brief -o json` provides a machine-readable listing of all available commands, flags, and resource types — ideal for agent bootstrap

## Color Control

ANSI color output follows the [no-color.org](https://no-color.org/) standard:

```
Color enabled = NOT (NO_COLOR is set) AND NOT (--plain flag) AND (stdout is a TTY OR FORCE_COLOR=1)
```

- **`NO_COLOR`** env var: any non-empty value disables color
- **`FORCE_COLOR=1`** env var: overrides TTY detection to force color on (e.g., in CI with color-capable terminals)
- **`--plain`** flag: disables color (and interactive prompts)
- **Non-TTY** (piped output): color is disabled automatically
- Implementation: `pkg/output/styles.go` (`ColorEnabled()`, `Colorize()`, `ColorCode()`)
- Result is cached with `sync.Once`; use `ResetColorCache()` in tests

## Adding a Supported Agent

When adding a new AI agent to the skills system, update **all** of the following:

1. **Code**: `pkg/aidetect/detect.go` (env var), `pkg/skills/installer.go` (agent entry + format), `cmd/skills.go` (help text, `--for` flag)
2. **Tests**: `pkg/aidetect/detect_test.go`, `pkg/skills/installer_test.go`, `cmd/skills_test.go`
3. **Docs**: `README.md`, `docs/QUICK_START.md` (agent detection list), `docs/dev/API_DESIGN.md` (agent detection list), `docs/dev/IMPLEMENTATION_STATUS.md` (skills feature line)

> Releases are automated by release-please from conventional commits — do not hand-edit a changelog. Just make sure the commit/PR title is a proper conventional commit (e.g. `feat: detect <agent> sessions`).

## Adding a Resource

1. **SDK layer** (`sdk/api/<name>/`): Create typed API wrapper with CRUD functions using `httpclient.Client`. No file I/O, no display logic.
2. **CLI layer** (`pkg/resources/<name>/`): Create resource handler that delegates to SDK. Handle file reading, display fields, name resolution here.
3. **Commands**: Add to `cmd/get.go`, `cmd/describe.go`, etc.
4. Register in resolver
5. Add tests: `sdk/api/<name>/*_test.go` (SDK unit tests) + `test/e2e/<name>_test.go` (E2E)

**SDK handler signature** (in `sdk/api/<name>/`):
```go
func NewHandler(client *httpclient.Client) *Handler
func (h *Handler) Get(id string) (*Resource, error)
func (h *Handler) List(opts ListOptions) ([]Resource, error)
```

**CLI handler** (in `pkg/resources/<name>/`): imports SDK types (often via type alias) and wraps with file I/O, display fields, etc.

## Design Principles

1. Verb-noun pattern: `dtctl <verb> <resource>`
2. No custom query flags - use DQL passthrough
3. YAML input, multiple outputs (table/JSON/YAML/charts)
4. Interactive name resolution (disable with `--plain`)
5. Idempotent apply (POST if new, PUT if exists)

## Common Tasks

| Task | Files | Pattern |
|------|-------|---------|
| Add GET | `cmd/get.go`, `pkg/resources/<name>/` | Copy `pkg/resources/slo/` |
| Add EXEC | `cmd/exec.go`, `pkg/exec/<type>.go` | See `pkg/exec/workflow.go` polling |
| Add DQL template | `pkg/exec/dql.go`, `pkg/util/template/` | Use `text/template`, `--set` flag |
| Fix output | `pkg/output/<format>.go` | Test: `dtctl get <resource> -o <format>` |

**Tests**: `make test` or `go test ./...` • E2E: `test/e2e/` • Integration: `test/integration/`

## Golden (Snapshot) Tests

Output formatting is covered by golden-file tests that capture the exact output of every printer for every resource type. These tests **must be updated** whenever you change output formatting, add/remove struct fields, or add a new resource.

### When to run

- **After modifying** anything in `pkg/output/` or resource structs in `pkg/resources/*/` — run golden tests to check for regressions
- **After adding a new resource** — add test cases to `pkg/output/golden_test.go` using the real production struct

### Commands

```bash
# Run golden tests (will FAIL if output changed — this is intentional)
go test ./pkg/output/ -run TestGolden

# Update golden files after intentional changes (review the diff!)
make test-update-golden
# or: go test ./pkg/output/ -run TestGolden -update

# Run full suite (golden tests are included automatically)
make test
```

### Key files

| File | Purpose |
|------|---------|
| `pkg/output/golden_test.go` | Test cases — uses **real production structs** from `pkg/resources/*` |
| `pkg/output/testdata/golden/` | Golden files across get/, describe/, query/, errors/, empty/ |
| `cmd/testutil/golden.go` | `AssertGolden` / `AssertGoldenStripped` helpers, `-update` flag |
| `pkg/output/testdata/README.md` | Workflow documentation |

### Important rules

1. **Use real structs** — Import from `pkg/resources/*`, never create test-only duplicates. This ensures golden files automatically catch when fields are added/removed.
2. **Review diffs** — After `make test-update-golden`, always `git diff` the golden files to verify changes are intentional.
3. **Privacy** — All test data must be synthetic. No real names, env IDs, tokens, or emails. Use `@example.invalid` for emails (RFC 2606).
4. **CI** — Golden tests run automatically in GitHub Actions via `go test ./...` on every PR. No separate workflow needed.

## 🚨 **CRITICAL: Safety Checks** 🚨

**ALL mutating commands MUST include safety checks.** Non-negotiable for security.

### Required for These Commands

✅ `create`, `edit`, `apply`, `delete`, `update` (all modify resources)  
❌ `get`, `describe`, `query`, `logs`, `history`, `ctx`, `doctor`, `commands` (read-only)

### Pattern (after `LoadConfig()`, before client ops)

```go
cfg, err := LoadConfig()
if err != nil { return err }

// Safety check - REQUIRED
checker, err := NewSafetyChecker(cfg)
if err != nil { return err }
if err := checker.CheckError(safety.OperationXXX, safety.OwnershipUnknown); err != nil {
    return err
}

c, err := NewClientFromConfig(cfg)
// ... proceed
```

**Operation types**: `OperationCreate`, `OperationUpdate`, `OperationDelete`, `OperationDeleteBucket`

**Skip in dry-run**:
```go
if !dryRun {
    checker, err := NewSafetyChecker(cfg)
    // ... safety check
}
```

**Verification**:
- [ ] Import `github.com/dynatrace-oss/dtctl/pkg/safety`
- [ ] Check after `LoadConfig()`, before operations
- [ ] Correct operation type
- [ ] Test with `readonly` context (should block)

**Examples**: [cmd/edit.go](cmd/edit.go), [cmd/create.go](cmd/create.go), [cmd/apply.go](cmd/apply.go)

## Privacy

Never put customer names, employee names, usernames, or specific Dynatrace environment identifiers into the codebase, GitHub issues, PRs, release notes, or commits.

## Common Pitfalls

❌ **Don't** add query filters as CLI flags (e.g., `--filter-status`)  
✅ **Do** use DQL: `dtctl query 'fetch logs | filter status == "ERROR"'`

❌ **Don't** assume resource names are unique  
✅ **Do** implement disambiguation or require ID

❌ **Don't** print to stdout in library code  
✅ **Do** return data, let cmd/ handle output

❌ **Don't** skip safety checks on mutating commands  
✅ **Do** add safety checks to ALL create/edit/apply/delete/update commands

❌ **Don't** send `page-size` together with `next-page-key`/`page-key` on paginated requests  
❌ **Don't** drop filter/search params on subsequent pages — page tokens do NOT always preserve them  
❌ **Don't** send `schemaIds`/`scopes` with `nextPageKey` on Settings API — the page token embeds everything  
✅ **Do** use the pagination pattern below: only `page-size` goes in the if/else; filters go outside (except Settings API)

## Pagination Pattern (CRITICAL)

Dynatrace APIs **reject** requests that combine `page-size` with `next-page-key`/`page-key` (HTTP 400). However, **page tokens do NOT always preserve filter parameters** — the Document API is a confirmed example where the `filter` param is dropped on page 2+ if not resent. To be safe, **always resend filter/search params on every page request**, and only exclude `page-size` when a page key is present.

**Exception — Document API**: The Document API (`/platform/document/v1/documents`) does **not** reject `page-size` + `page-key`. It also does **not** embed the page size in the page token (defaulting to 20/page if `page-size` is omitted). For Document API endpoints, send `page-size` on every request alongside `page-key`. See `pkg/resources/document/document.go` for the reference implementation.

**Exception — Settings API**: The Settings API (`/platform/classic/environment-api/v2/settings/objects`) rejects ALL other query params when `nextPageKey` is present — not just `pageSize`, but also `schemaIds`, `scopes`, and `fields` (HTTP 400: "must not be used in combination with nextPageKey query parameter"). The page token embeds everything. For Settings API endpoints, send ONLY `nextPageKey` on page 2+. See `pkg/resources/settings/settings.go` for the reference implementation.

### Correct pattern (default — most APIs)

```go
for {
    req := h.client.HTTP().R().SetResult(&result)

    if nextPageKey != "" {
        req.SetQueryParam("page-key", nextPageKey)
    } else if chunkSize > 0 {
        req.SetQueryParam("page-size", fmt.Sprintf("%d", chunkSize))
    }
    // Always send filter, regardless of pagination
    if filter != "" {
        req.SetQueryParam("filter", filter)
    }

    resp, err := req.Get("/platform/...")
    // ... handle response, break if no more pages
}
```

### Correct pattern (Document API — accepts page-size with page-key)

```go
for {
    req := h.client.HTTP().R().SetResult(&result)

    if nextPageKey != "" {
        req.SetQueryParam("page-key", nextPageKey)
    }
    // Document API: send page-size and filter on EVERY request
    if chunkSize > 0 {
        req.SetQueryParam("page-size", fmt.Sprintf("%d", chunkSize))
    }
    if filter != "" {
        req.SetQueryParam("filter", filter)
    }

    resp, err := req.Get("/platform/document/v1/documents")
    // ... handle response, break if no more pages
}
```

### Correct pattern (Settings API — page token embeds ALL params)

```go
for {
    req := h.client.HTTP().R().SetResult(&result)

    // Settings API rejects ALL other params when nextPageKey is present
    // (pageSize, schemaIds, scopes are all embedded in the page token).
    if nextPageKey != "" {
        req.SetQueryParam("nextPageKey", nextPageKey)
    } else {
        if chunkSize > 0 {
            req.SetQueryParam("pageSize", fmt.Sprintf("%d", chunkSize))
        }
        if schemaID != "" {
            req.SetQueryParam("schemaIds", schemaID)
        }
        if scope != "" {
            req.SetQueryParam("scopes", scope)
        }
    }

    resp, err := req.Get("/platform/classic/environment-api/v2/settings/objects")
    // ... handle response, break if no more pages
}
```

### Wrong pattern 1: sending page-size with page-key (causes HTTP 400 on non-Document APIs)

```go
for {
    req := h.client.HTTP().R().SetResult(&result)

    // BUG: page-size is sent on EVERY request, including subsequent pages
    if chunkSize > 0 {
        req.SetQueryParam("page-size", fmt.Sprintf("%d", chunkSize))
    }
    if filter != "" {
        req.SetQueryParam("filter", filter)
    }
    if nextPageKey != "" {
        req.SetQueryParam("page-key", nextPageKey)
    }

    resp, err := req.Get("/platform/...")
}
```

### Wrong pattern 2: dropping filter on page 2+ (causes unfiltered results)

```go
for {
    req := h.client.HTTP().R().SetResult(&result)

    if nextPageKey != "" {
        // BUG: only sends page-key, drops filter on subsequent pages
        req.SetQueryParam("page-key", nextPageKey)
    } else {
        if filter != "" {
            req.SetQueryParam("filter", filter)
        }
        if chunkSize > 0 {
            req.SetQueryParam("page-size", fmt.Sprintf("%d", chunkSize))
        }
    }

    resp, err := req.Get("/platform/...")
}
```

### Test guard (required for paginated mock servers of non-Document APIs)

Every test mock server for a paginated endpoint (except Document API) **must** include a constraint guard that rejects the invalid combination, so the bug is caught in tests:

```go
// Simulate API constraint: page-size must not be combined with page-key
if r.URL.Query().Get("page-size") != "" && r.URL.Query().Get("page-key") != "" {
    w.WriteHeader(http.StatusBadRequest)
    w.Write([]byte(`{"error":{"code":400,"message":"Constraints violated."}}`))
    return
}
```

For Settings API mocks, the guard must also reject `schemaIds`, `scopes`, and `fields` with `nextPageKey`:

```go
// Simulate Settings API constraint: pageSize, schemaIds, scopes, and fields
// must NOT be combined with nextPageKey (all are embedded in the page token).
if r.URL.Query().Get("nextPageKey") != "" {
    for _, param := range []string{"pageSize", "schemaIds", "scopes", "fields"} {
        if r.URL.Query().Get(param) != "" {
            w.WriteHeader(http.StatusBadRequest)
            fmt.Fprintf(w, `{"error":{"code":400,"message":"Constraints violated."}}`)
            return
        }
    }
}
```

**Reference implementations**: `pkg/resources/document/document.go` (Document API pattern), `pkg/resources/settings/settings.go` (Settings API pattern), `pkg/resources/extension/extension.go` (default pattern)

## Code Examples

- Simple CRUD: `pkg/resources/bucket/`
- Complex with subresources: `pkg/resources/workflow/`
- Execution pattern: `pkg/exec/workflow.go`
- History/versioning: `pkg/resources/document/`

## Resources

- **Design**: [docs/dev/API_DESIGN.md](docs/dev/API_DESIGN.md)
- **Architecture**: [docs/dev/ARCHITECTURE.md](docs/dev/ARCHITECTURE.md)
- **Status**: [docs/dev/IMPLEMENTATION_STATUS.md](docs/dev/IMPLEMENTATION_STATUS.md)
- **Future Work**: [docs/dev/FUTURE_FEATURES.md](docs/dev/FUTURE_FEATURES.md)

---

**Token Budget Tip**: Read API_DESIGN.md Design Principles section first (most critical context). Skip reading full ARCHITECTURE.md unless making structural changes.
