Skip to content

CLI Decisions ​

Each entry is a real fork in the road. Implementation details that aren't a tradeoff are NOT here.


CLI authentication — OAuth Device Flow, not Authorization Code ​

  • Chose: RFC 8628 Device Authorization grant — CLI shows a code, user approves in the browser.
  • Rejected: Authorization Code + PKCE with a localhost redirect, OR personal access token paste.
  • Why: Authorization Code requires a redirect URI which terminals can't receive; localhost-callback variants fail behind firewalls and are unreliable in WSL. Device Flow works on local, SSH, WSL, and headless servers identically.
  • Source: KD-0237

CLI v1 scope — issues only ​

  • Chose: 7 issue commands + 3 auth commands + 2 project commands.
  • Rejected: Issues + Sprints, OR full Issues + Sprints + Time.
  • Why: Issues cover the core developer workflow; sprints/time can be added in v2 without rewriting the v1 surface.
  • Source: KD-0237

CLI tokens reuse read+write scopes ​

  • Chose: Reuse the existing scopes used by personal access tokens.
  • Rejected: New cli:use scope.
  • Why: No backend changes needed — EnforceTokenScope middleware already handles read+write. Adds nothing operationally.
  • Source: KD-0237

Tenant resolution — config file, not per-command flag ​

  • Chose: Tenant URL stored once in ~/.config/kendo/config.yaml during kendo auth login.
  • Rejected: --tenant flag with config default.
  • Why: Single-tenant per CLI config is predictable; multi-tenant users can swap config dirs as a future enhancement.
  • Source: KD-0237

Token storage — file with 0600 permissions, not OS keychain ​

  • Chose: ~/.config/kendo/tokens.json with 0600 perms.
  • Rejected: Native OS keychain (libsecret/Keychain/Credential Manager).
  • Why: Cross-platform without native dependencies; keychain can be bolted on later as opt-in.
  • Source: KD-0237

