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:
+75
-83
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user