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:
@@ -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
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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{})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user