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
mainpackage — 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-restyor similar HTTP wrapper — adds a dependency for minimal benefit overnet/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)
Dohandles: URL construction, bearer token header, JSON marshal/unmarshal, HTTP error → Go error mappingDologs 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
APIErrortype 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 subcommandskong— 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:
GET /boards/{id}→ get board details (includes list of lists with IDs and names)GET /lists/{listId}/cardsfor 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}/actions→beforeIdparamGET /cards/{cardId}/actions→beforeIdparamGET /lists/{listId}/cards→beforeparamGET /cards/{cardId}/comments→beforeIdparam
Decision: Build pagination into the client layer from the start. List methods accept an optional limit and automatically page through results.
--limit Nflag 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
limitparameter (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 somethingsloghandles welllogrus— widely used but effectively in maintenance mode;slogis 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 resolutionINFO— 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 Nflag 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.