Reindex command, implicit note shorthand, add→addfile rename

- Add `kb reindex` command with confirmation prompt and --yes flag
- Add implicit note shorthand: `kb "my note"` submits a note directly
- Rename `add` to `addfile`, remove --note/--title/--type flags
- Add client-side file extension validation before upload
- Add `kb examples` command for common usage patterns
- Update README, SKILL.md, and main specs
- Archive completed changes and sync delta specs

BREAKING: `kb add` renamed to `kb addfile`, `kb add --note` replaced by `kb "text"`

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-29 13:58:04 +01:00
parent 528a09ca90
commit 7f4decee26
26 changed files with 786 additions and 142 deletions
+103 -24
View File
@@ -18,6 +18,73 @@ Go CLI (kb) ──HTTP──▶ FastAPI Engine (Docker) ──▶ SQLite + GPU
### 1. Start the engine
**From pre-built images** (recommended):
```bash
# NVIDIA GPU
docker run -d --name kb-engine \
--gpus all \
-p 8000:8000 \
-v ~/kb-data:/data \
-e KB_MODEL=all-MiniLM-L6-v2 \
-e KB_DEVICE=auto \
-e KB_API_KEY=your-secret-key \
--restart unless-stopped \
docker.dcglab.co.uk/dcg/kb/engine:latest-nvidia
# AMD GPU (ROCm)
docker run -d --name kb-engine \
--device /dev/kfd --device /dev/dri \
--group-add video \
-p 8000:8000 \
-v ~/kb-data:/data \
-e KB_MODEL=all-MiniLM-L6-v2 \
-e KB_DEVICE=auto \
-e KB_API_KEY=your-secret-key \
--restart unless-stopped \
docker.dcglab.co.uk/dcg/kb/engine:latest-rocm
```
Or use a compose file — create `compose.yaml`:
```yaml
services:
kb-engine:
image: docker.dcglab.co.uk/dcg/kb/engine:latest-nvidia # or latest-rocm
runtime: nvidia # remove for ROCm
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
# For ROCm, replace the above runtime/deploy block with:
# devices:
# - "/dev/kfd"
# - "/dev/dri"
# group_add:
# - "video"
ports:
- "${KB_PORT:-8000}:8000"
volumes:
- ${KB_DATA_PATH:-./data}:/data
environment:
- KB_MODEL=${KB_MODEL:-all-MiniLM-L6-v2}
- KB_DEVICE=${KB_DEVICE:-auto}
- KB_INGEST_DEVICE=${KB_INGEST_DEVICE:-auto}
- KB_API_KEY=${KB_API_KEY:-}
- KB_SEARCH_THRESHOLD=${KB_SEARCH_THRESHOLD:-0.01}
- HF_HUB_OFFLINE=${HF_HUB_OFFLINE:-}
restart: unless-stopped
```
```bash
KB_DATA_PATH=~/kb-data docker compose up -d
```
**From source** (for development):
```bash
cd engine
@@ -65,10 +132,13 @@ Override via environment variables (`KB_ENGINE_URL`, `KB_API_KEY`) or CLI flags
### 4. Use it
```bash
# Add documents (async — uploads and exits immediately)
kb add ~/docs/manual.pdf --tags admin
kb add ~/notes/ --recursive
kb add --note "Always restart nginx after config changes" --tags ops
# Quick notes (shorthand — no subcommand needed)
kb "Always restart nginx after config changes"
kb "Server room is building 3, floor 2" --tags ops
# Add files (async — uploads and exits immediately)
kb addfile ~/docs/manual.pdf --tags admin
kb addfile ~/notes/ --recursive
# Check ingestion progress
kb jobs
@@ -82,6 +152,7 @@ kb list
kb info 1
kb tags
kb tag 1 --add important
kb export 1 -o manual.pdf # download original file
kb remove 3 --yes
kb status
```
@@ -100,12 +171,14 @@ The engine is configured via environment variables (set in the compose file or v
|---|---|---|
| `KB_DATA_DIR` | `/data` | Data directory inside the container (bind-mounted) |
| `KB_MODEL` | `all-MiniLM-L6-v2` | HuggingFace embedding model name |
| `KB_DEVICE` | `auto` | Embedding device: `auto`, `cpu`, or `cuda` |
| `KB_INGEST_DEVICE` | `auto` | Docling layout detection device |
| `KB_DEVICE` | `auto` | Embedding/search device: `auto`, `cpu`, or `cuda` |
| `KB_INGEST_DEVICE` | `auto` | Docling layout detection device: `auto`, `cpu`, or `cuda` |
| `KB_API_KEY` | (none) | Optional Bearer token for API authentication |
| `KB_SEARCH_THRESHOLD` | `0.01` | Minimum score for search results (filters noise) |
| `KB_PORT` | `8000` | Port to expose |
| `KB_DATA_PATH` | `./data` | Host path for bind mount (compose variable) |
| `KB_HOST` | `0.0.0.0` | Host to bind to |
| `HF_HUB_OFFLINE` | (none) | Set to `1` to prevent model downloads (use cached only) |
| `KB_DATA_PATH` | `./data` | Host path for bind mount (compose variable, not used by engine) |
## Data portability
@@ -134,7 +207,8 @@ All endpoints are under `/api/v1/`. Requires `Authorization: Bearer <key>` heade
| `GET` | `/jobs/{id}` | Job details |
| `GET` | `/documents` | List documents |
| `GET` | `/documents/{id}` | Document details with chunks |
| `DELETE` | `/documents/{id}` | Remove a document |
| `GET` | `/documents/{id}/file` | Download original file |
| `DELETE` | `/documents/{id}` | Remove a document (and stored file) |
| `PUT` | `/documents/{id}/tags` | Add/remove tags |
| `GET` | `/tags` | List all tags |
| `GET` | `/status` | Engine status, GPU info, DB stats |
@@ -142,26 +216,31 @@ All endpoints are under `/api/v1/`. Requires `Authorization: Bearer <key>` heade
## Building and releasing
Versioning is managed via `client/VERSION` and `engine/VERSION` files. The release script bumps these, builds all artifacts, tags, and publishes in one step.
Client and engine are versioned independently via `client/VERSION` and `engine/VERSION`. Each has its own release script and git tag prefix.
### Release
### Release client
```bash
./release.sh --gitea # patch bump (e.g. 2.0.0 → 2.0.1), release via Gitea
./release.sh --github --minor # minor bump (e.g. 2.0.1 → 2.1.0), release via GitHub
./release.sh --gitea --major # major bump (e.g. 2.1.0 → 3.0.0)
./release.sh --gitea --no-increment # release current version as-is
./release.sh --gitea --dry-run # preview without doing anything
./release-client.sh --gitea # patch bump, release via Gitea
./release-client.sh --github --minor # minor bump, release via GitHub
./release-client.sh --gitea --no-increment # release current version as-is
./release-client.sh --gitea --dry-run # preview without doing anything
```
The script will:
Creates tag `client-vX.Y.Z`, builds Go binaries for all platforms, and creates a Gitea/GitHub release with binaries attached.
1. Bump the version in both `client/VERSION` and `engine/VERSION` (unless `--no-increment`)
2. Build Go client binaries for all platforms (linux/darwin/windows, amd64/arm64)
3. Build Docker engine images for NVIDIA and ROCm
4. Commit the version bump, create an annotated git tag, and push
5. Create a release (with client binaries attached) via `tea` or `gh`
6. Push Docker images to the registry
The client embeds a `MinEngineVersion` (from `client/MIN_ENGINE_VERSION`) and will hard-fail if the connected engine is too old.
### Release engine
```bash
./release-engine.sh --gitea # patch bump, release via Gitea
./release-engine.sh --github --minor # minor bump, release via GitHub
./release-engine.sh --gitea --no-increment # release current version as-is
./release-engine.sh --gitea --dry-run # preview without doing anything
```
Creates tag `engine-vX.Y.Z`, builds NVIDIA and ROCm Docker images, creates a Gitea/GitHub release, and pushes images to the registry.
### Checking versions
@@ -177,13 +256,13 @@ curl http://localhost:8000/api/v1/status | jq .version
Images are pushed to `docker.dcglab.co.uk/dcg/kb/engine` with tags:
- `v2.1.0-nvidia` / `v2.1.0-rocm` — versioned
- `engine-v2.0.6-nvidia` / `engine-v2.0.6-rocm` — versioned
- `latest-nvidia` / `latest-rocm` — latest release
Override the registry and org via environment variables:
```bash
REGISTRY=ghcr.io IMAGE_ORG=myorg ./release.sh --github
REGISTRY=ghcr.io IMAGE_ORG=myorg ./release-engine.sh --github
```
## Future: ROCm runtime migration
+70 -14
View File
@@ -1,6 +1,6 @@
# kb-search skill
Search the user's personal knowledge base containing PDFs, markdown documents, code snippets, and text notes.
Search, manage, and add to the user's personal knowledge base containing PDFs, Word docs, HTML, markdown, code files, and text notes.
## When to use
@@ -8,10 +8,18 @@ Search the user's personal knowledge base containing PDFs, markdown documents, c
- User explicitly says "check my notes", "search kb", "look in my knowledge base", "what do my docs say about..."
- User references documents or notes they've previously stored
- User asks "how do I..." style questions that their knowledge base likely covers
- User wants to save a note, add a file, or manage their knowledge base
## Available commands
## Quick notes
### Search (primary)
```bash
kb "remember to update DNS records" # add a note
kb "server room is building 3, floor 2" --tags ops # add a tagged note
```
Bare text without a subcommand is treated as a note and submitted for ingestion.
## Search (primary use case)
```bash
kb search "<query>" --top 10 --format json
@@ -20,25 +28,72 @@ kb search "<query>" --top 10 --format json
Returns JSON with ranked results combining full-text and semantic search.
**Flags:**
- `--top N` — number of results (default: 10)
- `-n, --top N` — number of results (default: 10)
- `--tags tag1,tag2` — filter by tags (AND logic)
- `--type pdf|markdown|code|note` — filter by document type
- `--format json|human` — output format (always use json)
- `--format json|human` — output format (always use json for parsing)
- `--fts-only` — keyword search only (skip semantic)
- `--vec-only` — semantic search only (skip keyword)
- `--threshold FLOAT` — minimum score cutoff
### Other useful commands
## Adding files
```bash
kb list --format json # List all documents
kb list --type pdf --format json # List only PDFs
kb tags --format json # List tags with counts
kb info <doc_id> --format json # Document details
kb status --format json # DB stats
kb addfile report.pdf # single file
kb addfile report.pdf --tags admin,reference # with tags
kb addfile ~/docs/ --recursive # directory (recursive)
kb addfile ~/docs/ --recursive --tags reference # directory with tags
```
## Output format (search)
Supported file types: `.pdf`, `.docx`, `.html`, `.md`, `.txt`, `.py`, `.sh`, `.go`. Unsupported extensions are rejected before upload.
**Flags:**
- `--tags tag1,tag2` — tags (comma-separated)
- `-r, --recursive` — recursively add directory contents
## Document management
```bash
kb list --format json # list all documents
kb list --type pdf --format json # filter by type
kb list --tags admin --format json # filter by tags
kb info <doc_id> --format json # document details with chunks
kb export <doc_id> -o file.pdf # download original file
kb remove <doc_id> # remove (prompts for confirmation)
kb remove <doc_id> --yes # remove without confirmation
```
## Tag management
```bash
kb tags --format json # list all tags with counts
kb tag <doc_id> --add important,ops # add tags to a document
kb tag <doc_id> --remove draft # remove tags from a document
```
## Jobs (ingestion queue)
```bash
kb jobs --format json # list recent jobs
kb jobs --status failed --format json # filter by status
kb jobs <job_id> --format json # job details
```
## Engine status and maintenance
```bash
kb status --format json # engine status, GPU info, DB stats
kb reindex --yes # re-embed all chunks (skip confirmation)
```
## Global flags
All commands support:
- `--format json|human` — output format (always use `json` for machine parsing)
- `--engine <url>` — engine API URL (default: http://localhost:8000)
- `--api-key <key>` — API key for authentication
## Search output format
```json
{
@@ -66,7 +121,7 @@ kb status --format json # DB stats
}
```
## How to answer
## How to answer search queries
1. Run `kb search "<query>" --top 10 --format json`
2. Read the returned chunks
@@ -93,7 +148,7 @@ Query 2: kb search "git merge explanation" --top 5 --format json
Query 3: kb search "git rebase vs merge" --top 5 --format json
```
## Filtering
## Filtering tips
Use filters when the question implies a specific domain:
@@ -108,3 +163,4 @@ Use filters when the question implies a specific domain:
- `source.page` is only present for PDF documents
- `source.section_header` is only present for markdown documents with headers
- Results are already ranked by relevance (hybrid FTS + vector search)
- Duplicate files are detected at upload time (HTTP 409) — the client handles this gracefully
+75 -83
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"github.com/kb-search/kb/internal/api"
@@ -39,93 +40,25 @@ var supportedExts = map[string]bool{
".go": true,
}
var addCmd = &cobra.Command{
Use: "add <path>",
Short: "Add a document or directory to the knowledge base",
Args: cobra.MaximumNArgs(1),
RunE: runAdd,
var addfileCmd = &cobra.Command{
Use: "addfile <path>",
Short: "Upload a file or directory to the knowledge base",
Args: cobra.ExactArgs(1),
RunE: runAddfile,
}
func init() {
addCmd.Flags().String("tags", "", "tags (comma-separated)")
addCmd.Flags().String("type", "", "document type")
addCmd.Flags().BoolP("recursive", "r", false, "recursively add directory contents")
addCmd.Flags().String("note", "", "add a text note instead of a file")
addCmd.Flags().String("title", "", "title for the note")
rootCmd.AddCommand(addCmd)
addfileCmd.Flags().String("tags", "", "tags (comma-separated)")
addfileCmd.Flags().BoolP("recursive", "r", false, "recursively add directory contents")
rootCmd.AddCommand(addfileCmd)
}
func runAdd(cmd *cobra.Command, args []string) error {
func runAddfile(cmd *cobra.Command, args []string) error {
tags, _ := cmd.Flags().GetString("tags")
docType, _ := cmd.Flags().GetString("type")
recursive, _ := cmd.Flags().GetBool("recursive")
note, _ := cmd.Flags().GetString("note")
title, _ := cmd.Flags().GetString("title")
client := api.NewClient()
// Note mode
if note != "" {
fields := map[string]string{
"note": note,
}
if title != "" {
fields["title"] = title
}
if tags != "" {
fields["tags"] = tags
}
if docType != "" {
fields["type"] = docType
}
resp, err := client.PostMultipart("/api/v1/jobs", fields, nil)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if resp.StatusCode == http.StatusConflict {
var result interface{}
if err := api.DecodeJSON(resp, &result); err != nil {
return fmt.Errorf("failed to decode response: %w", err)
}
if output.IsJSON() {
output.PrintJSON(result)
} else {
if m, ok := result.(map[string]interface{}); ok {
if docID, ok := m["document_id"].(float64); ok {
fmt.Printf("Already imported: %s (doc ID: %.0f)\n", m["title"], docID)
} else if jobID, ok := m["job_id"].(float64); ok {
fmt.Printf("Already queued: %s (job ID: %.0f)\n", m["title"], jobID)
}
}
}
return nil
}
if err := api.CheckError(resp); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
var result interface{}
if err := api.DecodeJSON(resp, &result); err != nil {
return fmt.Errorf("failed to decode response: %w", err)
}
if output.IsJSON() {
output.PrintJSON(result)
} else {
fmt.Println("Queued: note")
}
return nil
}
if len(args) == 0 {
return fmt.Errorf("path argument is required (or use --note)")
}
path := args[0]
info, err := os.Stat(path)
if err != nil {
@@ -133,8 +66,19 @@ func runAdd(cmd *cobra.Command, args []string) error {
}
if !info.IsDir() {
// Validate extension
ext := strings.ToLower(filepath.Ext(path))
if !supportedExts[ext] {
supported := make([]string, 0, len(supportedExts))
for e := range supportedExts {
supported = append(supported, e)
}
sort.Strings(supported)
return fmt.Errorf("unsupported file type %q — supported: %s", ext, strings.Join(supported, ", "))
}
// Single file upload
result, err := uploadFile(client, path, tags, docType)
result, err := uploadFile(client, path, tags)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
@@ -177,7 +121,7 @@ func runAdd(cmd *cobra.Command, args []string) error {
queued := 0
duplicates := 0
for _, f := range files {
result, err := uploadFile(client, f, tags, docType)
result, err := uploadFile(client, f, tags)
if err != nil {
fmt.Fprintf(os.Stderr, "Error uploading %s: %v\n", f, err)
continue
@@ -206,7 +150,7 @@ func runAdd(cmd *cobra.Command, args []string) error {
return nil
}
func uploadFile(client *api.Client, path, tags, docType string) (*uploadResult, error) {
func uploadFile(client *api.Client, path, tags string) (*uploadResult, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("cannot open %s: %w", path, err)
@@ -217,9 +161,6 @@ func uploadFile(client *api.Client, path, tags, docType string) (*uploadResult,
if tags != "" {
fields["tags"] = tags
}
if docType != "" {
fields["type"] = docType
}
upload := &api.FileUpload{
FieldName: "file",
@@ -264,3 +205,54 @@ func uploadFile(client *api.Client, path, tags, docType string) (*uploadResult,
}
return &uploadResult{Raw: result}, nil
}
func submitNote(client *api.Client, note, tags string) error {
fields := map[string]string{
"note": note,
}
if tags != "" {
fields["tags"] = tags
}
resp, err := client.PostMultipart("/api/v1/jobs", fields, nil)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if resp.StatusCode == http.StatusConflict {
var result interface{}
if err := api.DecodeJSON(resp, &result); err != nil {
return fmt.Errorf("failed to decode response: %w", err)
}
if output.IsJSON() {
output.PrintJSON(result)
} else {
if m, ok := result.(map[string]interface{}); ok {
if docID, ok := m["document_id"].(float64); ok {
fmt.Printf("Already imported: %s (doc ID: %.0f)\n", m["title"], docID)
} else if jobID, ok := m["job_id"].(float64); ok {
fmt.Printf("Already queued: %s (job ID: %.0f)\n", m["title"], jobID)
}
}
}
return nil
}
if err := api.CheckError(resp); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
var result interface{}
if err := api.DecodeJSON(resp, &result); err != nil {
return fmt.Errorf("failed to decode response: %w", err)
}
if output.IsJSON() {
output.PrintJSON(result)
} else {
fmt.Println("Queued: note")
}
return nil
}
+37
View File
@@ -0,0 +1,37 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
var examplesCmd = &cobra.Command{
Use: "examples",
Short: "Show common usage examples",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
fmt.Print(`Quick notes:
kb "Remember to update DNS records"
kb "Server room is building 3" --tags ops
Add files:
kb addfile report.pdf
kb addfile ~/docs/ --recursive --tags reference
Search:
kb search "how to restart nginx"
kb search "deploy" --tags ops --top 5
Manage documents:
kb list --type pdf
kb info 3
kb tag 3 --add important,ops
kb remove 3 --yes
`)
},
}
func init() {
rootCmd.AddCommand(examplesCmd)
}
+83
View File
@@ -0,0 +1,83 @@
package cmd
import (
"bufio"
"fmt"
"os"
"strings"
"github.com/kb-search/kb/internal/api"
"github.com/kb-search/kb/internal/output"
"github.com/spf13/cobra"
)
var reindexCmd = &cobra.Command{
Use: "reindex",
Short: "Re-embed all chunks with the current engine model",
Args: cobra.NoArgs,
RunE: runReindex,
}
func init() {
reindexCmd.Flags().BoolP("yes", "y", false, "skip confirmation prompt")
rootCmd.AddCommand(reindexCmd)
}
func runReindex(cmd *cobra.Command, args []string) error {
yes, _ := cmd.Flags().GetBool("yes")
client := api.NewClient()
if !yes {
// Fetch model name from engine status
modelName := "current"
statusResp, err := client.Get("/api/v1/status")
if err == nil && api.CheckError(statusResp) == nil {
var status struct {
ModelName string `json:"model_name"`
}
if api.DecodeJSON(statusResp, &status) == nil && status.ModelName != "" {
modelName = status.ModelName
}
}
fmt.Printf("Reindex all chunks? This will re-embed everything with the %s model. [y/N] ", modelName)
reader := bufio.NewReader(os.Stdin)
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
if answer != "y" && answer != "yes" {
fmt.Println("Cancelled.")
return nil
}
}
resp, err := client.Post("/api/v1/reindex", nil)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if err := api.CheckError(resp); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
var result struct {
ChunksReindexed int `json:"chunks_reindexed"`
Model string `json:"model"`
}
if output.IsJSON() {
var raw interface{}
if err := api.DecodeJSON(resp, &raw); err != nil {
fmt.Fprintln(os.Stderr, "Failed to parse response:", err)
os.Exit(1)
}
output.PrintJSON(raw)
} else {
if err := api.DecodeJSON(resp, &result); err != nil {
fmt.Fprintln(os.Stderr, "Failed to parse response:", err)
os.Exit(1)
}
fmt.Printf("Reindexed %d chunks (model: %s)\n", result.ChunksReindexed, result.Model)
}
return nil
}
+31 -2
View File
@@ -3,6 +3,7 @@ package cmd
import (
"fmt"
"os"
"strings"
"github.com/kb-search/kb/internal/api"
"github.com/kb-search/kb/internal/config"
@@ -22,9 +23,10 @@ var (
)
var rootCmd = &cobra.Command{
Use: "kb",
Use: "kb [\"note text\" | command]",
Short: "kb-search CLI client",
Long: "A CLI client for the kb-search v2 engine API.",
Long: "A CLI client for the kb-search v2 engine API.\nRun 'kb examples' for common usage patterns.",
Args: cobra.ArbitraryArgs,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
if err := config.Load(); err != nil {
return err
@@ -32,14 +34,41 @@ var rootCmd = &cobra.Command{
config.ApplyFlags(flagEngine, flagFormat, flagAPIKey)
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return cmd.Help()
}
note := strings.Join(args, " ")
tags, _ := cmd.Flags().GetString("tags")
client := api.NewClient()
return submitNote(client, note, tags)
},
}
func init() {
api.SetVersionInfo(Version, MinEngineVersion)
rootCmd.Version = Version
rootCmd.SetUsageTemplate(`Quick note taking:
kb "note text" [flags]
Normal usage:
kb [command] [flags]{{if .HasAvailableSubCommands}}
Available Commands:{{range .Commands}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
Flags:
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
Global Flags:
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}
Use "{{.CommandPath}} [command] --help" for more information about a command.
`)
rootCmd.PersistentFlags().StringVar(&flagEngine, "engine", "", "engine API URL")
rootCmd.PersistentFlags().StringVar(&flagFormat, "format", "", "output format (human|json)")
rootCmd.PersistentFlags().StringVar(&flagAPIKey, "api-key", "", "API key for authentication")
rootCmd.Flags().String("tags", "", "tags for note shorthand (comma-separated)")
}
// Execute runs the root command.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-29
@@ -0,0 +1,23 @@
## Context
The engine's `POST /api/v1/reindex` re-embeds all chunks synchronously and returns `{"chunks_reindexed": N, "model": "..."}`. The client has an established confirmation pattern in `remove.go` using `--yes`/`-y` flag.
## Goals / Non-Goals
**Goals:**
- Add `kb reindex` with confirmation prompt matching `kb remove` pattern
- Display human-readable and JSON output
**Non-Goals:**
- Progress reporting during reindex (engine returns synchronously)
- Model selection from the client (model is engine-side config)
## Decisions
### 1. Confirmation prompt before reindex
Reindex drops and rebuilds the vector table — destructive if interrupted. Use the same `[y/N]` prompt pattern as `kb remove`, skippable with `--yes`/`-y`.
### 2. Warn that it may take a while
The prompt should mention that reindex re-embeds all chunks, so the user knows it's not instant.
@@ -0,0 +1,22 @@
## Why
The engine exposes `POST /api/v1/reindex` but there's no client command for it. Users switching embedding models must use curl directly. Adding `kb reindex` with a confirmation prompt keeps it consistent with other destructive commands like `kb remove`.
## What Changes
- Add `kb reindex` command to the Go client with confirmation prompt (skip with `--yes`/`-y`)
- Display reindex results (chunks reindexed, model used)
## Capabilities
### New Capabilities
(none)
### Modified Capabilities
- `go-client`: Add reindex command requirement
## Impact
- New file: `client/cmd/reindex.go`
@@ -0,0 +1,25 @@
## ADDED Requirements
### Requirement: Reindex command
The client SHALL provide a `kb reindex` command that triggers re-embedding of all chunks on the engine. The command SHALL prompt for confirmation before proceeding.
#### Scenario: Reindex with confirmation
- **WHEN** the user runs `kb reindex`
- **THEN** the client SHALL display a warning that all chunks will be re-embedded and prompt `Reindex all chunks? This will re-embed everything. [y/N]`. If confirmed, it SHALL POST to `/api/v1/reindex` and display the result.
#### Scenario: Reindex with skip confirmation
- **WHEN** the user runs `kb reindex --yes`
- **THEN** the client SHALL skip the confirmation prompt and POST to `/api/v1/reindex` immediately
#### Scenario: Reindex cancelled
- **WHEN** the user runs `kb reindex` and responds with anything other than `y` or `yes`
- **THEN** the client SHALL print `Cancelled.` and exit with code 0
#### Scenario: Reindex human output
- **WHEN** the reindex completes successfully with default format
- **THEN** the client SHALL print `Reindexed N chunks (model: <model_name>)`
#### Scenario: Reindex JSON output
- **WHEN** the user runs `kb reindex --yes --format json`
- **THEN** the client SHALL output the raw JSON response from the engine
@@ -0,0 +1,5 @@
## 1. Implementation
- [x] 1.1 Create `client/cmd/reindex.go` with `kb reindex` command, `--yes`/`-y` flag, confirmation prompt matching `remove.go` pattern
- [x] 1.2 POST to `/api/v1/reindex`, handle human output (`Reindexed N chunks (model: ...)`) and JSON output
- [x] 1.3 Verify build compiles and command appears in `kb --help`
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-29
@@ -0,0 +1,43 @@
## Context
The `add` command currently handles both file uploads and notes via a `--note` string flag. This creates confusing flag parsing and a muddled help screen. The engine already auto-detects file type from extension (`detector.py`) and rejects unsupported ones, so the client's `--type` flag is redundant.
## Goals / Non-Goals
**Goals:**
- `kb "my note"` as the sole note entry path (replaces `kb add --note`)
- `kb addfile <path>` as a file-only upload command (replaces `kb add`)
- Client-side extension validation before uploading
- Clean, unambiguous help text for both paths
**Non-Goals:**
- Engine changes — type detection stays server-side
- Backward compatibility shim for `kb add` — clean break
- Client-side MIME type detection — extension check is sufficient
## Decisions
### Rename add → addfile, strip note/type flags
Rename the cobra command from `add` to `addfile`. Remove `--note`, `--title`, and `--type` flags. Keep `--tags`, `--recursive`. The command becomes purely about file uploads.
**Why not keep `add` as an alias?** Clean break is simpler. The old form was confusing — better to force a quick migration than maintain two paths.
### Extension validation on single file uploads
The `supportedExts` map already gates recursive walks. Apply the same check to single file uploads — reject with a clear error listing supported extensions. This gives instant feedback instead of a round-trip to the engine.
### Root command RunE for note shorthand
Use cobra's `Args: cobra.ArbitraryArgs` and `RunE` on the root command. When args are present and no subcommand matched, join all args into a single note string and submit. `--tags` flag on root for tagging notes. No `--title` — keep it minimal.
**Why join all args?** `kb remember to update dns` (unquoted) should work the same as `kb "remember to update dns"`.
### Reuse note submission logic via shared helper
Extract `submitNote` from the current `runAdd` so both the root command and any future callers use the same POST + duplicate-handling + output logic.
## Risks / Trade-offs
- **Breaking change** → Anyone with `kb add` in scripts needs to update to `kb addfile`. Acceptable for a personal tool.
- **No `--type` override** → If a user ever needs to force a type, they'd have to go through the engine API directly. Low risk since the engine's auto-detection covers all supported formats.
@@ -0,0 +1,34 @@
## Why
Adding a note requires `kb add --note "my note"` — too much ceremony for what should be instant. The `--note` flag taking a string value also creates confusing flag parsing (e.g. `kb add --note --tags foo` parses `--tags` as the note value). Meanwhile, `kb add` tries to do two things (files and notes) which muddies its help text and UX.
Splitting these into distinct paths makes the CLI clearer:
- **Notes**: `kb "my note"` — zero-friction, no subcommand needed
- **Files**: `kb addfile report.pdf` — explicit, file-only command
## What Changes
- **Add `kb "text"` shorthand**: bare string arguments without a subcommand are treated as notes, submitted via `POST /api/v1/jobs`
- **Rename `add``addfile`**: the command becomes file-only, no more `--note`/`--title` flags
- **Drop `--type` flag**: the engine already auto-detects type from file extension (`detector.py`); the client doesn't need to override this
- **Add client-side extension validation**: reject unsupported file extensions with a clear error before uploading, using the same extension set as recursive directory walks
- **Update README**: document the new shorthand and renamed command
- **BREAKING**: `kb add` no longer exists; `kb add --note` no longer exists
## Capabilities
### New Capabilities
_(none)_
### Modified Capabilities
- `go-client`: Rename `add` to `addfile`, remove `--note`/`--title`/`--type` flags, add extension validation for single file uploads, add implicit note shorthand on root command
## Impact
- `client/cmd/add.go` → renamed/refactored to `addfile` command, stripped of note logic, added extension check
- `client/cmd/root.go` — bare args handling + `--tags` flag for note shorthand
- `README.md` — updated usage examples
- No engine changes — engine already detects type from extension and rejects unsupported files
- Breaking change for any scripts using `kb add` or `kb add --note`
@@ -0,0 +1,95 @@
## ADDED Requirements
### Requirement: Implicit note shorthand
The client SHALL treat bare string arguments (with no subcommand) as an implicit note. `kb "my note"` SHALL behave identically to submitting a note via `POST /api/v1/jobs`. All persistent flags (`--format`, `--engine`, `--api-key`) and the root `--tags` flag SHALL work with the shorthand form.
#### Scenario: Quick note via bare argument
- **WHEN** the user runs `kb "remember to update DNS"`
- **THEN** the client SHALL submit the text as a note via `POST /api/v1/jobs` and print `Queued: note`
#### Scenario: Bare argument with tags
- **WHEN** the user runs `kb "server room is building 3" --tags ops`
- **THEN** the client SHALL submit the note with the specified tags
#### Scenario: Bare argument with JSON output
- **WHEN** the user runs `kb "my note" --format json`
- **THEN** the client SHALL output the raw JSON response from the engine
#### Scenario: Bare argument duplicate detection
- **WHEN** the user runs `kb "my note"` and the engine returns HTTP 409
- **THEN** the client SHALL handle the duplicate response identically to the previous `kb add --note` behaviour
#### Scenario: Multiple unquoted words
- **WHEN** the user runs `kb remember to update dns` (without quotes)
- **THEN** the client SHALL join all arguments into a single note string and submit it
#### Scenario: No interference with subcommands
- **WHEN** the user runs `kb search "query"` or any other existing subcommand
- **THEN** the client SHALL route to the subcommand as before — the implicit note shorthand SHALL NOT interfere
#### Scenario: No arguments
- **WHEN** the user runs `kb` with no arguments
- **THEN** the client SHALL display the help text
---
## MODIFIED Requirements
### Requirement: Add command (file and note ingestion)
The client SHALL provide a `kb addfile` command that uploads files to the engine for async ingestion. The command SHALL validate file extensions before uploading and reject unsupported types. The client SHALL handle duplicate rejection (HTTP 409) and display the existing document information. The command SHALL NOT handle notes — notes are submitted via the implicit note shorthand (`kb "text"`).
#### Scenario: Add a single file
- **WHEN** the user runs `kb addfile report.pdf`
- **THEN** the client SHALL validate the file extension, upload the file via `POST /api/v1/jobs` (multipart), print "Queued: report.pdf", and exit
#### Scenario: Add a file with tags
- **WHEN** the user runs `kb addfile manual.pdf --tags car,maintenance`
- **THEN** the client SHALL include the tags in the multipart upload metadata
#### Scenario: Add a directory recursively
- **WHEN** the user runs `kb addfile ~/documents/ --recursive`
- **THEN** the client SHALL discover all supported files in the directory tree, upload each one sequentially, and print "Queued: N files"
#### Scenario: Unsupported file extension
- **WHEN** the user runs `kb addfile photo.jpg`
- **THEN** the client SHALL print an error listing supported extensions and exit with a non-zero code without making any API call
#### Scenario: Duplicate file rejected (already ingested)
- **WHEN** the user runs `kb addfile report.pdf` and the engine returns HTTP 409 with `{"error": "duplicate", "document_id": 42, "title": "report.pdf"}`
- **THEN** the client SHALL print "Already imported: report.pdf (doc ID: 42)" and exit with code 0
#### Scenario: Duplicate file rejected (in-flight job)
- **WHEN** the user runs `kb addfile report.pdf` and the engine returns HTTP 409 with `{"error": "duplicate", "job_id": 7, "title": "report.pdf"}`
- **THEN** the client SHALL print "Already queued: report.pdf (job ID: 7)" and exit with code 0
#### Scenario: Duplicate file in recursive add
- **WHEN** the user runs `kb addfile ~/documents/ --recursive` and some files are rejected as duplicates
- **THEN** the client SHALL print the duplicate message for each rejected file, continue uploading remaining files, and include a summary (e.g., "Queued: 5 files, 2 duplicates skipped")
#### Scenario: Duplicate with JSON output
- **WHEN** the user runs `kb addfile report.pdf --format json` and the engine returns HTTP 409
- **THEN** the client SHALL output the raw JSON response from the engine including the document_id and title
#### Scenario: Add with JSON output
- **WHEN** the user runs `kb addfile report.pdf --format json`
- **THEN** the client SHALL output the JSON response from the engine including the job_id
#### Scenario: File not found
- **WHEN** the user runs `kb addfile nonexistent.pdf`
- **THEN** the client SHALL print an error and exit with a non-zero code without making any API call
#### Scenario: Upload failure
- **WHEN** the upload fails (network error, engine returns 4xx/5xx other than 409)
- **THEN** the client SHALL print the error and exit with a non-zero code
## REMOVED Requirements
### Requirement: Note ingestion via add command
**Reason**: Notes are now submitted via the implicit note shorthand (`kb "text"`). The `--note` and `--title` flags on the add command are removed.
**Migration**: Use `kb "my note"` or `kb "my note" --tags ops` instead of `kb add --note "my note" --tags ops`.
### Requirement: Document type override via add command
**Reason**: The engine auto-detects document type from file extension (`detector.py`). The client `--type` flag is redundant.
**Migration**: Remove `--type` from scripts. The engine handles type detection automatically.
@@ -0,0 +1,19 @@
## 1. Refactor note submission
- [x] 1.1 Extract note submission logic from `runAdd` into a shared `submitNote` helper (multipart POST, duplicate detection, output formatting)
## 2. Root command shorthand
- [x] 2.1 Add `Args: cobra.ArbitraryArgs` and `RunE` to the root command — join args into a note string, call `submitNote`; show help when no args
- [x] 2.2 Add `--tags` flag on the root command for note tagging
## 3. Rename add → addfile
- [x] 3.1 Rename command from `add` to `addfile` (`Use: "addfile <path>"`)
- [x] 3.2 Remove `--note`, `--title`, and `--type` flags from the command
- [x] 3.3 Add extension validation for single file uploads — reject unsupported extensions with a clear error listing supported types
## 4. Documentation and verification
- [x] 4.1 Update README.md usage section: show `kb "text"` shorthand, rename `add` references to `addfile`
- [x] 4.2 Verify build compiles, `kb --help` and `kb addfile --help` show expected output
+5 -1
View File
@@ -67,7 +67,7 @@ The engine SHALL store all persistent state (SQLite database, HF model cache, st
### Requirement: Compose files for deployment
The project SHALL provide Docker Compose files for single-command deployment.
The project SHALL provide Docker Compose files for single-command deployment. Compose files SHALL use `build:` context for local development. Release notes SHALL document the versioned image tag for users pulling pre-built images.
#### Scenario: Start NVIDIA deployment
- **WHEN** an admin runs `docker compose -f compose.nvidia.yaml up -d`
@@ -85,6 +85,10 @@ The project SHALL provide Docker Compose files for single-command deployment.
- **WHEN** an admin sets environment variables in the compose file (KB_MODEL, KB_API_KEY, KB_DEVICE, etc.)
- **THEN** the engine SHALL use those values
#### Scenario: Pre-built image deployment
- **WHEN** an admin wants to use a pre-built engine image without building from source
- **THEN** the engine release notes SHALL include the exact `docker pull` command with the versioned tag (e.g. `docker.dcglab.co.uk/dcg/kb/engine:engine-v2.1.0-nvidia`)
---
### Requirement: CPU-only fallback
+2 -2
View File
@@ -194,11 +194,11 @@ The engine SHALL provide endpoints to list all tags and manage tags on documents
### Requirement: Engine status and reindex
The engine SHALL provide status information and support re-embedding all chunks.
The engine SHALL provide status information and support re-embedding all chunks. The `version` field in the status response SHALL always be present and SHALL reflect the engine's release version as read from the `VERSION` file. This field is the contract used by clients for compatibility checking.
#### Scenario: Get engine status
- **WHEN** a client sends `GET /api/v1/status`
- **THEN** the engine SHALL return JSON with model_name, embedding_dim, GPU device info, database stats (document count by type, total chunks, DB size), and queue stats (queued/processing job count)
- **THEN** the engine SHALL return JSON with `version` (string, from VERSION file), model_name, embedding_dim, GPU device info, database stats (document count by type, total chunks, DB size), and queue stats (queued/processing job count)
#### Scenario: Trigger reindex
- **WHEN** a client sends `POST /api/v1/reindex`
+110 -16
View File
@@ -8,12 +8,16 @@ The Go client (`kb`) provides a command-line interface for interacting with the
### Requirement: Single static binary with zero runtime dependencies
The Go client SHALL compile to a single static binary with no runtime dependencies. It SHALL support cross-compilation for Linux (amd64, arm64), macOS (amd64, arm64), and Windows (amd64).
The Go client SHALL compile to a single static binary with no runtime dependencies. It SHALL support cross-compilation for Linux (amd64, arm64), macOS (amd64, arm64), and Windows (amd64). The build SHALL inject both `Version` and `MinEngineVersion` via ldflags.
#### Scenario: Install on a clean machine
- **WHEN** a user downloads the `kb` binary for their platform
- **THEN** they SHALL be able to run it immediately with no additional installs (no Python, no Docker, no shared libraries)
#### Scenario: Version and compatibility info embedded at build time
- **WHEN** the client is built with `make all VERSION=2.1.0 MIN_ENGINE_VERSION=2.0.0`
- **THEN** `kb --version` SHALL report `2.1.0` and the compatibility check SHALL use `2.0.0` as the minimum engine version
---
### Requirement: Client configuration
@@ -64,48 +68,82 @@ The client SHALL provide a `kb search <query>` command that sends the query to t
---
### Requirement: Implicit note shorthand
The client SHALL treat bare string arguments (with no subcommand) as an implicit note. `kb "my note"` SHALL behave identically to submitting a note via `POST /api/v1/jobs`. All persistent flags (`--format`, `--engine`, `--api-key`) and the root `--tags` flag SHALL work with the shorthand form.
#### Scenario: Quick note via bare argument
- **WHEN** the user runs `kb "remember to update DNS"`
- **THEN** the client SHALL submit the text as a note via `POST /api/v1/jobs` and print `Queued: note`
#### Scenario: Bare argument with tags
- **WHEN** the user runs `kb "server room is building 3" --tags ops`
- **THEN** the client SHALL submit the note with the specified tags
#### Scenario: Bare argument with JSON output
- **WHEN** the user runs `kb "my note" --format json`
- **THEN** the client SHALL output the raw JSON response from the engine
#### Scenario: Bare argument duplicate detection
- **WHEN** the user runs `kb "my note"` and the engine returns HTTP 409
- **THEN** the client SHALL handle the duplicate response identically to the previous `kb add --note` behaviour
#### Scenario: Multiple unquoted words
- **WHEN** the user runs `kb remember to update dns` (without quotes)
- **THEN** the client SHALL join all arguments into a single note string and submit it
#### Scenario: No interference with subcommands
- **WHEN** the user runs `kb search "query"` or any other existing subcommand
- **THEN** the client SHALL route to the subcommand as before — the implicit note shorthand SHALL NOT interfere
#### Scenario: No arguments
- **WHEN** the user runs `kb` with no arguments
- **THEN** the client SHALL display the help text
---
### Requirement: Add command (file and note ingestion)
The client SHALL provide a `kb add` command that uploads files or notes to the engine for async ingestion. The client SHALL exit immediately after a successful upload. The client SHALL handle duplicate rejection (HTTP 409) and display the existing document information.
The client SHALL provide a `kb addfile` command that uploads files to the engine for async ingestion. The command SHALL validate file extensions before uploading and reject unsupported types. The client SHALL handle duplicate rejection (HTTP 409) and display the existing document information. The command SHALL NOT handle notes — notes are submitted via the implicit note shorthand (`kb "text"`).
#### Scenario: Add a single file
- **WHEN** the user runs `kb add report.pdf`
- **THEN** the client SHALL upload the file via `POST /api/v1/jobs` (multipart), print "Queued: report.pdf", and exit
- **WHEN** the user runs `kb addfile report.pdf`
- **THEN** the client SHALL validate the file extension, upload the file via `POST /api/v1/jobs` (multipart), print "Queued: report.pdf", and exit
#### Scenario: Add a file with tags
- **WHEN** the user runs `kb add manual.pdf --tags car,maintenance`
- **WHEN** the user runs `kb addfile manual.pdf --tags car,maintenance`
- **THEN** the client SHALL include the tags in the multipart upload metadata
#### Scenario: Add a directory recursively
- **WHEN** the user runs `kb add ~/documents/ --recursive`
- **WHEN** the user runs `kb addfile ~/documents/ --recursive`
- **THEN** the client SHALL discover all supported files in the directory tree, upload each one sequentially, and print "Queued: N files"
#### Scenario: Add a text note
- **WHEN** the user runs `kb add --note "The server room is in building 3, floor 2"`
- **THEN** the client SHALL submit the note text via `POST /api/v1/jobs` (multipart with note field), print "Queued: note", and exit
#### Scenario: Unsupported file extension
- **WHEN** the user runs `kb addfile photo.jpg`
- **THEN** the client SHALL print an error listing supported extensions and exit with a non-zero code without making any API call
#### Scenario: Duplicate file rejected (already ingested)
- **WHEN** the user runs `kb add report.pdf` and the engine returns HTTP 409 with `{"error": "duplicate", "document_id": 42, "title": "report.pdf"}`
- **WHEN** the user runs `kb addfile report.pdf` and the engine returns HTTP 409 with `{"error": "duplicate", "document_id": 42, "title": "report.pdf"}`
- **THEN** the client SHALL print "Already imported: report.pdf (doc ID: 42)" and exit with code 0
#### Scenario: Duplicate file rejected (in-flight job)
- **WHEN** the user runs `kb add report.pdf` and the engine returns HTTP 409 with `{"error": "duplicate", "job_id": 7, "title": "report.pdf"}`
- **WHEN** the user runs `kb addfile report.pdf` and the engine returns HTTP 409 with `{"error": "duplicate", "job_id": 7, "title": "report.pdf"}`
- **THEN** the client SHALL print "Already queued: report.pdf (job ID: 7)" and exit with code 0
#### Scenario: Duplicate file in recursive add
- **WHEN** the user runs `kb add ~/documents/ --recursive` and some files are rejected as duplicates
- **THEN** the client SHALL print the duplicate message for each rejected file (distinguishing "Already imported" from "Already queued"), continue uploading remaining files, and include a summary (e.g., "Queued: 5 files, 2 duplicates skipped")
- **WHEN** the user runs `kb addfile ~/documents/ --recursive` and some files are rejected as duplicates
- **THEN** the client SHALL print the duplicate message for each rejected file, continue uploading remaining files, and include a summary (e.g., "Queued: 5 files, 2 duplicates skipped")
#### Scenario: Duplicate with JSON output
- **WHEN** the user runs `kb add report.pdf --format json` and the engine returns HTTP 409
- **WHEN** the user runs `kb addfile report.pdf --format json` and the engine returns HTTP 409
- **THEN** the client SHALL output the raw JSON response from the engine including the document_id and title
#### Scenario: Add with JSON output
- **WHEN** the user runs `kb add report.pdf --format json`
- **WHEN** the user runs `kb addfile report.pdf --format json`
- **THEN** the client SHALL output the JSON response from the engine including the job_id
#### Scenario: File not found
- **WHEN** the user runs `kb add nonexistent.pdf`
- **WHEN** the user runs `kb addfile nonexistent.pdf`
- **THEN** the client SHALL print an error and exit with a non-zero code without making any API call
#### Scenario: Upload failure
@@ -186,6 +224,62 @@ The client SHALL provide a `kb status` command to display engine status.
---
### Requirement: Reindex command
The client SHALL provide a `kb reindex` command that triggers re-embedding of all chunks on the engine. The command SHALL prompt for confirmation before proceeding.
#### Scenario: Reindex with confirmation
- **WHEN** the user runs `kb reindex`
- **THEN** the client SHALL display a warning that all chunks will be re-embedded and prompt `Reindex all chunks? This will re-embed everything. [y/N]`. If confirmed, it SHALL POST to `/api/v1/reindex` and display the result.
#### Scenario: Reindex with skip confirmation
- **WHEN** the user runs `kb reindex --yes`
- **THEN** the client SHALL skip the confirmation prompt and POST to `/api/v1/reindex` immediately
#### Scenario: Reindex cancelled
- **WHEN** the user runs `kb reindex` and responds with anything other than `y` or `yes`
- **THEN** the client SHALL print `Cancelled.` and exit with code 0
#### Scenario: Reindex human output
- **WHEN** the reindex completes successfully with default format
- **THEN** the client SHALL print `Reindexed N chunks (model: <model_name>)`
#### Scenario: Reindex JSON output
- **WHEN** the user runs `kb reindex --yes --format json`
- **THEN** the client SHALL output the raw JSON response from the engine
---
### Requirement: Engine version compatibility check
The client SHALL verify that the connected engine meets a minimum version requirement before executing any API command. The minimum required engine version SHALL be embedded in the client binary at build time. If the engine version is below the minimum, the client SHALL print an error message and exit with a non-zero code. There SHALL be no flag to skip or suppress this check.
#### Scenario: Compatible engine version
- **WHEN** the client connects to an engine reporting version `2.1.5` and `MinEngineVersion` is `2.1.0`
- **THEN** the client SHALL proceed with the command normally
#### Scenario: Incompatible engine version
- **WHEN** the client connects to an engine reporting version `2.0.3` and `MinEngineVersion` is `2.1.0`
- **THEN** the client SHALL print to stderr: `Error: kb client vX.Y.Z requires engine v2.1.0+ (connected engine is v2.0.3)` followed by an upgrade hint, and exit with code 1
#### Scenario: Engine unreachable during version check
- **WHEN** the client cannot reach the engine's `/api/v1/status` endpoint
- **THEN** the client SHALL skip the version check and proceed with the original command (the actual API call will surface the connectivity error)
#### Scenario: Version check is cached per session
- **WHEN** the client has already verified engine compatibility during the current invocation
- **THEN** subsequent API calls within the same invocation SHALL NOT repeat the version check
#### Scenario: Client version command does not check engine
- **WHEN** the user runs `kb --version`
- **THEN** the client SHALL print the client version without contacting the engine
#### Scenario: MinEngineVersion not set
- **WHEN** the client binary has `MinEngineVersion` set to empty string or `dev`
- **THEN** the client SHALL skip the version check entirely (development builds)
---
### Requirement: Global output format flag
All commands SHALL support a `--format` flag accepting `human` (default) or `json`. The default MAY be changed via the `default_format` config value.