v2 restructure: Go client, Docker engine, release tooling

- Remove v1 Python CLI (src/kb_search/, tests/, root pyproject.toml, uv.lock, .venv)
- Add Go client with cross-platform build (client/)
- Add FastAPI engine with NVIDIA and multi-stage ROCm Dockerfiles (engine/)
- Add VERSION files for client and engine, wired into builds
- Add release.sh for automated build, tag, release, and Docker push
- Update README with build/release docs and ROCm migration note
- Clean up .gitignore for v2 project structure

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 21:52:25 +00:00
parent 2030976b85
commit 9aab79d49b
98 changed files with 4526 additions and 7776 deletions
+2
View File
@@ -0,0 +1,2 @@
kb
dist/
+20
View File
@@ -0,0 +1,20 @@
VERSION ?= $(shell cat VERSION 2>/dev/null || echo "dev")
LDFLAGS := -ldflags "-s -w -X github.com/kb-search/kb/cmd.Version=$(VERSION)"
PLATFORMS := linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64
.PHONY: build clean all
build:
go build $(LDFLAGS) -o kb .
all: $(PLATFORMS)
$(PLATFORMS):
$(eval OS := $(word 1,$(subst /, ,$@)))
$(eval ARCH := $(word 2,$(subst /, ,$@)))
$(eval EXT := $(if $(filter windows,$(OS)),.exe,))
GOOS=$(OS) GOARCH=$(ARCH) go build $(LDFLAGS) -o dist/kb-$(OS)-$(ARCH)$(EXT) .
clean:
rm -rf kb dist/
+1
View File
@@ -0,0 +1 @@
2.0.3
+192
View File
@@ -0,0 +1,192 @@
package cmd
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/kb-search/kb/internal/api"
"github.com/kb-search/kb/internal/output"
"github.com/spf13/cobra"
)
var supportedExts = map[string]bool{
".pdf": true,
".docx": true,
".html": true,
".md": true,
".txt": true,
".py": true,
".sh": true,
".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,
}
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)
}
func runAdd(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 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 {
return fmt.Errorf("cannot access %s: %w", path, err)
}
if !info.IsDir() {
// Single file upload
result, err := uploadFile(client, path, tags, docType)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if output.IsJSON() {
output.PrintJSON([]interface{}{result})
} else {
fmt.Printf("Queued: %s\n", filepath.Base(path))
}
return nil
}
// Directory upload
var files []string
walkFn := func(p string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
if !recursive && p != path {
return filepath.SkipDir
}
return nil
}
ext := strings.ToLower(filepath.Ext(p))
if supportedExts[ext] {
files = append(files, p)
}
return nil
}
if err := filepath.WalkDir(path, walkFn); err != nil {
return fmt.Errorf("failed to walk directory: %w", err)
}
var results []interface{}
for _, f := range files {
result, err := uploadFile(client, f, tags, docType)
if err != nil {
fmt.Fprintf(os.Stderr, "Error uploading %s: %v\n", f, err)
continue
}
results = append(results, result)
if !output.IsJSON() {
fmt.Printf("Queued: %s\n", filepath.Base(f))
}
}
if output.IsJSON() {
output.PrintJSON(results)
} else {
fmt.Printf("Queued: %d files\n", len(results))
}
return nil
}
func uploadFile(client *api.Client, path, tags, docType string) (interface{}, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("cannot open %s: %w", path, err)
}
defer f.Close()
fields := make(map[string]string)
if tags != "" {
fields["tags"] = tags
}
if docType != "" {
fields["type"] = docType
}
upload := &api.FileUpload{
FieldName: "file",
FileName: filepath.Base(path),
Reader: f,
}
resp, err := client.PostMultipart("/api/v1/jobs", fields, upload)
if err != nil {
return nil, err
}
if err := api.CheckError(resp); err != nil {
return nil, err
}
var result interface{}
if err := api.DecodeJSON(resp, &result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return result, nil
}
+87
View File
@@ -0,0 +1,87 @@
package cmd
import (
"fmt"
"os"
"github.com/kb-search/kb/internal/api"
"github.com/kb-search/kb/internal/output"
"github.com/spf13/cobra"
)
var infoCmd = &cobra.Command{
Use: "info <id>",
Short: "Show document details",
Args: cobra.ExactArgs(1),
RunE: runInfo,
}
func init() {
rootCmd.AddCommand(infoCmd)
}
func runInfo(cmd *cobra.Command, args []string) error {
client := api.NewClient()
resp, err := client.Get("/api/v1/documents/" + args[0])
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)
}
if output.IsJSON() {
var raw interface{}
if err := api.DecodeJSON(resp, &raw); err != nil {
return fmt.Errorf("failed to decode response: %w", err)
}
output.PrintJSON(raw)
return nil
}
var doc struct {
ID int `json:"id"`
Title string `json:"title"`
Type string `json:"doc_type"`
Tags []string `json:"tags"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Chunks []struct {
ID int `json:"id"`
Page interface{} `json:"page"`
Section string `json:"section"`
} `json:"chunks"`
}
if err := api.DecodeJSON(resp, &doc); err != nil {
return fmt.Errorf("failed to decode response: %w", err)
}
pairs := [][]string{
{"ID", fmt.Sprintf("%d", doc.ID)},
{"Title", doc.Title},
{"Type", doc.Type},
{"Tags", joinStrings(doc.Tags)},
{"Created", doc.CreatedAt},
{"Updated", doc.UpdatedAt},
{"Chunks", fmt.Sprintf("%d", len(doc.Chunks))},
}
output.PrintKeyValue(pairs)
if len(doc.Chunks) > 0 {
fmt.Println()
headers := []string{"CHUNK ID", "PAGE", "SECTION"}
var rows [][]string
for _, c := range doc.Chunks {
page := ""
if c.Page != nil {
page = fmt.Sprintf("%v", c.Page)
}
rows = append(rows, []string{fmt.Sprintf("%d", c.ID), page, c.Section})
}
output.PrintTable(headers, rows)
}
return nil
}
+121
View File
@@ -0,0 +1,121 @@
package cmd
import (
"fmt"
"os"
"github.com/kb-search/kb/internal/api"
"github.com/kb-search/kb/internal/output"
"github.com/spf13/cobra"
)
var jobsCmd = &cobra.Command{
Use: "jobs [id]",
Short: "List jobs or show job details",
Args: cobra.MaximumNArgs(1),
RunE: runJobs,
}
func init() {
jobsCmd.Flags().String("status", "", "filter by status")
rootCmd.AddCommand(jobsCmd)
}
func runJobs(cmd *cobra.Command, args []string) error {
client := api.NewClient()
if len(args) == 1 {
// Show single job
resp, err := client.Get("/api/v1/jobs/" + args[0])
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)
}
if output.IsJSON() {
var raw interface{}
if err := api.DecodeJSON(resp, &raw); err != nil {
return fmt.Errorf("failed to decode response: %w", err)
}
output.PrintJSON(raw)
return nil
}
var job struct {
ID int `json:"id"`
Status string `json:"status"`
Filename string `json:"filename"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Error string `json:"error"`
}
if err := api.DecodeJSON(resp, &job); err != nil {
return fmt.Errorf("failed to decode response: %w", err)
}
pairs := [][]string{
{"ID", fmt.Sprintf("%d", job.ID)},
{"Status", job.Status},
{"Filename", job.Filename},
{"Created", job.CreatedAt},
{"Updated", job.UpdatedAt},
}
if job.Error != "" {
pairs = append(pairs, []string{"Error", job.Error})
}
output.PrintKeyValue(pairs)
return nil
}
// List jobs
path := "/api/v1/jobs"
status, _ := cmd.Flags().GetString("status")
if status != "" {
path += "?status=" + status
}
resp, err := client.Get(path)
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)
}
if output.IsJSON() {
var raw interface{}
if err := api.DecodeJSON(resp, &raw); err != nil {
return fmt.Errorf("failed to decode response: %w", err)
}
output.PrintJSON(raw)
return nil
}
var jobs []struct {
ID int `json:"id"`
Status string `json:"status"`
Filename string `json:"filename"`
}
if err := api.DecodeJSON(resp, &jobs); err != nil {
return fmt.Errorf("failed to decode response: %w", err)
}
if len(jobs) == 0 {
fmt.Println("No jobs found.")
return nil
}
headers := []string{"ID", "STATUS", "FILENAME"}
var rows [][]string
for _, j := range jobs {
rows = append(rows, []string{fmt.Sprintf("%d", j.ID), j.Status, j.Filename})
}
output.PrintTable(headers, rows)
return nil
}
+84
View File
@@ -0,0 +1,84 @@
package cmd
import (
"fmt"
"net/url"
"os"
"github.com/kb-search/kb/internal/api"
"github.com/kb-search/kb/internal/output"
"github.com/spf13/cobra"
)
var listCmd = &cobra.Command{
Use: "list",
Short: "List documents in the knowledge base",
RunE: runList,
}
func init() {
listCmd.Flags().String("type", "", "filter by document type")
listCmd.Flags().String("tags", "", "filter by tags (comma-separated)")
rootCmd.AddCommand(listCmd)
}
func runList(cmd *cobra.Command, args []string) error {
docType, _ := cmd.Flags().GetString("type")
tags, _ := cmd.Flags().GetString("tags")
params := url.Values{}
if docType != "" {
params.Set("type", docType)
}
if tags != "" {
params.Set("tags", tags)
}
path := "/api/v1/documents"
if len(params) > 0 {
path += "?" + params.Encode()
}
client := api.NewClient()
resp, err := client.Get(path)
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)
}
if output.IsJSON() {
var raw interface{}
if err := api.DecodeJSON(resp, &raw); err != nil {
return fmt.Errorf("failed to decode response: %w", err)
}
output.PrintJSON(raw)
return nil
}
var docs []struct {
ID int `json:"id"`
Title string `json:"title"`
Type string `json:"doc_type"`
Tags []string `json:"tags"`
}
if err := api.DecodeJSON(resp, &docs); err != nil {
return fmt.Errorf("failed to decode response: %w", err)
}
if len(docs) == 0 {
fmt.Println("No documents found.")
return nil
}
headers := []string{"ID", "TITLE", "TYPE", "TAGS"}
var rows [][]string
for _, d := range docs {
rows = append(rows, []string{fmt.Sprintf("%d", d.ID), d.Title, d.Type, joinStrings(d.Tags)})
}
output.PrintTable(headers, rows)
return nil
}
+63
View File
@@ -0,0 +1,63 @@
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 removeCmd = &cobra.Command{
Use: "remove <id>",
Short: "Remove a document from the knowledge base",
Args: cobra.ExactArgs(1),
RunE: runRemove,
}
func init() {
removeCmd.Flags().BoolP("yes", "y", false, "skip confirmation prompt")
rootCmd.AddCommand(removeCmd)
}
func runRemove(cmd *cobra.Command, args []string) error {
yes, _ := cmd.Flags().GetBool("yes")
if !yes {
fmt.Printf("Remove document %s? [y/N] ", args[0])
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
}
}
client := api.NewClient()
resp, err := client.Delete("/api/v1/documents/" + args[0])
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)
}
if output.IsJSON() {
var raw interface{}
if err := api.DecodeJSON(resp, &raw); err != nil {
// Some DELETE endpoints return no body
output.PrintJSON(map[string]string{"status": "removed", "id": args[0]})
return nil
}
output.PrintJSON(raw)
} else {
fmt.Printf("Removed: %s\n", args[0])
}
return nil
}
+46
View File
@@ -0,0 +1,46 @@
package cmd
import (
"fmt"
"os"
"github.com/kb-search/kb/internal/config"
"github.com/spf13/cobra"
)
// Version is set at build time via -ldflags.
var Version = "dev"
var (
flagEngine string
flagFormat string
flagAPIKey string
)
var rootCmd = &cobra.Command{
Use: "kb",
Short: "kb-search CLI client",
Long: "A CLI client for the kb-search v2 engine API.",
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
if err := config.Load(); err != nil {
return err
}
config.ApplyFlags(flagEngine, flagFormat, flagAPIKey)
return nil
},
}
func init() {
rootCmd.Version = Version
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")
}
// Execute runs the root command.
func Execute() {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
+142
View File
@@ -0,0 +1,142 @@
package cmd
import (
"fmt"
"os"
"github.com/kb-search/kb/internal/api"
"github.com/kb-search/kb/internal/output"
"github.com/spf13/cobra"
)
var searchCmd = &cobra.Command{
Use: "search <query>",
Short: "Search the knowledge base",
Args: cobra.ExactArgs(1),
RunE: runSearch,
}
func init() {
searchCmd.Flags().IntP("top", "n", 10, "number of results to return")
searchCmd.Flags().String("tags", "", "filter by tags (comma-separated)")
searchCmd.Flags().String("type", "", "filter by document type")
searchCmd.Flags().Bool("fts-only", false, "use full-text search only")
searchCmd.Flags().Bool("vec-only", false, "use vector search only")
searchCmd.Flags().Float64("threshold", 0, "minimum score threshold")
rootCmd.AddCommand(searchCmd)
}
func runSearch(cmd *cobra.Command, args []string) error {
top, _ := cmd.Flags().GetInt("top")
tags, _ := cmd.Flags().GetString("tags")
docType, _ := cmd.Flags().GetString("type")
ftsOnly, _ := cmd.Flags().GetBool("fts-only")
vecOnly, _ := cmd.Flags().GetBool("vec-only")
threshold, _ := cmd.Flags().GetFloat64("threshold")
body := map[string]interface{}{
"query": args[0],
"top": top,
}
if tags != "" {
body["tags"] = tags
}
if docType != "" {
body["type"] = docType
}
if ftsOnly {
body["fts_only"] = true
}
if vecOnly {
body["vec_only"] = true
}
if threshold > 0 {
body["threshold"] = threshold
}
client := api.NewClient()
resp, err := client.Post("/api/v1/search", body)
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 {
Results []struct {
Score float64 `json:"score"`
Document struct {
Title string `json:"title"`
Type string `json:"doc_type"`
Tags []string `json:"tags"`
} `json:"document"`
Page interface{} `json:"page"`
Section string `json:"section"`
Text string `json:"text"`
} `json:"results"`
}
if output.IsJSON() {
var raw interface{}
if err := api.DecodeJSON(resp, &raw); err != nil {
return fmt.Errorf("failed to decode response: %w", err)
}
output.PrintJSON(raw)
return nil
}
if err := api.DecodeJSON(resp, &result); err != nil {
return fmt.Errorf("failed to decode response: %w", err)
}
if len(result.Results) == 0 {
fmt.Println("No results found.")
return nil
}
for i, r := range result.Results {
snippet := r.Text
if len(snippet) > 200 {
snippet = snippet[:200] + "..."
}
fmt.Printf("\n%d. [%.4f] %s\n", i+1, r.Score, r.Document.Title)
location := ""
if r.Page != nil {
location = fmt.Sprintf("Page %v", r.Page)
}
if r.Section != "" {
if location != "" {
location += " / "
}
location += r.Section
}
if location != "" {
fmt.Printf(" Location: %s\n", location)
}
if r.Document.Type != "" {
fmt.Printf(" Type: %s\n", r.Document.Type)
}
if len(r.Document.Tags) > 0 {
fmt.Printf(" Tags: %s\n", joinStrings(r.Document.Tags))
}
fmt.Printf(" %s\n", snippet)
}
fmt.Println()
return nil
}
func joinStrings(ss []string) string {
result := ""
for i, s := range ss {
if i > 0 {
result += ", "
}
result += s
}
return result
}
+82
View File
@@ -0,0 +1,82 @@
package cmd
import (
"fmt"
"os"
"github.com/kb-search/kb/internal/api"
"github.com/kb-search/kb/internal/output"
"github.com/spf13/cobra"
)
var statusCmd = &cobra.Command{
Use: "status",
Short: "Show engine status",
RunE: runStatus,
}
func init() {
rootCmd.AddCommand(statusCmd)
}
func runStatus(cmd *cobra.Command, args []string) error {
client := api.NewClient()
resp, err := client.Get("/api/v1/status")
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)
}
if output.IsJSON() {
var raw interface{}
if err := api.DecodeJSON(resp, &raw); err != nil {
return fmt.Errorf("failed to decode response: %w", err)
}
output.PrintJSON(raw)
return nil
}
var status struct {
ModelName string `json:"model_name"`
EmbeddingDim int `json:"embedding_dim"`
Device string `json:"device"`
DB struct {
DocumentsByType map[string]int `json:"documents_by_type"`
TotalChunks int `json:"total_chunks"`
DBSizeBytes int64 `json:"db_size_bytes"`
} `json:"db"`
Queue struct {
Queued int `json:"queued"`
Processing int `json:"processing"`
} `json:"queue"`
}
if err := api.DecodeJSON(resp, &status); err != nil {
return fmt.Errorf("failed to decode response: %w", err)
}
pairs := [][]string{
{"Model", status.ModelName},
{"Embedding Dim", fmt.Sprintf("%d", status.EmbeddingDim)},
{"Device", status.Device},
{"Total Chunks", fmt.Sprintf("%d", status.DB.TotalChunks)},
{"DB Size", fmt.Sprintf("%d bytes", status.DB.DBSizeBytes)},
{"Queued Jobs", fmt.Sprintf("%d", status.Queue.Queued)},
{"Processing Jobs", fmt.Sprintf("%d", status.Queue.Processing)},
}
output.PrintKeyValue(pairs)
if len(status.DB.DocumentsByType) > 0 {
fmt.Println("\nDocuments by Type:")
var bPairs [][]string
for k, v := range status.DB.DocumentsByType {
bPairs = append(bPairs, []string{" " + k, fmt.Sprintf("%d", v)})
}
output.PrintKeyValue(bPairs)
}
return nil
}
+75
View File
@@ -0,0 +1,75 @@
package cmd
import (
"fmt"
"os"
"strings"
"github.com/kb-search/kb/internal/api"
"github.com/kb-search/kb/internal/output"
"github.com/spf13/cobra"
)
var tagCmd = &cobra.Command{
Use: "tag <id>",
Short: "Add or remove tags on a document",
Args: cobra.ExactArgs(1),
RunE: runTag,
}
func init() {
tagCmd.Flags().String("add", "", "tags to add (comma-separated)")
tagCmd.Flags().String("remove", "", "tags to remove (comma-separated)")
rootCmd.AddCommand(tagCmd)
}
func runTag(cmd *cobra.Command, args []string) error {
addStr, _ := cmd.Flags().GetString("add")
removeStr, _ := cmd.Flags().GetString("remove")
body := map[string]interface{}{}
if addStr != "" {
body["add"] = splitTags(addStr)
}
if removeStr != "" {
body["remove"] = splitTags(removeStr)
}
if len(body) == 0 {
return fmt.Errorf("specify --add and/or --remove")
}
client := api.NewClient()
resp, err := client.Put("/api/v1/documents/"+args[0]+"/tags", body)
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)
}
if output.IsJSON() {
var raw interface{}
if err := api.DecodeJSON(resp, &raw); err != nil {
return fmt.Errorf("failed to decode response: %w", err)
}
output.PrintJSON(raw)
} else {
fmt.Printf("Tags updated for document %s\n", args[0])
}
return nil
}
func splitTags(s string) []string {
var tags []string
for _, t := range strings.Split(s, ",") {
t = strings.TrimSpace(t)
if t != "" {
tags = append(tags, t)
}
}
return tags
}
+63
View File
@@ -0,0 +1,63 @@
package cmd
import (
"fmt"
"os"
"github.com/kb-search/kb/internal/api"
"github.com/kb-search/kb/internal/output"
"github.com/spf13/cobra"
)
var tagsCmd = &cobra.Command{
Use: "tags",
Short: "List all tags",
RunE: runTags,
}
func init() {
rootCmd.AddCommand(tagsCmd)
}
func runTags(cmd *cobra.Command, args []string) error {
client := api.NewClient()
resp, err := client.Get("/api/v1/tags")
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)
}
if output.IsJSON() {
var raw interface{}
if err := api.DecodeJSON(resp, &raw); err != nil {
return fmt.Errorf("failed to decode response: %w", err)
}
output.PrintJSON(raw)
return nil
}
var tags []struct {
Name string `json:"name"`
Count int `json:"count"`
}
if err := api.DecodeJSON(resp, &tags); err != nil {
return fmt.Errorf("failed to decode response: %w", err)
}
if len(tags) == 0 {
fmt.Println("No tags found.")
return nil
}
headers := []string{"TAG", "COUNT"}
var rows [][]string
for _, t := range tags {
rows = append(rows, []string{t.Name, fmt.Sprintf("%d", t.Count)})
}
output.PrintTable(headers, rows)
return nil
}
+13
View File
@@ -0,0 +1,13 @@
module github.com/kb-search/kb
go 1.22
require (
github.com/spf13/cobra v1.10.2
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
)
+13
View File
@@ -0,0 +1,13 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+158
View File
@@ -0,0 +1,158 @@
package api
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"github.com/kb-search/kb/internal/config"
)
// FileUpload describes a file to include in a multipart request.
type FileUpload struct {
FieldName string
FileName string
Reader io.Reader
}
// Client is an HTTP client for the kb-search engine API.
type Client struct {
baseURL string
apiKey string
httpClient *http.Client
}
// NewClient creates a Client from the current configuration.
func NewClient() *Client {
cfg := config.Get()
return &Client{
baseURL: cfg.EngineURL,
apiKey: cfg.APIKey,
httpClient: &http.Client{},
}
}
func (c *Client) newRequest(method, path string, body io.Reader) (*http.Request, error) {
url := c.baseURL + path
req, err := http.NewRequest(method, url, body)
if err != nil {
return nil, err
}
if c.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+c.apiKey)
}
return req, nil
}
func (c *Client) do(req *http.Request) (*http.Response, error) {
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("Cannot reach engine at %s: %v", c.baseURL, err)
}
return resp, nil
}
// Get performs a GET request to the given path.
func (c *Client) Get(path string) (*http.Response, error) {
req, err := c.newRequest(http.MethodGet, path, nil)
if err != nil {
return nil, err
}
return c.do(req)
}
// Post performs a POST request with a JSON body.
func (c *Client) Post(path string, body interface{}) (*http.Response, error) {
data, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %w", err)
}
req, err := c.newRequest(http.MethodPost, path, bytes.NewReader(data))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
return c.do(req)
}
// PostMultipart performs a POST request with multipart/form-data encoding.
func (c *Client) PostMultipart(path string, fields map[string]string, file *FileUpload) (*http.Response, error) {
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
for k, v := range fields {
if err := w.WriteField(k, v); err != nil {
return nil, fmt.Errorf("failed to write field %s: %w", k, err)
}
}
if file != nil {
part, err := w.CreateFormFile(file.FieldName, file.FileName)
if err != nil {
return nil, fmt.Errorf("failed to create form file: %w", err)
}
if _, err := io.Copy(part, file.Reader); err != nil {
return nil, fmt.Errorf("failed to copy file data: %w", err)
}
}
if err := w.Close(); err != nil {
return nil, fmt.Errorf("failed to close multipart writer: %w", err)
}
req, err := c.newRequest(http.MethodPost, path, &buf)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", w.FormDataContentType())
return c.do(req)
}
// Delete performs a DELETE request to the given path.
func (c *Client) Delete(path string) (*http.Response, error) {
req, err := c.newRequest(http.MethodDelete, path, nil)
if err != nil {
return nil, err
}
return c.do(req)
}
// Put performs a PUT request with a JSON body.
func (c *Client) Put(path string, body interface{}) (*http.Response, error) {
data, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %w", err)
}
req, err := c.newRequest(http.MethodPut, path, bytes.NewReader(data))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
return c.do(req)
}
// DecodeJSON reads the response body and decodes it into target.
func DecodeJSON(resp *http.Response, target interface{}) error {
defer resp.Body.Close()
return json.NewDecoder(resp.Body).Decode(target)
}
// CheckError returns a formatted error for non-2xx responses, or nil on success.
func CheckError(resp *http.Response) error {
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var errResp struct {
Detail string `json:"detail"`
}
if json.Unmarshal(body, &errResp) == nil && errResp.Detail != "" {
return fmt.Errorf("API error (%d): %s", resp.StatusCode, errResp.Detail)
}
return fmt.Errorf("API error (%d): %s", resp.StatusCode, string(body))
}
+82
View File
@@ -0,0 +1,82 @@
package config
import (
"os"
"path/filepath"
"sync"
"gopkg.in/yaml.v3"
)
// Config holds all client configuration.
type Config struct {
EngineURL string `yaml:"engine_url"`
APIKey string `yaml:"api_key"`
DefaultFormat string `yaml:"default_format"`
}
var (
cfg Config
once sync.Once
)
// Load reads configuration from ~/.kb/client.yaml, then applies environment
// variable overrides. Defaults are applied first.
func Load() error {
var loadErr error
once.Do(func() {
// Defaults
cfg = Config{
EngineURL: "http://localhost:8000",
DefaultFormat: "human",
}
// Read config file if it exists
home, err := os.UserHomeDir()
if err == nil {
path := filepath.Join(home, ".kb", "client.yaml")
data, err := os.ReadFile(path)
if err == nil {
var fileCfg Config
if err := yaml.Unmarshal(data, &fileCfg); err == nil {
if fileCfg.EngineURL != "" {
cfg.EngineURL = fileCfg.EngineURL
}
if fileCfg.APIKey != "" {
cfg.APIKey = fileCfg.APIKey
}
if fileCfg.DefaultFormat != "" {
cfg.DefaultFormat = fileCfg.DefaultFormat
}
}
}
}
// Environment variable overrides
if v := os.Getenv("KB_ENGINE_URL"); v != "" {
cfg.EngineURL = v
}
if v := os.Getenv("KB_API_KEY"); v != "" {
cfg.APIKey = v
}
})
return loadErr
}
// Get returns the loaded configuration singleton.
func Get() *Config {
return &cfg
}
// ApplyFlags overrides configuration with CLI flag values (only if non-empty).
func ApplyFlags(engine, format, apiKey string) {
if engine != "" {
cfg.EngineURL = engine
}
if format != "" {
cfg.DefaultFormat = format
}
if apiKey != "" {
cfg.APIKey = apiKey
}
}
+90
View File
@@ -0,0 +1,90 @@
package output
import (
"encoding/json"
"fmt"
"os"
"strings"
"github.com/kb-search/kb/internal/config"
)
// IsJSON returns true if the configured output format is "json".
func IsJSON() bool {
return config.Get().DefaultFormat == "json"
}
// PrintJSON pretty-prints data as JSON to stdout.
func PrintJSON(data interface{}) {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
_ = enc.Encode(data)
}
// PrintTable prints a simple aligned table with headers and rows.
func PrintTable(headers []string, rows [][]string) {
if len(headers) == 0 {
return
}
// Calculate column widths
widths := make([]int, len(headers))
for i, h := range headers {
widths[i] = len(h)
}
for _, row := range rows {
for i := 0; i < len(row) && i < len(widths); i++ {
if len(row[i]) > widths[i] {
widths[i] = len(row[i])
}
}
}
// Print header
for i, h := range headers {
if i > 0 {
fmt.Print(" ")
}
fmt.Printf("%-*s", widths[i], h)
}
fmt.Println()
// Print separator
for i, w := range widths {
if i > 0 {
fmt.Print(" ")
}
fmt.Print(strings.Repeat("-", w))
}
fmt.Println()
// Print rows
for _, row := range rows {
for i := 0; i < len(headers); i++ {
if i > 0 {
fmt.Print(" ")
}
val := ""
if i < len(row) {
val = row[i]
}
fmt.Printf("%-*s", widths[i], val)
}
fmt.Println()
}
}
// PrintKeyValue prints aligned key-value pairs.
func PrintKeyValue(pairs [][]string) {
maxKey := 0
for _, p := range pairs {
if len(p) >= 2 && len(p[0]) > maxKey {
maxKey = len(p[0])
}
}
for _, p := range pairs {
if len(p) >= 2 {
fmt.Printf("%-*s %s\n", maxKey, p[0]+":", p[1])
}
}
}
+7
View File
@@ -0,0 +1,7 @@
package main
import "github.com/kb-search/kb/cmd"
func main() {
cmd.Execute()
}