diff --git a/.env.example b/.env.example deleted file mode 100644 index 15918f6..0000000 --- a/.env.example +++ /dev/null @@ -1,14 +0,0 @@ -# Microsoft Entra ID (Azure AD) sign-in for the SPA — optional. -# Both values must be set for the "Sign in with Microsoft" button to work; -# leave them blank to use manual identity mode only. -# -# App registration requirements: -# - Platform: Single-page application (SPA) -# - Redirect URI: the app origin, e.g. http://localhost:5173 or https://kb.example.com -# - Scopes used: openid profile email (no Microsoft Graph) - -# Application (client) ID of the app registration (GUID). -VITE_AZURE_CLIENT_ID= - -# Directory (tenant) ID (GUID) or tenant domain, e.g. contoso.onmicrosoft.com. -VITE_AZURE_TENANT_ID= diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a1e5c1e..2d4c156 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -3,16 +3,5 @@ /scripts/check-* @aksOps /scripts/ci/ @aksOps /scripts/ci_monitor.cjs @aksOps -/sonar-project.properties @aksOps -/package.json @aksOps -/package-lock.json @aksOps -/npm-shrinkwrap.json @aksOps -/yarn.lock @aksOps -/pnpm-lock.yaml @aksOps -/bun.lock @aksOps -/bun.lockb @aksOps -/.npmrc @aksOps -/vite.config.* @aksOps -/vitest.config.* @aksOps /go.mod @aksOps /go.sum @aksOps diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index e5625f3..4a94525 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -1,4 +1,4 @@ -name: Quality and Sonar +name: Quality on: push: @@ -27,11 +27,10 @@ jobs: fetch-depth: 0 persist-credentials: false - - name: Set up Node.js + - name: Set up Node.js for the CI monitor test uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24.15.0 - cache: npm - name: Set up Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 @@ -39,21 +38,12 @@ jobs: go-version-file: go.mod cache-dependency-path: go.sum - - name: Install frontend dependencies - run: npm ci --ignore-scripts - - - name: Run frontend coverage gate - run: npm test - - name: Run Go coverage gate env: GO_PACKAGE_COVERAGE_THRESHOLD: '95.0' GO_TOTAL_COVERAGE_THRESHOLD: '96.4' GO_COVERAGE_PROFILE: coverage/go.out - run: npm run coverage:go - - - name: Build frontend - run: npm run build + run: sh scripts/check-go-coverage.sh - name: Vet Go packages run: go vet ./... @@ -64,16 +54,5 @@ jobs: - name: Check Go formatting run: sh scripts/check-go-format.sh - - name: Run SonarQube Cloud scan - if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository - uses: SonarSource/sonarqube-scan-action@22918119ff8e1ca75a623e15c8296b6ea4fbe28f # v8.2.1 - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - with: - args: >- - -Dsonar.organization=${{ vars.SONAR_ORGANIZATION }} - -Dsonar.projectKey=${{ vars.SONAR_PROJECT_KEY }} - - - name: Explain skipped Sonar scan - if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository - run: echo "Sonar scan skipped because GitHub does not expose repository secrets to fork pull requests." >> "$GITHUB_STEP_SUMMARY" + - name: Test CI monitor + run: node scripts/ci/test_ci_monitor.cjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2e6b9fd..5f3acda 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,8 +1,7 @@ name: Release -# Manually triggered release: builds the UI, commits it on a detached release -# commit under the tag, cross-compiles the binaries at that commit (so the Go -# toolchain stamps the exact version), and publishes the GitHub release. +# Manually triggered release: validates the clean Go source, tags that exact +# commit, cross-compiles the binaries, and publishes the GitHub release. # Everything is delegated to scripts/release.sh — the same script a local # release uses — so the two paths cannot drift apart. @@ -39,12 +38,6 @@ jobs: with: fetch-depth: 0 - - name: Set up Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: 24.15.0 - cache: npm - - name: Set up Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: diff --git a/.gitignore b/.gitignore index ee80a0d..416d3da 100644 --- a/.gitignore +++ b/.gitignore @@ -1,19 +1,9 @@ -node_modules - # Local oh-my-codex runtime, workflow state, logs, and generated artifacts. /.omx/ # Test coverage reports. /coverage/ -# Local SonarScanner analysis workspace and generated reports. -/.scannerwork/ - -# dist/ is a build artifact; only .gitkeep is tracked so that the -# //go:embed all:dist directive still resolves in a fresh clone. -/dist/* -!/dist/.gitkeep - /docs/ !/docs/ /docs/* diff --git a/README.md b/README.md index 986acf0..9760ada 100644 --- a/README.md +++ b/README.md @@ -1,1187 +1,362 @@ # kb -A personal kanban board that stays plain text. - -**kb** is a rich-card kanban web app (PWA-style single-page app) served by a -single-binary Go server. The frontend is Vite + React + TypeScript; the server -embeds the built SPA, stores boards in a single SQLite database, and speaks a -tiny markdown wire API — your whole board still round-trips as one -human-readable `.md` document. The same binary is also a task CLI and an MCP -server for coding agents. - -## Features - -- **Status columns** — To Do / Doing / Done / Cancelled (soft delete: cards can - be restored). -- **Rich cards** — emoji, wrapping title, description snippet, inline - expandable checklist (tick subtasks directly on the board), chunky progress - bar, priority dot, relative due chip ("in 5d" / "overdue · 6d"), effort chip - (S/M/L), blocked flag, plain tags, and GitLab-style scoped labels - (`key::value` two-tone pills). -- **Juice that earns its place** — confetti on ship, mini-confetti on subtask - tick, ticking the last subtask auto-ships the card, daily streak counter, - drag with lift shadow and column highlight (pointer events, no dnd library). -- **Plain-text native** — markdown is the wire and export format. - Import/export from the UI; round-trip is lossless for well-formed files. -- **Single binary server** — the Go server embeds the SPA and stores all - boards in one SQLite file (`modernc.org/sqlite`, pure Go, WAL mode, no - CGO). Existing per-user `.md` boards are imported automatically on first - start. -- **CLI** — `kb add/list/update/move/done/cancel/restore/rm` against the local - database, or against a remote kb server with `KB_SERVER` (see [CLI](#cli)). -- **MCP server** — `kb mcp` exposes five task tools and two context tools over - stdio for agent harnesses (see [MCP server](#mcp-server)). -- **Forge integrations** — import AI-transformed issues from GitLab or GitHub, - public or enterprise (see [Integrations](#integrations)). -- **AI assist** — draft a card from a prompt via any OpenAI-compatible - endpoint you configure; the server proxies, your API key never reaches the - browser (see [AI assist and settings](#ai-assist-and-settings)). -- **Label registry** — tags are remembered per user and suggested in the - story modal (see [Labels](#labels)). -- **Multi-user by identity, not by accounts** — each identity maps to its own - board. Identity comes from Azure Entra ID or a manual unique id (see - [Identity modes](#identity-modes)). - -## Quick start - -### Development (frontend only) - -```bash -npm i && npm run dev -``` - -### Production (single binary) - -```bash -go install github.com/RandomCodeSpace/kb@latest -kb --port 8080 --data ~/boards -``` +`kb` is a local-first kanban board for terminals, command-line tools, and AI +agents. The full-screen TUI, task CLI, and MCP server all read the same SQLite +database directly. An optional API-only HTTP server remains available for +remote CLI clients and integrations. -Release tags from v0.7.3 onward include the built web UI, so `go install` -produces the complete binary. Earlier tags (v0.7.2 and below) contain no UI -when installed this way — the API, CLI, and MCP server work, but the web -board answers 404; use the prebuilt binaries from the GitHub release page -for those versions instead. Releases are cut with `scripts/release.sh`, -which commits the built UI on the release tag itself. +There is no web UI, JavaScript bundle, or browser runtime in the binary. -A bare `kb` (or `kb` with only flags) serves the embedded SPA and the API on -the given port. Data lives in a SQLite database under the data directory -(default `~/.local/share/kb`, or `KB_DATA` / `--data`). +## Install -```bash -kb add "Ship the release" --prio 1 # CLI against the same database -kb list -kb mcp # MCP server on stdio for agents -``` +With Go 1.25 or newer: -> To build kb from source, run `npm run build` to generate the embedded SPA -> into `dist/`, then `CGO_ENABLED=0 go build .` for the binary. Release -> archives include the pre-built `dist/` for reproducible builds. - -## Storage - -Boards live in a single SQLite database at `/kb.db` -(`modernc.org/sqlite` — pure Go, CGO off, WAL mode). Per-user scoping is on -every row: `tasks` (UUID id, emoji, title, desc, status, prio, due, effort, -position, tags, checklist, timestamps), `labels` (label registry with -last-used stamps), and `settings` (AI configuration). - -Markdown remains the **legacy wire and export format**, not the storage format. -First-party browser sync can wrap the same markdown in a JSON identity envelope: - -- `GET/PUT /api/board` still accept `text/markdown`; UI import/export is - unchanged. Every `PUT` must carry `If-Match`, whichever format it sends: - without one the server answers `428 Precondition Required` rather than - replacing the board blind. -- `Accept: application/json` on `GET` returns the markdown with canonical task - ids. `Content-Type: application/json` on `PUT` sends those ids back so - renames, moves, and duplicate titles retain identity. -- A Go markdown codec (same grammar as the frontend's `src/lib/markdown.ts`, - shared golden test vectors) converts at the boundary. - -**Automatic one-time import**: on first start, any existing `/.md` -board files are parsed and imported into the database — once per user, and -only for users with no tasks in the database yet. The original `.md` files -are left untouched, and a user is never reimported (even after exporting or -deleting tasks). - -Why SQLite: the SPA, the CLI, and MCP agents share one database-backed board -revision. Task mutations, title changes, and full-board replacements advance -it. WAL permits concurrent access; conditional full-board writes reject a -stale revision instead of erasing an intervening task-level write. - -The data directory also holds `secret` — an auto-generated encryption key for -stored AI API keys and forge PATs (see [Integrations](#integrations) and -[AI assist and settings](#ai-assist-and-settings)). `kb.db`, `kb.db-wal`, and -`kb.db-shm` are created or tightened to mode `0600`. A generated secret is -fully written, synced, and closed in a same-directory `0600` temporary file -before an atomic no-overwrite publication; concurrent creators all use the -complete winning file. An existing short or invalid secret is reported and -left unchanged. - -## API - -| Method | Path | Auth | Behavior | -| ------ | ----------------- | ---- | --------------------------------------------------------------------- | -| GET | `/api/health` | none | `200` JSON `{"ok":true}` | -| GET | `/api/config` | none | `200` JSON `{"azure_client_id","azure_tenant_id"}` (see Entra below) | -| GET | `/api/board` | yes | `200` `text/markdown`, or JSON `{"board","task_ids"}` when requested; `404` if none exists; every result carries an `ETag` | -| PUT | `/api/board` | yes | `text/markdown` or JSON `{"board","task_ids"}`; `204`, negotiated `200` `{"task_ids":[…]}`, `409` for a stale `If-Match`, or `428` for a `PUT` that sends none | -| GET | `/api/labels` | yes | `200` JSON array of your labels, most recently used first | -| GET | `/api/similar` | yes | Similar-card advisory for query text; cheap stubs only | -| POST | `/api/tombstones` | yes | Record why a card was killed: `{"task_id","reason"}`; responds `204` | -| GET | `/api/settings` | yes | `200` JSON `{"ai_base_url","ai_model","has_key"}` (never the key) | -| PUT | `/api/settings` | yes | JSON patch `{"ai_base_url","ai_model","ai_key"}`; responds `204` | -| GET | `/api/integrations` | yes | List configured forge sources; returns `has_token`, never the PAT | -| PUT, DELETE | `/api/integrations/{name}` | yes | Create/update or remove one scoped forge source | -| POST | `/api/integrations/{name}/test` | yes | Test source access; returns an opaque result | -| POST | `/api/import/preview` | yes | Fetch and transform configured forge issues for review | -| POST | `/api/import/links` | yes | Record selected cards' canonical import provenance | -| POST | `/api/import/drift` | yes | Compare one imported card against its upstream issue, on request | -| POST | `/api/import/drift/accept` | yes | Advance that card's baseline after you review the change | -| POST | `/api/ai/test` | yes | 1-token ping of the configured endpoint; `{"ok":true}` or error | -| POST | `/api/ai/story` | yes | Prompt in, structured card draft out (see AI assist) | -| POST | `/api/ai/stories` | yes | ADR or configured forge issue in, `{"stories":[…]}` out (see AI assist) | -| POST | `/api/ai/run-skill` | yes | Run one named skill over your board; `{"cards":[…],"commentary":"…"}` out (see Skills) | -| GET | `/*` | none | Embedded SPA static files | - -Auth applies to every `/api/*` route except `/api/health` and `/api/config` -(the SPA has to read its sign-in configuration before there is anyone to -authenticate); static files are always open. - -**Content types are enforced.** A request with a body must declare it: -`PUT /api/board` accepts `text/markdown` or `application/json`; -`application/json` is required everywhere else. Anything else — including the -`text/plain` and form types a cross-site request can send without a preflight — -is rejected with `415`. This is deliberate: it is what stops another site's -page from driving your API. - -**Concurrent writes.** `GET /api/board` returns an opaque, revision-backed -`ETag`. Every task or board-title mutation and every full-board replacement -advances that per-user database revision, including writes by the SPA, CLI, -MCP, or an older binary. Revisions are monotonic, not necessarily contiguous; -clients must treat the token as opaque. - -Send the token back as `If-Match` on `PUT`. If the board changed underneath -the client, the server answers `409` with the current `ETag`; refetch, merge, -and retry. The `404` for a board that does not exist carries a token too, so a -first write can be conditional. `If-Match: *` succeeds only when a board -exists at the replacement transaction; concurrent edits do not invalidate the -wildcard, while a concurrent deletion returns `409`. A pre-upgrade content-hash -token receives the same `409` and current revision token, after which a normal -refetch/retry works. - -The SPA no longer takes this path for ordinary work: it reads and writes -through the per-task endpoints and keeps no board of its own. It uses -`PUT /api/board` only for the markdown file import, which reads the current -token and sends it back, so an import that would discard work committed since -that read fails with `409` instead. Remote CLI mutations surface `409` without -an unsafe unconditional retry. - -There is no unconditional path left. A `PUT` with no `If-Match` — `text/markdown` -or JSON — is refused with `428 Precondition Required` and a plain-text body -naming the header, because that write would silently delete every task created -since the client last read. Curl-style clients should `GET /api/board`, keep the -`ETag`, and send it back as `If-Match` (`If-Match: *` replaces whatever board -already exists). The rule is deliberately symmetric: a condition the server -synthesized from its own read would only cover the microseconds inside the -request, not the client's read/edit/write interval, which is where an -intervening CLI or MCP write actually lands — so swapping the content type -cannot buy back the unconditional behavior. - -**Canonical-id JSON negotiation.** A literal `Accept: application/json` on -`GET /api/board` returns: - -```json -{"board":"# kb\n...","task_ids":["4bc95ae6-3bf7-4bc2-a578-37af48a5ca85"]} +```sh +go install github.com/RandomCodeSpace/kb@latest ``` -The ids follow the card order in the serialized markdown. To preserve those -ids across renames, moves, and duplicate titles, send -`Content-Type: application/json` on `PUT` with exactly the same two fields: +Or build a checkout: -```json -{"board":"# kb\n...","task_ids":["4bc95ae6-3bf7-4bc2-a578-37af48a5ca85",null]} +```sh +go test ./... +CGO_ENABLED=0 go build -o kb . ``` -Each string names an existing task owned by the caller; `null` asks the server -to create a new task. The array length and order must match the cards in the -markdown. Malformed, duplicate, unknown, or foreign ids receive the same -non-disclosing `400`. A stale `If-Match` receives `409` before id validation. -Any request containing `null` must also carry a lowercase UUID -`Idempotency-Key`. The server stores a user-scoped receipt in the replacement -transaction. Replaying the same key and exact JSON bytes returns the original -IDs and `ETag` with `Idempotency-Replayed: true`; reusing the key with a -different body returns the same non-disclosing `400`. A new key on markdown or -on JSON without a `null` ID is rejected with that same `400`; the key cannot be -used as an unrelated request label. +No Node.js, npm, Vite, or generated asset directory is required. -By default a successful `PUT` answers `204` with no body. A literal -`Accept: application/json` instead negotiates `200` and -`{"task_ids":[…]}` for the committed cards; wildcard `Accept: */*` and -`Accept: application/json;q=0` retain `204`. Both success forms carry the -committed revision `ETag`. +## Start -**The board lives on the server.** The SPA holds no copy of it. It reads -`GET /api/tasks` on startup and after reconnecting, writes every change through -the per-task endpoints, and reads the list back once after each write, so the -display reflects what the server has even when the CLI or MCP is writing at the -same time. Task ids come from the server; the browser never mints one. There is -no offline mode: with no server there is no board, and the page says so. +Run `kb` in an interactive terminal: -A refused write shows the server's own words — a rejected field, or the -completion guard refusing a move to Done — and is followed by the same refetch, -so the card snaps back to where the server left it. Drag and keyboard moves send -the destination status and the slot within that column; the confirmation dialog -for shipping an unfinished card sends `force` once the person has answered it. +```sh +kb +``` -**Metadata that follows a write.** A cancellation reason is recorded after the -card reaches Cancelled, because the server only accepts a tombstone for a -cancelled task. If that second request fails the card stays cancelled with no -reason and the UI says so. Import provenance is best-effort in the same way: a -failure is reported and never rolls back the cards that were created. Restoring -a cancelled card drops its tombstone server-side; deleting one cascades to its -comments and links. +When both stdin and stdout are TTYs, a bare `kb` opens the full-screen TUI. +When either stream is redirected, it prints root help and exits successfully +without creating or opening the data directory. `kb tui` remains the explicit +form and accepts `--data` and `--user`. -## The board format +```sh +kb tui --data ~/.local/share/kb --user default +kb --help +``` -The markdown wire/export format. Example: +The default data directory is `$KB_DATA` or `~/.local/share/kb`. The default +board owner is `$KB_USER` or `default`. -```markdown -# Board Title +## Terminal UI -## To Do +The TUI is the primary human interface. It includes: -- [ ] 🚚 Migrate CI runner !1 @2026-07-21 ~L #infra #env::prod - Old runner image is EOL this month. - - [ ] provision new runner - - [ ] move secrets +- responsive Todo, Doing, Done, and optional Cancelled columns; +- card detail, markdown rendering, comments, and blocker links; +- create/edit forms with labels, due dates, effort, priority, checklists, and + blocked state; +- persistent text and label filters per board owner; +- keyboard lift/drop and mouse drag moves with filtered-order correctness; +- completion guards, tick-all/force-ship choices, auto-ship, unship, cancel, + restore, and permanent deletion; +- AI-assisted card drafts and ADR splitting with review before writes; +- forge issue import, duplicate review, provenance, and upstream drift review; +- local settings for AI providers, forge sources, and display identity. -## Doing +The footer shows the controls available in the current view. The primary board +keys are: -- [ ] 🔌 Wire up SSO %blocked #auth +| Key | Action | +| --- | --- | +| `j`/`k`, arrows | Move between cards | +| `h`/`l`, `Tab`/`Shift+Tab` | Move between columns | +| `1`..`4` | Jump to a column | +| `Enter` | Open card detail | +| `Space` | Lift/drop a card | +| `n` / `e` | Create / edit a card | +| `/` / `f` | Edit / clear filters | +| `c` | Toggle Cancelled | +| `s` | Open settings | +| `a` | Split an ADR into cards | +| `i` | Import forge issues | +| `q` | Quit | -## Done +Card-detail actions are shown in its footer, including comment and blocker +management, checklist toggles, shipping, cancellation, restore, and permanent +delete. `Escape` closes an overlay or cancels the current operation. Editors +use `Tab` to navigate, `Ctrl+S` to save, and protect unsaved work on close. -- [x] 🧱 Init repo +## Data and identity -## Cancelled +The data directory contains: -- [ ] 🧪 Spike a second renderer -``` +- `kb.db`, the SQLite database; +- `secret`, the generated encryption secret when `KB_SECRET` is unset; +- `skills/`, optional user skill overrides; +- TUI preference state scoped by database and board owner. -Tokens on the title line, parsed and stripped: `!1..!4` priority, `@YYYY-MM-DD` -due date, `~S|~M|~L` effort, `%blocked` (the card is flagged blocked), `#tag` -(scoped labels are just tags containing `::`). A leading pictographic character -is the card emoji. Indented plain lines form the description; indented -checkboxes form the checklist. Timestamps are not serialized (they reset on -import). +Board owners are normalized to lowercase and may contain ASCII letters, +digits, `.`, `_`, `@`, and `-`. Use `--user NAME` or `KB_USER` consistently +across the TUI, CLI, and MCP server. -A title word that would otherwise be read as a token is escaped with one -leading backslash, which the parser strips: write `\%blocked` for a card whose -title really says `%blocked`, and likewise `\!1`, `\~S`, `\@2026-07-21`, -`\#hash`, or a word that starts with `\` itself. +Provider API keys and forge tokens are encrypted at rest with AES-256-GCM. +Keep the `secret` file with the database. Losing it makes stored credentials +unreadable. A short explicit `KB_SECRET` is unsafe; `kb serve` refuses it and +local entry points warn. -Sections are `## To Do`, `## Doing`, `## Done` and `## Cancelled`, matched by -name (case-insensitively) and, for unrecognized headings, by position in that -order. `## Cancelled` comes after `## Done` and is written only when it has -cards, so a legacy three-section board round-trips byte-for-byte. Cancelled is -the soft-delete column (`kb cancel` / `kb restore`, and the SPA's delete -button); it is excluded from the shipped streak, progress totals and the -default `kb list`. +SQLite WAL mode may create `kb.db-wal` and `kb.db-shm`. Stop writers and back +up all three database files plus `secret` together. -## Decision graveyard +Legacy Markdown boards in the data directory are imported on first store open. +The import is idempotent; imported Markdown files are not deleted. -Killing a card still means moving it to the existing Cancelled soft-delete -column; it is not a new kind of deletion. A tombstone is an optional reason -attached to that killed card. When similar work is proposed later, kb can show -the prior decision as an advisory warning. The warning is dismissible and never -blocks creating or editing a card. +## Upgrade from the web UI release -Tombstone reasons are stored as plaintext in the local database. Anyone who can -read that database file can read the reasons, so do not put secrets or other -sensitive information in them. +No data migration is required. Point the new binary at the same data directory. +Cards, settings, integrations, comments, blocker links, tombstones, import +provenance, and encrypted credentials remain in the existing SQLite schema. -## CLI +The command surface changed deliberately: -The same `kb` binary is the task CLI. A bare `kb` serves; a subcommand runs -the CLI. Exit codes: `0` ok, `1` runtime error, `2` usage error. +- old bare `kb --port 8080 --data DIR` becomes + `kb serve --port 8080 --data DIR`; +- bare `kb` now opens the TUI on a terminal, or prints help when redirected; +- `kb tui`, every task CLI verb, `kb mcp`, and `kb version` remain available; +- `/`, static assets, and all other non-API HTTP paths now return 404. -```text -kb add "title" add a task -kb list list tasks -kb update patch a task (only provided flags change) -kb move move a task to todo, doing, done, or cancelled -kb done shorthand for: move done -kb cancel soft delete: move a task to cancelled, undo with restore -kb restore move a cancelled task back to todo -kb rm hard delete, no undo (requires --yes) -kb help show help -``` +Root server flags are rejected with exit code 2 and point to `kb serve`, so an +old service fails visibly instead of silently launching the wrong mode. -Common flags on every command: `--user name` (board owner, default -`default`) and `--data dir` (default `$KB_DATA` or `~/.local/share/kb`). +If an old Entra-backed deployment used the immutable `oid` claim as its board +owner, pass that value with `--user` or `KB_USER`. Token-mode deployments use +the former `X-KB-User` value. Browser-only session tokens and display state are +not database records and are not migrated. -Card flags on `add` and `update`: `--desc`, `--status -todo|doing|done|cancelled`, `--prio 1-4`, `--due YYYY-MM-DD`, `--effort -S|M|L`, `--emoji`, `--tag` (repeatable), `--check` (repeatable), -`--blocked` / `--no-blocked`, and `--title` (update only; `add` takes the -title as its argument). `list` takes `--status`, `--all` (cancelled tasks are -hidden by default) and `--json` (full tasks as JSON). +Update service units and container commands before replacing the binary. A +reverse proxy may continue forwarding `/api/*` for API clients, but there is no +HTML application behind `/`. -Finishing a task that still has open checklist items, or one flagged blocked, -needs `--force` on `done`, `move done` and `update --status done`. -Without it kb prints what is still open and exits non-zero — it never prompts. +## Task CLI -```bash -kb add "Migrate CI runner" --prio 1 --due 2026-08-01 --effort L \ - --tag infra --tag env::prod --check "provision new runner" --check "move secrets" -# added 8c1f4b02 Migrate CI runner +The task CLI works locally by default: +```sh +kb add "Fix the release" --prio 1 --tag release kb list -# ID STATUS PRIO BLOCKED TITLE TAGS -# 8c1f4b02 todo 1 - Migrate CI runner infra,env::prod - -kb update 8c1f --desc "Old runner image is EOL this month." --effort M -kb update 8c1f --blocked # waiting on something; --no-blocked clears it -kb move 8c1f doing -kb done 8c1f --force # --force: ship with checklist items still open -kb cancel 8c1f # soft delete; kb restore 8c1f undoes it -kb list --all # cancelled tasks are hidden without this -kb rm 8c1f --yes # hard delete; without --yes it previews and refuses +kb view 1 +kb update 1 --check "reproduce" --check "x patch" +kb move 1 doing +kb done 1 +kb cancel 1 +kb restore 1 +kb rm 1 --yes ``` -In local mode, tasks are addressed by UUID — any unique prefix works -(`kb move 8c1f doing`); ambiguous prefixes are rejected with a hint. +Run `kb help` for the full flag reference. Supported verbs are `add`, `list`, +`view`, `update`, `move`, `done`, `cancel`, `restore`, `rm`, `users`, `comment`, +`link`, and `unlink`. -### Remote mode +Tasks have stable per-board sequence numbers such as `#12`; bare `12` and a +unique UUID prefix also work. `cancel` is reversible. `rm --yes` permanently +deletes a Cancelled task. Moving to Done is refused while checklist items or +blockers remain unless `--force` is explicit. -Set `KB_SERVER` and the CLI talks HTTP to a running kb server (the markdown -wire API) instead of opening the database: +Every task command accepts `--data`, `--user`, and `--json`. Set `KB_SERVER` to +use the same verbs against an optional HTTP server: -```bash -export KB_SERVER=https://kb.example.com -export KB_SERVER_TOKEN= # omit against an open server -kb list --user ak -# ID STATUS PRIO TITLE TAGS -# i1 todo 1 Migrate CI runner infra,env::prod -kb done i1 +```sh +export KB_SERVER=http://127.0.0.1:8080 +export KB_SERVER_TOKEN=shared-secret +export KB_USER=alice +kb list ``` -`KB_SERVER_TOKEN` is sent as `Authorization: Bearer ...` and `--user` as -`X-KB-User`, so remote mode pairs with token-mode or open servers (Entra -servers expect short-lived Entra tokens, which don't fit a static env var). - -The markdown wire format carries no task ids, so **remote task ids are -ephemeral listing indexes** — `i1`, `i2`, ... in listing order (To Do, Doing, -Done, Cancelled; top to bottom within a column). They are valid only against -the board as currently listed: re-run `kb list` after the board changes before -addressing tasks. A bare number (`kb done 1`) is accepted as shorthand. +`users` is local-only because one authenticated API identity exposes one board. ## MCP server -`kb mcp` serves the board as an MCP server (name `kb`) over stdio, built on -the official `github.com/modelcontextprotocol/go-sdk`. It opens the local -database directly — no kb server needs to be running. +`kb mcp` serves the local board over MCP stdio. It opens SQLite directly; no +HTTP server is required. -```bash -kb mcp [--data DIR] [--user NAME] # defaults: $KB_DATA or ~/.local/share/kb; $KB_USER or "default" +```sh +kb mcp --data ~/.local/share/kb --user default ``` -Tools: - -| Tool | Purpose | -| ----------------- | -------------------------------------------------------------------------------- | -| `list_tasks` | List tasks (optionally one column), ordered by column then position | -| `add_task` | Add a task; only `title` is required (`blocked` optional) | -| `update_task` | Patch fields of a task by id (unique id prefix accepted) | -| `move_task` | Move a task to `todo`, `doing`, `done`, or `cancelled` | -| `delete_task` | Delete a task by id; soft by default (see below) | -| `search_similar` | Use before creating a card to search card text, tags, and import history | -| `duplicate_check` | Use before creating a proposed card to check its text and exact provenance link | - -`move_task` to `done` fails with an error naming the open checklist items, or -reporting the blocked flag, unless `force: true` is passed. - -`delete_task` takes `soft: boolean`, **default `true`**: the task moves to the -`cancelled` column and can be moved back, so an agent cannot destroy work by -accident. Pass `soft: false` for the permanent row delete. - -Harness configuration, Claude-style JSON (Claude Code `.mcp.json`, Claude -Desktop `claude_desktop_config.json`): - -```json -{ - "mcpServers": { - "kb": { - "command": "kb", - "args": ["mcp", "--user", "ak"], - "env": { "KB_DATA": "/home/you/.local/share/kb" } - } - } -} -``` - -Codex-style TOML (`~/.codex/config.toml`): +Example configuration: ```toml [mcp_servers.kb] command = "kb" -args = ["mcp", "--user", "ak"] +args = ["mcp"] ``` -## Integrations - -Open **Settings → Integrations** to add named GitLab or GitHub sources. Both -public hosts (`gitlab.com`, `github.com`) and self-managed GitLab or GitHub -Enterprise hosts are supported; set each source's base URL to the host and any -installation path prefix. Public repositories can be used without a personal -access token (PAT); private content requires a token with access to the source. - -Forge PATs are write-only. The server seals them with AES-256-GCM at rest, -returns only `has_token` to the browser, and sends them to the forge only in -the provider's authentication header (`PRIVATE-TOKEN` for GitLab, -`Authorization: Bearer` for GitHub) — never in a URL, query string, or AI -request. Changing a source to another origin without entering a replacement -PAT clears the saved token. - -Forge requests reject loopback, link-local, private, and unspecified addresses -by default. For a source on an enterprise LAN, set -`KB_FORGE_ALLOW_PRIVATE=gitlab.example.com`; use a comma-separated hostname -allowlist for more than one source. `1` or `*` allows every configured forge -host to resolve privately and should be reserved for controlled environments. -This setting belongs only to forge traffic. `KB_AI_ALLOW_PRIVATE` remains the -separate, unchanged opt-in for the configured AI endpoint, and -`KB_LINK_ALLOW_PRIVATE` — same syntax — is the separate opt-in for the links a -skill run fetches. - -### Importing issues - -Issue import also requires [AI assist](#ai-assist-and-settings). Choose -**Import issues**, select a configured source, and enter an issue, project or -repository, board, or milestone URL. A GitLab board URL resolves to that -project's open issues; a named GitHub source also accepts `owner/repo`. kb -fetches at most 20 open issues per preview, asks the configured model to turn -them into card proposals, and shows exact-link and similar-card hints. Review -and edit the proposals, choose a destination column, then press **Add -selected**. - -**Imports are one-shot AI transformations, not clones or mirrors; there is no -background sync.** Later changes on the forge do not update cards, and card -changes do not update forge issues. Import again when you want a new snapshot. - -### Checking imported issues - -Use **Check upstream** on an imported card when you want to compare it with the -current forge issue. -*a drift check is a one-time comparison you ask for; kb never syncs, never polls, and never writes to your forge.* - -The first check only records the issue's current state as the baseline. Any -drift between import and that first check is not detected; later checks compare -against the recorded baseline. - -Drift baselines keep the issue title and a bounded body excerpt as plaintext in -the local database. Anyone who can read that database file can read the -excerpt, including text captured from a private issue. - -## AI assist and settings - -The story modal can draft a card from a prompt. `POST /api/ai/story` takes -`{"mode":"create"|"update","prompt":"...","task":{...}}` (`task` is the -current card JSON in update mode) and returns a structured draft — -`{"title","desc","prio","due","effort","tags","checks"}` — that prefills the -modal. **You always confirm before anything is saved.** The server calls the -model, clamps every field into the card contract, and never forwards your API -key to the browser. - -The **Split ADR** header button (shown only when AI is configured) turns either -an architecture decision record or one configured forge issue into a set of -stories. `POST /api/ai/stories` accepts exactly one input mode: -`{"adr":"","max":}` or -`{"url":"","source":"","max":}`. In -either mode `max` defaults to 8 and is clamped to 1..20. In URL mode the -server fetches the issue and its bounded discussion, assembles at most 64 KiB -of context, and adds the canonical `link::` provenance tag to every returned -story. The response is `{"stories":[…]}` plus top-level `link`/`url` in URL -mode; each story has the same shape `/api/ai/story` returns. A pasted ADR over -64 KiB is rejected (`413`), source text is never stored as an ADR, and every -returned story goes through the same field clamping. The SPA lists the -proposals with checkboxes and a destination column; nothing reaches the board -until you press **Add selected**. - -Configure it on the gear screen (or via `PUT /api/settings`): an -OpenAI-compatible base URL, a model name, and an API key. The base URL is -accepted with or without `/v1`; the server appends `/v1/chat/completions`. -A test-connection button (`POST /api/ai/test`) sends a trivial `ping` tool and -passes only if the model answers with a call to it: tool calling is a -prerequisite for the AI features, so a model that replies with prose fails with -"model must support tool calling". It accepts -an optional body `{"ai_base_url","ai_model","ai_key"}` and tests those values -instead of the stored ones, so the gear screen can check a key *before* you -save it; a key sent this way is used for that one request and never persisted, -and leaving the key field blank tests the stored key against the form's URL and -model — but only while that URL keeps the stored scheme and host. Testing a -*different* origin with a blank key is refused ("enter the API key to test a -different endpoint"), for the same reason a save re-points: the stored key -never follows an endpoint you moved. With no body it tests the saved settings. -Examples: - -| Provider | Base URL | Model example | Key | -| ------------------------- | ---------------------------- | ------------------- | -------- | -| OpenAI | `https://api.openai.com` | `gpt-4o-mini` | required | -| ollama.com (cloud shim) | `https://ollama.com` | `qwen3-coder:480b` | required | -| Local Ollama | `http://localhost:11434` | `llama3.1` | none | - -**Output budget**: every request states one explicitly, sized per call site and -never above 4096 tokens — the smallest completion cap still in wide use, so the -budget itself is never the reason a request is rejected. Reasoning models -(`o1`/`o3`/`o4`, `gpt-5*`) get it as `max_completion_tokens`, which is the only -field they accept; everything else gets `max_tokens`. A reply that reaches the -budget comes back as `422` "the model's reply hit the output limit and was cut -off", not as a JSON parse failure. The upstream reply itself is capped at 1 MiB. -Nothing the server's own environment happens to define (`OPENAI_API_KEY`, -`OPENAI_ORG_ID`, `OPENAI_PROJECT_ID`, `OPENAI_CUSTOM_HEADERS`, …) travels to a -configured endpoint. - -**Key handling**: the API key is write-only and masked in the UI — -`GET /api/settings` returns only `has_key`, never the key. At rest it is -encrypted with AES-256-GCM. The encryption secret is `KB_SECRET` if that env -var is set; otherwise a 32-byte key is generated once at `/secret` -(mode `0600`). Sending `"ai_key": ""` clears the stored key. Changing the -base URL to a different scheme or host without re-sending the key also -clears it (the PUT answers `{"key_cleared":true}`) — a stored credential -never follows a re-pointed endpoint. - -**Private endpoints**: the AI client refuses to connect to loopback, -link-local, or private addresses (checked on the resolved IP). For a local -model server such as Ollama, start kb with `KB_AI_ALLOW_PRIVATE=1`. - -### Skills - -A skill is a markdown file — YAML frontmatter with `name` and `description`, -then instructions — that tells the model how to do one job on your board. -`POST /api/ai/run-skill` takes `{"skill":"","input":"","max": -}` and runs it as a tool loop rather than a single completion. The -answer is `{"cards":[…],"commentary":"…"}`: `cards` are the drafts the model -proposed, `commentary` is its closing prose. Input over 64 KiB is rejected -(`413`), an unknown skill answers `404`, and the run is bounded at 12 upstream -rounds and four minutes. - -The model reaches your board only through tools: `propose_card` (the only way -a card leaves a run — nothing parses the reply as JSON), `find_similar`, -`list_tasks`, `get_task`, `update_task`, and `fetch_link`, which reads one -http(s) document through an SSRF guard of its own (`KB_LINK_ALLOW_PRIVATE`) -and accepts only `text/*` or `application/json` up to 64 KiB. Proposals are -capped server-side by `max` (default 8, clamped to 1..20); past the cap the -tool returns an error the model can see instead of silently dropping the card. -No tool can move or delete a card. - -`update_task` and `fetch_link` are offered only to `POST /api/ai/run-skill`, -whose input you wrote. `POST /api/ai/stories` may be splitting a forge issue -whose body and comments anyone can write, so its run gets the read-only set: -it can read the board and propose drafts, and it can neither change a card nor -make an outbound request. - -`adr-split` ships built in; it is what `POST /api/ai/stories` runs. Drop a -`.md` file into `/skills/` to add your own, or to replace a built-in -skill by reusing its `name`. The directory is optional, but a file in it that -does not parse fails the request rather than disappearing from the catalogue. - -## Labels - -Every task write (SPA, CLI, MCP, import) upserts the task's tags into a -per-user label registry with a last-used stamp. `GET /api/labels` returns -your labels most-recently-used first and feeds the combobox in the story -modal — existing labels are suggested, free text is always allowed. Scoped -labels (`key::value`, e.g. `env::prod`) render as two-tone pills. - -## Debug overlay - -A performance and capability readout. Turn it on in the app: **⚙ Settings → -Show debug overlay**. The choice is persisted in localStorage (`kb.debug.v1`), -so the overlay stays as you left it across reloads. Untick the same box, or -press the `×` on the overlay itself, to turn it off. - -A URL parameter does the same thing for support on a machine you cannot click -through — it overrides the stored flag for that load, and persists it: +The server exposes task operations plus board and workflow context resources. +Writes use the same store invariants as the TUI and CLI. + +## Optional HTTP API +Start the API explicitly: + +```sh +kb serve --port 8080 --data ~/.local/share/kb +kb serve --help ``` -http://localhost:8080/?debug=1 # on -http://localhost:8080/?debug=0 # off + +`kb serve` owns `--port`, `--data`, and `--log`. Environment defaults are +`KB_PORT`, `KB_DATA`, and `KB_LOG_FILE`. + +The server is API-only. All non-API paths, unknown API paths, and wrong methods +return 404. It keeps the existing API, including `/api/config`, for remote +clients: + +```text +GET /api/health +GET /api/config +GET /api/board +PUT /api/board +GET /api/tasks +POST /api/tasks +GET /api/tasks/{ref} +PATCH /api/tasks/{ref} +DELETE /api/tasks/{ref} +GET /api/tasks/{ref}/comments +POST /api/tasks/{ref}/comments +DELETE /api/comments/{id} +POST /api/links +DELETE /api/links +GET /api/labels +GET /api/similar +POST /api/tombstones +GET /api/settings +PUT /api/settings +POST /api/ai/test +POST /api/ai/story +POST /api/ai/stories +POST /api/ai/run-skill +POST /api/import/preview +POST /api/import/links +POST /api/import/provenance +POST /api/import/drift +POST /api/import/drift/accept +GET /api/integrations +PUT /api/integrations/{name} +DELETE /api/integrations/{name} +POST /api/integrations/{name}/test ``` -When active, the overlay displays: -- **FPS meter** — rolling average frame rate over the last ~60 frames, updated - ≤4 times per second so the readout is readable. -- **Renderer capabilities** — shows whether `navigator.gpu` (WebGPU) is - available and whether a WebGL2 context can be created, each marked as - available/unavailable. Note: the board is DOM-rendered and confetti uses - canvas 2D; this is a capability survey only. -- **Frame-rate cap selector** — choose 60 / 90 / 120 FPS or uncapped. The - selected cap throttles the app's animation frame work by skipping frames, - and can only *limit* the display refresh rate, never raise it. - -The overlay is keyboard-dismissible, does not intercept pointer events over the -board, and carries zero performance cost when disabled. The ⚙ button is always -in the header, including when no server is reachable — the overlay is a local -display preference, and needing it is likeliest exactly then. - -## Native controls kb does not draw - -Everything in the board is styled to one visual system, with one deliberate -exception: parts of a few native controls belong to the browser or the OS and -cannot be reached from CSS. kb styles the *closed* control and stops there. -The calendar is the one replacement kb does draw: the native panel sat in the -middle of the card form looking like a different product, so the ▦ button -beside the due field opens kb's own popover (month grid, weeks starting -Monday; arrows move a day or a week, PageUp/PageDown a month, Enter picks, -Escape closes). The segmented date *input* stays the browser's — typing and -arrow-key editing keep the native grammar. What remains browser-drawn: - -- **The `` option list, the file chooser and the number spinners are the - browser's; their contrast and target size are whatever the browser does. See - "Native controls kb does not draw". -- **1.4.13 Content on Hover or Focus** was not evaluated. kb uses `title` - tooltips in a few places, and browser-drawn tooltips are not dismissible - without moving the pointer. -- **2.4.1 Bypass Blocks** — there is no skip link. The board has a shallow tab - order and no repeated navigation block, so the criterion is arguably not - applicable, but this has not been confirmed against a long board. -- **1.2.x (time-based media)** and **3.1.2 (language of parts)** are not - applicable: kb ships no audio or video, and its interface is English-only. - User content in another language is not marked up as such. -- **Contrast headroom is thin.** The green chip at 4.57:1 clears 4.5:1 by - 0.07. Any darkening of the ink or lightening of that green fails it, so the - palette is not free to drift. - -## Identity modes - -The server picks its mode from environment variables at startup: - -1. **Azure Entra ID** — set `KB_AZURE_TENANT_ID` and `KB_AZURE_CLIENT_ID`. - Requests to `/api/*` must carry `Authorization: Bearer ` - (the SPA's ID token). The server validates the token itself — RS256 - signature against the tenant's JWKS, issuer, audience, and expiry — and - takes the user identity from the token's immutable `oid` claim (tokens - without `oid` are rejected; `email`/`preferred_username` are mutable and - reassignable, so they are display-only and never select the board). - **Token decode/verify only: the server never calls Microsoft Graph or any - other Microsoft API.** -2. **Shared token** — else, if `KB_TOKEN` is set, requests must carry - `Authorization: Bearer ` (compared in constant time). The user - identity is the required, non-empty `X-KB-User` header — a manual unique id - you choose (e.g. your email or a handle). - In the browser this token is held in `sessionStorage`, never - `localStorage`: it authorizes *every* user's board, so it must not outlive - the browser session. Your identity does persist, so a new session starts - signed in but without a token — the board still works from local storage, - the header shows a **Reconnect** button, and a *Session expired* dialog - asks for the token. Reconnecting verifies it against the server before - adopting it, then pushes anything edited meanwhile. -3. **Open / localhost** — else, no auth. Identity is the `X-KB-User` header if - present, otherwise `default`. Only use this on a trusted local machine. - In this mode the server binds to `127.0.0.1` by default; set `KB_BIND` to - another address only if you understand that every reachable client can - read and write every board and settings entry. - -In Azure mode, the browser also persists MSAL's immutable `homeAccountId` and -uses it to select exactly one cached account and to namespace the device-local -state that remains (today's shipped tally). It never falls back to MSAL's active -account or the first cached account. An older email-only identity is upgraded only when -exactly one cached account has that username; zero or multiple matches require -sign-in again before a token is requested. - -Legacy email-keyed browser state is claimed under a Web Lock. The claim owner -is recorded before any copy, so the same `homeAccountId` can resume an -interrupted copy; a different account fails closed. If Web Locks are -unavailable, kb leaves the legacy data untouched and requires reauthentication -or manual import instead of guessing ownership. This browser key is separate -from the server's board identity, which remains the verified token's immutable -`oid` claim. - -### How identity maps to boards - -Every identity owns exactly one board: all rows in the database are keyed by -`sanitize(identity)`. `sanitize` lowercases the identity and rejects any -character outside `[a-z0-9._@-]` (rejected, not substituted — substitution -would collapse distinct identities such as `anna#lee@corp.com` and -`anna-lee@corp.com` onto the same board). Empty names and names starting with -`.` are also rejected. In Entra mode the identity is the account's `oid` -GUID, so Entra boards are keyed per directory object, not per email; -token-mode boards are keyed by the manual id you choose. Legacy markdown -boards are imported from `/.md`, so the same rules -applied when those files were written. - -## Azure app registration - -You need one app registration; no client secret, no Graph permissions. - -1. In the Azure portal, open **Microsoft Entra ID → App registrations → New - registration**. Name it (e.g. `kb`), pick the supported account types for - your tenant. -2. Under **Authentication**, add a **Single-page application (SPA)** platform - and set the redirect URI to **exactly the origin kb is served from, with no - trailing slash and no path**: - - | How you run kb | Redirect URI to register | - | --- | --- | - | the binary, default port | `http://localhost:8080` | - | the binary, `KB_PORT=9000` | `http://localhost:9000` | - | behind a domain | `https://kb.example.com` | - | `npm run dev` (Vite) | `http://localhost:5173` | - - kb sends `window.location.origin` as its redirect URI, and Entra matches - redirect URIs **exactly** — `https://kb.example.com/` with a trailing slash - is a different URI and fails with `AADSTS50011`. Register every origin you - actually use; a dev URI does not cover production. SPA platform means PKCE; - **do not create a client secret**. - - > **The platform matters as much as the URI.** Registered under **Web** - > instead of **SPA**, Entra returns the authorization code in the query - > string rather than the fragment. MSAL watches the fragment, so sign-in - > hangs forever on "Completing sign-in…" with no error. kb detects this - > exact case and says so in the popup. Same URI, wrong platform, silent - > stall — check this first if sign-in never completes. - - One app registration can serve several applications: add each app's origin - as its own SPA redirect URI on the same registration. Sharing the client id - is fine — kb only ever reads the ID token's `oid` claim to key your board, - and never calls Graph. -3. Under **API permissions**, the default delegated `openid`, `profile`, - `email` scopes are all kb needs. **Do not add Microsoft Graph or any other - permission.** -4. Copy the **Directory (tenant) ID** and **Application (client) ID** onto the - server as `KB_AZURE_TENANT_ID` / `KB_AZURE_CLIENT_ID` and restart it. That - is all a released binary needs: the server validates the tokens the SPA - sends *and* serves the two IDs to the browser from `GET /api/config`, which - is what turns on the Microsoft sign-in button. Both IDs are public by - design — every MSAL SPA ships them in its bundle — so nothing secret is - exposed; the endpoint returns these two fields and nothing else. - -5. Only when running the Vite dev server (`npm run dev`, no kb server behind - it) do you need the build-time fallback: - - ```bash - VITE_AZURE_TENANT_ID= - VITE_AZURE_CLIENT_ID= - npm run dev - ``` - - `GET /api/config` wins whenever it answers, so a value baked into the - bundle never overrides the running server's configuration. - -## Server environment reference - -| Variable | Kind | Default | Purpose | -| ---------------------- | ------------------ | -------------------- | ---------------------------------------------------------------------------- | -| `KB_PORT` | server, runtime | `8080` | Listen port (`--port` flag overrides) | -| `KB_BIND` | server, runtime | unset | Bind address; open mode defaults to `127.0.0.1`, other modes to all ifaces | -| `KB_AI_ALLOW_PRIVATE` | server, runtime | unset | `1` lets the AI proxy reach loopback/private addresses (local Ollama) | -| `KB_FORGE_ALLOW_PRIVATE` | server, runtime | unset | Comma-separated forge hostnames allowed to resolve privately; `1` or `*` allows all | -| `KB_LINK_ALLOW_PRIVATE` | server, runtime | unset | Same syntax, for the links a skill run fetches; independent of the forge setting | -| `KB_DATA` | all modes, runtime | `~/.local/share/kb` | Data directory: `kb.db`, `secret`, `skills/`, legacy `.md` boards (`--data` overrides) | -| `KB_LOG_FILE` | server, runtime | unset | Append logs to this file, created with mode `0600` (`--log` overrides) | -| `KB_TOKEN` | server, runtime | unset | Shared bearer token; enables token mode when Azure vars are unset | -| `KB_AZURE_TENANT_ID` | server, runtime | unset | Entra tenant ID; with client ID, enables Entra mode; served to the SPA by `GET /api/config` | -| `KB_AZURE_CLIENT_ID` | server, runtime | unset | Entra app (client) ID; expected token audience; served to the SPA by `GET /api/config` | -| `KB_ALLOWED_HOSTS` | server, runtime | unset | Comma-separated extra `Host` values the API accepts; loopback is always allowed | -| `KB_SECRET` | server/CLI/MCP | unset | AES-GCM secret for stored AI keys and forge PATs; if unset, generated at `/secret`. Under 16 bytes the server refuses to start and the CLI/MCP warn | -| `KB_SERVER` | CLI, runtime | unset | Remote mode: base URL of a kb server; CLI talks HTTP instead of the local DB | -| `KB_SERVER_TOKEN` | CLI, runtime | unset | Bearer token the CLI sends in remote mode (the server's `KB_TOKEN`) | -| `KB_USER` | TUI/MCP, runtime | `default` | Board user for `kb tui` and `kb mcp` (`--user` flag overrides) | -| `VITE_AZURE_TENANT_ID` | frontend, build | unset | Dev-server fallback only: baked into the bundle, used when `GET /api/config` returns no tenant ID. For a released binary set `KB_AZURE_TENANT_ID` | -| `VITE_AZURE_CLIENT_ID` | frontend, build | unset | Dev-server fallback only: baked into the bundle, used when `GET /api/config` returns no client ID. For a released binary set `KB_AZURE_CLIENT_ID` | - -Mode precedence at startup: Azure pair set → Entra mode; else `KB_TOKEN` set → -token mode; else open mode. - -When `KB_LOG_FILE` or `--log` is set, kb opens the file once in append mode and -creates it with permissions `0600`. Log rotation is the operator's job. kb does -not reopen the handle on `SIGHUP`, so `logrotate` configurations must use -`copytruncate`. With neither setting, logs continue to go to stderr. - -## Running under systemd +Request bodies are capped at 1 MiB. Mutation routes require their documented +JSON or Markdown content type. The server preserves conditional-write, +idempotency, auth, Host, Origin, CSRF, redirect, timeout, import, and logging +contracts from the previous release. + +### Binding and authentication + +Auth mode is selected at startup: + +1. `KB_AZURE_TENANT_ID` plus `KB_AZURE_CLIENT_ID`: Entra bearer tokens. The + immutable `oid` claim is the board owner. +2. `KB_TOKEN`: shared bearer token. `X-KB-User` selects the board. +3. Neither: open mode. `X-KB-User` or `default` selects the board. + +Open mode binds to `127.0.0.1` by default. `KB_BIND` explicitly changes the +bind address. Loopback Host headers are always accepted; add real deployment +hosts with `KB_ALLOWED_HOSTS`. Token and Entra modes may bind externally, but +TLS termination remains the operator's responsibility. + +Do not expose open mode to an untrusted network. Host pinning, same-origin +checks, strict content types, bounded bodies, safe redirects, and server +timeouts remain enabled, but they are not authentication. + +### systemd example ```ini [Unit] -Description=kb board server -After=network-online.target -Wants=network-online.target +Description=kb API server +After=network.target [Service] -Slice=workload.slice -ExecStart=/usr/local/bin/kb --port 8080 --data /var/lib/kb -Environment=KB_AZURE_TENANT_ID= -Environment=KB_AZURE_CLIENT_ID= - -# Run as an ephemeral unprivileged user (or use User=kb with a dedicated user). -DynamicUser=yes -StateDirectory=kb - -# Hardening +Type=simple +User=kb +Group=kb +Environment=KB_BIND=127.0.0.1 +ExecStart=/usr/local/bin/kb serve --port 8080 --data /var/lib/kb +Restart=on-failure +NoNewPrivileges=true +PrivateTmp=true ProtectSystem=strict -ProtectHome=yes -PrivateTmp=yes -NoNewPrivileges=yes +ProtectHome=true ReadWritePaths=/var/lib/kb -Restart=on-failure [Install] WantedBy=multi-user.target ``` -With `DynamicUser=yes`, `StateDirectory=kb` provisions `/var/lib/kb` with -correct ownership; the database (`kb.db`) and the encryption secret file live -there. If you prefer a fixed account, replace `DynamicUser=yes` with -`User=kb` and create the user and data directory yourself. Terminate TLS in -front of the binary (Caddy, nginx, etc.) — kb itself speaks plain HTTP. - -## What kb sends over the network - -kb is an on-device app with zero telemetry. There is no analytics, no crash -reporting, no font or asset CDN, and no update check. This section is the -exhaustive list of traffic kb can originate, including the parts that are not -entirely ours to control. - -**1. Same-origin requests to kb's own API.** The only traffic a signed-in -board generates. Loading the app fetches `/assets/*.js`, `/assets/*.css` and -`/favicon.ico`; the app then calls `/api/config`, `/api/health`, `/api/board`, -`/api/labels` and `/api/settings`; `/api/similar` while editing a card; -`/api/integrations/*` in Settings; `/api/import/*` when importing issues; and -`/api/ai/story`, `/api/ai/stories` and `/api/ai/test` when you use AI assist. -Nothing else. The emoji table is compiled into the bundle -(`@emoji-mart/data`), so the picker fetches neither its data nor a spritesheet -— it renders system emoji glyphs. All fonts are system stacks; there is no -`@font-face` pointing off-origin. - -This is enforced, not merely observed. The SPA response carries a -Content-Security-Policy that blocks cross-origin requests: +If `--log` is used, give the service write access to its parent directory. The +file is created with mode `0600` and appended to. Request logs never include +headers or tokens. + +## AI, skills, and forge access +AI and forge settings are managed in the TUI and stored in SQLite. The shared +AI runner is used directly by the TUI and by thin HTTP handlers. Draft and ADR +split operations are read-only until the user reviews and saves results. + +Outbound requests reject private, loopback, link-local, and metadata addresses +by default, including after DNS resolution and redirects. Controlled local +development can opt in: + +- `KB_AI_ALLOW_PRIVATE=1` for a local AI endpoint; +- `KB_FORGE_ALLOW_PRIVATE=host1,host2` for selected forge hosts, or `1` for all; +- `KB_LINK_ALLOW_PRIVATE=host1,host2` for links fetched by skills, or `1` for + all. + +These are separate trust decisions. Enabling one does not weaken the others. +Stored provider and forge secrets are write-only through the API and are never +returned in settings responses. + +## Environment reference + +| Variable | Used by | Meaning | +| --- | --- | --- | +| `KB_DATA` | all local modes, serve | Data directory | +| `KB_USER` | TUI, CLI, MCP | Default board owner | +| `KB_SECRET` | all store users | Encryption secret override | +| `KB_SERVER` | task CLI | Optional remote API base URL | +| `KB_SERVER_TOKEN` | task CLI | Bearer token for remote mode | +| `KB_PORT` | serve | Listen port, default `8080` | +| `KB_BIND` | serve | Bind address | +| `KB_LOG_FILE` | serve | Append-only log file | +| `KB_TOKEN` | serve | Shared bearer token | +| `KB_AZURE_TENANT_ID` | serve | Entra tenant | +| `KB_AZURE_CLIENT_ID` | serve | Entra audience/client ID | +| `KB_ALLOWED_HOSTS` | serve | Extra accepted Host values | +| `KB_AI_ALLOW_PRIVATE` | AI runner | Permit private AI endpoints | +| `KB_FORGE_ALLOW_PRIVATE` | forge service | Permit private forge hosts | +| `KB_LINK_ALLOW_PRIVATE` | skills runner | Permit private fetched links | + +## Development and release + +The primary local gates are: + +```sh +sh scripts/check-go-checkers.test.sh +sh scripts/check-go-coverage.sh +go test -race ./... -count=1 +go vet ./... +sh scripts/check-go-format.sh +node scripts/ci/test_ci_monitor.cjs ``` -default-src 'self'; connect-src 'self'; script-src 'self'; -style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; -frame-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none'; -frame-ancestors 'none' + +The CI monitor test uses only Node built-ins. It remains because it validates +the repository's workflow monitor; it is not a frontend dependency. + +Release builds are Go-only and start from a clean plain clone: + +```sh +bash scripts/release.sh vX.Y.Z --dry-run ``` -`style-src` allows inline styles for two reasons: kb's own React -`style={{…}}` attributes carry values that have to be computed (the drag -clone's position, progress-bar widths, tag colours), and emoji-mart styles its -shadow-DOM picker by setting the text of a `