From 0e081aec5cb694252556ffef8a8fee9642ead03a Mon Sep 17 00:00:00 2001 From: nullkey Date: Mon, 18 May 2026 01:38:16 +0800 Subject: [PATCH] feat(cli): --log-level + kb/agent status & check + cross-cutting refactor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operability surface and the bulk of the jopts→fopts migration: * --log-level error|warn|info|debug + WEKNORA_LOG_LEVEL env, wired to the SDK via client.SetDebugLevel. Invalid --log-level returns FlagError (exit 2). * kb status / kb check verb split (1 HTTP vs 1+N for failed_count aggregation). * agent status / agent check verb split (probes kb_scope_all_reachable via 1+N HTTP). * kb create positional (matches agent create). * Positional id help strings namespaced ( / ). * All auth / context / link / doctor / kb / agent CRUD commands migrated to the FormatOptions API. * root.go Execute(ctx) takes a context so signal-cancellation propagates via cmd.Context() into long-running commands. * Pagination termination uses len(accum) >= total (not page*pageSize) so server-capped page sizes do not truncate aggregations. --- cli/cmd/agent/agent.go | 2 + cli/cmd/agent/check.go | 128 ++++++++++++++++++++++ cli/cmd/agent/check_test.go | 150 ++++++++++++++++++++++++++ cli/cmd/agent/create.go | 21 ++-- cli/cmd/agent/create_test.go | 18 ++-- cli/cmd/agent/delete.go | 19 ++-- cli/cmd/agent/delete_test.go | 12 +-- cli/cmd/agent/edit.go | 13 +-- cli/cmd/agent/edit_test.go | 22 ++-- cli/cmd/agent/list.go | 15 +-- cli/cmd/agent/list_test.go | 27 ++--- cli/cmd/agent/status.go | 111 +++++++++++++++++++ cli/cmd/agent/status_test.go | 104 ++++++++++++++++++ cli/cmd/agent/view.go | 31 +++--- cli/cmd/agent/view_test.go | 19 ++-- cli/cmd/auth/auth_test.go | 8 +- cli/cmd/auth/list.go | 15 +-- cli/cmd/auth/list_test.go | 6 +- cli/cmd/auth/login.go | 33 +++--- cli/cmd/auth/login_test.go | 12 +-- cli/cmd/auth/logout.go | 15 +-- cli/cmd/auth/logout_test.go | 12 +-- cli/cmd/auth/refresh.go | 15 +-- cli/cmd/auth/refresh_test.go | 16 +-- cli/cmd/auth/status.go | 17 +-- cli/cmd/auth/status_test.go | 10 +- cli/cmd/auth/token.go | 21 ++-- cli/cmd/auth/token_test.go | 30 +++--- cli/cmd/context/add.go | 15 +-- cli/cmd/context/add_test.go | 10 +- cli/cmd/context/list.go | 15 +-- cli/cmd/context/list_test.go | 6 +- cli/cmd/context/remove.go | 17 +-- cli/cmd/context/remove_test.go | 10 +- cli/cmd/context/use.go | 17 +-- cli/cmd/context/use_test.go | 10 +- cli/cmd/doctor/doctor.go | 17 +-- cli/cmd/doctor/doctor_test.go | 14 +-- cli/cmd/kb/check.go | 149 +++++++++++++++++++++++++ cli/cmd/kb/check_test.go | 128 ++++++++++++++++++++++ cli/cmd/kb/delete.go | 31 +++--- cli/cmd/kb/delete_test.go | 26 ++--- cli/cmd/kb/edit.go | 17 +-- cli/cmd/kb/edit_test.go | 10 +- cli/cmd/kb/empty.go | 21 ++-- cli/cmd/kb/empty_test.go | 8 +- cli/cmd/kb/kb.go | 2 + cli/cmd/kb/pin.go | 21 ++-- cli/cmd/kb/pin_test.go | 14 +-- cli/cmd/kb/status.go | 119 ++++++++++++++++++++ cli/cmd/kb/status_test.go | 89 +++++++++++++++ cli/cmd/kb/view.go | 19 ++-- cli/cmd/kb/view_test.go | 22 ++-- cli/cmd/link/link.go | 15 +-- cli/cmd/link/link_test.go | 10 +- cli/cmd/link/unlink.go | 17 +-- cli/cmd/link/unlink_test.go | 8 +- cli/cmd/root.go | 41 +++++-- cli/cmd/root_test.go | 2 +- cli/internal/cmdutil/agentconfig.go | 2 +- cli/internal/cmdutil/factory.go | 23 ++++ cli/internal/cmdutil/loglevel.go | 61 +++++++++++ cli/internal/cmdutil/loglevel_test.go | 53 +++++++++ client/log_test.go | 33 ++++++ 64 files changed, 1581 insertions(+), 393 deletions(-) create mode 100644 cli/cmd/agent/check.go create mode 100644 cli/cmd/agent/check_test.go create mode 100644 cli/cmd/agent/status.go create mode 100644 cli/cmd/agent/status_test.go create mode 100644 cli/cmd/kb/check.go create mode 100644 cli/cmd/kb/check_test.go create mode 100644 cli/cmd/kb/status.go create mode 100644 cli/cmd/kb/status_test.go create mode 100644 cli/internal/cmdutil/loglevel.go create mode 100644 cli/internal/cmdutil/loglevel_test.go create mode 100644 client/log_test.go diff --git a/cli/cmd/agent/agent.go b/cli/cmd/agent/agent.go index ce5a39c0..d77e571b 100644 --- a/cli/cmd/agent/agent.go +++ b/cli/cmd/agent/agent.go @@ -34,5 +34,7 @@ or delete agents.`, cmd.AddCommand(NewCmdCreate(f)) cmd.AddCommand(NewCmdEdit(f)) cmd.AddCommand(NewCmdDelete(f)) + cmd.AddCommand(NewCmdStatus(f)) + cmd.AddCommand(NewCmdCheck(f)) return cmd } diff --git a/cli/cmd/agent/check.go b/cli/cmd/agent/check.go new file mode 100644 index 00000000..40083f83 --- /dev/null +++ b/cli/cmd/agent/check.go @@ -0,0 +1,128 @@ +package agentcmd + +import ( + "context" + "fmt" + "io" + + "github.com/spf13/cobra" + + "github.com/Tencent/WeKnora/cli/internal/cmdutil" + "github.com/Tencent/WeKnora/cli/internal/iostreams" + sdk "github.com/Tencent/WeKnora/client" +) + +// AgentCheckResult is the deep-verification response for `agent check `. +// Superset of AgentStatusResult: status fields + KBScopeAllReachable from +// probing each KB in agent.Config.KnowledgeBases. Verb split with +// `agent status`: status reads existing state cheaply, check actively +// verifies dependencies. +type AgentCheckResult struct { + ID string `json:"id"` + Reachable bool `json:"reachable"` + ModelID string `json:"model_id,omitempty"` + KBScopeAllReachable *bool `json:"kb_scope_all_reachable,omitempty"` // pointer so we can distinguish "not applicable" (e.g. agent unreachable) from "false" +} + +// AgentCheckService is the narrow SDK surface needed for agent check. +type AgentCheckService interface { + GetAgent(ctx context.Context, id string) (*sdk.Agent, error) + GetKnowledgeBase(ctx context.Context, id string) (*sdk.KnowledgeBase, error) +} + +var agentCheckFields = []string{"id", "reachable", "model_id", "kb_scope_all_reachable"} + +// NewCmdCheck builds `weknora agent check `. +func NewCmdCheck(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "check ", + Short: "Verify a custom agent end-to-end (status + kb_scope reachability)", + Long: `Active verification of a custom agent. + +Performs 1 + N HTTP calls: + 1 GET /agents/{id} — reachable + model_id + N GET /kb/{kb_id} for each id in agent.config.knowledge_bases + — sets kb_scope_all_reachable = true iff every probe succeeds + +Use 'weknora agent status ' for a fast read-only health snapshot +(1 HTTP call, no KB probing). Use 'agent check' when you need to verify +the agent's downstream dependencies are all reachable.`, + Example: ` weknora agent check ag_abc + weknora agent check ag_abc --format json`, + Args: cobra.ExactArgs(1), + RunE: func(c *cobra.Command, args []string) error { + fopts, err := cmdutil.CheckFormatFlag(c) + if err != nil { + return err + } + fopts.ResolveDefault(iostreams.IO.IsStdoutTTY()) + cli, err := f.Client() + if err != nil { + return err + } + res, err := runAgentCheck(c.Context(), cli, args[0]) + if err != nil { + return err + } + return emitAgentCheck(res, fopts, iostreams.IO.Out) + }, + } + cmdutil.AddFormatFlag(cmd, agentCheckFields...) + return cmd +} + +// runAgentCheck is the testable core. Never errors for "agent not +// reachable" — Reachable=false carries that signal. +func runAgentCheck(ctx context.Context, svc AgentCheckService, id string) (*AgentCheckResult, error) { + a, err := svc.GetAgent(ctx, id) + if err != nil { + return &AgentCheckResult{ID: id, Reachable: false}, nil + } + res := &AgentCheckResult{ID: a.ID, Reachable: true} + if a.Config != nil { + res.ModelID = a.Config.ModelID + } + // Probe each KB in scope. Vacuously true when scope is empty or + // config is nil. + allOK := true + if a.Config != nil { + for _, kbID := range a.Config.KnowledgeBases { + if _, err := svc.GetKnowledgeBase(ctx, kbID); err != nil { + allOK = false + break + } + } + } + res.KBScopeAllReachable = &allOK + return res, nil +} + +// emitAgentCheck renders res. Same dispatch as emitAgentStatus. +func emitAgentCheck(res *AgentCheckResult, fopts *cmdutil.FormatOptions, w io.Writer) error { + switch fopts.Mode { + case cmdutil.FormatJSON, cmdutil.FormatNDJSON: + return fopts.Emit(w, res) + case cmdutil.FormatText, "": + return writeAgentCheckText(w, res) + default: + return fmt.Errorf("unsupported --format %q for agent check", fopts.Mode) + } +} + +func writeAgentCheckText(w io.Writer, res *AgentCheckResult) error { + fmt.Fprintf(w, "ID: %s\n", res.ID) + fmt.Fprintf(w, "Reachable: %v\n", res.Reachable) + if !res.Reachable { + return nil + } + if res.ModelID != "" { + fmt.Fprintf(w, "Model: %s\n", res.ModelID) + } + if res.KBScopeAllReachable != nil { + fmt.Fprintf(w, "KB scope OK: %v\n", *res.KBScopeAllReachable) + } + return nil +} + +// compile-time check: SDK client satisfies AgentCheckService. +var _ AgentCheckService = (*sdk.Client)(nil) diff --git a/cli/cmd/agent/check_test.go b/cli/cmd/agent/check_test.go new file mode 100644 index 00000000..2bf65362 --- /dev/null +++ b/cli/cmd/agent/check_test.go @@ -0,0 +1,150 @@ +package agentcmd + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/Tencent/WeKnora/cli/internal/cmdutil" + sdk "github.com/Tencent/WeKnora/client" +) + +type fakeAgentCheckSvc struct { + agent *sdk.Agent + agentErr error + kbProbeOK map[string]bool // kb_id → reachable + kbErr error // applied for any KB id not in the map +} + +func (f *fakeAgentCheckSvc) GetAgent(_ context.Context, id string) (*sdk.Agent, error) { + if f.agentErr != nil { + return nil, f.agentErr + } + return f.agent, nil +} + +func (f *fakeAgentCheckSvc) GetKnowledgeBase(_ context.Context, id string) (*sdk.KnowledgeBase, error) { + if ok, found := f.kbProbeOK[id]; found { + if !ok { + return nil, fmt.Errorf("kb %s unreachable", id) + } + return &sdk.KnowledgeBase{ID: id}, nil + } + if f.kbErr != nil { + return nil, f.kbErr + } + return &sdk.KnowledgeBase{ID: id}, nil +} + +func TestRunAgentCheck_AllReachable(t *testing.T) { + svc := &fakeAgentCheckSvc{ + agent: &sdk.Agent{ID: "ag_x", Config: &sdk.AgentConfig{ + ModelID: "m_x", + KnowledgeBases: []string{"kb_a", "kb_b"}, + }}, + kbProbeOK: map[string]bool{"kb_a": true, "kb_b": true}, + } + res, err := runAgentCheck(context.Background(), svc, "ag_x") + if err != nil { + t.Fatalf("%v", err) + } + if !res.Reachable { + t.Error("Reachable=false, want true") + } + if res.ModelID != "m_x" { + t.Errorf("ModelID=%q, want m_x", res.ModelID) + } + if res.KBScopeAllReachable == nil || !*res.KBScopeAllReachable { + t.Errorf("KBScopeAllReachable should be true (pointer set), got %v", res.KBScopeAllReachable) + } +} + +func TestRunAgentCheck_OneKBFails(t *testing.T) { + svc := &fakeAgentCheckSvc{ + agent: &sdk.Agent{ID: "ag_x", Config: &sdk.AgentConfig{ + ModelID: "m_x", + KnowledgeBases: []string{"kb_a", "kb_b"}, + }}, + kbProbeOK: map[string]bool{"kb_a": true, "kb_b": false}, + } + res, _ := runAgentCheck(context.Background(), svc, "ag_x") + if res.KBScopeAllReachable == nil || *res.KBScopeAllReachable { + t.Errorf("KBScopeAllReachable should be false; got %v", res.KBScopeAllReachable) + } +} + +func TestRunAgentCheck_Unreachable(t *testing.T) { + svc := &fakeAgentCheckSvc{agentErr: fmt.Errorf("404")} + res, err := runAgentCheck(context.Background(), svc, "ag_x") + if err != nil { + t.Fatalf("%v", err) + } + if res.Reachable { + t.Error("Reachable=true on 404; want false") + } + if res.ID != "ag_x" { + t.Errorf("ID=%q, want ag_x (echoed even on unreachable)", res.ID) + } + // KBScopeAllReachable should be nil when agent is unreachable + if res.KBScopeAllReachable != nil { + t.Errorf("KBScopeAllReachable should be nil when agent unreachable; got %v", res.KBScopeAllReachable) + } +} + +func TestRunAgentCheck_NilConfig(t *testing.T) { + // Defensive: Agent.Config is a pointer; nil should not panic and + // KBScopeAllReachable should be vacuously true (no KBs to probe). + svc := &fakeAgentCheckSvc{agent: &sdk.Agent{ID: "ag_x", Config: nil}} + res, err := runAgentCheck(context.Background(), svc, "ag_x") + if err != nil { + t.Fatalf("%v", err) + } + if !res.Reachable { + t.Error("Reachable=false, want true on nil config") + } + if res.ModelID != "" { + t.Errorf("ModelID=%q, want empty (no config)", res.ModelID) + } + // Vacuously true: no KBs to probe + if res.KBScopeAllReachable == nil || !*res.KBScopeAllReachable { + t.Errorf("KBScopeAllReachable should be vacuously true for nil config; got %v", res.KBScopeAllReachable) + } +} + +func TestEmitAgentCheck_JSON(t *testing.T) { + trueP := true + var buf bytes.Buffer + res := &AgentCheckResult{ID: "ag_x", Reachable: true, ModelID: "m_x", KBScopeAllReachable: &trueP} + fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON} + if err := emitAgentCheck(res, fopts, &buf); err != nil { + t.Fatalf("%v", err) + } + var got AgentCheckResult + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("%v", err) + } + if got.ModelID != "m_x" { + t.Errorf("ModelID=%q, want m_x", got.ModelID) + } + if got.KBScopeAllReachable == nil || !*got.KBScopeAllReachable { + t.Errorf("KBScopeAllReachable should be true in JSON output; got %v", got.KBScopeAllReachable) + } +} + +func TestEmitAgentCheck_TextHuman(t *testing.T) { + trueP := true + var buf bytes.Buffer + res := &AgentCheckResult{ID: "ag_x", Reachable: true, ModelID: "m_x", KBScopeAllReachable: &trueP} + fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatText} + if err := emitAgentCheck(res, fopts, &buf); err != nil { + t.Fatalf("%v", err) + } + for _, want := range []string{"ag_x", "m_x", "true"} { + if !strings.Contains(buf.String(), want) { + t.Errorf("output missing %q:\n%s", want, buf.String()) + } + } +} diff --git a/cli/cmd/agent/create.go b/cli/cmd/agent/create.go index 4f0e43f1..d10127b4 100644 --- a/cli/cmd/agent/create.go +++ b/cli/cmd/agent/create.go @@ -139,15 +139,16 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { return nil }, RunE: func(cmd *cobra.Command, args []string) error { - jopts, err := cmdutil.CheckJSONFlags(cmd) + fopts, err := cmdutil.CheckFormatFlag(cmd) if err != nil { return err } + fopts.ResolveDefault(iostreams.IO.IsStdoutTTY()) cli, err := f.Client() if err != nil { return err } - return runCreate(cmd.Context(), opts, jopts, cli) + return runCreate(cmd.Context(), opts, fopts, cli) }, } @@ -172,11 +173,11 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { cmd.Flags().StringVar(&configFile, "config-file", "", "Full AgentConfig YAML or JSON (use '-' for stdin)") cmd.Flags().BoolVar(&opts.GenerateSkeleton, "generate-skeleton", false, "Emit blank AgentConfig YAML to stdout and exit") - cmdutil.AddJSONFlags(cmd, agentViewFields) + cmdutil.AddFormatFlag(cmd, agentViewFields...) return cmd } -func runCreate(ctx context.Context, opts *CreateOptions, jopts *cmdutil.JSONOptions, svc CreateService) error { +func runCreate(ctx context.Context, opts *CreateOptions, fopts *cmdutil.FormatOptions, svc CreateService) error { if opts.GenerateSkeleton { return cmdutil.GenerateAgentSkeleton(iostreams.IO.Out) } @@ -233,7 +234,7 @@ func runCreate(ctx context.Context, opts *CreateOptions, jopts *cmdutil.JSONOpti if err != nil { return cmdutil.WrapHTTP(err, "update copied agent %s", copied.ID) } - return emitAgent(jopts, updated) + return emitAgent(fopts, updated) } // 4. Plain create path: apply hot-path flag overrides onto base @@ -249,7 +250,7 @@ func runCreate(ctx context.Context, opts *CreateOptions, jopts *cmdutil.JSONOpti if err != nil { return cmdutil.WrapHTTP(err, "create agent") } - return emitAgent(jopts, created) + return emitAgent(fopts, created) } // applyCreateOverrides merges hot-path flag overrides into the base config, @@ -291,11 +292,11 @@ func openConfigFile(path string) (io.Reader, string, error) { } // emitAgent writes the Agent to stdout per the v0.4 wire contract (bare -// SDK shape for --json, human KV otherwise). Shared by create and edit; +// SDK shape for --format json, human KV otherwise). Shared by create and edit; // defined here for proximity to the create flow. -func emitAgent(jopts *cmdutil.JSONOptions, ag *sdk.Agent) error { - if jopts.Enabled() { - return jopts.Emit(iostreams.IO.Out, ag) +func emitAgent(fopts *cmdutil.FormatOptions, ag *sdk.Agent) error { + if fopts.WantsJSON() { + return fopts.Emit(iostreams.IO.Out, ag) } renderAgent(iostreams.IO.Out, ag) return nil diff --git a/cli/cmd/agent/create_test.go b/cli/cmd/agent/create_test.go index a5f72926..17dd6753 100644 --- a/cli/cmd/agent/create_test.go +++ b/cli/cmd/agent/create_test.go @@ -49,7 +49,7 @@ func TestCreate_HappyPath_MinimalRequired(t *testing.T) { _, _ = iostreams.SetForTest(t) svc := &fakeCreateSvc{createResp: &sdk.Agent{ID: "ag_new", Name: "Test"}} opts := &CreateOptions{Name: "Test", Model: "model-x"} - err := runCreate(context.Background(), opts, &cmdutil.JSONOptions{}, svc) + err := runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc) require.NoError(t, err) require.NotNil(t, svc.createReq) assert.Equal(t, "Test", svc.createReq.Name) @@ -88,7 +88,7 @@ func TestCreate_ConfigFile_FlagsOverrideFile(t *testing.T) { ConfigFileBody: bytes.NewBufferString(`{"agent_mode":"smart-reasoning","model_id":"model-y","temperature":0.5}`), ConfigFileKind: "json", } - require.NoError(t, runCreate(context.Background(), opts, &cmdutil.JSONOptions{}, svc)) + require.NoError(t, runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc)) require.NotNil(t, svc.createReq.Config) assert.Equal(t, "smart-reasoning", svc.createReq.Config.AgentMode, "file value preserved when no flag override") assert.Equal(t, "model-x", svc.createReq.Config.ModelID, "flag overrides file") @@ -102,7 +102,7 @@ func TestCreate_From_CopiesThenUpdates(t *testing.T) { updateResp: &sdk.Agent{ID: "ag_clone", Name: "Renamed"}, } opts := &CreateOptions{Name: "Renamed", Model: "model-x", From: "ag_source"} - require.NoError(t, runCreate(context.Background(), opts, &cmdutil.JSONOptions{}, svc)) + require.NoError(t, runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc)) assert.Equal(t, "ag_source", svc.copySrcID) require.True(t, svc.updateCalled, "must Update after Copy when overrides present") assert.Equal(t, "ag_clone", svc.updateID) @@ -115,7 +115,7 @@ func TestCreate_GenerateSkeleton_NoAPICall(t *testing.T) { out, _ := iostreams.SetForTest(t) svc := &fakeCreateSvc{} opts := &CreateOptions{GenerateSkeleton: true} - require.NoError(t, runCreate(context.Background(), opts, &cmdutil.JSONOptions{}, svc)) + require.NoError(t, runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc)) assert.Nil(t, svc.createReq, "must not call CreateAgent") assert.Equal(t, "", svc.copySrcID, "must not call CopyAgent") assert.Contains(t, out.String(), "agent_mode:", "skeleton emitted to stdout") @@ -130,7 +130,7 @@ func TestCreate_RepeatedKB_ImpliesSelectedMode(t *testing.T) { KBs: []string{"kb_a", "kb_b"}, flags: createFlagSet{kbsSet: true}, } - require.NoError(t, runCreate(context.Background(), opts, &cmdutil.JSONOptions{}, svc)) + require.NoError(t, runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc)) assert.Equal(t, []string{"kb_a", "kb_b"}, svc.createReq.Config.KnowledgeBases) assert.Equal(t, "selected", svc.createReq.Config.KBSelectionMode, "passing --kb implies selected mode") } @@ -144,7 +144,7 @@ func TestCreate_SystemPromptFile_ReaderRead(t *testing.T) { SystemPromptReader: strings.NewReader("You are a helpful assistant.\n"), flags: createFlagSet{systemPromptSet: true}, } - require.NoError(t, runCreate(context.Background(), opts, &cmdutil.JSONOptions{}, svc)) + require.NoError(t, runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc)) assert.Equal(t, "You are a helpful assistant.", svc.createReq.Config.SystemPrompt, "TrimSpace removes trailing newline") } @@ -171,7 +171,7 @@ func TestCreate_From_PreservesSourceFieldsNotOverridden(t *testing.T) { Temperature: 0.9, flags: createFlagSet{temperatureSet: true}, } - require.NoError(t, runCreate(context.Background(), opts, &cmdutil.JSONOptions{}, svc)) + require.NoError(t, runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc)) require.NotNil(t, svc.updateReq) require.NotNil(t, svc.updateReq.Config) assert.Equal(t, "Source prompt", svc.updateReq.Config.SystemPrompt, "source SystemPrompt must round-trip") @@ -198,7 +198,7 @@ func TestCreate_From_KBReplacesSourceList(t *testing.T) { KBs: []string{"kb_new"}, flags: createFlagSet{kbsSet: true}, } - require.NoError(t, runCreate(context.Background(), opts, &cmdutil.JSONOptions{}, svc)) + require.NoError(t, runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc)) require.NotNil(t, svc.updateReq.Config) assert.Equal(t, []string{"kb_new"}, svc.updateReq.Config.KnowledgeBases, "--kb replaces source KB list") assert.Equal(t, "selected", svc.updateReq.Config.KBSelectionMode, "--kb on --from implies selected mode") @@ -222,7 +222,7 @@ func TestCreate_CopyAgent_NotFound(t *testing.T) { _, _ = iostreams.SetForTest(t) svc := &fakeCreateSvc{copyErr: errBadHTTP404} opts := &CreateOptions{Name: "X", Model: "model-x", From: "ag_missing"} - err := runCreate(context.Background(), opts, &cmdutil.JSONOptions{}, svc) + err := runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc) require.Error(t, err) assert.Contains(t, err.Error(), "resource.not_found") } diff --git a/cli/cmd/agent/delete.go b/cli/cmd/agent/delete.go index e74e8e68..733ce9a6 100644 --- a/cli/cmd/agent/delete.go +++ b/cli/cmd/agent/delete.go @@ -36,7 +36,7 @@ type deleteResult struct { // reserved for unlink-style local cleanups, not server-side resource removal. const agentDeleteLong = `Permanently delete a custom agent. -Prompts for confirmation by default when stdout is a TTY and --json is +Prompts for confirmation by default when stdout is a TTY and --format json is not set. Pass -y/--yes (the global flag) to skip the prompt (required in agent / CI / piped contexts). @@ -52,7 +52,7 @@ exactly to guard against unintended deletes.` const agentDeleteExample = ` weknora agent delete ag_abc # interactive confirm weknora agent delete ag_abc -y # no prompt - weknora agent delete ag_abc -y --json # bare {id, deleted:true} JSON` + weknora agent delete ag_abc -y --format json # bare {id, deleted:true} JSON` // NewCmdDelete builds `weknora agent delete `. func NewCmdDelete(f *cmdutil.Factory) *cobra.Command { @@ -64,32 +64,33 @@ func NewCmdDelete(f *cmdutil.Factory) *cobra.Command { Example: agentDeleteExample, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - jopts, err := cmdutil.CheckJSONFlags(cmd) + fopts, err := cmdutil.CheckFormatFlag(cmd) if err != nil { return err } + fopts.ResolveDefault(iostreams.IO.IsStdoutTTY()) opts.AgentID = args[0] opts.Yes, _ = cmd.Flags().GetBool("yes") cli, err := f.Client() if err != nil { return err } - return runDelete(cmd.Context(), opts, jopts, cli, f.Prompter()) + return runDelete(cmd.Context(), opts, fopts, cli, f.Prompter()) }, } - cmdutil.AddJSONFlags(cmd, agentDeleteFields) + cmdutil.AddFormatFlag(cmd, agentDeleteFields...) return cmd } -func runDelete(ctx context.Context, opts *DeleteOptions, jopts *cmdutil.JSONOptions, svc DeleteService, p prompt.Prompter) error { - if err := cmdutil.ConfirmDestructive(p, opts.Yes, jopts.Enabled(), "agent", opts.AgentID); err != nil { +func runDelete(ctx context.Context, opts *DeleteOptions, fopts *cmdutil.FormatOptions, svc DeleteService, p prompt.Prompter) error { + if err := cmdutil.ConfirmDestructive(p, opts.Yes, fopts.WantsJSON(), "agent", opts.AgentID); err != nil { return err } if err := svc.DeleteAgent(ctx, opts.AgentID); err != nil { return cmdutil.WrapHTTP(err, "delete agent %s", opts.AgentID) } - if jopts.Enabled() { - return jopts.Emit(iostreams.IO.Out, deleteResult{ID: opts.AgentID, Deleted: true}) + if fopts.WantsJSON() { + return fopts.Emit(iostreams.IO.Out, deleteResult{ID: opts.AgentID, Deleted: true}) } fmt.Fprintf(iostreams.IO.Out, "✓ Deleted agent %s\n", opts.AgentID) return nil diff --git a/cli/cmd/agent/delete_test.go b/cli/cmd/agent/delete_test.go index b85785fe..58a71b46 100644 --- a/cli/cmd/agent/delete_test.go +++ b/cli/cmd/agent/delete_test.go @@ -28,7 +28,7 @@ func TestDelete_NonTTY_NoYes_ExitTen(t *testing.T) { err := runDelete( context.Background(), &DeleteOptions{AgentID: "ag_abc", Yes: false}, - &cmdutil.JSONOptions{}, svc, &testutil.ConfirmPrompter{}, + &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, &testutil.ConfirmPrompter{}, ) require.Error(t, err) var typed *cmdutil.Error @@ -44,7 +44,7 @@ func TestDelete_NonTTY_WithYes_Direct(t *testing.T) { require.NoError(t, runDelete( context.Background(), &DeleteOptions{AgentID: "ag_abc", Yes: true}, - nil, svc, &testutil.ConfirmPrompter{}, + &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{}, )) assert.Equal(t, "ag_abc", svc.gotID) assert.Contains(t, out.String(), "ag_abc") @@ -56,7 +56,7 @@ func TestDelete_404_PropagatesNotFound(t *testing.T) { err := runDelete( context.Background(), &DeleteOptions{AgentID: "ag_missing", Yes: true}, - nil, svc, &testutil.ConfirmPrompter{}, + &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{}, ) require.Error(t, err) var typed *cmdutil.Error @@ -71,7 +71,7 @@ func TestDelete_TTY_ConfirmYes(t *testing.T) { require.NoError(t, runDelete( context.Background(), &DeleteOptions{AgentID: "ag_abc"}, - nil, svc, p, + &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, p, )) assert.True(t, p.Asked) assert.Equal(t, "ag_abc", svc.gotID) @@ -84,7 +84,7 @@ func TestDelete_TTY_ConfirmNo(t *testing.T) { err := runDelete( context.Background(), &DeleteOptions{AgentID: "ag_abc"}, - nil, svc, p, + &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, p, ) require.Error(t, err) var typed *cmdutil.Error @@ -100,7 +100,7 @@ func TestDelete_JSON_BareObject(t *testing.T) { require.NoError(t, runDelete( context.Background(), &DeleteOptions{AgentID: "ag_abc", Yes: true}, - &cmdutil.JSONOptions{}, svc, &testutil.ConfirmPrompter{}, + &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, &testutil.ConfirmPrompter{}, )) assert.Contains(t, out.String(), `"id":"ag_abc"`) assert.Contains(t, out.String(), `"deleted":true`) diff --git a/cli/cmd/agent/edit.go b/cli/cmd/agent/edit.go index 09e3e71d..536d4b23 100644 --- a/cli/cmd/agent/edit.go +++ b/cli/cmd/agent/edit.go @@ -142,15 +142,16 @@ func NewCmdEdit(f *cmdutil.Factory) *cobra.Command { return nil }, RunE: func(cmd *cobra.Command, args []string) error { - jopts, err := cmdutil.CheckJSONFlags(cmd) + fopts, err := cmdutil.CheckFormatFlag(cmd) if err != nil { return err } + fopts.ResolveDefault(iostreams.IO.IsStdoutTTY()) cli, err := f.Client() if err != nil { return err } - return runEdit(cmd.Context(), opts, jopts, cli) + return runEdit(cmd.Context(), opts, fopts, cli) }, } @@ -171,7 +172,7 @@ func NewCmdEdit(f *cmdutil.Factory) *cobra.Command { // Full-replace cmd.Flags().StringVar(&configFile, "config-file", "", "Full AgentConfig YAML or JSON (REPLACES current config baseline; surgical flags then apply on top)") - cmdutil.AddJSONFlags(cmd, agentViewFields) + cmdutil.AddFormatFlag(cmd, agentViewFields...) return cmd } @@ -185,7 +186,7 @@ func editHasAnyFlag(opts *EditOptions) bool { fl.addKBsSet || fl.removeKBsSet || fl.kbSelectionModeSet || fl.configFileSet } -func runEdit(ctx context.Context, opts *EditOptions, jopts *cmdutil.JSONOptions, svc EditService) error { +func runEdit(ctx context.Context, opts *EditOptions, fopts *cmdutil.FormatOptions, svc EditService) error { if !editHasAnyFlag(opts) { return &cmdutil.Error{ Code: cmdutil.CodeInputInvalidArgument, @@ -260,14 +261,14 @@ func runEdit(ctx context.Context, opts *EditOptions, jopts *cmdutil.JSONOptions, if err != nil { return cmdutil.WrapHTTP(err, "edit agent %s", opts.AgentID) } - return emitAgent(jopts, updated) + return emitAgent(fopts, updated) } // computeKBList applies --add-kb / --remove-kb to current with idempotent // semantics. Ids present in both add and remove cancel out and surface a // stderr warning so users notice the conflict but don't see a hard error. // Stderr is the right channel here (not stdout) because callers piping -// --json | jq would otherwise see corrupted JSON. +// --format json | jq would otherwise see corrupted JSON. func computeKBList(current, add, remove []string) []string { // Detect ids in both add and remove; they net out to no-op and are // excluded from both operations. diff --git a/cli/cmd/agent/edit_test.go b/cli/cmd/agent/edit_test.go index a05d29d5..fa1d8e9f 100644 --- a/cli/cmd/agent/edit_test.go +++ b/cli/cmd/agent/edit_test.go @@ -54,7 +54,7 @@ func TestEdit_FetchThenUpdate_PreservesUntouchedFields(t *testing.T) { Description: "Updated", flags: editFlagSet{descriptionSet: true}, } - require.NoError(t, runEdit(context.Background(), opts, &cmdutil.JSONOptions{}, svc)) + require.NoError(t, runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc)) require.NotNil(t, svc.updateReq) assert.Equal(t, "Original", svc.updateReq.Name, "Name must round-trip unchanged") assert.Equal(t, "Updated", svc.updateReq.Description) @@ -76,14 +76,14 @@ func TestEdit_AddRemoveKB_SameID_NetNoOpWithWarning(t *testing.T) { RemoveKBs: []string{"kb_b"}, flags: editFlagSet{addKBsSet: true, removeKBsSet: true}, } - require.NoError(t, runEdit(context.Background(), opts, &cmdutil.JSONOptions{}, svc)) + require.NoError(t, runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc)) assert.Equal(t, []string{"kb_a"}, svc.updateReq.Config.KnowledgeBases, "net no-op preserves original list") assert.Contains(t, errBuf.String(), "cancel out", "warning emitted to stderr") } func TestEdit_NoFlags_InvalidArgument(t *testing.T) { svc := &fakeEditSvc{} - err := runEdit(context.Background(), &EditOptions{AgentID: "ag_abc"}, &cmdutil.JSONOptions{}, svc) + err := runEdit(context.Background(), &EditOptions{AgentID: "ag_abc"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc) require.Error(t, err) var typed *cmdutil.Error require.ErrorAs(t, err, &typed) @@ -102,7 +102,7 @@ func TestEdit_AddKB_AlreadyAttached_Silent(t *testing.T) { AddKBs: []string{"kb_a"}, // already attached flags: editFlagSet{addKBsSet: true}, } - require.NoError(t, runEdit(context.Background(), opts, &cmdutil.JSONOptions{}, svc)) + require.NoError(t, runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc)) assert.Equal(t, []string{"kb_a", "kb_b"}, svc.updateReq.Config.KnowledgeBases, "no duplicate") assert.NotContains(t, errBuf.String(), "warning", "already-attached is silent success") } @@ -118,7 +118,7 @@ func TestEdit_RemoveKB_Unattached_Silent(t *testing.T) { RemoveKBs: []string{"kb_zzz"}, flags: editFlagSet{removeKBsSet: true}, } - require.NoError(t, runEdit(context.Background(), opts, &cmdutil.JSONOptions{}, svc)) + require.NoError(t, runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc)) assert.Equal(t, []string{"kb_a"}, svc.updateReq.Config.KnowledgeBases) assert.NotContains(t, errBuf.String(), "warning") } @@ -134,7 +134,7 @@ func TestEdit_ClearDescription_EmptyString(t *testing.T) { Description: "", flags: editFlagSet{descriptionSet: true}, } - require.NoError(t, runEdit(context.Background(), opts, &cmdutil.JSONOptions{}, svc)) + require.NoError(t, runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc)) assert.Equal(t, "", svc.updateReq.Description, "explicit empty must clear server-side") assert.Equal(t, "X", svc.updateReq.Name, "Name round-trip unchanged") } @@ -155,7 +155,7 @@ func TestEdit_ConfigFile_OverridesByFlag(t *testing.T) { ConfigFileKind: "json", flags: editFlagSet{temperatureSet: true, configFileSet: true}, } - require.NoError(t, runEdit(context.Background(), opts, &cmdutil.JSONOptions{}, svc)) + require.NoError(t, runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc)) require.NotNil(t, svc.updateReq.Config) assert.Equal(t, "file-model", svc.updateReq.Config.ModelID, "file overrides current state") assert.InDelta(t, 0.9, svc.updateReq.Config.Temperature, 0.001, "flag overrides file") @@ -186,7 +186,7 @@ func TestEdit_ConfigFile_FullReplacesBaseline(t *testing.T) { ConfigFileKind: "json", flags: editFlagSet{configFileSet: true}, } - require.NoError(t, runEdit(context.Background(), opts, &cmdutil.JSONOptions{}, svc)) + require.NoError(t, runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc)) require.NotNil(t, svc.updateReq.Config) assert.Equal(t, "file-only", svc.updateReq.Config.ModelID, "file's model_id applied") assert.Equal(t, "", svc.updateReq.Config.SystemPrompt, "file fully replaces baseline; unset fields are zeroed") @@ -199,7 +199,7 @@ func TestEdit_NotFound(t *testing.T) { _, _ = iostreams.SetForTest(t) svc := &fakeEditSvc{getErr: errBadHTTP404} opts := &EditOptions{AgentID: "ag_missing", Name: "x", flags: editFlagSet{nameSet: true}} - err := runEdit(context.Background(), opts, &cmdutil.JSONOptions{}, svc) + err := runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc) require.Error(t, err) assert.Contains(t, err.Error(), "resource.not_found") } @@ -215,7 +215,7 @@ func TestEdit_AddKB_AppendsToExisting(t *testing.T) { AddKBs: []string{"kb_b", "kb_c"}, flags: editFlagSet{addKBsSet: true}, } - require.NoError(t, runEdit(context.Background(), opts, &cmdutil.JSONOptions{}, svc)) + require.NoError(t, runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc)) assert.Equal(t, []string{"kb_a", "kb_b", "kb_c"}, svc.updateReq.Config.KnowledgeBases) } @@ -244,6 +244,6 @@ func TestEdit_SystemPromptFile(t *testing.T) { SystemPromptReader: strings.NewReader("new prompt\n"), flags: editFlagSet{systemPromptSet: true}, } - require.NoError(t, runEdit(context.Background(), opts, &cmdutil.JSONOptions{}, svc)) + require.NoError(t, runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc)) assert.Equal(t, "new prompt", svc.updateReq.Config.SystemPrompt) } diff --git a/cli/cmd/agent/list.go b/cli/cmd/agent/list.go index 5bbb609f..8405f799 100644 --- a/cli/cmd/agent/list.go +++ b/cli/cmd/agent/list.go @@ -15,7 +15,7 @@ import ( sdk "github.com/Tencent/WeKnora/client" ) -// agentListFields enumerates the fields surfaced for `--json` discovery +// agentListFields enumerates the fields surfaced for `--format json` discovery // on `agent list`. Mirrors the json tags on sdk.Agent - nested Config is // omitted because its sub-fields make filtering noisy (use `--jq` instead). var agentListFields = []string{ @@ -45,23 +45,24 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { Short: "List custom agents visible to the active tenant", Args: cobra.NoArgs, RunE: func(c *cobra.Command, _ []string) error { - jopts, err := cmdutil.CheckJSONFlags(c) + fopts, err := cmdutil.CheckFormatFlag(c) if err != nil { return err } + fopts.ResolveDefault(iostreams.IO.IsStdoutTTY()) cli, err := f.Client() if err != nil { return err } - return runList(c.Context(), opts, jopts, cli) + return runList(c.Context(), opts, fopts, cli) }, } cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum results to return (0 = no cap, 1..10000 = explicit)") - cmdutil.AddJSONFlags(cmd, agentListFields) + cmdutil.AddFormatFlag(cmd, agentListFields...) return cmd } -func runList(ctx context.Context, opts *ListOptions, jopts *cmdutil.JSONOptions, svc ListService) error { +func runList(ctx context.Context, opts *ListOptions, fopts *cmdutil.FormatOptions, svc ListService) error { if opts == nil { opts = &ListOptions{} } @@ -88,8 +89,8 @@ func runList(ctx context.Context, opts *ListOptions, jopts *cmdutil.JSONOptions, items = items[:opts.Limit] } - if jopts.Enabled() { - return jopts.Emit(iostreams.IO.Out, items) + if fopts.WantsJSON() { + return fopts.Emit(iostreams.IO.Out, items) } if len(items) == 0 { diff --git a/cli/cmd/agent/list_test.go b/cli/cmd/agent/list_test.go index ee013a89..0cc2f83d 100644 --- a/cli/cmd/agent/list_test.go +++ b/cli/cmd/agent/list_test.go @@ -25,7 +25,7 @@ func (f *fakeListSvc) ListAgents(_ context.Context) ([]sdk.Agent, error) { func TestList_Empty_Human(t *testing.T) { out, _ := iostreams.SetForTest(t) - if err := runList(context.Background(), &ListOptions{}, nil, &fakeListSvc{}); err != nil { + if err := runList(context.Background(), &ListOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &fakeListSvc{}); err != nil { t.Fatalf("runList: %v", err) } if !strings.Contains(out.String(), "(no agents)") { @@ -35,7 +35,7 @@ func TestList_Empty_Human(t *testing.T) { func TestList_Empty_JSON(t *testing.T) { out, _ := iostreams.SetForTest(t) - if err := runList(context.Background(), &ListOptions{}, &cmdutil.JSONOptions{}, &fakeListSvc{}); err != nil { + if err := runList(context.Background(), &ListOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, &fakeListSvc{}); err != nil { t.Fatalf("runList: %v", err) } if got := strings.TrimSpace(out.String()); got != "[]" { @@ -50,7 +50,7 @@ func TestList_NonEmpty_Human_RendersColumns(t *testing.T) { {ID: "ag_a", Name: "Research", IsBuiltin: true, UpdatedAt: now.Add(-1 * time.Hour)}, {ID: "ag_b", Name: "Triage", UpdatedAt: now.Add(-3 * 24 * time.Hour)}, } - if err := runList(context.Background(), &ListOptions{}, nil, &fakeListSvc{items: items}); err != nil { + if err := runList(context.Background(), &ListOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &fakeListSvc{items: items}); err != nil { t.Fatalf("runList: %v", err) } got := out.String() @@ -69,7 +69,7 @@ func TestList_NonEmpty_JSON_SortsByUpdatedAtDesc(t *testing.T) { {ID: "ag_new", Name: "new", UpdatedAt: now}, {ID: "ag_mid", Name: "mid", UpdatedAt: now.Add(-1 * time.Hour)}, } - if err := runList(context.Background(), &ListOptions{}, &cmdutil.JSONOptions{}, &fakeListSvc{items: items}); err != nil { + if err := runList(context.Background(), &ListOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, &fakeListSvc{items: items}); err != nil { t.Fatalf("runList: %v", err) } var got []sdk.Agent @@ -87,21 +87,22 @@ func TestList_NonEmpty_JSON_SortsByUpdatedAtDesc(t *testing.T) { } } -func TestList_JSON_FieldFilter(t *testing.T) { +func TestList_JSON_JQProjection(t *testing.T) { out, _ := iostreams.SetForTest(t) items := []sdk.Agent{ {ID: "ag_x", Name: "Foo", Description: "long description"}, } - jopts := &cmdutil.JSONOptions{Fields: []string{"id", "name"}} - if err := runList(context.Background(), &ListOptions{}, jopts, &fakeListSvc{items: items}); err != nil { + // --jq is the canonical projection mechanism in v0.6+. + fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON, JQ: ".[] | {id, name}"} + if err := runList(context.Background(), &ListOptions{}, fopts, &fakeListSvc{items: items}); err != nil { t.Fatalf("runList: %v", err) } - var got []map[string]any + var got map[string]any if err := json.Unmarshal(out.Bytes(), &got); err != nil { t.Fatalf("parse: %v", err) } - if _, has := got[0]["description"]; has { - t.Errorf("description should be filtered out: %+v", got[0]) + if _, has := got["description"]; has { + t.Errorf("description should be filtered out: %+v", got) } } @@ -121,7 +122,7 @@ func makeAgents(n int) []sdk.Agent { func TestList_Limit_CapsResults(t *testing.T) { out, _ := iostreams.SetForTest(t) - if err := runList(context.Background(), &ListOptions{Limit: 5}, &cmdutil.JSONOptions{}, &fakeListSvc{items: makeAgents(20)}); err != nil { + if err := runList(context.Background(), &ListOptions{Limit: 5}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, &fakeListSvc{items: makeAgents(20)}); err != nil { t.Fatalf("runList: %v", err) } got := strings.Count(out.String(), `"id":"ag_`) @@ -132,7 +133,7 @@ func TestList_Limit_CapsResults(t *testing.T) { func TestList_Limit_Zero_NoCap(t *testing.T) { out, _ := iostreams.SetForTest(t) - if err := runList(context.Background(), &ListOptions{Limit: 0}, &cmdutil.JSONOptions{}, &fakeListSvc{items: makeAgents(7)}); err != nil { + if err := runList(context.Background(), &ListOptions{Limit: 0}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, &fakeListSvc{items: makeAgents(7)}); err != nil { t.Fatalf("runList: %v", err) } got := strings.Count(out.String(), `"id":"ag_`) @@ -143,7 +144,7 @@ func TestList_Limit_Zero_NoCap(t *testing.T) { func TestList_Limit_Negative_Rejected(t *testing.T) { _, _ = iostreams.SetForTest(t) - err := runList(context.Background(), &ListOptions{Limit: -1}, nil, &fakeListSvc{items: makeAgents(2)}) + err := runList(context.Background(), &ListOptions{Limit: -1}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &fakeListSvc{items: makeAgents(2)}) if err == nil { t.Fatal("expected error for negative --limit") } diff --git a/cli/cmd/agent/status.go b/cli/cmd/agent/status.go new file mode 100644 index 00000000..16916cd9 --- /dev/null +++ b/cli/cmd/agent/status.go @@ -0,0 +1,111 @@ +package agentcmd + +import ( + "context" + "fmt" + "io" + + "github.com/spf13/cobra" + + "github.com/Tencent/WeKnora/cli/internal/cmdutil" + "github.com/Tencent/WeKnora/cli/internal/iostreams" + sdk "github.com/Tencent/WeKnora/client" +) + +// AgentStatusResult is the shallow health snapshot for `agent status `. +// +// One HTTP call: reachable + model_id. For downstream KB reachability +// verification use 'agent check ' (1 + N HTTP). +// +// model_reachable is intentionally OMITTED: the server has no per-agent +// test endpoint, so we cannot verify model reachability from the agent +// resource alone. +type AgentStatusResult struct { + ID string `json:"id"` + Reachable bool `json:"reachable"` + ModelID string `json:"model_id,omitempty"` +} + +// AgentStatusService is the narrow SDK surface needed for agent status. +type AgentStatusService interface { + GetAgent(ctx context.Context, id string) (*sdk.Agent, error) +} + +var agentStatusFields = []string{"id", "reachable", "model_id"} + +func NewCmdStatus(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "status ", + Short: "Show health status of a custom agent", + Long: `Shallow health snapshot for an agent (1 HTTP call). + +Returns: reachable / model_id. + +For downstream KB reachability verification use 'weknora agent check ' +(active verification, 1 + N HTTP). For full agent config / metadata use +'weknora agent view '.`, + Example: ` weknora agent status ag_abc + weknora agent status ag_abc --format json`, + Args: cobra.ExactArgs(1), + RunE: func(c *cobra.Command, args []string) error { + fopts, err := cmdutil.CheckFormatFlag(c) + if err != nil { + return err + } + fopts.ResolveDefault(iostreams.IO.IsStdoutTTY()) + cli, err := f.Client() + if err != nil { + return err + } + res, err := runAgentStatus(c.Context(), cli, args[0]) + if err != nil { + return err + } + return emitAgentStatus(res, fopts, iostreams.IO.Out) + }, + } + cmdutil.AddFormatFlag(cmd, agentStatusFields...) + return cmd +} + +// runAgentStatus is the testable core. Never errors for "agent not +// reachable" — Reachable=false carries that signal. +func runAgentStatus(ctx context.Context, svc AgentStatusService, id string) (*AgentStatusResult, error) { + a, err := svc.GetAgent(ctx, id) + if err != nil { + return &AgentStatusResult{ID: id, Reachable: false}, nil + } + res := &AgentStatusResult{ID: a.ID, Reachable: true} + if a.Config != nil { + res.ModelID = a.Config.ModelID + } + return res, nil +} + +// emitAgentStatus renders res per --format. Mirrors emitStatus pattern from +// cli/cmd/kb/status.go (C3). +func emitAgentStatus(res *AgentStatusResult, fopts *cmdutil.FormatOptions, w io.Writer) error { + switch fopts.Mode { + case cmdutil.FormatJSON, cmdutil.FormatNDJSON: + return fopts.Emit(w, res) + case cmdutil.FormatText, "": + return writeAgentStatusText(w, res) + default: + return fmt.Errorf("unsupported --format %q for agent status", fopts.Mode) + } +} + +func writeAgentStatusText(w io.Writer, res *AgentStatusResult) error { + fmt.Fprintf(w, "ID: %s\n", res.ID) + fmt.Fprintf(w, "Reachable: %v\n", res.Reachable) + if !res.Reachable { + return nil + } + if res.ModelID != "" { + fmt.Fprintf(w, "Model: %s\n", res.ModelID) + } + return nil +} + +// compile-time check: SDK client satisfies AgentStatusService. +var _ AgentStatusService = (*sdk.Client)(nil) diff --git a/cli/cmd/agent/status_test.go b/cli/cmd/agent/status_test.go new file mode 100644 index 00000000..ab90ba2a --- /dev/null +++ b/cli/cmd/agent/status_test.go @@ -0,0 +1,104 @@ +package agentcmd + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/Tencent/WeKnora/cli/internal/cmdutil" + sdk "github.com/Tencent/WeKnora/client" +) + +type fakeAgentStatusSvc struct { + agent *sdk.Agent + agentErr error +} + +func (f *fakeAgentStatusSvc) GetAgent(_ context.Context, id string) (*sdk.Agent, error) { + if f.agentErr != nil { + return nil, f.agentErr + } + return f.agent, nil +} + +func TestRunAgentStatus_ShallowFields(t *testing.T) { + svc := &fakeAgentStatusSvc{agent: &sdk.Agent{ + ID: "ag_x", + Config: &sdk.AgentConfig{ + ModelID: "m_x", + KnowledgeBases: []string{"kb_a", "kb_b"}, + }, + }} + res, err := runAgentStatus(context.Background(), svc, "ag_x") + if err != nil { + t.Fatalf("%v", err) + } + if !res.Reachable { + t.Error("Reachable=false, want true") + } + if res.ModelID != "m_x" { + t.Errorf("ModelID=%q, want m_x", res.ModelID) + } +} + +func TestRunAgentStatus_Unreachable(t *testing.T) { + svc := &fakeAgentStatusSvc{agentErr: fmt.Errorf("404")} + res, err := runAgentStatus(context.Background(), svc, "ag_x") + if err != nil { + t.Fatalf("%v", err) + } + if res.Reachable { + t.Error("Reachable=true on 404; want false") + } + if res.ID != "ag_x" { + t.Errorf("ID=%q, want ag_x (echoed even on unreachable)", res.ID) + } +} + +func TestRunAgentStatus_NilConfig(t *testing.T) { + // Defensive: Agent.Config is a pointer; nil should not panic + svc := &fakeAgentStatusSvc{agent: &sdk.Agent{ID: "ag_x", Config: nil}} + res, err := runAgentStatus(context.Background(), svc, "ag_x") + if err != nil { + t.Fatalf("%v", err) + } + if !res.Reachable { + t.Error("Reachable=false, want true on nil config") + } + if res.ModelID != "" { + t.Errorf("ModelID=%q, want empty (no config)", res.ModelID) + } +} + +func TestEmitAgentStatus_JSON(t *testing.T) { + var buf bytes.Buffer + res := &AgentStatusResult{ID: "ag_x", Reachable: true, ModelID: "m_x"} + fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON} + if err := emitAgentStatus(res, fopts, &buf); err != nil { + t.Fatalf("%v", err) + } + var got AgentStatusResult + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("%v", err) + } + if got.ModelID != "m_x" { + t.Errorf("ModelID=%q, want m_x", got.ModelID) + } +} + +func TestEmitAgentStatus_TextHuman(t *testing.T) { + var buf bytes.Buffer + res := &AgentStatusResult{ID: "ag_x", Reachable: true, ModelID: "m_x"} + fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatText} + if err := emitAgentStatus(res, fopts, &buf); err != nil { + t.Fatalf("%v", err) + } + for _, want := range []string{"ag_x", "m_x", "true"} { + if !strings.Contains(buf.String(), want) { + t.Errorf("output missing %q:\n%s", want, buf.String()) + } + } +} diff --git a/cli/cmd/agent/view.go b/cli/cmd/agent/view.go index 037c44da..ad2ecf02 100644 --- a/cli/cmd/agent/view.go +++ b/cli/cmd/agent/view.go @@ -19,13 +19,10 @@ import ( // full multi-line treatment instead. const promptPreviewWidth = 80 -// agentViewFields enumerates fields surfaced for `--json=` field discovery -// on `agent view`. Only top-level Agent keys are listed because the -// `--json=foo,bar` field-projection filter matches flat top-level keys -// (see internal/format/filter.go). Nested AgentConfig fields are reachable +// agentViewFields enumerates the top-level Agent keys surfaced in `--help` +// as a hint for `--jq` projection. Nested AgentConfig fields are reachable // via `--jq '.config.system_prompt'` or by selecting `config` whole and -// post-processing — listing them here would misleadingly advertise a -// projection path that does not actually filter to them. +// post-processing. var agentViewFields = []string{ "id", "name", "description", "avatar", "is_builtin", "tenant_id", "created_by", "config", @@ -47,36 +44,36 @@ sections (Identity / LLM / KB attachment / Retrieval / Query rewrite / Tools / FAQ / Web search / Multi-turn / Fallback / Templates). Zero-value fields are omitted; sections with no set fields are suppressed entirely. -Pass --json for the bare SDK Agent object (config nested, not flattened). -Field projection (--json=id,name) works on top-level keys only; reach -nested config fields via --jq.`, +Pass --format json for the bare SDK Agent object (config nested, not flattened). +Use --jq to project specific fields or reach into nested config.`, Example: ` weknora agent view ag_abc - weknora agent view ag_abc --json=id,name,config # top-level projection - weknora agent view ag_abc --json --jq '.config.system_prompt'`, + weknora agent view ag_abc --format json --jq '{id, name, config}' # top-level projection + weknora agent view ag_abc --format json --jq '.config.system_prompt'`, Args: cobra.ExactArgs(1), RunE: func(c *cobra.Command, args []string) error { - jopts, err := cmdutil.CheckJSONFlags(c) + fopts, err := cmdutil.CheckFormatFlag(c) if err != nil { return err } + fopts.ResolveDefault(iostreams.IO.IsStdoutTTY()) cli, err := f.Client() if err != nil { return err } - return runView(c.Context(), jopts, cli, args[0]) + return runView(c.Context(), fopts, cli, args[0]) }, } - cmdutil.AddJSONFlags(cmd, agentViewFields) + cmdutil.AddFormatFlag(cmd, agentViewFields...) return cmd } -func runView(ctx context.Context, jopts *cmdutil.JSONOptions, svc ViewService, agentID string) error { +func runView(ctx context.Context, fopts *cmdutil.FormatOptions, svc ViewService, agentID string) error { a, err := svc.GetAgent(ctx, agentID) if err != nil { return cmdutil.WrapHTTP(err, "fetch agent %s", agentID) } - if jopts.Enabled() { - return jopts.Emit(iostreams.IO.Out, a) + if fopts.WantsJSON() { + return fopts.Emit(iostreams.IO.Out, a) } renderAgent(iostreams.IO.Out, a) return nil diff --git a/cli/cmd/agent/view_test.go b/cli/cmd/agent/view_test.go index e251baf7..651be690 100644 --- a/cli/cmd/agent/view_test.go +++ b/cli/cmd/agent/view_test.go @@ -41,7 +41,7 @@ func TestView_Human_RendersMetadataAndConfig(t *testing.T) { WebSearchEnabled: true, }, }} - if err := runView(context.Background(), nil, svc, "ag_abc"); err != nil { + if err := runView(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "ag_abc"); err != nil { t.Fatalf("runView: %v", err) } got := out.String() @@ -52,12 +52,9 @@ func TestView_Human_RendersMetadataAndConfig(t *testing.T) { } } -// TestAgentViewFields_TopLevelOnly pins the contract that `--json=` field -// discovery on `agent view` only lists top-level Agent keys (including -// `config` as a whole). Nested AgentConfig fields are NOT in the list -// because the filter at internal/format/filter.go matches flat top-level -// keys only — listing `config.system_prompt` etc. would advertise a -// projection path that silently returns nothing. +// TestAgentViewFields_TopLevelOnly pins the contract that the field-hint +// list on `agent view` only lists top-level Agent keys (including `config` +// as a whole). Nested AgentConfig fields are reachable via --jq. func TestAgentViewFields_TopLevelOnly(t *testing.T) { for _, want := range []string{"id", "name", "config", "created_at"} { if !slices.Contains(agentViewFields, want) { @@ -66,7 +63,7 @@ func TestAgentViewFields_TopLevelOnly(t *testing.T) { } for _, dotted := range []string{"config.system_prompt", "config.model_id", "config.fallback_strategy"} { if slices.Contains(agentViewFields, dotted) { - t.Errorf("agentViewFields must not list dotted nested key %q (filter does not support nested projection)", dotted) + t.Errorf("agentViewFields must not list dotted nested key %q (the --jq projection path handles nesting directly)", dotted) } } } @@ -129,7 +126,7 @@ func TestView_Human_OmitsEmptyFields(t *testing.T) { CreatedAt: time.Now(), UpdatedAt: time.Now(), }} - if err := runView(context.Background(), nil, svc, "ag_min"); err != nil { + if err := runView(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "ag_min"); err != nil { t.Fatalf("runView: %v", err) } got := out.String() @@ -150,7 +147,7 @@ func TestView_Human_OmitsEmptyFields(t *testing.T) { func TestView_JSON_BareObject(t *testing.T) { out, _ := iostreams.SetForTest(t) svc := &fakeViewSvc{resp: &sdk.Agent{ID: "ag_json", Name: "JSONy"}} - if err := runView(context.Background(), &cmdutil.JSONOptions{}, svc, "ag_json"); err != nil { + if err := runView(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "ag_json"); err != nil { t.Fatalf("runView: %v", err) } var got sdk.Agent @@ -168,7 +165,7 @@ func TestView_JSON_BareObject(t *testing.T) { func TestView_404_MapsToResourceNotFound(t *testing.T) { _, _ = iostreams.SetForTest(t) svc := &fakeViewSvc{err: errors.New("HTTP error 404: agent not found")} - err := runView(context.Background(), nil, svc, "ag_missing") + err := runView(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "ag_missing") if err == nil { t.Fatal("expected error, got nil") } diff --git a/cli/cmd/auth/auth_test.go b/cli/cmd/auth/auth_test.go index 36705151..c483a775 100644 --- a/cli/cmd/auth/auth_test.go +++ b/cli/cmd/auth/auth_test.go @@ -31,7 +31,7 @@ func TestNewCmdAuth_TreeShape(t *testing.T) { func TestNewCmdLogin_FlagsRegistered(t *testing.T) { cmd := NewCmdLogin(&cmdutil.Factory{}, nil) - for _, name := range []string{"host", "name", "with-token", "json"} { + for _, name := range []string{"host", "name", "with-token", "format"} { assert.NotNilf(t, cmd.Flags().Lookup(name), "flag %s missing", name) } // `--context` should NOT be a local flag (it's the global persistent flag). @@ -46,7 +46,7 @@ func TestNewCmdLogin_InvokesRunF(t *testing.T) { f := &cmdutil.Factory{ Secrets: func() (secrets.Store, error) { return store, nil }, } - cmd := NewCmdLogin(f, func(_ context.Context, opts *LoginOptions, _ *cmdutil.JSONOptions, _ *cmdutil.Factory, _ LoginService) error { + cmd := NewCmdLogin(f, func(_ context.Context, opts *LoginOptions, _ *cmdutil.FormatOptions, _ *cmdutil.Factory, _ LoginService) error { called = true assert.Equal(t, "https://kb.example.com", opts.Host) assert.True(t, opts.WithToken) @@ -76,7 +76,7 @@ func TestPersistAPIKey_WritesContext(t *testing.T) { Context: "ci", APIKey: "sk-zzz", } - require.NoError(t, persistAPIKey(opts, &cmdutil.JSONOptions{}, f, nil)) + require.NoError(t, persistAPIKey(opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, nil)) v, _ := store.Get("ci", "api_key") assert.Equal(t, "sk-zzz", v) cfg, _ := f.Config() @@ -104,7 +104,7 @@ func TestPersistJWT_StoresBothTokens(t *testing.T) { RefreshToken: "jwt-ref", User: &sdk.AuthUser{Email: "a@b.c", TenantID: 7}, } - require.NoError(t, persistJWT(opts, &cmdutil.JSONOptions{}, f, resp)) + require.NoError(t, persistJWT(opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, f, resp)) a, _ := store.Get("p", "access") r, _ := store.Get("p", "refresh") assert.Equal(t, "jwt-acc", a) diff --git a/cli/cmd/auth/list.go b/cli/cmd/auth/list.go index 81aa709e..6fc5f385 100644 --- a/cli/cmd/auth/list.go +++ b/cli/cmd/auth/list.go @@ -14,7 +14,7 @@ import ( type ListOptions struct{} -// authListFields enumerates the fields surfaced for `--json` discovery on +// authListFields enumerates the fields surfaced for `--format json` discovery on // `auth list`. Each entry is a per-context summary row. var authListFields = []string{ "name", "host", "user", "mode", "current", @@ -38,18 +38,19 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { Long: `Show every configured context (name, host, user, mode, current). Read-only; no network or keyring access.`, Args: cobra.NoArgs, RunE: func(c *cobra.Command, _ []string) error { - jopts, err := cmdutil.CheckJSONFlags(c) + fopts, err := cmdutil.CheckFormatFlag(c) if err != nil { return err } - return runList(jopts, f) + fopts.ResolveDefault(iostreams.IO.IsStdoutTTY()) + return runList(fopts, f) }, } - cmdutil.AddJSONFlags(cmd, authListFields) + cmdutil.AddFormatFlag(cmd, authListFields...) return cmd } -func runList(jopts *cmdutil.JSONOptions, f *cmdutil.Factory) error { +func runList(fopts *cmdutil.FormatOptions, f *cmdutil.Factory) error { cfg, err := f.Config() if err != nil { return err @@ -66,8 +67,8 @@ func runList(jopts *cmdutil.JSONOptions, f *cmdutil.Factory) error { } sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name }) - if jopts.Enabled() { - return jopts.Emit(iostreams.IO.Out, entries) + if fopts.WantsJSON() { + return fopts.Emit(iostreams.IO.Out, entries) } if len(entries) == 0 { fmt.Fprintln(iostreams.IO.Out, "No contexts configured. Run `weknora auth login` to create one.") diff --git a/cli/cmd/auth/list_test.go b/cli/cmd/auth/list_test.go index 0763aefc..2cab27f6 100644 --- a/cli/cmd/auth/list_test.go +++ b/cli/cmd/auth/list_test.go @@ -28,7 +28,7 @@ func TestList_HumanRender(t *testing.T) { "staging": {Host: "https://staging", APIKeyRef: "keychain://staging/api_key"}, }, } - require.NoError(t, runList(nil, newListFactory(cfg))) + require.NoError(t, runList(&cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newListFactory(cfg))) got := out.String() // One row per context, current marked with `*`. @@ -44,7 +44,7 @@ func TestList_HumanRender(t *testing.T) { func TestList_Empty(t *testing.T) { out, _ := iostreams.SetForTest(t) - require.NoError(t, runList(nil, newListFactory(&config.Config{}))) + require.NoError(t, runList(&cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newListFactory(&config.Config{}))) assert.Contains(t, out.String(), "No contexts configured") } @@ -57,7 +57,7 @@ func TestList_JSON_BareArray(t *testing.T) { "staging": {Host: "https://staging", APIKeyRef: "key"}, }, } - require.NoError(t, runList(&cmdutil.JSONOptions{}, newListFactory(cfg))) + require.NoError(t, runList(&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, newListFactory(cfg))) var got []listEntry require.NoError(t, json.Unmarshal(out.Bytes(), &got)) diff --git a/cli/cmd/auth/login.go b/cli/cmd/auth/login.go index 336892fe..98e6e2f5 100644 --- a/cli/cmd/auth/login.go +++ b/cli/cmd/auth/login.go @@ -15,7 +15,7 @@ import ( sdk "github.com/Tencent/WeKnora/client" ) -// authLoginFields enumerates the fields surfaced for `--json` discovery on +// authLoginFields enumerates the fields surfaced for `--format json` discovery on // `auth login`. The post-login summary has no token values - they stay in the // keyring; agents who need to verify the credential should re-run // `auth status`. @@ -64,7 +64,7 @@ var defaultAPIKeyValidator apiKeyValidator = func(ctx context.Context, host, api // NewCmdLogin builds the `weknora auth login` command. runF is the testable // entrypoint (left nil for production; see cli/cmd/auth/login_test.go). -func NewCmdLogin(f *cmdutil.Factory, runF func(context.Context, *LoginOptions, *cmdutil.JSONOptions, *cmdutil.Factory, LoginService) error) *cobra.Command { +func NewCmdLogin(f *cmdutil.Factory, runF func(context.Context, *LoginOptions, *cmdutil.FormatOptions, *cmdutil.Factory, LoginService) error) *cobra.Command { opts := &LoginOptions{} cmd := &cobra.Command{ Use: "login", @@ -76,10 +76,11 @@ Credentials are persisted to the OS keyring when available; otherwise to a the current_context in ~/.config/weknora/config.yaml.`, Args: cobra.NoArgs, RunE: func(c *cobra.Command, args []string) error { - jopts, err := cmdutil.CheckJSONFlags(c) + fopts, err := cmdutil.CheckFormatFlag(c) if err != nil { return err } + fopts.ResolveDefault(iostreams.IO.IsStdoutTTY()) run := runF if run == nil { run = runLogin @@ -88,13 +89,13 @@ the current_context in ~/.config/weknora/config.yaml.`, if opts.StdinReader == nil { opts.StdinReader = iostreams.IO.In } - return run(c.Context(), opts, jopts, f, svc) + return run(c.Context(), opts, fopts, f, svc) }, } cmd.Flags().StringVar(&opts.Host, "host", "", "WeKnora server URL, e.g. https://kb.example.com") cmd.Flags().StringVar(&opts.Context, "name", "default", "Context name to register in config.yaml") cmd.Flags().BoolVar(&opts.WithToken, "with-token", false, "Read an API key from stdin instead of prompting for password") - cmdutil.AddJSONFlags(cmd, authLoginFields) + cmdutil.AddFormatFlag(cmd, authLoginFields...) _ = cmd.MarkFlagRequired("host") return cmd } @@ -108,7 +109,7 @@ func loginServiceFor(host string) LoginService { return sdk.NewClient(host) } -func runLogin(ctx context.Context, opts *LoginOptions, jopts *cmdutil.JSONOptions, f *cmdutil.Factory, svc LoginService) error { +func runLogin(ctx context.Context, opts *LoginOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Factory, svc LoginService) error { if err := validateHost(opts.Host); err != nil { return err } @@ -129,7 +130,7 @@ func runLogin(ctx context.Context, opts *LoginOptions, jopts *cmdutil.JSONOption if err != nil { return cmdutil.Wrapf(cmdutil.CodeAuthBadCredential, err, "validate API key") } - return persistAPIKey(opts, jopts, f, user) + return persistAPIKey(opts, fopts, f, user) } // Interactive: prompt for email + password. @@ -162,14 +163,14 @@ func runLogin(ctx context.Context, opts *LoginOptions, jopts *cmdutil.JSONOption return cmdutil.NewError(cmdutil.CodeAuthBadCredential, fmt.Sprintf("login refused: %s", resp.Message)) } - return persistJWT(opts, jopts, f, resp) + return persistJWT(opts, fopts, f, resp) } // persistAPIKey saves the --with-token API key and writes the context. // user is the principal returned by /auth/me during pre-persist validation, // used to populate context.User / TenantID so `auth list` reflects who // owns the key. -func persistAPIKey(opts *LoginOptions, jopts *cmdutil.JSONOptions, f *cmdutil.Factory, user *sdk.AuthUser) error { +func persistAPIKey(opts *LoginOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Factory, user *sdk.AuthUser) error { store, err := f.Secrets() if err != nil { return err @@ -186,11 +187,11 @@ func persistAPIKey(opts *LoginOptions, jopts *cmdutil.JSONOptions, f *cmdutil.Fa ctx.User = user.Email ctx.TenantID = user.TenantID } - return saveContextRef(opts, jopts, f, ctx, user) + return saveContextRef(opts, fopts, f, ctx, user) } // persistJWT saves access + refresh tokens and writes the context. -func persistJWT(opts *LoginOptions, jopts *cmdutil.JSONOptions, f *cmdutil.Factory, resp *sdk.LoginResponse) error { +func persistJWT(opts *LoginOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Factory, resp *sdk.LoginResponse) error { store, err := f.Secrets() if err != nil { return err @@ -213,10 +214,10 @@ func persistJWT(opts *LoginOptions, jopts *cmdutil.JSONOptions, f *cmdutil.Facto c.User = resp.User.Email c.TenantID = resp.User.TenantID } - return saveContextRef(opts, jopts, f, c, resp.User) + return saveContextRef(opts, fopts, f, c, resp.User) } -// loginResult is the typed payload emitted by `--json`. mode is derived from +// loginResult is the typed payload emitted by `--format json`. mode is derived from // whether the server returned a user (password flow) vs API-key flow. type loginResult struct { Context string `json:"context"` @@ -227,7 +228,7 @@ type loginResult struct { } // saveContextRef writes the context to config.yaml and prints success. -func saveContextRef(opts *LoginOptions, jopts *cmdutil.JSONOptions, f *cmdutil.Factory, ctx *config.Context, user *sdk.AuthUser) error { +func saveContextRef(opts *LoginOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Factory, ctx *config.Context, user *sdk.AuthUser) error { cfg, err := f.Config() if err != nil { return err @@ -240,14 +241,14 @@ func saveContextRef(opts *LoginOptions, jopts *cmdutil.JSONOptions, f *cmdutil.F if err := config.Save(cfg); err != nil { return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "save config") } - if jopts.Enabled() { + if fopts.WantsJSON() { result := loginResult{Context: opts.Context, Host: opts.Host, Mode: ModeAPIKey} if user != nil { result.Mode = ModeBearer result.User = user.Email result.TenantID = user.TenantID } - return jopts.Emit(iostreams.IO.Out, result) + return fopts.Emit(iostreams.IO.Out, result) } who := opts.Context if user != nil { diff --git a/cli/cmd/auth/login_test.go b/cli/cmd/auth/login_test.go index 8a395759..e6488bba 100644 --- a/cli/cmd/auth/login_test.go +++ b/cli/cmd/auth/login_test.go @@ -62,7 +62,7 @@ func TestRunLogin_PasswordMode(t *testing.T) { Host: "https://kb.example.com", Context: "prod", } - require.NoError(t, runLogin(context.Background(), opts, nil, f, svc)) + require.NoError(t, runLogin(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, svc)) assert.Equal(t, "a@b.c", svc.got.email) assert.Equal(t, "secret", svc.got.password) @@ -84,7 +84,7 @@ func TestRunLogin_WithToken(t *testing.T) { WithToken: true, StdinReader: strings.NewReader(" sk-1234 \n"), } - require.NoError(t, runLogin(context.Background(), opts, nil, f, nil)) + require.NoError(t, runLogin(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, nil)) got, _ := store.Get("ci", "api_key") assert.Equal(t, "sk-1234", got) cfg, _ := f.Config() @@ -105,7 +105,7 @@ func TestRunLogin_WithToken_ServerRejects(t *testing.T) { WithToken: true, StdinReader: strings.NewReader("sk-bad"), } - err := runLogin(context.Background(), opts, nil, f, nil) + err := runLogin(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, nil) require.Error(t, err) var typed *cmdutil.Error require.ErrorAs(t, err, &typed) @@ -137,7 +137,7 @@ func TestRunLogin_WithToken_Empty(t *testing.T) { WithToken: true, StdinReader: strings.NewReader(""), } - err := runLogin(context.Background(), opts, nil, f, nil) + err := runLogin(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, nil) require.Error(t, err) assert.Contains(t, err.Error(), "input.missing_flag") } @@ -145,7 +145,7 @@ func TestRunLogin_WithToken_Empty(t *testing.T) { func TestRunLogin_BadHost(t *testing.T) { iostreams.SetForTest(t) f, _ := newTestFactoryWithConfig(t, prompt.AgentPrompter{}) - err := runLogin(context.Background(), &LoginOptions{Host: "ftp://nope"}, nil, f, nil) + err := runLogin(context.Background(), &LoginOptions{Host: "ftp://nope"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, nil) require.Error(t, err) assert.Contains(t, err.Error(), "input.invalid_argument") } @@ -154,7 +154,7 @@ func TestRunLogin_LoginRefused(t *testing.T) { iostreams.SetForTest(t) f, _ := newTestFactoryWithConfig(t, scriptedPrompter{email: "a@b.c", password: "x"}) svc := &fakeLoginService{resp: &sdk.LoginResponse{Success: false, Message: "bad password"}} - err := runLogin(context.Background(), &LoginOptions{Host: "https://x", Context: "p"}, nil, f, svc) + err := runLogin(context.Background(), &LoginOptions{Host: "https://x", Context: "p"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, svc) require.Error(t, err) assert.Contains(t, err.Error(), "auth.bad_credential") } diff --git a/cli/cmd/auth/logout.go b/cli/cmd/auth/logout.go index 1c019549..44e7055d 100644 --- a/cli/cmd/auth/logout.go +++ b/cli/cmd/auth/logout.go @@ -17,7 +17,7 @@ type LogoutOptions struct { All bool // --all: clear every context } -// authLogoutFields enumerates the fields surfaced for `--json` discovery +// authLogoutFields enumerates the fields surfaced for `--format json` discovery // on `auth logout`. The result is the list of context names that were // logged out. var authLogoutFields = []string{"removed"} @@ -46,21 +46,22 @@ accepted until it expires.`, weknora auth logout --all`, Args: cobra.NoArgs, RunE: func(c *cobra.Command, _ []string) error { - jopts, err := cmdutil.CheckJSONFlags(c) + fopts, err := cmdutil.CheckFormatFlag(c) if err != nil { return err } - return runLogout(opts, jopts, f) + fopts.ResolveDefault(iostreams.IO.IsStdoutTTY()) + return runLogout(opts, fopts, f) }, } cmd.Flags().StringVar(&opts.Name, "name", "", "Context to log out (defaults to the current context)") cmd.Flags().BoolVar(&opts.All, "all", false, "Log out of every configured context") - cmdutil.AddJSONFlags(cmd, authLogoutFields) + cmdutil.AddFormatFlag(cmd, authLogoutFields...) cmd.MarkFlagsMutuallyExclusive("name", "all") return cmd } -func runLogout(opts *LogoutOptions, jopts *cmdutil.JSONOptions, f *cmdutil.Factory) error { +func runLogout(opts *LogoutOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Factory) error { cfg, err := f.Config() if err != nil { return err @@ -93,8 +94,8 @@ func runLogout(opts *LogoutOptions, jopts *cmdutil.JSONOptions, f *cmdutil.Facto return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "save config") } - if jopts.Enabled() { - return jopts.Emit(iostreams.IO.Out, logoutResult{Removed: targets}) + if fopts.WantsJSON() { + return fopts.Emit(iostreams.IO.Out, logoutResult{Removed: targets}) } fmt.Fprintf(iostreams.IO.Out, "✓ Logged out of %d context(s): %s\n", len(targets), strings.Join(targets, ", ")) return nil diff --git a/cli/cmd/auth/logout_test.go b/cli/cmd/auth/logout_test.go index 165f4954..c7357544 100644 --- a/cli/cmd/auth/logout_test.go +++ b/cli/cmd/auth/logout_test.go @@ -45,7 +45,7 @@ func TestLogout_CurrentContext(t *testing.T) { "staging": {Host: "https://staging", APIKeyRef: store.Ref("staging", "api_key")}, }, } - require.NoError(t, runLogout(&LogoutOptions{}, nil, newLogoutFactory(t, cfg, store))) + require.NoError(t, runLogout(&LogoutOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newLogoutFactory(t, cfg, store))) assert.Empty(t, cfg.CurrentContext, "current_context should clear when removed") assert.NotContains(t, cfg.Contexts, "prod") @@ -73,7 +73,7 @@ func TestLogout_NamedContext(t *testing.T) { "staging": {Host: "https://staging", APIKeyRef: store.Ref("staging", "api_key")}, }, } - require.NoError(t, runLogout(&LogoutOptions{Name: "staging"}, nil, newLogoutFactory(t, cfg, store))) + require.NoError(t, runLogout(&LogoutOptions{Name: "staging"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newLogoutFactory(t, cfg, store))) assert.Equal(t, "prod", cfg.CurrentContext, "current_context untouched when removing other") assert.NotContains(t, cfg.Contexts, "staging") @@ -91,7 +91,7 @@ func TestLogout_All(t *testing.T) { "staging": {Host: "https://staging"}, }, } - require.NoError(t, runLogout(&LogoutOptions{All: true}, nil, newLogoutFactory(t, cfg, store))) + require.NoError(t, runLogout(&LogoutOptions{All: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newLogoutFactory(t, cfg, store))) assert.Empty(t, cfg.Contexts) assert.Empty(t, cfg.CurrentContext) @@ -101,7 +101,7 @@ func TestLogout_NoContexts(t *testing.T) { isolateConfig(t) _, _ = iostreams.SetForTest(t) cfg := &config.Config{} - err := runLogout(&LogoutOptions{}, nil, newLogoutFactory(t, cfg, secrets.NewMemStore())) + err := runLogout(&LogoutOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newLogoutFactory(t, cfg, secrets.NewMemStore())) require.Error(t, err) var typed *cmdutil.Error require.ErrorAs(t, err, &typed) @@ -115,7 +115,7 @@ func TestLogout_UnknownName(t *testing.T) { CurrentContext: "prod", Contexts: map[string]config.Context{"prod": {Host: "https://prod"}}, } - err := runLogout(&LogoutOptions{Name: "ghost"}, nil, newLogoutFactory(t, cfg, secrets.NewMemStore())) + err := runLogout(&LogoutOptions{Name: "ghost"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newLogoutFactory(t, cfg, secrets.NewMemStore())) require.Error(t, err) var typed *cmdutil.Error require.ErrorAs(t, err, &typed) @@ -128,7 +128,7 @@ func TestLogout_NoCurrentNoFlag(t *testing.T) { cfg := &config.Config{ Contexts: map[string]config.Context{"prod": {Host: "https://prod"}}, } - err := runLogout(&LogoutOptions{}, nil, newLogoutFactory(t, cfg, secrets.NewMemStore())) + err := runLogout(&LogoutOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newLogoutFactory(t, cfg, secrets.NewMemStore())) require.Error(t, err) var typed *cmdutil.Error require.ErrorAs(t, err, &typed) diff --git a/cli/cmd/auth/refresh.go b/cli/cmd/auth/refresh.go index eeec4662..3fcd3e7d 100644 --- a/cli/cmd/auth/refresh.go +++ b/cli/cmd/auth/refresh.go @@ -15,7 +15,7 @@ type RefreshOptions struct { Name string // --name: target context (defaults to current) } -// authRefreshFields enumerates the fields surfaced for `--json` discovery +// authRefreshFields enumerates the fields surfaced for `--format json` discovery // on `auth refresh`. Token values are intentionally omitted - see refreshResult. var authRefreshFields = []string{"context"} @@ -48,15 +48,16 @@ refresh semantic. Rotate the key in the server UI instead.`, weknora auth refresh --name staging # refresh a specific context`, Args: cobra.NoArgs, RunE: func(c *cobra.Command, _ []string) error { - jopts, err := cmdutil.CheckJSONFlags(c) + fopts, err := cmdutil.CheckFormatFlag(c) if err != nil { return err } - return runRefresh(c.Context(), opts, jopts, f, defaultRefresher) + fopts.ResolveDefault(iostreams.IO.IsStdoutTTY()) + return runRefresh(c.Context(), opts, fopts, f, defaultRefresher) }, } cmd.Flags().StringVar(&opts.Name, "name", "", "Context to refresh (defaults to the current context)") - cmdutil.AddJSONFlags(cmd, authRefreshFields) + cmdutil.AddFormatFlag(cmd, authRefreshFields...) return cmd } @@ -67,7 +68,7 @@ func defaultRefresher(host string) cmdutil.Refresher { return sdk.NewClient(host) } -func runRefresh(ctx context.Context, opts *RefreshOptions, jopts *cmdutil.JSONOptions, f *cmdutil.Factory, refresherFor func(host string) cmdutil.Refresher) error { +func runRefresh(ctx context.Context, opts *RefreshOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Factory, refresherFor func(host string) cmdutil.Refresher) error { cfg, err := f.Config() if err != nil { return err @@ -109,8 +110,8 @@ func runRefresh(ctx context.Context, opts *RefreshOptions, jopts *cmdutil.JSONOp return err } - if jopts.Enabled() { - return jopts.Emit(iostreams.IO.Out, refreshResult{Context: name}) + if fopts.WantsJSON() { + return fopts.Emit(iostreams.IO.Out, refreshResult{Context: name}) } fmt.Fprintf(iostreams.IO.Out, "✓ Refreshed access token for context %s\n", name) return nil diff --git a/cli/cmd/auth/refresh_test.go b/cli/cmd/auth/refresh_test.go index 60051628..048ac7c9 100644 --- a/cli/cmd/auth/refresh_test.go +++ b/cli/cmd/auth/refresh_test.go @@ -71,7 +71,7 @@ func TestRefresh_Happy(t *testing.T) { AccessToken: "new-access", RefreshToken: "new-refresh", }} - require.NoError(t, runRefresh(context.Background(), &RefreshOptions{}, nil, f, stubSvc(svc))) + require.NoError(t, runRefresh(context.Background(), &RefreshOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, stubSvc(svc))) assert.Equal(t, "old-refresh", svc.gotTok, "must pass stored refresh token to SDK") gotAccess, _ := store.Get("prod", "access") @@ -96,7 +96,7 @@ func TestRefresh_NamedContext(t *testing.T) { svc := &fakeRefreshService{resp: &sdk.RefreshTokenResponse{ Success: true, AccessToken: "new-stg-access", RefreshToken: "new-stg-refresh", }} - require.NoError(t, runRefresh(context.Background(), &RefreshOptions{Name: "staging"}, nil, f, stubSvc(svc))) + require.NoError(t, runRefresh(context.Background(), &RefreshOptions{Name: "staging"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, stubSvc(svc))) assert.Equal(t, "stg-refresh", svc.gotTok, "--name=staging must refresh the staging context, not current") // current is untouched @@ -108,7 +108,7 @@ func TestRefresh_NamedContext(t *testing.T) { func TestRefresh_NoCurrentContext(t *testing.T) { iostreams.SetForTest(t) f := newRefreshFactory(t, &config.Config{}, secrets.NewMemStore()) - err := runRefresh(context.Background(), &RefreshOptions{}, nil, f, stubSvc(&fakeRefreshService{})) + err := runRefresh(context.Background(), &RefreshOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, stubSvc(&fakeRefreshService{})) require.Error(t, err) var typed *cmdutil.Error require.ErrorAs(t, err, &typed) @@ -124,7 +124,7 @@ func TestRefresh_APIKeyContext(t *testing.T) { Contexts: map[string]config.Context{"ci": {Host: "https://kb", APIKeyRef: "mem://ci/api_key"}}, } f := newRefreshFactory(t, cfg, store) - err := runRefresh(context.Background(), &RefreshOptions{}, nil, f, stubSvc(&fakeRefreshService{})) + err := runRefresh(context.Background(), &RefreshOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, stubSvc(&fakeRefreshService{})) require.Error(t, err) var typed *cmdutil.Error require.ErrorAs(t, err, &typed) @@ -142,7 +142,7 @@ func TestRefresh_NoRefreshTokenStored(t *testing.T) { } // MemStore is empty - RefreshRef points to a slot that doesn't exist. f := newRefreshFactory(t, cfg, secrets.NewMemStore()) - err := runRefresh(context.Background(), &RefreshOptions{}, nil, f, stubSvc(&fakeRefreshService{})) + err := runRefresh(context.Background(), &RefreshOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, stubSvc(&fakeRefreshService{})) require.Error(t, err) var typed *cmdutil.Error require.ErrorAs(t, err, &typed) @@ -160,7 +160,7 @@ func TestRefresh_ServerRefused(t *testing.T) { } f := newRefreshFactory(t, cfg, store) svc := &fakeRefreshService{resp: &sdk.RefreshTokenResponse{Success: false, Message: "refresh token expired"}} - err := runRefresh(context.Background(), &RefreshOptions{}, nil, f, stubSvc(svc)) + err := runRefresh(context.Background(), &RefreshOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, stubSvc(svc)) require.Error(t, err) var typed *cmdutil.Error require.ErrorAs(t, err, &typed) @@ -183,7 +183,7 @@ func TestRefresh_TransportError(t *testing.T) { } f := newRefreshFactory(t, cfg, store) svc := &fakeRefreshService{err: errors.New("connection reset")} - err := runRefresh(context.Background(), &RefreshOptions{}, nil, f, stubSvc(svc)) + err := runRefresh(context.Background(), &RefreshOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, stubSvc(svc)) require.Error(t, err) var typed *cmdutil.Error require.ErrorAs(t, err, &typed) @@ -203,7 +203,7 @@ func TestRefresh_JSONOutput(t *testing.T) { } f := newRefreshFactory(t, cfg, store) svc := &fakeRefreshService{resp: &sdk.RefreshTokenResponse{Success: true, AccessToken: "a", RefreshToken: "r"}} - require.NoError(t, runRefresh(context.Background(), &RefreshOptions{}, &cmdutil.JSONOptions{}, f, stubSvc(svc))) + require.NoError(t, runRefresh(context.Background(), &RefreshOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, f, stubSvc(svc))) body := out.String() // payload must not leak the actual token values. diff --git a/cli/cmd/auth/status.go b/cli/cmd/auth/status.go index 03988dc2..98d3c160 100644 --- a/cli/cmd/auth/status.go +++ b/cli/cmd/auth/status.go @@ -11,7 +11,7 @@ import ( sdk "github.com/Tencent/WeKnora/client" ) -// authStatusFields enumerates the fields surfaced for `--json` discovery +// authStatusFields enumerates the fields surfaced for `--format json` discovery // on `auth status`. Single-resource shape: filter applies to data itself. var authStatusFields = []string{ "context", "user_id", "username", "email", "is_active", @@ -23,7 +23,7 @@ type StatusService interface { GetCurrentUser(ctx context.Context) (*sdk.CurrentUserResponse, error) } -// statusResult is the typed payload emitted by `--json`. Mirrors the +// statusResult is the typed payload emitted by `--format json`. Mirrors the // SDK AuthUser + AuthTenant projection so agents can branch on // can_access_all_tenants (cross-tenant admin) and is_active (disabled // account) without a second round-trip. @@ -52,22 +52,23 @@ For JWT contexts the SDK transparently refreshes on 401, so this command usually only surfaces a hard auth failure.`, Args: cobra.NoArgs, RunE: func(c *cobra.Command, args []string) error { - jopts, err := cmdutil.CheckJSONFlags(c) + fopts, err := cmdutil.CheckFormatFlag(c) if err != nil { return err } + fopts.ResolveDefault(iostreams.IO.IsStdoutTTY()) cli, err := f.Client() if err != nil { return err } - return runStatus(c.Context(), jopts, f, cli) + return runStatus(c.Context(), fopts, f, cli) }, } - cmdutil.AddJSONFlags(cmd, authStatusFields) + cmdutil.AddFormatFlag(cmd, authStatusFields...) return cmd } -func runStatus(ctx context.Context, jopts *cmdutil.JSONOptions, f *cmdutil.Factory, svc StatusService) error { +func runStatus(ctx context.Context, fopts *cmdutil.FormatOptions, f *cmdutil.Factory, svc StatusService) error { if svc == nil { return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated, "no SDK client available; run `weknora auth login`") } @@ -83,7 +84,7 @@ func runStatus(ctx context.Context, jopts *cmdutil.JSONOptions, f *cmdutil.Facto return err } - if jopts.Enabled() { + if fopts.WantsJSON() { result := statusResult{Context: cfg.CurrentContext} if user != nil { result.UserID = user.ID @@ -96,7 +97,7 @@ func runStatus(ctx context.Context, jopts *cmdutil.JSONOptions, f *cmdutil.Facto if tenant != nil { result.TenantName = tenant.Name } - return jopts.Emit(iostreams.IO.Out, result) + return fopts.Emit(iostreams.IO.Out, result) } host := "" diff --git a/cli/cmd/auth/status_test.go b/cli/cmd/auth/status_test.go index 0524bba7..b9ae06fe 100644 --- a/cli/cmd/auth/status_test.go +++ b/cli/cmd/auth/status_test.go @@ -52,7 +52,7 @@ func TestRunStatus_HumanOutput(t *testing.T) { &sdk.AuthTenant{ID: 7, Name: "Acme"}, ), } - require.NoError(t, runStatus(context.Background(), nil, f, svc)) + require.NoError(t, runStatus(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, svc)) got := out.String() assert.Contains(t, got, "context: prod") assert.Contains(t, got, "host: https://kb.example.com") @@ -69,7 +69,7 @@ func TestRunStatus_JSONOutput(t *testing.T) { })) f := &cmdutil.Factory{Config: func() (*config.Config, error) { return config.Load() }} svc := &fakeStatusService{resp: newCurrentUserResponse(&sdk.AuthUser{ID: "u1", Email: "a@b.c", TenantID: 7}, nil)} - require.NoError(t, runStatus(context.Background(), &cmdutil.JSONOptions{}, f, svc)) + require.NoError(t, runStatus(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, f, svc)) got := out.String() assert.True(t, strings.HasPrefix(strings.TrimSpace(got), `{"context":"prod"`), "expected bare object, got: %q", got) assert.NotContains(t, got, `"ok":`) @@ -78,7 +78,7 @@ func TestRunStatus_JSONOutput(t *testing.T) { func TestRunStatus_NoSDKClient(t *testing.T) { iostreams.SetForTest(t) - err := runStatus(context.Background(), nil, &cmdutil.Factory{}, nil) + err := runStatus(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &cmdutil.Factory{}, nil) require.Error(t, err) assert.True(t, cmdutil.IsAuthError(err)) } @@ -88,7 +88,7 @@ func TestRunStatus_SDKError_Transport(t *testing.T) { testutil.XDGTempDir(t) require.NoError(t, config.Save(&config.Config{CurrentContext: "p", Contexts: map[string]config.Context{"p": {Host: "https://x"}}})) f := &cmdutil.Factory{Config: func() (*config.Config, error) { return config.Load() }} - err := runStatus(context.Background(), nil, f, &fakeStatusService{err: assert.AnError}) + err := runStatus(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, &fakeStatusService{err: assert.AnError}) require.Error(t, err) // Non-HTTP errors (DNS / TCP) are transport problems, not auth problems - // classify network.error so retry logic / exit code 7 / IsTransient apply. @@ -100,7 +100,7 @@ func TestRunStatus_SDKError_HTTP401(t *testing.T) { testutil.XDGTempDir(t) require.NoError(t, config.Save(&config.Config{CurrentContext: "p", Contexts: map[string]config.Context{"p": {Host: "https://x"}}})) f := &cmdutil.Factory{Config: func() (*config.Config, error) { return config.Load() }} - err := runStatus(context.Background(), nil, f, &fakeStatusService{err: errors.New("HTTP error 401: invalid token")}) + err := runStatus(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, &fakeStatusService{err: errors.New("HTTP error 401: invalid token")}) require.Error(t, err) assert.True(t, cmdutil.IsAuthError(err)) } diff --git a/cli/cmd/auth/token.go b/cli/cmd/auth/token.go index 54b32013..a5652df5 100644 --- a/cli/cmd/auth/token.go +++ b/cli/cmd/auth/token.go @@ -9,8 +9,8 @@ import ( "github.com/Tencent/WeKnora/cli/internal/iostreams" ) -// authTokenFields lists fields available for `auth token --json=` projection. -// Single-resource shape: filter applies to the bare token object directly. +// authTokenFields lists fields surfaced in `--help` as a hint for `--jq` +// projection. Single-resource shape: emits the bare token object directly. var authTokenFields = []string{"token", "mode", "context"} type tokenResult struct { @@ -30,7 +30,7 @@ type tokenResult struct { // `auth list` shows which mode each context uses. // // Default output: raw token on stdout, no trailing newline (clean $(...)). -// `--json[=fields]` emits a bare {token, mode, context} object. +// `--format json[=fields]` emits a bare {token, mode, context} object. func NewCmdToken(f *cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ Use: "token", @@ -48,21 +48,22 @@ to see which mode each context uses, and construct the matching HTTP header: ` + "`--context `" + ` (global flag) selects a non-active context to read from.`, Example: ` WEKNORA_TOKEN=$(weknora auth token) weknora auth token --context staging - weknora auth token --json`, + weknora auth token --format json`, Args: cobra.NoArgs, RunE: func(c *cobra.Command, _ []string) error { - jopts, err := cmdutil.CheckJSONFlags(c) + fopts, err := cmdutil.CheckFormatFlag(c) if err != nil { return err } - return runToken(f, jopts) + fopts.ResolveDefault(iostreams.IO.IsStdoutTTY()) + return runToken(f, fopts) }, } - cmdutil.AddJSONFlags(cmd, authTokenFields) + cmdutil.AddFormatFlag(cmd, authTokenFields...) return cmd } -func runToken(f *cmdutil.Factory, jopts *cmdutil.JSONOptions) error { +func runToken(f *cmdutil.Factory, fopts *cmdutil.FormatOptions) error { cfg, err := f.Config() if err != nil { return err @@ -113,8 +114,8 @@ func runToken(f *cmdutil.Factory, jopts *cmdutil.JSONOptions) error { fmt.Sprintf("context %q credential is empty in keyring; run `weknora auth login`", ctxName)) } - if jopts.Enabled() { - return jopts.Emit(iostreams.IO.Out, tokenResult{Token: token, Mode: mode, Context: ctxName}) + if fopts.WantsJSON() { + return fopts.Emit(iostreams.IO.Out, tokenResult{Token: token, Mode: mode, Context: ctxName}) } // No trailing newline - clean $(weknora auth token) substitution. diff --git a/cli/cmd/auth/token_test.go b/cli/cmd/auth/token_test.go index 7e775535..2dae25d5 100644 --- a/cli/cmd/auth/token_test.go +++ b/cli/cmd/auth/token_test.go @@ -34,7 +34,7 @@ func TestAuthToken_BearerMode_PlainOutput(t *testing.T) { _ = store.Set("prod", "access", "jwt-token-xyz") out, _ := iostreams.SetForTest(t) - err := runToken(tokenTestFactory(t, cfg, store), nil) + err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}) if err != nil { t.Fatalf("runToken: %v", err) } @@ -58,7 +58,7 @@ func TestAuthToken_APIKeyMode_PlainOutput(t *testing.T) { _ = store.Set("ci", "api_key", "sk_test_apikey_42") out, _ := iostreams.SetForTest(t) - if err := runToken(tokenTestFactory(t, cfg, store), nil); err != nil { + if err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}); err != nil { t.Fatalf("runToken: %v", err) } if got := out.String(); got != "sk_test_apikey_42" { @@ -77,7 +77,7 @@ func TestAuthToken_JSON(t *testing.T) { _ = store.Set("prod", "access", "jwt-xyz") out, _ := iostreams.SetForTest(t) - if err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.JSONOptions{}); err != nil { + if err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}); err != nil { t.Fatalf("runToken: %v", err) } var got struct { @@ -93,7 +93,7 @@ func TestAuthToken_JSON(t *testing.T) { } } -func TestAuthToken_JSON_FieldFilter(t *testing.T) { +func TestAuthToken_JSON_JQProjection(t *testing.T) { cfg := &config.Config{ CurrentContext: "ci", Contexts: map[string]config.Context{ @@ -104,8 +104,8 @@ func TestAuthToken_JSON_FieldFilter(t *testing.T) { _ = store.Set("ci", "api_key", "sk_42") out, _ := iostreams.SetForTest(t) - jopts := &cmdutil.JSONOptions{Fields: []string{"token"}} - if err := runToken(tokenTestFactory(t, cfg, store), jopts); err != nil { + fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON, JQ: "{token}"} + if err := runToken(tokenTestFactory(t, cfg, store), fopts); err != nil { t.Fatalf("runToken: %v", err) } var got map[string]any @@ -124,7 +124,7 @@ func TestAuthToken_NoCurrentContext(t *testing.T) { cfg := &config.Config{} store := secrets.NewMemStore() iostreams.SetForTest(t) - err := runToken(tokenTestFactory(t, cfg, store), nil) + err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}) if err == nil { t.Fatal("expected error, got nil") } @@ -149,7 +149,7 @@ func TestAuthToken_ContextOverride(t *testing.T) { f.ContextOverride = "staging" out, _ := iostreams.SetForTest(t) - if err := runToken(f, nil); err != nil { + if err := runToken(f, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}); err != nil { t.Fatalf("runToken: %v", err) } if got := out.String(); got != "staging-key" { @@ -167,7 +167,7 @@ func TestAuthToken_NoStoredCredential(t *testing.T) { store := secrets.NewMemStore() // no Set - keyring is empty iostreams.SetForTest(t) - err := runToken(tokenTestFactory(t, cfg, store), nil) + err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}) if err == nil { t.Fatal("expected auth.unauthenticated, got nil") } @@ -185,7 +185,7 @@ func TestAuthToken_ContextWithNoCredentialRefs(t *testing.T) { } store := secrets.NewMemStore() iostreams.SetForTest(t) - err := runToken(tokenTestFactory(t, cfg, store), nil) + err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}) if err == nil { t.Fatal("expected auth.unauthenticated, got nil") } @@ -229,7 +229,7 @@ func makeAPIKeyCfg() (*config.Config, *secrets.MemStore) { func TestAuthToken_NonTTY_NoStderrHint(t *testing.T) { cfg, store := makeBearerCfg() out, errBuf := iostreams.SetForTest(t) - if err := runToken(tokenTestFactory(t, cfg, store), nil); err != nil { + if err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}); err != nil { t.Fatalf("runToken: %v", err) } if out.String() != "jwt-xyz" { @@ -243,7 +243,7 @@ func TestAuthToken_NonTTY_NoStderrHint(t *testing.T) { func TestAuthToken_TTY_BearerMode_StderrHintNoRotationNote(t *testing.T) { cfg, store := makeBearerCfg() out, errBuf := iostreams.SetForTestWithTTY(t) - if err := runToken(tokenTestFactory(t, cfg, store), nil); err != nil { + if err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}); err != nil { t.Fatalf("runToken: %v", err) } if out.String() != "jwt-xyz" { @@ -260,7 +260,7 @@ func TestAuthToken_TTY_BearerMode_StderrHintNoRotationNote(t *testing.T) { func TestAuthToken_TTY_APIKeyMode_IncludesRotationNote(t *testing.T) { cfg, store := makeAPIKeyCfg() out, errBuf := iostreams.SetForTestWithTTY(t) - if err := runToken(tokenTestFactory(t, cfg, store), nil); err != nil { + if err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}); err != nil { t.Fatalf("runToken: %v", err) } if out.String() != "sk_42" { @@ -276,12 +276,12 @@ func TestAuthToken_TTY_APIKeyMode_IncludesRotationNote(t *testing.T) { } func TestAuthToken_TTY_JSONMode_NoStderrHint(t *testing.T) { - // --json output mode targets script/agent consumers even when stdout + // --format json output mode targets script/agent consumers even when stdout // happens to be a TTY (e.g. an IDE running the CLI on the user's // behalf). Hint would pollute their parsing - suppress. cfg, store := makeBearerCfg() _, errBuf := iostreams.SetForTestWithTTY(t) - if err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.JSONOptions{}); err != nil { + if err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}); err != nil { t.Fatalf("runToken: %v", err) } if errBuf.Len() != 0 { diff --git a/cli/cmd/context/add.go b/cli/cmd/context/add.go index b4cb9cf2..b1e3ab3d 100644 --- a/cli/cmd/context/add.go +++ b/cli/cmd/context/add.go @@ -16,7 +16,7 @@ type AddOptions struct { User string } -// contextAddFields enumerates the fields surfaced for `--json` discovery on +// contextAddFields enumerates the fields surfaced for `--format json` discovery on // `context add`. The result describes the newly-registered context. var contextAddFields = []string{ "name", "host", "user", "current", @@ -52,21 +52,22 @@ adds leave the current context untouched.`, weknora context add prod --host https://prod.example.com --user alice@example.com`, Args: cobra.ExactArgs(1), RunE: func(c *cobra.Command, args []string) error { - jopts, err := cmdutil.CheckJSONFlags(c) + fopts, err := cmdutil.CheckFormatFlag(c) if err != nil { return err } - return runAdd(opts, jopts, args[0]) + fopts.ResolveDefault(iostreams.IO.IsStdoutTTY()) + return runAdd(opts, fopts, args[0]) }, } cmd.Flags().StringVar(&opts.Host, "host", "", "Server base URL, e.g. https://kb.example.com (required)") cmd.Flags().StringVar(&opts.User, "user", "", "Account email shown in 'context list' (optional, cosmetic only)") - cmdutil.AddJSONFlags(cmd, contextAddFields) + cmdutil.AddFormatFlag(cmd, contextAddFields...) _ = cmd.MarkFlagRequired("host") return cmd } -func runAdd(opts *AddOptions, jopts *cmdutil.JSONOptions, name string) error { +func runAdd(opts *AddOptions, fopts *cmdutil.FormatOptions, name string) error { if err := validateName(name); err != nil { return err } @@ -98,8 +99,8 @@ func runAdd(opts *AddOptions, jopts *cmdutil.JSONOptions, name string) error { return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "save config") } - if jopts.Enabled() { - return jopts.Emit(iostreams.IO.Out, addResult{Name: name, Host: host, User: opts.User, Current: wasFirst}) + if fopts.WantsJSON() { + return fopts.Emit(iostreams.IO.Out, addResult{Name: name, Host: host, User: opts.User, Current: wasFirst}) } if wasFirst { fmt.Fprintf(iostreams.IO.Out, "✓ Added context %s (now current). Run `weknora auth login --name %s` to attach credentials.\n", name, name) diff --git a/cli/cmd/context/add_test.go b/cli/cmd/context/add_test.go index ec6ff6f8..52b78e12 100644 --- a/cli/cmd/context/add_test.go +++ b/cli/cmd/context/add_test.go @@ -14,7 +14,7 @@ func TestAdd_HappyPath(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) out, _ := iostreams.SetForTest(t) - if err := runAdd(&AddOptions{Host: "https://my.example.com", User: "alice@example.com"}, nil, "staging"); err != nil { + if err := runAdd(&AddOptions{Host: "https://my.example.com", User: "alice@example.com"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, "staging"); err != nil { t.Fatalf("runAdd: %v", err) } @@ -53,7 +53,7 @@ func TestAdd_DuplicateName(t *testing.T) { t.Fatalf("Save: %v", err) } - err := runAdd(&AddOptions{Host: "https://new.example.com"}, nil, "staging") + err := runAdd(&AddOptions{Host: "https://new.example.com"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, "staging") if err == nil { t.Fatal("expected error on duplicate name") } @@ -82,7 +82,7 @@ func TestAdd_BadHost(t *testing.T) { "http://", // missing host } for _, h := range bad { - err := runAdd(&AddOptions{Host: h}, nil, "staging") + err := runAdd(&AddOptions{Host: h}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, "staging") if err == nil { t.Errorf("host=%q: expected error", h) continue @@ -110,7 +110,7 @@ func TestAdd_SecondContextDoesNotChangeCurrent(t *testing.T) { t.Fatalf("Save: %v", err) } - if err := runAdd(&AddOptions{Host: "https://stg.example.com"}, nil, "staging"); err != nil { + if err := runAdd(&AddOptions{Host: "https://stg.example.com"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, "staging"); err != nil { t.Fatalf("runAdd: %v", err) } got, _ := config.Load() @@ -123,7 +123,7 @@ func TestAdd_JSON(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) out, _ := iostreams.SetForTest(t) - if err := runAdd(&AddOptions{Host: "https://my.example.com"}, &cmdutil.JSONOptions{}, "staging"); err != nil { + if err := runAdd(&AddOptions{Host: "https://my.example.com"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, "staging"); err != nil { t.Fatalf("runAdd: %v", err) } var got map[string]any diff --git a/cli/cmd/context/list.go b/cli/cmd/context/list.go index 76f4ef24..c7e0bc45 100644 --- a/cli/cmd/context/list.go +++ b/cli/cmd/context/list.go @@ -15,7 +15,7 @@ import ( type ListOptions struct{} -// contextListFields enumerates the fields surfaced for `--json` discovery on +// contextListFields enumerates the fields surfaced for `--format json` discovery on // `context list`. Each entry is a per-context summary row. var contextListFields = []string{ "name", "host", "user", "current", @@ -43,18 +43,19 @@ run "weknora auth list" for that. "context list" is the catalog of *where* the CLI can talk to; "auth list" is the catalog of *how*.`, Args: cobra.NoArgs, RunE: func(c *cobra.Command, _ []string) error { - jopts, err := cmdutil.CheckJSONFlags(c) + fopts, err := cmdutil.CheckFormatFlag(c) if err != nil { return err } - return runList(jopts) + fopts.ResolveDefault(iostreams.IO.IsStdoutTTY()) + return runList(fopts) }, } - cmdutil.AddJSONFlags(cmd, contextListFields) + cmdutil.AddFormatFlag(cmd, contextListFields...) return cmd } -func runList(jopts *cmdutil.JSONOptions) error { +func runList(fopts *cmdutil.FormatOptions) error { cfg, err := config.Load() if err != nil { return err @@ -70,8 +71,8 @@ func runList(jopts *cmdutil.JSONOptions) error { } sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name }) - if jopts.Enabled() { - return jopts.Emit(iostreams.IO.Out, entries) + if fopts.WantsJSON() { + return fopts.Emit(iostreams.IO.Out, entries) } if len(entries) == 0 { fmt.Fprintln(iostreams.IO.Out, "No contexts configured. Run `weknora auth login` (or `weknora context add`) to create one.") diff --git a/cli/cmd/context/list_test.go b/cli/cmd/context/list_test.go index 2c7a08e5..7a1a8213 100644 --- a/cli/cmd/context/list_test.go +++ b/cli/cmd/context/list_test.go @@ -14,7 +14,7 @@ func TestList_Empty(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) out, _ := iostreams.SetForTest(t) - if err := runList(nil); err != nil { + if err := runList(&cmdutil.FormatOptions{Mode: cmdutil.FormatText}); err != nil { t.Fatalf("runList: %v", err) } if !strings.Contains(out.String(), "No contexts") { @@ -38,7 +38,7 @@ func TestList_MultipleSorted(t *testing.T) { t.Fatalf("Save: %v", err) } - if err := runList(nil); err != nil { + if err := runList(&cmdutil.FormatOptions{Mode: cmdutil.FormatText}); err != nil { t.Fatalf("runList: %v", err) } got := out.String() @@ -75,7 +75,7 @@ func TestList_JSON(t *testing.T) { t.Fatalf("Save: %v", err) } - if err := runList(&cmdutil.JSONOptions{}); err != nil { + if err := runList(&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}); err != nil { t.Fatalf("runList: %v", err) } diff --git a/cli/cmd/context/remove.go b/cli/cmd/context/remove.go index ceea85e3..39c496a2 100644 --- a/cli/cmd/context/remove.go +++ b/cli/cmd/context/remove.go @@ -16,7 +16,7 @@ type RemoveOptions struct { Yes bool // sourced from the global -y/--yes persistent flag (matches `kb delete`) } -// contextRemoveFields enumerates the fields surfaced for `--json` discovery on +// contextRemoveFields enumerates the fields surfaced for `--format json` discovery on // `context remove`. The result reports the disposition of the removed entry. var contextRemoveFields = []string{ "name", "removed", "was_current", @@ -46,28 +46,29 @@ Removing the current context also clears CurrentContext - subsequent commands will error until you select another with ` + "`weknora context use `" + ` or pick one up via the global ` + "`--context`" + ` flag. Because that change is observable in every later command, removing the current context requires explicit -y/--yes -in scripted / --json invocations (exit code 10; see cli/README.md).`, +in scripted / --format json invocations (exit code 10; see cli/README.md).`, Example: ` weknora context remove staging # remove non-current → no prompt weknora context remove production -y # remove current → confirm`, Args: cobra.ExactArgs(1), RunE: func(c *cobra.Command, args []string) error { - jopts, err := cmdutil.CheckJSONFlags(c) + fopts, err := cmdutil.CheckFormatFlag(c) if err != nil { return err } + fopts.ResolveDefault(iostreams.IO.IsStdoutTTY()) opts.Yes, _ = c.Flags().GetBool("yes") store, err := f.Secrets() if err != nil { return err } - return runRemove(opts, jopts, args[0], store, f.Prompter()) + return runRemove(opts, fopts, args[0], store, f.Prompter()) }, } - cmdutil.AddJSONFlags(cmd, contextRemoveFields) + cmdutil.AddFormatFlag(cmd, contextRemoveFields...) return cmd } -func runRemove(opts *RemoveOptions, jopts *cmdutil.JSONOptions, name string, store secrets.Store, p prompt.Prompter) error { +func runRemove(opts *RemoveOptions, fopts *cmdutil.FormatOptions, name string, store secrets.Store, p prompt.Prompter) error { cfg, err := config.Load() if err != nil { return err @@ -78,7 +79,7 @@ func runRemove(opts *RemoveOptions, jopts *cmdutil.JSONOptions, name string, sto } wasCurrent := name == cfg.CurrentContext - jsonOut := jopts.Enabled() + jsonOut := fopts.WantsJSON() // Confirmation only fires for removing the current context - non-current // remove uses the same low-friction policy as `auth logout`. if wasCurrent { @@ -100,7 +101,7 @@ func runRemove(opts *RemoveOptions, jopts *cmdutil.JSONOptions, name string, sto result := removeResult{Name: name, Removed: true, WasCurrent: wasCurrent} if jsonOut { - return jopts.Emit(iostreams.IO.Out, result) + return fopts.Emit(iostreams.IO.Out, result) } if wasCurrent { fmt.Fprintf(iostreams.IO.Out, "✓ Removed context %s (current context cleared - run `weknora context use ` to pick another)\n", name) diff --git a/cli/cmd/context/remove_test.go b/cli/cmd/context/remove_test.go index 58d72197..5dcb90a8 100644 --- a/cli/cmd/context/remove_test.go +++ b/cli/cmd/context/remove_test.go @@ -50,7 +50,7 @@ func TestRemove_NonCurrent_NoPromptNeeded(t *testing.T) { store := seedStore(t, "staging", "api_key") p := &testutil.ConfirmPrompter{} - if err := runRemove(&RemoveOptions{}, nil, "staging", store, p); err != nil { + if err := runRemove(&RemoveOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, "staging", store, p); err != nil { t.Fatalf("runRemove: %v", err) } if p.Asked { @@ -82,7 +82,7 @@ func TestRemove_NotFound_WithDidYouMean(t *testing.T) { t.Fatalf("Save: %v", err) } - err := runRemove(&RemoveOptions{}, nil, "prodution", secrets.NewMemStore(), &testutil.ConfirmPrompter{}) + err := runRemove(&RemoveOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, "prodution", secrets.NewMemStore(), &testutil.ConfirmPrompter{}) if err == nil { t.Fatal("expected not-found error") } @@ -114,7 +114,7 @@ func TestRemove_Current_NonTTY_NoYes_RequiresConfirmation(t *testing.T) { } store := seedStore(t, "production", "access") - err := runRemove(&RemoveOptions{}, nil, "production", store, &testutil.ConfirmPrompter{}) + err := runRemove(&RemoveOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, "production", store, &testutil.ConfirmPrompter{}) if err == nil { t.Fatal("expected confirmation-required error") } @@ -153,7 +153,7 @@ func TestRemove_Current_WithYes_ClearsCurrent(t *testing.T) { } store := seedStore(t, "production", "access") - if err := runRemove(&RemoveOptions{Yes: true}, nil, "production", store, &testutil.ConfirmPrompter{}); err != nil { + if err := runRemove(&RemoveOptions{Yes: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, "production", store, &testutil.ConfirmPrompter{}); err != nil { t.Fatalf("runRemove: %v", err) } got, _ := config.Load() @@ -182,7 +182,7 @@ func TestRemove_Current_TTY_PromptNo(t *testing.T) { } p := &testutil.ConfirmPrompter{Answer: false} - err := runRemove(&RemoveOptions{}, nil, "production", secrets.NewMemStore(), p) + err := runRemove(&RemoveOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, "production", secrets.NewMemStore(), p) if err == nil { t.Fatal("expected user-aborted error") } diff --git a/cli/cmd/context/use.go b/cli/cmd/context/use.go index 281b188d..e13adc79 100644 --- a/cli/cmd/context/use.go +++ b/cli/cmd/context/use.go @@ -11,7 +11,7 @@ import ( "github.com/Tencent/WeKnora/cli/internal/iostreams" ) -// contextUseFields enumerates fields surfaced for `--json` discovery on +// contextUseFields enumerates fields surfaced for `--format json` discovery on // `context use`. var contextUseFields = []string{"current_context", "previous_context"} @@ -31,17 +31,18 @@ you to. Context selection is a user preference; one-shot overrides should use the global --context flag instead, which writes nothing to disk.`, Example: ` weknora context use staging # persist switch weknora --context staging kb list # one-shot override (no disk write) - weknora context use staging --json # {current_context, previous_context}`, + weknora context use staging --format json # {current_context, previous_context}`, Args: cobra.ExactArgs(1), RunE: func(c *cobra.Command, args []string) error { - jopts, err := cmdutil.CheckJSONFlags(c) + fopts, err := cmdutil.CheckFormatFlag(c) if err != nil { return err } - return runUse(args[0], jopts) + fopts.ResolveDefault(iostreams.IO.IsStdoutTTY()) + return runUse(args[0], fopts) }, } - cmdutil.AddJSONFlags(cmd, contextUseFields) + cmdutil.AddFormatFlag(cmd, contextUseFields...) return cmd } @@ -50,7 +51,7 @@ type useResult struct { PreviousContext string `json:"previous_context,omitempty"` } -func runUse(name string, jopts *cmdutil.JSONOptions) error { +func runUse(name string, fopts *cmdutil.FormatOptions) error { cfg, err := config.Load() if err != nil { return err @@ -64,8 +65,8 @@ func runUse(name string, jopts *cmdutil.JSONOptions) error { return err } result := useResult{CurrentContext: name, PreviousContext: prev} - if jopts.Enabled() { - return jopts.Emit(iostreams.IO.Out, result) + if fopts.WantsJSON() { + return fopts.Emit(iostreams.IO.Out, result) } if prev != "" && prev != name { fmt.Fprintf(iostreams.IO.Out, "✓ Switched context to %s (was %s)\n", name, prev) diff --git a/cli/cmd/context/use_test.go b/cli/cmd/context/use_test.go index 6419d99d..2faebffd 100644 --- a/cli/cmd/context/use_test.go +++ b/cli/cmd/context/use_test.go @@ -24,7 +24,7 @@ func TestUse_OK(t *testing.T) { t.Fatalf("Save initial config: %v", err) } - if err := runUse("production", nil); err != nil { + if err := runUse("production", &cmdutil.FormatOptions{Mode: cmdutil.FormatText}); err != nil { t.Fatalf("runUse: %v", err) } @@ -52,7 +52,7 @@ func TestUse_NotFound_WithDidYouMean(t *testing.T) { t.Fatalf("Save: %v", err) } - err := runUse("prodution", nil) // typo: missing 'c' + err := runUse("prodution", &cmdutil.FormatOptions{Mode: cmdutil.FormatText}) // typo: missing 'c' if err == nil { t.Fatal("expected error") } @@ -84,7 +84,7 @@ func TestUse_NotFound_DeterministicTieBreak(t *testing.T) { } // "prox" is distance 1 from prod / prom (both win); lex tie-break → prod. for i := 0; i < 5; i++ { - err := runUse("prox", nil) + err := runUse("prox", &cmdutil.FormatOptions{Mode: cmdutil.FormatText}) if err == nil { t.Fatalf("iter %d: expected error", i) } @@ -99,7 +99,7 @@ func TestUse_NotFound_EmptyContexts(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) _, _ = iostreams.SetForTest(t) - err := runUse("anything", nil) + err := runUse("anything", &cmdutil.FormatOptions{Mode: cmdutil.FormatText}) if err == nil { t.Fatal("expected error") } @@ -118,7 +118,7 @@ func TestUse_CaseSensitive(t *testing.T) { }} _ = config.Save(cfg) - err := runUse("production", nil) // lowercase - must NOT match "Production" + err := runUse("production", &cmdutil.FormatOptions{Mode: cmdutil.FormatText}) // lowercase - must NOT match "Production" if err == nil { t.Fatal("expected case-sensitive miss") } diff --git a/cli/cmd/doctor/doctor.go b/cli/cmd/doctor/doctor.go index 278d57b6..7f3b302f 100644 --- a/cli/cmd/doctor/doctor.go +++ b/cli/cmd/doctor/doctor.go @@ -36,7 +36,7 @@ import ( sdk "github.com/Tencent/WeKnora/client" ) -// doctorFields enumerates the fields surfaced for `--json` discovery on +// doctorFields enumerates the fields surfaced for `--format json` discovery on // `doctor`. Items here refer to data.checks[*] entries (Check struct). var doctorFields = []string{"name", "status", "details", "hint"} @@ -100,17 +100,18 @@ func NewCmd(f *cmdutil.Factory) *cobra.Command { Short: "Run 4 self-checks: base URL, auth, server version, credential storage", Args: cobra.NoArgs, RunE: func(c *cobra.Command, _ []string) error { - jopts, err := cmdutil.CheckJSONFlags(c) + fopts, err := cmdutil.CheckFormatFlag(c) if err != nil { return err } + fopts.ResolveDefault(iostreams.IO.IsStdoutTTY()) svc, err := buildServices(f) if err != nil { return err } cliVer, _, _ := build.Info() r := runChecks(c.Context(), opts, svc, cliVer) - emit(jopts, r) + emit(fopts, r) // Exit-code policy: fail → exit 1; warn / ok / skip → exit 0. // SilentError suppresses both the human "error: ..." line and // the stderr error formatter, so the JSON already written by @@ -123,7 +124,7 @@ func NewCmd(f *cmdutil.Factory) *cobra.Command { } cmd.Flags().BoolVar(&opts.NoCache, "no-cache", false, "Bypass server-info cache (located at $XDG_CACHE_HOME/weknora/server-info.yaml); force re-probe") cmd.Flags().BoolVar(&opts.Offline, "offline", false, "Skip network checks; only verify local keyring/file storage (credential_storage check still runs)") - cmdutil.AddJSONFlags(cmd, doctorFields) + cmdutil.AddFormatFlag(cmd, doctorFields...) return cmd } @@ -331,9 +332,9 @@ func summarize(cs []Check) Summary { // emit renders the doctor result. The JSON path emits the Result directly; // pass/fail signaling is conveyed by summary.failed (and the process exit // code, set by the caller). -func emit(jopts *cmdutil.JSONOptions, r Result) { - if jopts.Enabled() { - _ = jopts.Emit(iostreams.IO.Out, r) +func emit(fopts *cmdutil.FormatOptions, r Result) { + if fopts.WantsJSON() { + _ = fopts.Emit(iostreams.IO.Out, r) return } for _, c := range r.Checks { @@ -357,7 +358,7 @@ func emit(jopts *cmdutil.JSONOptions, r Result) { // // Agent / JSON consumers read the stable status string from // data.checks[].status; the glyphs are presentation-only and never appear -// in --json output. +// in --format json output. func marker(s Status) string { switch s { case StatusFail: diff --git a/cli/cmd/doctor/doctor_test.go b/cli/cmd/doctor/doctor_test.go index 7c5d3bdf..4d88c581 100644 --- a/cli/cmd/doctor/doctor_test.go +++ b/cli/cmd/doctor/doctor_test.go @@ -77,7 +77,7 @@ func TestDoctor_AllOK(t *testing.T) { if r.Summary.Failed != 0 || r.Summary.Skipped != 0 { t.Errorf("expected 0 fail / 0 skip, got %+v", r.Summary) } - emit(&cmdutil.JSONOptions{}, r) + emit(&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, r) if !strings.Contains(out.String(), `"all_passed":true`) { t.Errorf("bare output should embed all_passed=true, got %q", out.String()) } @@ -282,7 +282,7 @@ func TestDoctor_VersionSkewWarns(t *testing.T) { // Wire shape: warn-only run still has summary.failed=0 (bare data // carries the signal; exit code stays 0). out, _ := iostreams.SetForTest(t) - emit(&cmdutil.JSONOptions{}, r) + emit(&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, r) body := out.String() if strings.Contains(body, `"ok":`) { t.Errorf("bare doctor output must not carry envelope keys, got %q", body) @@ -387,7 +387,7 @@ func TestDoctor_BareJSON_WarnDoesNotSignalFail(t *testing.T) { {Name: "credential_storage", Status: StatusOK}, }, } - emit(&cmdutil.JSONOptions{}, r) + emit(&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, r) got := out.String() if !strings.Contains(got, `"failed":0`) { t.Errorf("warn-only result must have summary.failed=0 (exit-0 signal), got %q", got) @@ -410,7 +410,7 @@ func TestDoctor_BareJSON_FailRaisesSummary(t *testing.T) { {Name: "credential_storage", Status: StatusOK}, }, } - emit(&cmdutil.JSONOptions{}, r) + emit(&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, r) got := out.String() if !strings.Contains(got, `"failed":1`) { t.Errorf("fail must surface in summary.failed, got %q", got) @@ -428,7 +428,7 @@ func TestDoctor_HumanMarker_Warn(t *testing.T) { {Name: "server_version", Status: StatusWarn, Details: "older"}, }, } - emit(nil, r) + emit(&cmdutil.FormatOptions{Mode: cmdutil.FormatText}, r) got := out.String() if !strings.Contains(got, "⚠") { t.Errorf("human output should contain ⚠ glyph for warn, got %q", got) @@ -453,7 +453,7 @@ func TestDoctor_WarnedField_OmittedAtZero(t *testing.T) { {Name: "credential_storage", Status: StatusOK}, }, } - emit(&cmdutil.JSONOptions{}, r) + emit(&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, r) got := out.String() if strings.Contains(got, `"warned"`) { t.Errorf("output should omit `warned` field when zero, got %q", got) @@ -479,7 +479,7 @@ func TestDoctor_RunE_FailReturnsSilentError(t *testing.T) { cmd := NewCmd(f) cmd.SilenceErrors = true cmd.SilenceUsage = true - cmd.SetArgs([]string{"--json"}) + cmd.SetArgs([]string{"--format", "json"}) cmd.SetContext(context.Background()) err := cmd.Execute() if !errors.Is(err, cmdutil.SilentError) { diff --git a/cli/cmd/kb/check.go b/cli/cmd/kb/check.go new file mode 100644 index 00000000..ed09f1b2 --- /dev/null +++ b/cli/cmd/kb/check.go @@ -0,0 +1,149 @@ +package kb + +import ( + "context" + "fmt" + "io" + + "github.com/spf13/cobra" + + "github.com/Tencent/WeKnora/cli/internal/cmdutil" + "github.com/Tencent/WeKnora/cli/internal/iostreams" + sdk "github.com/Tencent/WeKnora/client" +) + +// CheckResult is the deep-verification response for `kb check `. +// Superset of StatusResult: includes all status fields plus FailedCount +// aggregated by paging the doc list. Verb split with `kb status`: +// status reads existing state cheaply, check actively verifies. +type CheckResult struct { + ID string `json:"id"` + Reachable bool `json:"reachable"` + KnowledgeCount int64 `json:"knowledge_count,omitempty"` + ChunkCount int64 `json:"chunk_count,omitempty"` + IsProcessing bool `json:"is_processing,omitempty"` + ProcessingCount int64 `json:"processing_count,omitempty"` + FailedCount int64 `json:"failed_count"` // always populated (no omitempty) +} + +// CheckService is the narrow SDK surface needed for kb check. +type CheckService interface { + GetKnowledgeBase(ctx context.Context, id string) (*sdk.KnowledgeBase, error) + ListKnowledgeWithFilter(ctx context.Context, kbID string, page, pageSize int, filter sdk.KnowledgeListFilter) ([]sdk.Knowledge, int64, error) +} + +var kbCheckFields = []string{ + "id", "reachable", "knowledge_count", "chunk_count", + "is_processing", "processing_count", "failed_count", +} + +// NewCmdCheck builds `weknora kb check `. +func NewCmdCheck(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "check ", + Short: "Verify a knowledge base end-to-end (status + failed-doc aggregation)", + Long: `Active verification of a knowledge base. + +Performs 1 + N HTTP calls: + 1 GET /kb/{id} — reachable + counts + processing state + N page-walk doc list with parse_status=failed — failed_count + +Use 'weknora kb status ' for a fast read-only health snapshot +(1 HTTP call, no failed_count). Use 'kb check' when you need +verification including failed-doc aggregation.`, + Example: ` weknora kb check kb_abc + weknora kb check kb_abc --format json`, + Args: cobra.ExactArgs(1), + RunE: func(c *cobra.Command, args []string) error { + fopts, err := cmdutil.CheckFormatFlag(c) + if err != nil { + return err + } + fopts.ResolveDefault(iostreams.IO.IsStdoutTTY()) + cli, err := f.Client() + if err != nil { + return err + } + res, err := runCheck(c.Context(), cli, args[0]) + if err != nil { + return err + } + return emitCheck(res, fopts, iostreams.IO.Out) + }, + } + cmdutil.AddFormatFlag(cmd, kbCheckFields...) + return cmd +} + +// runCheck is the testable core: status fields + failed-doc aggregation. +// Never returns an error for "kb not reachable" (Reachable=false carries +// the signal). +func runCheck(ctx context.Context, svc CheckService, id string) (*CheckResult, error) { + kb, err := svc.GetKnowledgeBase(ctx, id) + if err != nil { + return &CheckResult{ID: id, Reachable: false}, nil + } + res := &CheckResult{ + ID: kb.ID, + Reachable: true, + KnowledgeCount: kb.KnowledgeCount, + ChunkCount: kb.ChunkCount, + IsProcessing: kb.IsProcessing, + ProcessingCount: kb.ProcessingCount, + } + failed, err := aggregateFailedCount(ctx, svc, id) + if err != nil { + return res, fmt.Errorf("aggregate failed_count: %w", err) + } + res.FailedCount = failed + return res, nil +} + +// aggregateFailedCount pages the doc list with parse_status=failed and +// returns the total count. Termination uses accumulated total (not +// page*pageSize) so server-capped page sizes don't truncate. +func aggregateFailedCount(ctx context.Context, svc CheckService, kbID string) (int64, error) { + const pageSize = 100 + var total int64 + page := 1 + for { + docs, totalAvailable, err := svc.ListKnowledgeWithFilter(ctx, kbID, page, pageSize, sdk.KnowledgeListFilter{ParseStatus: "failed"}) + if err != nil { + return total, err + } + total += int64(len(docs)) + if total >= totalAvailable || len(docs) == 0 { + break + } + page++ + } + return total, nil +} + +// emitCheck renders res. Same dispatch as emitStatus. +func emitCheck(res *CheckResult, fopts *cmdutil.FormatOptions, w io.Writer) error { + switch fopts.Mode { + case cmdutil.FormatJSON, cmdutil.FormatNDJSON: + return fopts.Emit(w, res) + case cmdutil.FormatText, "": + return writeCheckText(w, res) + default: + return fmt.Errorf("unsupported --format %q for kb check", fopts.Mode) + } +} + +func writeCheckText(w io.Writer, res *CheckResult) error { + fmt.Fprintf(w, "ID: %s\n", res.ID) + fmt.Fprintf(w, "Reachable: %v\n", res.Reachable) + if !res.Reachable { + return nil + } + fmt.Fprintf(w, "Knowledge: %d\n", res.KnowledgeCount) + fmt.Fprintf(w, "Chunks: %d\n", res.ChunkCount) + fmt.Fprintf(w, "Processing: %v (%d active)\n", res.IsProcessing, res.ProcessingCount) + fmt.Fprintf(w, "Failed: %d\n", res.FailedCount) + return nil +} + +// compile-time check: SDK client satisfies CheckService. +var _ CheckService = (*sdk.Client)(nil) diff --git a/cli/cmd/kb/check_test.go b/cli/cmd/kb/check_test.go new file mode 100644 index 00000000..abda73a5 --- /dev/null +++ b/cli/cmd/kb/check_test.go @@ -0,0 +1,128 @@ +package kb + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/Tencent/WeKnora/cli/internal/cmdutil" + sdk "github.com/Tencent/WeKnora/client" +) + +type fakeCheckSvc struct { + kb *sdk.KnowledgeBase + getErr error + failedDocs []sdk.Knowledge // returned by ListKnowledgeWithFilter when ParseStatus=failed + listErr error +} + +func (f *fakeCheckSvc) GetKnowledgeBase(_ context.Context, id string) (*sdk.KnowledgeBase, error) { + if f.getErr != nil { + return nil, f.getErr + } + return f.kb, nil +} + +func (f *fakeCheckSvc) ListKnowledgeWithFilter(_ context.Context, _ string, page, pageSize int, _ sdk.KnowledgeListFilter) ([]sdk.Knowledge, int64, error) { + if f.listErr != nil { + return nil, 0, f.listErr + } + // Single-page fake: return all docs at once (page 1), empty thereafter. + if page == 1 { + return f.failedDocs, int64(len(f.failedDocs)), nil + } + return nil, int64(len(f.failedDocs)), nil +} + +func TestRunCheck_AggregatesFailed(t *testing.T) { + svc := &fakeCheckSvc{ + kb: &sdk.KnowledgeBase{ID: "kb_x", KnowledgeCount: 5, ChunkCount: 20}, + failedDocs: []sdk.Knowledge{ + {ID: "d1", ParseStatus: "failed"}, + {ID: "d2", ParseStatus: "failed"}, + }, + } + res, err := runCheck(context.Background(), svc, "kb_x") + if err != nil { + t.Fatalf("runCheck: %v", err) + } + if res.FailedCount != 2 { + t.Errorf("FailedCount=%d, want 2", res.FailedCount) + } + if !res.Reachable { + t.Error("Reachable=false, want true") + } + if res.KnowledgeCount != 5 || res.ChunkCount != 20 { + t.Errorf("got %+v", res) + } +} + +func TestRunCheck_Unreachable(t *testing.T) { + svc := &fakeCheckSvc{getErr: fmt.Errorf("404 not found")} + res, err := runCheck(context.Background(), svc, "kb_x") + if err != nil { + t.Fatalf("runCheck should not return err on unreachable; got %v", err) + } + if res.Reachable { + t.Error("Reachable=true, want false") + } + if res.ID != "kb_x" { + t.Errorf("ID=%q, want kb_x (echoed even when unreachable)", res.ID) + } +} + +func TestRunCheck_NoFailedDocs(t *testing.T) { + svc := &fakeCheckSvc{ + kb: &sdk.KnowledgeBase{ID: "kb_y", KnowledgeCount: 10}, + failedDocs: nil, + } + res, err := runCheck(context.Background(), svc, "kb_y") + if err != nil { + t.Fatalf("runCheck: %v", err) + } + if res.FailedCount != 0 { + t.Errorf("FailedCount=%d, want 0", res.FailedCount) + } +} + +func TestEmitCheck_JSON(t *testing.T) { + var buf bytes.Buffer + res := &CheckResult{ID: "kb_x", Reachable: true, KnowledgeCount: 5, FailedCount: 3} + fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON} + if err := emitCheck(res, fopts, &buf); err != nil { + t.Fatalf("emitCheck: %v", err) + } + var got CheckResult + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("not JSON: %v\n%s", err, buf.String()) + } + if got.ID != "kb_x" || got.KnowledgeCount != 5 || got.FailedCount != 3 { + t.Errorf("got %+v", got) + } +} + +func TestEmitCheck_TextHuman(t *testing.T) { + var buf bytes.Buffer + res := &CheckResult{ + ID: "kb_x", Reachable: true, + KnowledgeCount: 5, ChunkCount: 20, + IsProcessing: true, ProcessingCount: 1, + FailedCount: 2, + } + fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatText} + if err := emitCheck(res, fopts, &buf); err != nil { + t.Fatalf("emitCheck: %v", err) + } + for _, want := range []string{"kb_x", "5", "20", "true", "2"} { + if !strings.Contains(buf.String(), want) { + t.Errorf("output missing %q:\n%s", want, buf.String()) + } + } + // "Failed:" line must always appear for check + if !strings.Contains(buf.String(), "Failed:") { + t.Errorf("output missing 'Failed:' line:\n%s", buf.String()) + } +} diff --git a/cli/cmd/kb/delete.go b/cli/cmd/kb/delete.go index d439c79b..19c02ad6 100644 --- a/cli/cmd/kb/delete.go +++ b/cli/cmd/kb/delete.go @@ -11,8 +11,8 @@ import ( "github.com/Tencent/WeKnora/cli/internal/prompt" ) -// kbDeleteFields enumerates the fields surfaced for `--json` discovery on -// `kb delete`. The result payload is a small {id, deleted} object. +// kbDeleteFields enumerates the fields surfaced for `--format json` discovery +// on `kb delete`. The result payload is a small {id, deleted} object. var kbDeleteFields = []string{"id", "deleted"} type DeleteOptions struct { @@ -37,40 +37,41 @@ type deleteResult struct { func NewCmdDelete(f *cmdutil.Factory) *cobra.Command { opts := &DeleteOptions{} cmd := &cobra.Command{ - Use: "delete ", + Use: "delete ", Short: "Delete a knowledge base", Long: `Permanently deletes a knowledge base and all its contents. -Prompts for confirmation by default when stdout is a TTY and --json is not set. +Prompts for confirmation by default when stdout is a TTY and JSON output is not set. Pass -y/--yes (global flag) to skip the prompt (required in agent / CI / piped contexts). AI agents: This is a high-risk write. Without -y/--yes the CLI exits 10 and writes input.confirmation_required to stderr. NEVER auto-pass -y -without the user's explicit go-ahead - the exit-10 protocol exists +without the user's explicit go-ahead — the exit-10 protocol exists exactly to guard against unintended deletes.`, - Example: ` weknora kb delete kb_abc # interactive confirm - weknora kb delete kb_abc -y # no prompt - weknora kb delete kb_abc -y --json # bare {id, deleted:true} JSON`, + Example: ` weknora kb delete kb_abc # interactive confirm + weknora kb delete kb_abc -y # no prompt + weknora kb delete kb_abc -y --format json # bare {id, deleted:true} JSON`, Args: cobra.ExactArgs(1), RunE: func(c *cobra.Command, args []string) error { - jopts, err := cmdutil.CheckJSONFlags(c) + fopts, err := cmdutil.CheckFormatFlag(c) if err != nil { return err } + fopts.ResolveDefault(iostreams.IO.IsStdoutTTY()) opts.Yes, _ = c.Flags().GetBool("yes") cli, err := f.Client() if err != nil { return err } - return runDelete(c.Context(), opts, jopts, cli, f.Prompter(), args[0]) + return runDelete(c.Context(), opts, fopts, cli, f.Prompter(), args[0]) }, } - cmdutil.AddJSONFlags(cmd, kbDeleteFields) + cmdutil.AddFormatFlag(cmd, kbDeleteFields...) return cmd } -func runDelete(ctx context.Context, opts *DeleteOptions, jopts *cmdutil.JSONOptions, svc DeleteService, p prompt.Prompter, id string) error { - if err := cmdutil.ConfirmDestructive(p, opts.Yes, jopts.Enabled(), "knowledge base", id); err != nil { +func runDelete(ctx context.Context, opts *DeleteOptions, fopts *cmdutil.FormatOptions, svc DeleteService, p prompt.Prompter, id string) error { + if err := cmdutil.ConfirmDestructive(p, opts.Yes, fopts.WantsJSON(), "knowledge base", id); err != nil { return err } @@ -78,8 +79,8 @@ func runDelete(ctx context.Context, opts *DeleteOptions, jopts *cmdutil.JSONOpti return cmdutil.WrapHTTP(err, "delete knowledge base %s", id) } - if jopts.Enabled() { - return jopts.Emit(iostreams.IO.Out, deleteResult{ID: id, Deleted: true}) + if fopts.WantsJSON() { + return fopts.Emit(iostreams.IO.Out, deleteResult{ID: id, Deleted: true}) } fmt.Fprintf(iostreams.IO.Out, "✓ Deleted knowledge base %s\n", id) return nil diff --git a/cli/cmd/kb/delete_test.go b/cli/cmd/kb/delete_test.go index 60f41926..58b08990 100644 --- a/cli/cmd/kb/delete_test.go +++ b/cli/cmd/kb/delete_test.go @@ -33,7 +33,7 @@ func TestDelete_Success_WithForce(t *testing.T) { svc := &fakeDeleteSvc{} p := &testutil.ConfirmPrompter{} opts := &DeleteOptions{Yes: true} - require.NoError(t, runDelete(context.Background(), opts, nil, svc, p, "kb_force")) + require.NoError(t, runDelete(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, p, "kb_force")) assert.True(t, svc.called) assert.Equal(t, "kb_force", svc.gotID) @@ -46,7 +46,7 @@ func TestDelete_NotFound(t *testing.T) { _, _ = iostreams.SetForTest(t) svc := &fakeDeleteSvc{err: errors.New("HTTP error 404: not found")} p := &testutil.ConfirmPrompter{} - err := runDelete(context.Background(), &DeleteOptions{Yes: true}, nil, svc, p, "kb_missing") + err := runDelete(context.Background(), &DeleteOptions{Yes: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, p, "kb_missing") require.Error(t, err) var typed *cmdutil.Error @@ -61,7 +61,7 @@ func TestDelete_NonTTY_NoYes_RequiresConfirmation(t *testing.T) { iostreams.SetForTest(t) svc := &fakeDeleteSvc{} p := &testutil.ConfirmPrompter{} - err := runDelete(context.Background(), &DeleteOptions{}, nil, svc, p, "kb_nontty") + err := runDelete(context.Background(), &DeleteOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, p, "kb_nontty") require.Error(t, err) var typed *cmdutil.Error @@ -77,7 +77,7 @@ func TestDelete_JSONOutput(t *testing.T) { svc := &fakeDeleteSvc{} p := &testutil.ConfirmPrompter{} opts := &DeleteOptions{Yes: true} - require.NoError(t, runDelete(context.Background(), opts, &cmdutil.JSONOptions{}, svc, p, "kb_json")) + require.NoError(t, runDelete(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, p, "kb_json")) got := out.String() assert.True(t, strings.HasPrefix(strings.TrimSpace(got), `{"id":"kb_json"`), "expected bare object; got %q", got) @@ -92,7 +92,7 @@ func TestDelete_ConfirmYes(t *testing.T) { _, _ = iostreams.SetForTestWithTTY(t) svc := &fakeDeleteSvc{} p := &testutil.ConfirmPrompter{Answer: true} - require.NoError(t, runDelete(context.Background(), &DeleteOptions{}, nil, svc, p, "kb_yes")) + require.NoError(t, runDelete(context.Background(), &DeleteOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, p, "kb_yes")) assert.True(t, p.Asked, "confirm prompt should fire on TTY without --force") assert.True(t, svc.called, "answer=yes ⇒ delete proceeds") @@ -103,7 +103,7 @@ func TestDelete_ConfirmNo(t *testing.T) { _, errBuf := iostreams.SetForTestWithTTY(t) svc := &fakeDeleteSvc{} p := &testutil.ConfirmPrompter{Answer: false} - err := runDelete(context.Background(), &DeleteOptions{}, nil, svc, p, "kb_no") + err := runDelete(context.Background(), &DeleteOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, p, "kb_no") require.Error(t, err) var typed *cmdutil.Error @@ -118,7 +118,7 @@ func TestDelete_ConfirmPrompterError(t *testing.T) { _, _ = iostreams.SetForTestWithTTY(t) svc := &fakeDeleteSvc{} p := &testutil.ConfirmPrompter{Err: prompt.ErrAgentNoPrompt} - err := runDelete(context.Background(), &DeleteOptions{}, nil, svc, p, "kb_err") + err := runDelete(context.Background(), &DeleteOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, p, "kb_err") require.Error(t, err) var typed *cmdutil.Error @@ -129,31 +129,31 @@ func TestDelete_ConfirmPrompterError(t *testing.T) { } func TestDelete_JSONOut_NoYes_RequiresConfirmation(t *testing.T) { - // Even on a TTY, --json indicates a scripted caller; cannot prompt. + // Even on a TTY, --format json indicates a scripted caller; cannot prompt. // Exit-10 protocol must fire when -y is absent. iostreams.SetForTestWithTTY(t) svc := &fakeDeleteSvc{} p := &testutil.ConfirmPrompter{} opts := &DeleteOptions{} - err := runDelete(context.Background(), opts, &cmdutil.JSONOptions{}, svc, p, "kb_jtty") + err := runDelete(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, p, "kb_jtty") require.Error(t, err) var typed *cmdutil.Error require.ErrorAs(t, err, &typed) assert.Equal(t, cmdutil.CodeInputConfirmationRequired, typed.Code) - assert.False(t, p.Asked, "--json must skip the prompt even on TTY") - assert.False(t, svc.called, "--json without -y must not call DeleteKnowledgeBase") + assert.False(t, p.Asked, "--format json must skip the prompt even on TTY") + assert.False(t, svc.called, "--format json without -y must not call DeleteKnowledgeBase") assert.Equal(t, 10, cmdutil.ExitCode(err)) } func TestDelete_JSONOut_WithYes_Proceeds(t *testing.T) { - // --json + -y is the agent happy-path: scripted caller with explicit + // --format json + -y is the agent happy-path: scripted caller with explicit // approval. Must call SDK and emit the bare result object. out, _ := iostreams.SetForTestWithTTY(t) svc := &fakeDeleteSvc{} p := &testutil.ConfirmPrompter{} opts := &DeleteOptions{Yes: true} - require.NoError(t, runDelete(context.Background(), opts, &cmdutil.JSONOptions{}, svc, p, "kb_jtty")) + require.NoError(t, runDelete(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, p, "kb_jtty")) assert.False(t, p.Asked, "-y must skip the prompt") assert.True(t, svc.called) diff --git a/cli/cmd/kb/edit.go b/cli/cmd/kb/edit.go index b5faf80f..34700663 100644 --- a/cli/cmd/kb/edit.go +++ b/cli/cmd/kb/edit.go @@ -11,7 +11,7 @@ import ( sdk "github.com/Tencent/WeKnora/client" ) -// kbEditFields enumerates the fields surfaced for `--json` discovery on +// kbEditFields enumerates the fields surfaced for `--format json` discovery on // `kb edit`. The result is the updated KnowledgeBase; mirrors the kb // top-level json tags. var kbEditFields = []string{ @@ -49,17 +49,18 @@ func NewCmdEdit(f *cmdutil.Factory) *cobra.Command { opts := &EditOptions{} var name, desc string cmd := &cobra.Command{ - Use: "edit ", + Use: "edit ", Short: "Edit a knowledge base's name or description", Long: `Update a knowledge base's name and/or description. At least one of --name / --description must be supplied; fields you omit are preserved server-side.`, Args: cobra.ExactArgs(1), RunE: func(c *cobra.Command, args []string) error { - jopts, err := cmdutil.CheckJSONFlags(c) + fopts, err := cmdutil.CheckFormatFlag(c) if err != nil { return err } + fopts.ResolveDefault(iostreams.IO.IsStdoutTTY()) if c.Flag("name").Changed { opts.Name = &name } @@ -70,16 +71,16 @@ server-side.`, if err != nil { return err } - return runEdit(c.Context(), opts, jopts, cli, args[0]) + return runEdit(c.Context(), opts, fopts, cli, args[0]) }, } cmd.Flags().StringVar(&name, "name", "", "New name (omit to leave unchanged)") cmd.Flags().StringVar(&desc, "description", "", "New description (omit to leave unchanged)") - cmdutil.AddJSONFlags(cmd, kbEditFields) + cmdutil.AddFormatFlag(cmd, kbEditFields...) return cmd } -func runEdit(ctx context.Context, opts *EditOptions, jopts *cmdutil.JSONOptions, svc EditService, id string) error { +func runEdit(ctx context.Context, opts *EditOptions, fopts *cmdutil.FormatOptions, svc EditService, id string) error { if opts.Name == nil && opts.Description == nil { return &cmdutil.Error{ Code: cmdutil.CodeInputMissingFlag, @@ -110,8 +111,8 @@ func runEdit(ctx context.Context, opts *EditOptions, jopts *cmdutil.JSONOptions, if err != nil { return cmdutil.WrapHTTP(err, "edit knowledge base %s", id) } - if jopts.Enabled() { - return jopts.Emit(iostreams.IO.Out, updated) + if fopts.WantsJSON() { + return fopts.Emit(iostreams.IO.Out, updated) } fmt.Fprintf(iostreams.IO.Out, "✓ Updated knowledge base %s\n", id) return nil diff --git a/cli/cmd/kb/edit_test.go b/cli/cmd/kb/edit_test.go index 36f0371f..7f4e8baf 100644 --- a/cli/cmd/kb/edit_test.go +++ b/cli/cmd/kb/edit_test.go @@ -43,7 +43,7 @@ func (f *fakeEditSvc) UpdateKnowledgeBase(_ context.Context, id string, req *sdk func TestEdit_RequiresAtLeastOneFlag(t *testing.T) { _, _ = iostreams.SetForTest(t) svc := &fakeEditSvc{} - err := runEdit(context.Background(), &EditOptions{}, nil, svc, "kb_abc") + err := runEdit(context.Background(), &EditOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_abc") require.Error(t, err) var typed *cmdutil.Error require.ErrorAs(t, err, &typed) @@ -64,7 +64,7 @@ func TestEdit_OnlyName_PreservesCurrentDescription(t *testing.T) { } opts := &EditOptions{} opts.Name = stringPtr("new") - require.NoError(t, runEdit(context.Background(), opts, nil, svc, "kb_abc")) + require.NoError(t, runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_abc")) assert.Equal(t, "kb_abc", svc.gotID) require.NotNil(t, svc.gotReq) @@ -83,7 +83,7 @@ func TestEdit_OnlyDescription_PreservesCurrentName(t *testing.T) { } opts := &EditOptions{} opts.Description = stringPtr("new desc") - require.NoError(t, runEdit(context.Background(), opts, nil, svc, "kb_abc")) + require.NoError(t, runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_abc")) require.NotNil(t, svc.gotReq) assert.Equal(t, "new desc", svc.gotReq.Description) @@ -96,7 +96,7 @@ func TestEdit_BothFlags(t *testing.T) { opts := &EditOptions{} opts.Name = stringPtr("renamed") opts.Description = stringPtr("new desc") - require.NoError(t, runEdit(context.Background(), opts, nil, svc, "kb_abc")) + require.NoError(t, runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_abc")) assert.Equal(t, "renamed", svc.gotReq.Name) assert.Equal(t, "new desc", svc.gotReq.Description) } @@ -108,7 +108,7 @@ func TestEdit_NotFound(t *testing.T) { svc := &fakeEditSvc{currentErr: errors.New("HTTP error 404: not found")} opts := &EditOptions{} opts.Name = stringPtr("x") - err := runEdit(context.Background(), opts, nil, svc, "kb_missing") + err := runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_missing") require.Error(t, err) var typed *cmdutil.Error require.ErrorAs(t, err, &typed) diff --git a/cli/cmd/kb/empty.go b/cli/cmd/kb/empty.go index dbad63bd..94b3ac3f 100644 --- a/cli/cmd/kb/empty.go +++ b/cli/cmd/kb/empty.go @@ -12,7 +12,7 @@ import ( sdk "github.com/Tencent/WeKnora/client" ) -// kbEmptyFields enumerates the fields surfaced for `--json` discovery on +// kbEmptyFields enumerates the fields surfaced for `--format json` discovery on // `kb empty`. The result payload is {id, deleted_count}. var kbEmptyFields = []string{"id", "deleted_count"} @@ -37,7 +37,7 @@ type emptyResult struct { func NewCmdEmpty(f *cmdutil.Factory) *cobra.Command { opts := &EmptyOptions{} cmd := &cobra.Command{ - Use: "empty ", + Use: "empty ", Short: "Delete every document in a knowledge base (preserves the KB)", Long: `Removes all documents and chunks from a knowledge base while keeping the KB record (its name, description, and config) intact. The delete is async; @@ -46,27 +46,28 @@ the server reports the count of items enqueued for removal. Prompts for confirmation by default; pass -y/--yes to skip in agent / CI / piped contexts. Without -y the CLI exits 10 in non-interactive mode.`, Example: ` weknora kb empty kb_abc # interactive confirm - weknora kb empty kb_abc -y --json # agent-friendly`, + weknora kb empty kb_abc -y --format json # agent-friendly`, Args: cobra.ExactArgs(1), RunE: func(c *cobra.Command, args []string) error { - jopts, err := cmdutil.CheckJSONFlags(c) + fopts, err := cmdutil.CheckFormatFlag(c) if err != nil { return err } + fopts.ResolveDefault(iostreams.IO.IsStdoutTTY()) opts.Yes, _ = c.Flags().GetBool("yes") cli, err := f.Client() if err != nil { return err } - return runEmpty(c.Context(), opts, jopts, cli, f.Prompter(), args[0]) + return runEmpty(c.Context(), opts, fopts, cli, f.Prompter(), args[0]) }, } - cmdutil.AddJSONFlags(cmd, kbEmptyFields) + cmdutil.AddFormatFlag(cmd, kbEmptyFields...) return cmd } -func runEmpty(ctx context.Context, opts *EmptyOptions, jopts *cmdutil.JSONOptions, svc EmptyService, p prompt.Prompter, id string) error { - if err := cmdutil.ConfirmDestructive(p, opts.Yes, jopts.Enabled(), "all contents of knowledge base", id); err != nil { +func runEmpty(ctx context.Context, opts *EmptyOptions, fopts *cmdutil.FormatOptions, svc EmptyService, p prompt.Prompter, id string) error { + if err := cmdutil.ConfirmDestructive(p, opts.Yes, fopts.WantsJSON(), "all contents of knowledge base", id); err != nil { return err } @@ -79,8 +80,8 @@ func runEmpty(ctx context.Context, opts *EmptyOptions, jopts *cmdutil.JSONOption deleted = resp.DeletedCount } - if jopts.Enabled() { - return jopts.Emit(iostreams.IO.Out, emptyResult{ID: id, DeletedCount: deleted}) + if fopts.WantsJSON() { + return fopts.Emit(iostreams.IO.Out, emptyResult{ID: id, DeletedCount: deleted}) } fmt.Fprintf(iostreams.IO.Out, "✓ Emptied knowledge base %s (%d document(s) cleared)\n", id, deleted) return nil diff --git a/cli/cmd/kb/empty_test.go b/cli/cmd/kb/empty_test.go index 8c944aff..ae40c15a 100644 --- a/cli/cmd/kb/empty_test.go +++ b/cli/cmd/kb/empty_test.go @@ -37,7 +37,7 @@ func (f *fakeEmptySvc) ClearKnowledgeBaseContents(_ context.Context, id string) func TestEmpty_WithYes(t *testing.T) { out, _ := iostreams.SetForTest(t) svc := &fakeEmptySvc{resp: &sdk.ClearKnowledgeBaseContentsResponse{DeletedCount: 42}} - require.NoError(t, runEmpty(context.Background(), &EmptyOptions{Yes: true}, nil, svc, &testutil.ConfirmPrompter{}, "kb_abc")) + require.NoError(t, runEmpty(context.Background(), &EmptyOptions{Yes: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{}, "kb_abc")) assert.True(t, svc.called) assert.Equal(t, "kb_abc", svc.gotID) body := out.String() @@ -48,7 +48,7 @@ func TestEmpty_WithYes(t *testing.T) { func TestEmpty_NonTTY_NoYes_RequiresConfirmation(t *testing.T) { iostreams.SetForTest(t) svc := &fakeEmptySvc{} - err := runEmpty(context.Background(), &EmptyOptions{}, nil, svc, &testutil.ConfirmPrompter{}, "kb_abc") + err := runEmpty(context.Background(), &EmptyOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{}, "kb_abc") require.Error(t, err) var typed *cmdutil.Error require.ErrorAs(t, err, &typed) @@ -61,7 +61,7 @@ func TestEmpty_TTY_ConfirmNo(t *testing.T) { _, errBuf := iostreams.SetForTestWithTTY(t) svc := &fakeEmptySvc{} p := &testutil.ConfirmPrompter{Answer: false} - err := runEmpty(context.Background(), &EmptyOptions{}, nil, svc, p, "kb_abc") + err := runEmpty(context.Background(), &EmptyOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, p, "kb_abc") require.Error(t, err) var typed *cmdutil.Error require.ErrorAs(t, err, &typed) @@ -73,7 +73,7 @@ func TestEmpty_TTY_ConfirmNo(t *testing.T) { func TestEmpty_NotFound(t *testing.T) { _, _ = iostreams.SetForTest(t) svc := &fakeEmptySvc{err: errors.New("HTTP error 404: not found")} - err := runEmpty(context.Background(), &EmptyOptions{Yes: true}, nil, svc, &testutil.ConfirmPrompter{}, "kb_missing") + err := runEmpty(context.Background(), &EmptyOptions{Yes: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{}, "kb_missing") require.Error(t, err) var typed *cmdutil.Error require.ErrorAs(t, err, &typed) diff --git a/cli/cmd/kb/kb.go b/cli/cmd/kb/kb.go index caa6c589..0366c670 100644 --- a/cli/cmd/kb/kb.go +++ b/cli/cmd/kb/kb.go @@ -25,5 +25,7 @@ func NewCmd(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(NewCmdPin(f)) cmd.AddCommand(NewCmdUnpin(f)) cmd.AddCommand(NewCmdEmpty(f)) + cmd.AddCommand(NewCmdStatus(f)) + cmd.AddCommand(NewCmdCheck(f)) return cmd } diff --git a/cli/cmd/kb/pin.go b/cli/cmd/kb/pin.go index f6eea525..95ecc39f 100644 --- a/cli/cmd/kb/pin.go +++ b/cli/cmd/kb/pin.go @@ -11,7 +11,7 @@ import ( sdk "github.com/Tencent/WeKnora/client" ) -// kbPinFields enumerates the fields surfaced for `--json` discovery on +// kbPinFields enumerates the fields surfaced for `--format json` discovery on // `kb pin` / `kb unpin`. The toggle result is the KnowledgeBase; the user- // relevant fields here are the id and the new pin state. var kbPinFields = []string{"id", "is_pinned"} @@ -39,27 +39,28 @@ func NewCmdUnpin(f *cmdutil.Factory) *cobra.Command { func newPinCmd(f *cmdutil.Factory, use string, want bool, short string) *cobra.Command { opts := &PinOptions{} cmd := &cobra.Command{ - Use: use + " ", + Use: use + " ", Short: short, Long: short + ". Idempotent: reads the current pin state and toggles only if different, so re-running on a KB already in the target state is a no-op.", Args: cobra.ExactArgs(1), RunE: func(c *cobra.Command, args []string) error { - jopts, err := cmdutil.CheckJSONFlags(c) + fopts, err := cmdutil.CheckFormatFlag(c) if err != nil { return err } + fopts.ResolveDefault(iostreams.IO.IsStdoutTTY()) cli, err := f.Client() if err != nil { return err } - return runPin(c.Context(), opts, jopts, cli, args[0], want) + return runPin(c.Context(), opts, fopts, cli, args[0], want) }, } - cmdutil.AddJSONFlags(cmd, kbPinFields) + cmdutil.AddFormatFlag(cmd, kbPinFields...) return cmd } -func runPin(ctx context.Context, opts *PinOptions, jopts *cmdutil.JSONOptions, svc PinService, id string, want bool) error { +func runPin(ctx context.Context, opts *PinOptions, fopts *cmdutil.FormatOptions, svc PinService, id string, want bool) error { verb := "pin" if !want { verb = "unpin" @@ -77,8 +78,8 @@ func runPin(ctx context.Context, opts *PinOptions, jopts *cmdutil.JSONOptions, s // emit the current resource so callers see the canonical shape on // both fresh-toggle and no-op paths. Human path prints a confirming // line; agents observe via the unchanged is_pinned field. - if jopts.Enabled() { - return jopts.Emit(iostreams.IO.Out, current) + if fopts.WantsJSON() { + return fopts.Emit(iostreams.IO.Out, current) } fmt.Fprintf(iostreams.IO.Out, "✓ %s is already %s\n", id, state) return nil @@ -88,8 +89,8 @@ func runPin(ctx context.Context, opts *PinOptions, jopts *cmdutil.JSONOptions, s if err != nil { return cmdutil.WrapHTTP(err, "%s knowledge base %s", verb, id) } - if jopts.Enabled() { - return jopts.Emit(iostreams.IO.Out, updated) + if fopts.WantsJSON() { + return fopts.Emit(iostreams.IO.Out, updated) } state := "pinned" if !updated.IsPinned { diff --git a/cli/cmd/kb/pin_test.go b/cli/cmd/kb/pin_test.go index 29503808..d8a193db 100644 --- a/cli/cmd/kb/pin_test.go +++ b/cli/cmd/kb/pin_test.go @@ -44,7 +44,7 @@ func (f *fakePinSvc) TogglePinKnowledgeBase(_ context.Context, id string) (*sdk. func TestPin_UnpinnedToPinned_CallsToggle(t *testing.T) { out, _ := iostreams.SetForTest(t) svc := &fakePinSvc{current: sdk.KnowledgeBase{IsPinned: false}} - require.NoError(t, runPin(context.Background(), &PinOptions{}, nil, svc, "kb_abc", true)) + require.NoError(t, runPin(context.Background(), &PinOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_abc", true)) assert.True(t, svc.toggleCalled, "must call toggle when current state differs") assert.Contains(t, out.String(), "kb_abc") } @@ -52,7 +52,7 @@ func TestPin_UnpinnedToPinned_CallsToggle(t *testing.T) { func TestPin_AlreadyPinned_NoOp(t *testing.T) { out, _ := iostreams.SetForTest(t) svc := &fakePinSvc{current: sdk.KnowledgeBase{IsPinned: true}} - require.NoError(t, runPin(context.Background(), &PinOptions{}, nil, svc, "kb_abc", true)) + require.NoError(t, runPin(context.Background(), &PinOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_abc", true)) assert.False(t, svc.toggleCalled, "already pinned ⇒ must not call toggle") assert.Contains(t, out.String(), "already pinned") } @@ -60,14 +60,14 @@ func TestPin_AlreadyPinned_NoOp(t *testing.T) { func TestUnpin_PinnedToUnpinned_CallsToggle(t *testing.T) { _, _ = iostreams.SetForTest(t) svc := &fakePinSvc{current: sdk.KnowledgeBase{IsPinned: true}} - require.NoError(t, runPin(context.Background(), &PinOptions{}, nil, svc, "kb_abc", false)) + require.NoError(t, runPin(context.Background(), &PinOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_abc", false)) assert.True(t, svc.toggleCalled) } func TestUnpin_AlreadyUnpinned_NoOp(t *testing.T) { out, _ := iostreams.SetForTest(t) svc := &fakePinSvc{current: sdk.KnowledgeBase{IsPinned: false}} - require.NoError(t, runPin(context.Background(), &PinOptions{}, nil, svc, "kb_abc", false)) + require.NoError(t, runPin(context.Background(), &PinOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_abc", false)) assert.False(t, svc.toggleCalled, "already unpinned ⇒ must not call toggle") assert.Contains(t, out.String(), "already unpinned") } @@ -75,7 +75,7 @@ func TestUnpin_AlreadyUnpinned_NoOp(t *testing.T) { func TestPin_NotFound(t *testing.T) { _, _ = iostreams.SetForTest(t) svc := &fakePinSvc{getErr: errors.New("HTTP error 404: not found")} - err := runPin(context.Background(), &PinOptions{}, nil, svc, "kb_missing", true) + err := runPin(context.Background(), &PinOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_missing", true) require.Error(t, err) var typed *cmdutil.Error require.ErrorAs(t, err, &typed) @@ -89,7 +89,7 @@ func TestPin_ToggleError(t *testing.T) { current: sdk.KnowledgeBase{IsPinned: false}, toggleErr: errors.New("HTTP error 500: internal"), } - err := runPin(context.Background(), &PinOptions{}, nil, svc, "kb_abc", true) + err := runPin(context.Background(), &PinOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_abc", true) require.Error(t, err) var typed *cmdutil.Error require.ErrorAs(t, err, &typed) @@ -99,7 +99,7 @@ func TestPin_ToggleError(t *testing.T) { func TestPin_JSON(t *testing.T) { out, _ := iostreams.SetForTest(t) svc := &fakePinSvc{current: sdk.KnowledgeBase{IsPinned: false}} - require.NoError(t, runPin(context.Background(), &PinOptions{}, &cmdutil.JSONOptions{}, svc, "kb_abc", true)) + require.NoError(t, runPin(context.Background(), &PinOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb_abc", true)) body := out.String() assert.Contains(t, body, `"is_pinned":true`) assert.Contains(t, body, `"id":"kb_abc"`) diff --git a/cli/cmd/kb/status.go b/cli/cmd/kb/status.go new file mode 100644 index 00000000..6d759a02 --- /dev/null +++ b/cli/cmd/kb/status.go @@ -0,0 +1,119 @@ +package kb + +import ( + "context" + "fmt" + "io" + + "github.com/spf13/cobra" + + "github.com/Tencent/WeKnora/cli/internal/cmdutil" + "github.com/Tencent/WeKnora/cli/internal/iostreams" + sdk "github.com/Tencent/WeKnora/client" +) + +// StatusResult is the health-oriented response for `kb status `. +// Shallow read only: 1 HTTP call, no failed-doc aggregation. +// For deep verification including failed_count, use `kb check `. +type StatusResult struct { + ID string `json:"id"` + Reachable bool `json:"reachable"` + KnowledgeCount int64 `json:"knowledge_count,omitempty"` + ChunkCount int64 `json:"chunk_count,omitempty"` + IsProcessing bool `json:"is_processing,omitempty"` + ProcessingCount int64 `json:"processing_count,omitempty"` +} + +// StatusService is the narrow SDK surface needed for kb status. +type StatusService interface { + GetKnowledgeBase(ctx context.Context, id string) (*sdk.KnowledgeBase, error) +} + +var kbStatusFields = []string{ + "id", "reachable", "knowledge_count", "chunk_count", + "is_processing", "processing_count", +} + +// NewCmdStatus builds `weknora kb status `. +func NewCmdStatus(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "status ", + Short: "Show health status of a knowledge base (shallow, 1 HTTP)", + Long: `Show health-oriented fields for a KB. + +1 HTTP call: + reachable / knowledge_count / chunk_count / is_processing / processing_count + +For deep verification including failed_count, use 'weknora kb check ' +(1 + N HTTP, pages the doc list with parse_status=failed). + +For full metadata (config / pinned / tenant), use 'weknora kb view '.`, + Example: ` weknora kb status kb_abc + weknora kb status kb_abc --format json`, + Args: cobra.ExactArgs(1), + RunE: func(c *cobra.Command, args []string) error { + fopts, err := cmdutil.CheckFormatFlag(c) + if err != nil { + return err + } + fopts.ResolveDefault(iostreams.IO.IsStdoutTTY()) + cli, err := f.Client() + if err != nil { + return err + } + res, err := runStatus(c.Context(), cli, args[0]) + if err != nil { + return err + } + return emitStatus(res, fopts, iostreams.IO.Out) + }, + } + cmdutil.AddFormatFlag(cmd, kbStatusFields...) + return cmd +} + +// runStatus is the testable core: fetch KB metadata and return a StatusResult. +// Never returns an error for "kb not reachable" (Reachable=false carries +// that signal). +func runStatus(ctx context.Context, svc StatusService, id string) (*StatusResult, error) { + kb, err := svc.GetKnowledgeBase(ctx, id) + if err != nil { + return &StatusResult{ID: id, Reachable: false}, nil + } + return &StatusResult{ + ID: kb.ID, + Reachable: true, + KnowledgeCount: kb.KnowledgeCount, + ChunkCount: kb.ChunkCount, + IsProcessing: kb.IsProcessing, + ProcessingCount: kb.ProcessingCount, + }, nil +} + +// emitStatus renders res using --format options. Mirrors emitWaitResult +// pattern from cli/cmd/doc/wait.go for consistency. +func emitStatus(res *StatusResult, fopts *cmdutil.FormatOptions, w io.Writer) error { + switch fopts.Mode { + case cmdutil.FormatJSON, cmdutil.FormatNDJSON: + return fopts.Emit(w, res) + case cmdutil.FormatText, "": + return writeStatusText(w, res) + default: + return fmt.Errorf("unsupported --format %q for kb status", fopts.Mode) + } +} + +func writeStatusText(w io.Writer, res *StatusResult) error { + fmt.Fprintf(w, "ID: %s\n", res.ID) + fmt.Fprintf(w, "Reachable: %v\n", res.Reachable) + if !res.Reachable { + return nil + } + fmt.Fprintf(w, "Knowledge: %d\n", res.KnowledgeCount) + fmt.Fprintf(w, "Chunks: %d\n", res.ChunkCount) + fmt.Fprintf(w, "Processing: %v (%d active)\n", res.IsProcessing, res.ProcessingCount) + return nil +} + +// compile-time check: SDK client satisfies StatusService. +var _ StatusService = (*sdk.Client)(nil) diff --git a/cli/cmd/kb/status_test.go b/cli/cmd/kb/status_test.go new file mode 100644 index 00000000..b3a9b4b6 --- /dev/null +++ b/cli/cmd/kb/status_test.go @@ -0,0 +1,89 @@ +package kb + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/Tencent/WeKnora/cli/internal/cmdutil" + sdk "github.com/Tencent/WeKnora/client" +) + +type fakeStatusSvc struct { + kb *sdk.KnowledgeBase + getErr error +} + +func (f *fakeStatusSvc) GetKnowledgeBase(_ context.Context, id string) (*sdk.KnowledgeBase, error) { + if f.getErr != nil { + return nil, f.getErr + } + return f.kb, nil +} + +func TestRunStatus_ShallowFields(t *testing.T) { + svc := &fakeStatusSvc{kb: &sdk.KnowledgeBase{ + ID: "kb_x", + KnowledgeCount: 42, + ChunkCount: 100, + IsProcessing: true, + ProcessingCount: 3, + }} + res, err := runStatus(context.Background(), svc, "kb_x") + if err != nil { + t.Fatalf("runStatus: %v", err) + } + if !res.Reachable { + t.Error("Reachable=false, want true") + } + if res.KnowledgeCount != 42 || res.ChunkCount != 100 || res.ProcessingCount != 3 || !res.IsProcessing { + t.Errorf("got %+v", res) + } +} + +func TestRunStatus_Unreachable(t *testing.T) { + svc := &fakeStatusSvc{getErr: fmt.Errorf("404 not found")} + res, err := runStatus(context.Background(), svc, "kb_x") + if err != nil { + t.Fatalf("runStatus should not return err on unreachable; got %v", err) + } + if res.Reachable { + t.Error("Reachable=true, want false") + } + if res.ID != "kb_x" { + t.Errorf("ID=%q, want kb_x (echoed even when unreachable)", res.ID) + } +} + +func TestEmitStatus_JSON(t *testing.T) { + var buf bytes.Buffer + res := &StatusResult{ID: "kb_x", Reachable: true, KnowledgeCount: 5} + fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON} + if err := emitStatus(res, fopts, &buf); err != nil { + t.Fatalf("emitStatus: %v", err) + } + var got StatusResult + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("not JSON: %v\n%s", err, buf.String()) + } + if got.ID != "kb_x" || got.KnowledgeCount != 5 { + t.Errorf("got %+v", got) + } +} + +func TestEmitStatus_TextHuman(t *testing.T) { + var buf bytes.Buffer + res := &StatusResult{ID: "kb_x", Reachable: true, KnowledgeCount: 5, ChunkCount: 20, IsProcessing: true, ProcessingCount: 1} + fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatText} + if err := emitStatus(res, fopts, &buf); err != nil { + t.Fatalf("emitStatus: %v", err) + } + for _, want := range []string{"kb_x", "5", "20", "true"} { + if !strings.Contains(buf.String(), want) { + t.Errorf("output missing %q:\n%s", want, buf.String()) + } + } +} diff --git a/cli/cmd/kb/view.go b/cli/cmd/kb/view.go index 38cbefba..3b979726 100644 --- a/cli/cmd/kb/view.go +++ b/cli/cmd/kb/view.go @@ -12,7 +12,7 @@ import ( sdk "github.com/Tencent/WeKnora/client" ) -// kbViewFields enumerates the fields surfaced for `--json` discovery on +// kbViewFields enumerates the fields surfaced for `--format json` discovery on // `kb view`. Lists the KnowledgeBase top-level json tags; nested config // structs are omitted (use --jq for those). var kbViewFields = []string{ @@ -35,37 +35,38 @@ type ViewService interface { func NewCmdView(f *cmdutil.Factory) *cobra.Command { opts := &ViewOptions{} cmd := &cobra.Command{ - Use: "view ", + Use: "view ", Short: "Show a knowledge base by ID", Long: `Fetch a knowledge base's full configuration: chunking settings, embedding / summary model IDs, knowledge_count, chunk_count.`, Args: cobra.ExactArgs(1), RunE: func(c *cobra.Command, args []string) error { - jopts, err := cmdutil.CheckJSONFlags(c) + fopts, err := cmdutil.CheckFormatFlag(c) if err != nil { return err } + fopts.ResolveDefault(iostreams.IO.IsStdoutTTY()) cli, err := f.Client() if err != nil { return err } - return runView(c.Context(), opts, jopts, cli, args[0]) + return runView(c.Context(), opts, fopts, cli, args[0]) }, } - cmdutil.AddJSONFlags(cmd, kbViewFields) + cmdutil.AddFormatFlag(cmd, kbViewFields...) return cmd } -func runView(ctx context.Context, opts *ViewOptions, jopts *cmdutil.JSONOptions, svc ViewService, id string) error { +func runView(ctx context.Context, opts *ViewOptions, fopts *cmdutil.FormatOptions, svc ViewService, id string) error { kb, err := svc.GetKnowledgeBase(ctx, id) if err != nil { return cmdutil.WrapHTTP(err, "get knowledge base %q", id) } - if jopts.Enabled() { - return jopts.Emit(iostreams.IO.Out, kb) + if fopts.WantsJSON() { + return fopts.Emit(iostreams.IO.Out, kb) } // Human: KEY: VALUE. Nested config structs (chunking_config, vlm_config, // etc.) are intentionally omitted from the human render — those are for - // `--json | jq '.chunking_config'` workflows. + // `--format json | jq '.chunking_config'` workflows. w := iostreams.IO.Out fmt.Fprintf(w, "ID: %s\n", kb.ID) fmt.Fprintf(w, "NAME: %s\n", kb.Name) diff --git a/cli/cmd/kb/view_test.go b/cli/cmd/kb/view_test.go index c397f5f9..ed2adfec 100644 --- a/cli/cmd/kb/view_test.go +++ b/cli/cmd/kb/view_test.go @@ -26,7 +26,7 @@ func TestGet_OK_Human(t *testing.T) { svc := &fakeGetSvc{kb: &sdk.KnowledgeBase{ ID: "kb1", Name: "Marketing", KnowledgeCount: 12, ChunkCount: 245, }} - if err := runView(context.Background(), &ViewOptions{}, nil, svc, "kb1"); err != nil { + if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb1"); err != nil { t.Fatalf("runGet: %v", err) } got := out.String() @@ -40,7 +40,7 @@ func TestGet_OK_Human(t *testing.T) { func TestGet_OK_JSON(t *testing.T) { out, _ := iostreams.SetForTest(t) svc := &fakeGetSvc{kb: &sdk.KnowledgeBase{ID: "kb1", Name: "Marketing"}} - if err := runView(context.Background(), &ViewOptions{}, &cmdutil.JSONOptions{}, svc, "kb1"); err != nil { + if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb1"); err != nil { t.Fatalf("runGet: %v", err) } got := out.String() @@ -55,7 +55,7 @@ func TestGet_OK_JSON(t *testing.T) { func TestGet_NotFound(t *testing.T) { _, _ = iostreams.SetForTest(t) svc := &fakeGetSvc{err: errors.New("HTTP error 404: not found")} - err := runView(context.Background(), &ViewOptions{}, nil, svc, "missing") + err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "missing") if err == nil { t.Fatal("expected error") } @@ -69,7 +69,7 @@ func TestGet_NotFound(t *testing.T) { func TestView_Pinned_RendersPinnedLine(t *testing.T) { out, _ := iostreams.SetForTest(t) svc := &fakeGetSvc{kb: &sdk.KnowledgeBase{ID: "kb1", Name: "Pinned", IsPinned: true}} - if err := runView(context.Background(), &ViewOptions{}, nil, svc, "kb1"); err != nil { + if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb1"); err != nil { t.Fatalf("runView: %v", err) } if !strings.Contains(out.String(), "PINNED:") { @@ -80,7 +80,7 @@ func TestView_Pinned_RendersPinnedLine(t *testing.T) { func TestView_NotPinned_OmitsPinnedLine(t *testing.T) { out, _ := iostreams.SetForTest(t) svc := &fakeGetSvc{kb: &sdk.KnowledgeBase{ID: "kb1", Name: "Plain", IsPinned: false}} - if err := runView(context.Background(), &ViewOptions{}, nil, svc, "kb1"); err != nil { + if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb1"); err != nil { t.Fatalf("runView: %v", err) } for _, l := range strings.Split(out.String(), "\n") { @@ -93,7 +93,7 @@ func TestView_NotPinned_OmitsPinnedLine(t *testing.T) { func TestView_Temporary_RendersTempLine(t *testing.T) { out, _ := iostreams.SetForTest(t) svc := &fakeGetSvc{kb: &sdk.KnowledgeBase{ID: "kb_t", Name: "Tmp", IsTemporary: true}} - if err := runView(context.Background(), &ViewOptions{}, nil, svc, "kb_t"); err != nil { + if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_t"); err != nil { t.Fatalf("runView: %v", err) } if !strings.Contains(out.String(), "TEMPORARY:") { @@ -104,7 +104,7 @@ func TestView_Temporary_RendersTempLine(t *testing.T) { func TestView_SummaryModel(t *testing.T) { out, _ := iostreams.SetForTest(t) svc := &fakeGetSvc{kb: &sdk.KnowledgeBase{ID: "kb1", Name: "X", SummaryModelID: "summary-model-x"}} - if err := runView(context.Background(), &ViewOptions{}, nil, svc, "kb1"); err != nil { + if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb1"); err != nil { t.Fatalf("runView: %v", err) } got := out.String() @@ -116,7 +116,7 @@ func TestView_SummaryModel(t *testing.T) { func TestView_TypeAndSource(t *testing.T) { out, _ := iostreams.SetForTest(t) svc := &fakeGetSvc{kb: &sdk.KnowledgeBase{ID: "kb1", Name: "X", Type: "general", Description: "d"}} - if err := runView(context.Background(), &ViewOptions{}, nil, svc, "kb1"); err != nil { + if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb1"); err != nil { t.Fatalf("runView: %v", err) } got := out.String() @@ -128,7 +128,7 @@ func TestView_TypeAndSource(t *testing.T) { func TestView_Processing(t *testing.T) { out, _ := iostreams.SetForTest(t) svc := &fakeGetSvc{kb: &sdk.KnowledgeBase{ID: "kb_p", Name: "Busy", IsProcessing: true, ProcessingCount: 3}} - if err := runView(context.Background(), &ViewOptions{}, nil, svc, "kb_p"); err != nil { + if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_p"); err != nil { t.Fatalf("runView: %v", err) } got := out.String() @@ -140,7 +140,7 @@ func TestView_Processing(t *testing.T) { func TestView_NotProcessing_OmitsProcessingLine(t *testing.T) { out, _ := iostreams.SetForTest(t) svc := &fakeGetSvc{kb: &sdk.KnowledgeBase{ID: "kb_idle", Name: "Idle", IsProcessing: false}} - if err := runView(context.Background(), &ViewOptions{}, nil, svc, "kb_idle"); err != nil { + if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_idle"); err != nil { t.Fatalf("runView: %v", err) } for _, l := range strings.Split(out.String(), "\n") { @@ -157,7 +157,7 @@ func TestView_CreatedAt_AlwaysRendered(t *testing.T) { CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), UpdatedAt: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC), }} - if err := runView(context.Background(), &ViewOptions{}, nil, svc, "kb1"); err != nil { + if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb1"); err != nil { t.Fatalf("runView: %v", err) } got := out.String() diff --git a/cli/cmd/link/link.go b/cli/cmd/link/link.go index c02f8abd..eaed6662 100644 --- a/cli/cmd/link/link.go +++ b/cli/cmd/link/link.go @@ -19,7 +19,7 @@ import ( "github.com/Tencent/WeKnora/cli/internal/projectlink" ) -// linkFields enumerates the fields surfaced for `--json` discovery on +// linkFields enumerates the fields surfaced for `--format json` discovery on // `link`. Tracks the small linkResult struct. var linkFields = []string{"context", "kb_id", "kb_name", "project_link_path"} @@ -57,19 +57,20 @@ user explicitly asked to bind this directory; don't run it as a side effect.`, weknora link # interactive (TTY)`, Args: cobra.NoArgs, RunE: func(c *cobra.Command, _ []string) error { - jopts, err := cmdutil.CheckJSONFlags(c) + fopts, err := cmdutil.CheckFormatFlag(c) if err != nil { return err } - return runLink(c.Context(), opts, jopts, f) + fopts.ResolveDefault(iostreams.IO.IsStdoutTTY()) + return runLink(c.Context(), opts, fopts, f) }, } cmd.Flags().StringVar(&opts.KB, "kb", "", "Knowledge base UUID or name; omit on a TTY for interactive prompt") - cmdutil.AddJSONFlags(cmd, linkFields) + cmdutil.AddFormatFlag(cmd, linkFields...) return cmd } -func runLink(ctx context.Context, opts *Options, jopts *cmdutil.JSONOptions, f *cmdutil.Factory) error { +func runLink(ctx context.Context, opts *Options, fopts *cmdutil.FormatOptions, f *cmdutil.Factory) error { cwd, err := os.Getwd() if err != nil { return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "get cwd") @@ -101,8 +102,8 @@ func runLink(ctx context.Context, opts *Options, jopts *cmdutil.JSONOptions, f * KBName: kbName, ProjectLinkPath: linkPath, } - if jopts.Enabled() { - return jopts.Emit(iostreams.IO.Out, r) + if fopts.WantsJSON() { + return fopts.Emit(iostreams.IO.Out, r) } if kbName != "" { fmt.Fprintf(iostreams.IO.Out, "✓ Linked %s to %s (kb=%s, id=%s)\n", linkPath, ctxName, kbName, kbID) diff --git a/cli/cmd/link/link_test.go b/cli/cmd/link/link_test.go index cccf058c..42bc6946 100644 --- a/cli/cmd/link/link_test.go +++ b/cli/cmd/link/link_test.go @@ -65,7 +65,7 @@ func TestLink_ByID(t *testing.T) { f := newFactory("default", nil) opts := &Options{KB: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"} - require.NoError(t, runLink(context.Background(), opts, nil, f)) + require.NoError(t, runLink(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f)) linkPath := filepath.Join(dir, ".weknora", "project.yaml") p, err := projectlink.Load(linkPath) @@ -87,7 +87,7 @@ func TestLink_ByName(t *testing.T) { cli := sdk.NewClient(srv.URL) f := newFactory("default", cli) opts := &Options{KB: "foo"} - require.NoError(t, runLink(context.Background(), opts, nil, f)) + require.NoError(t, runLink(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f)) p, err := projectlink.Load(filepath.Join(dir, ".weknora", "project.yaml")) require.NoError(t, err) @@ -103,7 +103,7 @@ func TestLink_KBNotFound(t *testing.T) { cli := sdk.NewClient(srv.URL) f := newFactory("default", cli) opts := &Options{KB: "missing"} - err := runLink(context.Background(), opts, nil, f) + err := runLink(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f) require.Error(t, err) var typed *cmdutil.Error require.ErrorAs(t, err, &typed) @@ -123,7 +123,7 @@ func TestLink_OverwritesExisting(t *testing.T) { f := newFactory("default", nil) opts := &Options{KB: "22222222-2222-4222-8222-222222222222"} - require.NoError(t, runLink(context.Background(), opts, nil, f)) + require.NoError(t, runLink(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f)) p, err := projectlink.Load(linkPath) require.NoError(t, err) @@ -140,7 +140,7 @@ func TestLink_NonInteractive_NoKB(t *testing.T) { f := newFactory("default", nil) opts := &Options{} // no KB - err := runLink(context.Background(), opts, nil, f) + err := runLink(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f) require.Error(t, err) var typed *cmdutil.Error require.ErrorAs(t, err, &typed) diff --git a/cli/cmd/link/unlink.go b/cli/cmd/link/unlink.go index e092a276..94b802eb 100644 --- a/cli/cmd/link/unlink.go +++ b/cli/cmd/link/unlink.go @@ -11,7 +11,7 @@ import ( "github.com/Tencent/WeKnora/cli/internal/projectlink" ) -// unlinkFields enumerates the fields surfaced for `--json` discovery on +// unlinkFields enumerates the fields surfaced for `--format json` discovery on // `unlink`. Tracks the small unlinkResult struct. var unlinkFields = []string{"project_link_path"} @@ -37,21 +37,22 @@ discovery that ` + "`--kb`" + ` resolution uses; you do not need to cd to the project root to unlink. Errors with input.invalid_argument when no link is present anywhere in the parent chain.`, Example: ` weknora unlink # remove the binding for this project - weknora unlink --json # bare JSON (CI / agents)`, + weknora unlink --format json # bare JSON (CI / agents)`, Args: cobra.NoArgs, RunE: func(c *cobra.Command, _ []string) error { - jopts, err := cmdutil.CheckJSONFlags(c) + fopts, err := cmdutil.CheckFormatFlag(c) if err != nil { return err } - return runUnlink(opts, jopts) + fopts.ResolveDefault(iostreams.IO.IsStdoutTTY()) + return runUnlink(opts, fopts) }, } - cmdutil.AddJSONFlags(cmd, unlinkFields) + cmdutil.AddFormatFlag(cmd, unlinkFields...) return cmd } -func runUnlink(opts *UnlinkOptions, jopts *cmdutil.JSONOptions) error { +func runUnlink(opts *UnlinkOptions, fopts *cmdutil.FormatOptions) error { cwd, err := os.Getwd() if err != nil { return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "get cwd") @@ -70,8 +71,8 @@ func runUnlink(opts *UnlinkOptions, jopts *cmdutil.JSONOptions) error { if err := projectlink.Remove(linkPath); err != nil { return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "remove %s", linkPath) } - if jopts.Enabled() { - return jopts.Emit(iostreams.IO.Out, unlinkResult{ProjectLinkPath: linkPath}) + if fopts.WantsJSON() { + return fopts.Emit(iostreams.IO.Out, unlinkResult{ProjectLinkPath: linkPath}) } fmt.Fprintf(iostreams.IO.Out, "✓ Unlinked %s\n", linkPath) return nil diff --git a/cli/cmd/link/unlink_test.go b/cli/cmd/link/unlink_test.go index b1665777..84db64d8 100644 --- a/cli/cmd/link/unlink_test.go +++ b/cli/cmd/link/unlink_test.go @@ -32,7 +32,7 @@ func TestUnlink_RemovesLinkInCwd(t *testing.T) { linkPath := mkLinkFile(t, tmp) t.Chdir(tmp) - if err := runUnlink(&UnlinkOptions{}, nil); err != nil { + if err := runUnlink(&UnlinkOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}); err != nil { t.Fatalf("runUnlink: %v", err) } if _, err := os.Stat(linkPath); !os.IsNotExist(err) { @@ -50,7 +50,7 @@ func TestUnlink_WalksUpFromSubdir(t *testing.T) { } t.Chdir(sub) - if err := runUnlink(&UnlinkOptions{}, nil); err != nil { + if err := runUnlink(&UnlinkOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}); err != nil { t.Fatalf("runUnlink: %v", err) } if _, err := os.Stat(linkPath); !os.IsNotExist(err) { @@ -63,7 +63,7 @@ func TestUnlink_NoLink_Errors(t *testing.T) { tmp := t.TempDir() t.Chdir(tmp) - err := runUnlink(&UnlinkOptions{}, nil) + err := runUnlink(&UnlinkOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}) if err == nil { t.Fatal("expected error when no link present") } @@ -85,7 +85,7 @@ func TestUnlink_JSON_BareObject(t *testing.T) { mkLinkFile(t, tmp) t.Chdir(tmp) - if err := runUnlink(&UnlinkOptions{}, &cmdutil.JSONOptions{}); err != nil { + if err := runUnlink(&UnlinkOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}); err != nil { t.Fatalf("runUnlink: %v", err) } got := out.String() diff --git a/cli/cmd/root.go b/cli/cmd/root.go index 056580aa..eef8f299 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -2,6 +2,7 @@ package cmd import ( + "context" "fmt" "strings" @@ -27,12 +28,14 @@ import ( ) // Execute is the entry point invoked by main(). Returns the process exit code. -func Execute() int { +// The passed context is wired to OS signals (SIGINT / SIGTERM) by main so +// commands that respect cmd.Context() can run their cancellation cleanup. +func Execute(ctx context.Context) int { root := NewRootCmd(cmdutil.New()) - if err := root.Execute(); err != nil { + if err := root.ExecuteContext(ctx); err != nil { // Errors go to stderr. Stdout stays // empty (or holds partial success the command produced) so - // downstream `--json | jq` pipelines never filter error shapes + // downstream `--format json | jq` pipelines never filter error shapes // out of the success stream. The typed exit code (3/4/5/6/7/10) // carries the error class. mapped := MapCobraError(err) @@ -92,20 +95,25 @@ hybrid searches against a WeKnora server from your shell or an AI agent.`, weknora kb list # list knowledge bases weknora kb view # show one weknora search chunks "your question" --kb= # hybrid retrieval - weknora doctor --json # health check (agent-readable)`, + weknora doctor --format json # health check (agent-readable)`, SilenceUsage: true, SilenceErrors: true, // Version makes cobra auto-register a `--version` global flag that // prints this string. We accept both `--version` and a `version` - // subcommand; the subcommand still owns the richer `--json` output + // subcommand; the subcommand still owns the richer `--format json` output // (build commit + date). Version: fmt.Sprintf("%s (commit %s, built %s)", v, commit, date), - PersistentPreRun: func(c *cobra.Command, args []string) { + PersistentPreRunE: func(c *cobra.Command, args []string) error { // Propagate the global --context flag into the Factory for this // invocation only - single-shot override, no disk write. if v, _ := c.Flags().GetString("context"); v != "" { f.ContextOverride = v } + // Resolve --log-level / WEKNORA_LOG_LEVEL and apply to the SDK + // debug logger before any SDK call is made. Returns a typed error + // when --log-level was passed explicitly with an invalid value + // (matches --format validation strictness). + return f.ApplyLogLevel(c, iostreams.IO.Err) }, } // Match `weknora version` line format so both forms output the same. @@ -143,9 +151,19 @@ func addGlobalFlags(cmd *cobra.Command) { pf := cmd.PersistentFlags() pf.BoolP("yes", "y", false, "Skip confirmation prompts on destructive operations") pf.String("context", "", "Override the active context for this invocation (no disk write)") + // --log-level is registered as a persistent (global) flag because the SDK + // debug logger is initialised once at factory time before any command runs, + // so the flag must be visible on all subcommands. Unlike --format (which + // only some commands honour and is registered per-command, Method D), + // --log-level applies uniformly to all SDK calls. + cmdutil.AddLogLevelFlag(cmd) + // NOTE: --format is registered per-command (cmdutil.AddFormatFlag in each + // command's NewCmd). Only commands that actually honor --format register + // it; cobra rejects --format on others with "unknown flag" rather than + // silently ignoring it. } -// versionFields enumerates the fields surfaced for `--json` discovery on +// versionFields enumerates the fields surfaced for `--format json` discovery on // `version`. Mirrors the version object payload. var versionFields = []string{"version", "commit", "date"} @@ -157,12 +175,13 @@ func newVersionCmd(f *cmdutil.Factory) *cobra.Command { Short: "Show CLI build metadata", Args: cobra.NoArgs, RunE: func(c *cobra.Command, args []string) error { - jopts, err := cmdutil.CheckJSONFlags(c) + fopts, err := cmdutil.CheckFormatFlag(c) if err != nil { return err } + fopts.ResolveDefault(iostreams.IO.IsStdoutTTY()) v, commit, date := build.Info() - if jopts.Enabled() { + if fopts.WantsJSON() { return format.WriteJSONFiltered( c.OutOrStdout(), map[string]string{ @@ -170,13 +189,13 @@ func newVersionCmd(f *cmdutil.Factory) *cobra.Command { "commit": commit, "date": date, }, - jopts.Fields, jopts.JQ, + nil, fopts.JQ, ) } fmt.Fprintf(c.OutOrStdout(), "weknora %s (commit %s, built %s)\n", v, commit, date) return nil }, } - cmdutil.AddJSONFlags(cmd, versionFields) + cmdutil.AddFormatFlag(cmd, versionFields...) return cmd } diff --git a/cli/cmd/root_test.go b/cli/cmd/root_test.go index a6d661ac..43abeb34 100644 --- a/cli/cmd/root_test.go +++ b/cli/cmd/root_test.go @@ -27,7 +27,7 @@ func TestRoot_Help(t *testing.T) { func TestVersion_JSON(t *testing.T) { var out bytes.Buffer root := NewRootCmd(cmdutil.New()) - root.SetArgs([]string{"version", "--json"}) + root.SetArgs([]string{"version", "--format", "json"}) root.SetOut(&out) require.NoError(t, root.Execute()) got := out.String() diff --git a/cli/internal/cmdutil/agentconfig.go b/cli/internal/cmdutil/agentconfig.go index 8f4cdd30..848875e2 100644 --- a/cli/internal/cmdutil/agentconfig.go +++ b/cli/internal/cmdutil/agentconfig.go @@ -104,7 +104,7 @@ func MergeAgentConfig(base *sdk.AgentConfig, ov AgentConfigFlags) *sdk.AgentConf // skeletonCommentVersion is embedded in the skeleton header so users // regenerating an outdated template after a schema bump can spot the // version mismatch. -const skeletonCommentVersion = "v0.5 / 34 fields" +const skeletonCommentVersion = "v0.6 / 34 fields" // GenerateAgentSkeleton writes a commented YAML template with every // AgentConfig field at its zero value to w. Used by diff --git a/cli/internal/cmdutil/factory.go b/cli/internal/cmdutil/factory.go index cc68cbde..be78c001 100644 --- a/cli/internal/cmdutil/factory.go +++ b/cli/internal/cmdutil/factory.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "io" "net/http" "os" "sync" @@ -211,6 +212,28 @@ func (f *Factory) ResolveKB(cmd *cobra.Command) (string, error) { return "", NewError(CodeKBIDRequired, "kb is required") } +// ApplyLogLevel resolves --log-level / WEKNORA_LOG_LEVEL (in priority order) +// and applies the result to the SDK's debug logger. Intended to be called +// from the root command's PersistentPreRunE so the resolved level is in +// effect before any SDK call. +// +// Returns a typed error if the user passed an explicit --log-level with +// an invalid value — matches the strictness of --format validation +// (env values stay silent-fallthrough; flag values are strict). +func (f *Factory) ApplyLogLevel(cmd *cobra.Command, stderr io.Writer) error { + if cmd != nil { + if fl := cmd.Flags().Lookup("log-level"); fl != nil && fl.Changed { + if !IsValidLogLevel(fl.Value.String()) { + return NewFlagError(fmt.Errorf( + "invalid --log-level %q: must be error | warn | info | debug", fl.Value.String())) + } + } + } + level, _ := ResolveLogLevel(cmd, stderr) + sdk.SetDebugLevel(level) + return nil +} + // LoadSecret fetches a named secret for the given context from the keyring. // Returns ("", nil) when the secret is absent (ErrNotFound); a real keyring // access failure surfaces as CodeLocalKeychainDenied. Used by buildClient diff --git a/cli/internal/cmdutil/loglevel.go b/cli/internal/cmdutil/loglevel.go new file mode 100644 index 00000000..57be0e44 --- /dev/null +++ b/cli/internal/cmdutil/loglevel.go @@ -0,0 +1,61 @@ +package cmdutil + +import ( + "io" + "os" + + "github.com/spf13/cobra" +) + +// validLogLevels is the canonical set of accepted log levels +// (debug|info|warn|error). Map gives O(1) validation. +var validLogLevels = map[string]bool{ + "error": true, + "warn": true, + "info": true, + "debug": true, +} + +// IsValidLogLevel reports whether v is one of the accepted log levels. +// Exported so factory-level validation can give a strict error on an +// explicit --log-level invalid value (env values stay silent-fallthrough). +func IsValidLogLevel(v string) bool { return validLogLevels[v] } + +// LogLevelDefault is the resolved level when nothing is configured: only +// emit error-level logs (minimal noise for non-debug usage). +const LogLevelDefault = "error" + +// AddLogLevelFlag registers --log-level as a persistent flag on cmd +// (typically the root command). Unlike --format (per-command, Method D), +// --log-level is uniformly applied across all SDK calls so it lives on root. +func AddLogLevelFlag(cmd *cobra.Command) { + cmd.PersistentFlags().String("log-level", "", "Log verbosity: error | warn | info | debug (env: WEKNORA_LOG_LEVEL)") +} + +// ResolveLogLevel resolves the effective log level using priority (high → low): +// 1. --log-level flag (if explicitly set, even from a parent command's persistent flagset) +// 2. WEKNORA_LOG_LEVEL env (if set to a valid value) +// 3. Default "error" +// +// Invalid env values fall through to the next priority (no error — env is +// best-effort). The stderr writer is retained in the signature for symmetry +// with ApplyLogLevel callers but is unused in v0.6. +func ResolveLogLevel(cmd *cobra.Command, _ io.Writer) (string, bool) { + // Priority 1: explicit --log-level flag. + if cmd != nil { + if f := cmd.Flags().Lookup("log-level"); f != nil && f.Changed { + v := f.Value.String() + if validLogLevels[v] { + return v, false + } + } + } + + // Priority 2: WEKNORA_LOG_LEVEL env (silently skip invalid values). + if v := os.Getenv("WEKNORA_LOG_LEVEL"); v != "" && validLogLevels[v] { + return v, false + } + + // Priority 3: default. + return LogLevelDefault, false +} diff --git a/cli/internal/cmdutil/loglevel_test.go b/cli/internal/cmdutil/loglevel_test.go new file mode 100644 index 00000000..3d4c6fa4 --- /dev/null +++ b/cli/internal/cmdutil/loglevel_test.go @@ -0,0 +1,53 @@ +package cmdutil + +import ( + "bytes" + "testing" + + "github.com/spf13/cobra" +) + +func TestResolveLogLevel(t *testing.T) { + cases := []struct { + name string + flagVal string // "" means flag not set + envVal string // WEKNORA_LOG_LEVEL + wantLvl string + wantWarn bool + }{ + {"default", "", "", "error", false}, + {"flag wins over env", "debug", "info", "debug", false}, + {"env when no flag", "", "info", "info", false}, + {"invalid env falls through to default", "", "trace", "error", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("WEKNORA_LOG_LEVEL", tc.envVal) + + cmd := &cobra.Command{} + AddLogLevelFlag(cmd) + if tc.flagVal != "" { + if err := cmd.ParseFlags([]string{"--log-level", tc.flagVal}); err != nil { + t.Fatalf("ParseFlags: %v", err) + } + } + + var stderr bytes.Buffer + got, warn := ResolveLogLevel(cmd, &stderr) + if got != tc.wantLvl { + t.Errorf("level=%q want %q", got, tc.wantLvl) + } + if warn != tc.wantWarn { + t.Errorf("warn=%v want %v", warn, tc.wantWarn) + } + }) + } +} + +func TestAddLogLevelFlag_RegistersPersistent(t *testing.T) { + cmd := &cobra.Command{} + AddLogLevelFlag(cmd) + if f := cmd.PersistentFlags().Lookup("log-level"); f == nil { + t.Error("--log-level must be registered as a persistent flag (global across all subcommands)") + } +} diff --git a/client/log_test.go b/client/log_test.go new file mode 100644 index 00000000..75c483f4 --- /dev/null +++ b/client/log_test.go @@ -0,0 +1,33 @@ +package client + +import ( + "bytes" + "log/slog" + "testing" +) + +func TestSetDebugLevel(t *testing.T) { + // Save and restore + saved := debugLogger + defer func() { debugLogger = saved }() + + cases := []string{"debug", "info", "warn", "error", "DEBUG", "", "invalid"} + for _, lvl := range cases { + SetDebugLevel(lvl) + if debugLogger == nil { + t.Errorf("SetDebugLevel(%q): debugLogger nil", lvl) + } + } +} + +func TestSetDebugLevel_DebugRoutesEmissions(t *testing.T) { + saved := debugLogger + defer func() { debugLogger = saved }() + + var buf bytes.Buffer + debugLogger = slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + debugLogger.Debug("test_event", "k", "v") + if buf.Len() == 0 { + t.Error("expected debug emission to land in buffer") + } +}