From cf84bf2a386a9eae5d95103679255e535fa7704d Mon Sep 17 00:00:00 2001 From: nullkey Date: Fri, 8 May 2026 16:13:58 +0800 Subject: [PATCH] feat(cli): add whoami / doctor / kb / context commands (PR-7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 跟随更新. --- cli/cmd/context/context.go | 24 +++ cli/cmd/context/use.go | 128 ++++++++++++++ cli/cmd/context/use_test.go | 103 +++++++++++ cli/cmd/doctor/doctor.go | 303 +++++++++++++++++++++++++++++++++ cli/cmd/doctor/doctor_test.go | 161 ++++++++++++++++++ cli/cmd/kb/get.go | 72 ++++++++ cli/cmd/kb/get_test.go | 64 +++++++ cli/cmd/kb/kb.go | 21 +++ cli/cmd/kb/list.go | 82 +++++++++ cli/cmd/kb/list_test.go | 62 +++++++ cli/cmd/root.go | 21 ++- cli/cmd/root_test.go | 6 +- cli/cmd/whoami/whoami.go | 77 +++++++++ cli/cmd/whoami/whoami_test.go | 80 +++++++++ cli/internal/cmdutil/errors.go | 12 +- 15 files changed, 1201 insertions(+), 15 deletions(-) create mode 100644 cli/cmd/context/context.go create mode 100644 cli/cmd/context/use.go create mode 100644 cli/cmd/context/use_test.go create mode 100644 cli/cmd/doctor/doctor.go create mode 100644 cli/cmd/doctor/doctor_test.go create mode 100644 cli/cmd/kb/get.go create mode 100644 cli/cmd/kb/get_test.go create mode 100644 cli/cmd/kb/kb.go create mode 100644 cli/cmd/kb/list.go create mode 100644 cli/cmd/kb/list_test.go create mode 100644 cli/cmd/whoami/whoami.go create mode 100644 cli/cmd/whoami/whoami_test.go diff --git a/cli/cmd/context/context.go b/cli/cmd/context/context.go new file mode 100644 index 00000000..f2b23876 --- /dev/null +++ b/cli/cmd/context/context.go @@ -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 +} diff --git a/cli/cmd/context/use.go b/cli/cmd/context/use.go new file mode 100644 index 00000000..b3aa1d90 --- /dev/null +++ b/cli/cmd/context/use.go @@ -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 ` command. +func NewCmdUse(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "use ", + 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] +} diff --git a/cli/cmd/context/use_test.go b/cli/cmd/context/use_test.go new file mode 100644 index 00000000..258766ce --- /dev/null +++ b/cli/cmd/context/use_test.go @@ -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) + } +} diff --git a/cli/cmd/doctor/doctor.go b/cli/cmd/doctor/doctor.go new file mode 100644 index 00000000..cedb8978 --- /dev/null +++ b/cli/cmd/doctor/doctor.go @@ -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 +} diff --git a/cli/cmd/doctor/doctor_test.go b/cli/cmd/doctor/doctor_test.go new file mode 100644 index 00000000..c54ab43e --- /dev/null +++ b/cli/cmd/doctor/doctor_test.go @@ -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) + } +} diff --git a/cli/cmd/kb/get.go b/cli/cmd/kb/get.go new file mode 100644 index 00000000..92224632 --- /dev/null +++ b/cli/cmd/kb/get.go @@ -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 `. +func NewCmdGet(f *cmdutil.Factory) *cobra.Command { + opts := &GetOptions{} + cmd := &cobra.Command{ + Use: "get ", + 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 +} diff --git a/cli/cmd/kb/get_test.go b/cli/cmd/kb/get_test.go new file mode 100644 index 00000000..a657423f --- /dev/null +++ b/cli/cmd/kb/get_test.go @@ -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) + } +} diff --git a/cli/cmd/kb/kb.go b/cli/cmd/kb/kb.go new file mode 100644 index 00000000..5a3c6040 --- /dev/null +++ b/cli/cmd/kb/kb.go @@ -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 +} diff --git a/cli/cmd/kb/list.go b/cli/cmd/kb/list.go new file mode 100644 index 00000000..1469d670 --- /dev/null +++ b/cli/cmd/kb/list.go @@ -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() +} diff --git a/cli/cmd/kb/list_test.go b/cli/cmd/kb/list_test.go new file mode 100644 index 00000000..b61715e4 --- /dev/null +++ b/cli/cmd/kb/list_test.go @@ -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) + } + } +} diff --git a/cli/cmd/root.go b/cli/cmd/root.go index 6b6a867c..c01317a9 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -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 } diff --git a/cli/cmd/root_test.go b/cli/cmd/root_test.go index 5550207a..76e2d37a 100644 --- a/cli/cmd/root_test.go +++ b/cli/cmd/root_test.go @@ -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{}) diff --git a/cli/cmd/whoami/whoami.go b/cli/cmd/whoami/whoami.go new file mode 100644 index 00000000..52b1bd7d --- /dev/null +++ b/cli/cmd/whoami/whoami.go @@ -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 +} diff --git a/cli/cmd/whoami/whoami_test.go b/cli/cmd/whoami/whoami_test.go new file mode 100644 index 00000000..ddb8dd4e --- /dev/null +++ b/cli/cmd/whoami/whoami_test.go @@ -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") + } +} diff --git a/cli/internal/cmdutil/errors.go b/cli/internal/cmdutil/errors.go index 190d0581..d330470e 100644 --- a/cli/internal/cmdutil/errors.go +++ b/cli/internal/cmdutil/errors.go @@ -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 } }