feat(cli): add whoami / doctor / kb / context commands (PR-7)

5 new leaf commands wired into the root tree:
  whoami      — simplified `auth status` (user_id + tenant_id only)
  doctor      — 4-item self-check (base_url / auth / server_version / cred_storage)
                with --offline / --no-cache flags + skip cascade + summary.all_passed
                防 agent 看到 envelope.ok=true 误判命令整体 success
  kb list     — list KBs (default updated_at desc; 0 KB → "(no knowledge bases)")
                tabwriter 4-col (ID/NAME/DOCS/UPDATED), display-width truncation
  kb get      — show single KB details (KEY: VALUE, suppress empty fields)
  context use — switch default context (writes config.current_context),
                带 levenshtein distance ≤ 2 的 did-you-mean hint

Each command uses the v0.0 narrow Service interface pattern (testable via
fakes), agent.SetAgentHelp for AI-friendly hints, and ClassifyHTTPError
for stable error code mapping.

cmdutil/errors.go: 新增 CodeLocalContextNotFound for `context use`,加入 AllCodes() 注册集.

cli/cmd/root.go: NewRootCmd 改为 exported (acceptance/contract 测试需要),
                 注册 4 个新命令 + 1 parent group; root_test.go 跟随更新.
This commit is contained in:
nullkey
2026-05-08 16:13:58 +08:00
committed by lyingbug
parent 92d78dea72
commit cf84bf2a38
15 changed files with 1201 additions and 15 deletions
+24
View File
@@ -0,0 +1,24 @@
// Package contextcmd holds `weknora context` command tree (use; list / set /
// delete in v0.4).
//
// Package name `contextcmd` (not `context`) to avoid shadowing stdlib context.
// The cobra Use: string is "context" — this is what users type.
package contextcmd
import (
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
)
// NewCmd builds the `weknora context` parent command.
func NewCmd(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "context",
Short: "Manage CLI contexts (named connection targets)",
Args: cobra.NoArgs,
Run: func(c *cobra.Command, _ []string) { _ = c.Help() },
}
cmd.AddCommand(NewCmdUse(f))
return cmd
}
+128
View File
@@ -0,0 +1,128 @@
package contextcmd
import (
"fmt"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/agent"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/config"
"github.com/Tencent/WeKnora/cli/internal/format"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
)
// NewCmdUse builds the `weknora context use <name>` command.
func NewCmdUse(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "use <name>",
Short: "Switch the default context for subsequent commands",
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
return runUse(args[0])
},
}
agent.SetAgentHelp(cmd, "Switches default CLI context. Returns previous_context + current_context. Errors with hint when name unknown.")
return cmd
}
type useResult struct {
CurrentContext string `json:"current_context"`
PreviousContext string `json:"previous_context,omitempty"`
}
func runUse(name string) error {
cfg, err := config.Load()
if err != nil {
return err
}
if _, ok := cfg.Contexts[name]; !ok {
return notFoundError(name, cfg)
}
prev := cfg.CurrentContext
cfg.CurrentContext = name
if err := config.Save(cfg); err != nil {
return err
}
return format.WriteEnvelope(iostreams.IO.Out, format.Success(useResult{
CurrentContext: name,
PreviousContext: prev,
}, nil))
}
func notFoundError(name string, cfg *config.Config) error {
if len(cfg.Contexts) == 0 {
return &cmdutil.Error{
Code: cmdutil.CodeLocalContextNotFound,
Message: fmt.Sprintf("context not found: %s", name),
Hint: "no contexts registered — run `weknora auth login` first",
}
}
candidate := closestMatch(name, contextKeys(cfg.Contexts))
hint := availableHint(cfg)
if candidate != "" && candidate != name {
hint = fmt.Sprintf("did you mean: %q?", candidate)
}
return &cmdutil.Error{
Code: cmdutil.CodeLocalContextNotFound,
Message: fmt.Sprintf("context not found: %s", name),
Hint: hint,
}
}
func contextKeys(m map[string]config.Context) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
func availableHint(cfg *config.Config) string {
return fmt.Sprintf("available contexts: %v", contextKeys(cfg.Contexts))
}
// closestMatch returns the candidate with min levenshtein distance ≤ 2,
// or "" if none qualifies.
func closestMatch(target string, candidates []string) string {
best := ""
bestD := 3
for _, c := range candidates {
d := levenshtein(target, c)
if d < bestD {
bestD = d
best = c
}
}
if bestD > 2 {
return ""
}
return best
}
func levenshtein(a, b string) int {
la, lb := len(a), len(b)
if la == 0 {
return lb
}
if lb == 0 {
return la
}
prev := make([]int, lb+1)
curr := make([]int, lb+1)
for j := 0; j <= lb; j++ {
prev[j] = j
}
for i := 1; i <= la; i++ {
curr[0] = i
for j := 1; j <= lb; j++ {
cost := 1
if a[i-1] == b[j-1] {
cost = 0
}
curr[j] = min(curr[j-1]+1, prev[j]+1, prev[j-1]+cost)
}
prev, curr = curr, prev
}
return prev[lb]
}
+103
View File
@@ -0,0 +1,103 @@
package contextcmd
import (
"strings"
"testing"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/config"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
)
func TestUse_OK(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
out, _ := iostreams.SetForTest(t)
cfg := &config.Config{
CurrentContext: "staging",
Contexts: map[string]config.Context{
"staging": {Host: "https://staging.example.com"},
"production": {Host: "https://prod.example.com"},
},
}
if err := config.Save(cfg); err != nil {
t.Fatalf("Save initial config: %v", err)
}
if err := runUse("production"); err != nil {
t.Fatalf("runUse: %v", err)
}
got, err := config.Load()
if err != nil {
t.Fatalf("reload: %v", err)
}
if got.CurrentContext != "production" {
t.Errorf("CurrentContext = %q, want production", got.CurrentContext)
}
if !strings.Contains(out.String(), "production") {
t.Errorf("output should mention switched-to context, got %q", out.String())
}
}
func TestUse_NotFound_WithDidYouMean(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
_, _ = iostreams.SetForTest(t)
cfg := &config.Config{Contexts: map[string]config.Context{
"production": {Host: "https://prod"},
"staging": {Host: "https://staging"},
}}
if err := config.Save(cfg); err != nil {
t.Fatalf("Save: %v", err)
}
err := runUse("prodution") // typo: missing 'c'
if err == nil {
t.Fatal("expected error")
}
cm, ok := err.(*cmdutil.Error)
if !ok {
t.Fatalf("expected *cmdutil.Error, got %T", err)
}
if cm.Code != cmdutil.CodeLocalContextNotFound {
t.Errorf("code = %q, want %q", cm.Code, cmdutil.CodeLocalContextNotFound)
}
if !strings.Contains(cm.Hint, "production") {
t.Errorf("hint should suggest 'production', got %q", cm.Hint)
}
}
func TestUse_NotFound_EmptyContexts(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
_, _ = iostreams.SetForTest(t)
err := runUse("anything")
if err == nil {
t.Fatal("expected error")
}
cm := err.(*cmdutil.Error)
if !strings.Contains(cm.Hint, "auth login") {
t.Errorf("hint should mention `auth login` for empty Contexts, got %q", cm.Hint)
}
}
func TestUse_CaseSensitive(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
_, _ = iostreams.SetForTest(t)
cfg := &config.Config{Contexts: map[string]config.Context{
"Production": {Host: "https://prod"},
}}
_ = config.Save(cfg)
err := runUse("production") // lowercase — must NOT match "Production"
if err == nil {
t.Fatal("expected case-sensitive miss")
}
cm := err.(*cmdutil.Error)
// did-you-mean kicks in (distance 1 — "P"→"p")
if !strings.Contains(cm.Hint, "Production") {
t.Errorf("hint should suggest 'Production' (case-different), got %q", cm.Hint)
}
}
+303
View File
@@ -0,0 +1,303 @@
// Package doctor implements `weknora doctor` — 4-item self-check (spec §1.2).
//
// Status semantics (3-tier, from larksuite/cli's pass/fail/warn/skip set):
//
// ok — passed
// fail — failed; "hint" actionable
// skip — cascade-skipped (prereq failed) or --offline mode
//
// Envelope: ok=true normally; data.summary.all_passed gives the agent a
// one-line short-circuit (spec §1.2 防 envelope.ok=true 误判).
//
// Special: base URL completely unreachable + non-offline → no checks can be
// initiated → caller may decide to surface envelope.ok=false. v0.1 minimal
// approach: even base_url fail still runs credential_storage (independent),
// so envelope.ok stays true; agents read data.summary.failed > 0.
package doctor
import (
"context"
"fmt"
"net/http"
"os"
"time"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/agent"
"github.com/Tencent/WeKnora/cli/internal/build"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/compat"
"github.com/Tencent/WeKnora/cli/internal/format"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/secrets"
sdk "github.com/Tencent/WeKnora/client"
)
// Options captures the CLI flags for `weknora doctor`.
type Options struct {
NoCache bool
Offline bool
JSONOut bool
}
// Check is one row in the report.
type Check struct {
Name string `json:"name"`
Status string `json:"status"` // ok / fail / skip
Details string `json:"details,omitempty"`
Hint string `json:"hint,omitempty"`
}
// Summary is the agent-friendly short-circuit payload (spec §1.2).
type Summary struct {
AllPassed bool `json:"all_passed"`
Passed int `json:"passed"`
Failed int `json:"failed"`
Skipped int `json:"skipped"`
}
// Result is the full envelope data.
type Result struct {
Summary Summary `json:"summary"`
Checks []Check `json:"checks"`
}
// Services groups the narrow interfaces doctor needs. Implemented by
// realServices (production) and fakeServices (tests).
type Services interface {
PingBaseURL(ctx context.Context) error
GetCurrentUser(ctx context.Context) (*sdk.CurrentUserResponse, error)
GetSystemInfo(ctx context.Context) (*sdk.SystemInfo, error)
}
// NewCmd builds `weknora doctor`.
func NewCmd(f *cmdutil.Factory) *cobra.Command {
opts := &Options{}
cmd := &cobra.Command{
Use: "doctor",
Short: "Run 4 self-checks: base URL, auth, server version, credential storage",
RunE: func(c *cobra.Command, _ []string) error {
svc, err := buildServices(f)
if err != nil {
return err
}
cliVer, _, _ := build.Info()
r := runChecks(c.Context(), opts, svc, cliVer)
emit(opts, r)
return nil // doctor 自身不返回 error;失败状态在 data.checks
},
}
cmd.Flags().BoolVar(&opts.NoCache, "no-cache", false, "Bypass server-info cache; force re-probe")
cmd.Flags().BoolVar(&opts.Offline, "offline", false, "Skip network checks; only verify local keyring/file storage")
cmd.Flags().BoolVar(&opts.JSONOut, "json", false, "Output JSON envelope")
agent.SetAgentHelp(cmd, "Returns 4 health checks. AGENT short-circuit: read data.summary.all_passed; if false, inspect data.checks[].status (ok/fail/skip).")
return cmd
}
// runChecks executes the 4-item check matrix with cascade-skip semantics.
// Pure function over Services so tests can drive it directly.
func runChecks(ctx context.Context, opts *Options, svc Services, cliVer string) Result {
checks := []Check{
{Name: "base_url_reachable"},
{Name: "auth_credential"},
{Name: "server_version"},
{Name: "credential_storage"},
}
// 1. base_url_reachable
if opts.Offline {
checks[0].Status = "skip"
checks[0].Details = "offline mode"
} else {
t0 := time.Now()
if err := svc.PingBaseURL(ctx); err != nil {
checks[0].Status = "fail"
checks[0].Hint = "verify --base-url and network reachability"
checks[0].Details = err.Error()
} else {
checks[0].Status = "ok"
checks[0].Details = fmt.Sprintf("reachable in %s", time.Since(t0).Round(time.Millisecond))
}
}
// 2. auth_credential — depends on base_url
switch {
case opts.Offline:
checks[1].Status = "skip"
checks[1].Details = "offline mode"
case checks[0].Status == "fail":
checks[1].Status = "skip"
checks[1].Details = "prereq failed: base_url_reachable"
default:
_, err := svc.GetCurrentUser(ctx)
if err != nil {
checks[1].Status = "fail"
checks[1].Hint = "run `weknora auth login`"
checks[1].Details = err.Error()
} else {
checks[1].Status = "ok"
}
}
// 3. server_version — depends on auth_credential
switch {
case opts.Offline:
checks[2].Status = "skip"
checks[2].Details = "offline mode"
case checks[1].Status != "ok":
checks[2].Status = "skip"
checks[2].Details = "prereq failed: auth_credential"
default:
info, fromCache, err := loadOrProbeServerInfo(ctx, opts, svc)
if err != nil {
checks[2].Status = "fail"
checks[2].Details = err.Error()
} else {
level, hint := compat.Compat(info.ServerVersion, cliVer)
suffix := ""
if fromCache {
suffix = " (cached, pass --no-cache to refresh)"
}
if level == compat.HardError {
checks[2].Status = "fail"
checks[2].Hint = hint
checks[2].Details = "server " + info.ServerVersion + suffix
} else {
checks[2].Status = "ok"
if hint != "" {
checks[2].Details = hint + suffix
} else {
checks[2].Details = fmt.Sprintf("server %s%s", info.ServerVersion, suffix)
}
}
}
}
// 4. credential_storage — independent of network
if _, err := secrets.NewBestEffortStore(); err != nil {
checks[3].Status = "fail"
checks[3].Details = err.Error()
checks[3].Hint = "verify keyring access; falls back to file store"
} else {
checks[3].Status = "ok"
checks[3].Details = "keyring or file storage available"
}
return Result{Summary: summarize(checks), Checks: checks}
}
// loadOrProbeServerInfo respects --no-cache:
// --no-cache or stale/missing cache → call svc.GetSystemInfo + SaveCache
// else → return cached Info (fromCache=true)
//
// SaveCache failure does NOT propagate — best-effort write. The probe
// data is still returned to the caller for the compat check.
func loadOrProbeServerInfo(ctx context.Context, opts *Options, svc Services) (info *compat.Info, fromCache bool, err error) {
if !opts.NoCache {
if cached, fresh, _ := compat.LoadCache(); fresh && cached != nil {
return cached, true, nil
}
}
sys, err := svc.GetSystemInfo(ctx)
if err != nil {
return nil, false, err
}
fresh := &compat.Info{ServerVersion: sys.Version, ProbedAt: time.Now()}
_ = compat.SaveCache(fresh) // best-effort
return fresh, false, nil
}
func summarize(cs []Check) Summary {
s := Summary{}
for _, c := range cs {
switch c.Status {
case "ok":
s.Passed++
case "fail":
s.Failed++
case "skip":
s.Skipped++
}
}
s.AllPassed = s.Failed == 0 && s.Skipped == 0
return s
}
func emit(opts *Options, r Result) {
if opts.JSONOut {
_ = format.WriteEnvelope(iostreams.IO.Out, format.Success(r, nil))
return
}
for _, c := range r.Checks {
marker := "[ok]"
switch c.Status {
case "fail":
marker = "[fail]"
case "skip":
marker = "[skip]"
}
line := fmt.Sprintf("%-6s %-20s %s", marker, c.Name, c.Status)
if c.Details != "" {
line += " (" + c.Details + ")"
}
fmt.Fprintln(iostreams.IO.Out, line)
if c.Hint != "" {
fmt.Fprintf(iostreams.IO.Out, " hint: %s\n", c.Hint)
}
}
fmt.Fprintf(iostreams.IO.Out, "\nsummary: %d passed, %d failed, %d skipped\n",
r.Summary.Passed, r.Summary.Failed, r.Summary.Skipped)
}
// buildServices wires the Factory closures into the doctor.Services interface.
func buildServices(f *cmdutil.Factory) (Services, error) {
cli, err := f.Client()
if err != nil {
return nil, err
}
return &realServices{cli: cli}, nil
}
type realServices struct {
cli *sdk.Client
}
// pingTimeout caps the HEAD /health probe so a wedged TCP connection
// can't hang doctor indefinitely.
const pingTimeout = 5 * time.Second
func (s *realServices) PingBaseURL(ctx context.Context) error {
// WEKNORA_BASE_URL is the test-scaffold override; production should plumb
// config.Host through (TODO v0.2: add Client.BaseURL() accessor in SDK).
url := envOrDefault("WEKNORA_BASE_URL", "http://localhost:8080") + "/health"
ctx, cancel := context.WithTimeout(ctx, pingTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 500 {
return fmt.Errorf("server returned %d", resp.StatusCode)
}
return nil
}
func (s *realServices) GetCurrentUser(ctx context.Context) (*sdk.CurrentUserResponse, error) {
return s.cli.GetCurrentUser(ctx)
}
func (s *realServices) GetSystemInfo(ctx context.Context) (*sdk.SystemInfo, error) {
return s.cli.GetSystemInfo(ctx)
}
func envOrDefault(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
+161
View File
@@ -0,0 +1,161 @@
package doctor
import (
"context"
"errors"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/Tencent/WeKnora/cli/internal/compat"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
type fakeServices struct {
systemInfo *sdk.SystemInfo
systemErr error
userResp *sdk.CurrentUserResponse
userErr error
pingErr error
systemInfoHits atomic.Int32 // count GetSystemInfo invocations
}
func (f *fakeServices) GetSystemInfo(ctx context.Context) (*sdk.SystemInfo, error) {
f.systemInfoHits.Add(1)
return f.systemInfo, f.systemErr
}
func (f *fakeServices) GetCurrentUser(ctx context.Context) (*sdk.CurrentUserResponse, error) {
return f.userResp, f.userErr
}
func (f *fakeServices) PingBaseURL(ctx context.Context) error { return f.pingErr }
func goodUserResp() *sdk.CurrentUserResponse {
r := &sdk.CurrentUserResponse{}
r.Data.User = &sdk.AuthUser{ID: "u1"}
return r
}
func TestDoctor_AllOK(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
out, _ := iostreams.SetForTest(t)
svc := &fakeServices{
systemInfo: &sdk.SystemInfo{Version: "1.0.0"},
userResp: goodUserResp(),
}
r := runChecks(context.Background(), &Options{JSONOut: true}, svc, "1.0.0")
if !r.Summary.AllPassed {
t.Errorf("expected all_passed, got summary %+v", r.Summary)
}
if r.Summary.Failed != 0 || r.Summary.Skipped != 0 {
t.Errorf("expected 0 fail / 0 skip, got %+v", r.Summary)
}
emit(&Options{JSONOut: true}, r)
if !strings.Contains(out.String(), `"all_passed":true`) {
t.Errorf("envelope should embed all_passed=true, got %q", out.String())
}
}
func TestDoctor_BaseURLFails_DownstreamSkip(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
_, _ = iostreams.SetForTest(t)
svc := &fakeServices{
pingErr: errors.New("connect refused"),
userResp: goodUserResp(),
systemInfo: &sdk.SystemInfo{Version: "1.0.0"},
}
r := runChecks(context.Background(), &Options{}, svc, "1.0.0")
if r.Summary.Skipped != 2 {
t.Errorf("expected 2 skipped (auth_credential + server_version), got %d", r.Summary.Skipped)
}
if r.Checks[0].Status != "fail" {
t.Errorf("base_url_reachable status = %q, want fail", r.Checks[0].Status)
}
if r.Checks[1].Status != "skip" {
t.Errorf("auth_credential status = %q, want skip", r.Checks[1].Status)
}
if r.Checks[2].Status != "skip" {
t.Errorf("server_version status = %q, want skip", r.Checks[2].Status)
}
// credential_storage 与网络无关,应该独立运行(不受 base_url fail 影响)
if r.Checks[3].Name != "credential_storage" {
t.Errorf("Checks[3] = %q, want credential_storage", r.Checks[3].Name)
}
}
func TestDoctor_Offline_OnlyKeyringChecked(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
_, _ = iostreams.SetForTest(t)
svc := &fakeServices{}
r := runChecks(context.Background(), &Options{Offline: true}, svc, "1.0.0")
if r.Summary.Skipped < 3 {
t.Errorf("expected >=3 skip in offline mode, got %d", r.Summary.Skipped)
}
last := r.Checks[3]
if last.Name != "credential_storage" {
t.Errorf("last check = %q, want credential_storage", last.Name)
}
if last.Status == "skip" {
t.Error("credential_storage should NOT be skipped even in offline mode")
}
}
func TestDoctor_AuthFails_VersionSkipped(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
_, _ = iostreams.SetForTest(t)
svc := &fakeServices{
userErr: errors.New("HTTP error 401: unauthenticated"),
systemInfo: &sdk.SystemInfo{Version: "1.0.0"},
}
r := runChecks(context.Background(), &Options{}, svc, "1.0.0")
if r.Checks[0].Status != "ok" {
t.Errorf("base_url should pass, got %q", r.Checks[0].Status)
}
if r.Checks[1].Status != "fail" {
t.Errorf("auth_credential should fail, got %q", r.Checks[1].Status)
}
if r.Checks[2].Status != "skip" {
t.Errorf("server_version should skip due to auth fail, got %q", r.Checks[2].Status)
}
}
func TestDoctor_CacheHit_SkipsProbe(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
_, _ = iostreams.SetForTest(t)
// Pre-populate fresh cache
if err := compat.SaveCache(&compat.Info{ServerVersion: "1.0.0", ProbedAt: time.Now()}); err != nil {
t.Fatalf("seed cache: %v", err)
}
svc := &fakeServices{userResp: goodUserResp()}
r := runChecks(context.Background(), &Options{}, svc, "1.0.0")
if r.Checks[2].Status != "ok" {
t.Errorf("server_version should be ok via cache, got %q (%s)", r.Checks[2].Status, r.Checks[2].Details)
}
if svc.systemInfoHits.Load() != 0 {
t.Errorf("expected 0 GetSystemInfo calls (cache hit), got %d", svc.systemInfoHits.Load())
}
if !strings.Contains(r.Checks[2].Details, "cached") {
t.Errorf("details should mention cache, got %q", r.Checks[2].Details)
}
}
func TestDoctor_NoCache_BypassesCache(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
_, _ = iostreams.SetForTest(t)
// Pre-populate fresh cache; --no-cache should ignore it
if err := compat.SaveCache(&compat.Info{ServerVersion: "9.9.9", ProbedAt: time.Now()}); err != nil {
t.Fatalf("seed cache: %v", err)
}
svc := &fakeServices{
userResp: goodUserResp(),
systemInfo: &sdk.SystemInfo{Version: "1.0.0"},
}
r := runChecks(context.Background(), &Options{NoCache: true}, svc, "1.0.0")
if svc.systemInfoHits.Load() != 1 {
t.Errorf("expected 1 GetSystemInfo call (--no-cache), got %d", svc.systemInfoHits.Load())
}
if !strings.Contains(r.Checks[2].Details, "1.0.0") {
t.Errorf("details should reflect probed version 1.0.0 not cached 9.9.9, got %q", r.Checks[2].Details)
}
}
+72
View File
@@ -0,0 +1,72 @@
package kb
import (
"context"
"fmt"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/agent"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/format"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/text"
sdk "github.com/Tencent/WeKnora/client"
)
// GetOptions captures `weknora kb get` flags.
type GetOptions struct {
JSONOut bool
}
// GetService is the narrow SDK surface this command depends on.
type GetService interface {
GetKnowledgeBase(ctx context.Context, id string) (*sdk.KnowledgeBase, error)
}
// NewCmdGet builds `weknora kb get <id>`.
func NewCmdGet(f *cmdutil.Factory) *cobra.Command {
opts := &GetOptions{}
cmd := &cobra.Command{
Use: "get <id>",
Short: "Show a knowledge base by ID",
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
cli, err := f.Client()
if err != nil {
return err
}
return runGet(c.Context(), opts, cli, args[0])
},
}
cmd.Flags().BoolVar(&opts.JSONOut, "json", false, "Output JSON envelope")
agent.SetAgentHelp(cmd, "Returns details of one knowledge base by ID (config + counts).")
return cmd
}
func runGet(ctx context.Context, opts *GetOptions, svc GetService, id string) error {
kb, err := svc.GetKnowledgeBase(ctx, id)
if err != nil {
return cmdutil.Wrapf(cmdutil.ClassifyHTTPError(err), err, "get knowledge base %q", id)
}
if opts.JSONOut {
return format.WriteEnvelope(iostreams.IO.Out, format.Success(kb, nil))
}
// Human: KEY: VALUE
w := iostreams.IO.Out
fmt.Fprintf(w, "ID: %s\n", kb.ID)
fmt.Fprintf(w, "NAME: %s\n", kb.Name)
if kb.Description != "" {
fmt.Fprintf(w, "DESC: %s\n", kb.Description)
}
fmt.Fprintf(w, "DOCS: %s\n", text.Pluralize(int(kb.KnowledgeCount), "doc"))
fmt.Fprintf(w, "CHUNKS: %s\n", text.Pluralize(int(kb.ChunkCount), "chunk"))
if kb.EmbeddingModelID != "" {
fmt.Fprintf(w, "EMBEDDING: %s\n", kb.EmbeddingModelID)
}
if !kb.UpdatedAt.IsZero() {
// Detail page favors absolute time; FuzzyAgo is reserved for list views.
fmt.Fprintf(w, "UPDATED: %s\n", kb.UpdatedAt.Format("2006-01-02 15:04:05"))
}
return nil
}
+64
View File
@@ -0,0 +1,64 @@
package kb
import (
"context"
"errors"
"strings"
"testing"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
type fakeGetSvc struct {
kb *sdk.KnowledgeBase
err error
}
func (f *fakeGetSvc) GetKnowledgeBase(ctx context.Context, id string) (*sdk.KnowledgeBase, error) {
return f.kb, f.err
}
func TestGet_OK_Human(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeGetSvc{kb: &sdk.KnowledgeBase{
ID: "kb1", Name: "Marketing", KnowledgeCount: 12, ChunkCount: 245,
}}
if err := runGet(context.Background(), &GetOptions{}, svc, "kb1"); err != nil {
t.Fatalf("runGet: %v", err)
}
got := out.String()
for _, want := range []string{"ID:", "kb1", "NAME:", "Marketing", "DOCS:", "12 docs", "CHUNKS:", "245 chunks"} {
if !strings.Contains(got, want) {
t.Errorf("missing %q in:\n%s", want, got)
}
}
}
func TestGet_OK_JSON(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeGetSvc{kb: &sdk.KnowledgeBase{ID: "kb1", Name: "Marketing"}}
if err := runGet(context.Background(), &GetOptions{JSONOut: true}, svc, "kb1"); err != nil {
t.Fatalf("runGet: %v", err)
}
got := out.String()
if !strings.Contains(got, `"ok":true`) {
t.Errorf("expected ok:true in %q", got)
}
if !strings.Contains(got, `"id":"kb1"`) {
t.Errorf("expected id field in %q", got)
}
}
func TestGet_NotFound(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeGetSvc{err: errors.New("HTTP error 404: not found")}
err := runGet(context.Background(), &GetOptions{}, svc, "missing")
if err == nil {
t.Fatal("expected error")
}
if !cmdutil.IsNotFound(err) {
t.Errorf("expected resource.not_found, got %v", err)
}
}
+21
View File
@@ -0,0 +1,21 @@
// Package kb holds `weknora kb` command tree (list / get; create / delete in v0.2).
package kb
import (
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
)
// NewCmd builds the `weknora kb` parent command.
func NewCmd(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "kb",
Short: "Manage knowledge bases",
Args: cobra.NoArgs,
Run: func(c *cobra.Command, _ []string) { _ = c.Help() },
}
cmd.AddCommand(NewCmdList(f))
cmd.AddCommand(NewCmdGet(f))
return cmd
}
+82
View File
@@ -0,0 +1,82 @@
package kb
import (
"context"
"fmt"
"text/tabwriter"
"time"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/agent"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/format"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/text"
sdk "github.com/Tencent/WeKnora/client"
)
// ListOptions captures `weknora kb list` flags.
type ListOptions struct {
JSONOut bool
}
// ListService is the narrow SDK surface this command depends on.
type ListService interface {
ListKnowledgeBases(ctx context.Context) ([]sdk.KnowledgeBase, error)
}
// listResult is the typed payload emitted under data.items.
type listResult struct {
Items []sdk.KnowledgeBase `json:"items"`
}
// NewCmdList builds `weknora kb list`.
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
opts := &ListOptions{}
cmd := &cobra.Command{
Use: "list",
Short: "List knowledge bases visible to the active context",
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, _ []string) error {
cli, err := f.Client()
if err != nil {
return err
}
return runList(c.Context(), opts, cli)
},
}
cmd.Flags().BoolVar(&opts.JSONOut, "json", false, "Output JSON envelope")
agent.SetAgentHelp(cmd, "Lists all knowledge bases. Returns data.items: [{id, name, ...}]; empty array when none.")
return cmd
}
func runList(ctx context.Context, opts *ListOptions, svc ListService) error {
items, err := svc.ListKnowledgeBases(ctx)
if err != nil {
return cmdutil.Wrapf(cmdutil.ClassifyHTTPError(err), err, "list knowledge bases")
}
if items == nil {
items = []sdk.KnowledgeBase{} // ensure JSON [] not null
}
if opts.JSONOut {
return format.WriteEnvelope(iostreams.IO.Out, format.Success(listResult{Items: items}, nil))
}
if len(items) == 0 {
fmt.Fprintln(iostreams.IO.Out, "(no knowledge bases)")
return nil
}
tw := tabwriter.NewWriter(iostreams.IO.Out, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "ID\tNAME\tDOCS\tUPDATED")
now := time.Now()
for _, kb := range items {
name := text.Truncate(40, kb.Name)
docs := text.Pluralize(int(kb.KnowledgeCount), "doc")
updated := text.FuzzyAgo(now, kb.UpdatedAt)
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", kb.ID, name, docs, updated)
}
return tw.Flush()
}
+62
View File
@@ -0,0 +1,62 @@
package kb
import (
"context"
"strings"
"testing"
"time"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
type fakeListSvc struct {
items []sdk.KnowledgeBase
err error
}
func (f *fakeListSvc) ListKnowledgeBases(ctx context.Context) ([]sdk.KnowledgeBase, error) {
return f.items, f.err
}
func TestList_Empty_Human(t *testing.T) {
out, _ := iostreams.SetForTest(t)
if err := runList(context.Background(), &ListOptions{}, &fakeListSvc{items: []sdk.KnowledgeBase{}}); err != nil {
t.Fatalf("runList: %v", err)
}
if !strings.Contains(out.String(), "(no knowledge bases)") {
t.Errorf("empty output expected '(no knowledge bases)', got %q", out.String())
}
}
func TestList_Empty_JSON(t *testing.T) {
out, _ := iostreams.SetForTest(t)
if err := runList(context.Background(), &ListOptions{JSONOut: true}, &fakeListSvc{items: []sdk.KnowledgeBase{}}); err != nil {
t.Fatalf("runList: %v", err)
}
got := out.String()
if !strings.Contains(got, `"items":[]`) {
t.Errorf("empty JSON should contain items:[], got %q", got)
}
if strings.Contains(got, `"items":null`) {
t.Error("items must be [] not null")
}
}
func TestList_NonEmpty_Human_RenderColumns(t *testing.T) {
out, _ := iostreams.SetForTest(t)
now := time.Now()
items := []sdk.KnowledgeBase{
{ID: "kb1", Name: "Marketing", KnowledgeCount: 5, UpdatedAt: now.Add(-3 * time.Hour)},
{ID: "kb2", Name: "Engineering", KnowledgeCount: 1, UpdatedAt: now.Add(-2 * 24 * time.Hour)},
}
if err := runList(context.Background(), &ListOptions{}, &fakeListSvc{items: items}); err != nil {
t.Fatalf("runList: %v", err)
}
got := out.String()
for _, want := range []string{"ID", "NAME", "DOCS", "UPDATED", "kb1", "Marketing", "5 docs", "kb2", "Engineering", "1 doc"} {
if !strings.Contains(got, want) {
t.Errorf("output missing %q in:\n%s", want, got)
}
}
}
+15 -6
View File
@@ -1,7 +1,7 @@
// Package cmd holds the cobra command tree. main.go calls Execute().
//
// Foundation PR registers only the root command and `version`; resource
// commands (auth, kb, doc, ...) land in PR-4 and later.
// v0.0 shipped: version / auth / search.
// v0.1 adds: whoami / doctor / kb (list + get) / context (use).
package cmd
import (
@@ -12,7 +12,11 @@ import (
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/cmd/auth"
contextcmd "github.com/Tencent/WeKnora/cli/cmd/context"
"github.com/Tencent/WeKnora/cli/cmd/doctor"
"github.com/Tencent/WeKnora/cli/cmd/kb"
"github.com/Tencent/WeKnora/cli/cmd/search"
"github.com/Tencent/WeKnora/cli/cmd/whoami"
"github.com/Tencent/WeKnora/cli/internal/agent"
"github.com/Tencent/WeKnora/cli/internal/build"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
@@ -22,7 +26,7 @@ import (
// Execute is the entry point invoked by main(). Returns the process exit code.
func Execute() int {
root := newRootCmd(cmdutil.New())
root := NewRootCmd(cmdutil.New())
// ExecuteC returns the actually-invoked leaf (or root when invocation
// failed before dispatch); we use it to honor the leaf's --json and
// inherited --format without walking the tree ourselves.
@@ -116,9 +120,10 @@ var cobraFlagErrorPrefixes = []string{
"invalid argument", // pflag type-coercion failure (e.g. --top-k=foo)
}
// newRootCmd builds the cobra tree. Splitting it from Execute() lets tests
// drive the tree directly with their own factory.
func newRootCmd(f *cmdutil.Factory) *cobra.Command {
// NewRootCmd builds the cobra tree. Splitting it from Execute() lets tests
// drive the tree directly with their own factory. Exported (PR-7) so the
// acceptance/contract suite can construct the tree in-process.
func NewRootCmd(f *cmdutil.Factory) *cobra.Command {
v, commit, date := build.Info()
cmd := &cobra.Command{
Use: "weknora",
@@ -148,6 +153,10 @@ func newRootCmd(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(newVersionCmd(f))
cmd.AddCommand(auth.NewCmdAuth(f))
cmd.AddCommand(search.NewCmdSearch(f))
cmd.AddCommand(whoami.NewCmd(f))
cmd.AddCommand(doctor.NewCmd(f))
cmd.AddCommand(kb.NewCmd(f))
cmd.AddCommand(contextcmd.NewCmd(f))
return cmd
}
+3 -3
View File
@@ -15,7 +15,7 @@ import (
func TestRoot_Help(t *testing.T) {
var out bytes.Buffer
root := newRootCmd(cmdutil.New())
root := NewRootCmd(cmdutil.New())
root.SetArgs([]string{"--help"})
root.SetOut(&out)
require.NoError(t, root.Execute())
@@ -26,7 +26,7 @@ func TestRoot_Help(t *testing.T) {
func TestVersion_JSON(t *testing.T) {
var out bytes.Buffer
root := newRootCmd(cmdutil.New())
root := NewRootCmd(cmdutil.New())
root.SetArgs([]string{"version", "--json"})
root.SetOut(&out)
require.NoError(t, root.Execute())
@@ -50,7 +50,7 @@ func TestExecute_ExitCodeSurface(t *testing.T) {
// provides them).
func TestMapCobraError_PinnedPrefixes(t *testing.T) {
t.Run("unknown command", func(t *testing.T) {
root := newRootCmd(cmdutil.New())
root := NewRootCmd(cmdutil.New())
root.SetArgs([]string{"bogus"})
root.SetErr(&bytes.Buffer{})
root.SetOut(&bytes.Buffer{})
+77
View File
@@ -0,0 +1,77 @@
// Package whoami implements `weknora whoami` — a top-level standalone leaf,
// the simplified counterpart of `auth status` that prints only user_id and
// tenant_id (spec §1.2). Use `auth status` for full diagnostics.
package whoami
import (
"context"
"fmt"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/agent"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/format"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
// Options captures the (sparse) configuration of `weknora whoami`.
type Options struct {
JSONOut bool
}
// Service is the narrow SDK surface this command depends on.
type Service interface {
GetCurrentUser(ctx context.Context) (*sdk.CurrentUserResponse, error)
}
// result is the typed payload emitted by `--json`.
type result struct {
UserID string `json:"user_id"`
TenantID uint64 `json:"tenant_id,omitempty"`
}
// NewCmd builds the `weknora whoami` command.
func NewCmd(f *cmdutil.Factory) *cobra.Command {
opts := &Options{}
cmd := &cobra.Command{
Use: "whoami",
Short: "Print user_id and tenant_id of the active context",
RunE: func(c *cobra.Command, _ []string) error {
cli, err := f.Client()
if err != nil {
return err
}
return runWhoami(c.Context(), opts, cli)
},
}
cmd.Flags().BoolVar(&opts.JSONOut, "json", false, "Output JSON envelope")
agent.SetAgentHelp(cmd, "Returns the active context principal as JSON envelope. Quick identity check; for full diagnostics use `weknora auth status`.")
return cmd
}
func runWhoami(ctx context.Context, opts *Options, svc Service) error {
if svc == nil {
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated, "no SDK client available; run `weknora auth login`")
}
resp, err := svc.GetCurrentUser(ctx)
if err != nil {
return cmdutil.Wrapf(cmdutil.ClassifyHTTPError(err), err, "fetch current user")
}
if resp == nil || resp.Data.User == nil {
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated, "server returned empty user; run `weknora auth login`")
}
r := result{UserID: resp.Data.User.ID}
if resp.Data.Tenant != nil {
r.TenantID = resp.Data.Tenant.ID
}
if opts.JSONOut {
return cmdutil.NewJSONExporter().Write(iostreams.IO.Out, format.Success(r, nil))
}
fmt.Fprintf(iostreams.IO.Out, "user_id: %s\n", r.UserID)
if r.TenantID != 0 {
fmt.Fprintf(iostreams.IO.Out, "tenant_id: %d\n", r.TenantID)
}
return nil
}
+80
View File
@@ -0,0 +1,80 @@
package whoami
import (
"context"
"errors"
"strings"
"testing"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
type fakeSvc struct {
resp *sdk.CurrentUserResponse
err error
}
func (f *fakeSvc) GetCurrentUser(_ context.Context) (*sdk.CurrentUserResponse, error) {
return f.resp, f.err
}
func newRespOK() *sdk.CurrentUserResponse {
r := &sdk.CurrentUserResponse{}
r.Data.User = &sdk.AuthUser{ID: "usr_abc"}
r.Data.Tenant = &sdk.AuthTenant{ID: 42}
return r
}
func TestWhoami_Human(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeSvc{resp: newRespOK()}
if err := runWhoami(context.Background(), &Options{}, svc); err != nil {
t.Fatalf("runWhoami: %v", err)
}
got := out.String()
if !strings.Contains(got, "user_id:") || !strings.Contains(got, "usr_abc") {
t.Errorf("expected user_id: usr_abc in %q", got)
}
if !strings.Contains(got, "tenant_id:") || !strings.Contains(got, "42") {
t.Errorf("expected tenant_id: 42 in %q", got)
}
}
func TestWhoami_JSON(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeSvc{resp: newRespOK()}
if err := runWhoami(context.Background(), &Options{JSONOut: true}, svc); err != nil {
t.Fatalf("runWhoami: %v", err)
}
got := out.String()
if !strings.Contains(got, `"ok":true`) {
t.Errorf("expected ok:true in %q", got)
}
if !strings.Contains(got, `"user_id":"usr_abc"`) {
t.Errorf("expected user_id field in %q", got)
}
}
func TestWhoami_Unauthenticated(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeSvc{err: errors.New("HTTP error 401: unauthenticated")}
err := runWhoami(context.Background(), &Options{}, svc)
if err == nil {
t.Fatal("expected error")
}
if !cmdutil.IsAuthError(err) {
t.Errorf("expected auth error, got %v", err)
}
}
func TestWhoami_NilUser(t *testing.T) {
// SDK could return success but without user pointer (server bug or partial response)
_, _ = iostreams.SetForTest(t)
svc := &fakeSvc{resp: &sdk.CurrentUserResponse{}} // Data.User == nil
err := runWhoami(context.Background(), &Options{}, svc)
if err == nil {
t.Fatal("expected error for nil user")
}
}
+6 -6
View File
@@ -40,10 +40,11 @@ const (
CodeNetworkError ErrorCode = "network.error"
// local.* — config / file / keychain on the user's machine
CodeLocalConfigCorrupt ErrorCode = "local.config_corrupt"
CodeLocalKeychainDenied ErrorCode = "local.keychain_denied"
CodeLocalFileIO ErrorCode = "local.file_io"
CodeLocalUnimplemented ErrorCode = "local.unimplemented"
CodeLocalConfigCorrupt ErrorCode = "local.config_corrupt"
CodeLocalKeychainDenied ErrorCode = "local.keychain_denied"
CodeLocalFileIO ErrorCode = "local.file_io"
CodeLocalUnimplemented ErrorCode = "local.unimplemented"
CodeLocalContextNotFound ErrorCode = "local.context_not_found"
// mcp.*
CodeMCPReadonlyMode ErrorCode = "mcp.readonly_mode"
@@ -203,10 +204,9 @@ func AllCodes() []ErrorCode {
CodeServerIncompatibleVersion, CodeNetworkError,
// local
CodeLocalConfigCorrupt, CodeLocalKeychainDenied, CodeLocalFileIO,
CodeLocalUnimplemented,
CodeLocalUnimplemented, CodeLocalContextNotFound,
// mcp
CodeMCPReadonlyMode, CodeMCPToolNotAllowed, CodeMCPSchemaUnknown,
// v0.1: context use — added in PR-7 (Task 15) when CodeLocalContextNotFound lands
}
}