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:
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user