From 0f1015f5eff20a005710150865f19250dc63a76c Mon Sep 17 00:00:00 2001 From: nullkey Date: Thu, 14 May 2026 22:07:46 +0800 Subject: [PATCH 01/10] feat(vectoria): add ListKBs client method --- client/vectoria/knowledgebase.go | 25 +++++++++++++++++++ client/vectoria/knowledgebase_test.go | 36 +++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/client/vectoria/knowledgebase.go b/client/vectoria/knowledgebase.go index 671596b..98188d2 100644 --- a/client/vectoria/knowledgebase.go +++ b/client/vectoria/knowledgebase.go @@ -67,3 +67,28 @@ func (c *Client) DeleteKB(ctx context.Context, kbID string) error { } return nil } + +type KB struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + CreatedAt string `json:"created_at"` +} + +type ListKBsResponse struct { + Total int `json:"total"` + Offset int `json:"offset"` + Limit int `json:"limit"` + Items []KB `json:"items"` +} + +// ListKBs paginates the user's knowledgebases. +// Backend caps limit at 100; callers walk pages by incrementing offset. +func (c *Client) ListKBs(ctx context.Context, offset, limit int) (*ListKBsResponse, error) { + var resp ListKBsResponse + path := fmt.Sprintf("/v1/knowledgebases?offset=%d&limit=%d", offset, limit) + if err := c.http.Do(ctx, "GET", path, nil, &resp); err != nil { + return nil, fmt.Errorf("list knowledgebases: %w", err) + } + return &resp, nil +} diff --git a/client/vectoria/knowledgebase_test.go b/client/vectoria/knowledgebase_test.go index f6bf7d0..768e04a 100644 --- a/client/vectoria/knowledgebase_test.go +++ b/client/vectoria/knowledgebase_test.go @@ -169,3 +169,39 @@ func TestUploadDoc_FileContent(t *testing.T) { t.Fatalf("file content = %q, want 'hello world'", gotContent) } } + +func TestListKBs(t *testing.T) { + var gotQuery string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.RawQuery + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "total": 2, + "offset": 0, + "limit": 50, + "items": []map[string]any{ + {"id": "kb_1", "name": "vibeknow-cli-1", "description": "", "created_at": "2026-05-14T09:47:00Z"}, + {"id": "kb_2", "name": "manual-kb", "description": "user-created", "created_at": "2026-05-13T11:00:00Z"}, + }, + }) + })) + defer srv.Close() + + c := vectoria.New(srv.URL, staticToken("test-jwt")) + resp, err := c.ListKBs(context.Background(), 0, 50) + if err != nil { + t.Fatalf("ListKBs: %v", err) + } + if resp.Total != 2 { + t.Fatalf("total = %d, want 2", resp.Total) + } + if len(resp.Items) != 2 { + t.Fatalf("items len = %d, want 2", len(resp.Items)) + } + if resp.Items[0].ID != "kb_1" || resp.Items[0].Name != "vibeknow-cli-1" { + t.Fatalf("items[0] = %+v", resp.Items[0]) + } + if !strings.Contains(gotQuery, "offset=0") || !strings.Contains(gotQuery, "limit=50") { + t.Fatalf("query = %q, want offset=0 & limit=50", gotQuery) + } +} From 772610e33054ae6843cbbcefb7c6c6c576ceb405 Mon Sep 17 00:00:00 2001 From: nullkey Date: Thu, 14 May 2026 22:08:46 +0800 Subject: [PATCH 02/10] feat(durfmt): add ParseAge supporting Nd day-suffix shortcut --- internal/durfmt/durfmt.go | 29 ++++++++++++++++++++++++ internal/durfmt/durfmt_test.go | 41 ++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 internal/durfmt/durfmt.go create mode 100644 internal/durfmt/durfmt_test.go diff --git a/internal/durfmt/durfmt.go b/internal/durfmt/durfmt.go new file mode 100644 index 0000000..940ba03 --- /dev/null +++ b/internal/durfmt/durfmt.go @@ -0,0 +1,29 @@ +// Package durfmt parses durations with a `d` (day) suffix that Go's +// time.ParseDuration doesn't support natively. +package durfmt + +import ( + "fmt" + "strconv" + "strings" + "time" +) + +// ParseAge parses a duration like "7d", "24h", "1h30m". +// "Nd" (a non-empty digit run followed by "d", and nothing else) is +// rewritten to N*24h. Other forms delegate to time.ParseDuration. +func ParseAge(s string) (time.Duration, error) { + s = strings.TrimSpace(s) + if s == "" { + return 0, fmt.Errorf("empty duration") + } + if strings.HasSuffix(s, "d") { + nStr := strings.TrimSuffix(s, "d") + n, err := strconv.Atoi(nStr) + if err != nil || n < 0 { + return 0, fmt.Errorf("invalid days in %q", s) + } + return time.Duration(n) * 24 * time.Hour, nil + } + return time.ParseDuration(s) +} diff --git a/internal/durfmt/durfmt_test.go b/internal/durfmt/durfmt_test.go new file mode 100644 index 0000000..c49c49e --- /dev/null +++ b/internal/durfmt/durfmt_test.go @@ -0,0 +1,41 @@ +package durfmt_test + +import ( + "testing" + "time" + + "github.com/vibeknow/cli/internal/durfmt" +) + +func TestParseAge(t *testing.T) { + tests := []struct { + in string + want time.Duration + }{ + {"24h", 24 * time.Hour}, + {"1h30m", time.Hour + 30*time.Minute}, + {"30m", 30 * time.Minute}, + {"7d", 7 * 24 * time.Hour}, + {"1d", 24 * time.Hour}, + {"30d", 30 * 24 * time.Hour}, + } + for _, tt := range tests { + t.Run(tt.in, func(t *testing.T) { + got, err := durfmt.ParseAge(tt.in) + if err != nil { + t.Fatalf("err: %v", err) + } + if got != tt.want { + t.Fatalf("got %v, want %v", got, tt.want) + } + }) + } +} + +func TestParseAge_Invalid(t *testing.T) { + for _, bad := range []string{"", "abc", "7", "d", "7days"} { + if _, err := durfmt.ParseAge(bad); err == nil { + t.Errorf("ParseAge(%q) = nil err, want error", bad) + } + } +} From 6ac258a5df46175c8c5cc16fb31ea6e60a2566fa Mon Sep 17 00:00:00 2001 From: nullkey Date: Thu, 14 May 2026 22:11:41 +0800 Subject: [PATCH 03/10] feat(kb): add subcommand skeleton + i18n keys --- cmd/kb/kb.go | 13 +++++++++++++ cmd/root.go | 2 ++ internal/i18n/strings.go | 38 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+) create mode 100644 cmd/kb/kb.go diff --git a/cmd/kb/kb.go b/cmd/kb/kb.go new file mode 100644 index 0000000..34b933e --- /dev/null +++ b/cmd/kb/kb.go @@ -0,0 +1,13 @@ +// Package kb implements the `vk kb` subcommand family: list, delete, prune. +package kb + +import ( + "github.com/spf13/cobra" + + "github.com/vibeknow/cli/internal/i18n" +) + +var Cmd = &cobra.Command{ + Use: "kb", + Short: i18n.T("kb.short"), +} diff --git a/cmd/root.go b/cmd/root.go index 6325775..66c40f3 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -11,6 +11,7 @@ import ( configcmd "github.com/vibeknow/cli/cmd/config" creditscmd "github.com/vibeknow/cli/cmd/credits" doccmd "github.com/vibeknow/cli/cmd/doc" + kbcmd "github.com/vibeknow/cli/cmd/kb" profilecmd "github.com/vibeknow/cli/cmd/profile" videocmd "github.com/vibeknow/cli/cmd/video" voicecmd "github.com/vibeknow/cli/cmd/voice" @@ -52,6 +53,7 @@ func init() { rootCmd.AddCommand(createCmd) rootCmd.AddCommand(creditscmd.Cmd) rootCmd.AddCommand(doccmd.Cmd) + rootCmd.AddCommand(kbcmd.Cmd) rootCmd.AddCommand(profilecmd.Cmd) rootCmd.AddCommand(videocmd.Cmd) rootCmd.AddCommand(voicecmd.Cmd) diff --git a/internal/i18n/strings.go b/internal/i18n/strings.go index e71d104..9366fee 100644 --- a/internal/i18n/strings.go +++ b/internal/i18n/strings.go @@ -82,6 +82,25 @@ func init() { "create.err.aspect_invalid": "--aspect must be one of: horizontal, vertical, 16:9, 9:16 (got %q)", "create.err.script_needs_doc": "--mode script requires an uploaded document (--from must be a file or URL, not a doc_id)", + // kb + "kb.short": "manage vectoria knowledgebases", + "kb.list.short": "list your knowledgebases", + "kb.list.empty": "no knowledgebases", + "kb.list.footer": "Showing %d of %d (page %d)", + "kb.delete.short": "delete a knowledgebase (and all its documents)", + "kb.delete.confirm.no_name": "About to delete kb %s. Irreversible. Continue? (y/N)", + "kb.delete.done": "deleted.", + "kb.delete.already_gone": "already deleted (or never existed)", + "kb.prune.short": "bulk-delete knowledgebases matching a filter", + "kb.prune.no_filter": "--pattern or --older-than is required; refusing to operate on all knowledgebases", + "kb.prune.scanning": "Scanning… (page %d, %d/%d)", + "kb.prune.match_header": "Found %d kbs matching filter:", + "kb.prune.dry_run_hint": "This is a dry run. Run with --yes to actually delete.", + "kb.prune.applying": "Deleting %d kbs…", + "kb.prune.done": "Done: %d deleted, %d failed.", + "kb.prune.bad_pattern": "invalid --pattern %q: %s", + "kb.prune.bad_age": "invalid --older-than %q: %s", + // auth status (text mode) "auth.status.signed_in_as": "✓ Signed in as %s", "auth.status.signed_in": "✓ Signed in", @@ -222,6 +241,25 @@ func init() { "create.err.aspect_invalid": "--aspect 必须是 horizontal、vertical、16:9 或 9:16(当前为 %q)", "create.err.script_needs_doc": "--mode script 需要上传文档(--from 必须是文件或 URL,不能是 doc_id)", + // kb + "kb.short": "管理 vectoria 知识库", + "kb.list.short": "列出你的知识库", + "kb.list.empty": "没有知识库", + "kb.list.footer": "显示 %d / %d(第 %d 页)", + "kb.delete.short": "删除知识库及其所有文档", + "kb.delete.confirm.no_name": "即将删除知识库 %s。此操作不可恢复。继续?(y/N)", + "kb.delete.done": "已删除。", + "kb.delete.already_gone": "已不存在(或从未存在)", + "kb.prune.short": "按过滤器批量删除知识库", + "kb.prune.no_filter": "需要 --pattern 或 --older-than;拒绝对所有知识库操作", + "kb.prune.scanning": "扫描中…(第 %d 页,%d / %d)", + "kb.prune.match_header": "匹配到 %d 个知识库:", + "kb.prune.dry_run_hint": "这是试运行。加 --yes 才会真正删除。", + "kb.prune.applying": "正在删除 %d 个知识库…", + "kb.prune.done": "完成:删除 %d 个,失败 %d 个。", + "kb.prune.bad_pattern": "--pattern 格式错误 %q:%s", + "kb.prune.bad_age": "--older-than 格式错误 %q:%s", + // auth status (text mode) "auth.status.signed_in_as": "✓ 已登录为 %s", "auth.status.signed_in": "✓ 已登录", From 8d742ab3eb59a108deb9e40b690370772ff0e50b Mon Sep 17 00:00:00 2001 From: nullkey Date: Thu, 14 May 2026 22:15:03 +0800 Subject: [PATCH 04/10] feat(kb): vk kb list with --pattern and --older-than filters --- cmd/kb/list.go | 144 ++++++++++++++++++++++++++++++++++++++++++++ cmd/kb/list_test.go | 58 ++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 cmd/kb/list.go create mode 100644 cmd/kb/list_test.go diff --git a/cmd/kb/list.go b/cmd/kb/list.go new file mode 100644 index 0000000..13dcef7 --- /dev/null +++ b/cmd/kb/list.go @@ -0,0 +1,144 @@ +package kb + +import ( + "context" + "fmt" + "path/filepath" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/vibeknow/cli/client/vectoria" + "github.com/vibeknow/cli/internal/cliauth" + "github.com/vibeknow/cli/internal/durfmt" + "github.com/vibeknow/cli/internal/i18n" + "github.com/vibeknow/cli/internal/output" +) + +var ( + flagListPage int + flagListSize int + flagListPattern string + flagListOlderThan string +) + +// kbItem is the display-side struct: vectoria.KB fields + parsed CreatedAt. +type kbItem struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + CreatedAt time.Time `json:"-"` + CreatedRaw string `json:"created_at"` +} + +func toItems(in []vectoria.KB) []kbItem { + out := make([]kbItem, 0, len(in)) + for _, k := range in { + t, _ := time.Parse(time.RFC3339, k.CreatedAt) + out = append(out, kbItem{ + ID: k.ID, + Name: k.Name, + Description: k.Description, + CreatedAt: t, + CreatedRaw: k.CreatedAt, + }) + } + return out +} + +// filterKBs applies pattern (filepath.Match glob) and age-min filters. +// pattern == "" → no pattern filter. olderThan == 0 → no age filter. +// `now` is injected for deterministic tests. +func filterKBs(items []kbItem, pattern string, olderThan time.Duration, now time.Time) ([]kbItem, error) { + if pattern != "" { + if _, err := filepath.Match(pattern, ""); err != nil { + return nil, err + } + } + out := make([]kbItem, 0, len(items)) + for _, k := range items { + if pattern != "" { + ok, _ := filepath.Match(pattern, k.Name) + if !ok { + continue + } + } + if olderThan > 0 && !k.CreatedAt.IsZero() { + if now.Sub(k.CreatedAt) < olderThan { + continue + } + } + out = append(out, k) + } + return out, nil +} + +var listCmd = &cobra.Command{ + Use: "list", + Short: i18n.T("kb.list.short"), + RunE: func(cmd *cobra.Command, args []string) error { + var olderThan time.Duration + if flagListOlderThan != "" { + d, err := durfmt.ParseAge(flagListOlderThan) + if err != nil { + return fmt.Errorf("--older-than: %w", err) + } + olderThan = d + } + + vc, err := cliauth.NewVectoriaClient() + if err != nil { + return err + } + offset := (flagListPage - 1) * flagListSize + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + resp, err := vc.ListKBs(ctx, offset, flagListSize) + if err != nil { + return err + } + items := toItems(resp.Items) + filtered, err := filterKBs(items, flagListPattern, olderThan, time.Now()) + if err != nil { + return err + } + + format, _ := cmd.Flags().GetString("output") + if format == "json" { + return output.NewJSON(cmd.OutOrStdout()).Object(map[string]any{ + "page": flagListPage, + "size": flagListSize, + "total": resp.Total, + "kbs": filtered, + }) + } + if len(filtered) == 0 { + fmt.Println(i18n.T("kb.list.empty")) + return nil + } + fmt.Printf("%-38s %-30s %-19s %s\n", "ID", "NAME", "CREATED", "DESCRIPTION") + fmt.Println(strings.Repeat("-", 110)) + for _, k := range filtered { + name := k.Name + if len(name) > 28 { + name = name[:28] + ".." + } + created := "--" + if !k.CreatedAt.IsZero() { + created = k.CreatedAt.Local().Format("2006-01-02 15:04") + } + fmt.Printf("%-38s %-30s %-19s %s\n", k.ID, name, created, k.Description) + } + fmt.Println(i18n.T("kb.list.footer", len(filtered), resp.Total, flagListPage)) + return nil + }, +} + +func init() { + listCmd.Flags().IntVar(&flagListPage, "page", 1, "page number") + listCmd.Flags().IntVar(&flagListSize, "size", 50, "page size (backend caps at 100)") + listCmd.Flags().StringVar(&flagListPattern, "pattern", "", "glob pattern matched against kb name (filepath.Match syntax)") + listCmd.Flags().StringVar(&flagListOlderThan, "older-than", "", "filter to kbs older than this duration (e.g., 7d, 24h, 1h30m)") + Cmd.AddCommand(listCmd) +} diff --git a/cmd/kb/list_test.go b/cmd/kb/list_test.go new file mode 100644 index 0000000..a191de4 --- /dev/null +++ b/cmd/kb/list_test.go @@ -0,0 +1,58 @@ +package kb + +import ( + "testing" + "time" +) + +func TestFilterKBs_Pattern(t *testing.T) { + items := []kbItem{ + {Name: "vibeknow-cli-1"}, + {Name: "manual-kb"}, + {Name: "vibeknow-cli-2"}, + } + got, err := filterKBs(items, "vibeknow-cli-*", 0, time.Time{}) + if err != nil { + t.Fatalf("err: %v", err) + } + if len(got) != 2 { + t.Fatalf("matched %d, want 2", len(got)) + } +} + +func TestFilterKBs_Age(t *testing.T) { + now := time.Date(2026, 5, 14, 12, 0, 0, 0, time.UTC) + items := []kbItem{ + {Name: "old", CreatedAt: now.Add(-10 * 24 * time.Hour)}, + {Name: "fresh", CreatedAt: now.Add(-3 * 24 * time.Hour)}, + } + got, err := filterKBs(items, "", 7*24*time.Hour, now) + if err != nil { + t.Fatalf("err: %v", err) + } + if len(got) != 1 || got[0].Name != "old" { + t.Fatalf("got %v", got) + } +} + +func TestFilterKBs_BadPattern(t *testing.T) { + if _, err := filterKBs([]kbItem{{Name: "x"}}, "[unterminated", 0, time.Time{}); err == nil { + t.Fatal("expected error on bad pattern") + } +} + +func TestFilterKBs_Combined(t *testing.T) { + now := time.Date(2026, 5, 14, 12, 0, 0, 0, time.UTC) + items := []kbItem{ + {Name: "vibeknow-cli-old", CreatedAt: now.Add(-10 * 24 * time.Hour)}, + {Name: "vibeknow-cli-fresh", CreatedAt: now.Add(-3 * 24 * time.Hour)}, + {Name: "manual-old", CreatedAt: now.Add(-10 * 24 * time.Hour)}, + } + got, err := filterKBs(items, "vibeknow-cli-*", 7*24*time.Hour, now) + if err != nil { + t.Fatalf("err: %v", err) + } + if len(got) != 1 || got[0].Name != "vibeknow-cli-old" { + t.Fatalf("want only vibeknow-cli-old, got %v", got) + } +} From e309b834d0e13e3f3825a7bfb5ceaeea4517f889 Mon Sep 17 00:00:00 2001 From: nullkey Date: Thu, 14 May 2026 22:17:08 +0800 Subject: [PATCH 05/10] feat(kb): vk kb delete with idempotent 404 handling --- cmd/kb/delete.go | 58 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 cmd/kb/delete.go diff --git a/cmd/kb/delete.go b/cmd/kb/delete.go new file mode 100644 index 0000000..e858e9f --- /dev/null +++ b/cmd/kb/delete.go @@ -0,0 +1,58 @@ +package kb + +import ( + "context" + "fmt" + "time" + + "github.com/spf13/cobra" + + "github.com/vibeknow/cli/internal/cliauth" + "github.com/vibeknow/cli/internal/cmdutil" + "github.com/vibeknow/cli/internal/errs" + "github.com/vibeknow/cli/internal/i18n" +) + +var flagDeleteYes bool + +var deleteCmd = &cobra.Command{ + Use: "delete ", + Short: i18n.T("kb.delete.short"), + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + kbID := args[0] + + ok, err := cmdutil.Confirm(cmdutil.ConfirmOptions{ + Prompt: i18n.T("kb.delete.confirm.no_name", kbID), + Yes: flagDeleteYes, + }) + if err != nil { + return err + } + if !ok { + return nil + } + + vc, err := cliauth.NewVectoriaClient() + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := vc.DeleteKB(ctx, kbID); err != nil { + // 404 → idempotent success (rm -f semantics). + if errs.HasCode(err, "not_found") { + fmt.Println(i18n.T("kb.delete.already_gone")) + return nil + } + return err + } + fmt.Println(i18n.T("kb.delete.done")) + return nil + }, +} + +func init() { + deleteCmd.Flags().BoolVarP(&flagDeleteYes, "yes", "y", false, "skip confirmation prompt") + Cmd.AddCommand(deleteCmd) +} From d558dbcd36da01afefed9855a7eb0d9eae2ed2a3 Mon Sep 17 00:00:00 2001 From: nullkey Date: Thu, 14 May 2026 22:20:10 +0800 Subject: [PATCH 06/10] feat(kb): vk kb prune with dry-run default, pattern/age filters, partial-success exit 7 --- cmd/kb/prune.go | 175 +++++++++++++++++++++++++++++++++++++++++++ cmd/kb/prune_test.go | 21 ++++++ 2 files changed, 196 insertions(+) create mode 100644 cmd/kb/prune.go create mode 100644 cmd/kb/prune_test.go diff --git a/cmd/kb/prune.go b/cmd/kb/prune.go new file mode 100644 index 0000000..3319a00 --- /dev/null +++ b/cmd/kb/prune.go @@ -0,0 +1,175 @@ +package kb + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/spf13/cobra" + + "github.com/vibeknow/cli/internal/clerr" + "github.com/vibeknow/cli/internal/cliauth" + "github.com/vibeknow/cli/internal/durfmt" + "github.com/vibeknow/cli/internal/errs" + "github.com/vibeknow/cli/internal/i18n" + "github.com/vibeknow/cli/internal/output" +) + +var ( + flagPrunePattern string + flagPruneOlderThan string + flagPruneYes bool +) + +const prunePageSize = 100 // backend cap; internal detail, not exposed + +// validatePruneFilters rejects "delete everything" — at least one of +// --pattern or --older-than must be set. +func validatePruneFilters(pattern, age string) error { + if pattern == "" && age == "" { + return clerr.Validation(i18n.T("kb.prune.no_filter")) + } + return nil +} + +var pruneCmd = &cobra.Command{ + Use: "prune", + Short: i18n.T("kb.prune.short"), + RunE: func(cmd *cobra.Command, args []string) error { + if err := validatePruneFilters(flagPrunePattern, flagPruneOlderThan); err != nil { + return err + } + var olderThan time.Duration + if flagPruneOlderThan != "" { + d, err := durfmt.ParseAge(flagPruneOlderThan) + if err != nil { + return clerr.Validation(i18n.T("kb.prune.bad_age", flagPruneOlderThan, err.Error())) + } + olderThan = d + } + + vc, err := cliauth.NewVectoriaClient() + if err != nil { + return err + } + + // Scan all pages, accumulating matches. + matched := make([]kbItem, 0, 64) + offset := 0 + var total int + now := time.Now() + for { + scanCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + resp, err := vc.ListKBs(scanCtx, offset, prunePageSize) + cancel() + if err != nil { + return err + } + total = resp.Total + page := (offset / prunePageSize) + 1 + fmt.Fprintln(os.Stderr, i18n.T("kb.prune.scanning", page, offset+len(resp.Items), total)) + items := toItems(resp.Items) + filtered, ferr := filterKBs(items, flagPrunePattern, olderThan, now) + if ferr != nil { + return clerr.Validation(i18n.T("kb.prune.bad_pattern", flagPrunePattern, ferr.Error())) + } + matched = append(matched, filtered...) + offset += len(resp.Items) + if offset >= total || len(resp.Items) == 0 { + break + } + } + + format, _ := cmd.Flags().GetString("output") + + // Dry-run path (default). + if !flagPruneYes { + if format == "json" { + outItems := make([]map[string]any, 0, len(matched)) + for _, k := range matched { + outItems = append(outItems, map[string]any{ + "id": k.ID, + "name": k.Name, + "created_at": k.CreatedRaw, + "status": "would_delete", + }) + } + return output.NewJSON(cmd.OutOrStdout()).Object(map[string]any{ + "dry_run": true, + "matched": len(matched), + "items": outItems, + }) + } + fmt.Println(i18n.T("kb.prune.match_header", len(matched))) + for _, k := range matched { + fmt.Printf(" %s %s %s\n", k.ID, k.Name, k.CreatedRaw) + } + fmt.Println(i18n.T("kb.prune.dry_run_hint")) + return nil + } + + // Apply path. + fmt.Fprintln(os.Stderr, i18n.T("kb.prune.applying", len(matched))) + deleted := 0 + failed := 0 + outItems := make([]map[string]any, 0, len(matched)) + for _, k := range matched { + delCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + err := vc.DeleteKB(delCtx, k.ID) + cancel() + status := "deleted" + if err != nil { + // 404 → idempotent success. + if errs.HasCode(err, "not_found") { + deleted++ + fmt.Fprintf(os.Stderr, " ✓ %s %s (already gone)\n", k.ID, k.Name) + outItems = append(outItems, map[string]any{ + "id": k.ID, + "name": k.Name, + "status": "deleted", + }) + continue + } + failed++ + status = "failed" + fmt.Fprintf(os.Stderr, " ✗ %s %s (%s)\n", k.ID, k.Name, err.Error()) + } else { + deleted++ + fmt.Fprintf(os.Stderr, " ✓ %s %s\n", k.ID, k.Name) + } + outItems = append(outItems, map[string]any{ + "id": k.ID, + "name": k.Name, + "status": status, + }) + } + fmt.Fprintln(os.Stderr, i18n.T("kb.prune.done", deleted, failed)) + + if format == "json" { + _ = output.NewJSON(cmd.OutOrStdout()).Object(map[string]any{ + "dry_run": false, + "matched": len(matched), + "deleted": deleted, + "failed": failed, + "items": outItems, + }) + } + + // Exit codes: all-fail → 5, partial → 7, all-ok → 0. + if failed > 0 && deleted == 0 { + os.Exit(5) + } + if failed > 0 { + os.Exit(7) + } + return nil + }, +} + +func init() { + pruneCmd.Flags().StringVar(&flagPrunePattern, "pattern", "", "glob matched against kb name (e.g., vibeknow-cli-*)") + pruneCmd.Flags().StringVar(&flagPruneOlderThan, "older-than", "", "filter to kbs older than this duration (e.g., 7d)") + pruneCmd.Flags().BoolVarP(&flagPruneYes, "yes", "y", false, "actually delete (default is dry-run)") + Cmd.AddCommand(pruneCmd) +} diff --git a/cmd/kb/prune_test.go b/cmd/kb/prune_test.go new file mode 100644 index 0000000..7de3297 --- /dev/null +++ b/cmd/kb/prune_test.go @@ -0,0 +1,21 @@ +package kb + +import "testing" + +func TestPruneRequiresFilter(t *testing.T) { + if err := validatePruneFilters("", ""); err == nil { + t.Fatal("expected error when neither --pattern nor --older-than set") + } +} + +func TestPruneAcceptsEitherFilter(t *testing.T) { + if err := validatePruneFilters("vibeknow-cli-*", ""); err != nil { + t.Fatalf("pattern-only should be valid, got: %v", err) + } + if err := validatePruneFilters("", "7d"); err != nil { + t.Fatalf("age-only should be valid, got: %v", err) + } + if err := validatePruneFilters("vibeknow-cli-*", "7d"); err != nil { + t.Fatalf("both filters should be valid, got: %v", err) + } +} From 9d3b8a24a6124c3ad48abc5e24f9afd7f69ec7f5 Mon Sep 17 00:00:00 2001 From: nullkey Date: Thu, 14 May 2026 22:21:57 +0800 Subject: [PATCH 07/10] test(integration): kb prune dry-run-by-default, apply, filter-required --- tests/integration/kb_prune_test.go | 164 +++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 tests/integration/kb_prune_test.go diff --git a/tests/integration/kb_prune_test.go b/tests/integration/kb_prune_test.go new file mode 100644 index 0000000..7e08152 --- /dev/null +++ b/tests/integration/kb_prune_test.go @@ -0,0 +1,164 @@ +package integration + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "strings" + "sync" + "testing" +) + +func TestKBPrune_DryRunDoesNotDelete(t *testing.T) { + if testing.Short() { + t.Skip("integration test") + } + + var mu sync.Mutex + deleteCalls := 0 + + mux := http.NewServeMux() + mux.HandleFunc("/v1/knowledgebases", func(w http.ResponseWriter, r *http.Request) { + // list endpoint + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "total": 2, "offset": 0, "limit": 100, + "items": []map[string]any{ + {"id": "kb_a", "name": "vibeknow-cli-1", "description": "", "created_at": "2026-05-14T00:00:00Z"}, + {"id": "kb_b", "name": "manual-kb", "description": "user", "created_at": "2026-05-13T00:00:00Z"}, + }, + }) + }) + mux.HandleFunc("/v1/knowledgebases/", func(w http.ResponseWriter, r *http.Request) { + if r.Method == "DELETE" { + mu.Lock() + deleteCalls++ + mu.Unlock() + w.WriteHeader(204) + } + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + bin := build(t) + configHome := buildProfile(t, map[string]string{"vectoria": srv.URL}) + + cmd := exec.Command(bin, "kb", "prune", "--pattern", "vibeknow-cli-*") + var stdout, stderr strings.Builder + cmd.Stdout = &stdout + cmd.Stderr = &stderr + cmd.Env = append(os.Environ(), + "VIBEKNOW_TOKEN=fake-token", + "VIBEKNOW_CONFIG_HOME="+configHome, + ) + if err := cmd.Run(); err != nil { + t.Fatalf("dry-run unexpected exit: %v\nstdout:%s\nstderr:%s", err, stdout.String(), stderr.String()) + } + combined := stdout.String() + stderr.String() + if !strings.Contains(combined, "vibeknow-cli-1") { + t.Fatalf("dry-run output should list matched kb:\nstdout:%s\nstderr:%s", stdout.String(), stderr.String()) + } + if !strings.Contains(combined, "dry run") { + t.Fatalf("dry-run output should mention 'dry run' hint:\nstdout:%s\nstderr:%s", stdout.String(), stderr.String()) + } + mu.Lock() + dc := deleteCalls + mu.Unlock() + if dc != 0 { + t.Fatalf("dry-run made %d DELETE calls; expected 0", dc) + } +} + +func TestKBPrune_ApplyDeletesMatchedOnly(t *testing.T) { + if testing.Short() { + t.Skip("integration test") + } + + var mu sync.Mutex + var deletedIDs []string + + mux := http.NewServeMux() + mux.HandleFunc("/v1/knowledgebases", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "total": 3, "offset": 0, "limit": 100, + "items": []map[string]any{ + {"id": "kb_a", "name": "vibeknow-cli-1", "description": "", "created_at": "2026-05-14T00:00:00Z"}, + {"id": "kb_b", "name": "manual-kb", "description": "user", "created_at": "2026-05-13T00:00:00Z"}, + {"id": "kb_c", "name": "vibeknow-cli-2", "description": "", "created_at": "2026-05-13T00:00:00Z"}, + }, + }) + }) + mux.HandleFunc("/v1/knowledgebases/", func(w http.ResponseWriter, r *http.Request) { + if r.Method == "DELETE" { + id := strings.TrimPrefix(r.URL.Path, "/v1/knowledgebases/") + mu.Lock() + deletedIDs = append(deletedIDs, id) + mu.Unlock() + w.WriteHeader(204) + } + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + bin := build(t) + configHome := buildProfile(t, map[string]string{"vectoria": srv.URL}) + + cmd := exec.Command(bin, "kb", "prune", "--pattern", "vibeknow-cli-*", "--yes") + var stdout, stderr strings.Builder + cmd.Stdout = &stdout + cmd.Stderr = &stderr + cmd.Env = append(os.Environ(), + "VIBEKNOW_TOKEN=fake-token", + "VIBEKNOW_CONFIG_HOME="+configHome, + ) + if err := cmd.Run(); err != nil { + t.Fatalf("apply unexpected exit: %v\nstderr:%s", err, stderr.String()) + } + + mu.Lock() + defer mu.Unlock() + if len(deletedIDs) != 2 { + t.Fatalf("expected 2 deletes (vibeknow-cli-*), got %d: %v", len(deletedIDs), deletedIDs) + } + // Order-independent check that both expected ids are present. + seen := map[string]bool{} + for _, id := range deletedIDs { + seen[id] = true + } + if !seen["kb_a"] || !seen["kb_c"] { + t.Fatalf("expected kb_a + kb_c deleted, got: %v", deletedIDs) + } + if seen["kb_b"] { + t.Fatalf("manual-kb (kb_b) should NOT have been deleted: %v", deletedIDs) + } +} + +func TestKBPrune_NoFilterExits2(t *testing.T) { + if testing.Short() { + t.Skip("integration test") + } + bin := build(t) + configHome := buildProfile(t, map[string]string{"vectoria": "http://example.invalid"}) + cmd := exec.Command(bin, "kb", "prune", "--yes") + var stdout, stderr strings.Builder + cmd.Stdout = &stdout + cmd.Stderr = &stderr + cmd.Env = append(os.Environ(), + "VIBEKNOW_TOKEN=fake-token", + "VIBEKNOW_CONFIG_HOME="+configHome, + ) + err := cmd.Run() + ee, ok := err.(*exec.ExitError) + if !ok { + t.Fatalf("expected exec.ExitError, got err=%v stderr=%s", err, stderr.String()) + } + if ee.ExitCode() != 2 { + t.Fatalf("expected exit 2, got %d\nstderr:%s", ee.ExitCode(), stderr.String()) + } + if !strings.Contains(stderr.String(), "--pattern") || !strings.Contains(stderr.String(), "--older-than") { + t.Fatalf("error message should mention --pattern AND --older-than, got: %s", stderr.String()) + } +} From 9516ce442bb8a310fa00791bdd33865ef9e14bcd Mon Sep 17 00:00:00 2001 From: nullkey Date: Thu, 14 May 2026 22:23:58 +0800 Subject: [PATCH 08/10] docs: document vk kb subcommand family for 0.6.0 --- AGENTS.md | 25 +++++++++++++++++++++++++ CHANGELOG.md | 27 +++++++++++++++++++++++++++ README.md | 8 ++++++++ README.zh.md | 8 ++++++++ 4 files changed, 68 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 5930c98..f4f0d47 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,3 +81,28 @@ backend: The `engine` field in JSON snapshot output reflects which engine ran (`"pipeline"` / `"agent"`), letting agents verify routing. + +## KB management + +`vk kb list / delete / prune` manage vectoria knowledgebases. The +headline command is `vk kb prune`, which exists to clean up the kb +backlog that `vk create` accumulates (one new kb per invocation). + +Safety contract for `vk kb prune`: + +- **Refuses to run without `--pattern` or `--older-than`** (exit 2). + No "delete all" shortcut. +- **Dry-run by default**: prints matched kbs without issuing any + DELETE. `--yes` (or `VIBEKNOW_ASSUME_YES=1`) actually deletes. +- **Idempotent**: a 404 on DELETE counts as success (kb already gone). +- **Partial failure**: exit 7 if some succeed and some fail; exit 5 + if all fail; exit 0 if all succeed or matched empty. + +Recipes: + +- Clean CLI's own orphans: `vk kb prune --pattern 'vibeknow-cli-*' --yes` +- Clean old kbs (last 30 days): `vk kb prune --older-than 30d --yes` +- Both: `vk kb prune --pattern '*' --older-than 90d --yes` + +Output via `--output json` for piping; default text mode is +human-friendly. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f85b73..1c4a555 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## 0.6.0 — 2026-05-DD + +### New + +- `vk kb list` — list your vectoria knowledgebases with `--page`, + `--size`, `--pattern `, `--older-than ` filters. + Glob uses Go `filepath.Match` syntax. Duration accepts `Nd` / `Nh` + / `Nm` forms (`7d`, `24h`, `1h30m`). +- `vk kb delete ` — single-kb delete with confirmation prompt + (skip via `--yes` / `VIBEKNOW_ASSUME_YES=1`). 404 from backend is + treated as success (idempotent, `rm -f` semantics). +- `vk kb prune` — bulk-delete by filter. **Dry-run by default**: + prints matched kbs without deleting; requires `--yes` to actually + delete. Refuses to run without `--pattern` or `--older-than` — + no "delete everything" shortcut. Partial-failure semantics: + exit 7 if some succeed and some fail (matches `vk create --export`). +- `vectoria.Client.ListKBs(ctx, offset, limit)` — exposed for + callers of the client library. +- `internal/durfmt.ParseAge` — duration parser with `Nd` day-suffix + shortcut used by the kb filters. + +### Migration + +- New commands, no breaking changes. +- For users carrying the 0.5.x backlog of CLI-named orphan kbs: + `vk kb prune --pattern 'vibeknow-cli-*' --yes` cleans them up. + ## 0.5.2 — 2026-05-14 ### Fixed diff --git a/README.md b/README.md index 33a1f28..c44a406 100644 --- a/README.md +++ b/README.md @@ -218,6 +218,14 @@ vk create --from --engine agent # v=2 agent engine (frontend toggle vk create --from --engine pipeline # v=3 pipeline (default) ``` +### Clean up accumulated knowledgebases + +```bash +vk kb list --output json --size 5 # peek at what's there +vk kb prune --pattern 'vibeknow-cli-*' # dry-run (default) +vk kb prune --pattern 'vibeknow-cli-*' --yes # actually delete +``` + ### Voice Templates ```bash diff --git a/README.zh.md b/README.zh.md index aae36c7..ad48a1a 100644 --- a/README.zh.md +++ b/README.zh.md @@ -218,6 +218,14 @@ vk create --from --engine agent # v=2 agent 引擎(与前端选项 vk create --from --engine pipeline # v=3 pipeline(默认) ``` +### 清理累积的知识库 + +```bash +vk kb list --output json --size 5 # 看下都有啥 +vk kb prune --pattern 'vibeknow-cli-*' # 试运行(默认) +vk kb prune --pattern 'vibeknow-cli-*' --yes # 真正删除 +``` + ### 音色模板 ```bash From 6586d6905e55c3bb84439986da52cdf538843805 Mon Sep 17 00:00:00 2001 From: nullkey Date: Thu, 14 May 2026 22:26:16 +0800 Subject: [PATCH 09/10] chore: bump version to 0.6.0 --- CHANGELOG.md | 2 +- package.json | 2 +- skills/vibeknow-core/SKILL.md | 2 +- skills/vibeknow-create/SKILL.md | 2 +- skills/vibeknow-doc/SKILL.md | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c4a555..b812ea1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.6.0 — 2026-05-DD +## 0.6.0 — 2026-05-14 ### New diff --git a/package.json b/package.json index 987fc49..81c084c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vibeknow-cli", - "version": "0.5.2", + "version": "0.6.0", "description": "VibeKnow CLI — turn docs / URLs into videos", "license": "MIT", "bin": { diff --git a/skills/vibeknow-core/SKILL.md b/skills/vibeknow-core/SKILL.md index 985a870..9e0ee90 100644 --- a/skills/vibeknow-core/SKILL.md +++ b/skills/vibeknow-core/SKILL.md @@ -1,6 +1,6 @@ --- name: vibeknow-core -version: 0.5.2 +version: 0.6.0 description: "vibeknow CLI setup, authentication, profile management, and diagnostics. Use when: first-time setup, auth errors, switching environments, diagnosing connection issues." metadata: requires: diff --git a/skills/vibeknow-create/SKILL.md b/skills/vibeknow-create/SKILL.md index b2d990c..01ca265 100644 --- a/skills/vibeknow-create/SKILL.md +++ b/skills/vibeknow-create/SKILL.md @@ -1,6 +1,6 @@ --- name: vibeknow-create -version: 0.5.2 +version: 0.6.0 description: "Generate videos from documents/URLs/files, track video task progress, download results, list voice templates. Use when: user wants to create a video, check task status, download video, or browse voices." metadata: requires: diff --git a/skills/vibeknow-doc/SKILL.md b/skills/vibeknow-doc/SKILL.md index c65d915..89a8c6b 100644 --- a/skills/vibeknow-doc/SKILL.md +++ b/skills/vibeknow-doc/SKILL.md @@ -1,6 +1,6 @@ --- name: vibeknow-doc -version: 0.5.2 +version: 0.6.0 description: "Upload documents to vectoria and check processing status. Use when: user wants to upload a document, check if a document is ready, or get a doc_id for use with vibeknow create." metadata: requires: From ba44016bb7f7aacbcce641c81811414433dd9ee2 Mon Sep 17 00:00:00 2001 From: nullkey Date: Thu, 14 May 2026 22:39:44 +0800 Subject: [PATCH 10/10] =?UTF-8?q?refactor(kb):=20final-review=20fixes=20?= =?UTF-8?q?=E2=80=94=20exit-2=20consistency,=20pre-flight,=20comment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply 3 of 4 important findings from final code review: - cmd/kb/list.go: --older-than parse error and --pattern glob error now route through clerr.Validation so they exit 2 (matches prune's behavior + the documented exit-code contract). Pre-flight pattern syntax check moved before the HTTP call. - cmd/kb/prune.go: pattern syntax pre-flighted at the top of RunE so a bad glob exits 2 before the first list-page round-trip. The scan-loop's per-page filterKBs() still validates as a safety net. - cmd/kb/delete.go: document why the confirmation prompt doesn't show the kb name. Spec §3 imagined a per-id GET to enrich the prompt; the vectoria backend returns 405 Method Not Allowed for that route, so the spec's "fallback to UUID-only" path is the only path. Skipped: --page/--size client-side clamping (consistent with the existing vk video list pattern; backend returns clear error on bad values). Locale pinning in integration tests is a cross-cutting concern best handled in one sweep across all integration tests. --- cmd/kb/delete.go | 5 +++++ cmd/kb/list.go | 10 +++++++++- cmd/kb/prune.go | 9 +++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/cmd/kb/delete.go b/cmd/kb/delete.go index e858e9f..42f19c4 100644 --- a/cmd/kb/delete.go +++ b/cmd/kb/delete.go @@ -22,6 +22,11 @@ var deleteCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { kbID := args[0] + // The vectoria backend does not expose GET /v1/knowledgebases/ + // (returns 405), so we can't enrich the prompt with the kb's name + // without an O(N) list-and-scan. The "no_name" variant is the only + // path; in practice users got the id from `vk kb list` and already + // know which one they're deleting. ok, err := cmdutil.Confirm(cmdutil.ConfirmOptions{ Prompt: i18n.T("kb.delete.confirm.no_name", kbID), Yes: flagDeleteYes, diff --git a/cmd/kb/list.go b/cmd/kb/list.go index 13dcef7..1d12acf 100644 --- a/cmd/kb/list.go +++ b/cmd/kb/list.go @@ -10,6 +10,7 @@ import ( "github.com/spf13/cobra" "github.com/vibeknow/cli/client/vectoria" + "github.com/vibeknow/cli/internal/clerr" "github.com/vibeknow/cli/internal/cliauth" "github.com/vibeknow/cli/internal/durfmt" "github.com/vibeknow/cli/internal/i18n" @@ -82,10 +83,16 @@ var listCmd = &cobra.Command{ if flagListOlderThan != "" { d, err := durfmt.ParseAge(flagListOlderThan) if err != nil { - return fmt.Errorf("--older-than: %w", err) + return clerr.Validation(i18n.T("kb.prune.bad_age", flagListOlderThan, err.Error())) } olderThan = d } + // Pre-flight pattern validity so a bad glob exits 2 before any HTTP. + if flagListPattern != "" { + if _, err := filepath.Match(flagListPattern, ""); err != nil { + return clerr.Validation(i18n.T("kb.prune.bad_pattern", flagListPattern, err.Error())) + } + } vc, err := cliauth.NewVectoriaClient() if err != nil { @@ -101,6 +108,7 @@ var listCmd = &cobra.Command{ items := toItems(resp.Items) filtered, err := filterKBs(items, flagListPattern, olderThan, time.Now()) if err != nil { + // Pre-flighted above; reaching here means a non-pattern issue. return err } diff --git a/cmd/kb/prune.go b/cmd/kb/prune.go index 3319a00..5e602a6 100644 --- a/cmd/kb/prune.go +++ b/cmd/kb/prune.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "path/filepath" "time" "github.com/spf13/cobra" @@ -40,6 +41,14 @@ var pruneCmd = &cobra.Command{ if err := validatePruneFilters(flagPrunePattern, flagPruneOlderThan); err != nil { return err } + // Pre-flight the pattern syntax so a bad glob exits 2 before any + // HTTP round-trip (the scan loop's per-page filterKBs() also + // validates, but only after the first page has been fetched). + if flagPrunePattern != "" { + if _, err := filepath.Match(flagPrunePattern, ""); err != nil { + return clerr.Validation(i18n.T("kb.prune.bad_pattern", flagPrunePattern, err.Error())) + } + } var olderThan time.Duration if flagPruneOlderThan != "" { d, err := durfmt.ParseAge(flagPruneOlderThan)