Appearance
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:usescope. - Why: No backend changes needed —
EnforceTokenScopemiddleware already handlesread+write. Adds nothing operationally. - Source: KD-0237
Tenant resolution — config file, not per-command flag
- Chose: Tenant URL stored once in
~/.config/kendo/config.yamlduringkendo auth login. - Rejected:
--tenantflag 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.jsonwith 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 installonly (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.jsonfile in the bucket. - Rejected: A
cli_releasestable 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:
ListTokensActionfilters 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
--limiton commands whose backend route already accepts?limit=(the search endpoints with theCappedListResourceDataenvelope). - Rejected: Client-side trim — fetch the full list, slice in cmd/, so every list command gets
--limituniformly. - 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
[]Issueunmarshal against the new{data, meta}envelope in the same PR as the new--limitwiring. - 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> 0pre-check; backend'smin:1, max:500rule is the single source of truth for the upper bound. - Rejected: Mirror the backend's
1..500range 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=600is negligible and Laravel's error message is already user-friendly viaparseAPIError. - 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/| greppipelines; 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:
--jsonoutput remains[{...}, {...}]; truncation info is stderr-only. - Rejected: Switch
--jsonto{data: [...], meta: {...}}so programmatic consumers can seetruncated. - Why: Wrapping breaks every existing
kendo search --json | jq '.[0]'invocation; consumers who care about truncation can hit--limitexactly 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 nolimit=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,enhancementcommand hitting the label-sync endpoint (PUT /issues/{issue}/labels);issue updateis not touched. - Rejected: A
--labelflag onissue update. - Why: A label-only
issue updatere-runs the full issue PUT, emitting a spurious "Issue Updated" audit row + broadcast event even when nothing but labels changed — the MCPsync-issue-labelstool 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 --labelvalidates 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 setafterward. - Why: It matches MCP
create-issue(which acceptslabel_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
searchcommand unmodified; project-scopedissue list --labelcovers label filtering. - Rejected: Add a project-scoped
issue searchcommand (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 capabilityissue list --labelalready 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 --jsonwith a nested comments array, or shipping both surfaces. - Why:
issue viewis a summary that already reportsComments: NandAttachments: Nas 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/usersonce and emit a flatuser_namealongside the wire'suser_idon the JSON path as well as the human table. - Rejected: Names in the human table only, leaving
--jsonid-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_idmakes 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:Readstill 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 viewprints its description block. - Rejected: An
AUTHOR / DATE / COMMENTtable with the body truncated to ~30 chars, astime listdoes. - Why: Bodies are long and often multi-line, which breaks
tabwriteralignment, 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.yamlgainsactive_tenantplus atenantsmap; each tenant's tokens live intenants/<slug>/tokens.json. - Rejected: A single keyed
config.yamlholding 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.Configas a flat resolved view of one tenant, withSave()doing a read-modify-write that touches only that tenant's slice. - Rejected: Exposing the multi-tenant
Storedirectly and updating the ~50 command sites that readActiveProjectID. - 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:
Tokenscarries 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:
--tenantbeatsKENDO_TENANTbeats persistedactive_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