Distribution — GoReleaser + GitHub Actions on tag push ​

  • Chose: Tag cli/v* triggers cross-platform GoReleaser build (linux/macOS/windows × amd64/arm64).
  • Rejected: go install only (requires Go toolchain on user's machine), OR manual builds.
  • Why: Non-Go users need pre-built binaries; manual builds don't scale across six target triples.
  • Source: KD-0237

Binary hosting — existing R2 bucket with cli/ prefix ​

  • Chose: Reuse the central app's S3 disk with a cli/ path prefix.
  • Rejected: New public R2 bucket on a custom domain (e.g. releases.kendo.dev), OR Fly.io local storage.
  • Why: GitHub Releases aren't accessible (private repo); a separate bucket adds domain config and credential sprawl; Fly local storage is single-region and wiped on deploy.
  • Source: KD-0261

Download serving — central app proxy, not 302 redirect ​

  • Chose: Stream binaries through the central app via Storage::disk('s3')->download().
  • Rejected: Pre-signed R2 URL redirects.
  • Why: Clean URLs (central.kendo.dev/cli/download/...), enables future download tracking, avoids exposing R2 internals; ~10-15MB binaries are fine through Fly.io.
  • Source: KD-0261

Release sync — GitHub release webhook pull, not CI push ​

  • Chose: GoReleaser publishes to GitHub Releases → webhook fires → central app downloads assets via GithubAppService → writes to R2.
  • Rejected: CI step using AWS CLI to upload directly, OR GoReleaser S3 publisher config.
  • Why: Pull avoids putting R2 credentials in GitHub Actions secrets — the central app already has webhook infra, GitHub App credentials, and the S3 disk. Zero new secrets anywhere.
  • Source: KD-0261

Latest-version metadata — latest.json in R2, no database ​

  • Chose: A single cli/latest.json file in the bucket.
  • Rejected: A cli_releases table with version, released_at, platform info.
  • Why: The only runtime query is "what's the latest version?" — a JSON file answers that with zero migrations or models.
  • Source: KD-0261

Self-update — rename + replace + cleanup, not in-place overwrite ​

  • Chose: Download to temp → verify checksum → rename current to .old → move new in → verify runs → delete .old.
  • Rejected: Simple overwrite of the running binary.
  • Why: Overwrite leaves a broken CLI if the download is corrupted or interrupted; rename+replace gives a rollback path.
  • Source: KD-0261

Project tokens — exclude from profile, don't show separately ​

  • Chose: ListTokensAction filters project tokens out of profile responses entirely.
  • Rejected: Render personal tokens and project tokens in two sections on the profile page.
  • Why: Project tokens already have a dedicated UI in project settings where the right permissions are checked; duplicating that on the profile adds maintenance with no value.
  • Source: KD-0396

--limit flag — server-side query param, not client-side slice ​

  • Chose: Only expose --limit on commands whose backend route already accepts ?limit= (the search endpoints with the CappedListResourceData envelope).
  • Rejected: Client-side trim — fetch the full list, slice in cmd/, so every list command gets --limit uniformly.
  • Why: Backend has deliberately gated ?limit= to search endpoints; a CLI flag that pretends to limit while the API still ships the full set creates semantic drift between commands and lies about what the wire saw.
  • Source: KD-0308

Bundle latent envelope-parse fix into the flag PR ​

  • Chose: Fix the broken []Issue unmarshal against the new {data, meta} envelope in the same PR as the new --limit wiring.
  • Rejected: File a separate bug, block KD-0308 on it, then return.
  • Why: A new flag whose acceptance criterion ("results truncated to N") is observable on the wire can't ship into a parse path that's already broken — splitting the PRs would mean merging a no-op flag first.
  • Source: KD-0308

--limit validation — CLI rejects non-positive, backend owns the cap ​

  • Chose: CLI uses cobra's Int (rejects non-numeric) plus a > 0 pre-check; backend's min:1, max:500 rule is the single source of truth for the upper bound.
  • Rejected: Mirror the backend's 1..500 range in the CLI so every invalid value fails before the round-trip.
  • Why: Mirroring duplicates a canonical rule — if the backend ever raises the cap, a mirrored CLI silently caps low; the round-trip cost on --limit=600 is negligible and Laravel's error message is already user-friendly via parseAPIError.
  • Source: KD-0308

Truncation hint goes to stderr, not stdout ​

  • Chose: Write the "results truncated to N" footer to cmd.ErrOrStderr().
  • Rejected: Footer line on stdout under the table so it's always visible.
  • Why: Stdout footers pollute | jq / | grep pipelines; stderr stays visible in interactive use and invisible to pipes, which is the only way to be honest to humans without breaking scripts.
  • Source: KD-0308

--json stays a plain array, no envelope ​

  • Chose: --json output remains [{...}, {...}]; truncation info is stderr-only.
  • Rejected: Switch --json to {data: [...], meta: {...}} so programmatic consumers can see truncated.
  • Why: Wrapping breaks every existing kendo search --json | jq '.[0]' invocation; consumers who care about truncation can hit --limit exactly or read stderr.
  • Source: KD-0308

Omitted --limit sends no query param, lets backend default ​

  • Chose: When the user doesn't pass --limit, the CLI sends no limit= and the backend Action chooses the default.
  • Rejected: Send limit=500 (the backend max) so users get everything unless they opt in to less.
  • Why: "Sometimes" is the backend's declared shape for this param — owning the default in one place means a future backend tuning doesn't require a CLI release; opt-in maxing is a user choice, not a CLI policy.
  • Source: KD-0308

Label attach — dedicated issue label set, not --label on issue update ​

  • Chose: A dedicated kendo issue label set KD-42 bug,enhancement command hitting the label-sync endpoint (PUT /issues/{issue}/labels); issue update is not touched.
  • Rejected: A --label flag on issue update.
  • Why: A label-only issue update re-runs the full issue PUT, emitting a spurious "Issue Updated" audit row + broadcast event even when nothing but labels changed — the MCP sync-issue-labels tool deliberately avoids that side effect, and the CLI should not reintroduce it.
  • Source: KD-0830

issue create --label — accept it, two-call with pre-validation ​

  • Chose: issue create --label validates label IDs against the project first, creates the issue, then syncs labels (POST then PUT); a post-create sync failure prints a stderr warning but doesn't fail the command.
  • Rejected: No labels on create — force a separate issue label set afterward.
  • Why: It matches MCP create-issue (which accepts label_ids) so the two surfaces stay consistent; pre-validating label scope before the create means a bad label id never leaves a stray label-less issue behind, and the issue is genuinely created so failing the whole command on a sync hiccup would be misleading.
  • Source: KD-0830

search --label dropped — labels are project-scoped by design ​

  • Chose: Leave the global search command unmodified; project-scoped issue list --label covers label filtering.
  • Rejected: Add a project-scoped issue search command (or wire labels into global search) to satisfy the original AC4.
  • Why: The global search endpoint doesn't accept label_id[] because labels are project-scoped — the same boundary KD-0828 and KD-0829 drew — so honoring AC4 would need a backend change first and duplicate a capability issue list --label already provides.
  • Source: KD-0830

Comment bodies: dedicated issue comments <ref>, not folded into issue view ​

  • Chose: A new subcommand mirroring the issue attachments <ref> sibling.
  • Rejected: Extending issue view --json with a nested comments array, or shipping both surfaces.
  • Why: issue view is a summary that already reports Comments: N and Attachments: N as counts — putting bodies in a sibling list command preserves that symmetry instead of bloating the summary payload.
  • Source: KD-0954

Comment author: resolved user_name in both the table and --json ​

  • Chose: Fetch GET /api/users once and emit a flat user_name alongside the wire's user_id on the JSON path as well as the human table.
  • Rejected: Names in the human table only, leaving --json id-only per the CLI's convention elsewhere.
  • Why: The users call is already paid for on the human path, so withholding the name from scripts just forces them to redo the lookup and join; keeping user_id makes the enrichment additive rather than lossy.
  • Source: KD-0954

Author-name lookup failure: stderr note, render IDs, exit 0 ​

  • Chose: One stderr line naming the reason, then rows showing user <id>.
  • Rejected: Silent fallback to IDs, or hard-failing the command.
  • Why: A token whose member lacks Users:Read still deserves the comment bodies — the primary payload — but silent degradation reads like a half-built feature, so the failure is announced without polluting stdout or the exit code.
  • Source: KD-0954

Comment rendering: block list, not a truncated table ​

  • Chose: A per-comment header line followed by the indented full body, like issue view prints its description block.
  • Rejected: An AUTHOR / DATE / COMMENT table with the body truncated to ~30 chars, as time list does.
  • Why: Bodies are long and often multi-line, which breaks tabwriter alignment, and truncating the payload defeats the entire point of a read command.
  • Source: KD-0954

Multi-tenant storage: per-tenant token files, one keyed config ​

  • Chose: config.yaml gains active_tenant plus a tenants map; each tenant's tokens live in tenants/<slug>/tokens.json.
  • Rejected: A single keyed config.yaml holding the tokens inside each tenant's entry.
  • Why: Token refresh is an automatic write firing on nearly every command — a refresh on one tenant racing any write on another would clobber a shared file, which is exactly the parallel-safety hazard the feature exists to remove.
  • Source: KD-0980

Multi-tenant Config: resolved flat per-tenant view, not a Store at call sites ​

  • Chose: Keep activeCfg *config.Config as a flat resolved view of one tenant, with Save() doing a read-modify-write that touches only that tenant's slice.
  • Rejected: Exposing the multi-tenant Store directly and updating the ~50 command sites that read ActiveProjectID.
  • Why: A deliberately shallow compatibility adapter confines the change to four files; the alternative rewrites 50 call sites to express the same thing.
  • Source: KD-0980

Token save routing: bind the tenant to the Tokens value ​

  • Chose: Tokens carries an unexported slug set at load or construction; Save() self-routes to that tenant's file.
  • Rejected: Having the API client pass the active tenant down when it refreshes.
  • Why: The refresh path in the client then needs no change at all — per-tenant write isolation falls out of the type design instead of depending on every caller remembering to scope the write.
  • Source: KD-0980

Tenant resolution: flag > env > persisted, hard error on unauthed ​

  • Chose: --tenant beats KENDO_TENANT beats persisted active_tenant; an unknown or token-less tenant errors and names the fix command.
  • Rejected: Falling back to the active tenant when the requested one can't be resolved.
  • Why: A silent fallback writes to the wrong board — the tenant-axis version of the wrong-project hazard — so refusing loudly is the only safe failure mode.
  • Source: KD-0980

Legacy CLI config: clean-slate cutover, no migration ​

  • Chose: Treat the old flat config as empty, delete the orphaned tokens.json, strip legacy keys on next write, and accept one forced re-login.
  • Rejected: A copy-then-verify migration of legacy tokens into the keyed layout, as the issue's original AC required.
  • Why: With very few CLI users, the migration's fallback and verification branches cost more than a single re-auth — and there is never a half-migrated state to debug.
  • Source: KD-0980

Config writes: no file locking, reload immediately before every write ​

  • Chose: Accept last-write-wins on config.yaml, but require every writer to reload the Store and rewrite only its own slice — including the login path, which reloads after the device-flow poll.
  • Rejected: Adding a file-locking subsystem to serialize config writes.
  • Why: Only the automatic write path (token refresh) needs true parallel safety and per-tenant files already give it; holding a pre-poll snapshot across a minutes-long device flow was the one genuinely wide race, and reloading after the poll shrinks it to the same sub-millisecond window as every other writer.
  • Source: KD-0980

Issue JSON names — backend payload, not a CLI-side join ​

  • Chose: Serve lane/sprint/epic/assignee names in the issue resource so every client gets them in one round-trip.
  • Rejected: Building lookup maps in the Go CLI from list endpoints and populating struct fields before render.
  • Why: A CLI-side join would match MCP output while diverging in mechanism, cost up to four extra GETs per call, and leave every other client behind.
  • Source: KD-1022

Nested {id, title} objects, not flat *_title fields ​

  • Chose: Emit lane: {id, title} (and the same shape for sprint, epic, assignee) matching the MCP resources.
  • Rejected: Flat lane_title / sprint_title / epic_title matching AC3's wording and the search resource.
  • Why: The epic's goal is MCP parity, which is a clearer signal than an AC's literal field names.
  • Source: KD-1022

Unconditional name enrichment, no --expand flag ​

  • Chose: Embed the nested name objects in every issue payload, always.
  • Rejected: An --expand CLI flag backed by a backend ?expand= query param that picks a different resource class.
  • Why: No controller branches resource class on a request param, and the summary objects are cheap enough to serve unconditionally.
  • Source: KD-1022

Labels and epic name — ship them even though MCP lacks them ​

  • Chose: Add nested labels and epic to the issue payload alongside the MCP-parity lane/sprint/assignee.
  • Rejected: Strict MCP parity — nested lane + sprint only, leaving labels and epic name out.
  • Why: Strict parity would preserve the exact gap the report filed; epics and labels are assumed to land on MCP next, so the divergence is directional.
  • Source: KD-1022

Nested issue relations: slim summaries, not full resources ​

  • Chose: Dedicated summary DTOs carrying only id + display fields, with no eager-load aggregates.
  • Rejected: Nesting the existing full lane/sprint/epic/label resources, or reusing the label resource as-is.
  • Why: Full resources pull issue-counts and github-triggers onto every issue in every listing and broadcast; reusing the label resource still drags timestamps and project_id into a payload that only needs name and color.
  • Source: KD-1022

Sprint JSON — stderr legend, not status_name / is_active fields ​

  • Chose: Keep status as the bare int the API already emits, and print 0=Planned 1=Active 2=Completed to stderr.
  • Rejected: Adding status_name and is_active fields populated CLI-side.
  • Why: Every enum in CLI JSON is a bare int today; inventing serialized-name fields on one command would set a convention the rest of the surface doesn't follow, and MCP answers the same question with a legend in the tool description.
  • Source: KD-1022

Search/my issue JSON stays flat — don't reshape the search resource ​

  • Chose: Leave kendo search and issue my on the existing flat lane_title / epic_title shape.
  • Rejected: Aligning the search resource to nested objects so every CLI issue command emits one shape.
  • Why: That resource feeds the SPA's MyIssues page and filter options — aligning it is a breaking frontend change, not an additive CLI tidy.
  • Source: KD-1022

User commands named user, not member ​

  • Chose: kendo user list and kendo user me.
  • Rejected: kendo member list, matching the glossary's canonical noun.
  • Why: There is no Member model or members table — "member id" is literally users.id — so a member command would make the CLI the only surface using a word that names nothing.
  • Source: KD-1023

User listing backed by /api/users, not a new members endpoint ​

  • Chose: Reuse the existing /api/users endpoint the CLI client already calls.
  • Rejected: A new project-scoped members endpoint gated only on project access, mirroring the MCP context tool.
  • Why: The seeded Member role already grants Users:Read, so the 403 path is a custom-role edge case; a new route, controller, resource, and policy is a lot of backend to avoid it.
  • Source: KD-1023

--date on time log — deprecate, don't rename or remove ​

  • Chose: Keep --date working, warn on stderr, and add --started-at as the real flag.
  • Rejected: Renaming outright, or keeping both as equals and erroring only when both are passed.
  • Why: --date is misnamed (the field is an instant) and breaks MCP/UI parity, but removing it would break existing scripts; a deprecation warning gives a grace period that a silent dual-flag setup never would.
  • Source: KD-1023

Naked --started-at timestamps accepted as UTC, recorded instant echoed ​

  • Chose: Accept offset-less timestamps as UTC (same as MCP and the API) and print the stored instant on the confirmation line.
  • Rejected: Rejecting naked values, or documenting the UTC default with no echo.
  • Why: Rejecting would make the CLI stricter than both other surfaces for the same field; a human at a shell drops the offset far more often than an LLM filling a schema, so echoing the recorded instant makes a wrong offset visible at the moment it's made.
  • Source: KD-1023
  • Chose: kendo issue unlink-branch <ref> [branch-name] — omitted name resolves by candidate count (act on 1, error listing them on 2+).
  • Rejected: Auto-detecting the current git branch, as link-branch does.
  • Why: The typical unlink is a mis-linked branch you are not on, possibly already deleted locally; auto-detecting would guess the wrong target.
  • Source: KD-1023
  • Chose: Error listing candidate repo names and require --repo to pick.
  • Rejected: Silently picking the primary repo, as link-branch does.
  • Why: A wrong link is visible and re-linkable; a wrong unlink is silent and the link must be recreated — guessing is acceptable on create, not on delete.
  • Source: KD-1023

Linked branches render on issue view, not a dedicated command ​

  • Chose: A Branches section on issue view.
  • Rejected: A dedicated kendo issue branches command.
  • Why: A whole command for one list, and users would not know to look for it — you cannot unlink what you cannot see.
  • Source: KD-1023

report view gets a backend show route, not a client-side filter ​

  • Chose: Add a report show route, gated the same as the index, with no project-token carve-out.
  • Rejected: Fetch the unpaginated index and filter to one report in the CLI.
  • Why: A project already holds hundreds of reports each with a full description — downloading all of them to print one grows without bound.
  • Source: KD-1023

report view attachments have no download_url ​

  • Chose: List attachments with the REST index shape and footer-point at kendo report download.
  • Rejected: Emitting download_url the way MCP's get-report does, or adding a download-URL field to the CLI attachment type.
  • Why: The CLI already streams any mime to disk with no size cap; a URL would be a second, worse route, and the only URL in the CLI's output surface, sitting empty on every other attachments command.
  • Source: KD-1023

Don't copy MCP attachment fetch caps into the CLI ​

  • Chose: Leave report download uncapped and mime-unrestricted.
  • Rejected: Mirroring the MCP fetch-attachment tool's mime branching, size caps, and binary-type errors with a download_url hint.
  • Why: Those behaviours work around an LLM's lack of a filesystem and a finite context window; copying them into a CLI that already streams PDFs and zips to disk would be a straight regression.
  • Source: KD-1023