feat(cli): --log-level + kb/agent status & check + cross-cutting refactor

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-id> / kb check <kb-id> verb split (1 HTTP vs 1+N for
  failed_count aggregation).
* agent status <agent-id> / agent check <agent-id> verb split
  (probes kb_scope_all_reachable via 1+N HTTP).
* kb create <name> positional (matches agent create).
* Positional id help strings namespaced (<kb-id> / <agent-id>).
* 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.
This commit is contained in:
nullkey
2026-05-18 01:38:16 +08:00
committed by lyingbug
parent 7eeb3bec5d
commit 0e081aec5c
64 changed files with 1581 additions and 393 deletions

View File

@@ -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
}

128
cli/cmd/agent/check.go Normal file
View File

@@ -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 <id>`.
// 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 <id>`.
func NewCmdCheck(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "check <agent-id>",
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 <id>' 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)

150
cli/cmd/agent/check_test.go Normal file
View File

@@ -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())
}
}
}

View File

@@ -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

View File

@@ -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")
}

View File

@@ -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 <agent-id>`.
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

View File

@@ -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`)

View File

@@ -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.

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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")
}

111
cli/cmd/agent/status.go Normal file
View File

@@ -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 <id>`.
//
// One HTTP call: reachable + model_id. For downstream KB reachability
// verification use 'agent check <id>' (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 <agent-id>",
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 <id>'
(active verification, 1 + N HTTP). For full agent config / metadata use
'weknora agent view <id>'.`,
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)

View File

@@ -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())
}
}
}

View File

@@ -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

View File

@@ -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")
}

View File

@@ -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)

View File

@@ -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.")

View File

@@ -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))

View File

@@ -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 {

View File

@@ -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")
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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.

View File

@@ -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 := ""

View File

@@ -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))
}

View File

@@ -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 <name>`" + ` (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.

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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

View File

@@ -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.")

View File

@@ -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)
}

View File

@@ -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 <name>`" + ` 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 <name>` to pick another)\n", name)

View File

@@ -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")
}

View File

@@ -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)

View File

@@ -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")
}

View File

@@ -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:

View File

@@ -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) {

149
cli/cmd/kb/check.go Normal file
View File

@@ -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 <id>`.
// 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 <id>`.
func NewCmdCheck(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "check <kb-id>",
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 <id>' 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)

128
cli/cmd/kb/check_test.go Normal file
View File

@@ -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())
}
}

View File

@@ -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 <id>",
Use: "delete <kb-id>",
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

View File

@@ -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)

View File

@@ -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 <id>",
Use: "edit <kb-id>",
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

View File

@@ -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)

View File

@@ -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 <id>",
Use: "empty <kb-id>",
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

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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 + " <id>",
Use: use + " <kb-id>",
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 {

View File

@@ -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"`)

119
cli/cmd/kb/status.go Normal file
View File

@@ -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 <id>`.
// Shallow read only: 1 HTTP call, no failed-doc aggregation.
// For deep verification including failed_count, use `kb check <id>`.
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 <id>`.
func NewCmdStatus(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "status <kb-id>",
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 <id>'
(1 + N HTTP, pages the doc list with parse_status=failed).
For full metadata (config / pinned / tenant), use 'weknora kb view <id>'.`,
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)

89
cli/cmd/kb/status_test.go Normal file
View File

@@ -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())
}
}
}

View File

@@ -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 <id>",
Use: "view <kb-id>",
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)

View File

@@ -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()

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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()

View File

@@ -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 <id> # show one
weknora search chunks "your question" --kb=<id> # 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
}

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View File

@@ -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)")
}
}

33
client/log_test.go Normal file
View File

@@ -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")
}
}