Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,32 @@
# Changelog

## 0.6.0 — 2026-05-14

### New

- `vk kb list` — list your vectoria knowledgebases with `--page`,
`--size`, `--pattern <glob>`, `--older-than <duration>` filters.
Glob uses Go `filepath.Match` syntax. Duration accepts `Nd` / `Nh`
/ `Nm` forms (`7d`, `24h`, `1h30m`).
- `vk kb delete <id>` — 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
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,14 @@ vk create --from <src> --engine agent # v=2 agent engine (frontend toggle
vk create --from <src> --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
Expand Down
8 changes: 8 additions & 0 deletions README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,14 @@ vk create --from <src> --engine agent # v=2 agent 引擎(与前端选项
vk create --from <src> --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
Expand Down
25 changes: 25 additions & 0 deletions client/vectoria/knowledgebase.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
36 changes: 36 additions & 0 deletions client/vectoria/knowledgebase_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
63 changes: 63 additions & 0 deletions cmd/kb/delete.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
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 <id>",
Short: i18n.T("kb.delete.short"),
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
kbID := args[0]

// The vectoria backend does not expose GET /v1/knowledgebases/<id>
// (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,
})
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)
}
13 changes: 13 additions & 0 deletions cmd/kb/kb.go
Original file line number Diff line number Diff line change
@@ -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"),
}
152 changes: 152 additions & 0 deletions cmd/kb/list.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package kb

import (
"context"
"fmt"
"path/filepath"
"strings"
"time"

"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"
"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 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 {
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 {
// Pre-flighted above; reaching here means a non-pattern issue.
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)
}
Loading
Loading