Replace implicit note shorthand with explicit addnote command and split README

Two changes:

1. structured-add-commands: The implicit note shorthand (kb "text") caused
   accidental note creation from mistyped commands. Replaced with explicit
   kb addnote <text> command. Root command reverts to standard Cobra
   behaviour. Updated examples, tests, SKILL.md, and specs.

2. split-readme-developer-docs: Moved build-from-source instructions, release
   process, API reference, and ROCm migration notes from README.md into a
   new DEVELOPER.md. README now links to DEVELOPER.md for dev workflows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-31 20:48:22 +01:00
parent 9e957f1a9a
commit afbe270181
20 changed files with 688 additions and 242 deletions
-50
View File
@@ -206,53 +206,3 @@ func uploadFile(client *api.Client, path, tags string) (*uploadResult, error) {
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
}
+88
View File
@@ -0,0 +1,88 @@
package cmd
import (
"fmt"
"net/http"
"os"
"github.com/kb-search/kb/internal/api"
"github.com/kb-search/kb/internal/output"
"github.com/spf13/cobra"
)
var addnoteCmd = &cobra.Command{
Use: "addnote <text>",
Short: "Add a text note to the knowledge base",
Args: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return fmt.Errorf("requires a note text argument\n\n Usage: kb addnote \"your note text here\"")
}
if len(args) > 1 {
return fmt.Errorf("accepts 1 arg but received %d — quote your note text, e.g. kb addnote \"your note text here\"", len(args))
}
return nil
},
RunE: runAddnote,
}
func init() {
addnoteCmd.Flags().String("tags", "", "tags (comma-separated)")
rootCmd.AddCommand(addnoteCmd)
}
func runAddnote(cmd *cobra.Command, args []string) error {
tags, _ := cmd.Flags().GetString("tags")
client := api.NewClient()
return submitNote(client, args[0], tags)
}
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
}
+3 -3
View File
@@ -11,9 +11,9 @@ var examplesCmd = &cobra.Command{
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
fmt.Print(`Add notes:
kb addnote "Remember to update DNS records"
kb addnote "Server room is building 3" --tags ops
Add files:
kb addfile report.pdf
+1 -33
View File
@@ -3,7 +3,6 @@ package cmd
import (
"fmt"
"os"
"strings"
"github.com/kb-search/kb/internal/api"
"github.com/kb-search/kb/internal/config"
@@ -23,10 +22,9 @@ var (
)
var rootCmd = &cobra.Command{
Use: "kb [\"note text\" | command]",
Use: "kb [command]",
Short: "kb-search CLI client",
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
@@ -34,44 +32,14 @@ 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()
}
if len(args) == 1 {
return fmt.Errorf("unknown command %q\nTo add a note, use: kb \"%s ...\" or pass multiple words", args[0], args[0])
}
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 (must be more than one word):
kb "note text here" [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.
+45 -30
View File
@@ -6,36 +6,6 @@ import (
"testing"
)
func TestRootCmd_SingleWordRejected(t *testing.T) {
rootCmd.SetArgs([]string{"infow"})
var stderr bytes.Buffer
rootCmd.SetErr(&stderr)
err := rootCmd.Execute()
if err == nil {
t.Fatal("expected error for single bare word, got nil")
}
errMsg := err.Error()
if !strings.Contains(errMsg, `unknown command "infow"`) {
t.Errorf("expected error to mention unknown command, got: %s", errMsg)
}
if !strings.Contains(errMsg, "multiple words") {
t.Errorf("expected error to suggest multiple words, got: %s", errMsg)
}
}
func TestRootCmd_MultipleWordsNotRejected(t *testing.T) {
rootCmd.SetArgs([]string{"remember", "to", "update", "dns"})
err := rootCmd.Execute()
// Will fail at API call (no server), but should NOT be the "unknown command" error
if err != nil && strings.Contains(err.Error(), "unknown command") {
t.Errorf("multi-word input should not be rejected as unknown command, got: %s", err.Error())
}
}
func TestRootCmd_NoArgs_ShowsHelp(t *testing.T) {
rootCmd.SetArgs([]string{})
@@ -52,3 +22,48 @@ func TestRootCmd_NoArgs_ShowsHelp(t *testing.T) {
t.Errorf("expected help output, got: %s", output)
}
}
func TestRootCmd_UnknownCommand_ReturnsError(t *testing.T) {
rootCmd.SetArgs([]string{"notacommand"})
var stderr bytes.Buffer
rootCmd.SetErr(&stderr)
err := rootCmd.Execute()
if err == nil {
t.Fatal("expected error for unknown command, got nil")
}
errMsg := err.Error()
if !strings.Contains(errMsg, "unknown command") {
t.Errorf("expected 'unknown command' error, got: %s", errMsg)
}
}
func TestAddnoteCmd_NoArgs_ReturnsError(t *testing.T) {
rootCmd.SetArgs([]string{"addnote"})
err := rootCmd.Execute()
if err == nil {
t.Fatal("expected error for addnote with no args, got nil")
}
errMsg := err.Error()
if !strings.Contains(errMsg, "requires a note text argument") {
t.Errorf("expected 'requires a note text argument' error, got: %s", errMsg)
}
}
func TestAddnoteCmd_TooManyArgs_ReturnsError(t *testing.T) {
rootCmd.SetArgs([]string{"addnote", "hello", "world"})
err := rootCmd.Execute()
if err == nil {
t.Fatal("expected error for addnote with too many args, got nil")
}
errMsg := err.Error()
if !strings.Contains(errMsg, "quote your note text") {
t.Errorf("expected 'accepts 1 arg' error, got: %s", errMsg)
}
}