Files
Steve Cliff b07572fed5 Released v1
2026-02-12 10:37:19 +00:00

10 KiB

Context

This is a greenfield Go project. There is no existing codebase — the repository currently contains only the Planka OpenAPI 3.0 spec (planka-api.json) and OpenSpec configuration. The Planka API uses JWT bearer auth and returns JSON responses. The API follows a nested resource model: Projects → Boards → Lists → Cards, with sub-resources (comments, tasks, labels, memberships) hanging off cards and boards.

Primary consumers are AI agents and CI/CD pipelines, not humans at a terminal. This shapes every design decision — JSON-first output, env-var auth, no interactive prompts.

Goals / Non-Goals

Goals:

  • Single statically-linked Go binary with zero runtime dependencies
  • Predictable, machine-parseable output for every command
  • Clean mapping from CLI commands to Planka API operations
  • Minimal abstraction — thin client, not an SDK
  • Easy to extend with new resources/commands later

Non-Goals:

  • Interactive TUI or prompt-based workflows
  • Caching, offline mode, or local state
  • WebSocket/real-time integration (Planka supports it, but out of scope)
  • Admin operations (user management, project/board CRUD beyond read)
  • Attachment upload/download
  • Shell completions (can be added later, not v1)

Decisions

1. Project layout — flat internal/ package vs separate packages

Decision: Separate packages under project root: cmd/, client/, model/, output/

Alternatives considered:

  • internal/ with sub-packages — adds nesting without benefit for a single-binary tool
  • Single main package — doesn't scale past ~10 files

Rationale: Clear separation of concerns. cmd/ owns CLI wiring, client/ owns HTTP, model/ owns types, output/ owns formatting. Each package has a single responsibility and can be tested independently.

github.com/dcgsteve/pcli
├── main.go                  → entry point, calls cmd.Execute()
├── cmd/
│   ├── root.go              → root command, global flags, env loading
│   ├── project.go           → project list, get
│   ├── board.go             → board get, actions
│   ├── card.go              → card CRUD, move, duplicate, assign, labels
│   ├── comment.go           → comment CRUD
│   ├── task_list.go         → task-list CRUD
│   ├── task.go              → task CRUD
│   └── label.go             → label CRUD
├── client/
│   ├── client.go            → base HTTP client (URL, token, Do, error handling)
│   ├── projects.go
│   ├── boards.go
│   ├── cards.go
│   ├── comments.go
│   ├── task_lists.go
│   ├── tasks.go
│   └── labels.go
├── model/
│   └── types.go             → all API structs + response envelope
├── output/
│   └── output.go            → Print(data, format), envelope wrapping, table rendering
├── logging/
│   └── logging.go           → structured logger setup, level control
├── go.mod
└── go.sum

2. HTTP client — code-generated vs hand-rolled

Decision: Hand-rolled thin client using net/http from the standard library.

Alternatives considered:

  • oapi-codegen — generates typed client from OpenAPI spec. Produces verbose code, hard to customize, and we only cover ~30 of 96 endpoints.
  • go-resty or similar HTTP wrapper — adds a dependency for minimal benefit over net/http.

Rationale: The API is straightforward REST/JSON. A base client with Do(method, path, body) (*http.Response, error) plus per-resource methods is simple, readable, and fully under our control. ~30 endpoints don't justify code generation overhead.

3. Base client design

type Client struct {
    BaseURL    string
    Token      string
    HTTPClient *http.Client
    Logger     *slog.Logger
}

func (c *Client) Do(ctx context.Context, method, path string, body any) (json.RawMessage, error)
  • Do handles: URL construction, bearer token header, JSON marshal/unmarshal, HTTP error → Go error mapping
  • Do logs every request at DEBUG level (method, path, status, duration) and errors at WARN
  • Per-resource files add typed methods: func (c *Client) GetCard(ctx context.Context, id string) (*model.Card, error)
  • API errors (4xx/5xx) are mapped to a structured APIError type with status code and message

4. Response envelope

Decision: All CLI output wrapped in {"data": ..., "error": null}.

type Envelope struct {
    Data  any    `json:"data"`
    Error *string `json:"error"`
}
  • On success: {"data": <result>, "error": null} + exit code 0
  • On error: {"data": null, "error": "<message>"} + exit code 1
  • Table format (--format=table) bypasses the envelope — prints human-readable rows directly, errors go to stderr

5. Auth — env vars with flag override

Decision: PLANKA_URL and PLANKA_TOKEN environment variables, overridable with --url and --token global flags.

Rationale: Env vars are the standard for CI/CD and agent environments. Flag overrides allow ad-hoc use without modifying the environment. No config file, no login command, no token storage.

Precedence: flag > env var. Missing values produce a clear error message and exit code 1.

6. CLI framework — Cobra

Decision: github.com/spf13/cobra for command structure.

Alternatives considered:

  • urfave/cli — simpler but less ecosystem support, no built-in nested subcommands
  • kong — struct-based, clean but less widely adopted

Rationale: Cobra is the de facto standard for Go CLIs. Nested subcommand support maps directly to our pcli <resource> <action> pattern. Well-documented, widely understood.

7. card list --board <id> enrichment

This command requires multiple API calls:

  1. GET /boards/{id} → get board details (includes list of lists with IDs and names)
  2. GET /lists/{listId}/cards for each list → get cards

The client method composes these calls and injects listName into each card response. This is the only command that does multi-call enrichment — all others are 1:1 with API endpoints.

8. Model types — partial structs

Decision: Model structs include all fields from the API schemas that are relevant to our scoped operations. Fields use json tags and pointer types for nullable/optional fields.

Rationale: We don't need every field from every schema. Types are defined once in model/types.go and shared by both client/ and cmd/. Using json.RawMessage for truly dynamic fields (like Action.data).

9. Pagination — cursor-based with auto-fetch

The Planka API uses cursor-based pagination on 4 list endpoints:

  • GET /boards/{boardId}/actionsbeforeId param
  • GET /cards/{cardId}/actionsbeforeId param
  • GET /lists/{listId}/cardsbefore param
  • GET /cards/{cardId}/commentsbeforeId param

Decision: Build pagination into the client layer from the start. List methods accept an optional limit and automatically page through results.

  • --limit N flag on all list commands (actions, cards, comments). When set, fetch stops after N total items. When omitted, fetch all pages.
  • Client list methods accept a limit parameter (0 = no limit).
// Client list methods follow this pattern:
func (c *Client) ListCardComments(ctx context.Context, cardId string, limit int) ([]model.Comment, error) {
    var all []model.Comment
    var beforeId string
    for {
        page, err := c.listCardCommentsPage(ctx, cardId, beforeId)
        if err != nil { return nil, err }
        all = append(all, page...)
        if len(page) == 0 { break }
        if limit > 0 && len(all) >= limit {
            all = all[:limit]
            break
        }
        beforeId = page[len(page)-1].ID  // cursor = last item's ID
    }
    return all, nil
}

Rationale: Cursor-based pagination is easy to get wrong if bolted on later — callers assume they get complete results. Building it in from day one means every list command returns complete data by default, while --limit gives agents control over response size and latency. The loop terminates when a page returns empty results or the limit is reached.

Logging at DEBUG level tracks each page fetch so pagination behavior is observable.

10. Structured logging

Decision: Use Go's standard library log/slog for structured logging.

Alternatives considered:

  • zerolog — fast, but an external dependency for something slog handles well
  • logrus — widely used but effectively in maintenance mode; slog is the successor
  • No logging — makes debugging agent/CI failures opaque

Rationale: slog is in the standard library (Go 1.21+), produces structured JSON or text output, and has zero dependencies. Perfect fit for a tool consumed by agents.

Log levels:

  • DEBUG — HTTP request/response details, pagination page fetches, config resolution
  • INFO — command execution summary (what resource, what action)
  • WARN — non-fatal issues (e.g., unexpected API response fields)
  • ERROR — failures that cause non-zero exit

Control: --log-level global flag (default: WARN). Agents can set --log-level=debug for full visibility. Logs go to stderr so they never pollute the JSON output on stdout.

// In root.go
var logLevel string  // global flag: --log-level

func initLogger() *slog.Logger {
    var level slog.Level
    level.UnmarshalText([]byte(logLevel))
    return slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: level}))
}

Risks / Trade-offs

  • API version coupling → The client is built against Planka v2.0 API. Breaking API changes require pcli updates. Mitigation: pin to known API version, document compatibility.
  • Pagination completeness → Auto-fetching all pages could be slow for very large result sets (e.g., thousands of actions). Mitigation: --limit N flag available on all list commands to cap results.
  • Multi-call card list --board → Slower than a single API call, N+1 pattern (1 board call + N list calls). Mitigation: acceptable for typical board sizes (< 20 lists). Could parallelize list fetches if needed.
  • No retry/backoff → Network errors fail immediately. Mitigation: acceptable for v1. Agents can implement their own retry logic. Can add later with minimal changes to client.Do.
  • Bearer token in env var → Token visible in process environment. Mitigation: standard practice for CI/CD tools; better than on-disk storage or passing credentials.