diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index 70651619..1227dd76 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -47,6 +47,12 @@ jobs: run: go test -race -coverprofile=coverage.out ./... - name: go vet run: go vet ./... + - name: Check skill wire vocabulary + if: runner.os == 'Linux' + run: scripts/check-skill-wire-vocab.sh + - name: Check for committed credentials in docs + if: runner.os == 'Linux' + run: scripts/check-secret-tokens.sh - name: coverage report if: matrix.os == 'ubuntu-latest' run: go tool cover -func=coverage.out diff --git a/cli/AGENTS.md b/cli/AGENTS.md index 32467e0a..aa5bddce 100644 --- a/cli/AGENTS.md +++ b/cli/AGENTS.md @@ -419,6 +419,7 @@ Agents parse the first colon to extract the typed code. The exit code class (see | `local.unimplemented` | 1 | no | (planned in a future release) | | `local.upload_file_not_found` | 1 | no | verify the path is correct and readable | | `local.user_aborted` | 1 | no (user said no) | no action taken; pass `-y/--yes` to skip the confirmation prompt | +| `internal.error` | 1 | no | catch-all for an untyped error that reached the top (a bug or unmapped dependency error); a recurring one is a classification gap worth reporting | diff --git a/cli/acceptance/contract/errorcodes_test.go b/cli/acceptance/contract/errorcodes_test.go index 26ae553a..88c6011f 100644 --- a/cli/acceptance/contract/errorcodes_test.go +++ b/cli/acceptance/contract/errorcodes_test.go @@ -185,8 +185,6 @@ func identToErrorCode(name string) (cmdutil.ErrorCode, bool) { return cmdutil.CodeLocalKeychainDenied, true case "CodeLocalFileIO": return cmdutil.CodeLocalFileIO, true - case "CodeLocalUnimplemented": - return cmdutil.CodeLocalUnimplemented, true case "CodeLocalProfileNotFound": return cmdutil.CodeLocalProfileNotFound, true case "CodeKBIDRequired": @@ -209,6 +207,8 @@ func identToErrorCode(name string) (cmdutil.ErrorCode, bool) { return cmdutil.CodeOperationFailed, true case "CodeOperationCancelled": return cmdutil.CodeOperationCancelled, true + case "CodeInternalError": + return cmdutil.CodeInternalError, true } return "", false } diff --git a/cli/acceptance/testdata/wire/auth_status.success.json b/cli/acceptance/testdata/wire/auth_status.success.json index dc18b126..d61cc42e 100644 --- a/cli/acceptance/testdata/wire/auth_status.success.json +++ b/cli/acceptance/testdata/wire/auth_status.success.json @@ -1 +1 @@ -{"ok":true,"data":{"profile":"","user_id":"usr_abc","email":"user@example.com","tenant_id":42,"tenant_name":"Acme"}} +{"ok":true,"data":{"profile":"","auth_source":"profile + keyring","user_id":"usr_abc","email":"user@example.com","tenant_id":42,"tenant_name":"Acme"}} diff --git a/cli/acceptance/testdata/wire/kb_list.success.json b/cli/acceptance/testdata/wire/kb_list.success.json index d6a2e757..8f4247dd 100644 --- a/cli/acceptance/testdata/wire/kb_list.success.json +++ b/cli/acceptance/testdata/wire/kb_list.success.json @@ -1 +1 @@ -{"ok":true,"data":[{"id":"kb1","name":"Onboarding Docs","type":"","is_temporary":false,"is_pinned":false,"description":"","tenant_id":42,"chunking_config":{"chunk_size":0,"chunk_overlap":0,"separators":null},"image_processing_config":{"model_id":""},"faq_config":null,"embedding_model_id":"text-embedding-3","summary_model_id":"","vlm_config":{"enabled":false,"model_id":""},"storage_provider_config":null,"storage_config":{"secret_id":"","secret_key":"","region":"","bucket_name":"","app_id":"","path_prefix":"","provider":""},"extract_config":null,"created_at":"2025-01-01T12:00:00Z","updated_at":"2025-01-01T12:00:00Z","knowledge_count":5,"chunk_count":128,"is_processing":false,"processing_count":0},{"id":"kb2","name":"API Reference","type":"","is_temporary":false,"is_pinned":false,"description":"","tenant_id":42,"chunking_config":{"chunk_size":0,"chunk_overlap":0,"separators":null},"image_processing_config":{"model_id":""},"faq_config":null,"embedding_model_id":"text-embedding-3","summary_model_id":"","vlm_config":{"enabled":false,"model_id":""},"storage_provider_config":null,"storage_config":{"secret_id":"","secret_key":"","region":"","bucket_name":"","app_id":"","path_prefix":"","provider":""},"extract_config":null,"created_at":"2025-01-01T12:00:00Z","updated_at":"2025-01-01T12:00:00Z","knowledge_count":12,"chunk_count":340,"is_processing":false,"processing_count":0}],"meta":{"count":2}} +{"ok":true,"data":[{"id":"kb1","name":"Onboarding Docs","type":"","is_temporary":false,"is_pinned":false,"description":"","tenant_id":42,"chunking_config":{"chunk_size":0,"chunk_overlap":0,"separators":null},"image_processing_config":{"model_id":""},"faq_config":null,"embedding_model_id":"text-embedding-3","summary_model_id":"","vlm_config":{"enabled":false,"model_id":""},"storage_provider_config":null,"storage_config":{"secret_id":"","secret_key":"","region":"","bucket_name":"","app_id":"","path_prefix":"","provider":""},"extract_config":null,"created_at":"2025-01-01T12:00:00Z","updated_at":"2025-01-01T12:00:00Z","knowledge_count":5,"chunk_count":128,"is_processing":false,"processing_count":0},{"id":"kb2","name":"API Reference","type":"","is_temporary":false,"is_pinned":false,"description":"","tenant_id":42,"chunking_config":{"chunk_size":0,"chunk_overlap":0,"separators":null},"image_processing_config":{"model_id":""},"faq_config":null,"embedding_model_id":"text-embedding-3","summary_model_id":"","vlm_config":{"enabled":false,"model_id":""},"storage_provider_config":null,"storage_config":{"secret_id":"","secret_key":"","region":"","bucket_name":"","app_id":"","path_prefix":"","provider":""},"extract_config":null,"created_at":"2025-01-01T12:00:00Z","updated_at":"2025-01-01T12:00:00Z","knowledge_count":12,"chunk_count":340,"is_processing":false,"processing_count":0}],"meta":{"count":2,"total_count":2}} diff --git a/cli/acceptance/testdata/wire/kb_list.success_empty.json b/cli/acceptance/testdata/wire/kb_list.success_empty.json index 4c9d3c56..18d5d94c 100644 --- a/cli/acceptance/testdata/wire/kb_list.success_empty.json +++ b/cli/acceptance/testdata/wire/kb_list.success_empty.json @@ -1 +1 @@ -{"ok":true,"data":[],"meta":{}} +{"ok":true,"data":[],"meta":{"count":0,"total_count":0}} diff --git a/cli/cmd/agent/agent.go b/cli/cmd/agent/agent.go index 72bdbaed..70d2dab4 100644 --- a/cli/cmd/agent/agent.go +++ b/cli/cmd/agent/agent.go @@ -1,5 +1,5 @@ // Package agentcmd holds the `weknora agent` command tree: -// list / view / create / edit / delete / status / check. The directory is +// list / view / create / update / delete / status / check. The directory is // named `agent/` to match the cobra subcommand; the Go package is `agentcmd` // to avoid colliding with cobra's *cobra.Command identifier. // @@ -22,7 +22,7 @@ func NewCmd(f *cmdutil.Factory) *cobra.Command { Use: "agent", Short: "Manage custom agents (CRUD + status/check)", Long: `Custom Agents bundle a system prompt, model, tool allow-list, and KB -scope into an addressable resource. Create, edit, list, view, check, +scope into an addressable resource. Create, update, list, view, check, or delete agents. To invoke an agent, use: weknora session ask --agent `, } cmd.AddCommand(NewCmdList(f)) diff --git a/cli/cmd/agent/create.go b/cli/cmd/agent/create.go index 8b485e2c..6c451fb5 100644 --- a/cli/cmd/agent/create.go +++ b/cli/cmd/agent/create.go @@ -66,7 +66,8 @@ const agentCreateExample = ` weknora agent create "Support Bot" --model 0 { + resolved := make([]string, 0, len(opts.KBs)) + for _, raw := range opts.KBs { + id, rerr := cmdutil.ResolveKBFlag(cmd.Context(), cli, raw) + if rerr != nil { + return rerr + } + resolved = append(resolved, id) + } + opts.KBs = resolved + } return runCreate(cmd.Context(), opts, fopts, cli) }, } @@ -166,17 +198,17 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { // Required (enforced in PreRunE rather than cmd.MarkFlagRequired so // --generate-skeleton can bypass it — cobra's MarkFlagRequired runs // before PreRunE and would otherwise block the skeleton path). - cmd.Flags().StringVar(&opts.Model, "model", "", "LLM model id (required, except with --generate-skeleton)") + cmd.Flags().StringVar(&opts.Model, "model", "", "LLM model id or name (required, except with --generate-skeleton)") // Hot-path (8 flag names, 7 distinct config fields) cmd.Flags().StringVar(&opts.Description, "description", "", "Agent description") cmd.Flags().StringVar(&opts.SystemPrompt, "system-prompt", "", "System prompt text (mutex with --system-prompt-file)") cmd.Flags().StringVar(&systemPromptFile, "system-prompt-file", "", "Read system prompt from FILE, or '-' for stdin") cmd.MarkFlagsMutuallyExclusive("system-prompt", "system-prompt-file") - cmd.Flags().StringVar(&opts.AgentMode, "agent-mode", "", "Agent operating mode: quick-answer | smart-reasoning") - cmd.Flags().StringSliceVar(&opts.KBs, "attach-kb", nil, "Attach a knowledge base id (repeatable); aligns with 'agent edit --add-kb'") - cmd.Flags().StringVar(&opts.KBSelectionMode, "kb-selection-mode", "", "KB selection mode: all | selected | none") - cmd.Flags().StringVar(&opts.RerankModel, "rerank-model", "", "Rerank model id") + cmd.Flags().StringVar(&opts.AgentMode, "agent-mode", "", "Agent operating mode: "+strings.Join(agentModeValues, " | ")) + cmd.Flags().StringSliceVar(&opts.KBs, "attach-kb", nil, "Attach a knowledge base id (repeatable); aligns with 'agent update --add-kb'") + cmd.Flags().StringVar(&opts.KBSelectionMode, "kb-selection-mode", "", "KB selection mode: "+strings.Join(kbSelectionModeValues, " | ")) + cmd.Flags().StringVar(&opts.RerankModel, "rerank-model", "", "Rerank model id or name") cmd.Flags().Float64Var(&opts.Temperature, "temperature", 0.0, "Generation temperature (0.0..2.0)") // Power-user / utility @@ -187,9 +219,14 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { cmdutil.AddFormatFlag(cmd, agentViewFields...) cmdutil.AddDryRunFlag(cmd, &opts.DryRun) cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{ - UsedFor: "Create a new custom agent. --model is required; --attach-kb takes KB ids (repeatable, not names). Emits the created Agent object.", + UsedFor: "Create a new custom agent. --model is required and accepts a model id or name; --attach-kb (repeatable) accepts KB ids or names. Emits the created Agent object.", RequiredFlags: []string{" (positional)", "--model"}, - Output: "envelope.data is the created Agent object with id, name, config", + Examples: []string{ + `weknora agent create "Support Bot" --model gpt-4o`, + `weknora agent create "Support Bot" --model gpt-4o --attach-kb kb_eng --system-prompt "You are a support assistant."`, + `weknora agent create --generate-skeleton # print a blank config to fill in (no --model needed)`, + }, + Output: "envelope.data is the created Agent object with id, name, config", }) return cmd } @@ -270,6 +307,33 @@ func runCreate(ctx context.Context, opts *CreateOptions, fopts *cmdutil.FormatOp return emitAgent(fopts, created) } +// agentModeValues / kbSelectionModeValues are the closed server enums for the +// --agent-mode / --kb-selection-mode flags, sourced from the SDK enumerators so +// the CLI can't drift from the server vocabulary. +var ( + agentModeValues = cmdutil.EnumStrings(sdk.AllAgentModes()) + kbSelectionModeValues = cmdutil.EnumStrings(sdk.AllKBSelectionModes()) +) + +// validateAgentModeFlags validates and canonicalises --agent-mode and +// --kb-selection-mode in place against the SDK enums. An empty value passes +// through (flag unset → no override). Rejecting a typo here gives a fast, +// typed input.invalid_argument instead of a confusing server-side failure. +// Shared by `agent create` and `agent update`. +func validateAgentModeFlags(agentMode, kbSelectionMode *string) error { + v, err := cmdutil.ValidateEnum("agent-mode", *agentMode, agentModeValues) + if err != nil { + return err + } + *agentMode = v + v, err = cmdutil.ValidateEnum("kb-selection-mode", *kbSelectionMode, kbSelectionModeValues) + if err != nil { + return err + } + *kbSelectionMode = v + return nil +} + // applyCreateOverrides merges hot-path flag overrides into the base config, // then applies the "--attach-kb implies --kb-selection-mode=selected" fallback. // Shared by the --from path (base=copied agent's config) and the plain diff --git a/cli/cmd/agent/create_test.go b/cli/cmd/agent/create_test.go index 1f38c73a..9c68b3c3 100644 --- a/cli/cmd/agent/create_test.go +++ b/cli/cmd/agent/create_test.go @@ -218,6 +218,44 @@ func TestCreate_Temperature_Bounds(t *testing.T) { } } +// TestCreate_AgentModeValidation: an unknown --agent-mode / --kb-selection-mode +// is rejected up front (PreRunE) with a typed input.invalid_argument (exit 5), +// while a valid value passes the gate. The closed set is sourced from the SDK +// enumerators, so this also guards against CLI/SDK drift. +func TestCreate_AgentModeValidation(t *testing.T) { + cases := []struct { + name string + args []string + wantErr string // substring; "" means the mode gate must pass + }{ + {"bad agent-mode", []string{"--agent-mode", "bogus"}, "agent-mode"}, + {"bad kb-selection-mode", []string{"--kb-selection-mode", "every"}, "kb-selection-mode"}, + {"valid agent-mode", []string{"--agent-mode", "smart-reasoning", "--dry-run"}, ""}, + {"valid kb-selection-mode", []string{"--kb-selection-mode", "selected", "--dry-run"}, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, _ = iostreams.SetForTest(t) + cmd := NewCmdCreate(nil) + cmd.SetArgs(append([]string{"Test", "--model", "model-x"}, tc.args...)) + cmd.SilenceUsage, cmd.SilenceErrors = true, true + err := cmd.Execute() + if tc.wantErr == "" { + // Valid mode + --dry-run: must clear the mode gate. (--dry-run + // short-circuits before any client call, so nil factory is fine.) + require.NoError(t, err) + return + } + require.Error(t, err) + var ce *cmdutil.Error + require.ErrorAs(t, err, &ce) + assert.Equal(t, cmdutil.CodeInputInvalidArgument, ce.Code) + assert.Equal(t, 5, cmdutil.ExitCode(err)) + assert.Contains(t, err.Error(), tc.wantErr) + }) + } +} + func TestCreate_CopyAgent_NotFound(t *testing.T) { _, _ = iostreams.SetForTest(t) svc := &fakeCreateSvc{copyErr: errBadHTTP404} @@ -251,4 +289,3 @@ func TestCreate_AttachKBFlagExists(t *testing.T) { old := cmd.Flags().Lookup("kb") assert.Nil(t, old, "old --kb flag must not exist on 'agent create' (renamed to --attach-kb)") } - diff --git a/cli/cmd/agent/delete.go b/cli/cmd/agent/delete.go index 4f6f7cb2..361640bd 100644 --- a/cli/cmd/agent/delete.go +++ b/cli/cmd/agent/delete.go @@ -106,7 +106,7 @@ func NewCmdDelete(f *cmdutil.Factory) *cobra.Command { } 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(), "delete", "agent", opts.AgentID, "agent.delete", "weknora agent delete "+opts.AgentID+" -y"); err != nil { + if err := cmdutil.ConfirmDestructive(p, opts.Yes, fopts.WantsJSON(), "delete", "agent", opts.AgentID, "agent.delete", []string{"weknora", "agent", "delete", opts.AgentID, "-y"}); err != nil { return err } if err := svc.DeleteAgent(ctx, opts.AgentID); err != nil { diff --git a/cli/cmd/agent/edit.go b/cli/cmd/agent/edit.go index 323a24b1..3620cbe1 100644 --- a/cli/cmd/agent/edit.go +++ b/cli/cmd/agent/edit.go @@ -8,7 +8,6 @@ import ( "strings" "github.com/spf13/cobra" - "github.com/spf13/pflag" "github.com/Tencent/WeKnora/cli/internal/cmdutil" "github.com/Tencent/WeKnora/cli/internal/iostreams" @@ -64,7 +63,7 @@ type editFlagSet struct { configFileSet bool } -const agentEditLong = `Edit a custom agent's fields surgically. +const agentEditLong = `Update a custom agent's fields surgically. At least one update flag is required; flags you omit preserve the current server-side value via fetch-then-update. Pass --description "" to clear @@ -89,20 +88,20 @@ with input.confirmation_required. Surface the prompt to the user and only retry with -y after explicit approval. Other failure codes: resource.not_found (agent id or KB id), auth.forbidden, input.invalid_argument (no flags, bad file).` -const agentEditExample = ` weknora agent edit ag_abc --name "Renamed" -y - weknora agent edit ag_abc --description "" -y # clear description - weknora agent edit ag_abc --add-kb kb_new --remove-kb kb_old -y - weknora agent edit ag_abc --system-prompt-file ./prompt.md -y - weknora agent edit ag_abc --config-file ./tuned.yaml --temperature 0.9 -y` +const agentEditExample = ` weknora agent update ag_abc --name "Renamed" -y + weknora agent update ag_abc --description "" -y # clear description + weknora agent update ag_abc --add-kb kb_new --remove-kb kb_old -y + weknora agent update ag_abc --system-prompt-file ./prompt.md -y + weknora agent update ag_abc --config-file ./tuned.yaml --temperature 0.9 -y` -// NewCmdEdit builds `weknora agent edit `. +// NewCmdEdit builds `weknora agent update `. func NewCmdEdit(f *cmdutil.Factory) *cobra.Command { opts := &EditOptions{} var systemPromptFile, configFile string cmd := &cobra.Command{ - Use: "edit ", - Short: "Edit a custom agent's configuration", + Use: "update ", + Short: "Update a custom agent's configuration", Long: agentEditLong, Example: agentEditExample, Args: cobra.ExactArgs(1), @@ -127,6 +126,12 @@ func NewCmdEdit(f *cmdutil.Factory) *cobra.Command { fmt.Sprintf("--temperature must be in 0.0..2.0, got %g", opts.Temperature)) } + // Reject an unknown --agent-mode / --kb-selection-mode up front + // (typed exit 5) rather than letting the server fail the update. + if err := validateAgentModeFlags(&opts.AgentMode, &opts.KBSelectionMode); err != nil { + return err + } + if systemPromptFile != "" { r, err := cmdutil.OpenInput(systemPromptFile) if err != nil { @@ -156,7 +161,7 @@ func NewCmdEdit(f *cmdutil.Factory) *cobra.Command { if !editHasAnyFlag(opts) { return &cmdutil.Error{ Code: cmdutil.CodeInputInvalidArgument, - Message: "agent edit requires at least one flag", + Message: "agent update requires at least one flag", Hint: "pass at least one update flag (e.g., --name, --add-kb, --description) or --config-file", } } @@ -200,7 +205,7 @@ func NewCmdEdit(f *cmdutil.Factory) *cobra.Command { } } if handled, err := cmdutil.HandleDryRun(cmd, true, cmdutil.DryRunPlan{ - Action: "agent.edit", + Action: "agent.update", Args: planArgs, }); handled { return err @@ -208,14 +213,32 @@ func NewCmdEdit(f *cmdutil.Factory) *cobra.Command { } yes, _ := cmd.Flags().GetBool("yes") // Build the retry command from the flags the user actually passed. - retryCmd := buildAgentEditRetryCmd(cmd, opts.AgentID) - if err := cmdutil.ConfirmDestructive(f.Prompter(), yes, fopts.WantsJSON(), "edit", "agent", opts.AgentID, "agent.edit", retryCmd); err != nil { + // --add-kb/--remove-kb/--system-prompt-file/--config-file are excluded + // (multi-value / file-based — a precise single argv is impractical). + retryCmd := cmdutil.BuildRetryArgv(cmd, []string{"weknora", "agent", "update", opts.AgentID}, + "name", "description", "model", "system-prompt", "agent-mode", + "rerank-model", "temperature", "kb-selection-mode", "format") + if err := cmdutil.ConfirmWrite(f.Prompter(), yes, fopts.WantsJSON(), "update", "agent", opts.AgentID, "agent.update", retryCmd); err != nil { return err } cli, err := f.Client() if err != nil { return err } + // Resolve --add-kb values (id or name) to canonical ids, matching + // the --kb id-or-name policy and agent create --attach-kb. Avoids + // silently attaching an unresolvable name as a kb_id. + if len(opts.AddKBs) > 0 { + resolved := make([]string, 0, len(opts.AddKBs)) + for _, raw := range opts.AddKBs { + id, rerr := cmdutil.ResolveKBFlag(cmd.Context(), cli, raw) + if rerr != nil { + return rerr + } + resolved = append(resolved, id) + } + opts.AddKBs = resolved + } return runEdit(cmd.Context(), opts, fopts, cli) }, } @@ -227,68 +250,36 @@ func NewCmdEdit(f *cmdutil.Factory) *cobra.Command { cmd.Flags().StringVar(&opts.SystemPrompt, "system-prompt", "", "System prompt text (mutex with --system-prompt-file)") cmd.Flags().StringVar(&systemPromptFile, "system-prompt-file", "", "Read system prompt from FILE, or '-' for stdin") cmd.MarkFlagsMutuallyExclusive("system-prompt", "system-prompt-file") - cmd.Flags().StringVar(&opts.AgentMode, "agent-mode", "", "Agent operating mode: quick-answer | smart-reasoning") + cmd.Flags().StringVar(&opts.AgentMode, "agent-mode", "", "Agent operating mode: "+strings.Join(agentModeValues, " | ")) cmd.Flags().StringVar(&opts.RerankModel, "rerank-model", "", "Rerank model id") cmd.Flags().Float64Var(&opts.Temperature, "temperature", 0.0, "Generation temperature (0.0..2.0)") cmd.Flags().StringSliceVar(&opts.AddKBs, "add-kb", nil, "Attach knowledge base id (repeatable, idempotent)") cmd.Flags().StringSliceVar(&opts.RemoveKBs, "remove-kb", nil, "Detach knowledge base id (repeatable, idempotent)") - cmd.Flags().StringVar(&opts.KBSelectionMode, "kb-selection-mode", "", "KB selection mode: all | selected | none") + cmd.Flags().StringVar(&opts.KBSelectionMode, "kb-selection-mode", "", "KB selection mode: "+strings.Join(kbSelectionModeValues, " | ")) // Full-replace cmd.Flags().StringVar(&configFile, "config-file", "", "Full AgentConfig YAML or JSON (REPLACES current config baseline; surgical flags then apply on top)") cmdutil.AddFormatFlag(cmd, agentViewFields...) cmdutil.AddDryRunFlag(cmd, &opts.DryRun) - cmdutil.SetRisk(cmd, "agent.edit") + cmdutil.SetWriteRisk(cmd, "agent.update") cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{ - UsedFor: "surgically edit a custom agent's configuration", - RequiredFlags: []string{" (positional)", "at least one edit flag (--name, --add-kb, etc.)"}, + UsedFor: "surgically update a custom agent's configuration", + RequiredFlags: []string{" (positional)", "at least one update flag (--name, --add-kb, etc.)"}, Examples: []string{ - "weknora agent edit ag_abc --name \"Renamed\"", - "weknora agent edit ag_abc --add-kb kb_new --remove-kb kb_old", - "weknora agent edit ag_abc --config-file ./tuned.yaml", + "weknora agent update ag_abc --name \"Renamed\"", + "weknora agent update ag_abc --add-kb kb_new --remove-kb kb_old", + "weknora agent update ag_abc --config-file ./tuned.yaml", }, + Output: "envelope.data is the updated Agent object (id, name, config) after the update is applied", Warnings: []string{ "Requires explicit user approval (exit 10 / input.confirmation_required); never auto-add -y.", - "agent edit overwrites config; fetch-then-update protects unmentioned fields, but bad input still saved.", + "agent update overwrites config; fetch-then-update protects unmentioned fields, but bad input still saved.", }, }) return cmd } -// buildAgentEditRetryCmd constructs a directly-executable retry argv from the -// flags the user actually set so agents can surface a precise re-run command. -func buildAgentEditRetryCmd(cmd *cobra.Command, id string) string { - var parts []string - parts = append(parts, "weknora", "agent", "edit", id) - cmd.Flags().Visit(func(f *pflag.Flag) { - switch f.Name { - case "name": - parts = append(parts, "--name", f.Value.String()) - case "description": - parts = append(parts, "--description", f.Value.String()) - case "model": - parts = append(parts, "--model", f.Value.String()) - case "system-prompt": - parts = append(parts, "--system-prompt", f.Value.String()) - case "agent-mode": - parts = append(parts, "--agent-mode", f.Value.String()) - case "rerank-model": - parts = append(parts, "--rerank-model", f.Value.String()) - case "temperature": - parts = append(parts, "--temperature", f.Value.String()) - case "kb-selection-mode": - parts = append(parts, "--kb-selection-mode", f.Value.String()) - case "format": - parts = append(parts, "--format", f.Value.String()) - // --add-kb, --remove-kb, --system-prompt-file, --config-file are excluded - // because they are multi-value or file-based; a precise argv is impractical. - // The user must reconstruct those manually. - } - }) - parts = append(parts, "-y") - return strings.Join(parts, " ") -} // editHasAnyFlag reports whether opts carries at least one surgical update // signal. Required-flag validation lives in runEdit (not PreRunE) so unit @@ -304,7 +295,7 @@ func runEdit(ctx context.Context, opts *EditOptions, fopts *cmdutil.FormatOption if !editHasAnyFlag(opts) { return &cmdutil.Error{ Code: cmdutil.CodeInputInvalidArgument, - Message: "agent edit requires at least one flag", + Message: "agent update requires at least one flag", Hint: "pass at least one update flag (e.g., --name, --add-kb, --description) or --config-file", } } @@ -373,7 +364,7 @@ func runEdit(ctx context.Context, opts *EditOptions, fopts *cmdutil.FormatOption updated, err := svc.UpdateAgent(ctx, opts.AgentID, req) if err != nil { - return cmdutil.WrapHTTP(err, "edit agent %s", opts.AgentID) + return cmdutil.WrapHTTP(err, "update agent %s", opts.AgentID) } return emitAgent(fopts, updated) } diff --git a/cli/cmd/agent/edit_test.go b/cli/cmd/agent/edit_test.go index 2b12e6c9..058c4137 100644 --- a/cli/cmd/agent/edit_test.go +++ b/cli/cmd/agent/edit_test.go @@ -250,7 +250,7 @@ func TestEdit_SystemPromptFile(t *testing.T) { assert.Equal(t, "new prompt", svc.updateReq.Config.SystemPrompt) } -// withRootHarnessAgent wraps `weknora agent edit ...` under a synthetic root +// withRootHarnessAgent wraps `weknora agent update ...` under a synthetic root // cmd that registers the global persistent flags (mirrors addGlobalFlags in // cmd/root.go). func withRootHarnessAgent(edit *cobra.Command, args ...string) *cobra.Command { @@ -262,7 +262,7 @@ func withRootHarnessAgent(edit *cobra.Command, args ...string) *cobra.Command { ag := &cobra.Command{Use: "agent"} ag.AddCommand(edit) root.AddCommand(ag) - root.SetArgs(append([]string{"agent", "edit"}, args...)) + root.SetArgs(append([]string{"agent", "update"}, args...)) root.SetContext(context.Background()) root.SilenceErrors = true root.SilenceUsage = true @@ -270,7 +270,7 @@ func withRootHarnessAgent(edit *cobra.Command, args ...string) *cobra.Command { } // TestAgentEdit_RequiresConfirmation asserts that without -y (non-TTY / JSON -// mode), agent edit returns input.confirmation_required (exit 10). +// mode), agent update returns input.confirmation_required (exit 10). func TestAgentEdit_RequiresConfirmation(t *testing.T) { iostreams.SetForTest(t) // non-TTY f := &cmdutil.Factory{ @@ -284,7 +284,7 @@ func TestAgentEdit_RequiresConfirmation(t *testing.T) { require.ErrorAs(t, err, &ce) assert.Equal(t, cmdutil.CodeInputConfirmationRequired, ce.Code) assert.Equal(t, 10, cmdutil.ExitCode(err), "exit code 10 per destructive-write protocol") - // retry command must include -y and the agent id - assert.Contains(t, ce.RetryCommand, "-y") - assert.Contains(t, ce.RetryCommand, "ag_abc") + // retry argv must include -y and the agent id + assert.Contains(t, ce.RetryArgv, "-y") + assert.Contains(t, ce.RetryArgv, "ag_abc") } diff --git a/cli/cmd/agent/list.go b/cli/cmd/agent/list.go index 19cbb649..14f81b6c 100644 --- a/cli/cmd/agent/list.go +++ b/cli/cmd/agent/list.go @@ -51,6 +51,11 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { return err } fopts.ResolveDefault(iostreams.IO.IsStdoutTTY()) + // Validate static input before building the client so a bad --limit + // returns input.invalid_argument (exit 5), not an auth error (exit 3). + if err := validateListOpts(opts); err != nil { + return err + } cli, err := f.Client() if err != nil { return err @@ -58,19 +63,22 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { return runList(c.Context(), opts, fopts, cli) }, } - cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum results to return (1..10000)") + cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum results to return — client-side cap; meta.has_more/total_count report the full size (1..10000)") cmdutil.AddFormatFlag(cmd, agentListFields...) cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{ UsedFor: "List custom agents visible to the active tenant. The SDK returns all agents in one call (no server-side pagination); meta.count reflects the full tenant set, --limit caps client-side.", Examples: []string{"weknora agent list --format json", "weknora agent list --limit 10 --format json"}, - Output: "envelope.data is an array of Agent objects with id, name, is_builtin; meta.count is the total returned", + Output: "envelope.data is an array of Agent objects with id, name, is_builtin; meta.total_count is the full tenant set and meta.has_more=true means --limit truncated it (raise --limit to get the rest)", }) return cmd } -func runList(ctx context.Context, opts *ListOptions, fopts *cmdutil.FormatOptions, svc ListService) error { +// validateListOpts checks --limit. Called from RunE before the client is built +// (so a bad value surfaces as exit 5, not an auth error) and at runList's top +// for direct callers; idempotent and nil-safe. +func validateListOpts(opts *ListOptions) error { if opts == nil { - opts = &ListOptions{} + return nil } if opts.Limit < 1 || opts.Limit > 10000 { return &cmdutil.Error{ @@ -78,6 +86,16 @@ func runList(ctx context.Context, opts *ListOptions, fopts *cmdutil.FormatOption Message: fmt.Sprintf("--limit must be in 1..10000, got %d", opts.Limit), } } + return nil +} + +func runList(ctx context.Context, opts *ListOptions, fopts *cmdutil.FormatOptions, svc ListService) error { + if opts == nil { + opts = &ListOptions{} + } + if err := validateListOpts(opts); err != nil { + return err + } items, err := svc.ListAgents(ctx) if err != nil { return cmdutil.WrapHTTP(err, "list agents") @@ -90,16 +108,20 @@ func runList(ctx context.Context, opts *ListOptions, fopts *cmdutil.FormatOption sort.Slice(items, func(i, j int) bool { return items[i].UpdatedAt.After(items[j].UpdatedAt) }) - // --limit applies after sort so the cap returns the top-N most-recent. - // The agent list SDK is unpaginated, so client-side truncation does not - // imply server-side has_more — there is no cursor to continue with. - // has_more is omitted; callers needing all agents should raise --limit. + // The agent list SDK is unpaginated — it returns every agent in one + // call — so the CLI holds the true total and can tell the caller + // whether the client-side --limit dropped any. total_count is the full + // count; has_more flags that --limit truncated it (raise --limit to get + // the rest — there is no server cursor to continue with). + total := len(items) + truncated := false if opts.Limit > 0 && len(items) > opts.Limit { items = items[:opts.Limit] + truncated = true } if fopts.WantsJSON() { - meta := &output.Meta{Count: len(items)} + meta := &output.Meta{Count: output.IntPtr(len(items)), HasMore: truncated, TotalCount: output.IntPtr(total)} return fopts.Emit(iostreams.IO.Out, items, meta) } diff --git a/cli/cmd/agent/list_test.go b/cli/cmd/agent/list_test.go index bf82b31a..952b6e26 100644 --- a/cli/cmd/agent/list_test.go +++ b/cli/cmd/agent/list_test.go @@ -145,6 +145,35 @@ func TestList_Limit_CapsResults(t *testing.T) { } } +// TestList_Truncation_SignalsHasMoreAndTotal pins that --limit truncation +// tells the agent it did not get everything (has_more + total_count). +// Regression: agent list silently dropped agents past --limit with no signal. +func TestList_Truncation_SignalsHasMoreAndTotal(t *testing.T) { + out, _ := iostreams.SetForTest(t) + if err := runList(context.Background(), &ListOptions{Limit: 5}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, &fakeListSvc{items: makeAgents(20)}); err != nil { + t.Fatalf("runList: %v", err) + } + if !strings.Contains(out.String(), `"has_more":true`) { + t.Errorf("truncated list must set has_more:true; got:\n%s", out.String()) + } + if !strings.Contains(out.String(), `"total_count":20`) { + t.Errorf("truncated list must report total_count:20; got:\n%s", out.String()) + } +} + +func TestList_NoTruncation_OmitsHasMore(t *testing.T) { + out, _ := iostreams.SetForTest(t) + if err := runList(context.Background(), &ListOptions{Limit: 30}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, &fakeListSvc{items: makeAgents(3)}); err != nil { + t.Fatalf("runList: %v", err) + } + if strings.Contains(out.String(), `"has_more"`) { + t.Errorf("non-truncated list must omit has_more; got:\n%s", out.String()) + } + if !strings.Contains(out.String(), `"total_count":3`) { + t.Errorf("list must report total_count:3; got:\n%s", out.String()) + } +} + func TestList_Limit_Zero_Rejected(t *testing.T) { _, _ = iostreams.SetForTest(t) err := runList(context.Background(), &ListOptions{Limit: 0}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, &fakeListSvc{items: makeAgents(7)}) diff --git a/cli/cmd/api/api.go b/cli/cmd/api/api.go index d45336f5..630b2029 100644 --- a/cli/cmd/api/api.go +++ b/cli/cmd/api/api.go @@ -1,12 +1,15 @@ // Package api implements the `weknora api` raw HTTP passthrough command. // // Shape: one positional (path) + `-X/--method` flag, default GET (auto- -// promoted to POST when a body is supplied via --input). Default raw -// response body to stdout; --format json emits a {status, headers, body} -// object. Reuses sdk.Client.Raw which already applies tenant + auth headers. +// promoted to POST when a body is supplied via -d/--input). Text mode writes +// the raw response body to stdout; --format json places the parsed server +// response directly under envelope.data (no {status, headers, body} wrapper), +// so `--jq '.data...'` projects at the server's own depth. Reuses +// sdk.Client.Raw which already applies tenant + auth headers. package api import ( + "bytes" "context" "encoding/json" "fmt" @@ -31,7 +34,9 @@ var apiFields = []string{""} type Options struct { Method string - Input string // --input: file path, "-" for stdin + Input string // --input: file path, "-" for stdin + Data string // -d/--data: inline JSON body (mutually exclusive with --input) + Fields []string // -F/--field: key=value pairs assembled into a JSON object body (mutually exclusive with -d/--input) Yes bool DryRun bool StdinReader io.Reader // overridden by tests; defaults to iostreams.IO.In @@ -51,19 +56,23 @@ func NewCmd(f *cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ Use: "api ", Short: "Make a raw API request to the WeKnora server", - Long: `Raw HTTP API access. Body via --input or --input - (stdin). + Long: `Raw HTTP API access. JSON body via -d/--data (inline) or --input /- (stdin). -The default method is GET; passing --input auto-promotes it to POST. Use --X/--method to override (any non-empty method is accepted: DELETE / PUT / -PATCH / HEAD / OPTIONS / TRACE / custom). +The default method is GET; supplying a body (-d or --input) auto-promotes it +to POST. Use -X/--method to override (any non-empty method is accepted: +DELETE / PUT / PATCH / HEAD / OPTIONS / TRACE / custom). Auth, tenant, and request-id headers are applied automatically from the -active profile. The response body is written to stdout by default; use ---format json to emit a {status, headers, body} envelope. +active profile. In text mode (default) the raw server response body is written +to stdout. In --format json the parsed server response is placed directly +under envelope.data — drill in with --jq '.data...' at the server's own depth +(e.g. '.data.data[]' for a list endpoint). Only -X DELETE is confirmation-gated. Examples: - weknora api /api/v1/knowledge-bases # GET - echo '{"name":"foo"}' | weknora api /api/v1/knowledge-bases --input - # POST (auto) + weknora api /api/v1/knowledge-bases # GET + weknora api /api/v1/knowledge-bases -d '{"name":"foo"}' # POST (auto) + weknora api /api/v1/knowledge-bases -F name=foo -F enabled=true # POST, typed body + echo '{"name":"foo"}' | weknora api /api/v1/knowledge-bases --input - # POST via stdin weknora api /api/v1/knowledge-bases/kb_xxx -X DELETE`, Args: cobra.ExactArgs(1), RunE: func(c *cobra.Command, args []string) error { @@ -86,14 +95,14 @@ Examples: "--dry-run requires explicit -X POST/PUT/PATCH/DELETE; default GET is read-only with no side effect to preview")) } var body any - if opts.Input != "" { - contents, err := readInput(opts) + if hasBody(opts) { + contents, err := resolveBody(opts) if err != nil { return err } - // Best-effort JSON decode so the plan body surfaces a - // structured object (agents grep meta.plan.body for shape); - // fall back to raw string when the payload isn't JSON. + // Surface the parsed body in the plan so agents can grep + // meta.plan.body for shape; resolveBody has already validated + // it as JSON. var parsed any if json.Unmarshal(contents, &parsed) == nil { body = parsed @@ -114,7 +123,7 @@ Examples: // Escape-hatch DELETE through `weknora api` is just as destructive // as `weknora kb delete` - exit-10 protocol must apply (cli/README.md). if method == http.MethodDelete { - if err := cmdutil.ConfirmDestructive(f.Prompter(), opts.Yes, fopts.WantsJSON(), "delete", "endpoint", args[0], "api.delete", "weknora api -X DELETE "+args[0]+" -y"); err != nil { + if err := cmdutil.ConfirmDestructive(f.Prompter(), opts.Yes, fopts.WantsJSON(), "delete", "endpoint", args[0], "api.delete", []string{"weknora", "api", "-X", "DELETE", args[0], "-y"}); err != nil { return err } } @@ -126,8 +135,10 @@ Examples: return runAPI(c.Context(), opts, fopts, cli, method, args[0], paginate) }, } - cmd.Flags().StringVarP(&opts.Method, "method", "X", "", "HTTP method (default: GET, or POST when --input is supplied). Any non-empty method is accepted.") - cmd.Flags().StringVar(&opts.Input, "input", "", "Read request body from file (use `-` for stdin)") + cmd.Flags().StringVarP(&opts.Method, "method", "X", "", "HTTP method (default: GET, or POST when a body is supplied via -d/--input). Any non-empty method is accepted.") + cmd.Flags().StringVarP(&opts.Data, "data", "d", "", "Inline JSON request body (e.g. -d '{\"name\":\"x\"}'). Mutually exclusive with --input.") + cmd.Flags().StringVar(&opts.Input, "input", "", "Read JSON request body from file (use `-` for stdin). Mutually exclusive with -d/--data.") + cmd.Flags().StringArrayVarP(&opts.Fields, "field", "F", nil, "Add a key=value to a JSON object body (repeatable; e.g. -F name=foo -F enabled=true). true/false/null and numbers are typed; everything else is a string. Flat keys only. Mutually exclusive with -d/--input.") cmd.Flags().Bool("paginate", false, "Follow offset-based pagination (?page=N&page_size=M), merging all pages into a single {data, total} JSON response.") cmdutil.AddFormatFlag(cmd, apiFields...) cmdutil.AddDryRunFlag(cmd, &opts.DryRun) @@ -136,10 +147,12 @@ Examples: RequiredFlags: []string{"path (positional)"}, Examples: []string{ "weknora api /api/v1/knowledge-bases", + "weknora api /api/v1/knowledge-bases -d '{\"name\":\"foo\"}'", + "weknora api -X POST /api/v1/knowledge-bases -F name=\"Eng Docs\" -F enabled=true", "weknora api -X DELETE /api/v1/knowledge-bases/kb_x -y", - "echo '{\"name\":\"foo\"}' | weknora api -X POST /api/v1/knowledge-bases --input -", + "echo '{\"name\":\"foo\"}' | weknora api /api/v1/knowledge-bases --input -", }, - Output: "raw server response body or envelope on error", + Output: "text mode (default): the raw server response body on stdout. json mode: the parsed server response is placed directly under envelope.data — project with --jq '.data...' at the server's own depth (e.g. '.data.data[]' for a list endpoint, '.data.data.id' for a created object). With --paginate, envelope.data is the merged {data, total}.", Warnings: []string{ "Only -X DELETE is confirmation-gated (exit 10 / input.confirmation_required unless -y); -X GET/POST/PUT/PATCH and other methods are unguarded — you own the safety of writes made through this escape hatch.", "Raw HTTP passthrough; agents should prefer typed subcommands (kb/doc/session/...) when available.", @@ -151,32 +164,130 @@ Examples: // readInput reads opts.Input and returns its contents. "-" reads from // opts.StdinReader (or iostreams.IO.In as the production default) for // piped JSON payloads. +// +// The body is sent as a JSON document (the SDK marshals it as json.RawMessage), +// so a non-JSON / malformed payload is validated and rejected here as +// input.invalid_argument (exit 5). Without this, the failure surfaced from the +// SDK's marshal step as a confusing network.error (exit 7, "retryable") — +// looping an agent on a body that can never be sent. func readInput(opts *Options) ([]byte, error) { + var b []byte if opts.Input == "-" { r := opts.StdinReader if r == nil { r = iostreams.IO.In } - b, err := io.ReadAll(r) + var err error + b, err = io.ReadAll(r) if err != nil { return nil, cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "read request body from stdin") } - return b, nil + } else { + var err error + b, err = os.ReadFile(opts.Input) + if err != nil { + return nil, cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "read input file %s", opts.Input) + } } - b, err := os.ReadFile(opts.Input) - if err != nil { - return nil, cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "read input file %s", opts.Input) + if len(bytes.TrimSpace(b)) > 0 && !json.Valid(b) { + return nil, cmdutil.NewError(cmdutil.CodeInputInvalidArgument, + "--input must be valid JSON (the request body is sent as a JSON document)") } return b, nil } +// resolveBody returns the request body bytes from -d/--data (inline) or +// --input (file/stdin), or nil when neither is set. Inline --data is +// validated as JSON here; --input is validated inside readInput. The two +// flags are mutually exclusive — checked here (rather than cobra's +// MarkFlagsMutuallyExclusive, whose violation surfaces as an unclassified +// internal.error / exit 1) so the conflict reports input.invalid_argument. +func resolveBody(opts *Options) ([]byte, error) { + set := 0 + if opts.Data != "" { + set++ + } + if opts.Input != "" { + set++ + } + if len(opts.Fields) > 0 { + set++ + } + if set > 1 { + return nil, cmdutil.NewError(cmdutil.CodeInputInvalidArgument, + "-d/--data, --input, and -F/--field are mutually exclusive; supply the body one way") + } + if len(opts.Fields) > 0 { + return buildFieldBody(opts.Fields) + } + if opts.Data != "" { + if !json.Valid([]byte(opts.Data)) { + return nil, cmdutil.NewError(cmdutil.CodeInputInvalidArgument, + "-d/--data must be valid JSON (the request body is sent as a JSON document)") + } + return []byte(opts.Data), nil + } + if opts.Input != "" { + return readInput(opts) + } + return nil, nil +} + +// hasBody reports whether a request body was supplied via -d or --input. +func hasBody(opts *Options) bool { + return opts.Data != "" || opts.Input != "" || len(opts.Fields) > 0 +} + +// inferFieldValue maps a -F value string to a typed JSON scalar: literal +// true/false/null and integer/float literals become typed JSON; everything +// else stays a string. A value that looks numeric but must stay a string +// (e.g. a zero-padded id "007") can't be forced here — use -d/--data or +// --input for full control. +func inferFieldValue(v string) any { + switch v { + case "true": + return true + case "false": + return false + case "null": + return nil + } + if i, err := strconv.ParseInt(v, 10, 64); err == nil { + return i + } + if f, err := strconv.ParseFloat(v, 64); err == nil { + return f + } + return v +} + +// buildFieldBody assembles -F/--field key=value pairs into a JSON object with +// type inference. Flat keys only. Returns input.invalid_argument (exit 5) for +// a malformed pair or an unsupported @file value. +func buildFieldBody(fields []string) ([]byte, error) { + obj := make(map[string]any, len(fields)) + for _, f := range fields { + k, v, ok := strings.Cut(f, "=") + if !ok || k == "" { + return nil, cmdutil.NewError(cmdutil.CodeInputInvalidArgument, + fmt.Sprintf("-F/--field must be key=value, got %q", f)) + } + if strings.HasPrefix(v, "@") { + return nil, cmdutil.NewError(cmdutil.CodeInputInvalidArgument, + fmt.Sprintf("-F %q: @file values are not supported; read a file body with --input", f)) + } + obj[k] = inferFieldValue(v) + } + return json.Marshal(obj) +} + // resolveMethod implements the auto-method behavior: explicit -X wins; -// otherwise body presence promotes GET → POST. +// otherwise body presence (-d/--data or --input) promotes GET → POST. func resolveMethod(opts *Options) string { if opts.Method != "" { return strings.ToUpper(opts.Method) } - if opts.Input != "" { + if hasBody(opts) { return "POST" } return "GET" @@ -207,13 +318,15 @@ func runAPISingle(ctx context.Context, opts *Options, fopts *cmdutil.FormatOptio return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, fmt.Sprintf("path must start with /: %s", path)) } - // Resolve request body from --input (file path or `-` for stdin). + // Resolve request body from -d/--data (inline) or --input (file / `-`). + // Empty input is treated as no body rather than an empty JSON document + // (which fails to marshal). + contents, err := resolveBody(opts) + if err != nil { + return err + } var body any - if opts.Input != "" { - contents, err := readInput(opts) - if err != nil { - return err - } + if len(bytes.TrimSpace(contents)) > 0 { body = json.RawMessage(contents) } @@ -241,38 +354,31 @@ func runAPISingle(ctx context.Context, opts *Options, fopts *cmdutil.FormatOptio return ce } + return emitRawBody(respBody, fopts) +} + +// emitRawBody writes a server response body under the active format. JSON mode +// places the parsed body directly under envelope.data — no {status, headers, +// body} wrapper — so `--jq '.data...'` projects at the server's own shape +// (e.g. `.data.data[]` for a list endpoint) rather than a triple-nested +// `.data.body.data[]`; non-JSON decodes best-effort to a raw string. Text mode +// writes the body verbatim with a trailing newline. Shared by the single and +// paginated-fallback paths so both project identically. +func emitRawBody(body []byte, fopts *cmdutil.FormatOptions) error { out := iostreams.IO.Out if fopts.WantsJSON() { - // Best-effort decode: if response body is valid JSON, surface the - // parsed structure under .body so JSON consumers can drill - // in; otherwise fall back to the raw string. var bodyAny any - if len(respBody) > 0 { - if err := json.Unmarshal(respBody, &bodyAny); err != nil { - bodyAny = string(respBody) + if len(body) > 0 { + if err := json.Unmarshal(body, &bodyAny); err != nil { + bodyAny = string(body) } } - hdrs := make(map[string]string, len(resp.Header)) - for k, v := range resp.Header { - if len(v) > 0 { - hdrs[k] = v[0] - } - } - // Route through fopts.Emit so the payload lives under .data in the - // success envelope. --jq applies to the full envelope, so users - // project with ".data.status", ".data.body", etc. - payload := map[string]any{ - "status": resp.StatusCode, - "headers": hdrs, - "body": bodyAny, - } - return fopts.Emit(out, payload, nil) + return fopts.Emit(out, bodyAny, nil) } - - if _, err := out.Write(respBody); err != nil { + if _, err := out.Write(body); err != nil { return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "write response body") } - if len(respBody) > 0 && respBody[len(respBody)-1] != '\n' { + if len(body) > 0 && body[len(body)-1] != '\n' { _, _ = out.Write([]byte{'\n'}) } return nil @@ -320,7 +426,7 @@ func runAPIPaginated(ctx context.Context, opts *Options, fopts *cmdutil.FormatOp if err := json.Unmarshal(body, &pageResp); err != nil { // Non-JSON response on first page — fall back through the envelope. if page == 1 { - return passThroughFallback(body, resp, fopts) + return emitRawBody(body, fopts) } return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, fmt.Sprintf("--paginate: page %d response not in expected shape: %v", page, err)) @@ -329,7 +435,7 @@ func runAPIPaginated(ctx context.Context, opts *Options, fopts *cmdutil.FormatOp // Heuristic: if the first page lacks pagination metadata, treat the // response as non-paginated and fall back through the envelope. if page == 1 && pageResp.Total == 0 && pageResp.PageSize == 0 { - return passThroughFallback(body, resp, fopts) + return emitRawBody(body, fopts) } allData = append(allData, pageResp.Data...) @@ -349,50 +455,6 @@ func runAPIPaginated(ctx context.Context, opts *Options, fopts *cmdutil.FormatOp return fopts.Emit(iostreams.IO.Out, merged, nil) } -// passThroughFallback handles the case where --paginate detects a -// non-paginated first-page response. When JSON output is requested, it routes -// the body through fopts.Emit in the same {status, headers, body} envelope -// shape that runAPISingle produces — keeping the wire contract consistent -// regardless of whether --paginate was passed. For human-readable mode it -// writes the raw body verbatim (same as the original passThroughRaw behavior). -func passThroughFallback(body []byte, resp *http.Response, fopts *cmdutil.FormatOptions) error { - out := iostreams.IO.Out - if fopts.WantsJSON() { - var bodyAny any - if len(body) > 0 { - if err := json.Unmarshal(body, &bodyAny); err != nil { - bodyAny = string(body) // best-effort string fallback - } - } - hdrs := collectHeaders(resp) - return fopts.Emit(out, map[string]any{ - "status": resp.StatusCode, - "headers": hdrs, - "body": bodyAny, - }, nil) - } - // Human-readable: raw passthrough, same as before. - if _, err := out.Write(body); err != nil { - return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "write response body") - } - if len(body) > 0 && body[len(body)-1] != '\n' { - _, _ = out.Write([]byte{'\n'}) - } - return nil -} - -// collectHeaders extracts the first value of each response header into a flat -// map, matching the shape that runAPISingle emits in its JSON envelope. -func collectHeaders(resp *http.Response) map[string]string { - hdrs := make(map[string]string, len(resp.Header)) - for k, v := range resp.Header { - if len(v) > 0 { - hdrs[k] = v[0] - } - } - return hdrs -} - // extractPageSize parses the page_size query parameter from path, returning 0 // if absent or unparseable. func extractPageSize(path string) int { diff --git a/cli/cmd/api/api_dry_run_test.go b/cli/cmd/api/api_dry_run_test.go index a8e84397..1f69e879 100644 --- a/cli/cmd/api/api_dry_run_test.go +++ b/cli/cmd/api/api_dry_run_test.go @@ -125,3 +125,75 @@ func TestApi_DryRunWithPost_EmitsPlan(t *testing.T) { require.True(t, ok, "plan.body must be a JSON object when --input is valid JSON, got %T", env.Meta.Plan["body"]) assert.Equal(t, "foo", body["name"]) } + +// TestApi_FieldBody_TypedDryRun: -F/--field builds a typed JSON object body — +// true/false/null and numbers are typed, everything else stays a string — and +// the resolved body is surfaced in the dry-run plan for agent inspection. +func TestApi_FieldBody_TypedDryRun(t *testing.T) { + out, _ := iostreams.SetForTest(t) + root := withRootHarness(NewCmd(apiDryRunFactory(t)), + "/api/v1/x", "-X", "POST", + "-F", "name=Hello", "-F", "enabled=true", "-F", "count=3", + "-F", "ratio=1.5", "-F", "empty=null", + "--dry-run", "--format", "json") + require.NoError(t, root.Execute(), "-F + --dry-run must succeed without SDK") + + var env struct { + Meta struct { + Plan map[string]any `json:"plan"` + } `json:"meta"` + } + require.NoError(t, json.Unmarshal(out.Bytes(), &env), "envelope: %q", out.String()) + body, ok := env.Meta.Plan["body"].(map[string]any) + require.True(t, ok, "plan.body must be a JSON object, got %T", env.Meta.Plan["body"]) + assert.Equal(t, "Hello", body["name"], "non-numeric stays string") + assert.Equal(t, true, body["enabled"], "true -> JSON bool") + assert.Equal(t, float64(3), body["count"], "integer -> JSON number") + assert.Equal(t, 1.5, body["ratio"], "float -> JSON number") + assert.Nil(t, body["empty"], "null -> JSON null") +} + +// TestApi_FieldBody_AutoPromotesPost: -F on its own (no -X) auto-promotes +// GET -> POST, matching -d/--input body behavior. +func TestApi_FieldBody_AutoPromotesPost(t *testing.T) { + out, _ := iostreams.SetForTest(t) + root := withRootHarness(NewCmd(apiDryRunFactory(t)), + "/api/v1/x", "-F", "a=b", "--dry-run", "--format", "json") + require.NoError(t, root.Execute()) + var env struct { + Meta struct { + Plan map[string]any `json:"plan"` + } `json:"meta"` + } + require.NoError(t, json.Unmarshal(out.Bytes(), &env)) + assert.Equal(t, "POST", env.Meta.Plan["method"], "-F must auto-promote GET -> POST") +} + +// TestApi_FieldBody_ConflictsWithData: -F and -d are mutually exclusive — the +// conflict is reported as input.invalid_argument (exit 5), consistent with the +// existing -d/--input conflict. +func TestApi_FieldBody_ConflictsWithData(t *testing.T) { + iostreams.SetForTest(t) + root := withRootHarness(NewCmd(apiDryRunFactory(t)), + "/api/v1/x", "-X", "POST", "-F", "a=b", "-d", "{}", "--dry-run", "--format", "json") + err := root.Execute() + require.Error(t, err, "-F + -d must conflict") + var ce *cmdutil.Error + require.ErrorAs(t, err, &ce) + assert.Equal(t, cmdutil.CodeInputInvalidArgument, ce.Code) + assert.Equal(t, 5, cmdutil.ExitCode(err)) +} + +// TestApi_FieldBody_Malformed: a -F value without '=' (or empty key) is a typed +// input error, not a panic or a silently-dropped field. +func TestApi_FieldBody_Malformed(t *testing.T) { + iostreams.SetForTest(t) + root := withRootHarness(NewCmd(apiDryRunFactory(t)), + "/api/v1/x", "-X", "POST", "-F", "nope", "--dry-run", "--format", "json") + err := root.Execute() + require.Error(t, err) + var ce *cmdutil.Error + require.ErrorAs(t, err, &ce) + assert.Equal(t, cmdutil.CodeInputInvalidArgument, ce.Code) + assert.Contains(t, err.Error(), "key=value") +} diff --git a/cli/cmd/api/api_test.go b/cli/cmd/api/api_test.go index c7b1b7ff..c8402edc 100644 --- a/cli/cmd/api/api_test.go +++ b/cli/cmd/api/api_test.go @@ -78,14 +78,12 @@ func TestAPI_GetSuccess_JSON(t *testing.T) { if err := runAPI(context.Background(), &Options{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, cli, "GET", "/api/v1/foo", false); err != nil { t.Fatalf("runAPI: %v", err) } - // v0.7 envelope: {ok:true, data:{status, headers, body}} + // The server response is passed through directly under envelope.data — no + // {status, headers, body} wrapper — so consumers project at the server's + // own depth (e.g. .data.value here). var env struct { - OK bool `json:"ok"` - Data struct { - Status int `json:"status"` - Headers map[string]string `json:"headers"` - Body map[string]any `json:"body"` - } `json:"data"` + OK bool `json:"ok"` + Data map[string]any `json:"data"` } if err := json.Unmarshal(out.Bytes(), &env); err != nil { t.Fatalf("decode envelope JSON: %v\n%s", err, out.String()) @@ -93,15 +91,55 @@ func TestAPI_GetSuccess_JSON(t *testing.T) { if !env.OK { t.Errorf("envelope ok: want true, got false") } - got := env.Data - if got.Status != 200 { - t.Errorf("status: want 200, got %d", got.Status) + if v, ok := env.Data["value"]; !ok || v.(float64) != 42 { + t.Errorf("data.value: want 42 directly under .data (no .body nesting), got %v", env.Data) } - if got.Headers["Content-Type"] != "application/json" { - t.Errorf("Content-Type header missing: %v", got.Headers) +} + +// TestAPI_InlineDataBody verifies -d/--data sends an inline JSON body and +// auto-promotes the method to POST. +func TestAPI_InlineDataBody(t *testing.T) { + _, _ = iostreams.SetForTest(t) + var seenBody []byte + var seenMethod string + cli, stop := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + seenMethod = r.Method + seenBody, _ = io.ReadAll(r.Body) + _, _ = w.Write([]byte(`{"id":"new"}`)) + }) + defer stop() + + opts := &Options{Data: `{"name":"foo"}`} + if err := runAPI(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, cli, resolveMethod(opts), "/api/v1/things", false); err != nil { + t.Fatalf("runAPI: %v", err) } - if v, ok := got.Body["value"]; !ok || v.(float64) != 42 { - t.Errorf("body.value: want 42, got %v", got.Body) + if seenMethod != http.MethodPost { + t.Errorf("inline -d should auto-promote to POST, got %s", seenMethod) + } + if string(seenBody) != `{"name":"foo"}` { + t.Errorf("server received body %q, want the inline --data JSON", seenBody) + } +} + +// TestAPI_InlineDataMalformed_RejectedAsInput pins that a malformed -d body is +// rejected as input.invalid_argument before any request. +func TestAPI_InlineDataMalformed_RejectedAsInput(t *testing.T) { + _, _ = iostreams.SetForTest(t) + called := false + cli, stop := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + called = true + _, _ = w.Write([]byte(`{}`)) + }) + defer stop() + + opts := &Options{Data: `{bad`} + err := runAPI(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, cli, "POST", "/api/v1/things", false) + var typed *cmdutil.Error + if !errors.As(err, &typed) || typed.Code != cmdutil.CodeInputInvalidArgument { + t.Errorf("want input.invalid_argument for malformed -d, got %v", err) + } + if called { + t.Error("server must not be called with a malformed inline body") } } @@ -130,6 +168,33 @@ func TestAPI_PostWithStdinInput(t *testing.T) { } } +// TestAPI_MalformedInputJSON_RejectedAsInput pins that a non-JSON --input body +// is rejected with input.invalid_argument (exit 5) at the boundary, not the +// confusing network.error (exit 7, "retryable") the SDK's marshal step +// produced. The server must never be called. +func TestAPI_MalformedInputJSON_RejectedAsInput(t *testing.T) { + _, _ = iostreams.SetForTest(t) + called := false + cli, stop := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + called = true + _, _ = w.Write([]byte(`{}`)) + }) + defer stop() + + opts := &Options{Input: "-", StdinReader: strings.NewReader(`{bad json`)} + err := runAPI(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, cli, "POST", "/api/v1/things", false) + if err == nil { + t.Fatal("expected an error for malformed --input JSON") + } + var typed *cmdutil.Error + if !errors.As(err, &typed) || typed.Code != cmdutil.CodeInputInvalidArgument { + t.Errorf("want input.invalid_argument, got %v", err) + } + if called { + t.Error("server must not be called with a malformed body") + } +} + // TestAPI_InputFile verifies --input reads the request body from disk. func TestAPI_InputFile(t *testing.T) { _, _ = iostreams.SetForTest(t) @@ -418,14 +483,12 @@ func TestAPI_PaginateNoMetadataPassesThrough(t *testing.T) { if called != 1 { t.Errorf("called %d times, want 1 (non-paginated response)", called) } - // H3a: fallback must produce an envelope, not raw passthrough. + // The non-paginated fallback must emit the SAME flat shape as a single + // call: the server response directly under .data (no {status,headers,body} + // wrapper), so --paginate and single-call project identically. var env struct { - OK bool `json:"ok"` - Data struct { - Status int `json:"status"` - Headers map[string]string `json:"headers"` - Body map[string]any `json:"body"` - } `json:"data"` + OK bool `json:"ok"` + Data map[string]any `json:"data"` } if err := json.Unmarshal(out.Bytes(), &env); err != nil { t.Fatalf("non-paginated fallback must emit envelope JSON, unmarshal failed: %v\n%s", err, out.String()) @@ -433,11 +496,8 @@ func TestAPI_PaginateNoMetadataPassesThrough(t *testing.T) { if !env.OK { t.Errorf("envelope ok: want true, got false") } - if env.Data.Status != 200 { - t.Errorf("envelope data.status: want 200, got %d", env.Data.Status) - } - if v, ok := env.Data.Body["hello"]; !ok || v.(string) != "world" { - t.Errorf("envelope data.body: want {hello:world}, got %v", env.Data.Body) + if v, ok := env.Data["hello"]; !ok || v.(string) != "world" { + t.Errorf("fallback must pass the body through flat under .data (.data.hello), got %v", env.Data) } } diff --git a/cli/cmd/auth/list.go b/cli/cmd/auth/list.go index 0ead9751..86ea8ff9 100644 --- a/cli/cmd/auth/list.go +++ b/cli/cmd/auth/list.go @@ -74,7 +74,7 @@ func runList(fopts *cmdutil.FormatOptions, f *cmdutil.Factory) error { sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name }) if fopts.WantsJSON() { - meta := &output.Meta{Count: len(entries)} + meta := &output.Meta{Count: output.IntPtr(len(entries))} return fopts.Emit(iostreams.IO.Out, entries, meta) } if len(entries) == 0 { diff --git a/cli/cmd/auth/login.go b/cli/cmd/auth/login.go index 724f27ec..561048a4 100644 --- a/cli/cmd/auth/login.go +++ b/cli/cmd/auth/login.go @@ -20,7 +20,7 @@ import ( // keyring; agents who need to verify the credential should re-run // `auth status`. var authLoginFields = []string{ - "profile", "host", "mode", "user", "tenant_id", + "profile", "host", "mode", "email", "tenant_id", } // LoginOptions is the configuration captured from flags + prompts. Host and @@ -104,7 +104,7 @@ Credentials are persisted to the OS keyring when available; otherwise to a cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{ UsedFor: "authenticate the active profile; --with-token reads an API key from stdin (non-interactive)", Examples: []string{`echo "$WEKNORA_API_KEY" | weknora auth login --with-token`}, - Output: "envelope.data is {profile, host, mode, user, tenant_id} on success", + Output: "envelope.data is {profile, host, mode, email, tenant_id} on success (email matches `auth status`)", Warnings: []string{ "a profile must exist and be active first (`weknora profile add --host --use`)", "password login is interactive-only (no flags) — agents must use --with-token with the key piped to stdin", @@ -171,7 +171,7 @@ func runLogin(ctx context.Context, opts *LoginOptions, fopts *cmdutil.FormatOpti } // Reject shell-metacharacter / path-like names up-front so opts.Profile // stays safe to interpolate into the keyring namespace, config.yaml - // keys, and (later) envelope.error.retry_command. Matches `profile add`. + // keys, and envelope.error.retry_argv. Matches `profile add`. if err := cmdutil.ValidateProfileName(opts.Profile); err != nil { return err } @@ -313,7 +313,10 @@ type loginResult struct { Profile string `json:"profile"` Host string `json:"host"` Mode string `json:"mode"` // ModeBearer or ModeAPIKey - User string `json:"user,omitempty"` + // Email is the authenticated principal's email. Named "email" (not + // "user") so the identity field matches `auth status`, which also + // exposes it as `email` — one key for one concept across both commands. + Email string `json:"email,omitempty"` TenantID uint64 `json:"tenant_id,omitempty"` } @@ -344,7 +347,7 @@ func saveProfileRef(opts *LoginOptions, fopts *cmdutil.FormatOptions, f *cmdutil // user != nil would mislabel it as bearer. result := loginResult{Profile: opts.Profile, Host: opts.Host, Mode: mode} if user != nil { - result.User = user.Email + result.Email = user.Email result.TenantID = user.TenantID } return fopts.Emit(iostreams.IO.Out, result, nil) diff --git a/cli/cmd/auth/logout.go b/cli/cmd/auth/logout.go index 49fa64ea..c35b25a6 100644 --- a/cli/cmd/auth/logout.go +++ b/cli/cmd/auth/logout.go @@ -92,6 +92,7 @@ accepted until it expires.`, "weknora --profile staging auth logout", "weknora auth logout --all", }, + Output: "envelope.data is {removed: [profile names cleared]}", Warnings: []string{ "Requires explicit user approval (exit 10 / input.confirmation_required); never auto-add -y.", "auth logout clears local credentials for this profile; server-side session continues until expiry.", @@ -116,9 +117,9 @@ func runLogout(opts *LogoutOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Fac // Destructive-write protocol: confirm before clearing credentials. scope := fmt.Sprintf("%d profile(s) [%s]", len(targets), strings.Join(targets, ", ")) - retryCmd := "weknora auth logout -y" + retryCmd := []string{"weknora", "auth", "logout", "-y"} if opts.All { - retryCmd = "weknora auth logout --all -y" + retryCmd = []string{"weknora", "auth", "logout", "--all", "-y"} } if err := cmdutil.ConfirmDestructive(f.Prompter(), opts.Yes, fopts.WantsJSON(), "delete", "auth credentials", scope, "auth.logout", retryCmd); err != nil { return err diff --git a/cli/cmd/auth/status.go b/cli/cmd/auth/status.go index 62f398db..dbcc3500 100644 --- a/cli/cmd/auth/status.go +++ b/cli/cmd/auth/status.go @@ -3,6 +3,8 @@ package auth import ( "context" "fmt" + "os" + "strings" "github.com/spf13/cobra" @@ -14,7 +16,7 @@ import ( // authStatusFields enumerates the fields surfaced for `--format json` discovery // on `auth status`. Single-resource shape: filter applies to data itself. var authStatusFields = []string{ - "profile", "user_id", "username", "email", "is_active", + "profile", "host", "auth_source", "user_id", "username", "email", "is_active", "can_access_all_tenants", "tenant_id", "tenant_name", } @@ -29,6 +31,8 @@ type StatusService interface { // account) without a second round-trip. type statusResult struct { Profile string `json:"profile"` + Host string `json:"host,omitempty"` + AuthSource string `json:"auth_source,omitempty"` UserID string `json:"user_id,omitempty"` Username string `json:"username,omitempty"` Email string `json:"email,omitempty"` @@ -68,7 +72,7 @@ usually only surfaces a hard auth failure.`, cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{ UsedFor: "show the active profile, the authenticated principal, and token state", Examples: []string{"weknora auth status", "weknora auth status --jq .data.tenant_id"}, - Output: "envelope.data is {profile, host, user, tenant, ...}; exit 3 if unauthenticated", + Output: "envelope.data is {profile, host, auth_source, user_id, username, email, tenant_id, tenant_name, ...}; auth_source is 'profile + keyring' or a stateless env credential; exit 3 if unauthenticated", }) return cmd } @@ -89,8 +93,24 @@ func runStatus(ctx context.Context, fopts *cmdutil.FormatOptions, f *cmdutil.Fac return err } + // Effective host + auth source. Stateless env credentials + // (WEKNORA_TOKEN/WEKNORA_API_KEY) override the profile + keyring for the + // client, so report the host they authenticate against (WEKNORA_HOST) and + // that they are in effect — not the bypassed config profile's host. + host := "" + if c, ok := cfg.Profiles[cfg.CurrentProfile]; ok { + host = c.Host + } + authSource := "profile + keyring" + if active, kind := cmdutil.EnvCredential(); active { + authSource = kind + " env (stateless)" + if h := strings.TrimSpace(os.Getenv("WEKNORA_HOST")); h != "" { + host = h + } + } + if fopts.WantsJSON() { - result := statusResult{Profile: cfg.CurrentProfile} + result := statusResult{Profile: cfg.CurrentProfile, Host: host, AuthSource: authSource} if user != nil { result.UserID = user.ID result.Username = user.Username @@ -105,12 +125,9 @@ func runStatus(ctx context.Context, fopts *cmdutil.FormatOptions, f *cmdutil.Fac return fopts.Emit(iostreams.IO.Out, result, nil) } - host := "" - if c, ok := cfg.Profiles[cfg.CurrentProfile]; ok { - host = c.Host - } - fmt.Fprintf(iostreams.IO.Out, "profile: %s\n", cfg.CurrentProfile) - fmt.Fprintf(iostreams.IO.Out, "host: %s\n", host) + fmt.Fprintf(iostreams.IO.Out, "profile: %s\n", cfg.CurrentProfile) + fmt.Fprintf(iostreams.IO.Out, "auth_source: %s\n", authSource) + fmt.Fprintf(iostreams.IO.Out, "host: %s\n", host) if user != nil { fmt.Fprintf(iostreams.IO.Out, "user: %s (%s)\n", user.Email, user.ID) fmt.Fprintf(iostreams.IO.Out, "tenant: %d", user.TenantID) diff --git a/cli/cmd/auth/status_test.go b/cli/cmd/auth/status_test.go index f1fe7b00..bae0e633 100644 --- a/cli/cmd/auth/status_test.go +++ b/cli/cmd/auth/status_test.go @@ -54,8 +54,9 @@ func TestRunStatus_TextOutput(t *testing.T) { } require.NoError(t, runStatus(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, svc)) got := out.String() - assert.Contains(t, got, "profile: prod") - assert.Contains(t, got, "host: https://kb.example.com") + assert.Contains(t, got, "profile: prod") + assert.Contains(t, got, "auth_source: profile + keyring") + assert.Contains(t, got, "host: https://kb.example.com") assert.Contains(t, got, "alice@example.com") assert.Contains(t, got, "Acme") } diff --git a/cli/cmd/auth/token.go b/cli/cmd/auth/token.go index 3d948278..f438b233 100644 --- a/cli/cmd/auth/token.go +++ b/cli/cmd/auth/token.go @@ -57,9 +57,9 @@ to see which mode each profile uses, and construct the matching HTTP header: } // `auth token` is a scalar scripting helper (WEKNORA_TOKEN=$(...)), // so it defaults to the raw token — overriding the global json - // default (gh auth token does the same; cf. doc download streaming - // raw bytes). Explicit --format json / WEKNORA_FORMAT=json still - // emit the {token,mode,profile} envelope. + // default, the same way `doc download` streams raw bytes. Explicit + // --format json / WEKNORA_FORMAT=json still emit the + // {token,mode,profile} envelope. fopts.FromEnv() if fopts.Mode == "" { // --jq implies JSON (it filters the envelope); without it, @@ -83,7 +83,7 @@ to see which mode each profile uses, and construct the matching HTTP header: }, Output: "raw token on stdout (no envelope, no trailing newline) by default; --format json emits {token, mode, profile}", Warnings: []string{ - "default output is the bare token, NOT the JSON envelope — it is a scripting helper (cf. `gh auth token`)", + "default output is the bare token, NOT the JSON envelope — it is a scripting helper for shell capture", }, }) return cmd diff --git a/cli/cmd/chat/chat.go b/cli/cmd/chat/chat.go index cdac8452..45a30f0f 100644 --- a/cli/cmd/chat/chat.go +++ b/cli/cmd/chat/chat.go @@ -116,7 +116,7 @@ both flags for the complete projected stream.`, cmdutil.AddFormatFlag(cmd, chatFields...) cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{ UsedFor: "Ask a RAG question against a knowledge base. Default JSON returns a bounded answer-event projection. --reference adds indexed citations; --verbose adds reasoning, tools, and lifecycle events. --format ndjson streams raw SDK events; --format text renders the selected events live.", - RequiredFlags: []string{"--kb"}, + RequiredFlags: []string{" (positional)", "--kb"}, Examples: []string{ `weknora chat "What is RRF?" --kb kb_abc`, `weknora chat "What is RRF?" --kb kb_abc --jq '[.data.events[].content] | join("")'`, diff --git a/cli/cmd/chunk/delete.go b/cli/cmd/chunk/delete.go index e1d333c7..b3d8545b 100644 --- a/cli/cmd/chunk/delete.go +++ b/cli/cmd/chunk/delete.go @@ -3,7 +3,6 @@ package chunkcmd import ( "context" "fmt" - "strings" "github.com/spf13/cobra" @@ -101,7 +100,9 @@ func NewCmdDelete(f *cmdutil.Factory) *cobra.Command { opts.ChunkID = args[0] return runDelete(c.Context(), opts, fopts, cli, f.Prompter()) } - if err := cmdutil.ConfirmDestructiveBatch(f.Prompter(), opts.Yes, fopts.WantsJSON(), "delete", "chunk", len(args), "chunk.delete", "weknora chunk delete "+strings.Join(args, " ")+" --doc "+opts.DocID+" -y"); err != nil { + batchRetry := append([]string{"weknora", "chunk", "delete"}, args...) + batchRetry = append(batchRetry, "--doc", opts.DocID, "-y") + if err := cmdutil.ConfirmDestructiveBatch(f.Prompter(), opts.Yes, fopts.WantsJSON(), "delete", "chunk", len(args), "chunk.delete", batchRetry); err != nil { return err } outcomes, runErr := cmdutil.RunBatch(c.Context(), args, func(ctx context.Context, id string) error { @@ -134,6 +135,7 @@ func NewCmdDelete(f *cmdutil.Factory) *cobra.Command { "weknora chunk delete c1 c2 c3 --doc doc_xyz -y", "weknora chunk delete chunk_abc --doc doc_xyz -y --format json", }, + Output: "envelope.data shape depends on the form: a single chunk id -> {id, deleted:true}; multiple ids -> batch envelope (top-level status success|partial|error, ok=(failures==0), data per-item [{id, ok, error?}], meta.successes/failures — read data[].ok per id).", Warnings: []string{ "Requires explicit user approval (exit 10 / input.confirmation_required); never auto-add -y.", "chunk delete is irreversible; loses the chunk + breaks RAG retrieval coherence for downstream queries.", @@ -143,7 +145,7 @@ func NewCmdDelete(f *cmdutil.Factory) *cobra.Command { } 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(), "delete", "chunk", opts.ChunkID, "chunk.delete", "weknora chunk delete "+opts.ChunkID+" --doc "+opts.DocID+" -y"); err != nil { + if err := cmdutil.ConfirmDestructive(p, opts.Yes, fopts.WantsJSON(), "delete", "chunk", opts.ChunkID, "chunk.delete", []string{"weknora", "chunk", "delete", opts.ChunkID, "--doc", opts.DocID, "-y"}); err != nil { return err } if err := svc.DeleteChunk(ctx, opts.DocID, opts.ChunkID); err != nil { diff --git a/cli/cmd/chunk/delete_test.go b/cli/cmd/chunk/delete_test.go index 8f246e8f..ac230ff7 100644 --- a/cli/cmd/chunk/delete_test.go +++ b/cli/cmd/chunk/delete_test.go @@ -178,7 +178,7 @@ func TestMultiDelete_NonTTY_NoYes_RequiresConfirmation(t *testing.T) { _, _ = iostreams.SetForTest(t) svc := &fakeMultiChunkDeleteSvc{} fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatText} - err := cmdutil.ConfirmDestructiveBatch(&testutil.ConfirmPrompter{}, false, fopts.WantsJSON(), "delete", "chunk", 2, "chunk.delete", "") + err := cmdutil.ConfirmDestructiveBatch(&testutil.ConfirmPrompter{}, false, fopts.WantsJSON(), "delete", "chunk", 2, "chunk.delete", nil) require.Error(t, err) var typed *cmdutil.Error require.ErrorAs(t, err, &typed) diff --git a/cli/cmd/chunk/list.go b/cli/cmd/chunk/list.go index ee811085..24c2fbbd 100644 --- a/cli/cmd/chunk/list.go +++ b/cli/cmd/chunk/list.go @@ -91,6 +91,12 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { return err } fopts.ResolveDefault(iostreams.IO.IsStdoutTTY()) + // Validate static input before building the client so a bad + // --limit/--page-size returns input.invalid_argument (exit 5), not + // an auth error (exit 3). + if err := validateListOpts(opts); err != nil { + return err + } cli, err := f.Client() if err != nil { return err @@ -100,7 +106,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { } cmd.Flags().StringVar(&opts.DocID, "doc", "", "Document id (SDK knowledge_id) to enumerate chunks for") _ = cmd.MarkFlagRequired("doc") - cmd.Flags().IntVarP(&opts.Limit, "limit", "L", defaultLimit, "Maximum results to return (1..1000)") + cmd.Flags().IntVarP(&opts.Limit, "limit", "L", defaultLimit, "Maximum results to return — client-side cap; meta.has_more reports truncation (1..1000)") cmd.Flags().IntVar(&opts.PageSize, "page-size", defaultPageSize, "Items per server batch (1..1000)") cmd.Flags().BoolVar(&opts.AllPages, "all-pages", false, "Walk all server pages until exhausted (or --limit hit)") cmdutil.AddFormatFlag(cmd, chunkListFields...) @@ -113,7 +119,10 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { return cmd } -func runList(ctx context.Context, opts *ListOptions, fopts *cmdutil.FormatOptions, svc ListService) error { +// validateListOpts checks --limit / --page-size. Called from RunE before the +// client is built (so a bad value surfaces as exit 5, not an auth error) and at +// runList's top for direct callers; idempotent. +func validateListOpts(opts *ListOptions) error { if opts.Limit < 1 || opts.Limit > maxLimit { return &cmdutil.Error{ Code: cmdutil.CodeInputInvalidArgument, @@ -126,6 +135,13 @@ func runList(ctx context.Context, opts *ListOptions, fopts *cmdutil.FormatOption Message: fmt.Sprintf("--page-size must be in 1..%d, got %d", maxPageSize, opts.PageSize), } } + return nil +} + +func runList(ctx context.Context, opts *ListOptions, fopts *cmdutil.FormatOptions, svc ListService) error { + if err := validateListOpts(opts); err != nil { + return err + } var items []sdk.Chunk truncated := false @@ -163,7 +179,7 @@ func runList(ctx context.Context, opts *ListOptions, fopts *cmdutil.FormatOption } if fopts.WantsJSON() { - meta := &output.Meta{Count: len(items), HasMore: truncated} + meta := &output.Meta{Count: output.IntPtr(len(items)), HasMore: truncated} return fopts.Emit(iostreams.IO.Out, items, meta) } diff --git a/cli/cmd/config/config.go b/cli/cmd/config/config.go new file mode 100644 index 00000000..2d9cd7b0 --- /dev/null +++ b/cli/cmd/config/config.go @@ -0,0 +1,25 @@ +// Package configcmd holds the `weknora config` command tree. +// +// Currently a single read-only `view` subcommand that prints the resolved +// CLI configuration and its resolution chain. No mutation, no network. +// +// Package name `configcmd` (not `config`) avoids colliding with the +// internal/config package and matches the cmd-subpackage naming pattern. +// The cobra Use: string is "config" — what users type. +package configcmd + +import ( + "github.com/spf13/cobra" + + "github.com/Tencent/WeKnora/cli/internal/cmdutil" +) + +// NewCmd builds the `weknora config` parent command. +func NewCmd(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "config", + Short: "Inspect the CLI's resolved configuration", + } + cmd.AddCommand(NewCmdView(f)) + return cmd +} diff --git a/cli/cmd/config/view.go b/cli/cmd/config/view.go new file mode 100644 index 00000000..69aaf127 --- /dev/null +++ b/cli/cmd/config/view.go @@ -0,0 +1,275 @@ +package configcmd + +import ( + "fmt" + "os" + "strings" + "text/tabwriter" + + "github.com/spf13/cobra" + + "github.com/Tencent/WeKnora/cli/internal/cmdutil" + "github.com/Tencent/WeKnora/cli/internal/config" + "github.com/Tencent/WeKnora/cli/internal/format" + "github.com/Tencent/WeKnora/cli/internal/iostreams" + "github.com/Tencent/WeKnora/cli/internal/projectlink" + "github.com/Tencent/WeKnora/cli/internal/secrets" + "github.com/Tencent/WeKnora/cli/internal/xdg" +) + +// viewFields enumerates the fields surfaced for `--format json` discovery on +// `config view`. Flat shape — each entry is a top-level key of envelope.data. +var viewFields = []string{ + "active_profile", "profile_source", "auth_source", "host", + "kb_id", "kb_source", + "log_level", "log_level_source", + "format_default", + "config_file", "cache_dir", "secrets", "project_link", +} + +// viewData is the flat JSON payload describing the resolved config and where +// each value came from. Sources are stable human strings (e.g. "--profile +// flag") so an agent can explain the resolution without re-deriving it. +type viewData struct { + ActiveProfile string `json:"active_profile"` + ProfileSource string `json:"profile_source"` + AuthSource string `json:"auth_source"` + Host string `json:"host"` + KBID string `json:"kb_id"` + KBSource string `json:"kb_source"` + LogLevel string `json:"log_level"` + LogLevelSource string `json:"log_level_source"` + FormatDefault string `json:"format_default"` + ConfigFile string `json:"config_file"` + CacheDir string `json:"cache_dir"` + Secrets string `json:"secrets"` + ProjectLink string `json:"project_link"` +} + +// NewCmdView builds `weknora config view`. Read-only inspection of the +// resolved config and resolution chain. Never builds the SDK client and +// never issues a network request: KB resolution uses ResolveKBLocal, secrets +// storage is detected from the store type, and everything else is read from +// config.yaml / env / flags. +func NewCmdView(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "view", + Short: "Show the resolved configuration and where each value came from", + Long: `Print the configuration currently in effect and its resolution chain: +the active profile (and its source), the profile host, the resolved knowledge +base (and source), log level, default output format, and the on-disk paths for +config / cache / secrets / project link. + +Read-only: no values are mutated and no network request is issued. Succeeds +even when no profile or KB is configured (sources report "(none)" / +"(unresolved)").`, + Args: cobra.NoArgs, + RunE: func(c *cobra.Command, _ []string) error { + fopts, err := cmdutil.CheckFormatFlag(c) + if err != nil { + return err + } + fopts.ResolveDefault(iostreams.IO.IsStdoutTTY()) + return runView(c, f, fopts) + }, + } + cmdutil.AddFormatFlag(cmd, viewFields...) + cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{ + UsedFor: "inspect the resolved CLI config and resolution chain (active profile, host, kb, log level, default format, file paths) without mutating or hitting the network", + Examples: []string{ + "weknora config view", + "weknora config view --format json", + "weknora config view --jq '.data.active_profile'", + }, + Output: "envelope.data is {active_profile, profile_source, auth_source, host, kb_id, kb_source, log_level, log_level_source, format_default, config_file, cache_dir, secrets, project_link}. auth_source reports whether the active credential is the profile+keyring or a stateless WEKNORA_TOKEN/WEKNORA_API_KEY env override.", + }) + return cmd +} + +func runView(cmd *cobra.Command, f *cmdutil.Factory, fopts *cmdutil.FormatOptions) error { + d := resolveView(cmd, f) + + if fopts.WantsJSON() { + return fopts.Emit(iostreams.IO.Out, d, nil) + } + + tw := tabwriter.NewWriter(iostreams.IO.Out, 0, 0, 2, ' ', 0) + row := func(k, v string) { fmt.Fprintf(tw, "%s\t%s\n", k, format.DashIfEmpty(v)) } + row("active_profile", d.ActiveProfile) + row("profile_source", d.ProfileSource) + row("auth_source", d.AuthSource) + row("host", d.Host) + row("kb_id", d.KBID) + row("kb_source", d.KBSource) + row("log_level", d.LogLevel) + row("log_level_source", d.LogLevelSource) + row("format_default", d.FormatDefault) + row("config_file", d.ConfigFile) + row("cache_dir", d.CacheDir) + row("secrets", d.Secrets) + row("project_link", d.ProjectLink) + return tw.Flush() +} + +// resolveView assembles the resolved-config snapshot. Pure read: reads +// config.yaml (no network), env vars, flags, and the local project link. +func resolveView(cmd *cobra.Command, f *cmdutil.Factory) viewData { + d := viewData{} + + // Profile + its source. + d.ActiveProfile, d.ProfileSource = resolveProfile(f) + + // Host: looked up from the active profile in config (best-effort; empty + // when unset, no profile, or config unreadable). + if d.ActiveProfile != "" && f.Config != nil { + if cfg, err := f.Config(); err == nil && cfg != nil { + if prof, ok := cfg.Profiles[d.ActiveProfile]; ok { + d.Host = prof.Host + } + } + } + + // Auth source: stateless env credentials (WEKNORA_TOKEN/WEKNORA_API_KEY) + // override the profile + keyring for the actual client, so report that — + // otherwise host/profile above would silently describe a profile the env + // credential bypassed. WEKNORA_HOST (when set) is the host that env cred + // authenticates against. + if active, kind := cmdutil.EnvCredential(); active { + d.AuthSource = kind + " env (stateless; bypasses profile + keyring)" + if h := strings.TrimSpace(os.Getenv("WEKNORA_HOST")); h != "" { + d.Host = h + } + } else if d.ActiveProfile != "" { + d.AuthSource = "profile + keyring" + } else { + d.AuthSource = "(none)" + } + + // KB id + source (never network — ResolveKBLocal). + d.KBID, d.KBSource = resolveKB(cmd, f) + + // Log level + source. + d.LogLevel, d.LogLevelSource = resolveLogLevel(cmd) + + // Default output format (configured default, not the per-invocation flag). + d.FormatDefault = resolveFormatDefault() + + // Paths. + if p, err := config.Path(); err == nil { + d.ConfigFile = p + } + if p, err := xdg.Path("XDG_CACHE_HOME", ".cache"); err == nil { + d.CacheDir = p + } + d.Secrets = resolveSecrets(f) + d.ProjectLink = resolveProjectLink() + + return d +} + +// resolveProfile mirrors Factory.ActiveProfile's precedence but also reports +// which level supplied the value, so `config view` can explain the choice. +func resolveProfile(f *cmdutil.Factory) (name, source string) { + if f.ProfileOverride != "" { + return f.ProfileOverride, "--profile flag" + } + if v := os.Getenv("WEKNORA_PROFILE"); v != "" { + return v, "WEKNORA_PROFILE env" + } + if f.Config != nil { + if cfg, err := f.Config(); err == nil && cfg != nil && cfg.CurrentProfile != "" { + return cfg.CurrentProfile, "config current_profile" + } + } + return "", "(none)" +} + +// resolveKB reports the locally-resolved KB id and its source. ResolveKBLocal +// never hits the SDK; a CodeKBIDRequired error means nothing is in scope, which +// config view reports as "(unresolved)" rather than failing. +func resolveKB(cmd *cobra.Command, f *cmdutil.Factory) (id, source string) { + id, err := f.ResolveKBLocal(cmd) + if err != nil { + return "", "(unresolved)" + } + // Re-derive which level supplied it (ResolveKBLocal returns only the value). + if v, _ := cmd.Flags().GetString("kb"); v != "" { + return id, "--kb flag" + } + if v := os.Getenv("WEKNORA_KB_ID"); v != "" { + return id, "WEKNORA_KB_ID env" + } + if cwd, werr := os.Getwd(); werr == nil { + if path, found, derr := projectlink.Discover(cwd); derr == nil && found { + return id, "project link " + path + } + } + return id, "(unresolved)" +} + +// resolveLogLevel reports the effective level and its source, matching +// ResolveLogLevel's precedence (flag > env > default). +func resolveLogLevel(cmd *cobra.Command) (level, source string) { + level, _ = cmdutil.ResolveLogLevel(cmd, iostreams.IO.Err) + if cmd != nil { + if fl := cmd.Flags().Lookup("log-level"); fl != nil && fl.Changed && cmdutil.IsValidLogLevel(fl.Value.String()) { + return level, "--log-level flag" + } + } + if v := os.Getenv("WEKNORA_LOG_LEVEL"); v != "" && cmdutil.IsValidLogLevel(v) { + return level, "WEKNORA_LOG_LEVEL env" + } + return level, "default" +} + +// resolveFormatDefault reports the configured default output format: the +// config.yaml defaults.format value, else WEKNORA_FORMAT, else the hard +// default. This is the *default* used when --format is unset — not the +// per-invocation --format flag value. +func resolveFormatDefault() string { + cfg, err := config.Load() + if err == nil && cfg != nil && cfg.Defaults.Format != "" { + return cfg.Defaults.Format + } + if v := os.Getenv("WEKNORA_FORMAT"); v == "text" || v == "json" || v == "ndjson" { + return v + } + return string(cmdutil.DefaultFormatMode) +} + +// resolveSecrets reports where credentials are stored: "file " when +// the keyring is unavailable and the layer fell back to a 0600 FileStore, +// otherwise "keyring". Detection mirrors doctor's credential_storage check +// (type-assert on the best-effort store). On construction failure, reports the +// file fallback root so the user knows where to look. +func resolveSecrets(f *cmdutil.Factory) string { + var store secrets.Store + var err error + if f.Secrets != nil { + store, err = f.Secrets() + } else { + store, err = secrets.NewBestEffortStore() + } + if err == nil && store != nil { + if _, isFile := store.(*secrets.FileStore); !isFile { + return "keyring" + } + } + if p, perr := xdg.Path("XDG_CONFIG_HOME", ".config", "secrets"); perr == nil { + return "file " + p + } + return "file" +} + +// resolveProjectLink reports the discovered .weknora/project.yaml path or +// "(none)". Walks up from cwd like ResolveKBLocal does. +func resolveProjectLink() string { + cwd, err := os.Getwd() + if err != nil { + return "(none)" + } + if path, found, derr := projectlink.Discover(cwd); derr == nil && found { + return path + } + return "(none)" +} diff --git a/cli/cmd/config/view_test.go b/cli/cmd/config/view_test.go new file mode 100644 index 00000000..1f6ffd93 --- /dev/null +++ b/cli/cmd/config/view_test.go @@ -0,0 +1,120 @@ +package configcmd + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/Tencent/WeKnora/cli/internal/cmdutil" + "github.com/Tencent/WeKnora/cli/internal/config" + "github.com/Tencent/WeKnora/cli/internal/iostreams" + sdk "github.com/Tencent/WeKnora/client" +) + +type viewEnvelope struct { + OK bool `json:"ok"` + Data struct { + ActiveProfile string `json:"active_profile"` + ProfileSource string `json:"profile_source"` + AuthSource string `json:"auth_source"` + Host string `json:"host"` + KBID string `json:"kb_id"` + KBSource string `json:"kb_source"` + FormatDefault string `json:"format_default"` + ConfigFile string `json:"config_file"` + } `json:"data"` +} + +func runViewJSON(t *testing.T, f *cmdutil.Factory) viewEnvelope { + t.Helper() + out, _ := iostreams.SetForTest(t) + // --format is a root-level persistent flag, so drive runView directly with + // a JSON FormatOptions rather than through standalone leaf flag parsing. + cmd := NewCmdView(f) + require.NoError(t, runView(cmd, f, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON})) + var env viewEnvelope + require.NoError(t, json.Unmarshal(out.Bytes(), &env), "envelope: %q", out.String()) + return env +} + +// TestConfigView_ConfigProfileSource: with a current_profile set in config and +// no flag/env override, config view reports the profile, its source, and the +// host — and resolves the KB to "(unresolved)" (no --kb/env/link) without error. +func TestConfigView_ConfigProfileSource(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + cfg := &config.Config{ + CurrentProfile: "prod", + Profiles: map[string]config.Profile{"prod": {Host: "https://kb.example.com"}}, + } + require.NoError(t, config.Save(cfg)) + + env := runViewJSON(t, networkFreeFactory(t)) + assert.True(t, env.OK) + assert.Equal(t, "prod", env.Data.ActiveProfile) + assert.Equal(t, "config current_profile", env.Data.ProfileSource) + assert.Equal(t, "https://kb.example.com", env.Data.Host) + assert.Equal(t, "(unresolved)", env.Data.KBSource, "no --kb/env/link must report unresolved, not error") + assert.Equal(t, "json", env.Data.FormatDefault) + assert.NotEmpty(t, env.Data.ConfigFile) +} + +// TestConfigView_ProfileOverrideSource: the --profile override (set on the +// Factory by root's PersistentPreRunE) wins over config and is reported as +// such, so an agent can see why a non-default profile is active. +func TestConfigView_ProfileOverrideSource(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + cfg := &config.Config{ + CurrentProfile: "prod", + Profiles: map[string]config.Profile{"prod": {Host: "https://kb.example.com"}}, + } + require.NoError(t, config.Save(cfg)) + + f := networkFreeFactory(t) + f.ProfileOverride = "other" + env := runViewJSON(t, f) + assert.Equal(t, "other", env.Data.ActiveProfile) + assert.Equal(t, "--profile flag", env.Data.ProfileSource) +} + +// TestConfigView_NoProfile: config view succeeds with an empty config — +// active_profile "" / source "(none)" — never erroring on a fresh install. +func TestConfigView_NoProfile(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + env := runViewJSON(t, networkFreeFactory(t)) + assert.True(t, env.OK) + assert.Equal(t, "", env.Data.ActiveProfile) + assert.Equal(t, "(none)", env.Data.ProfileSource) +} + +// networkFreeFactory builds a Factory whose Client closure fails the test if +// invoked — config view must resolve everything locally (ResolveKBLocal, +// ActiveProfile) and never build the SDK client. +func networkFreeFactory(t *testing.T) *cmdutil.Factory { + t.Helper() + f := cmdutil.New() + f.Client = func() (*sdk.Client, error) { + t.Fatal("config view must not build the SDK client / hit the network") + return nil, nil + } + return f +} + +// TestConfigView_EnvCredentialSurfaced: when WEKNORA_API_KEY + WEKNORA_HOST are +// set, config view reports the env override (auth_source + the env host), not +// the bypassed config profile's host. +func TestConfigView_EnvCredentialSurfaced(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + cfg := &config.Config{ + CurrentProfile: "prod", + Profiles: map[string]config.Profile{"prod": {Host: "https://configured.example.com"}}, + } + require.NoError(t, config.Save(cfg)) + t.Setenv("WEKNORA_API_KEY", "sk-test") + t.Setenv("WEKNORA_HOST", "https://env-override.example.com") + + env := runViewJSON(t, networkFreeFactory(t)) + assert.Contains(t, env.Data.AuthSource, "WEKNORA_API_KEY env") + assert.Equal(t, "https://env-override.example.com", env.Data.Host, "host must be the env override, not the profile host") +} diff --git a/cli/cmd/doc/create.go b/cli/cmd/doc/create.go index b6f5eda9..64f6f02c 100644 --- a/cli/cmd/doc/create.go +++ b/cli/cmd/doc/create.go @@ -26,12 +26,17 @@ var docCreateFields = []string{ // CreateOptions holds CLI flag values for `doc create`. type CreateOptions struct { Text string // --text (required): document text content (Markdown) - Name string // --name: document title + Title string // --title: document title (preferred; matches `doc update --title`) + Name string // --name: deprecated alias for --title TagID string // --tag-id: associate with a tag Channel string // --channel: ingestion-channel tag (default "api") DryRun bool } +// title returns the resolved document title, preferring --title over the +// deprecated --name alias. +func (o *CreateOptions) title() string { return cmp.Or(o.Title, o.Name) } + // CreateService is the narrow SDK surface for `doc create`. // *sdk.Client satisfies it. type CreateService interface { @@ -54,12 +59,12 @@ don't require a file upload or remote URL. KB resolution follows the standard 4-level chain: --kb flag > WEKNORA_KB_ID env > .weknora/project.yaml > error. --text Document text in Markdown format (required). - --name Display title stored with the entry. + --title <title> Display title stored with the entry (matches doc update --title). --tag-id <id> Associate the new entry with a tag. --channel <name> Override the ingestion-channel tag (default "api").`, Example: ` weknora doc create --text "# Meeting Notes\n\nAction items: ..." - weknora doc create --text "$(cat notes.md)" --name "Sprint Notes" --kb my-kb - weknora doc create --text "API usage guide" --name "API Guide" --tag-id tag_abc`, + weknora doc create --text "$(cat notes.md)" --title "Sprint Notes" --kb my-kb + weknora doc create --text "API usage guide" --title "API Guide" --tag-id tag_abc`, Args: cobra.NoArgs, RunE: func(c *cobra.Command, _ []string) error { fopts, err := cmdutil.CheckFormatFlag(c) @@ -78,9 +83,9 @@ don't require a file upload or remote URL. KB resolution follows the standard if handled, err := cmdutil.HandleDryRun(c, true, cmdutil.DryRunPlan{ Action: "doc.create", Args: map[string]any{ - "text": opts.Text, - "name": opts.Name, - "kb": kbID, + "text": opts.Text, + "title": opts.title(), + "kb": kbID, }, }); handled { return err @@ -100,7 +105,9 @@ don't require a file upload or remote URL. KB resolution follows the standard } cmdutil.AddKBFlag(cmd) cmd.Flags().StringVar(&opts.Text, "text", "", "Document text content in Markdown format (required)") - cmd.Flags().StringVar(&opts.Name, "name", "", "Document title") + cmd.Flags().StringVar(&opts.Title, "title", "", "Document title") + cmd.Flags().StringVar(&opts.Name, "name", "", "Document title (deprecated: use --title)") + _ = cmd.Flags().MarkDeprecated("name", "use --title instead") cmd.Flags().StringVar(&opts.TagID, "tag-id", "", "Tag id to associate with the new entry") cmd.Flags().StringVar(&opts.Channel, "channel", "", "Ingestion-channel tag recorded server-side (default \"api\")") _ = cmd.MarkFlagRequired("text") @@ -108,8 +115,12 @@ don't require a file upload or remote URL. KB resolution follows the standard cmdutil.AddDryRunFlag(cmd, &opts.DryRun) cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{ UsedFor: "Create a knowledge entry from inline Markdown text. KB resolved via --kb flag, WEKNORA_KB_ID env, or project link. Emits the created Knowledge object with its id.", - RequiredFlags: []string{"--text"}, - Output: "envelope.data is the created Knowledge object with id, knowledge_base_id, title, parse_status", + RequiredFlags: []string{"--text", "--kb (or WEKNORA_KB_ID / project link)"}, + Examples: []string{ + `weknora doc create --kb kb_eng --text "# Runbook\n\nRestart steps: ..."`, + `weknora doc create --kb kb_eng --text "$(cat notes.md)" --title "Sprint Notes"`, + }, + Output: "envelope.data is the created Knowledge object with id, knowledge_base_id, title, parse_status", }) return cmd } @@ -123,7 +134,7 @@ func runCreate(ctx context.Context, opts *CreateOptions, fopts *cmdutil.FormatOp return cmdutil.NewFlagError(fmt.Errorf("--text is required")) } req := &sdk.CreateManualKnowledgeRequest{ - Title: opts.Name, + Title: opts.title(), Content: opts.Text, TagID: opts.TagID, Channel: cmp.Or(opts.Channel, uploadChannel), @@ -135,7 +146,7 @@ func runCreate(ctx context.Context, opts *CreateOptions, fopts *cmdutil.FormatOp if fopts.WantsJSON() { return fopts.Emit(iostreams.IO.Out, k, nil) } - displayed := opts.Name + displayed := opts.title() if displayed == "" { displayed = k.Title } diff --git a/cli/cmd/doc/create_test.go b/cli/cmd/doc/create_test.go index 6c7e5ad2..c8a4200d 100644 --- a/cli/cmd/doc/create_test.go +++ b/cli/cmd/doc/create_test.go @@ -35,6 +35,24 @@ func (f *fakeCreateSvc) CreateManualKnowledge( return f.resp, f.err } +// TestCreate_TitlePreferredOverName: --title is the canonical flag; the +// deprecated --name remains a working alias but --title wins when both are set. +func TestCreate_TitlePreferredOverName(t *testing.T) { + _, _ = iostreams.SetForTest(t) + svc := &fakeCreateSvc{resp: &sdk.Knowledge{ID: "d1"}} + require.NoError(t, runCreate(context.Background(), + &CreateOptions{Text: "x", Title: "FromTitle", Name: "FromName"}, + &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb1")) + assert.Equal(t, "FromTitle", svc.got.req.Title) + + // --name alone still works (back-compat for the deprecated alias). + svc2 := &fakeCreateSvc{resp: &sdk.Knowledge{ID: "d2"}} + require.NoError(t, runCreate(context.Background(), + &CreateOptions{Text: "x", Name: "OnlyName"}, + &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc2, "kb1")) + assert.Equal(t, "OnlyName", svc2.got.req.Title) +} + func TestCreate_Success_Text(t *testing.T) { out, _ := iostreams.SetForTest(t) svc := &fakeCreateSvc{resp: &sdk.Knowledge{ID: "doc_manual_1", Title: "Sprint Notes"}} diff --git a/cli/cmd/doc/delete.go b/cli/cmd/doc/delete.go index 86216589..1b6ca2e7 100644 --- a/cli/cmd/doc/delete.go +++ b/cli/cmd/doc/delete.go @@ -3,7 +3,6 @@ package doc import ( "context" "fmt" - "strings" "github.com/spf13/cobra" @@ -95,7 +94,7 @@ without the user's explicit go-ahead.`, if opts.KB == "" { return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "--all requires --kb=<id>"). WithHint("specify --kb=<uuid-or-name> to scope the delete-all operation"). - WithRetryCommand("weknora doc delete --all --kb=<kb-id> -y") + WithRetryArgv([]string{"weknora", "doc", "delete", "--all", "--kb=<kb-id>", "-y"}) } if len(args) > 0 { return cmdutil.NewFlagError(fmt.Errorf("--all is exclusive with positional doc ids")) @@ -138,7 +137,9 @@ without the user's explicit go-ahead.`, if len(args) == 1 { return runDelete(c.Context(), opts, fopts, cli, f.Prompter(), args[0]) } - if err := cmdutil.ConfirmDestructiveBatch(f.Prompter(), opts.Yes, fopts.WantsJSON(), "delete", "document", len(args), "doc.delete", "weknora doc delete "+strings.Join(args, " ")+" -y"); err != nil { + batchRetry := append([]string{"weknora", "doc", "delete"}, args...) + batchRetry = append(batchRetry, "-y") + if err := cmdutil.ConfirmDestructiveBatch(f.Prompter(), opts.Yes, fopts.WantsJSON(), "delete", "document", len(args), "doc.delete", batchRetry); err != nil { return err } outcomes, runErr := cmdutil.RunBatch(c.Context(), args, func(ctx context.Context, id string) error { @@ -171,6 +172,7 @@ without the user's explicit go-ahead.`, "weknora doc delete doc_a doc_b doc_c -y", "weknora doc delete --all --kb=kb_x -y --format json", }, + Output: "envelope.data shape depends on the form: a single doc id -> {id, deleted:true}; multiple doc ids -> batch envelope (top-level status success|partial|error, ok=(failures==0), data per-item [{id, ok, error?}], meta.successes/failures — read data[].ok per id, ok:true overall does not survive a partial failure / exit 1); --all --kb -> {kb_id, deleted_count}.", Warnings: []string{ "Requires explicit user approval (exit 10 / input.confirmation_required); never auto-add -y.", "doc delete is irreversible; loses the document + its chunks + embeddings.", @@ -181,7 +183,7 @@ without the user's explicit go-ahead.`, } 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(), "delete", "document", id, "doc.delete", "weknora doc delete "+id+" -y"); err != nil { + if err := cmdutil.ConfirmDestructive(p, opts.Yes, fopts.WantsJSON(), "delete", "document", id, "doc.delete", []string{"weknora", "doc", "delete", id, "-y"}); err != nil { return err } @@ -201,7 +203,7 @@ func runDelete(ctx context.Context, opts *DeleteOptions, fopts *cmdutil.FormatOp // CodeInputConfirmationRequired (exit 10) with risk metadata so agents can // surface the risk to the user before re-invoking with -y. func runDeleteAll(ctx context.Context, opts *DeleteOptions, fopts *cmdutil.FormatOptions, svc AllService, p prompt.Prompter) error { - if err := cmdutil.ConfirmDestructive(p, opts.Yes, fopts.WantsJSON(), "delete", "all docs in KB", opts.KB, "doc.delete_all", fmt.Sprintf("weknora doc delete --all --kb=%s -y", opts.KB)); err != nil { + if err := cmdutil.ConfirmDestructive(p, opts.Yes, fopts.WantsJSON(), "delete", "all docs in KB", opts.KB, "doc.delete_all", []string{"weknora", "doc", "delete", "--all", "--kb=" + opts.KB, "-y"}); err != nil { return err } diff --git a/cli/cmd/doc/delete_test.go b/cli/cmd/doc/delete_test.go index 402b267a..0d81c3c4 100644 --- a/cli/cmd/doc/delete_test.go +++ b/cli/cmd/doc/delete_test.go @@ -262,7 +262,7 @@ func TestRunMultiDelete_ConfirmBatch_NonTTY_RequiresConfirmation(t *testing.T) { _, _ = iostreams.SetForTest(t) // non-TTY svc := &fakeDeleteSvc{} fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON} - err := cmdutil.ConfirmDestructiveBatch(&testutil.ConfirmPrompter{Answer: false}, false, fopts.WantsJSON(), "delete", "document", 2, "doc.delete", "") + err := cmdutil.ConfirmDestructiveBatch(&testutil.ConfirmPrompter{Answer: false}, false, fopts.WantsJSON(), "delete", "document", 2, "doc.delete", nil) require.Error(t, err) var typed *cmdutil.Error require.ErrorAs(t, err, &typed) @@ -274,7 +274,7 @@ func TestRunMultiDelete_ConfirmBatch_TTY_UserAborts(t *testing.T) { _, errBuf := iostreams.SetForTestWithTTY(t) svc := &fakeDeleteSvc{} fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatText} - err := cmdutil.ConfirmDestructiveBatch(&testutil.ConfirmPrompter{Answer: false}, false, fopts.WantsJSON(), "delete", "document", 3, "doc.delete", "") + err := cmdutil.ConfirmDestructiveBatch(&testutil.ConfirmPrompter{Answer: false}, false, fopts.WantsJSON(), "delete", "document", 3, "doc.delete", nil) require.Error(t, err) var typed *cmdutil.Error require.ErrorAs(t, err, &typed) diff --git a/cli/cmd/doc/doc.go b/cli/cmd/doc/doc.go index 57fe0c0b..a1b83832 100644 --- a/cli/cmd/doc/doc.go +++ b/cli/cmd/doc/doc.go @@ -26,6 +26,8 @@ func NewCmd(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(NewCmdDownload(f)) cmd.AddCommand(NewCmdFetch(f)) cmd.AddCommand(NewCmdList(f)) + cmd.AddCommand(NewCmdReparse(f)) + cmd.AddCommand(NewCmdUpdate(f)) cmd.AddCommand(NewCmdUpload(f)) cmd.AddCommand(NewCmdView(f)) cmd.AddCommand(NewCmdWait(f)) diff --git a/cli/cmd/doc/dryrun_validation_test.go b/cli/cmd/doc/dryrun_validation_test.go index 865c8fd3..5db9825c 100644 --- a/cli/cmd/doc/dryrun_validation_test.go +++ b/cli/cmd/doc/dryrun_validation_test.go @@ -1,7 +1,7 @@ // Package doc — dryrun_validation_test.go asserts that --dry-run on -// doc subcommands rejects identically to the live path (industry standard: -// gh / kubectl / lark all validate before previewing). Before the surrounding -// fix, validation lived in runX() and was reached only after HandleDryRun +// doc subcommands rejects identically to the live path (validation must run +// before previewing). Before the surrounding fix, validation lived in runX() +// and was reached only after HandleDryRun // short-circuited, so --dry-run silently emitted plan envelopes for inputs // the live path would reject — agents got false-positive previews. package doc diff --git a/cli/cmd/doc/fetch.go b/cli/cmd/doc/fetch.go index 0ef5ae8c..e4b368cd 100644 --- a/cli/cmd/doc/fetch.go +++ b/cli/cmd/doc/fetch.go @@ -136,8 +136,11 @@ Server-side ingestion knobs: cmdutil.AddDryRunFlag(cmd, &opts.DryRun) cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{ UsedFor: "Ingest a remote URL into the resolved knowledge base. KB resolved via --kb flag, WEKNORA_KB_ID env, or project link. Emits the created Knowledge object with its id.", - RequiredFlags: []string{"<url> (positional)"}, - Output: "envelope.data is the created Knowledge object with id, knowledge_base_id, source, parse_status", + RequiredFlags: []string{"<url> (positional)", "--kb (or WEKNORA_KB_ID / project link)"}, + Examples: []string{ + `weknora doc fetch https://example.com/whitepaper.pdf --kb kb_eng`, + }, + Output: "envelope.data is the created Knowledge object with id, knowledge_base_id, source, parse_status", }) return cmd } diff --git a/cli/cmd/doc/list.go b/cli/cmd/doc/list.go index 327bd0d0..1aa992a8 100644 --- a/cli/cmd/doc/list.go +++ b/cli/cmd/doc/list.go @@ -3,9 +3,7 @@ package doc import ( "context" "fmt" - "slices" "sort" - "strings" "text/tabwriter" "time" @@ -48,10 +46,6 @@ type ListOptions struct { EndTime string // raw RFC3339; parsed into filter.EndTime } -// rfc3339Example is the canonical RFC3339 hint surfaced when --start-time / -// --end-time fail to parse. Picked to match Go's reference time docs. -const rfc3339Example = "2006-01-02T15:04:05Z" - // docListStatusValues mirrors internal/types/knowledge.go ParseStatus* // constants - these are the values the server accepts on the // ?parse_status= query. Kept in sync manually since the SDK doesn't @@ -88,6 +82,14 @@ backend storage order is not guaranteed and varies between deployments.`, return err } fopts.ResolveDefault(iostreams.IO.IsStdoutTTY()) + // Validate static input (--page-size / --limit / --status) before + // resolving the KB or building the client so a bad value returns + // input.invalid_argument (exit 5) instead of an auth error (exit 3) + // when the profile is unconfigured — an agent must see the real, + // fixable problem. + if err := validateListOpts(opts); err != nil { + return err + } kbID, err := f.ResolveKB(c) if err != nil { return err @@ -101,7 +103,7 @@ backend storage order is not guaranteed and varies between deployments.`, } cmdutil.AddKBFlag(cmd) cmd.Flags().IntVar(&opts.PageSize, "page-size", 50, "Items per server batch (1..1000)") - cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum results to return (1..10000)") + cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum results to return — client-side cap; meta.has_more/total_count report the full size (1..10000)") cmd.Flags().BoolVar(&opts.AllPages, "all-pages", false, "Walk all server pages until exhausted (or --limit hit)") cmd.Flags().StringVar(&opts.Status, "status", "", "Filter by parse status: pending | processing | completed | failed") cmd.Flags().StringVar(&opts.Keyword, "keyword", "", "Server-side substring match against title / file_name (case-sensitive)") @@ -114,12 +116,15 @@ backend storage order is not guaranteed and varies between deployments.`, cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{ UsedFor: "List documents in the resolved knowledge base. Results come with meta.count; use --limit to cap, --all-pages to walk every server page, --status/--keyword to filter server-side.", Examples: []string{"weknora doc list --format json", "weknora doc list --all-pages --limit 200 --format json"}, - Output: "envelope.data is an array of Knowledge objects with id, title, file_name, parse_status; meta.count is the total returned", + Output: "envelope.data is an array of Knowledge objects with id, title, file_name, parse_status; meta.count is the total returned; meta.total_count is the server-side total before client-side --limit truncation", }) return cmd } -func runList(ctx context.Context, opts *ListOptions, fopts *cmdutil.FormatOptions, svc ListService, kbID string) error { +// validateListOpts checks --page-size / --limit / --status. Called from RunE +// before the KB/client are resolved (so bad input surfaces as exit 5, not an +// auth error) and again at the top of runList for direct callers; idempotent. +func validateListOpts(opts *ListOptions) error { if opts.PageSize < 1 || opts.PageSize > 1000 { return &cmdutil.Error{ Code: cmdutil.CodeInputInvalidArgument, @@ -132,12 +137,19 @@ func runList(ctx context.Context, opts *ListOptions, fopts *cmdutil.FormatOption Message: fmt.Sprintf("--limit must be in 1..10000, got %d", opts.Limit), } } - if opts.Status != "" && !validDocListStatus(opts.Status) { - return &cmdutil.Error{ - Code: cmdutil.CodeInputInvalidArgument, - Message: fmt.Sprintf("--status must be one of: %s - got %q", - strings.Join(docListStatusValues, " | "), opts.Status), - } + // Validate + normalize --status via the shared enum helper (same exit-5 + // path and case-insensitive normalization as every other enum flag). + status, err := cmdutil.ValidateEnum("status", opts.Status, docListStatusValues) + if err != nil { + return err + } + opts.Status = status + return nil +} + +func runList(ctx context.Context, opts *ListOptions, fopts *cmdutil.FormatOptions, svc ListService, kbID string) error { + if err := validateListOpts(opts); err != nil { + return err } filter := sdk.KnowledgeListFilter{ ParseStatus: opts.Status, @@ -146,26 +158,25 @@ func runList(ctx context.Context, opts *ListOptions, fopts *cmdutil.FormatOption Source: opts.Source, TagID: opts.TagID, } - if opts.StartTime != "" { - t, err := time.Parse(time.RFC3339, opts.StartTime) - if err != nil { - return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, - fmt.Sprintf("--start-time must be RFC3339 (e.g. %s), got %q", rfc3339Example, opts.StartTime)) - } - filter.StartTime = t + start, err := cmdutil.ParseTimeFlag("--start-time", opts.StartTime) + if err != nil { + return err } - if opts.EndTime != "" { - t, err := time.Parse(time.RFC3339, opts.EndTime) - if err != nil { - return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, - fmt.Sprintf("--end-time must be RFC3339 (e.g. %s), got %q", rfc3339Example, opts.EndTime)) - } - filter.EndTime = t + if start != nil { + filter.StartTime = *start + } + end, err := cmdutil.ParseTimeFlag("--end-time", opts.EndTime) + if err != nil { + return err + } + if end != nil { + filter.EndTime = *end } // Pagination is always 1-indexed internally. --all-pages walks; the // non-walking path returns the first page only. var items []sdk.Knowledge + var serverTotal int64 if opts.AllPages { accum := make([]sdk.Knowledge, 0) for page := 1; ; page++ { @@ -173,6 +184,7 @@ func runList(ctx context.Context, opts *ListOptions, fopts *cmdutil.FormatOption if err != nil { return cmdutil.WrapHTTP(err, "list documents") } + serverTotal = total accum = append(accum, chunk...) if opts.Limit > 0 && len(accum) >= opts.Limit { accum = accum[:opts.Limit] @@ -184,10 +196,11 @@ func runList(ctx context.Context, opts *ListOptions, fopts *cmdutil.FormatOption } items = accum } else { - chunk, _, err := svc.ListKnowledgeWithFilter(ctx, kbID, 1, opts.PageSize, filter) + chunk, total, err := svc.ListKnowledgeWithFilter(ctx, kbID, 1, opts.PageSize, filter) if err != nil { return cmdutil.WrapHTTP(err, "list documents") } + serverTotal = total items = chunk } if items == nil { @@ -208,7 +221,7 @@ func runList(ctx context.Context, opts *ListOptions, fopts *cmdutil.FormatOption } if fopts.WantsJSON() { - meta := &output.Meta{Count: len(items), HasMore: truncated} + meta := &output.Meta{Count: output.IntPtr(len(items)), HasMore: truncated, TotalCount: output.IntPtr(int(serverTotal))} return fopts.Emit(iostreams.IO.Out, items, meta) } @@ -228,12 +241,6 @@ func runList(ctx context.Context, opts *ListOptions, fopts *cmdutil.FormatOption return tw.Flush() } -// validDocListStatus reports whether s matches one of the server-accepted -// parse_status enum values surfaced via --status. -func validDocListStatus(s string) bool { - return slices.Contains(docListStatusValues, s) -} - // formatSize renders a byte count as a short human string (KB / MB). // Kept tiny on purpose - go-humanize would pull a transitive dep just for one // column. A "-" placeholder hides zero-size entries (URL / text). diff --git a/cli/cmd/doc/list_test.go b/cli/cmd/doc/list_test.go index 8ec832bf..e968ab08 100644 --- a/cli/cmd/doc/list_test.go +++ b/cli/cmd/doc/list_test.go @@ -431,6 +431,45 @@ func TestList_EndTime_InvalidFormat_Rejected(t *testing.T) { assert.Contains(t, typed.Message, "--end-time") } +// TestList_JSON_TotalCount_SinglePage asserts that meta.total_count is populated +// from the server total when doing a single-page fetch. +func TestList_JSON_TotalCount_SinglePage(t *testing.T) { + out, _ := iostreams.SetForTest(t) + svc := &fakeListSvc{ + items: []sdk.Knowledge{{ID: "doc1", FileName: "a.pdf"}}, + total: 99, + } + opts := &ListOptions{PageSize: 20, Limit: 30} + require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb_xxx")) + body := out.String() + assert.Contains(t, body, `"total_count":99`, "single-page fetch must surface server total in meta.total_count") +} + +// TestList_JSON_TotalCount_AllPages asserts meta.total_count is populated +// from the server total when walking all pages. +func TestList_JSON_TotalCount_AllPages(t *testing.T) { + out, _ := iostreams.SetForTest(t) + svc := &pagedDocSvc{all: makeDocs(45)} + opts := &ListOptions{PageSize: 20, Limit: 10000, AllPages: true} + require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb_xxx")) + body := out.String() + assert.Contains(t, body, `"total_count":45`, "--all-pages fetch must surface server total in meta.total_count") +} + +// TestList_JSON_TotalCount_Zero_Present asserts that when server returns total=0 +// on an empty list, meta.total_count (and meta.count) still serialize as 0. The +// *int + omitempty pattern omits only nil, so the agent contract stays stable: +// an empty result reports 0, not a missing key. +func TestList_JSON_TotalCount_Zero_Present(t *testing.T) { + out, _ := iostreams.SetForTest(t) + svc := &fakeListSvc{items: nil, total: 0} + opts := &ListOptions{PageSize: 20, Limit: 30} + require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb_xxx")) + body := out.String() + assert.Contains(t, body, `"total_count":0`, "zero server total must serialize as 0 on empty list") + assert.Contains(t, body, `"count":0`, "empty list must report count:0, not omit it") +} + // TestList_AllFiltersCombined drives every new filter flag at once to confirm // they all land on the same filter struct (AND combine on the server). func TestList_AllFiltersCombined(t *testing.T) { diff --git a/cli/cmd/doc/reparse.go b/cli/cmd/doc/reparse.go new file mode 100644 index 00000000..58f82272 --- /dev/null +++ b/cli/cmd/doc/reparse.go @@ -0,0 +1,95 @@ +package doc + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/Tencent/WeKnora/cli/internal/cmdutil" + "github.com/Tencent/WeKnora/cli/internal/iostreams" + "github.com/Tencent/WeKnora/cli/internal/text" + sdk "github.com/Tencent/WeKnora/client" +) + +// docReparseFields enumerates the fields surfaced for `--format json` discovery +// on `doc reparse`. The result is the Knowledge object; the caller mainly wants +// the id and the reset parse_status. +var docReparseFields = []string{"id", "parse_status", "file_name", "title"} + +// ReparseOptions captures `doc reparse` flag state. +type ReparseOptions struct { + DryRun bool +} + +// ReparseService is the narrow SDK surface this command depends on. +type ReparseService interface { + ReparseKnowledge(ctx context.Context, id string) (*sdk.Knowledge, error) +} + +// NewCmdReparse builds `weknora doc reparse <doc-id>`. +func NewCmdReparse(f *cmdutil.Factory) *cobra.Command { + opts := &ReparseOptions{} + cmd := &cobra.Command{ + Use: "reparse <doc-id>", + Short: "Re-run parsing on a document", + Long: `Re-trigger server-side parsing of a document — the recovery path when a +document's parse_status is failed, or after its source changed. The document +keeps its id; parsing restarts asynchronously, so follow with +'weknora doc wait <doc-id>' to block until it reaches a terminal status.`, + 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()) + if handled, err := cmdutil.HandleDryRun(c, opts.DryRun, cmdutil.DryRunPlan{ + Action: "doc.reparse", + Args: map[string]any{"doc": args[0]}, + }); handled { + return err + } + cli, err := f.Client() + if err != nil { + return err + } + return runReparse(c.Context(), opts, fopts, cli, args[0]) + }, + } + cmdutil.AddFormatFlag(cmd, docReparseFields...) + cmdutil.AddDryRunFlag(cmd, &opts.DryRun) + cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{ + UsedFor: "re-run parsing on a document (recovery after parse_status=failed, or a changed source)", + RequiredFlags: []string{"<doc-id> (positional)"}, + Examples: []string{ + "weknora doc reparse doc_abc", + "weknora doc reparse doc_abc --format json", + }, + Output: "envelope.data is the Knowledge object with parse_status reset to the re-processing state; poll `weknora doc wait <doc-id>` for completion", + }) + return cmd +} + +func runReparse(ctx context.Context, opts *ReparseOptions, fopts *cmdutil.FormatOptions, svc ReparseService, id string) error { + k, err := svc.ReparseKnowledge(ctx, id) + if err != nil { + return cmdutil.WrapHTTP(err, "reparse document %s", id) + } + if fopts.WantsJSON() { + return fopts.Emit(iostreams.IO.Out, k, nil) + } + // fileName → title, with no id fallback (empty means "skip the suffix"). + name := text.KnowledgeDisplayName(k.FileName, k.Title, "") + w := iostreams.IO.Out + fmt.Fprintf(w, "✓ reparse triggered for %s", id) + if name != "" { + fmt.Fprintf(w, " (%s)", name) + } + fmt.Fprintln(w) + if k.ParseStatus != "" { + fmt.Fprintf(w, " parse_status: %s\n", k.ParseStatus) + } + fmt.Fprintf(w, " next: weknora doc wait %s\n", id) + return nil +} diff --git a/cli/cmd/doc/reparse_test.go b/cli/cmd/doc/reparse_test.go new file mode 100644 index 00000000..aef1b45d --- /dev/null +++ b/cli/cmd/doc/reparse_test.go @@ -0,0 +1,84 @@ +package doc + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/Tencent/WeKnora/cli/internal/cmdutil" + "github.com/Tencent/WeKnora/cli/internal/iostreams" + sdk "github.com/Tencent/WeKnora/client" +) + +type fakeReparseSvc struct { + k *sdk.Knowledge + err error + called bool +} + +func (f *fakeReparseSvc) ReparseKnowledge(_ context.Context, id string) (*sdk.Knowledge, error) { + f.called = true + if f.err != nil { + return nil, f.err + } + k := *f.k + k.ID = id + return &k, nil +} + +func TestDocReparse_Text(t *testing.T) { + out, _ := iostreams.SetForTest(t) + svc := &fakeReparseSvc{k: &sdk.Knowledge{FileName: "runbook.md", ParseStatus: "processing"}} + require.NoError(t, runReparse(context.Background(), &ReparseOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "doc_abc")) + assert.True(t, svc.called) + got := out.String() + assert.Contains(t, got, "doc_abc") + assert.Contains(t, got, "runbook.md") + assert.Contains(t, got, "processing") + assert.Contains(t, got, "doc wait doc_abc") +} + +func TestDocReparse_JSON(t *testing.T) { + out, _ := iostreams.SetForTest(t) + svc := &fakeReparseSvc{k: &sdk.Knowledge{ParseStatus: "processing"}} + require.NoError(t, runReparse(context.Background(), &ReparseOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "doc_abc")) + var env struct { + OK bool `json:"ok"` + Data sdk.Knowledge `json:"data"` + } + require.NoError(t, json.Unmarshal(out.Bytes(), &env), "got %q", out.String()) + assert.True(t, env.OK) + assert.Equal(t, "doc_abc", env.Data.ID) + assert.Equal(t, "processing", env.Data.ParseStatus) +} + +func TestDocReparse_NotFound(t *testing.T) { + _, _ = iostreams.SetForTest(t) + svc := &fakeReparseSvc{err: errors.New("HTTP error 404: not found")} + err := runReparse(context.Background(), &ReparseOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "missing") + require.Error(t, err) + assert.True(t, cmdutil.IsNotFound(err)) +} + +// TestDocReparse_DryRun_NoServerCall: --dry-run must emit a doc.reparse plan +// (exit 0) without reaching the server, honoring the mutation-preview contract. +func TestDocReparse_DryRun_NoServerCall(t *testing.T) { + out, _ := iostreams.SetForTest(t) + root := withRootHarnessDoc(NewCmdReparse(docDryRunFactory(t)), "doc_x", "--dry-run", "--format", "json") + require.NoError(t, root.Execute(), "dry-run must succeed without a client") + var env struct { + OK bool `json:"ok"` + Meta struct { + DryRun bool `json:"dry_run"` + Plan map[string]any `json:"plan"` + } `json:"meta"` + } + require.NoError(t, json.Unmarshal(out.Bytes(), &env), "got %q", out.String()) + assert.True(t, env.OK) + assert.True(t, env.Meta.DryRun) + assert.Equal(t, "doc.reparse", env.Meta.Plan["action"]) +} diff --git a/cli/cmd/doc/update.go b/cli/cmd/doc/update.go new file mode 100644 index 00000000..a51266e5 --- /dev/null +++ b/cli/cmd/doc/update.go @@ -0,0 +1,152 @@ +package doc + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/Tencent/WeKnora/cli/internal/cmdutil" + "github.com/Tencent/WeKnora/cli/internal/iostreams" + sdk "github.com/Tencent/WeKnora/client" +) + +// docUpdateFields enumerates the fields surfaced for `--format json` discovery +// on `doc update`. The result is the updated Knowledge object. +var docUpdateFields = []string{"id", "title", "description", "file_name", "parse_status"} + +// UpdateOptions captures `doc update` flag state. Title/Description are *string +// so an unset flag is distinguishable from "set to empty" — only fields the +// user passed are changed; the rest are preserved via fetch-then-update. +type UpdateOptions struct { + Title *string + Description *string + Yes bool // sourced from global -y/--yes persistent flag + DryRun bool +} + +// UpdateService is the narrow SDK surface this command depends on. The server's +// UpdateKnowledge endpoint persists only title and description (other fields on +// the PUT body are ignored), so this command edits just those two; GetKnowledge +// supplies the fetch half of the fetch-then-update flow. +type UpdateService interface { + GetKnowledge(ctx context.Context, id string) (*sdk.Knowledge, error) + UpdateKnowledge(ctx context.Context, k *sdk.Knowledge) error +} + +// NewCmdUpdate builds `weknora doc update <doc-id>`. At least one of +// --title / --description must be provided. +func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command { + opts := &UpdateOptions{} + var title, desc string + cmd := &cobra.Command{ + Use: "update <doc-id>", + Short: "Update a document's title or description", + Long: `Update a document's title and/or description. At least one of --title / +--description must be supplied; the document's content, file, and parse state are +untouched (only the title and description are editable — re-upload to change the +content). + +AI agents: this is a high-risk write. Without -y/--yes the CLI exits 10 +with input.confirmation_required. Never auto-pass -y; surface the prompt +to the user first.`, + 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()) + opts.Yes, _ = c.Flags().GetBool("yes") + if c.Flag("title").Changed { + opts.Title = &title + } + if c.Flag("description").Changed { + opts.Description = &desc + } + id := args[0] + // Validate "at least one mutation flag" before the dry-run gate so + // --dry-run rejects identically to the live path. + if opts.Title == nil && opts.Description == nil { + return errNoUpdateFlag() + } + planArgs := map[string]any{"doc": id} + if opts.Title != nil { + planArgs["title"] = *opts.Title + } + if opts.Description != nil { + planArgs["description"] = *opts.Description + } + if handled, err := cmdutil.HandleDryRun(c, opts.DryRun, cmdutil.DryRunPlan{ + Action: "doc.update", + Args: planArgs, + }); handled { + return err + } + cli, err := f.Client() + if err != nil { + return err + } + // Build a retry command from the flags the user actually passed so + // agents can re-invoke with -y after explicit human approval. + retryCmd := cmdutil.BuildRetryArgv(c, []string{"weknora", "doc", "update", id}, "title", "description", "format") + if err := cmdutil.ConfirmWrite(f.Prompter(), opts.Yes, fopts.WantsJSON(), "update", "document", id, "doc.update", retryCmd); err != nil { + return err + } + return runUpdate(c.Context(), opts, fopts, cli, id) + }, + } + cmd.Flags().StringVar(&title, "title", "", "New title (omit to leave unchanged)") + cmd.Flags().StringVar(&desc, "description", "", "New description (omit to leave unchanged)") + cmdutil.AddFormatFlag(cmd, docUpdateFields...) + cmdutil.AddDryRunFlag(cmd, &opts.DryRun) + cmdutil.SetWriteRisk(cmd, "doc.update") + cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{ + UsedFor: "update a document's title or description (content is not editable — re-upload for that)", + RequiredFlags: []string{"<doc-id> (positional)", "--title or --description (at least one)"}, + Examples: []string{ + `weknora doc update doc_abc --title "Q3 Runbook" -y`, + `weknora doc update doc_abc --description "archived" --format json -y`, + }, + Output: "envelope.data is the Knowledge object with the new title/description", + Warnings: []string{ + "Requires explicit user approval (exit 10 / input.confirmation_required); never auto-add -y.", + }, + }) + return cmd +} + +func errNoUpdateFlag() error { + return &cmdutil.Error{ + Code: cmdutil.CodeInputMissingFlag, + Message: "doc update requires at least one of --title or --description", + Hint: "pass --title <title> and/or --description <desc>", + } +} + +func runUpdate(ctx context.Context, opts *UpdateOptions, fopts *cmdutil.FormatOptions, svc UpdateService, id string) error { + if opts.Title == nil && opts.Description == nil { + return errNoUpdateFlag() + } + // Fetch-then-update: load the current record, overlay only the fields the + // user set, and PUT the whole object. TOCTOU note: a concurrent writer could + // change the record between the Get and Put (same window as kb update). + current, err := svc.GetKnowledge(ctx, id) + if err != nil { + return cmdutil.WrapHTTP(err, "fetch document %s", id) + } + if opts.Title != nil { + current.Title = *opts.Title + } + if opts.Description != nil { + current.Description = *opts.Description + } + if err := svc.UpdateKnowledge(ctx, current); err != nil { + return cmdutil.WrapHTTP(err, "update document %s", id) + } + if fopts.WantsJSON() { + return fopts.Emit(iostreams.IO.Out, current, nil) + } + fmt.Fprintf(iostreams.IO.Out, "✓ Updated document %s\n", id) + return nil +} diff --git a/cli/cmd/doc/update_test.go b/cli/cmd/doc/update_test.go new file mode 100644 index 00000000..75ee1b9e --- /dev/null +++ b/cli/cmd/doc/update_test.go @@ -0,0 +1,137 @@ +package doc + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/Tencent/WeKnora/cli/internal/cmdutil" + "github.com/Tencent/WeKnora/cli/internal/iostreams" + "github.com/Tencent/WeKnora/cli/internal/prompt" + sdk "github.com/Tencent/WeKnora/client" +) + +type fakeUpdateSvc struct { + current *sdk.Knowledge + getErr error + updateErr error + got *sdk.Knowledge // captured object passed to UpdateKnowledge +} + +func (f *fakeUpdateSvc) GetKnowledge(_ context.Context, id string) (*sdk.Knowledge, error) { + if f.getErr != nil { + return nil, f.getErr + } + c := *f.current + c.ID = id + return &c, nil +} + +func (f *fakeUpdateSvc) UpdateKnowledge(_ context.Context, k *sdk.Knowledge) error { + f.got = k + return f.updateErr +} + +func strptr(s string) *string { return &s } + +func TestDocUpdate_Title_FetchThenUpdate(t *testing.T) { + out, _ := iostreams.SetForTest(t) + svc := &fakeUpdateSvc{current: &sdk.Knowledge{Title: "Old", Description: "keep", FileName: "f.md"}} + require.NoError(t, runUpdate(context.Background(), + &UpdateOptions{Title: strptr("New")}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "doc_abc")) + // Only title changed; description preserved from the fetched record. + require.NotNil(t, svc.got) + assert.Equal(t, "New", svc.got.Title) + assert.Equal(t, "keep", svc.got.Description) + var env struct { + OK bool `json:"ok"` + Data sdk.Knowledge `json:"data"` + } + require.NoError(t, json.Unmarshal(out.Bytes(), &env), "got %q", out.String()) + assert.True(t, env.OK) + assert.Equal(t, "doc_abc", env.Data.ID) + assert.Equal(t, "New", env.Data.Title) +} + +func TestDocUpdate_DescriptionOnly_PreservesTitle(t *testing.T) { + _, _ = iostreams.SetForTest(t) + svc := &fakeUpdateSvc{current: &sdk.Knowledge{Title: "Keep", Description: "old"}} + require.NoError(t, runUpdate(context.Background(), + &UpdateOptions{Description: strptr("fresh")}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "doc_abc")) + assert.Equal(t, "Keep", svc.got.Title) + assert.Equal(t, "fresh", svc.got.Description) +} + +func TestDocUpdate_NoFlags_MissingFlag(t *testing.T) { + _, _ = iostreams.SetForTest(t) + svc := &fakeUpdateSvc{current: &sdk.Knowledge{}} + err := runUpdate(context.Background(), &UpdateOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "doc_abc") + var typed *cmdutil.Error + require.True(t, errors.As(err, &typed)) + assert.Equal(t, cmdutil.CodeInputMissingFlag, typed.Code) + assert.Nil(t, svc.got, "must not call UpdateKnowledge when no flag is set") +} + +func TestDocUpdate_NotFound(t *testing.T) { + _, _ = iostreams.SetForTest(t) + svc := &fakeUpdateSvc{getErr: errors.New("HTTP error 404: not found")} + err := runUpdate(context.Background(), + &UpdateOptions{Title: strptr("x")}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "missing") + require.Error(t, err) + assert.True(t, cmdutil.IsNotFound(err)) +} + +// TestDocUpdate_DryRun_NoServerCall: --dry-run emits a doc.update plan (exit 0) +// without reaching the server. +func TestDocUpdate_DryRun_NoServerCall(t *testing.T) { + out, _ := iostreams.SetForTest(t) + root := withRootHarnessDoc(NewCmdUpdate(docDryRunFactory(t)), "doc_x", "--title", "T", "--dry-run", "--format", "json") + require.NoError(t, root.Execute(), "dry-run must succeed without a client") + var env struct { + OK bool `json:"ok"` + Meta struct { + DryRun bool `json:"dry_run"` + Plan map[string]any `json:"plan"` + } `json:"meta"` + } + require.NoError(t, json.Unmarshal(out.Bytes(), &env), "got %q", out.String()) + assert.True(t, env.Meta.DryRun) + assert.Equal(t, "doc.update", env.Meta.Plan["action"]) +} + +// TestDocUpdate_RequiresConfirmation asserts that without -y (non-TTY / JSON +// mode), doc update returns input.confirmation_required (exit 10) — parity with +// kb/agent update gating (AGENTS.md §3.1: all three updates are confirmation-gated). +func TestDocUpdate_RequiresConfirmation(t *testing.T) { + iostreams.SetForTest(t) // non-TTY + f := &cmdutil.Factory{ + Client: func() (*sdk.Client, error) { return nil, nil }, + Prompter: func() prompt.Prompter { return prompt.AgentPrompter{} }, + } + root := withRootHarnessDoc(NewCmdUpdate(f), "doc_abc", "--title", "New", "--format", "json") + err := root.Execute() + require.Error(t, err) + var ce *cmdutil.Error + require.ErrorAs(t, err, &ce) + assert.Equal(t, cmdutil.CodeInputConfirmationRequired, ce.Code) + assert.Equal(t, 10, cmdutil.ExitCode(err)) + // retry argv must include -y and the target id + assert.Contains(t, ce.RetryArgv, "-y") + assert.Contains(t, ce.RetryArgv, "doc_abc") +} + +// TestDocUpdate_DryRun_RejectsNoFlag: --dry-run rejects the no-mutation-flag +// invocation identically to the live path (validation before the dry-run gate). +func TestDocUpdate_DryRun_RejectsNoFlag(t *testing.T) { + _, _ = iostreams.SetForTest(t) + root := withRootHarnessDoc(NewCmdUpdate(docDryRunFactory(t)), "doc_x", "--dry-run", "--format", "json") + err := root.Execute() + require.Error(t, err, "dry-run must reject identically to live path") + var typed *cmdutil.Error + require.True(t, errors.As(err, &typed)) + assert.Equal(t, cmdutil.CodeInputMissingFlag, typed.Code) +} diff --git a/cli/cmd/doc/upload.go b/cli/cmd/doc/upload.go index d733dc9d..dc50fea4 100644 --- a/cli/cmd/doc/upload.go +++ b/cli/cmd/doc/upload.go @@ -191,8 +191,12 @@ Server-side ingestion knobs: cmdutil.AddDryRunFlag(cmd, &opts.DryRun) cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{ UsedFor: "Upload a local file to the resolved knowledge base. KB resolved via --kb flag, WEKNORA_KB_ID env, or project link. Emits the created Knowledge object with its id.", - RequiredFlags: []string{"<file> (positional)"}, - Output: "envelope.data is the created Knowledge object with id, knowledge_base_id, file_name, parse_status", + RequiredFlags: []string{"<file> (positional)", "--kb (or WEKNORA_KB_ID / project link)"}, + Examples: []string{ + `weknora doc upload report.pdf --kb kb_eng`, + `weknora doc upload report.pdf --kb kb_eng --jq .data.id # then: doc wait <id>`, + }, + Output: "envelope.data is the created Knowledge object with id, knowledge_base_id, file_name, parse_status", }) return cmd } diff --git a/cli/cmd/doctor/doctor.go b/cli/cmd/doctor/doctor.go index 75a4c27f..ae267ac7 100644 --- a/cli/cmd/doctor/doctor.go +++ b/cli/cmd/doctor/doctor.go @@ -132,7 +132,7 @@ func NewCmd(f *cmdutil.Factory) *cobra.Command { "weknora doctor --offline", "weknora doctor --no-cache --format json", }, - Output: "envelope.data is {summary:{all_passed,passed,failed,skipped}, checks:[{name,status,detail}]}", + Output: "envelope.data is {summary:{all_passed,passed,warned,failed,skipped}, checks:[{name,status,details,hint?}]}", }) return cmd } diff --git a/cli/cmd/dryrun_coverage_test.go b/cli/cmd/dryrun_coverage_test.go index 29b53616..69b6d743 100644 --- a/cli/cmd/dryrun_coverage_test.go +++ b/cli/cmd/dryrun_coverage_test.go @@ -20,12 +20,17 @@ import ( // decision rather than silently inheriting "no preview". var dryRunExpectation = map[string]bool{ // --- mutations: MUST have --dry-run --- - "kb create": true, "kb edit": true, "kb delete": true, "kb pin": true, "kb unpin": true, + "kb create": true, "kb update": true, "kb delete": true, "kb pin": true, "kb unpin": true, + "kb init": true, // binds models to a KB (state change) + "model create": true, "model delete": true, "doc create": true, "doc upload": true, "doc fetch": true, "doc delete": true, - "chunk delete": true, - "session delete": true, "session stop": true, - "agent create": true, "agent edit": true, "agent delete": true, + "doc reparse": true, // re-triggers server-side parsing (a state change) + "doc update": true, // edits title/description server-side + "chunk delete": true, "message delete": true, + "session delete": true, "session stop": true, "session tool-approval resolve": true, + "agent create": true, "agent update": true, "agent delete": true, "profile add": true, "profile use": true, "profile remove": true, + "skills install": true, // writes skill files to a local dir (state change) "auth logout": true, "auth refresh": true, "link": true, "unlink": true, "api": true, // passthrough: dry-run previews write methods, rejected on GET @@ -33,15 +38,21 @@ var dryRunExpectation = map[string]bool{ // --- exempt: no state change to preview --- // reads "kb list": false, "kb view": false, "kb status": false, "kb check": false, + "kb config": false, // read-only inspection of a KB's model config "doc list": false, "doc view": false, "doc download": false, "doc wait": false, // polling read, no mutation "chunk list": false, "chunk view": false, + "message list": false, "message search": false, "session list": false, "session view": false, "agent list": false, "agent view": false, "agent status": false, "agent check": false, + "model list": false, "model view": false, "search chunks": false, "search docs": false, "search kb": false, "search sessions": false, "auth list": false, "auth status": false, "auth token": false, "profile list": false, - "doctor": false, "version": false, + "skills list": false, // read-only: lists the embedded skills + // read-only inspection of the resolved config; no mutation, no network. + "config view": false, + "doctor": false, "version": false, // generate / stream ops — the session-creation side effect is incidental, // not a CRUD write; a no-SDK-call preview would be meaningless. "chat": false, "session ask": false, "session continue-stream": false, @@ -51,6 +62,12 @@ var dryRunExpectation = map[string]bool{ "auth login": false, // long-running stdio server, not a one-shot command. "mcp serve": false, + // offline read: enumerates tool metadata without any network call. + "mcp tools list": false, + // offline help topic: prints the static exit-code matrix. + "exit-codes": false, + // offline introspection: prints command contracts from the in-binary tree. + "schema": false, } func TestDryRunCoverageMatchesExpectation(t *testing.T) { diff --git a/cli/cmd/exitcodes.go b/cli/cmd/exitcodes.go new file mode 100644 index 00000000..a953a140 --- /dev/null +++ b/cli/cmd/exitcodes.go @@ -0,0 +1,84 @@ +package cmd + +import ( + "fmt" + "strings" + "text/tabwriter" + + "github.com/spf13/cobra" + + "github.com/Tencent/WeKnora/cli/internal/cmdutil" + "github.com/Tencent/WeKnora/cli/internal/iostreams" + "github.com/Tencent/WeKnora/cli/internal/output" +) + +type exitCodeRow struct { + Code int `json:"code"` + Meaning string `json:"meaning"` + ErrorTypes string `json:"error_types,omitempty"` + AgentAction string `json:"agent_action"` +} + +func exitCodeRows() []exitCodeRow { + return []exitCodeRow{ + {0, "success", "", "continue"}, + {1, "typed local.* / operation.failed / unclassified", "local.*, operation.failed, operation.cancelled, server.session_create_failed, internal.error", "read stderr, decide retry/abort"}, + {2, "flag / argument validation error (cobra parse: unknown flag, arg count, missing required flag)", "input.invalid_argument (same type as exit 5; distinguish by exit code)", "re-check weknora <cmd> --help"}, + {3, "authentication / authorization", "auth.*", "re-auth (weknora auth login), then retry"}, + {4, "resource not found", "resource.not_found", "verify the resource id"}, + {5, "invalid input value (typed validation, not a parse error)", "input.* (other than confirmation_required)", "adjust args, retry"}, + {6, "rate limited", "server.rate_limited", "back off, retry"}, + {7, "server / network error", "server.* (excl. rate_limited→6, session_create_failed→1), network.*", "transient — retry with backoff"}, + {10, "confirmation required (high-risk write)", "input.confirmation_required", "ask the human; retry with -y only after explicit approval"}, + {124, "operation timeout", "operation.timeout", "raise --timeout or check the underlying job"}, + {130, "cancelled by signal (SIGINT/SIGTERM)", "", "stop, do not retry"}, + } +} + +// newCmdExitCodes builds the `weknora exit-codes` help-topic command. +// `weknora help exit-codes` renders Long for humans; running it directly +// emits the machine-readable matrix (json by default, per the agent-first +// output contract). Listed in the command tree (not hidden) so humans have a +// discoverable path to it via `weknora help`, not only the README. +func newCmdExitCodes() *cobra.Command { + cmd := &cobra.Command{ + Use: "exit-codes", + Short: "Exit code matrix and the agent action for each", + Args: cobra.NoArgs, + RunE: func(c *cobra.Command, _ []string) error { + fopts, err := cmdutil.CheckFormatFlag(c) + if err != nil { + return err + } + fopts.ResolveDefault(iostreams.IO.IsStdoutTTY()) + rows := exitCodeRows() + if fopts.WantsJSON() { + return fopts.Emit(iostreams.IO.Out, rows, &output.Meta{Count: output.IntPtr(len(rows))}) + } + tw := tabwriter.NewWriter(iostreams.IO.Out, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "CODE\tMEANING\tERROR TYPES\tAGENT ACTION") + for _, r := range rows { + fmt.Fprintf(tw, "%d\t%s\t%s\t%s\n", r.Code, r.Meaning, r.ErrorTypes, r.AgentAction) + } + return tw.Flush() + }, + } + cmdutil.AddFormatFlag(cmd, "code", "meaning", "error_types", "agent_action") + cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{ + UsedFor: "Machine-readable exit code matrix: what each code means and what to do next.", + Examples: []string{"weknora exit-codes", "weknora help exit-codes"}, + Output: "envelope.data is an array of {code, meaning, error_types, agent_action}", + }) + cmd.Long = "Exit codes and the agent action for each:\n\n" + exitCodesLongTable() + return cmd +} + +func exitCodesLongTable() string { + var sb strings.Builder + tw := tabwriter.NewWriter(&sb, 0, 0, 2, ' ', 0) + for _, r := range exitCodeRows() { + fmt.Fprintf(tw, " %d\t%s\t%s\n", r.Code, r.Meaning, r.AgentAction) + } + tw.Flush() + return sb.String() +} diff --git a/cli/cmd/exitcodes_test.go b/cli/cmd/exitcodes_test.go new file mode 100644 index 00000000..12eccb86 --- /dev/null +++ b/cli/cmd/exitcodes_test.go @@ -0,0 +1,99 @@ +package cmd + +import ( + "bytes" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/Tencent/WeKnora/cli/internal/cmdutil" + "github.com/Tencent/WeKnora/cli/internal/iostreams" +) + +func TestExitCodesRows_CoversDocumentedMatrix(t *testing.T) { + rows := exitCodeRows() + codes := make([]int, 0, len(rows)) + for _, r := range rows { + require.NotEmpty(t, r.Meaning) + require.NotEmpty(t, r.AgentAction) + codes = append(codes, r.Code) + } + assert.Equal(t, []int{0, 1, 2, 3, 4, 5, 6, 7, 10, 124, 130}, codes) +} + +// TestExitCodesRows_MatrixSnapshot guards key rows against silent drift. +func TestExitCodesRows_MatrixSnapshot(t *testing.T) { + byCode := map[int]exitCodeRow{} + for _, r := range exitCodeRows() { + byCode[r.Code] = r + } + + // exit 130 — produced by main.go signal guard, not ExitCode(); no typed code routes here + r130 := byCode[130] + assert.Equal(t, "cancelled by signal (SIGINT/SIGTERM)", r130.Meaning) + assert.Equal(t, "", r130.ErrorTypes, "exit 130 must have empty ErrorTypes (omitempty)") + assert.Equal(t, "stop, do not retry", r130.AgentAction) + + // exit 1 — fallback bucket; operation.cancelled typed errors land here + r1 := byCode[1] + assert.Contains(t, r1.ErrorTypes, "operation.cancelled") + assert.Contains(t, r1.ErrorTypes, "server.session_create_failed") + assert.Contains(t, r1.ErrorTypes, "local.*") + + // exit 7 — server transient, excluding the two special-cased server codes + r7 := byCode[7] + assert.Contains(t, r7.ErrorTypes, "network.*") + assert.Contains(t, r7.ErrorTypes, "rate_limited→6") + assert.Contains(t, r7.ErrorTypes, "session_create_failed→1") +} + +func TestExitCodes_JSONOutput(t *testing.T) { + out, _ := iostreams.SetForTest(t) + root := NewRootCmd(cmdutil.New()) + root.SetArgs([]string{"exit-codes", "--format", "json"}) + root.SetOut(&bytes.Buffer{}) // discard cobra's writer; real output goes to iostreams.IO.Out + root.SetErr(&bytes.Buffer{}) + require.NoError(t, root.Execute()) + + got := out.String() + // Must contain the envelope key for code 10 + assert.Contains(t, got, `"code":10`, "JSON output must include code 10") + // Must contain the count + assert.Contains(t, got, `"count":11`, "JSON output must include count:11") + // Must contain exit 130 (signal-guard exit) + assert.Contains(t, got, `"code":130`, "JSON output must include code 130") +} + +func TestExitCodes_TextOutput(t *testing.T) { + out, _ := iostreams.SetForTest(t) + root := NewRootCmd(cmdutil.New()) + root.SetArgs([]string{"exit-codes", "--format", "text"}) + root.SetOut(&bytes.Buffer{}) // discard cobra's writer; real output goes to iostreams.IO.Out + root.SetErr(&bytes.Buffer{}) + require.NoError(t, root.Execute()) + + got := out.String() + lines := strings.Split(strings.TrimRight(got, "\n"), "\n") + // Header line + 11 data rows = 12 lines + require.Len(t, lines, 12, "text output must have header + 11 data rows; got:\n%s", got) + // Header must contain column names + assert.Contains(t, lines[0], "CODE") + assert.Contains(t, lines[0], "MEANING") + assert.Contains(t, lines[0], "AGENT ACTION") + // First data row (exit 0) must mention "success" + assert.Contains(t, lines[1], "success", "first data row must describe exit 0 success") +} + +func TestExitCodes_HelpOutput(t *testing.T) { + var out bytes.Buffer + root := NewRootCmd(cmdutil.New()) + root.SetArgs([]string{"help", "exit-codes"}) + root.SetOut(&out) + root.SetErr(&bytes.Buffer{}) + require.NoError(t, root.Execute()) + + got := out.String() + assert.Contains(t, got, "Exit codes and the agent action for each", "help output must contain the Long description header") +} diff --git a/cli/cmd/kb/config.go b/cli/cmd/kb/config.go new file mode 100644 index 00000000..5b1f3e6c --- /dev/null +++ b/cli/cmd/kb/config.go @@ -0,0 +1,88 @@ +package kb + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/Tencent/WeKnora/cli/internal/cmdutil" + "github.com/Tencent/WeKnora/cli/internal/iostreams" + sdk "github.com/Tencent/WeKnora/client" +) + +// kbConfigFields enumerates the fields surfaced for `--format json` discovery on +// `kb config`. Mirrors client.InitializationConfig. +var kbConfigFields = []string{ + "chat_model_id", "embedding_model_id", "rerank_model_id", "multimodal_id", +} + +// ConfigService is the narrow SDK surface this command depends on. +type ConfigService interface { + GetInitializationConfig(ctx context.Context, kbID string) (*sdk.InitializationConfig, error) +} + +// NewCmdConfig builds `weknora kb config <kb-id>` — read-only inspection of a +// knowledge base's model configuration (set it with `weknora kb init`). +func NewCmdConfig(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "config <kb-id>", + Short: "Show a knowledge base's model configuration", + Long: `Show the model configuration bound to a knowledge base: chat, embedding, +rerank, and multimodal model ids. An empty embedding_model_id means the KB is +not yet usable for retrieval — configure it with 'weknora kb init'.`, + 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 + } + return runConfig(c.Context(), fopts, cli, args[0]) + }, + } + cmdutil.AddFormatFlag(cmd, kbConfigFields...) + cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{ + UsedFor: "show a KB's model config (chat/embedding/rerank/multimodal model ids). Empty embedding_model_id => not retrieval-ready; run `weknora kb init`.", + RequiredFlags: []string{"<kb-id> (positional)"}, + Examples: []string{"weknora kb config kb_abc --jq .data.embedding_model_id"}, + Output: "envelope.data is {chat_model_id, embedding_model_id, rerank_model_id, multimodal_id}", + }) + return cmd +} + +func runConfig(ctx context.Context, fopts *cmdutil.FormatOptions, svc ConfigService, kbID string) error { + cfg, err := svc.GetInitializationConfig(ctx, kbID) + if err != nil { + return cmdutil.WrapHTTP(err, "get config for knowledge base %q", kbID) + } + if cfg == nil { + cfg = &sdk.InitializationConfig{} + } + if fopts.WantsJSON() { + return fopts.Emit(iostreams.IO.Out, cfg, nil) + } + w := iostreams.IO.Out + fmt.Fprintf(w, "%-11s %s\n", "EMBEDDING:", orNone(cfg.EmbeddingModelID)) + fmt.Fprintf(w, "%-11s %s\n", "CHAT:", orNone(cfg.ChatModelID)) + fmt.Fprintf(w, "%-11s %s\n", "RERANK:", orNone(cfg.RerankModelID)) + fmt.Fprintf(w, "%-11s %s\n", "MULTIMODAL:", orNone(cfg.MultimodalID)) + if cfg.EmbeddingModelID == "" { + fmt.Fprintln(w, "\n(no embedding model set — run `weknora kb init <kb-id> --embedding-model <id>`)") + } + return nil +} + +func orNone(s string) string { + if s == "" { + return "(unset)" + } + return s +} + +// compile-time check: the production SDK client implements ConfigService. +var _ ConfigService = (*sdk.Client)(nil) diff --git a/cli/cmd/kb/config_test.go b/cli/cmd/kb/config_test.go new file mode 100644 index 00000000..98771271 --- /dev/null +++ b/cli/cmd/kb/config_test.go @@ -0,0 +1,50 @@ +package kb + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/Tencent/WeKnora/cli/internal/cmdutil" + "github.com/Tencent/WeKnora/cli/internal/iostreams" + sdk "github.com/Tencent/WeKnora/client" +) + +type fakeConfigSvc struct { + cfg *sdk.InitializationConfig + err error +} + +func (f *fakeConfigSvc) GetInitializationConfig(_ context.Context, _ string) (*sdk.InitializationConfig, error) { + return f.cfg, f.err +} + +func TestKBConfig_EmitsConfig(t *testing.T) { + out, _ := iostreams.SetForTest(t) + svc := &fakeConfigSvc{cfg: &sdk.InitializationConfig{EmbeddingModelID: "model_emb", ChatModelID: "model_chat"}} + require.NoError(t, runConfig(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb_abc")) + var env struct { + OK bool `json:"ok"` + Data sdk.InitializationConfig `json:"data"` + } + require.NoError(t, json.Unmarshal(out.Bytes(), &env)) + assert.True(t, env.OK) + assert.Equal(t, "model_emb", env.Data.EmbeddingModelID) + assert.Equal(t, "model_chat", env.Data.ChatModelID) +} + +// TestKBConfig_NilConfig: a nil server config (KB not yet initialized) emits an +// empty object, not a crash. +func TestKBConfig_NilConfig(t *testing.T) { + out, _ := iostreams.SetForTest(t) + svc := &fakeConfigSvc{cfg: nil} + require.NoError(t, runConfig(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb_abc")) + var env struct { + Data sdk.InitializationConfig `json:"data"` + } + require.NoError(t, json.Unmarshal(out.Bytes(), &env)) + assert.Empty(t, env.Data.EmbeddingModelID) +} diff --git a/cli/cmd/kb/create.go b/cli/cmd/kb/create.go index 6521d84d..37e7fab7 100644 --- a/cli/cmd/kb/create.go +++ b/cli/cmd/kb/create.go @@ -3,7 +3,6 @@ package kb import ( "context" "fmt" - "slices" "strings" "github.com/spf13/cobra" @@ -36,7 +35,7 @@ type CreateOptions struct { // storageProviderValues mirrors the server enum in // internal/types/knowledgebase.go:StorageProviderConfig.Provider. -var storageProviderValues = []string{"local", "minio", "cos", "tos", "s3", "oss", "ks3"} +var storageProviderValues = []string{"local", "minio", "cos", "tos", "s3", "oss", "ks3", "obs"} // CreateService is the narrow SDK surface this command depends on. // *sdk.Client satisfies it via duck typing. @@ -60,16 +59,16 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { fopts.ResolveDefault(iostreams.IO.IsStdoutTTY()) opts.Name = args[0] // Validate --storage-provider enum before the dry-run gate so - // --dry-run rejects identically to the live path. Same typed - // FlagError as runCreate (kept there for direct-call callers). - if opts.StorageProvider != "" { - v := strings.ToLower(strings.TrimSpace(opts.StorageProvider)) - if !slices.Contains(storageProviderValues, v) { - return cmdutil.NewFlagError(fmt.Errorf( - "invalid --storage-provider %q: must be %s", - opts.StorageProvider, strings.Join(storageProviderValues, " | "))) - } + // --dry-run rejects identically to the live path. ValidateEnum + // returns input.invalid_argument (exit 5) and normalizes to the + // canonical lowercase form — consistent with every other enum flag + // (model --type, agent --agent-mode, message search --mode). + // runCreate re-validates for direct-call callers. + canonSP, err := cmdutil.ValidateEnum("storage-provider", opts.StorageProvider, storageProviderValues) + if err != nil { + return err } + opts.StorageProvider = canonSP if handled, err := cmdutil.HandleDryRun(c, opts.DryRun, cmdutil.DryRunPlan{ Action: "kb.create", Args: map[string]any{ @@ -83,11 +82,18 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { if err != nil { return err } + // --embedding-model accepts a model id or name (a UUID passes + // through; a name resolves among Embedding models). Configuring a + // KB's models fully is `weknora kb init`; this just pre-sets the + // embedding model at creation. + if opts.EmbeddingModel, err = cmdutil.ResolveModelRef(c.Context(), cli, opts.EmbeddingModel, "Embedding"); err != nil { + return err + } return runCreate(c.Context(), opts, fopts, cli) }, } cmd.Flags().StringVar(&opts.Description, "description", "", "Knowledge base description (optional)") - cmd.Flags().StringVar(&opts.EmbeddingModel, "embedding-model", "", "Embedding model ID (optional; server picks default when unset)") + cmd.Flags().StringVar(&opts.EmbeddingModel, "embedding-model", "", "Embedding model id or name (optional; configure models fully with `weknora kb init`)") cmd.Flags().StringVar(&opts.StorageProvider, "storage-provider", "", "Storage provider for documents in this KB: "+strings.Join(storageProviderValues, " | ")+" (optional; server default when unset)") cmdutil.AddFormatFlag(cmd, kbCreateFields...) @@ -95,7 +101,12 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{ UsedFor: "Create a new knowledge base with the given name. Emits the created KB object with its id.", RequiredFlags: []string{"<name> (positional)"}, - Output: "envelope.data is the created KnowledgeBase object with id, name, type, embedding_model_id", + Examples: []string{ + `weknora kb create "Eng Docs"`, + `weknora kb create "Eng Docs" --description "engineering knowledge base"`, + `weknora kb create "Eng Docs" --jq .data.id # capture id to chain into doc upload --kb`, + }, + Output: "envelope.data is the created KnowledgeBase object with id, name, type, embedding_model_id", }) return cmd } @@ -115,13 +126,11 @@ func runCreate(ctx context.Context, opts *CreateOptions, fopts *cmdutil.FormatOp req.EmbeddingModelID = opts.EmbeddingModel } if opts.StorageProvider != "" { - v := strings.ToLower(strings.TrimSpace(opts.StorageProvider)) - if !slices.Contains(storageProviderValues, v) { - return cmdutil.NewFlagError(fmt.Errorf( - "invalid --storage-provider %q: must be %s", - opts.StorageProvider, strings.Join(storageProviderValues, " | "))) + canonSP, err := cmdutil.ValidateEnum("storage-provider", opts.StorageProvider, storageProviderValues) + if err != nil { + return err } - req.StorageProviderConfig = &sdk.StorageProviderConfig{Provider: v} + req.StorageProviderConfig = &sdk.StorageProviderConfig{Provider: canonSP} } created, err := svc.CreateKnowledgeBase(ctx, req) diff --git a/cli/cmd/kb/create_dry_run_test.go b/cli/cmd/kb/create_dry_run_test.go index ebd2662f..a296507b 100644 --- a/cli/cmd/kb/create_dry_run_test.go +++ b/cli/cmd/kb/create_dry_run_test.go @@ -93,7 +93,7 @@ func TestKBCreate_DryRun_RejectsInvalidStorageProvider(t *testing.T) { err := root.Execute() require.Error(t, err, "dry-run must reject invalid --storage-provider") - // runCreate emits a FlagError (exit 2) for this — make sure dry-run preserves - // that exact mapping. - assert.Equal(t, 2, cmdutil.ExitCode(err), "FlagError must map to exit 2") + // The enum check returns input.invalid_argument (exit 5) — make sure the + // dry-run path preserves that exact mapping (same as the live path). + assert.Equal(t, 5, cmdutil.ExitCode(err), "invalid --storage-provider must map to exit 5") } diff --git a/cli/cmd/kb/create_test.go b/cli/cmd/kb/create_test.go index 34fd8e21..a92ca438 100644 --- a/cli/cmd/kb/create_test.go +++ b/cli/cmd/kb/create_test.go @@ -138,16 +138,22 @@ func TestCreate_StorageProvider_InjectsRequest(t *testing.T) { assert.Equal(t, "local", svc.got.StorageProviderConfig.Provider, "value should be lowercased + trimmed before send") } -func TestCreate_StorageProvider_InvalidValueReturnsFlagError(t *testing.T) { +func TestCreate_StorageProvider_InvalidValueReturnsInputError(t *testing.T) { _, _ = iostreams.SetForTest(t) svc := &fakeCreateSvc{} opts := &CreateOptions{Name: "n", StorageProvider: "azure"} err := runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc) require.Error(t, err) - assert.Equal(t, 2, cmdutil.ExitCode(err), "invalid --storage-provider must exit 2 (flag validation)") - assert.Contains(t, err.Error(), "invalid --storage-provider") - assert.Nil(t, svc.got, "SDK must not be called when flag validation fails") + // A bad enum *value* (cobra accepted the string; the app rejected it) is an + // app-level input error → exit 5, consistent with every other enum flag + // (model --type, agent --agent-mode, message search --mode). + var typed *cmdutil.Error + require.ErrorAs(t, err, &typed) + assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code) + assert.Equal(t, 5, cmdutil.ExitCode(err), "invalid --storage-provider must exit 5 (app-level input validation)") + assert.Contains(t, err.Error(), "--storage-provider") + assert.Nil(t, svc.got, "SDK must not be called when input validation fails") } func TestCreate_StorageProvider_OmittedWhenEmpty(t *testing.T) { diff --git a/cli/cmd/kb/delete.go b/cli/cmd/kb/delete.go index 89ca2e5f..02a5cd52 100644 --- a/cli/cmd/kb/delete.go +++ b/cli/cmd/kb/delete.go @@ -94,7 +94,7 @@ exactly to guard against unintended deletes.`, } 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(), "delete", "knowledge base", id, "kb.delete", "weknora kb delete "+id+" -y"); err != nil { + if err := cmdutil.ConfirmDestructive(p, opts.Yes, fopts.WantsJSON(), "delete", "knowledge base", id, "kb.delete", []string{"weknora", "kb", "delete", id, "-y"}); err != nil { return err } diff --git a/cli/cmd/kb/delete_test.go b/cli/cmd/kb/delete_test.go index 9ba3a229..ba9329eb 100644 --- a/cli/cmd/kb/delete_test.go +++ b/cli/cmd/kb/delete_test.go @@ -167,8 +167,8 @@ func TestDelete_JSONOut_WithYes_Proceeds(t *testing.T) { func TestKbDelete_NoYes_JSONMode_AttachesRiskAndRetry(t *testing.T) { // Non-TTY + JSON mode without -y must return CodeInputConfirmationRequired - // with risk.action == "kb.delete" and retry_command == "weknora kb delete kb_x -y". - // Regression test for H1: ConfirmDestructive must attach risk + retry_command. + // with risk.action == "kb.delete" and retry_argv == [weknora kb delete kb_x -y]. + // Regression test for H1: ConfirmDestructive must attach risk + retry_argv. iostreams.SetForTest(t) svc := &fakeDeleteSvc{} p := &testutil.ConfirmPrompter{} @@ -185,5 +185,5 @@ func TestKbDelete_NoYes_JSONMode_AttachesRiskAndRetry(t *testing.T) { require.NotNil(t, ce.Risk, "expected risk metadata on confirmation_required error") assert.Equal(t, "kb.delete", ce.Risk.Action, "expected risk.action == kb.delete") assert.Equal(t, "destructive", ce.Risk.Level, "expected risk.level == destructive") - assert.Equal(t, "weknora kb delete kb_x -y", ce.RetryCommand, "expected retry_command") + assert.Equal(t, []string{"weknora", "kb", "delete", "kb_x", "-y"}, ce.RetryArgv, "expected retry_argv") } diff --git a/cli/cmd/kb/edit.go b/cli/cmd/kb/edit.go index 876e846b..25ffed6c 100644 --- a/cli/cmd/kb/edit.go +++ b/cli/cmd/kb/edit.go @@ -3,10 +3,8 @@ package kb import ( "context" "fmt" - "strings" "github.com/spf13/cobra" - "github.com/spf13/pflag" "github.com/Tencent/WeKnora/cli/internal/cmdutil" "github.com/Tencent/WeKnora/cli/internal/iostreams" @@ -14,7 +12,7 @@ import ( ) // kbEditFields enumerates the fields surfaced for `--format json` discovery on -// `kb edit`. The result is the updated KnowledgeBase; mirrors the kb +// `kb update`. The result is the updated KnowledgeBase; mirrors the kb // top-level json tags. var kbEditFields = []string{ "id", "name", "type", "description", @@ -47,14 +45,14 @@ type EditService interface { UpdateKnowledgeBase(ctx context.Context, id string, req *sdk.UpdateKnowledgeBaseRequest) (*sdk.KnowledgeBase, error) } -// NewCmdEdit builds `weknora kb edit <id>`. At least one of --name / +// NewCmdEdit builds `weknora kb update <id>`. At least one of --name / // --description must be provided. func NewCmdEdit(f *cmdutil.Factory) *cobra.Command { opts := &EditOptions{} var name, desc string cmd := &cobra.Command{ - Use: "edit <kb-id>", - Short: "Edit a knowledge base's name or description", + Use: "update <kb-id>", + Short: "Update 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. @@ -78,14 +76,13 @@ to the user first.`, } id := args[0] // Validate pure-local "at least one mutation flag" before the - // dry-run gate so --dry-run rejects identically to the live path - // (industry standard: gh / kubectl / lark all validate before - // preview). Same typed Error as runEdit so live behavior is - // unchanged, just earlier. + // dry-run gate so --dry-run rejects identically to the live path. + // Same typed Error as runEdit so live behavior is unchanged, just + // earlier. if opts.Name == nil && opts.Description == nil { return &cmdutil.Error{ Code: cmdutil.CodeInputMissingFlag, - Message: "kb edit requires at least one of --name or --description", + Message: "kb update requires at least one of --name or --description", Hint: "pass --name <name> and/or --description <desc>", } } @@ -97,7 +94,7 @@ to the user first.`, planArgs["description"] = *opts.Description } if handled, err := cmdutil.HandleDryRun(c, opts.DryRun, cmdutil.DryRunPlan{ - Action: "kb.edit", + Action: "kb.update", Args: planArgs, }); handled { return err @@ -108,8 +105,8 @@ to the user first.`, } // Build a retry command from the flags the user actually passed so // agents can re-invoke with -y after explicit human approval. - retryCmd := buildKBEditRetryCmd(c, id) - if err := cmdutil.ConfirmDestructive(f.Prompter(), opts.Yes, fopts.WantsJSON(), "edit", "knowledge base", id, "kb.edit", retryCmd); err != nil { + retryCmd := cmdutil.BuildRetryArgv(c, []string{"weknora", "kb", "update", id}, "name", "description", "format") + if err := cmdutil.ConfirmWrite(f.Prompter(), opts.Yes, fopts.WantsJSON(), "update", "knowledge base", id, "kb.update", retryCmd); err != nil { return err } return runEdit(c.Context(), opts, fopts, cli, id) @@ -119,46 +116,27 @@ to the user first.`, cmd.Flags().StringVar(&desc, "description", "", "New description (omit to leave unchanged)") cmdutil.AddFormatFlag(cmd, kbEditFields...) cmdutil.AddDryRunFlag(cmd, &opts.DryRun) - cmdutil.SetRisk(cmd, "kb.edit") + cmdutil.SetWriteRisk(cmd, "kb.update") cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{ UsedFor: "update a knowledge base's name or description", RequiredFlags: []string{"<kb-id> (positional)", "--name or --description (at least one)"}, Examples: []string{ - "weknora kb edit kb_abc --name \"New Name\" -y", - "weknora kb edit kb_abc --description \"Updated desc\" --format json -y", + "weknora kb update kb_abc --name \"New Name\" -y", + "weknora kb update kb_abc --description \"Updated desc\" --format json -y", }, Warnings: []string{ "Requires explicit user approval (exit 10 / input.confirmation_required); never auto-add -y.", - "kb edit overwrites fields; SDK uses fetch-then-update to avoid clobbering, but malformed input can still corrupt config.", + "kb update overwrites fields; SDK uses fetch-then-update to avoid clobbering, but malformed input can still corrupt config.", }, }) return cmd } -// buildKBEditRetryCmd constructs a directly-executable retry argv from the -// flags the user actually set so agents can surface a precise re-run command. -func buildKBEditRetryCmd(c *cobra.Command, id string) string { - var parts []string - parts = append(parts, "weknora", "kb", "edit", id) - c.Flags().Visit(func(f *pflag.Flag) { - switch f.Name { - case "name": - parts = append(parts, "--name", f.Value.String()) - case "description": - parts = append(parts, "--description", f.Value.String()) - case "format": - parts = append(parts, "--format", f.Value.String()) - } - }) - parts = append(parts, "-y") - return strings.Join(parts, " ") -} - 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, - Message: "kb edit requires at least one of --name or --description", + Message: "kb update requires at least one of --name or --description", Hint: "pass --name <name> and/or --description <desc>", } } @@ -183,7 +161,7 @@ func runEdit(ctx context.Context, opts *EditOptions, fopts *cmdutil.FormatOption updated, err := svc.UpdateKnowledgeBase(ctx, id, req) if err != nil { - return cmdutil.WrapHTTP(err, "edit knowledge base %s", id) + return cmdutil.WrapHTTP(err, "update knowledge base %s", id) } if fopts.WantsJSON() { return fopts.Emit(iostreams.IO.Out, updated, nil) diff --git a/cli/cmd/kb/edit_dry_run_test.go b/cli/cmd/kb/edit_dry_run_test.go index c3e88460..1818be30 100644 --- a/cli/cmd/kb/edit_dry_run_test.go +++ b/cli/cmd/kb/edit_dry_run_test.go @@ -15,8 +15,7 @@ import ( // "no mutation flag supplied" invocation the live path rejects. Before the // fix the validation lived in runEdit() (the live-only callee) and was // reached only after HandleDryRun early-exited, so --dry-run silently -// emitted a no-op plan envelope. Industry standard (gh / kubectl / lark) -// validates before previewing. +// emitted a no-op plan envelope. Validation must run before previewing. func TestKBEdit_DryRun_RejectsNoMutationFlag(t *testing.T) { iostreams.SetForTest(t) f := kbDryRunFactory(t) diff --git a/cli/cmd/kb/edit_test.go b/cli/cmd/kb/edit_test.go index 2b2a948e..dcef1097 100644 --- a/cli/cmd/kb/edit_test.go +++ b/cli/cmd/kb/edit_test.go @@ -119,7 +119,7 @@ func TestEdit_NotFound(t *testing.T) { func stringPtr(s string) *string { return &s } -// withRootHarnessKB wraps `weknora kb edit ...` under a synthetic root cmd +// withRootHarnessKB wraps `weknora kb update ...` under a synthetic root cmd // that registers the global persistent flags (mirrors addGlobalFlags in // cmd/root.go). Required because NewCmdEdit reads --yes and --format from the // persistent flag set. @@ -132,7 +132,7 @@ func withRootHarnessKB(edit *cobra.Command, args ...string) *cobra.Command { kb := &cobra.Command{Use: "kb"} kb.AddCommand(edit) root.AddCommand(kb) - root.SetArgs(append([]string{"kb", "edit"}, args...)) + root.SetArgs(append([]string{"kb", "update"}, args...)) root.SetContext(context.Background()) root.SilenceErrors = true root.SilenceUsage = true @@ -140,7 +140,7 @@ func withRootHarnessKB(edit *cobra.Command, args ...string) *cobra.Command { } // TestKBEdit_RequiresConfirmation asserts that without -y (non-TTY / JSON -// mode), kb edit returns input.confirmation_required (exit 10). +// mode), kb update returns input.confirmation_required (exit 10). func TestKBEdit_RequiresConfirmation(t *testing.T) { iostreams.SetForTest(t) // non-TTY svc := &fakeEditSvc{ @@ -159,7 +159,7 @@ func TestKBEdit_RequiresConfirmation(t *testing.T) { require.ErrorAs(t, err, &ce) assert.Equal(t, cmdutil.CodeInputConfirmationRequired, ce.Code) assert.Equal(t, 10, cmdutil.ExitCode(err)) - // retry command must include -y - assert.Contains(t, ce.RetryCommand, "-y") - assert.Contains(t, ce.RetryCommand, "kb_abc") + // retry argv must include -y + assert.Contains(t, ce.RetryArgv, "-y") + assert.Contains(t, ce.RetryArgv, "kb_abc") } diff --git a/cli/cmd/kb/init.go b/cli/cmd/kb/init.go new file mode 100644 index 00000000..a6096ed1 --- /dev/null +++ b/cli/cmd/kb/init.go @@ -0,0 +1,158 @@ +package kb + +import ( + "context" + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/Tencent/WeKnora/cli/internal/cmdutil" + "github.com/Tencent/WeKnora/cli/internal/iostreams" + sdk "github.com/Tencent/WeKnora/client" +) + +// kbInitFields enumerates the fields surfaced for `--format json` discovery on +// `kb init`. The result is the resulting InitializationConfig (read back). +var kbInitFields = []string{ + "chat_model_id", "embedding_model_id", "rerank_model_id", "multimodal_id", +} + +type InitOptions struct { + ChatModel string + EmbeddingModel string + Yes bool + DryRun bool +} + +// InitService is the narrow SDK surface this command depends on. SetKBModelConfig +// points the KB at already-registered models; GetInitializationConfig re-reads +// the server's resulting state so the success envelope reflects what stuck. +type InitService interface { + SetKBModelConfig(ctx context.Context, kbID string, cfg *sdk.KBModelConfig) error + GetInitializationConfig(ctx context.Context, kbID string) (*sdk.InitializationConfig, error) +} + +// NewCmdInit builds `weknora kb init <kb-id>` — bind models to a knowledge base +// so it becomes usable for retrieval and generation. +func NewCmdInit(f *cmdutil.Factory) *cobra.Command { + opts := &InitOptions{} + cmd := &cobra.Command{ + Use: "init <kb-id>", + Short: "Configure a knowledge base's models (make it usable)", + Long: `Bind already-registered models to a knowledge base so it can embed, retrieve, +and generate. Both --chat-model (LLM, used for generation/summary) and +--embedding-model (used for retrieval) are required; register models first with +'weknora model create' and discover ids with 'weknora model list'. + +High-risk write: changing a KB's embedding model affects how its content is +indexed and searched (and the server refuses once the KB has documents). +Without -y/--yes in a non-TTY / JSON context it exits 10 +(input.confirmation_required) without applying the change.`, + 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()) + opts.Yes, _ = c.Flags().GetBool("yes") + kbID := args[0] + // Validate required flags before the dry-run gate so --dry-run rejects + // identically to the live path. + if err := validateInitFlags(opts); err != nil { + return err + } + if handled, err := cmdutil.HandleDryRun(c, opts.DryRun, cmdutil.DryRunPlan{ + Action: "kb.init", + Args: map[string]any{"kb": kbID, "chat_model": opts.ChatModel, "embedding_model": opts.EmbeddingModel}, + }); handled { + return err + } + cli, err := f.Client() + if err != nil { + return err + } + if err := cmdutil.ConfirmDestructive(f.Prompter(), opts.Yes, fopts.WantsJSON(), + "configure", "knowledge base", kbID, "kb.init", + cmdutil.BuildRetryArgv(c, []string{"weknora", "kb", "init", kbID}, "chat-model", "embedding-model", "format")); err != nil { + return err + } + // Resolve name-or-id for the model flags (a UUID passes through; a + // name is looked up among models of the expected type). Network read + // on the live path only — the dry-run above shows the raw refs. + if opts.ChatModel, err = cmdutil.ResolveModelRef(c.Context(), cli, opts.ChatModel, "KnowledgeQA"); err != nil { + return err + } + if opts.EmbeddingModel, err = cmdutil.ResolveModelRef(c.Context(), cli, opts.EmbeddingModel, "Embedding"); err != nil { + return err + } + return runInit(c.Context(), opts, fopts, cli, kbID) + }, + } + cmd.Flags().StringVar(&opts.ChatModel, "chat-model", "", "Chat / LLM model id or name for generation & summary (required) — see `weknora model list`") + cmd.Flags().StringVar(&opts.EmbeddingModel, "embedding-model", "", "Embedding model id or name for retrieval (required) — see `weknora model list`") + cmdutil.AddFormatFlag(cmd, kbInitFields...) + cmdutil.AddDryRunFlag(cmd, &opts.DryRun) + cmdutil.SetRisk(cmd, "kb.init") + cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{ + UsedFor: "bind models to a KB so it becomes usable. --chat-model and --embedding-model are required and accept a model id or name; discover them with `weknora model list`.", + RequiredFlags: []string{"<kb-id> (positional)", "--chat-model", "--embedding-model"}, + Examples: []string{ + "weknora kb init kb_abc --chat-model model_llm --embedding-model model_emb -y", + }, + Output: "envelope.data is the resulting {chat_model_id, embedding_model_id, rerank_model_id, multimodal_id}", + Warnings: []string{ + "Requires explicit user approval (exit 10 / input.confirmation_required); never auto-add -y.", + "The server refuses to change the embedding model of a KB that already has documents.", + }, + }) + return cmd +} + +func validateInitFlags(opts *InitOptions) error { + var missing []string + if strings.TrimSpace(opts.ChatModel) == "" { + missing = append(missing, "--chat-model") + } + if strings.TrimSpace(opts.EmbeddingModel) == "" { + missing = append(missing, "--embedding-model") + } + if len(missing) == 0 { + return nil + } + return &cmdutil.Error{ + Code: cmdutil.CodeInputMissingFlag, + Message: "kb init requires " + strings.Join(missing, " and "), + Hint: "discover model ids with `weknora model list` (or register one with `weknora model create`), then pass --chat-model <id> --embedding-model <id>", + } +} + + +func runInit(ctx context.Context, opts *InitOptions, fopts *cmdutil.FormatOptions, svc InitService, kbID string) error { + if err := validateInitFlags(opts); err != nil { + return err + } + cfg := &sdk.KBModelConfig{ + LLMModelID: opts.ChatModel, + EmbeddingModelID: opts.EmbeddingModel, + } + if err := svc.SetKBModelConfig(ctx, kbID, cfg); err != nil { + return cmdutil.WrapHTTP(err, "configure knowledge base %q", kbID) + } + // Re-read the server's resulting state so the envelope reflects what stuck. + result, err := svc.GetInitializationConfig(ctx, kbID) + if err != nil || result == nil { + // The write succeeded; surface what we applied if the read-back failed. + result = &sdk.InitializationConfig{ChatModelID: opts.ChatModel, EmbeddingModelID: opts.EmbeddingModel} + } + if fopts.WantsJSON() { + return fopts.Emit(iostreams.IO.Out, result, nil) + } + fmt.Fprintf(iostreams.IO.Out, "✓ Configured knowledge base %s (chat: %s, embedding: %s)\n", + kbID, result.ChatModelID, result.EmbeddingModelID) + return nil +} + +// compile-time check: the production SDK client implements InitService. +var _ InitService = (*sdk.Client)(nil) diff --git a/cli/cmd/kb/init_test.go b/cli/cmd/kb/init_test.go new file mode 100644 index 00000000..8993720d --- /dev/null +++ b/cli/cmd/kb/init_test.go @@ -0,0 +1,134 @@ +package kb + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/Tencent/WeKnora/cli/internal/cmdutil" + "github.com/Tencent/WeKnora/cli/internal/iostreams" + "github.com/Tencent/WeKnora/cli/internal/prompt" + sdk "github.com/Tencent/WeKnora/client" +) + +type fakeInitSvc struct { + gotKB string + gotCfg *sdk.KBModelConfig + result *sdk.InitializationConfig + setErr error +} + +func (f *fakeInitSvc) SetKBModelConfig(_ context.Context, kbID string, cfg *sdk.KBModelConfig) error { + f.gotKB = kbID + f.gotCfg = cfg + return f.setErr +} + +func (f *fakeInitSvc) GetInitializationConfig(_ context.Context, _ string) (*sdk.InitializationConfig, error) { + if f.result != nil { + return f.result, nil + } + if f.gotCfg == nil { + return &sdk.InitializationConfig{}, nil + } + return &sdk.InitializationConfig{ + ChatModelID: f.gotCfg.LLMModelID, + EmbeddingModelID: f.gotCfg.EmbeddingModelID, + }, nil +} + +func TestKBInit_AppliesAndEmitsResult(t *testing.T) { + out, _ := iostreams.SetForTest(t) + svc := &fakeInitSvc{} + opts := &InitOptions{ChatModel: "model_llm", EmbeddingModel: "model_emb"} + require.NoError(t, runInit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb_abc")) + + assert.Equal(t, "kb_abc", svc.gotKB) + require.NotNil(t, svc.gotCfg) + assert.Equal(t, "model_llm", svc.gotCfg.LLMModelID) + assert.Equal(t, "model_emb", svc.gotCfg.EmbeddingModelID) + + var env struct { + OK bool `json:"ok"` + Data sdk.InitializationConfig `json:"data"` + } + require.NoError(t, json.Unmarshal(out.Bytes(), &env)) + assert.True(t, env.OK) + assert.Equal(t, "model_emb", env.Data.EmbeddingModelID) + assert.Equal(t, "model_llm", env.Data.ChatModelID) +} + +func TestKBInit_RequiresBothModels(t *testing.T) { + _, _ = iostreams.SetForTest(t) + svc := &fakeInitSvc{} + // Missing both. + err := runInit(context.Background(), &InitOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_abc") + var ce *cmdutil.Error + require.ErrorAs(t, err, &ce) + assert.Equal(t, cmdutil.CodeInputMissingFlag, ce.Code) + assert.Equal(t, "", svc.gotKB, "must not call SetKBModelConfig when flags are missing") + + // Missing just embedding. + err = runInit(context.Background(), &InitOptions{ChatModel: "m"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_abc") + require.ErrorAs(t, err, &ce) + assert.Contains(t, ce.Message, "--embedding-model") +} + +func TestKBInit_WriteSucceedsReadbackFails(t *testing.T) { + out, _ := iostreams.SetForTest(t) + svc2 := &readbackErrSvc{fakeInitSvc: &fakeInitSvc{}} + opts := &InitOptions{ChatModel: "model_llm", EmbeddingModel: "model_emb"} + require.NoError(t, runInit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc2, "kb_abc")) + var env struct { + Data sdk.InitializationConfig `json:"data"` + } + require.NoError(t, json.Unmarshal(out.Bytes(), &env)) + assert.Equal(t, "model_emb", env.Data.EmbeddingModelID, "falls back to applied config when read-back fails") +} + +type readbackErrSvc struct{ *fakeInitSvc } + +func (s *readbackErrSvc) GetInitializationConfig(_ context.Context, _ string) (*sdk.InitializationConfig, error) { + return nil, errors.New("read-back boom") +} + +// withRootKB wraps a kb subcommand under a synthetic root with global flags, +// keyed off the sub's own name (unlike withRootHarnessKB which is hardcoded to +// `update`). +func withRootKB(sub *cobra.Command, args ...string) *cobra.Command { + root := &cobra.Command{Use: "weknora"} + pf := root.PersistentFlags() + pf.BoolP("yes", "y", false, "") + pf.String("format", "", "") + pf.StringP("jq", "q", "", "") + kb := &cobra.Command{Use: "kb"} + kb.AddCommand(sub) + root.AddCommand(kb) + root.SetArgs(append([]string{"kb", sub.Name()}, args...)) + root.SetContext(context.Background()) + root.SilenceErrors = true + root.SilenceUsage = true + return root +} + +func TestKBInit_RequiresConfirmation(t *testing.T) { + iostreams.SetForTest(t) + f := &cmdutil.Factory{ + Client: func() (*sdk.Client, error) { return nil, nil }, + Prompter: func() prompt.Prompter { return prompt.AgentPrompter{} }, + } + root := withRootKB(NewCmdInit(f), "kb_abc", "--chat-model", "model_llm", "--embedding-model", "model_emb", "--format", "json") + err := root.Execute() + require.Error(t, err) + var ce *cmdutil.Error + require.ErrorAs(t, err, &ce) + assert.Equal(t, cmdutil.CodeInputConfirmationRequired, ce.Code) + assert.Equal(t, 10, cmdutil.ExitCode(err)) + assert.Contains(t, ce.RetryArgv, "-y") + assert.Contains(t, ce.RetryArgv, "model_emb") +} diff --git a/cli/cmd/kb/kb.go b/cli/cmd/kb/kb.go index 06e40a21..182f752e 100644 --- a/cli/cmd/kb/kb.go +++ b/cli/cmd/kb/kb.go @@ -1,6 +1,6 @@ // Package kb holds the `weknora kb` command tree: list / view / create / -// edit / delete / pin / unpin. Verb set follows common CRUD vocabulary -// (list/view/create/edit/delete) plus pin/unpin. Bulk content deletion +// update / delete / pin / unpin. Verb set follows common CRUD vocabulary +// (list/view/create/update/delete) plus pin/unpin. Bulk content deletion // is exposed via `weknora doc delete --all --kb=<id>`. package kb @@ -25,5 +25,7 @@ func NewCmd(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(NewCmdUnpin(f)) cmd.AddCommand(NewCmdStatus(f)) cmd.AddCommand(NewCmdCheck(f)) + cmd.AddCommand(NewCmdConfig(f)) + cmd.AddCommand(NewCmdInit(f)) return cmd } diff --git a/cli/cmd/kb/list.go b/cli/cmd/kb/list.go index d02b722d..0e8ac646 100644 --- a/cli/cmd/kb/list.go +++ b/cli/cmd/kb/list.go @@ -57,6 +57,12 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { return err } fopts.ResolveDefault(iostreams.IO.IsStdoutTTY()) + // Validate static input before building the client so a bad --limit + // returns input.invalid_argument (exit 5) rather than an auth error + // (exit 3) when no profile is configured. + if err := validateListOpts(opts); err != nil { + return err + } cli, err := f.Client() if err != nil { return err @@ -65,23 +71,33 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { }, } cmd.Flags().BoolVar(&opts.Pinned, "pinned", false, "Only show pinned knowledge bases") - cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum results to return (1..10000)") + cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum results to return — client-side cap; meta.has_more/total_count report the full size (1..10000)") cmdutil.AddFormatFlag(cmd, kbListFields...) cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{ - UsedFor: "List knowledge bases in the current tenant. --format json emits the v0.7 envelope {ok, data:[...], meta:{count}, profile}.", + UsedFor: "List knowledge bases in the current tenant. --format json emits the standard envelope {ok, data:[...], meta:{count}, profile}.", Examples: []string{"weknora kb list --format json"}, - Output: "envelope.data is an array of KnowledgeBase objects with id, name, is_pinned, type, embedding_model_id", + Output: "envelope.data is an array of KnowledgeBase objects with id, name, is_pinned, type, embedding_model_id; meta.total_count is the full tenant set and meta.has_more=true means --limit truncated it (raise --limit to get the rest)", }) return cmd } -func runList(ctx context.Context, opts *ListOptions, fopts *cmdutil.FormatOptions, svc ListService) error { +// validateListOpts checks --limit. Called from RunE before the client is built +// (so a bad value surfaces as exit 5, not an auth error) and at runList's top +// for direct callers; idempotent. +func validateListOpts(opts *ListOptions) error { if opts.Limit < 1 || opts.Limit > 10000 { return &cmdutil.Error{ Code: cmdutil.CodeInputInvalidArgument, Message: fmt.Sprintf("--limit must be in 1..10000, got %d", opts.Limit), } } + return nil +} + +func runList(ctx context.Context, opts *ListOptions, fopts *cmdutil.FormatOptions, svc ListService) error { + if err := validateListOpts(opts); err != nil { + return err + } items, err := svc.ListKnowledgeBases(ctx) if err != nil { return cmdutil.WrapHTTP(err, "list knowledge bases") @@ -104,16 +120,20 @@ func runList(ctx context.Context, opts *ListOptions, fopts *cmdutil.FormatOption sort.Slice(items, func(i, j int) bool { return items[i].UpdatedAt.After(items[j].UpdatedAt) }) - // --limit applies after sort so the cap returns the top-N most-recent. - // The KB list SDK is unpaginated, so client-side truncation does not - // imply server-side has_more — there is no cursor to continue with. - // has_more is omitted; callers needing all KBs should raise --limit. + // The KB list SDK is unpaginated — it returns every KB in one call — + // so the CLI holds the true total and can tell the caller whether the + // client-side --limit dropped any. total_count is the full count; + // has_more flags that --limit truncated it (raise --limit to get the + // rest — there is no server cursor to continue with). + total := len(items) + truncated := false if opts.Limit > 0 && len(items) > opts.Limit { items = items[:opts.Limit] + truncated = true } if fopts.WantsJSON() { - meta := &output.Meta{Count: len(items)} + meta := &output.Meta{Count: output.IntPtr(len(items)), HasMore: truncated, TotalCount: output.IntPtr(total)} return fopts.Emit(iostreams.IO.Out, items, meta) } diff --git a/cli/cmd/kb/list_test.go b/cli/cmd/kb/list_test.go index fb463a65..ac5a75b3 100644 --- a/cli/cmd/kb/list_test.go +++ b/cli/cmd/kb/list_test.go @@ -172,6 +172,43 @@ func TestList_Limit_CapsResults(t *testing.T) { } } +// TestList_Truncation_SignalsHasMoreAndTotal pins that a client-side --limit +// truncation tells the agent it did NOT get everything: has_more=true and +// total_count=full set. Regression: kb list silently dropped items past +// --limit with no completeness signal, so an agent listing to find a KB by +// name could miss KBs beyond position 30 and never know. +func TestList_Truncation_SignalsHasMoreAndTotal(t *testing.T) { + out, _ := iostreams.SetForTest(t) + svc := &fakeListSvc{items: makeKBs(20)} + fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON} + if err := runList(context.Background(), &ListOptions{Limit: 5}, fopts, svc); err != nil { + t.Fatalf("runList: %v", err) + } + if !strings.Contains(out.String(), `"has_more":true`) { + t.Errorf("truncated list must set has_more:true; got:\n%s", out.String()) + } + if !strings.Contains(out.String(), `"total_count":20`) { + t.Errorf("truncated list must report total_count:20; got:\n%s", out.String()) + } +} + +// TestList_NoTruncation_OmitsHasMore pins that when --limit does NOT truncate, +// has_more is absent (omitempty) so the agent reads "complete". +func TestList_NoTruncation_OmitsHasMore(t *testing.T) { + out, _ := iostreams.SetForTest(t) + svc := &fakeListSvc{items: makeKBs(3)} + fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON} + if err := runList(context.Background(), &ListOptions{Limit: 30}, fopts, svc); err != nil { + t.Fatalf("runList: %v", err) + } + if strings.Contains(out.String(), `"has_more"`) { + t.Errorf("non-truncated list must omit has_more; got:\n%s", out.String()) + } + if !strings.Contains(out.String(), `"total_count":3`) { + t.Errorf("list must report total_count:3; got:\n%s", out.String()) + } +} + func TestList_Limit_Zero_Rejected(t *testing.T) { _, _ = iostreams.SetForTest(t) svc := &fakeListSvc{items: makeKBs(7)} diff --git a/cli/cmd/kb/pin.go b/cli/cmd/kb/pin.go index 89cbfc89..0bd83916 100644 --- a/cli/cmd/kb/pin.go +++ b/cli/cmd/kb/pin.go @@ -21,10 +21,13 @@ type PinOptions struct { } // PinService is the narrow SDK surface this command depends on. The CLI -// reads current state before toggling so `pin`/`unpin` are idempotent - -// the server endpoint is only a non-idempotent toggle. +// reads current state before toggling so `pin`/`unpin` are idempotent — the +// server endpoint is only a toggle. Pin state is read from the LIST endpoint: +// the single-KB GET does not carry per-user pin state (pin is a per-user +// list-ordering preference, surfaced where the user sees it — in `kb list`), +// so the list is the canonical source. type PinService interface { - GetKnowledgeBase(ctx context.Context, id string) (*sdk.KnowledgeBase, error) + ListKnowledgeBases(ctx context.Context) ([]sdk.KnowledgeBase, error) TogglePinKnowledgeBase(ctx context.Context, id string) (*sdk.KnowledgeBase, error) } @@ -73,7 +76,7 @@ func newPinCmd(f *cmdutil.Factory, use string, want bool, short string) *cobra.C cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{ UsedFor: use + " a knowledge base (idempotent: a no-op if it is already in the target state)", RequiredFlags: []string{"<kb-id> (positional)"}, - Examples: []string{"weknora " + use + " kb_abc"}, + Examples: []string{"weknora kb " + use + " kb_abc"}, Output: "envelope.data is the KnowledgeBase with is_pinned reflecting the new state", }) return cmd @@ -84,9 +87,23 @@ func runPin(ctx context.Context, opts *PinOptions, fopts *cmdutil.FormatOptions, if !want { verb = "unpin" } - current, err := svc.GetKnowledgeBase(ctx, id) + // Read the current pin state from the list (the canonical per-user pin + // source); the single-KB GET doesn't carry it. This makes pin/unpin + // idempotent: we toggle only when the state actually needs to change. + kbs, err := svc.ListKnowledgeBases(ctx) if err != nil { - return cmdutil.WrapHTTP(err, "get knowledge base %s", id) + return cmdutil.WrapHTTP(err, "list knowledge bases") + } + var current *sdk.KnowledgeBase + for i := range kbs { + if kbs[i].ID == id { + current = &kbs[i] + break + } + } + if current == nil { + return cmdutil.NewError(cmdutil.CodeResourceNotFound, + fmt.Sprintf("knowledge base not found: %s", id)) } if current.IsPinned == want { state := "pinned" diff --git a/cli/cmd/kb/pin_test.go b/cli/cmd/kb/pin_test.go index f640ddf6..8662b0e3 100644 --- a/cli/cmd/kb/pin_test.go +++ b/cli/cmd/kb/pin_test.go @@ -15,7 +15,9 @@ import ( sdk "github.com/Tencent/WeKnora/client" ) -// fakePinSvc satisfies PinService: GetKnowledgeBase + TogglePinKnowledgeBase. +// fakePinSvc satisfies PinService: ListKnowledgeBases + TogglePinKnowledgeBase. +// The fake's `current` is returned as the single KB in the list (the list is +// the canonical pin-state source). type fakePinSvc struct { current sdk.KnowledgeBase getErr error @@ -23,13 +25,15 @@ type fakePinSvc struct { toggleCalled bool } -func (f *fakePinSvc) GetKnowledgeBase(_ context.Context, id string) (*sdk.KnowledgeBase, error) { +func (f *fakePinSvc) ListKnowledgeBases(_ context.Context) ([]sdk.KnowledgeBase, error) { if f.getErr != nil { return nil, f.getErr } c := f.current - c.ID = id - return &c, nil + if c.ID == "" { + c.ID = "kb_abc" // tests address this id + } + return []sdk.KnowledgeBase{c}, nil } func (f *fakePinSvc) TogglePinKnowledgeBase(_ context.Context, id string) (*sdk.KnowledgeBase, error) { diff --git a/cli/cmd/kb/view.go b/cli/cmd/kb/view.go index f2075ea0..a9392a41 100644 --- a/cli/cmd/kb/view.go +++ b/cli/cmd/kb/view.go @@ -27,8 +27,11 @@ var kbViewFields = []string{ type ViewOptions struct{} // ViewService is the narrow SDK surface this command depends on. +// ListKnowledgeBases is used only to enrich is_pinned (the single-KB GET +// doesn't carry per-user pin state — pin is a per-user list concept). type ViewService interface { GetKnowledgeBase(ctx context.Context, id string) (*sdk.KnowledgeBase, error) + ListKnowledgeBases(ctx context.Context) ([]sdk.KnowledgeBase, error) } // NewCmdView builds `weknora kb view <id>`. @@ -70,6 +73,18 @@ func runView(ctx context.Context, opts *ViewOptions, fopts *cmdutil.FormatOption if err != nil { return cmdutil.WrapHTTP(err, "get knowledge base %q", id) } + // The single-KB GET doesn't stamp per-user pin state, so is_pinned would + // always read false here. Enrich it from the list (the canonical pin + // source) so the field is accurate. Best-effort: a list error leaves the + // (unstamped) value rather than failing the view. + if kbs, lerr := svc.ListKnowledgeBases(ctx); lerr == nil { + for i := range kbs { + if kbs[i].ID == id { + kb.IsPinned = kbs[i].IsPinned + break + } + } + } if fopts.WantsJSON() { return fopts.Emit(iostreams.IO.Out, kb, nil) } diff --git a/cli/cmd/kb/view_test.go b/cli/cmd/kb/view_test.go index 941100a7..6e91abff 100644 --- a/cli/cmd/kb/view_test.go +++ b/cli/cmd/kb/view_test.go @@ -22,6 +22,15 @@ func (f *fakeGetSvc) GetKnowledgeBase(ctx context.Context, id string) (*sdk.Know return f.kb, f.err } +// ListKnowledgeBases backs is_pinned enrichment in runView. Returns the same +// KB so the enrichment finds it; nil/empty is fine (enrichment is best-effort). +func (f *fakeGetSvc) ListKnowledgeBases(ctx context.Context) ([]sdk.KnowledgeBase, error) { + if f.kb == nil { + return nil, nil + } + return []sdk.KnowledgeBase{*f.kb}, nil +} + func TestGet_OK_Text(t *testing.T) { out, _ := iostreams.SetForTest(t) svc := &fakeGetSvc{kb: &sdk.KnowledgeBase{ diff --git a/cli/cmd/link/link.go b/cli/cmd/link/link.go index 98f78bde..53ffef93 100644 --- a/cli/cmd/link/link.go +++ b/cli/cmd/link/link.go @@ -40,29 +40,42 @@ type linkResult struct { func NewCmd(f *cmdutil.Factory) *cobra.Command { opts := &Options{} cmd := &cobra.Command{ - Use: "link", + Use: "link [kb]", Short: "Bind the current directory to a knowledge base", Long: `Writes .weknora/project.yaml in the current working directory pointing at the supplied knowledge base. Subsequent commands run from this directory (or any subdirectory) automatically resolve --kb from the link unless overridden by the --kb flag or WEKNORA_KB_ID env var. -Pass --kb <id-or-name> for non-interactive use (scripts, CI). Run on a TTY -without --kb to be prompted from the list of available KBs. Always overwrites -any existing link - re-run to switch. +Pass the knowledge base as a positional argument (weknora link <id-or-name>) or +via --kb — the two are equivalent — for non-interactive use (scripts, CI). Run +on a TTY without either to be prompted from the list of available KBs. Always +overwrites any existing link - re-run to switch. AI agents: link writes to the user's working directory. Only run it when the user explicitly asked to bind this directory; don't run it as a side effect.`, - Example: ` weknora link --kb a32a63ff-fb36-4874-bcaa-30f48570a694 # explicit UUID - weknora link --kb engineering # name → id + Example: ` weknora link a32a63ff-fb36-4874-bcaa-30f48570a694 # positional UUID + weknora link engineering # positional name → id + weknora link --kb engineering # --kb form (equivalent) weknora link # interactive (TTY)`, - Args: cobra.NoArgs, - RunE: func(c *cobra.Command, _ []string) error { + // The knowledge base may be given as a positional arg (matching every + // other <id> command) or via --kb; the two are equivalent. + Args: cobra.MaximumNArgs(1), + RunE: func(c *cobra.Command, args []string) error { fopts, err := cmdutil.CheckFormatFlag(c) if err != nil { return err } fopts.ResolveDefault(iostreams.IO.IsStdoutTTY()) + // Accept the KB as a positional (consistent with `kb view <id>`, + // `doc view <id>`, …) in addition to --kb. Reject supplying both. + if len(args) == 1 { + if opts.KB != "" && opts.KB != args[0] { + return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, + "specify the knowledge base once: as a positional argument or --kb, not both") + } + opts.KB = args[0] + } // Pure-local validation runs before the dry-run gate so --dry-run // rejects identically to the live path. resolveProfile only reads // config; the non-TTY-without-`--kb` check is a flag-shape error. @@ -88,9 +101,10 @@ user explicitly asked to bind this directory; don't run it as a side effect.`, cmdutil.AddFormatFlag(cmd, linkFields...) cmdutil.AddDryRunFlag(cmd, &opts.DryRun) cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{ - UsedFor: "Bind the current directory to a knowledge base by writing .weknora/project.yaml. Requires --kb (non-interactive); only run when the user explicitly asks to link this directory.", - RequiredFlags: []string{"--kb (required when no TTY)"}, - Output: "envelope.data has kb_id, kb_name, project_link_path", + UsedFor: "Bind the current directory to a knowledge base by writing .weknora/project.yaml. Give the KB as a positional arg or --kb (non-interactive); only run when the user explicitly asks to link this directory.", + RequiredFlags: []string{"<kb> positional or --kb (required when no TTY)"}, + Examples: []string{"weknora link engineering", "weknora link --kb a32a63ff-fb36-4874-bcaa-30f48570a694"}, + Output: "envelope.data has profile, kb_id, kb_name, project_link_path", }) return cmd } diff --git a/cli/cmd/message/delete.go b/cli/cmd/message/delete.go new file mode 100644 index 00000000..420aaaef --- /dev/null +++ b/cli/cmd/message/delete.go @@ -0,0 +1,117 @@ +package messagecmd + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/Tencent/WeKnora/cli/internal/cmdutil" + "github.com/Tencent/WeKnora/cli/internal/iostreams" + "github.com/Tencent/WeKnora/cli/internal/prompt" +) + +var messageDeleteFields = []string{"id", "deleted"} + +// DeleteOptions holds the parsed flag/arg values for `message delete`. +type DeleteOptions struct { + SessionID string + MessageID string + Yes bool + DryRun bool +} + +// DeleteService is the narrow SDK surface this command depends on. +type DeleteService interface { + DeleteMessage(ctx context.Context, sessionID, messageID string) error +} + +// deleteResult is the typed payload emitted on success in JSON mode. +type deleteResult struct { + ID string `json:"id"` + Deleted bool `json:"deleted"` +} + +const messageDeleteLong = `Permanently delete one message from a session. + +Requires both the message id (positional) and the parent session id +(--session) because the server route encodes both: +DELETE /messages/{session_id}/{id}. + +Deleting an assistant message breaks the turn chain for follow-up +references (session ask). Prefer deleting whole sessions +(weknora session delete) unless you specifically need to redact one turn. + +Typed exit codes: + resource.not_found no message with the given id under that session (exit 4) + auth.forbidden caller lacks delete permission on the message (exit 3) + input.confirmation_required destructive op without -y on a TTY (exit 10) + +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 +exactly to guard against unintended deletes.` + +// NewCmdDelete builds `weknora message delete <message-id> --session <session-id>`. +func NewCmdDelete(f *cmdutil.Factory) *cobra.Command { + opts := &DeleteOptions{} + cmd := &cobra.Command{ + Use: "delete <message-id> --session <session-id>", + Short: "Delete one message from a session (high-risk write)", + Long: messageDeleteLong, + Example: ` weknora message delete msg_abc --session sess_xyz # interactive confirm + weknora message delete msg_abc --session sess_xyz -y # no prompt + weknora message delete msg_abc --session sess_xyz -y --format json # agent / JSON form`, + 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()) + opts.MessageID = args[0] + opts.Yes, _ = c.Flags().GetBool("yes") + if handled, err := cmdutil.HandleDryRun(c, opts.DryRun, cmdutil.DryRunPlan{ + Action: "message.delete", + Args: map[string]any{"message_id": opts.MessageID, "session": opts.SessionID}, + }); handled { + return err + } + cli, err := f.Client() + if err != nil { + return err + } + return runDelete(c.Context(), opts, fopts, cli, f.Prompter()) + }, + } + cmd.Flags().StringVar(&opts.SessionID, "session", "", "Parent session id the message lives in") + _ = cmd.MarkFlagRequired("session") + cmdutil.AddFormatFlag(cmd, messageDeleteFields...) + cmdutil.AddDryRunFlag(cmd, &opts.DryRun) + cmdutil.SetRisk(cmd, "message.delete") + cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{ + UsedFor: "permanently delete one message from a session", + RequiredFlags: []string{"<message-id> (positional)", "--session <session-id>"}, + Examples: []string{"weknora message delete msg_abc --session sess_xyz -y"}, + Output: "envelope.data is {id, deleted:true}", + Warnings: []string{ + "Requires explicit user approval (exit 10 / input.confirmation_required); never auto-add -y.", + "message delete is irreversible; breaks the turn chain for follow-up references to that conversation turn.", + }, + }) + return cmd +} + +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(), "delete", "message", opts.MessageID, "message.delete", []string{"weknora", "message", "delete", opts.MessageID, "--session", opts.SessionID, "-y"}); err != nil { + return err + } + if err := svc.DeleteMessage(ctx, opts.SessionID, opts.MessageID); err != nil { + return cmdutil.WrapHTTP(err, "delete message %s", opts.MessageID) + } + if fopts.WantsJSON() { + return fopts.Emit(iostreams.IO.Out, deleteResult{ID: opts.MessageID, Deleted: true}, nil) + } + fmt.Fprintf(iostreams.IO.Out, "✓ Deleted message %s\n", opts.MessageID) + return nil +} diff --git a/cli/cmd/message/delete_test.go b/cli/cmd/message/delete_test.go new file mode 100644 index 00000000..267cecac --- /dev/null +++ b/cli/cmd/message/delete_test.go @@ -0,0 +1,99 @@ +package messagecmd + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/Tencent/WeKnora/cli/internal/cmdutil" + "github.com/Tencent/WeKnora/cli/internal/iostreams" + "github.com/Tencent/WeKnora/cli/internal/testutil" +) + +type fakeDeleteSvc struct { + err error + gotSessID string + gotMsgID string +} + +func (s *fakeDeleteSvc) DeleteMessage(_ context.Context, sessionID, messageID string) error { + s.gotSessID, s.gotMsgID = sessionID, messageID + return s.err +} + +// TestRunDelete_YesSkipsPromptAndDeletes verifies that -y bypasses the confirm +// prompt, calls the service with the correct IDs, and emits {"deleted":true} +// JSON. +func TestRunDelete_YesSkipsPromptAndDeletes(t *testing.T) { + out, _ := iostreams.SetForTest(t) + svc := &fakeDeleteSvc{} + opts := &DeleteOptions{SessionID: "s1", MessageID: "m1", Yes: true} + require.NoError(t, runDelete(context.Background(), opts, jsonOpts(), svc, &testutil.ConfirmPrompter{})) + assert.Equal(t, "s1", svc.gotSessID) + assert.Equal(t, "m1", svc.gotMsgID) + assert.Contains(t, out.String(), `"deleted":true`) +} + +// TestRunDelete_NoYesInJSONModeIsConfirmationRequired verifies that without -y +// on a non-TTY (JSON mode), the CLI returns exit 10 / +// input.confirmation_required and does NOT call the service. +func TestRunDelete_NoYesInJSONModeIsConfirmationRequired(t *testing.T) { + _, _ = iostreams.SetForTest(t) + svc := &fakeDeleteSvc{} + opts := &DeleteOptions{SessionID: "s1", MessageID: "m1", Yes: false} + err := runDelete(context.Background(), opts, jsonOpts(), svc, &testutil.ConfirmPrompter{}) + require.Error(t, err) + var typed *cmdutil.Error + require.ErrorAs(t, err, &typed) + assert.Equal(t, cmdutil.CodeInputConfirmationRequired, typed.Code) + assert.Equal(t, 10, cmdutil.ExitCode(err), "exit 10 per destructive-write protocol") + assert.Empty(t, svc.gotMsgID, "SDK must not be called without confirmation") +} + +// TestRunDelete_TTY_ConfirmYes_Deletes verifies the interactive TTY path where +// the user answers yes: service is called, text output contains success line. +func TestRunDelete_TTY_ConfirmYes_Deletes(t *testing.T) { + out, _ := iostreams.SetForTestWithTTY(t) + svc := &fakeDeleteSvc{} + p := &testutil.ConfirmPrompter{Answer: true} + opts := &DeleteOptions{SessionID: "s1", MessageID: "m1"} + fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatText} + require.NoError(t, runDelete(context.Background(), opts, fopts, svc, p)) + assert.True(t, p.Asked) + assert.Equal(t, "m1", svc.gotMsgID) + assert.Equal(t, "s1", svc.gotSessID) + assert.Contains(t, out.String(), "Deleted message m1") +} + +// TestRunDelete_TTY_ConfirmNo_Aborts verifies the interactive TTY path where +// the user declines: service is NOT called, error has CodeUserAborted. +func TestRunDelete_TTY_ConfirmNo_Aborts(t *testing.T) { + _, errBuf := iostreams.SetForTestWithTTY(t) + svc := &fakeDeleteSvc{} + p := &testutil.ConfirmPrompter{Answer: false} + opts := &DeleteOptions{SessionID: "s1", MessageID: "m1"} + fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatText} + err := runDelete(context.Background(), opts, fopts, svc, p) + require.Error(t, err) + var typed *cmdutil.Error + require.ErrorAs(t, err, &typed) + assert.Equal(t, cmdutil.CodeUserAborted, typed.Code) + assert.Empty(t, svc.gotMsgID, "answer=no must not call DeleteMessage") + assert.Contains(t, errBuf.String(), "Aborted") +} + +// TestRunDelete_ServiceError_Propagates verifies that an SDK-level error is +// wrapped and returned (service called but returns HTTP error). +func TestRunDelete_ServiceError_Propagates(t *testing.T) { + _, _ = iostreams.SetForTest(t) + svc := &fakeDeleteSvc{err: errors.New("HTTP error 404: not found")} + opts := &DeleteOptions{SessionID: "s1", MessageID: "missing", Yes: true} + err := runDelete(context.Background(), opts, jsonOpts(), svc, &testutil.ConfirmPrompter{}) + require.Error(t, err) + var typed *cmdutil.Error + require.ErrorAs(t, err, &typed) + assert.Equal(t, cmdutil.CodeResourceNotFound, typed.Code) +} diff --git a/cli/cmd/message/list.go b/cli/cmd/message/list.go new file mode 100644 index 00000000..d19dc365 --- /dev/null +++ b/cli/cmd/message/list.go @@ -0,0 +1,130 @@ +package messagecmd + +import ( + "context" + "fmt" + "text/tabwriter" + "time" + + "github.com/spf13/cobra" + + "github.com/Tencent/WeKnora/cli/internal/cmdutil" + "github.com/Tencent/WeKnora/cli/internal/iostreams" + "github.com/Tencent/WeKnora/cli/internal/output" + "github.com/Tencent/WeKnora/cli/internal/text" + sdk "github.com/Tencent/WeKnora/client" +) + +// messageListFields enumerates the projectable scalar fields of sdk.Message +// (nested knowledge_references / agent_steps are intentionally excluded). +var messageListFields = []string{ + "id", "session_id", "request_id", "role", "content", + "is_completed", "channel", "created_at", "updated_at", +} + +type ListOptions struct { + SessionID string + Limit int + // Before filters to messages created before this RFC3339 timestamp. + // The server pages by time cursor (limit + before_time), not by page + // number — to walk further back, re-run with --before set to the + // oldest created_at from the previous batch. + Before string +} + +// ListService is the narrow SDK surface this command depends on. +type ListService interface { + LoadMessages(ctx context.Context, sessionID string, limit int, beforeTime *time.Time) ([]sdk.Message, error) +} + +// NewCmdList builds `weknora message list --session <id>`. +func NewCmdList(f *cmdutil.Factory) *cobra.Command { + opts := &ListOptions{} + cmd := &cobra.Command{ + Use: "list --session <session-id>", + Short: "List messages in a chat session (newest first, time-cursor paged)", + Args: cobra.NoArgs, + RunE: func(c *cobra.Command, _ []string) error { + fopts, err := cmdutil.CheckFormatFlag(c) + if err != nil { + return err + } + fopts.ResolveDefault(iostreams.IO.IsStdoutTTY()) + // Validate static input before building the client so a bad --limit + // returns input.invalid_argument (exit 5), not an auth error (exit 3). + if err := validateListOpts(opts); err != nil { + return err + } + cli, err := f.Client() + if err != nil { + return err + } + return runList(c.Context(), opts, fopts, cli) + }, + } + cmd.Flags().StringVar(&opts.SessionID, "session", "", "Session id to load messages from") + _ = cmd.MarkFlagRequired("session") + cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum messages to return — server-side page size (1..1000)") + cmd.Flags().StringVar(&opts.Before, "before", "", "Only messages created before this RFC3339 timestamp (time-cursor pagination)") + cmdutil.AddFormatFlag(cmd, messageListFields...) + cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{ + UsedFor: "List messages in a session. Use the assistant message id from the latest turn to chain follow-ups (session ask), or --before <oldest created_at> to page further back.", + RequiredFlags: []string{"--session <session-id>"}, + Examples: []string{ + "weknora message list --session sess_abc", + "weknora message list --session sess_abc --limit 100 --before 2026-06-01T00:00:00Z", + }, + Output: "envelope.data is an array of Message objects (id, session_id, role, content, is_completed, created_at); meta.count is the number returned. To page further back, re-run with --before <oldest created_at>; a batch shorter than --limit means the start of history was reached", + }) + return cmd +} + +// validateListOpts checks --limit. Called from RunE before the client is built +// (so a bad value surfaces as exit 5, not an auth error) and at runList's top +// for direct callers; idempotent. +func validateListOpts(opts *ListOptions) error { + if opts.Limit < 1 || opts.Limit > 1000 { + return &cmdutil.Error{ + Code: cmdutil.CodeInputInvalidArgument, + Message: fmt.Sprintf("--limit must be in 1..1000, got %d", opts.Limit), + } + } + return nil +} + +func runList(ctx context.Context, opts *ListOptions, fopts *cmdutil.FormatOptions, svc ListService) error { + if err := validateListOpts(opts); err != nil { + return err + } + before, err := cmdutil.ParseTimeFlag("--before", opts.Before) + if err != nil { + return err + } + items, err := svc.LoadMessages(ctx, opts.SessionID, opts.Limit, before) + if err != nil { + return cmdutil.WrapHTTP(err, "list messages for session %s", opts.SessionID) + } + if items == nil { + items = []sdk.Message{} // JSON [] not null + } + if fopts.WantsJSON() { + // No has_more here: the server exposes neither a total nor a cursor + // for message loads, and fabricating one from len==limit would give + // the same envelope key a second, heuristic meaning (elsewhere + // has_more strictly means "client-side truncation happened"). + // Consumers walk back with --before until a batch comes up short. + meta := &output.Meta{Count: output.IntPtr(len(items))} + return fopts.Emit(iostreams.IO.Out, items, meta) + } + if len(items) == 0 { + fmt.Fprintln(iostreams.IO.Out, "(no messages)") + return nil + } + now := time.Now() + tw := tabwriter.NewWriter(iostreams.IO.Out, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "ID\tROLE\tCONTENT\tCREATED") + for _, m := range items { + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", m.ID, m.Role, text.OneLine(60, m.Content), text.FuzzyAgo(now, m.CreatedAt)) + } + return tw.Flush() +} diff --git a/cli/cmd/message/list_test.go b/cli/cmd/message/list_test.go new file mode 100644 index 00000000..2807026b --- /dev/null +++ b/cli/cmd/message/list_test.go @@ -0,0 +1,119 @@ +package messagecmd + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/Tencent/WeKnora/cli/internal/cmdutil" + "github.com/Tencent/WeKnora/cli/internal/iostreams" + sdk "github.com/Tencent/WeKnora/client" +) + +// jsonOpts is the package-local FormatOptions shorthand (same pattern as +// chunk delete_test.go's inline construction). +func jsonOpts() *cmdutil.FormatOptions { return &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON} } + +type fakeListSvc struct { + items []sdk.Message + err error + gotSessID string + gotLimit int + gotBefore *time.Time +} + +func (s *fakeListSvc) LoadMessages(_ context.Context, sessionID string, limit int, before *time.Time) ([]sdk.Message, error) { + s.gotSessID, s.gotLimit, s.gotBefore = sessionID, limit, before + return s.items, s.err +} + +func TestRunList_PassesArgsAndEmitsJSON(t *testing.T) { + out, _ := iostreams.SetForTest(t) + svc := &fakeListSvc{items: []sdk.Message{{ID: "m1", SessionID: "s1", Role: "assistant", Content: "hi"}}} + opts := &ListOptions{SessionID: "s1", Limit: 20} + require.NoError(t, runList(context.Background(), opts, jsonOpts(), svc)) + assert.Equal(t, "s1", svc.gotSessID) + assert.Equal(t, 20, svc.gotLimit) + assert.Nil(t, svc.gotBefore) + assert.Contains(t, out.String(), `"id":"m1"`) + assert.Contains(t, out.String(), `"count":1`) + // message list never emits has_more: the server provides no total or + // cursor, so the CLI does not fabricate one (omitempty keeps it absent) + assert.NotContains(t, out.String(), `"has_more"`) +} + +func TestRunList_BeforeParsedAsRFC3339(t *testing.T) { + _, _ = iostreams.SetForTest(t) + svc := &fakeListSvc{} + opts := &ListOptions{SessionID: "s1", Limit: 20, Before: "2026-06-01T00:00:00Z"} + require.NoError(t, runList(context.Background(), opts, jsonOpts(), svc)) + require.NotNil(t, svc.gotBefore) + assert.Equal(t, 2026, svc.gotBefore.Year()) +} + +func TestRunList_BadBeforeIsInvalidArgument(t *testing.T) { + _, _ = iostreams.SetForTest(t) + err := runList(context.Background(), &ListOptions{SessionID: "s1", Limit: 20, Before: "yesterday"}, jsonOpts(), &fakeListSvc{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "RFC3339") + var cliErr *cmdutil.Error + require.True(t, errors.As(err, &cliErr)) + assert.Equal(t, cmdutil.CodeInputInvalidArgument, cliErr.Code) +} + +func TestRunList_EmptyIsJSONArrayNotNull(t *testing.T) { + out, _ := iostreams.SetForTest(t) + require.NoError(t, runList(context.Background(), &ListOptions{SessionID: "s1", Limit: 20}, jsonOpts(), &fakeListSvc{})) + assert.Contains(t, out.String(), `"data":[]`) +} + +// TestRunList_TextMode_NewlineInContent asserts that a message whose Content +// contains embedded newlines is collapsed to a single tabwriter row (OneLine). +func TestRunList_TextMode_NewlineInContent(t *testing.T) { + out, _ := iostreams.SetForTest(t) + ts := time.Now().Add(-5 * time.Minute) + svc := &fakeListSvc{items: []sdk.Message{ + {ID: "m1", Role: "user", Content: "first line\nsecond line\nthird line", CreatedAt: ts}, + }} + opts := &ListOptions{SessionID: "s1", Limit: 20} + fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatText} + require.NoError(t, runList(context.Background(), opts, fopts, svc)) + got := out.String() + // Header + 1 data row = exactly 2 lines. TrimRight removes the trailing + // newline that tabwriter emits, so Split should produce exactly 2 tokens. + lines := strings.Split(strings.TrimRight(got, "\n"), "\n") + assert.Len(t, lines, 2, "newlines in content must be collapsed to a single row: got %q", got) + // The data row must contain "m1" (id) and not a bare literal \n inside it. + assert.Contains(t, lines[1], "m1") +} + +// TestRunList_ServiceError_ReturnsError asserts that a service-level error +// propagates out of runList. +func TestRunList_ServiceError_ReturnsError(t *testing.T) { + _, _ = iostreams.SetForTest(t) + svc := &fakeListSvc{err: errors.New("HTTP error 503: service unavailable")} + err := runList(context.Background(), &ListOptions{SessionID: "s1", Limit: 20}, jsonOpts(), svc) + require.Error(t, err) +} + +// TestRunList_NoHasMoreEvenWhenLimitFilled pins the contract that message +// list never fabricates has_more — even a limit-filled batch (the case a +// heuristic would flag) must not emit the key, because the server provides +// no total/cursor and elsewhere has_more means client-side truncation. +func TestRunList_NoHasMoreEvenWhenLimitFilled(t *testing.T) { + out, _ := iostreams.SetForTest(t) + items := []sdk.Message{ + {ID: "m1", Role: "user", Content: "a"}, + {ID: "m2", Role: "assistant", Content: "b"}, + {ID: "m3", Role: "user", Content: "c"}, + } + opts := &ListOptions{SessionID: "s1", Limit: 3} + svc := &fakeListSvc{items: items} + require.NoError(t, runList(context.Background(), opts, jsonOpts(), svc)) + assert.NotContains(t, out.String(), `"has_more"`) +} diff --git a/cli/cmd/message/message.go b/cli/cmd/message/message.go new file mode 100644 index 00000000..9865a6c5 --- /dev/null +++ b/cli/cmd/message/message.go @@ -0,0 +1,21 @@ +// Package messagecmd implements `weknora message` — inspect and manage the +// messages inside chat sessions (the multi-turn substrate behind session ask). +package messagecmd + +import ( + "github.com/spf13/cobra" + + "github.com/Tencent/WeKnora/cli/internal/cmdutil" +) + +// NewCmd builds the `weknora message` command group. +func NewCmd(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "message", + Short: "Inspect and manage messages inside chat sessions", + } + cmd.AddCommand(NewCmdList(f)) + cmd.AddCommand(NewCmdSearch(f)) + cmd.AddCommand(NewCmdDelete(f)) + return cmd +} diff --git a/cli/cmd/message/search.go b/cli/cmd/message/search.go new file mode 100644 index 00000000..0df91abe --- /dev/null +++ b/cli/cmd/message/search.go @@ -0,0 +1,143 @@ +package messagecmd + +import ( + "context" + "fmt" + "text/tabwriter" + + "github.com/spf13/cobra" + + "github.com/Tencent/WeKnora/cli/internal/cmdutil" + "github.com/Tencent/WeKnora/cli/internal/iostreams" + "github.com/Tencent/WeKnora/cli/internal/output" + "github.com/Tencent/WeKnora/cli/internal/text" + sdk "github.com/Tencent/WeKnora/client" +) + +// messageSearchFields enumerates the projectable fields of MessageSearchGroupItem. +var messageSearchFields = []string{ + "request_id", "session_id", "session_title", "query_content", + "answer_content", "score", "match_type", "created_at", +} + +// messageSearchModes is the closed set the server accepts for --mode, sourced +// from the SDK enumerator so it can't drift from the server vocabulary. +var messageSearchModes = cmdutil.EnumStrings(sdk.AllMessageSearchModes()) + +// SearchOptions holds the parsed flag values for `message search`. +type SearchOptions struct { + Query string + Mode string // server-defined search mode, passed through verbatim + Limit int + SessionIDs []string +} + +// SearchService is the narrow SDK surface this command depends on. +type SearchService interface { + SearchMessages(ctx context.Context, req *sdk.SearchMessagesRequest) (*sdk.MessageSearchResult, error) +} + +// NewCmdSearch builds `weknora message search "<query>"`. +func NewCmdSearch(f *cmdutil.Factory) *cobra.Command { + opts := &SearchOptions{} + cmd := &cobra.Command{ + Use: `search "<query>"`, + Short: "Search chat history across sessions (question/answer pairs)", + 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()) + opts.Query = args[0] + // Validate static input (--limit / --mode) before building the + // client so a bad value returns input.invalid_argument (exit 5) + // instead of an auth error (exit 3) when the profile is + // unconfigured — an agent must see the real, fixable problem. + if err := validateSearchOpts(opts); err != nil { + return err + } + cli, err := f.Client() + if err != nil { + return err + } + return runSearch(c.Context(), opts, fopts, cli) + }, + } + cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 20, "Maximum results to return — server-side limit (1..1000; default 20 mirrors the server default)") + cmd.Flags().StringVar(&opts.Mode, "mode", "", "Search mode: keyword | vector | hybrid (omit for server default: hybrid)") + cmd.Flags().StringArrayVar(&opts.SessionIDs, "session", nil, "Restrict search to these session ids (repeatable)") + cmdutil.AddFormatFlag(cmd, messageSearchFields...) + cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{ + UsedFor: "Search past Q&A pairs across chat history. This searches conversation messages — for RAG retrieval over documents use `weknora search chunks` instead.", + RequiredFlags: []string{"<query> (positional)"}, + Examples: []string{ + `weknora message search "deploy steps"`, + `weknora message search "deploy steps" --session sess_abc --limit 5`, + }, + Output: "envelope.data is an array of grouped results (request_id, session_id, query_content, answer_content, score); meta.total_count is the server-side total. --mode accepts keyword | vector | hybrid (server default: hybrid)", + }) + return cmd +} + +// validateSearchOpts checks --limit / --mode and normalizes --mode to the +// canonical form. Called from RunE before the client is built (so bad input +// surfaces as exit 5, not an auth error) and again at the top of runSearch +// for direct callers; it is idempotent. +func validateSearchOpts(opts *SearchOptions) error { + // 1..1000 mirrors `search chunks` (the sibling search command); the + // server itself does not cap limit (handler passes it through). + if opts.Limit < 1 || opts.Limit > 1000 { + return &cmdutil.Error{ + Code: cmdutil.CodeInputInvalidArgument, + Message: fmt.Sprintf("--limit must be in 1..1000, got %d", opts.Limit), + } + } + // Validate --mode against the closed server enum and normalize to the + // canonical (lowercase) form the server matches. An unrecognised mode + // matches no channel server-side and returns an empty set with no error — + // which an agent cannot tell apart from a genuine no-match — so reject it. + mode, err := cmdutil.ValidateEnum("mode", opts.Mode, messageSearchModes) + if err != nil { + return err + } + opts.Mode = mode + return nil +} + +func runSearch(ctx context.Context, opts *SearchOptions, fopts *cmdutil.FormatOptions, svc SearchService) error { + if err := validateSearchOpts(opts); err != nil { + return err + } + res, err := svc.SearchMessages(ctx, &sdk.SearchMessagesRequest{ + Query: opts.Query, + Mode: opts.Mode, + Limit: opts.Limit, + SessionIDs: opts.SessionIDs, + }) + if err != nil { + return cmdutil.WrapHTTP(err, "search messages") + } + if res == nil { + res = &sdk.MessageSearchResult{} + } + items := res.Items + if items == nil { + items = []*sdk.MessageSearchGroupItem{} + } + if fopts.WantsJSON() { + meta := &output.Meta{Count: output.IntPtr(len(items)), TotalCount: output.IntPtr(res.Total)} + return fopts.Emit(iostreams.IO.Out, items, meta) + } + if len(items) == 0 { + fmt.Fprintln(iostreams.IO.Out, "(no matches)") + return nil + } + tw := tabwriter.NewWriter(iostreams.IO.Out, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "SESSION ID\tQUERY\tANSWER\tSCORE") + for _, it := range items { + fmt.Fprintf(tw, "%s\t%s\t%s\t%.2f\n", it.SessionID, text.OneLine(40, it.QueryContent), text.OneLine(40, it.AnswerContent), it.Score) + } + return tw.Flush() +} diff --git a/cli/cmd/message/search_test.go b/cli/cmd/message/search_test.go new file mode 100644 index 00000000..6f70b510 --- /dev/null +++ b/cli/cmd/message/search_test.go @@ -0,0 +1,153 @@ +package messagecmd + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/Tencent/WeKnora/cli/internal/cmdutil" + "github.com/Tencent/WeKnora/cli/internal/iostreams" + sdk "github.com/Tencent/WeKnora/client" +) + +type fakeSearchSvc struct { + result *sdk.MessageSearchResult + err error + gotReq *sdk.SearchMessagesRequest +} + +func (s *fakeSearchSvc) SearchMessages(_ context.Context, req *sdk.SearchMessagesRequest) (*sdk.MessageSearchResult, error) { + s.gotReq = req + if s.err != nil { + return nil, s.err + } + return s.result, nil +} + +// --- baseline tests (from spec) --- + +func TestRunSearch_EmitsItemsAndTotal(t *testing.T) { + out, _ := iostreams.SetForTest(t) + svc := &fakeSearchSvc{result: &sdk.MessageSearchResult{ + Items: []*sdk.MessageSearchGroupItem{{RequestID: "r1", SessionID: "s1", QueryContent: "q", AnswerContent: "a"}}, + Total: 7, + }} + opts := &SearchOptions{Query: "deploy steps", Limit: 20} + require.NoError(t, runSearch(context.Background(), opts, jsonOpts(), svc)) + assert.Equal(t, "deploy steps", svc.gotReq.Query) + assert.Contains(t, out.String(), `"request_id":"r1"`) + assert.Contains(t, out.String(), `"total_count":7`) +} + +func TestRunSearch_SessionScopePassedThrough(t *testing.T) { + _, _ = iostreams.SetForTest(t) + svc := &fakeSearchSvc{result: &sdk.MessageSearchResult{}} + opts := &SearchOptions{Query: "q", Limit: 20, SessionIDs: []string{"s1", "s2"}} + require.NoError(t, runSearch(context.Background(), opts, jsonOpts(), svc)) + assert.Equal(t, []string{"s1", "s2"}, svc.gotReq.SessionIDs) +} + +// --- extended quality tests --- + +// TestRunSearch_LimitOutOfRange_InvalidArgument asserts that a limit outside +// 1..1000 is rejected with a typed *cmdutil.Error and CodeInputInvalidArgument. +func TestRunSearch_LimitOutOfRange_InvalidArgument(t *testing.T) { + _, _ = iostreams.SetForTest(t) + cases := []struct { + name string + limit int + }{ + {"zero", 0}, + {"negative", -5}, + {"above max", 1001}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := runSearch(context.Background(), &SearchOptions{Query: "q", Limit: tc.limit}, jsonOpts(), &fakeSearchSvc{}) + require.Error(t, err) + var cliErr *cmdutil.Error + require.True(t, errors.As(err, &cliErr), "expected *cmdutil.Error, got %T", err) + assert.Equal(t, cmdutil.CodeInputInvalidArgument, cliErr.Code) + }) + } +} + +// TestRunSearch_InvalidMode_InvalidArgument: a --mode outside the closed enum +// is rejected with input.invalid_argument rather than silently returning empty. +func TestRunSearch_InvalidMode_InvalidArgument(t *testing.T) { + _, _ = iostreams.SetForTest(t) + svc := &fakeSearchSvc{result: &sdk.MessageSearchResult{}} + err := runSearch(context.Background(), &SearchOptions{Query: "q", Limit: 20, Mode: "hybird"}, jsonOpts(), svc) + require.Error(t, err) + var cliErr *cmdutil.Error + require.True(t, errors.As(err, &cliErr), "expected *cmdutil.Error, got %T", err) + assert.Equal(t, cmdutil.CodeInputInvalidArgument, cliErr.Code) + assert.Nil(t, svc.gotReq, "must reject before calling the server") +} + +// TestRunSearch_ModeNormalized: a valid mode in any case is normalized to the +// lowercase form the server matches before being sent. +func TestRunSearch_ModeNormalized(t *testing.T) { + _, _ = iostreams.SetForTest(t) + svc := &fakeSearchSvc{result: &sdk.MessageSearchResult{}} + require.NoError(t, runSearch(context.Background(), &SearchOptions{Query: "q", Limit: 20, Mode: "Hybrid"}, jsonOpts(), svc)) + assert.Equal(t, "hybrid", svc.gotReq.Mode) +} + +// TestRunSearch_TextMode_NewlineInContent asserts that embedded newlines in +// query/answer content are collapsed to a single tabwriter row (OneLine). +func TestRunSearch_TextMode_NewlineInContent(t *testing.T) { + out, _ := iostreams.SetForTest(t) + svc := &fakeSearchSvc{result: &sdk.MessageSearchResult{ + Items: []*sdk.MessageSearchGroupItem{{ + SessionID: "sess1", + QueryContent: "line one\nline two\nline three", + AnswerContent: "answer line\nanother line", + Score: 0.9, + }}, + Total: 1, + }} + opts := &SearchOptions{Query: "q", Limit: 20} + fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatText} + require.NoError(t, runSearch(context.Background(), opts, fopts, svc)) + got := out.String() + // Header + 1 data row = exactly 2 lines. + lines := strings.Split(strings.TrimRight(got, "\n"), "\n") + assert.Len(t, lines, 2, "newlines in content must be collapsed to a single row: got %q", got) + assert.Contains(t, lines[1], "sess1") + // Score must render with the %.2f format: 0.9 → "0.90" (pin the formatting). + assert.Contains(t, lines[1], "0.90", "score must render with %%.2f formatting: got %q", got) +} + +// TestRunSearch_ServiceError_ReturnsError asserts that a service-level error +// propagates out of runSearch. +func TestRunSearch_ServiceError_ReturnsError(t *testing.T) { + _, _ = iostreams.SetForTest(t) + svc := &fakeSearchSvc{err: errors.New("HTTP error 503: service unavailable")} + err := runSearch(context.Background(), &SearchOptions{Query: "q", Limit: 20}, jsonOpts(), svc) + require.Error(t, err) +} + +// TestRunSearch_EmptyResult_JSONArrayNotNull asserts that an empty result set +// is serialised as "data":[] and not "data":null. +func TestRunSearch_EmptyResult_JSONArrayNotNull(t *testing.T) { + out, _ := iostreams.SetForTest(t) + svc := &fakeSearchSvc{result: &sdk.MessageSearchResult{Items: nil, Total: 0}} + opts := &SearchOptions{Query: "q", Limit: 20} + require.NoError(t, runSearch(context.Background(), opts, jsonOpts(), svc)) + assert.Contains(t, out.String(), `"data":[]`) +} + +// TestRunSearch_ModePassedThrough asserts that a valid --mode value is +// forwarded to the SDK request. +func TestRunSearch_ModePassedThrough(t *testing.T) { + _, _ = iostreams.SetForTest(t) + svc := &fakeSearchSvc{result: &sdk.MessageSearchResult{}} + opts := &SearchOptions{Query: "q", Limit: 20, Mode: "vector"} + require.NoError(t, runSearch(context.Background(), opts, jsonOpts(), svc)) + assert.Equal(t, "vector", svc.gotReq.Mode) +} diff --git a/cli/cmd/model/create.go b/cli/cmd/model/create.go new file mode 100644 index 00000000..e0479522 --- /dev/null +++ b/cli/cmd/model/create.go @@ -0,0 +1,316 @@ +package modelcmd + +import ( + "context" + "encoding/json" + "fmt" + "io" + "strings" + + "github.com/spf13/cobra" + + "github.com/Tencent/WeKnora/cli/internal/cmdutil" + "github.com/Tencent/WeKnora/cli/internal/iostreams" + sdk "github.com/Tencent/WeKnora/client" +) + +// modelCreateFields enumerates the fields surfaced for `--format json` discovery +// on `model create`. The result is the created Model. +var modelCreateFields = []string{ + "id", "name", "display_name", "type", "source", + "description", "is_default", "parameters", "created_at", +} + +// createSourceValues is the restricted --source set for `model create`. The +// server's CreateModel only special-cases "remote" (registered active, routed +// to a provider API); every other source falls into the local Ollama download +// path. So create offers exactly the two working modes — the provider identity +// for a remote model goes in --provider, not --source. (`model list --source` +// still accepts the broad modelSourceValues for filtering pre-existing records.) +var createSourceValues = []string{string(sdk.ModelSourceLocal), string(sdk.ModelSourceRemote)} + +// canonicalModelType maps the server's frontend term "chat" to the KnowledgeQA +// enum — that is the server's own /models/providers vocabulary (see its +// frontendToModelType map). The other frontend terms (embedding / rerank / +// vllm / asr) already match the enum case-insensitively, so no alias is needed. +// Returns the input unchanged when it isn't an alias. +func canonicalModelType(t string) string { + if strings.EqualFold(strings.TrimSpace(t), "chat") { + return string(sdk.ModelTypeKnowledgeQA) + } + return t +} + +type CreateOptions struct { + Name string + DisplayName string + Description string + Type string + Source string + Provider string + BaseURL string + APIKeyStdin bool + Dimension int + Default bool + Params []string // repeatable key=value → top-level Parameters entries + DryRun bool + StdinReader io.Reader // overridden by tests +} + +// CreateService is the narrow SDK surface this command depends on. +// ListModelProviders supplies the authoritative provider catalog used to +// validate --provider and default --base-url for remote models. +type CreateService interface { + CreateModel(ctx context.Context, req *sdk.CreateModelRequest) (*sdk.Model, error) + ListModelProviders(ctx context.Context, modelType string) ([]sdk.ModelProvider, error) +} + +// frontendModelType maps the create enum to the server's /models/providers +// "model_type" query vocabulary (KnowledgeQA→chat, VLLM→vllm; others lowercase). +func frontendModelType(t string) string { + switch t { + case string(sdk.ModelTypeKnowledgeQA): + return "chat" + case string(sdk.ModelTypeVLLM): + return "vllm" + default: + return strings.ToLower(t) + } +} + +// NewCmdCreate builds `weknora model create <name>`. +func NewCmdCreate(f *cmdutil.Factory) *cobra.Command { + opts := &CreateOptions{} + cmd := &cobra.Command{ + Use: "create <name>", + Short: "Register a model (embedding / rerank / chat / VLLM / ASR)", + Long: `Register a model on the server so it can back a knowledge base's embedding / +summary config (see 'weknora kb init') or an agent (--model). + +<name> is the model name as the provider knows it (e.g. "nomic-embed-text", +"gpt-4o", "qwen2"). --type and --source are required. + +Two modes: + + Local (Ollama): --source local + The server pulls <name> from Ollama (async download). + --base-url points at the Ollama endpoint when not default. + + Remote (API): --source remote --provider <name> [--api-key-stdin] [--base-url <url>] + Registered active and routed to the provider's API. + --provider is required and is validated against the server's + live provider catalog (weknora api /api/v1/models/providers); + --base-url defaults to that provider's URL for the type when + omitted. + +--type accepts the server's term "chat" for KnowledgeQA (embedding/rerank/vllm/ +asr match the enum directly). Embedding models take --dimension. Pipe the +provider key via --api-key-stdin so it never lands in argv/history. Anything +else goes through repeatable --param key=value.`, + 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()) + opts.Name = args[0] + // Validate + normalize enums (case-insensitive) before the dry-run + // gate so --dry-run rejects identically to the live path. Mirrors + // `model list`, which accepts the same flags case-insensitively and + // fails an unknown value as input.invalid_argument (exit 5). + canonType, err := cmdutil.ValidateEnum("type", canonicalModelType(opts.Type), modelTypeValues) + if err != nil { + return err + } + opts.Type = canonType + canonSource, err := cmdutil.ValidateEnum("source", opts.Source, createSourceValues) + if err != nil { + return err + } + opts.Source = canonSource + // Mode-specific guardrails: a remote model needs a provider to route + // its API calls; a local (Ollama) model has no provider concept. + if opts.Source == "remote" && strings.TrimSpace(opts.Provider) == "" { + return cmdutil.NewError(cmdutil.CodeInputMissingFlag, + "--source remote requires --provider (e.g. openai, aliyun, deepseek)"). + WithHint("for a local Ollama model use --source local (no --provider)") + } + if opts.Source == "local" && strings.TrimSpace(opts.Provider) != "" { + return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, + "--provider applies to --source remote; a local model is pulled from Ollama by name"). + WithHint("drop --provider, or switch to --source remote") + } + params, err := parseParams(opts.Params) + if err != nil { + return err + } + if handled, err := cmdutil.HandleDryRun(c, opts.DryRun, cmdutil.DryRunPlan{ + Action: "model.create", + // Never echo the API key into the plan (it is read from stdin + // precisely so it never lands in argv / history / dry-run output). + Args: map[string]any{"name": opts.Name, "type": opts.Type, "source": opts.Source, "provider": opts.Provider}, + }); handled { + return err + } + if opts.StdinReader == nil { + opts.StdinReader = iostreams.IO.In + } + cli, err := f.Client() + if err != nil { + return err + } + return runCreate(c.Context(), opts, fopts, cli, params) + }, + } + cmd.Flags().StringVar(&opts.Type, "type", "", "Model type: "+strings.Join(modelTypeValues, " | ")+" (required; \"chat\" is accepted for KnowledgeQA)") + cmd.Flags().StringVar(&opts.Source, "source", "", "Where the model runs: "+strings.Join(createSourceValues, " | ")+" (required; local=Ollama, remote=provider API)") + cmd.Flags().StringVar(&opts.Provider, "provider", "", "Remote provider id, required+validated with --source remote (see `weknora api /api/v1/models/providers`)") + cmd.Flags().StringVar(&opts.DisplayName, "display-name", "", "Human-friendly name (optional)") + cmd.Flags().StringVar(&opts.Description, "description", "", "Description (optional)") + cmd.Flags().StringVar(&opts.BaseURL, "base-url", "", "Model API base URL (e.g. http://localhost:11434 for Ollama)") + cmd.Flags().BoolVar(&opts.APIKeyStdin, "api-key-stdin", false, "Read the provider API key from stdin (kept out of argv / history)") + cmd.Flags().IntVar(&opts.Dimension, "dimension", 0, "Embedding dimension (Embedding models only)") + cmd.Flags().BoolVar(&opts.Default, "default", false, "Mark this the default model for its type") + cmd.Flags().StringArrayVar(&opts.Params, "param", nil, "Extra provider parameter as key=value, repeatable (value parsed as JSON: true/42/text)") + _ = cmd.MarkFlagRequired("type") + _ = cmd.MarkFlagRequired("source") + cmdutil.AddFormatFlag(cmd, modelCreateFields...) + cmdutil.AddDryRunFlag(cmd, &opts.DryRun) + cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{ + UsedFor: "register a model (embedding/rerank/chat/VLLM/ASR) so a KB or agent can use it; capture .data.id to pass to `weknora kb init` / `agent create --model`.", + RequiredFlags: []string{"<name> (positional)", "--type", "--source (local|remote)", "--provider (when --source remote)"}, + Examples: []string{ + `weknora model create nomic-embed-text --type Embedding --source local --dimension 768 # Ollama (server pulls it)`, + `printf '%s' "$OPENAI_KEY" | weknora model create text-embedding-3-small --type Embedding --source remote --provider openai --dimension 1536 --api-key-stdin`, + }, + Output: "envelope.data is the created Model object with id, name, type, source, parameters", + Warnings: []string{ + "Two modes: --source local (Ollama pulls <name>, async) vs --source remote --provider <id> (provider API). A provider name is NOT a --source value.", + "Pass the API key via --api-key-stdin (piped), never as a flag — flag values leak into ps/history.", + "A local model starts in a 'downloading' state and is unusable until the pull finishes; the embedding/chat call fails until then.", + }, + }) + return cmd +} + +// parseParams turns repeated key=value flags into a map. Each value is parsed +// as JSON so true/false, numbers, and objects keep their type (the server's +// ModelParameters has typed fields like supports_vision bool); a value that +// isn't valid JSON is kept as a plain string. Returns a typed flag error on a +// malformed entry so the failure is exit 2, not a server 400. +func parseParams(kvs []string) (map[string]any, error) { + if len(kvs) == 0 { + return nil, nil + } + out := make(map[string]any, len(kvs)) + for _, kv := range kvs { + k, v, ok := strings.Cut(kv, "=") + if !ok || strings.TrimSpace(k) == "" { + return nil, cmdutil.NewFlagError(fmt.Errorf("invalid --param %q: expected key=value", kv)) + } + var parsed any + if json.Unmarshal([]byte(v), &parsed) == nil { + out[k] = parsed // true/false, numbers, JSON objects/arrays + } else { + out[k] = v // plain string (the common case) + } + } + return out, nil +} + +func runCreate(ctx context.Context, opts *CreateOptions, fopts *cmdutil.FormatOptions, svc CreateService, params map[string]any) error { + // Remote models: validate --provider against the server's live provider + // catalog for this model type, and default --base-url from it when omitted. + // Uses the authoritative /models/providers data (via the SDK) instead of a + // hardcoded list, so the CLI never drifts from the server. + if opts.Source == string(sdk.ModelSourceRemote) { + if err := resolveRemoteProvider(ctx, svc, opts); err != nil { + return err + } + } + parameters := sdk.ModelParameters{} + for k, v := range params { + parameters[k] = v + } + if opts.Provider != "" { + parameters["provider"] = opts.Provider + } + if opts.BaseURL != "" { + parameters["base_url"] = opts.BaseURL + } + if opts.Dimension > 0 { + parameters["embedding_parameters"] = map[string]any{"dimension": opts.Dimension} + } + if opts.APIKeyStdin { + key, err := readStdinTrimmed(opts.StdinReader) + if err != nil { + return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "read API key from stdin") + } + if key == "" { + return cmdutil.NewError(cmdutil.CodeInputMissingFlag, "--api-key-stdin requires the key piped to stdin") + } + parameters["api_key"] = key + } + + req := &sdk.CreateModelRequest{ + Name: opts.Name, + DisplayName: opts.DisplayName, + Type: sdk.ModelType(opts.Type), + Source: sdk.ModelSource(opts.Source), + Description: opts.Description, + Parameters: parameters, + IsDefault: opts.Default, + } + created, err := svc.CreateModel(ctx, req) + if err != nil { + return cmdutil.WrapHTTP(err, "create model") + } + if fopts.WantsJSON() { + return fopts.Emit(iostreams.IO.Out, created, nil) + } + fmt.Fprintf(iostreams.IO.Out, "✓ Created model %q (id: %s, type: %s)\n", created.Name, created.ID, created.Type) + return nil +} + +// resolveRemoteProvider validates opts.Provider against the server's provider +// catalog for the model's type and, when --base-url was omitted, defaults it +// from the provider's catalog entry. Canonicalizes the provider's casing. +func resolveRemoteProvider(ctx context.Context, svc CreateService, opts *CreateOptions) error { + ft := frontendModelType(opts.Type) + providers, err := svc.ListModelProviders(ctx, ft) + if err != nil { + return cmdutil.WrapHTTP(err, "list model providers") + } + for i := range providers { + if strings.EqualFold(providers[i].Value, opts.Provider) { + opts.Provider = providers[i].Value // canonicalize casing + if opts.BaseURL == "" { + opts.BaseURL = providers[i].DefaultURLs[ft] + } + return nil + } + } + vals := make([]string, len(providers)) + for i, p := range providers { + vals[i] = p.Value + } + return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, + fmt.Sprintf("unknown --provider %q for %s models", opts.Provider, opts.Type)). + WithHint("supported providers: " + strings.Join(vals, ", ")) +} + +// readStdinTrimmed reads all of r and returns it whitespace-trimmed. +func readStdinTrimmed(r io.Reader) (string, error) { + if r == nil { + return "", nil + } + b, err := io.ReadAll(r) + if err != nil { + return "", err + } + return strings.TrimSpace(string(b)), nil +} + +// compile-time check: the production SDK client implements CreateService. +var _ CreateService = (*sdk.Client)(nil) diff --git a/cli/cmd/model/create_test.go b/cli/cmd/model/create_test.go new file mode 100644 index 00000000..1d8191cc --- /dev/null +++ b/cli/cmd/model/create_test.go @@ -0,0 +1,237 @@ +package modelcmd + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/Tencent/WeKnora/cli/internal/cmdutil" + "github.com/Tencent/WeKnora/cli/internal/iostreams" + sdk "github.com/Tencent/WeKnora/client" +) + +type fakeCreateSvc struct { + got *sdk.CreateModelRequest + resp *sdk.Model + err error + providers []sdk.ModelProvider // returned by ListModelProviders; nil → default catalog +} + +func (f *fakeCreateSvc) CreateModel(_ context.Context, req *sdk.CreateModelRequest) (*sdk.Model, error) { + f.got = req + if f.resp == nil { + f.resp = &sdk.Model{ID: "model_new", Name: req.Name, Type: req.Type, Source: req.Source} + } + return f.resp, f.err +} + +func (f *fakeCreateSvc) ListModelProviders(_ context.Context, _ string) ([]sdk.ModelProvider, error) { + if f.providers != nil { + return f.providers, nil + } + return []sdk.ModelProvider{ + {Value: "openai", ModelTypes: []string{"chat", "embedding"}, + DefaultURLs: map[string]string{"chat": "https://api.openai.com/v1", "embedding": "https://api.openai.com/v1"}}, + {Value: "aliyun", ModelTypes: []string{"chat", "embedding", "rerank"}, + DefaultURLs: map[string]string{"embedding": "https://dashscope.example/v1"}}, + }, nil +} + +func TestModelCreate_BuildsRequest(t *testing.T) { + out, _ := iostreams.SetForTest(t) + svc := &fakeCreateSvc{} + opts := &CreateOptions{ + Name: "nomic-embed-text", Type: "Embedding", Source: "local", + BaseURL: "http://localhost:11434", Dimension: 768, Default: true, + } + require.NoError(t, runCreate(context.Background(), opts, + &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, map[string]any{"provider": "ollama"})) + + require.NotNil(t, svc.got) + assert.Equal(t, "nomic-embed-text", svc.got.Name) + assert.Equal(t, sdk.ModelTypeEmbedding, svc.got.Type) + assert.Equal(t, sdk.ModelSource("local"), svc.got.Source) + assert.True(t, svc.got.IsDefault) + assert.Equal(t, "http://localhost:11434", svc.got.Parameters["base_url"]) + assert.Equal(t, "ollama", svc.got.Parameters["provider"]) + emb, ok := svc.got.Parameters["embedding_parameters"].(map[string]any) + require.True(t, ok, "embedding_parameters should be a nested map") + assert.Equal(t, 768, emb["dimension"]) + + var env struct { + OK bool `json:"ok"` + Data sdk.Model `json:"data"` + } + require.NoError(t, json.Unmarshal(out.Bytes(), &env)) + assert.True(t, env.OK) + assert.Equal(t, "model_new", env.Data.ID) +} + +func TestModelCreate_APIKeyFromStdin(t *testing.T) { + _, _ = iostreams.SetForTest(t) + svc := &fakeCreateSvc{} + opts := &CreateOptions{ + Name: "text-embedding-3-small", Type: "Embedding", Source: "remote", Provider: "openai", + APIKeyStdin: true, StdinReader: strings.NewReader(" sk-secret\n"), + } + require.NoError(t, runCreate(context.Background(), opts, + &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, nil)) + assert.Equal(t, "sk-secret", svc.got.Parameters["api_key"], "key must be trimmed and set from stdin") + assert.Equal(t, "openai", svc.got.Parameters["provider"], "--provider maps into parameters.provider") +} + +// TestModelCreate_RemoteProviderValidatedAndBaseURLDefaulted: for a remote +// model the provider is validated against the server's catalog and --base-url +// is defaulted from it (not hardcoded in the CLI). +func TestModelCreate_RemoteProviderValidatedAndBaseURLDefaulted(t *testing.T) { + _, _ = iostreams.SetForTest(t) + // Known provider, no --base-url → defaulted from the catalog entry. + svc := &fakeCreateSvc{} + require.NoError(t, runCreate(context.Background(), + &CreateOptions{Name: "text-embedding-3-small", Type: "Embedding", Source: "remote", Provider: "openai"}, + &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, nil)) + assert.Equal(t, "openai", svc.got.Parameters["provider"]) + assert.Equal(t, "https://api.openai.com/v1", svc.got.Parameters["base_url"], "base_url defaulted from provider catalog") + + // Explicit --base-url is respected (not overridden by the catalog default). + svc2 := &fakeCreateSvc{} + require.NoError(t, runCreate(context.Background(), + &CreateOptions{Name: "m", Type: "Embedding", Source: "remote", Provider: "openai", BaseURL: "https://proxy.local/v1"}, + &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc2, nil)) + assert.Equal(t, "https://proxy.local/v1", svc2.got.Parameters["base_url"]) + + // Unknown provider → input.invalid_argument, no model created. + svc3 := &fakeCreateSvc{} + err := runCreate(context.Background(), + &CreateOptions{Name: "x", Type: "Embedding", Source: "remote", Provider: "bogus-provider"}, + &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc3, nil) + var ce *cmdutil.Error + require.ErrorAs(t, err, &ce) + assert.Equal(t, cmdutil.CodeInputInvalidArgument, ce.Code) + assert.Nil(t, svc3.got, "must not create a model when --provider is unknown") +} + +func TestModelCreate_APIKeyStdinEmpty(t *testing.T) { + _, _ = iostreams.SetForTest(t) + svc := &fakeCreateSvc{} + opts := &CreateOptions{Name: "m", Type: "Embedding", Source: "openai", APIKeyStdin: true, StdinReader: strings.NewReader("")} + err := runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, nil) + var ce *cmdutil.Error + require.ErrorAs(t, err, &ce) + assert.Equal(t, cmdutil.CodeInputMissingFlag, ce.Code) + assert.Nil(t, svc.got, "must not call CreateModel when stdin key is empty") +} + +func TestParseParams(t *testing.T) { + // Bare words aren't valid JSON → kept as strings (the common case). + m, err := parseParams([]string{"provider=openai", "interface_type=chat"}) + require.NoError(t, err) + assert.Equal(t, "openai", m["provider"]) + assert.Equal(t, "chat", m["interface_type"]) + + // JSON-typed values keep their type so the server's typed ModelParameters + // fields (e.g. supports_vision bool) bind correctly. + typed, err := parseParams([]string{"supports_vision=true", "n=42", "name=plain"}) + require.NoError(t, err) + assert.Equal(t, true, typed["supports_vision"]) + assert.Equal(t, float64(42), typed["n"]) + assert.Equal(t, "plain", typed["name"]) + + _, err = parseParams([]string{"noequals"}) + require.Error(t, err) + var fe *cmdutil.FlagError + assert.ErrorAs(t, err, &fe) +} + +// withRootHarnessModel wraps a model subcommand under a synthetic root with the +// global persistent flags so flag/enum validation and gating route correctly. +func withRootHarnessModel(sub *cobra.Command, args ...string) *cobra.Command { + root := &cobra.Command{Use: "weknora"} + pf := root.PersistentFlags() + pf.BoolP("yes", "y", false, "") + pf.String("format", "", "") + pf.StringP("jq", "q", "", "") + model := &cobra.Command{Use: "model"} + model.AddCommand(sub) + root.AddCommand(model) + root.SetArgs(append([]string{"model", sub.Name()}, args...)) + root.SetContext(context.Background()) + root.SilenceErrors = true + root.SilenceUsage = true + return root +} + +func TestModelCreate_InvalidTypeRejected(t *testing.T) { + iostreams.SetForTest(t) + root := withRootHarnessModel(NewCmdCreate(modelDryRunFactory(t)), + "my-model", "--type", "Bogus", "--source", "local", "--format", "json") + err := root.Execute() + require.Error(t, err) + // An unknown enum VALUE is input.invalid_argument (exit 5) — same as + // `model list`, not a cobra parse error (exit 2). + var ce *cmdutil.Error + require.ErrorAs(t, err, &ce) + assert.Equal(t, cmdutil.CodeInputInvalidArgument, ce.Code) + assert.Equal(t, 5, cmdutil.ExitCode(err)) +} + +// TestModelCreate_NormalizesTypeAliasAndSourceCase: --type accepts friendly +// aliases (chat → KnowledgeQA) and --source is case-insensitive, both +// normalized to canonical form in the plan. +func TestModelCreate_NormalizesTypeAliasAndSourceCase(t *testing.T) { + out, _ := iostreams.SetForTest(t) + root := withRootHarnessModel(NewCmdCreate(modelDryRunFactory(t)), + "m", "--type", "chat", "--source", "REMOTE", "--provider", "openai", "--dry-run", "--format", "json") + require.NoError(t, root.Execute()) + var env struct { + Meta struct { + Plan map[string]any `json:"plan"` + } `json:"meta"` + } + require.NoError(t, json.Unmarshal(out.Bytes(), &env)) + args, ok := env.Meta.Plan["args"].(map[string]any) + require.True(t, ok, "plan.args should be present; got %v", env.Meta.Plan) + assert.Equal(t, "KnowledgeQA", args["type"], "chat alias normalized to KnowledgeQA") + assert.Equal(t, "remote", args["source"], "source normalized to lowercase canonical") +} + +// TestModelCreate_RemoteRequiresProvider: --source remote without --provider is +// rejected (the server can't route a remote model without a provider). +func TestModelCreate_RemoteRequiresProvider(t *testing.T) { + iostreams.SetForTest(t) + root := withRootHarnessModel(NewCmdCreate(modelDryRunFactory(t)), + "gpt-4o", "--type", "KnowledgeQA", "--source", "remote", "--format", "json") + err := root.Execute() + var ce *cmdutil.Error + require.ErrorAs(t, err, &ce) + assert.Equal(t, cmdutil.CodeInputMissingFlag, ce.Code) +} + +// TestModelCreate_LocalRejectsProvider: --provider with --source local is a +// contradiction (a local Ollama model has no provider). +func TestModelCreate_LocalRejectsProvider(t *testing.T) { + iostreams.SetForTest(t) + root := withRootHarnessModel(NewCmdCreate(modelDryRunFactory(t)), + "qwen2", "--type", "KnowledgeQA", "--source", "local", "--provider", "openai", "--format", "json") + err := root.Execute() + var ce *cmdutil.Error + require.ErrorAs(t, err, &ce) + assert.Equal(t, cmdutil.CodeInputInvalidArgument, ce.Code) +} + +// TestModelCreate_RejectsProviderNameAsSource: a provider name is not a valid +// --source (only local/remote) — guards the misleading-source regression. +func TestModelCreate_RejectsProviderNameAsSource(t *testing.T) { + iostreams.SetForTest(t) + root := withRootHarnessModel(NewCmdCreate(modelDryRunFactory(t)), + "x", "--type", "Embedding", "--source", "openai", "--format", "json") + err := root.Execute() + var ce *cmdutil.Error + require.ErrorAs(t, err, &ce) + assert.Equal(t, cmdutil.CodeInputInvalidArgument, ce.Code) +} diff --git a/cli/cmd/model/delete.go b/cli/cmd/model/delete.go new file mode 100644 index 00000000..6b172d34 --- /dev/null +++ b/cli/cmd/model/delete.go @@ -0,0 +1,87 @@ +package modelcmd + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/Tencent/WeKnora/cli/internal/cmdutil" + "github.com/Tencent/WeKnora/cli/internal/iostreams" + sdk "github.com/Tencent/WeKnora/client" +) + +// DeleteService is the narrow SDK surface this command depends on. +type DeleteService interface { + DeleteModel(ctx context.Context, id string) error +} + +type deleteOptions struct { + Yes bool + DryRun bool +} + +// NewCmdDelete builds `weknora model delete <model-id>`. +func NewCmdDelete(f *cmdutil.Factory) *cobra.Command { + opts := &deleteOptions{} + cmd := &cobra.Command{ + Use: "delete <model-id>", + Short: "Delete a model", + Long: `Delete a model by id. High-risk write: without -y/--yes in a non-TTY / JSON +context it exits 10 (input.confirmation_required) without deleting. A KB or +agent still referencing the model will fail until repointed.`, + 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()) + opts.Yes, _ = c.Flags().GetBool("yes") + id := args[0] + if handled, err := cmdutil.HandleDryRun(c, opts.DryRun, cmdutil.DryRunPlan{ + Action: "model.delete", + Args: map[string]any{"model": id}, + }); handled { + return err + } + cli, err := f.Client() + if err != nil { + return err + } + if err := cmdutil.ConfirmDestructive(f.Prompter(), opts.Yes, fopts.WantsJSON(), + "delete", "model", id, "model.delete", []string{"weknora", "model", "delete", id, "-y"}); err != nil { + return err + } + return runDelete(c.Context(), fopts, cli, id) + }, + } + cmdutil.AddFormatFlag(cmd, "id", "deleted") + cmdutil.AddDryRunFlag(cmd, &opts.DryRun) + cmdutil.SetRisk(cmd, "model.delete") + cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{ + UsedFor: "delete a model by id", + RequiredFlags: []string{"<model-id> (positional)"}, + Examples: []string{"weknora model delete model_abc -y"}, + Output: "envelope.data is {id, deleted:true}", + Warnings: []string{ + "Requires explicit user approval (exit 10 / input.confirmation_required); never auto-add -y.", + "A knowledge base or agent still referencing this model will fail until repointed.", + }, + }) + return cmd +} + +func runDelete(ctx context.Context, fopts *cmdutil.FormatOptions, svc DeleteService, id string) error { + if err := svc.DeleteModel(ctx, id); err != nil { + return cmdutil.WrapHTTP(err, "delete model %q", id) + } + if fopts.WantsJSON() { + return fopts.Emit(iostreams.IO.Out, map[string]any{"id": id, "deleted": true}, nil) + } + fmt.Fprintf(iostreams.IO.Out, "✓ Deleted model %s\n", id) + return nil +} + +// compile-time check: the production SDK client implements DeleteService. +var _ DeleteService = (*sdk.Client)(nil) diff --git a/cli/cmd/model/delete_test.go b/cli/cmd/model/delete_test.go new file mode 100644 index 00000000..cdbb41aa --- /dev/null +++ b/cli/cmd/model/delete_test.go @@ -0,0 +1,103 @@ +package modelcmd + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/Tencent/WeKnora/cli/internal/cmdutil" + "github.com/Tencent/WeKnora/cli/internal/iostreams" + "github.com/Tencent/WeKnora/cli/internal/prompt" + sdk "github.com/Tencent/WeKnora/client" +) + +// modelDryRunFactory builds a Factory whose Client / Prompter closures fail the +// test if invoked — used by paths (flag validation, dry-run) that must not +// reach the SDK. For the confirmation test, a permissive factory is built inline. +func modelDryRunFactory(t *testing.T) *cmdutil.Factory { + t.Helper() + return &cmdutil.Factory{ + Client: func() (*sdk.Client, error) { + t.Fatal("path must not call Factory.Client()") + return nil, nil + }, + Prompter: func() prompt.Prompter { + t.Fatal("path must not call Factory.Prompter()") + return nil + }, + } +} + +type fakeDeleteSvc struct { + gotID string + err error +} + +func (f *fakeDeleteSvc) DeleteModel(_ context.Context, id string) error { + f.gotID = id + return f.err +} + +func TestModelDelete_CallsSDKAndEmits(t *testing.T) { + out, _ := iostreams.SetForTest(t) + svc := &fakeDeleteSvc{} + require.NoError(t, runDelete(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "model_abc")) + assert.Equal(t, "model_abc", svc.gotID) + var env struct { + OK bool `json:"ok"` + Data struct { + ID string `json:"id"` + Deleted bool `json:"deleted"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(out.Bytes(), &env)) + assert.True(t, env.OK) + assert.Equal(t, "model_abc", env.Data.ID) + assert.True(t, env.Data.Deleted) +} + +func TestModelDelete_NotFound(t *testing.T) { + _, _ = iostreams.SetForTest(t) + svc := &fakeDeleteSvc{err: errors.New("HTTP error 404: not found")} + err := runDelete(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "missing") + require.Error(t, err) + assert.True(t, cmdutil.IsNotFound(err)) +} + +// TestModelDelete_RequiresConfirmation: without -y (non-TTY/JSON), delete exits +// 10 (input.confirmation_required) and never reaches the SDK. +func TestModelDelete_RequiresConfirmation(t *testing.T) { + iostreams.SetForTest(t) + f := &cmdutil.Factory{ + Client: func() (*sdk.Client, error) { return nil, nil }, + Prompter: func() prompt.Prompter { return prompt.AgentPrompter{} }, + } + root := withRootHarnessModel(NewCmdDelete(f), "model_abc", "--format", "json") + err := root.Execute() + require.Error(t, err) + var ce *cmdutil.Error + require.ErrorAs(t, err, &ce) + assert.Equal(t, cmdutil.CodeInputConfirmationRequired, ce.Code) + assert.Equal(t, 10, cmdutil.ExitCode(err)) + assert.Contains(t, ce.RetryArgv, "-y") +} + +// TestModelDelete_DryRun: --dry-run previews without reaching the SDK. +func TestModelDelete_DryRun(t *testing.T) { + out, _ := iostreams.SetForTest(t) + root := withRootHarnessModel(NewCmdDelete(modelDryRunFactory(t)), "model_abc", "--dry-run", "--format", "json") + require.NoError(t, root.Execute()) + var env struct { + Meta struct { + DryRun bool `json:"dry_run"` + Plan map[string]any `json:"plan"` + } `json:"meta"` + } + require.NoError(t, json.Unmarshal(out.Bytes(), &env)) + assert.True(t, env.Meta.DryRun) + assert.Equal(t, "model.delete", env.Meta.Plan["action"]) +} diff --git a/cli/cmd/model/list.go b/cli/cmd/model/list.go new file mode 100644 index 00000000..ecdcdfcf --- /dev/null +++ b/cli/cmd/model/list.go @@ -0,0 +1,148 @@ +package modelcmd + +import ( + "context" + "fmt" + "sort" + "strings" + "text/tabwriter" + + "github.com/spf13/cobra" + + "github.com/Tencent/WeKnora/cli/internal/cmdutil" + "github.com/Tencent/WeKnora/cli/internal/iostreams" + "github.com/Tencent/WeKnora/cli/internal/output" + "github.com/Tencent/WeKnora/cli/internal/text" + sdk "github.com/Tencent/WeKnora/client" +) + +// modelListFields enumerates the fields surfaced for `--format json` discovery +// on `model list`. The nested `parameters` object is omitted — use `--jq` for +// it (and `model view` renders the full record). +var modelListFields = []string{ + "id", "name", "display_name", "type", "source", + "description", "is_default", "created_at", "updated_at", +} + +// modelTypeValues / modelSourceValues are the closed server enums the +// corresponding filter flags accept, sourced from the SDK's enumerators so the +// CLI can't drift from the SDK/server. A typo is rejected up front rather than +// silently returning an empty set (which an agent cannot distinguish from a +// genuine no-match). +var modelTypeValues = cmdutil.EnumStrings(sdk.AllModelTypes()) + +var modelSourceValues = cmdutil.EnumStrings(sdk.AllModelSources()) + +// ListOptions captures `model list` filter flag state. +type ListOptions struct { + // Type / Source, when set, restrict output to models of that type + // (Embedding, Rerank, KnowledgeQA, VLLM, ASR) or provider (local, openai, + // …), matched case-insensitively. Empty shows everything. + Type string + Source string +} + +// ListService is the narrow SDK surface this command depends on. +type ListService interface { + ListModels(ctx context.Context) ([]sdk.Model, error) +} + +// NewCmdList builds `weknora model list`. +func NewCmdList(f *cmdutil.Factory) *cobra.Command { + opts := &ListOptions{} + cmd := &cobra.Command{ + Use: "list", + Short: "List models configured on the server", + Long: `List the models configured on the server, sorted by type then name. Pass +--type to restrict to one model type (Embedding, Rerank, KnowledgeQA, VLLM, ASR).`, + Args: cobra.NoArgs, + RunE: func(c *cobra.Command, _ []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 + } + return runList(c.Context(), opts, fopts, cli) + }, + } + cmd.Flags().StringVar(&opts.Type, "type", "", "Only show models of this type (Embedding, Rerank, KnowledgeQA, VLLM, ASR)") + cmd.Flags().StringVar(&opts.Source, "source", "", "Only show models from this provider (local, remote, openai, aliyun, …)") + cmdutil.AddFormatFlag(cmd, modelListFields...) + cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{ + UsedFor: "discover model ids for `agent create --model` and a KB's embedding/summary model", + Examples: []string{ + "weknora model list", + "weknora model list --type KnowledgeQA --format json", + "weknora model list --source local", + }, + Output: "envelope.data is an array of Model objects (id, name, display_name, type, source, is_default); narrow it with --type / --source", + }) + return cmd +} + +func runList(ctx context.Context, opts *ListOptions, fopts *cmdutil.FormatOptions, svc ListService) error { + if _, err := cmdutil.ValidateEnum("type", opts.Type, modelTypeValues); err != nil { + return err + } + if _, err := cmdutil.ValidateEnum("source", opts.Source, modelSourceValues); err != nil { + return err + } + items, err := svc.ListModels(ctx) + if err != nil { + return cmdutil.WrapHTTP(err, "list models") + } + if items == nil { + items = []sdk.Model{} // ensure JSON [] not null + } + hasFilter := opts.Type != "" || opts.Source != "" + if hasFilter { + filtered := items[:0] + for _, m := range items { + if opts.Type != "" && !strings.EqualFold(string(m.Type), opts.Type) { + continue + } + if opts.Source != "" && !strings.EqualFold(string(m.Source), opts.Source) { + continue + } + filtered = append(filtered, m) + } + items = filtered + } + // Deterministic order: by type, then label. Server return order is not + // guaranteed, so a client-side sort keeps output stable. + sort.Slice(items, func(i, j int) bool { + if items[i].Type != items[j].Type { + return items[i].Type < items[j].Type + } + return modelLabel(items[i]) < modelLabel(items[j]) + }) + + if fopts.WantsJSON() { + return fopts.Emit(iostreams.IO.Out, items, &output.Meta{Count: output.IntPtr(len(items))}) + } + + if len(items) == 0 { + if hasFilter { + fmt.Fprintln(iostreams.IO.Out, "(no models match the filter)") + return nil + } + fmt.Fprintln(iostreams.IO.Out, "(no models)") + return nil + } + + tw := tabwriter.NewWriter(iostreams.IO.Out, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "ID\tNAME\tTYPE\tSOURCE\tDEFAULT") + for _, m := range items { + def := "" + if m.IsDefault { + def = "default" + } + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", + m.ID, text.Truncate(40, modelLabel(m)), m.Type, m.Source, def) + } + return tw.Flush() +} diff --git a/cli/cmd/model/list_test.go b/cli/cmd/model/list_test.go new file mode 100644 index 00000000..1dd85726 --- /dev/null +++ b/cli/cmd/model/list_test.go @@ -0,0 +1,129 @@ +package modelcmd + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/Tencent/WeKnora/cli/internal/cmdutil" + "github.com/Tencent/WeKnora/cli/internal/iostreams" + sdk "github.com/Tencent/WeKnora/client" +) + +type fakeListSvc struct { + models []sdk.Model + err error +} + +func (f *fakeListSvc) ListModels(_ context.Context) ([]sdk.Model, error) { + return f.models, f.err +} + +func TestModelList_Text(t *testing.T) { + out, _ := iostreams.SetForTest(t) + svc := &fakeListSvc{models: []sdk.Model{ + {ID: "m1", DisplayName: "GPT-X", Type: sdk.ModelTypeKnowledgeQA, Source: sdk.ModelSourceOpenAI, IsDefault: true}, + {ID: "m2", Name: "bge", Type: sdk.ModelTypeEmbedding, Source: sdk.ModelSourceLocal}, + }} + if err := runList(context.Background(), &ListOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc); err != nil { + t.Fatalf("runList: %v", err) + } + got := out.String() + for _, want := range []string{"ID", "NAME", "TYPE", "SOURCE", "m1", "GPT-X", "KnowledgeQA", "m2", "bge", "Embedding", "default"} { + if !strings.Contains(got, want) { + t.Errorf("missing %q in:\n%s", want, got) + } + } +} + +// TestModelList_TypeFilter: --type narrows the set case-insensitively, and the +// JSON envelope's meta.count reflects the filtered total. +func TestModelList_TypeFilter(t *testing.T) { + out, _ := iostreams.SetForTest(t) + svc := &fakeListSvc{models: []sdk.Model{ + {ID: "m1", Type: sdk.ModelTypeKnowledgeQA}, + {ID: "m2", Type: sdk.ModelTypeEmbedding}, + }} + if err := runList(context.Background(), &ListOptions{Type: "embedding"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc); err != nil { + t.Fatalf("runList: %v", err) + } + var env struct { + Data []sdk.Model `json:"data"` + Meta struct { + Count int `json:"count"` + } `json:"meta"` + } + if err := json.Unmarshal(out.Bytes(), &env); err != nil { + t.Fatalf("parse: %v\n%s", err, out.String()) + } + if len(env.Data) != 1 || env.Data[0].ID != "m2" { + t.Errorf("expected only m2 (Embedding), got %+v", env.Data) + } + if env.Meta.Count != 1 { + t.Errorf("meta.count = %d, want 1", env.Meta.Count) + } +} + +func TestModelList_Empty(t *testing.T) { + out, _ := iostreams.SetForTest(t) + svc := &fakeListSvc{models: nil} + if err := runList(context.Background(), &ListOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc); err != nil { + t.Fatalf("runList: %v", err) + } + if !strings.Contains(out.String(), "(no models)") { + t.Errorf("expected empty marker, got %q", out.String()) + } +} + +func TestModelList_EmptyAfterFilter(t *testing.T) { + out, _ := iostreams.SetForTest(t) + svc := &fakeListSvc{models: []sdk.Model{{ID: "m1", Type: sdk.ModelTypeEmbedding}}} + if err := runList(context.Background(), &ListOptions{Type: "Rerank"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc); err != nil { + t.Fatalf("runList: %v", err) + } + if !strings.Contains(out.String(), "(no models match the filter)") { + t.Errorf("expected filter empty marker, got %q", out.String()) + } +} + +// TestModelList_SourceFilter: --source narrows by provider, case-insensitively. +func TestModelList_SourceFilter(t *testing.T) { + out, _ := iostreams.SetForTest(t) + svc := &fakeListSvc{models: []sdk.Model{ + {ID: "m1", Type: sdk.ModelTypeEmbedding, Source: sdk.ModelSourceLocal}, + {ID: "m2", Type: sdk.ModelTypeKnowledgeQA, Source: sdk.ModelSourceOpenAI}, + }} + if err := runList(context.Background(), &ListOptions{Source: "OpenAI"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc); err != nil { + t.Fatalf("runList: %v", err) + } + var env struct { + Data []sdk.Model `json:"data"` + } + if err := json.Unmarshal(out.Bytes(), &env); err != nil { + t.Fatalf("parse: %v\n%s", err, out.String()) + } + if len(env.Data) != 1 || env.Data[0].ID != "m2" { + t.Errorf("expected only m2 (openai), got %+v", env.Data) + } +} + +// TestModelList_InvalidEnum: a typo'd --type / --source is rejected up front +// (input.invalid_argument) instead of silently returning an empty set. +func TestModelList_InvalidEnum(t *testing.T) { + for _, tc := range []struct{ name string; opts ListOptions }{ + {"type", ListOptions{Type: "bogus"}}, + {"source", ListOptions{Source: "bogus"}}, + } { + t.Run(tc.name, func(t *testing.T) { + _, _ = iostreams.SetForTest(t) + svc := &fakeListSvc{models: []sdk.Model{{ID: "m1", Type: sdk.ModelTypeEmbedding, Source: sdk.ModelSourceLocal}}} + err := runList(context.Background(), &tc.opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc) + var typed *cmdutil.Error + if !errors.As(err, &typed) || typed.Code != cmdutil.CodeInputInvalidArgument { + t.Errorf("expected input.invalid_argument, got %v", err) + } + }) + } +} diff --git a/cli/cmd/model/model.go b/cli/cmd/model/model.go new file mode 100644 index 00000000..f1ce15f1 --- /dev/null +++ b/cli/cmd/model/model.go @@ -0,0 +1,42 @@ +// Package modelcmd holds the `weknora model` command tree: list / view. +// +// These are read-only discovery commands. The model id is a required input to +// `weknora agent create --model <id>` and to a knowledge base's embedding / +// summary model settings, so the CLI must offer a first-class way to find it +// rather than forcing callers down to the raw `weknora api` escape hatch. +// +// The directory is named `model/` to match the cobra subcommand; the Go +// package is `modelcmd` to avoid colliding with the SDK's Model type. +package modelcmd + +import ( + "github.com/spf13/cobra" + + "github.com/Tencent/WeKnora/cli/internal/cmdutil" + sdk "github.com/Tencent/WeKnora/client" +) + +// NewCmd builds the `weknora model` parent and registers leaves. Called from +// cli/cmd/root.go. +func NewCmd(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "model", + Short: "Manage models (list / view / create / delete)", + Long: `List, inspect, register, and delete the models configured on the server. Use +the model id to back a knowledge base's embedding / summary config ('weknora kb +init') or an agent ('weknora agent create --model <id>').`, + } + cmd.AddCommand(NewCmdList(f)) + cmd.AddCommand(NewCmdView(f)) + cmd.AddCommand(NewCmdCreate(f)) + cmd.AddCommand(NewCmdDelete(f)) + return cmd +} + +// modelLabel prefers display_name, falling back to the raw name. +func modelLabel(m sdk.Model) string { + if m.DisplayName != "" { + return m.DisplayName + } + return m.Name +} diff --git a/cli/cmd/model/view.go b/cli/cmd/model/view.go new file mode 100644 index 00000000..c220aa73 --- /dev/null +++ b/cli/cmd/model/view.go @@ -0,0 +1,85 @@ +package modelcmd + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/Tencent/WeKnora/cli/internal/cmdutil" + "github.com/Tencent/WeKnora/cli/internal/iostreams" + sdk "github.com/Tencent/WeKnora/client" +) + +// modelViewFields enumerates the fields surfaced for `--format json` discovery +// on `model view`. Includes the nested `parameters` object (the single-record +// view is where callers want the full configuration). +var modelViewFields = []string{ + "id", "name", "display_name", "type", "source", + "description", "is_default", "parameters", "created_at", "updated_at", +} + +// ViewService is the narrow SDK surface this command depends on. +type ViewService interface { + GetModel(ctx context.Context, id string) (*sdk.Model, error) +} + +// NewCmdView builds `weknora model view <model-id>`. +func NewCmdView(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "view <model-id>", + Short: "Show a model by ID", + Long: `Fetch one model's full record: type, source, default flag, and parameters.`, + 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 + } + return runView(c.Context(), fopts, cli, args[0]) + }, + } + cmdutil.AddFormatFlag(cmd, modelViewFields...) + cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{ + UsedFor: "fetch one model's full record by id", + RequiredFlags: []string{"<model-id> (positional)"}, + Examples: []string{"weknora model view model_abc --jq .data.type"}, + Output: "envelope.data is the Model object (id, name, display_name, type, source, is_default, parameters)", + }) + return cmd +} + +func runView(ctx context.Context, fopts *cmdutil.FormatOptions, svc ViewService, id string) error { + m, err := svc.GetModel(ctx, id) + if err != nil { + return cmdutil.WrapHTTP(err, "get model %q", id) + } + if fopts.WantsJSON() { + return fopts.Emit(iostreams.IO.Out, m, nil) + } + w := iostreams.IO.Out + fmt.Fprintf(w, "ID: %s\n", m.ID) + if label := modelLabel(*m); label != "" { + fmt.Fprintf(w, "NAME: %s\n", label) + } + fmt.Fprintf(w, "TYPE: %s\n", m.Type) + fmt.Fprintf(w, "SOURCE: %s\n", m.Source) + if m.IsDefault { + fmt.Fprintf(w, "DEFAULT: yes\n") + } + if m.Description != "" { + fmt.Fprintf(w, "DESCRIPTION: %s\n", m.Description) + } + if m.CreatedAt != "" { + fmt.Fprintf(w, "CREATED: %s\n", m.CreatedAt) + } + if m.UpdatedAt != "" { + fmt.Fprintf(w, "UPDATED: %s\n", m.UpdatedAt) + } + return nil +} diff --git a/cli/cmd/model/view_test.go b/cli/cmd/model/view_test.go new file mode 100644 index 00000000..d9d2da03 --- /dev/null +++ b/cli/cmd/model/view_test.go @@ -0,0 +1,69 @@ +package modelcmd + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/Tencent/WeKnora/cli/internal/cmdutil" + "github.com/Tencent/WeKnora/cli/internal/iostreams" + sdk "github.com/Tencent/WeKnora/client" +) + +type fakeViewSvc struct { + model *sdk.Model + err error +} + +func (f *fakeViewSvc) GetModel(_ context.Context, _ string) (*sdk.Model, error) { + return f.model, f.err +} + +func TestModelView_Text(t *testing.T) { + out, _ := iostreams.SetForTest(t) + svc := &fakeViewSvc{model: &sdk.Model{ + ID: "m1", DisplayName: "GPT-X", Type: sdk.ModelTypeKnowledgeQA, + Source: sdk.ModelSourceOpenAI, IsDefault: true, + }} + if err := runView(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "m1"); err != nil { + t.Fatalf("runView: %v", err) + } + got := out.String() + for _, want := range []string{"ID:", "m1", "NAME:", "GPT-X", "TYPE:", "KnowledgeQA", "SOURCE:", "openai", "DEFAULT:"} { + if !strings.Contains(got, want) { + t.Errorf("missing %q in:\n%s", want, got) + } + } +} + +func TestModelView_JSON(t *testing.T) { + out, _ := iostreams.SetForTest(t) + svc := &fakeViewSvc{model: &sdk.Model{ID: "m1", Type: sdk.ModelTypeEmbedding}} + if err := runView(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "m1"); err != nil { + t.Fatalf("runView: %v", err) + } + var env struct { + OK bool `json:"ok"` + Data sdk.Model `json:"data"` + } + if err := json.Unmarshal(out.Bytes(), &env); err != nil { + t.Fatalf("parse: %v\n%s", err, out.String()) + } + if !env.OK || env.Data.ID != "m1" { + t.Errorf("expected ok envelope with id=m1, got %q", out.String()) + } +} + +func TestModelView_NotFound(t *testing.T) { + _, _ = iostreams.SetForTest(t) + svc := &fakeViewSvc{err: errors.New("HTTP error 404: not found")} + err := runView(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "missing") + if err == nil { + t.Fatal("expected error") + } + if !cmdutil.IsNotFound(err) { + t.Errorf("expected resource.not_found, got %v", err) + } +} diff --git a/cli/cmd/profile/list.go b/cli/cmd/profile/list.go index 354dcb57..d7c8b028 100644 --- a/cli/cmd/profile/list.go +++ b/cli/cmd/profile/list.go @@ -78,7 +78,7 @@ func runList(fopts *cmdutil.FormatOptions) error { sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name }) if fopts.WantsJSON() { - meta := &output.Meta{Count: len(entries)} + meta := &output.Meta{Count: output.IntPtr(len(entries))} return fopts.Emit(iostreams.IO.Out, entries, meta) } if len(entries) == 0 { diff --git a/cli/cmd/profile/remove.go b/cli/cmd/profile/remove.go index ff1d0f52..5e2b3603 100644 --- a/cli/cmd/profile/remove.go +++ b/cli/cmd/profile/remove.go @@ -117,7 +117,7 @@ func runRemove(opts *RemoveOptions, fopts *cmdutil.FormatOptions, name string, s // Confirmation only fires for removing the current profile - non-current // remove uses the same low-friction policy as `auth logout`. if wasCurrent { - if err := cmdutil.ConfirmDestructive(p, opts.Yes, jsonOut, "remove", "current profile", name, "profile.remove", fmt.Sprintf("weknora profile remove %s -y", name)); err != nil { + if err := cmdutil.ConfirmDestructive(p, opts.Yes, jsonOut, "remove", "current profile", name, "profile.remove", []string{"weknora", "profile", "remove", name, "-y"}); err != nil { return err } } diff --git a/cli/cmd/profile/use.go b/cli/cmd/profile/use.go index 22daf07e..e8b5ca9c 100644 --- a/cli/cmd/profile/use.go +++ b/cli/cmd/profile/use.go @@ -2,7 +2,6 @@ package profilecmd import ( "fmt" - "sort" "github.com/spf13/cobra" @@ -99,7 +98,7 @@ func notFoundError(name string, cfg *config.Config) error { } } keys := profileKeys(cfg.Profiles) - candidate := closestMatch(name, keys) + candidate := cmdutil.SuggestOne(name, keys) var hint string if candidate != "" && candidate != name { hint = fmt.Sprintf("did you mean: %q?", candidate) @@ -120,53 +119,3 @@ func profileKeys(m map[string]config.Profile) []string { } return out } - -// closestMatch returns the candidate with min levenshtein distance ≤ 2, -// or "" if none qualifies. Ties broken by lexicographic order so the hint -// is deterministic across map-iteration orderings (Go randomizes range over -// map; without this, did-you-mean output is flaky for equally-close -// candidates). -func closestMatch(target string, candidates []string) string { - sorted := append([]string(nil), candidates...) - sort.Strings(sorted) - best := "" - bestD := 3 - for _, c := range sorted { - d := levenshtein(target, c) - if d < bestD { - bestD = d - best = c - } - } - if bestD > 2 { - return "" - } - return best -} - -func levenshtein(a, b string) int { - la, lb := len(a), len(b) - if la == 0 { - return lb - } - if lb == 0 { - return la - } - prev := make([]int, lb+1) - curr := make([]int, lb+1) - for j := 0; j <= lb; j++ { - prev[j] = j - } - for i := 1; i <= la; i++ { - curr[0] = i - for j := 1; j <= lb; j++ { - cost := 1 - if a[i-1] == b[j-1] { - cost = 0 - } - curr[j] = min(curr[j-1]+1, prev[j]+1, prev[j-1]+cost) - } - prev, curr = curr, prev - } - return prev[lb] -} diff --git a/cli/cmd/required_positional_coverage_test.go b/cli/cmd/required_positional_coverage_test.go new file mode 100644 index 00000000..ecc24a18 --- /dev/null +++ b/cli/cmd/required_positional_coverage_test.go @@ -0,0 +1,74 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "sort" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/Tencent/WeKnora/cli/internal/cmdutil" +) + +// TestLeafCommandsDeclareRequiredPositional guards the agent-first contract: a +// command whose Use string carries a <...> positional placeholder (it takes a +// required positional arg, enforced by cobra.ExactArgs/MinimumNArgs) MUST also +// declare that positional in its agent-help RequiredFlags. Otherwise +// `weknora schema <cmd>` under-reports the requirement, and an agent that builds +// a call from schema omits the positional and hits "accepts N arg(s), received 0". +// +// Sibling of TestEveryLeafCommandHasAgentHelp / the dry-run coverage guard. +func TestLeafCommandsDeclareRequiredPositional(t *testing.T) { + t.Setenv("WEKNORA_AGENT_HELP", "1") + root := NewRootCmd(cmdutil.New()) + + var missing []string + eachLeafCommand(root, func(c *cobra.Command) { + // Only flag a command whose Use carries a *positional* <...> placeholder + // (codebase convention). A <...> that follows a --flag is a flag value + // (e.g. "list --session <session-id>"), not a positional — skip those. + if !hasPositionalPlaceholder(c.Use) { + return + } + var buf bytes.Buffer + c.SetOut(&buf) + _ = c.Help() + var ah struct { + RequiredFlags []string `json:"required_flags"` + } + _ = json.Unmarshal(buf.Bytes(), &ah) + declared := false + for _, rf := range ah.RequiredFlags { + if strings.Contains(rf, "positional") { + declared = true + break + } + } + if !declared { + missing = append(missing, c.CommandPath()+" (Use: "+c.Use+")") + } + }) + + sort.Strings(missing) + if len(missing) > 0 { + t.Errorf("leaf commands take a required positional but don't declare it in agent-help RequiredFlags "+ + "(so `weknora schema <cmd>` under-reports the requirement). Add a \"<name> (positional)\" entry "+ + "to each SetAgentHelp RequiredFlags:\n %s\n(%d commands)", + strings.Join(missing, "\n "), len(missing)) + } +} + +// hasPositionalPlaceholder reports whether the Use string declares a required +// positional argument: a <...> token (after the command name) that is NOT +// preceded by a --flag token (which would make it a flag value, not a positional). +func hasPositionalPlaceholder(use string) bool { + fields := strings.Fields(use) + for i := 1; i < len(fields); i++ { // fields[0] is the command name + if strings.Contains(fields[i], "<") && !strings.HasPrefix(fields[i-1], "-") { + return true + } + } + return false +} diff --git a/cli/cmd/root.go b/cli/cmd/root.go index 1b22c872..98a14024 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -15,14 +15,18 @@ import ( "github.com/Tencent/WeKnora/cli/cmd/auth" chatcmd "github.com/Tencent/WeKnora/cli/cmd/chat" chunkcmd "github.com/Tencent/WeKnora/cli/cmd/chunk" + configcmd "github.com/Tencent/WeKnora/cli/cmd/config" "github.com/Tencent/WeKnora/cli/cmd/doc" "github.com/Tencent/WeKnora/cli/cmd/doctor" "github.com/Tencent/WeKnora/cli/cmd/kb" linkcmd "github.com/Tencent/WeKnora/cli/cmd/link" + messagecmd "github.com/Tencent/WeKnora/cli/cmd/message" mcpcmd "github.com/Tencent/WeKnora/cli/cmd/mcp" + modelcmd "github.com/Tencent/WeKnora/cli/cmd/model" profilecmd "github.com/Tencent/WeKnora/cli/cmd/profile" "github.com/Tencent/WeKnora/cli/cmd/search" sessioncmd "github.com/Tencent/WeKnora/cli/cmd/session" + skillscmd "github.com/Tencent/WeKnora/cli/cmd/skills" "github.com/Tencent/WeKnora/cli/internal/build" "github.com/Tencent/WeKnora/cli/internal/cmdutil" "github.com/Tencent/WeKnora/cli/internal/iostreams" @@ -148,6 +152,14 @@ a curated read-only MCP tool surface for AI agents.`, // (build commit + date). Version: fmt.Sprintf("%s (commit %s, built %s)", v, commit, date), PersistentPreRunE: func(c *cobra.Command, args []string) error { + // Input hygiene: reject control / ANSI-escape / Bidi / zero-width + // characters in positional args and supplied flag values before any + // work. CLI inputs are untrusted (agent- or content-sourced), and a + // smuggled escape stored in a resource name would fire in a human's + // terminal on a later list/view. Returns input.invalid_argument (exit 5). + if err := cmdutil.CheckSafeArgs(args, c.Flags()); err != nil { + return err + } // Propagate the global --profile flag (or WEKNORA_PROFILE env) into // the Factory for this invocation only - single-shot override, no disk write. // Flag takes precedence over env; env takes precedence over config file. @@ -189,15 +201,21 @@ a curated read-only MCP tool surface for AI agents.`, cmd.AddCommand(doctor.NewCmd(f)) cmd.AddCommand(kb.NewCmd(f)) cmd.AddCommand(profilecmd.NewCmd(f)) + cmd.AddCommand(configcmd.NewCmd(f)) cmd.AddCommand(linkcmd.NewCmd(f)) cmd.AddCommand(linkcmd.NewCmdUnlink()) cmd.AddCommand(doc.NewCmd(f)) cmd.AddCommand(apicmd.NewCmd(f)) cmd.AddCommand(chatcmd.NewCmd(f)) cmd.AddCommand(sessioncmd.NewCmd(f)) + cmd.AddCommand(messagecmd.NewCmd(f)) cmd.AddCommand(agentcmd.NewCmd(f)) + cmd.AddCommand(modelcmd.NewCmd(f)) cmd.AddCommand(chunkcmd.NewCmdChunk(f)) cmd.AddCommand(mcpcmd.NewCmd(f)) + cmd.AddCommand(skillscmd.NewCmd(f)) + cmd.AddCommand(newCmdExitCodes()) + cmd.AddCommand(newCmdSchema()) installUnknownSubcommandGuard(cmd) return cmd } @@ -289,17 +307,29 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error { } unknown := args[0] available := availableSubcommandNames(cmd) + // Lead with a "did you mean" when there's a plausible typo, so an agent + // gets a single actionable fix instead of scanning the whole list. + hint := fmt.Sprintf("available subcommands: %s", strings.Join(available, ", ")) + suggestions := cmdutil.SuggestClosest(unknown, available) + if len(suggestions) > 0 { + hint = fmt.Sprintf("did you mean: %s? (available: %s)", + strings.Join(suggestions, ", "), strings.Join(available, ", ")) + } + detail := map[string]any{ + "unknown": unknown, + "command_path": cmd.CommandPath(), + "available": available, + } + if len(suggestions) > 0 { + detail["suggestions"] = suggestions + } return cmdutil.NewError( cmdutil.CodeInputUnknownSubcommand, fmt.Sprintf("unknown subcommand %q for %q", unknown, cmd.CommandPath()), ). - WithHint(fmt.Sprintf("available subcommands: %s", strings.Join(available, ", "))). - WithRetryCommand(cmd.CommandPath() + " --help"). - WithDetail(map[string]any{ - "unknown": unknown, - "command_path": cmd.CommandPath(), - "available": available, - }) + WithHint(hint). + WithRetryArgv(append(strings.Fields(cmd.CommandPath()), "--help")). + WithDetail(detail) } func availableSubcommandNames(cmd *cobra.Command) []string { diff --git a/cli/cmd/root_unknown_subcommand_test.go b/cli/cmd/root_unknown_subcommand_test.go index 36b074f7..b5fd2f9d 100644 --- a/cli/cmd/root_unknown_subcommand_test.go +++ b/cli/cmd/root_unknown_subcommand_test.go @@ -48,7 +48,7 @@ func TestUnknownSubcommand_EmitsTypedEnvelope(t *testing.T) { if !strings.Contains(got, `"available":[`) { t.Errorf("expected detail.available[]; got %q", got) } - if !strings.Contains(got, `"retry_command":"weknora --help"`) { - t.Errorf("expected retry_command; got %q", got) + if !strings.Contains(got, `"retry_argv":["weknora","--help"]`) { + t.Errorf("expected retry_argv; got %q", got) } } diff --git a/cli/cmd/schema.go b/cli/cmd/schema.go new file mode 100644 index 00000000..76970dfd --- /dev/null +++ b/cli/cmd/schema.go @@ -0,0 +1,252 @@ +package cmd + +import ( + "fmt" + "io" + "sort" + "strings" + "text/tabwriter" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + + "github.com/Tencent/WeKnora/cli/internal/cmdutil" + "github.com/Tencent/WeKnora/cli/internal/iostreams" + "github.com/Tencent/WeKnora/cli/internal/output" +) + +// schemaRisk mirrors the destructive-write risk annotation in the schema view. +type schemaRisk struct { + Level string `json:"level"` + Action string `json:"action"` +} + +// schemaFlag is one command-local flag in the schema view. +type schemaFlag struct { + Name string `json:"name"` + Shorthand string `json:"shorthand,omitempty"` + Type string `json:"type"` + Default string `json:"default,omitempty"` + Usage string `json:"usage,omitempty"` +} + +// commandSchema is the full machine-readable contract for one command. It is a +// superset of cmdutil.AgentHelp augmented with the resolved command path, the +// destructive-write risk, and the command's local flags — so an agent can +// learn how to invoke a command without scraping prose help. +type commandSchema struct { + Command string `json:"command"` + UsedFor string `json:"used_for,omitempty"` + RequiredFlags []string `json:"required_flags,omitempty"` + Examples []string `json:"examples,omitempty"` + Output string `json:"output,omitempty"` + Warnings []string `json:"warnings,omitempty"` + Risk *schemaRisk `json:"risk,omitempty"` + Flags []schemaFlag `json:"flags,omitempty"` +} + +// schemaIndexEntry is one row of the no-argument `weknora schema` index. +type schemaIndexEntry struct { + Command string `json:"command"` + UsedFor string `json:"used_for,omitempty"` +} + +// newCmdSchema builds the `weknora schema` introspection command. With no args +// it lists every leaf command and its purpose; with a command path it prints +// that command's full contract. This makes the structured help (otherwise only +// reachable via WEKNORA_AGENT_HELP=1 on --help) a first-class, discoverable +// command — mirroring how mainstream agent-first CLIs expose schema introspection. +func newCmdSchema() *cobra.Command { + cmd := &cobra.Command{ + Use: "schema [command...]", + Short: "Machine-readable contract for a command (or the whole surface)", + Long: `Print the machine-readable contract for a command, or — with no argument — +an index of every command and what it is used for. + + weknora schema # index: every leaf command + used_for + weknora schema kb create # the contract for one command + weknora schema doc update # used_for, flags, examples, output, risk + +This is the discoverable form of WEKNORA_AGENT_HELP=1 <cmd> --help: an agent +can enumerate the surface and learn how to call any command without scraping +human help prose.`, + Args: cobra.ArbitraryArgs, + RunE: func(c *cobra.Command, args []string) error { + fopts, err := cmdutil.CheckFormatFlag(c) + if err != nil { + return err + } + fopts.ResolveDefault(iostreams.IO.IsStdoutTTY()) + root := c.Root() + if len(args) == 0 { + return emitSchemaIndex(root, fopts) + } + target, err := resolveSchemaTarget(root, args) + if err != nil { + return err + } + return emitCommandSchema(target, fopts) + }, + } + cmdutil.AddFormatFlag(cmd, "command", "used_for", "required_flags", "examples", "output", "warnings", "risk", "flags") + cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{ + UsedFor: "introspect the CLI surface: list every command (no args) or print one command's contract (used_for, flags, examples, output, risk).", + Examples: []string{ + "weknora schema", + "weknora schema kb create", + "weknora schema doc update --format json", + }, + Output: "with no args, envelope.data is an array of {command, used_for}; with a command path, envelope.data is {command, used_for, required_flags, examples, output, warnings, risk, flags}", + }) + return cmd +} + +// resolveSchemaTarget walks the command tree to the command named by args. +// Returns a typed input.unknown_subcommand error (with did-you-mean) when the +// path does not resolve to a real command. +func resolveSchemaTarget(root *cobra.Command, args []string) (*cobra.Command, error) { + target, rest, err := root.Find(args) + // Find returns root (with the args unconsumed) when nothing matched; a + // fully-resolved leaf returns itself with its positional args as rest. + if err != nil || target == nil || target == root { + unknown := args[0] + available := availableSubcommandNames(root) + hint := fmt.Sprintf("available top-level commands: %s", strings.Join(available, ", ")) + if sug := cmdutil.SuggestClosest(unknown, available); len(sug) > 0 { + hint = fmt.Sprintf("did you mean: %s? (run `weknora schema` to list all)", strings.Join(sug, ", ")) + } + return nil, cmdutil.NewError(cmdutil.CodeInputUnknownSubcommand, + fmt.Sprintf("no command named %q", strings.Join(args, " "))). + WithHint(hint). + WithRetryArgv([]string{"weknora", "schema"}) + } + // Leftover args that are not the command's own resource positionals mean a + // deeper path was requested that doesn't exist (e.g. `schema kb bogus`). + if len(rest) > 0 && target.HasSubCommands() { + return nil, cmdutil.NewError(cmdutil.CodeInputUnknownSubcommand, + fmt.Sprintf("no subcommand %q under %q", rest[0], strings.TrimPrefix(target.CommandPath(), "weknora "))). + WithHint(fmt.Sprintf("available: %s", strings.Join(availableSubcommandNames(target), ", "))). + WithRetryArgv(append([]string{"weknora", "schema"}, strings.Fields(strings.TrimPrefix(target.CommandPath(), "weknora "))...)) + } + return target, nil +} + +// emitCommandSchema renders one command's contract. +func emitCommandSchema(target *cobra.Command, fopts *cmdutil.FormatOptions) error { + cs := buildCommandSchema(target) + if fopts.WantsJSON() { + return fopts.Emit(iostreams.IO.Out, cs, nil) + } + return writeCommandSchemaText(iostreams.IO.Out, cs) +} + +// buildCommandSchema assembles the schema view from the command's AgentHelp, +// risk annotation, and local (non-inherited) flags. +func buildCommandSchema(target *cobra.Command) commandSchema { + cs := commandSchema{Command: strings.TrimPrefix(target.CommandPath(), "weknora ")} + if ah, ok := cmdutil.AgentHelpFor(target); ok { + cs.UsedFor = ah.UsedFor + cs.RequiredFlags = ah.RequiredFlags + cs.Examples = ah.Examples + cs.Output = ah.Output + cs.Warnings = ah.Warnings + } else { + cs.UsedFor = target.Short + } + if level, action, ok := cmdutil.GetRisk(target); ok { + cs.Risk = &schemaRisk{Level: level, Action: action} + } + target.LocalFlags().VisitAll(func(f *pflag.Flag) { + if f.Hidden { + return + } + cs.Flags = append(cs.Flags, schemaFlag{ + Name: f.Name, + Shorthand: f.Shorthand, + Type: f.Value.Type(), + Default: f.DefValue, + Usage: f.Usage, + }) + }) + return cs +} + +// writeCommandSchemaText renders one command's contract as readable prose for +// the human (--format text) path. +func writeCommandSchemaText(w io.Writer, cs commandSchema) error { + fmt.Fprintf(w, "weknora %s\n", cs.Command) + if cs.UsedFor != "" { + fmt.Fprintf(w, "\n%s\n", cs.UsedFor) + } + if cs.Risk != nil { + fmt.Fprintf(w, "\nRisk: %s (%s) — confirmation-gated (exit 10) unless -y\n", cs.Risk.Action, cs.Risk.Level) + } + if len(cs.RequiredFlags) > 0 { + fmt.Fprintf(w, "\nRequired:\n") + for _, r := range cs.RequiredFlags { + fmt.Fprintf(w, " - %s\n", r) + } + } + if len(cs.Flags) > 0 { + fmt.Fprintf(w, "\nFlags:\n") + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + for _, f := range cs.Flags { + name := "--" + f.Name + if f.Shorthand != "" { + name += ", -" + f.Shorthand + } + fmt.Fprintf(tw, " %s\t%s\t%s\n", name, f.Type, f.Usage) + } + _ = tw.Flush() + } + if cs.Output != "" { + fmt.Fprintf(w, "\nOutput: %s\n", cs.Output) + } + if len(cs.Examples) > 0 { + fmt.Fprintf(w, "\nExamples:\n") + for _, e := range cs.Examples { + fmt.Fprintf(w, " %s\n", e) + } + } + if len(cs.Warnings) > 0 { + fmt.Fprintf(w, "\nAI agents:\n") + for _, msg := range cs.Warnings { + fmt.Fprintf(w, " - %s\n", msg) + } + } + return nil +} + +// emitSchemaIndex lists every leaf command and its purpose, sorted by path. +func emitSchemaIndex(root *cobra.Command, fopts *cmdutil.FormatOptions) error { + var entries []schemaIndexEntry + var walk func(c *cobra.Command) + walk = func(c *cobra.Command) { + for _, sub := range c.Commands() { + if sub.Hidden || sub.Name() == "help" || sub.Name() == "completion" { + continue + } + if sub.HasSubCommands() { + walk(sub) + continue + } + e := schemaIndexEntry{Command: strings.TrimPrefix(sub.CommandPath(), "weknora ")} + if ah, ok := cmdutil.AgentHelpFor(sub); ok { + e.UsedFor = ah.UsedFor + } else { + e.UsedFor = sub.Short + } + entries = append(entries, e) + } + } + walk(root) + sort.Slice(entries, func(i, j int) bool { return entries[i].Command < entries[j].Command }) + + if fopts.WantsJSON() { + return fopts.Emit(iostreams.IO.Out, entries, &output.Meta{Count: output.IntPtr(len(entries))}) + } + for _, e := range entries { + fmt.Fprintf(iostreams.IO.Out, "%-28s %s\n", e.Command, e.UsedFor) + } + return nil +} diff --git a/cli/cmd/schema_test.go b/cli/cmd/schema_test.go new file mode 100644 index 00000000..bf0abdbf --- /dev/null +++ b/cli/cmd/schema_test.go @@ -0,0 +1,100 @@ +package cmd + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/Tencent/WeKnora/cli/internal/cmdutil" + "github.com/Tencent/WeKnora/cli/internal/iostreams" +) + +// TestSchema_Index: `weknora schema` lists every leaf command with its purpose. +func TestSchema_Index(t *testing.T) { + out, _ := iostreams.SetForTest(t) + root := NewRootCmd(cmdutil.New()) + root.SetArgs([]string{"schema", "--format", "json"}) + require.NoError(t, root.Execute()) + + var env struct { + OK bool `json:"ok"` + Data []schemaIndexEntry `json:"data"` + Meta struct { + Count int `json:"count"` + } `json:"meta"` + } + require.NoError(t, json.Unmarshal(out.Bytes(), &env), "got %q", out.String()) + assert.True(t, env.OK) + assert.Greater(t, env.Meta.Count, 30, "index should enumerate the whole leaf surface") + + byCmd := map[string]string{} + for _, e := range env.Data { + byCmd[e.Command] = e.UsedFor + } + // schema lists itself and other leaves, each with a used_for. + assert.Contains(t, byCmd, "kb create") + assert.Contains(t, byCmd, "schema") + assert.NotEmpty(t, byCmd["kb create"]) + // group commands (kb, doc) are not leaves and must not appear. + assert.NotContains(t, byCmd, "kb") +} + +// TestSchema_SingleCommand: a command path emits its full contract incl. flags. +func TestSchema_SingleCommand(t *testing.T) { + out, _ := iostreams.SetForTest(t) + root := NewRootCmd(cmdutil.New()) + root.SetArgs([]string{"schema", "kb", "create", "--format", "json"}) + require.NoError(t, root.Execute()) + + var env struct { + OK bool `json:"ok"` + Data commandSchema `json:"data"` + } + require.NoError(t, json.Unmarshal(out.Bytes(), &env), "got %q", out.String()) + assert.True(t, env.OK) + assert.Equal(t, "kb create", env.Data.Command) + assert.NotEmpty(t, env.Data.UsedFor) + assert.NotEmpty(t, env.Data.Examples) + + flagNames := map[string]bool{} + for _, f := range env.Data.Flags { + flagNames[f.Name] = true + } + assert.True(t, flagNames["description"], "kb create local flags should include --description; got %v", flagNames) + // global persistent flags (--format, --profile) are inherited, not local — + // schema lists only command-specific flags. + assert.False(t, flagNames["profile"], "inherited global flags must be excluded") +} + +// TestSchema_SurfacesRisk: a destructive command exposes its risk annotation, +// so an agent can discover confirmation-gating without running the command. +func TestSchema_SurfacesRisk(t *testing.T) { + out, _ := iostreams.SetForTest(t) + root := NewRootCmd(cmdutil.New()) + root.SetArgs([]string{"schema", "doc", "update", "--format", "json"}) + require.NoError(t, root.Execute()) + + var env struct { + Data commandSchema `json:"data"` + } + require.NoError(t, json.Unmarshal(out.Bytes(), &env), "got %q", out.String()) + require.NotNil(t, env.Data.Risk) + assert.Equal(t, "doc.update", env.Data.Risk.Action) + assert.Equal(t, "write", env.Data.Risk.Level) +} + +// TestSchema_UnknownCommand: a bad path returns a typed unknown_subcommand +// error (exit 5) with a did-you-mean hint — never a silent empty result. +func TestSchema_UnknownCommand(t *testing.T) { + iostreams.SetForTest(t) + root := NewRootCmd(cmdutil.New()) + root.SetArgs([]string{"schema", "kbb", "--format", "json"}) + err := root.Execute() + require.Error(t, err) + var ce *cmdutil.Error + require.ErrorAs(t, err, &ce) + assert.Equal(t, cmdutil.CodeInputUnknownSubcommand, ce.Code) + assert.Contains(t, ce.Hint, "did you mean") +} diff --git a/cli/cmd/search/chunks.go b/cli/cmd/search/chunks.go index 2636b4ac..806e4955 100644 --- a/cli/cmd/search/chunks.go +++ b/cli/cmd/search/chunks.go @@ -158,7 +158,7 @@ func runChunks(ctx context.Context, opts *ChunksOptions, fopts *cmdutil.FormatOp if results == nil { results = []*sdk.SearchResult{} } - meta := &output.Meta{Count: len(results), HasMore: truncated} + meta := &output.Meta{Count: output.IntPtr(len(results)), HasMore: truncated} return fopts.Emit(iostreams.IO.Out, results, meta) } return renderChunkResults(results, opts.KBID) diff --git a/cli/cmd/search/docs.go b/cli/cmd/search/docs.go index 26065308..65ce09fa 100644 --- a/cli/cmd/search/docs.go +++ b/cli/cmd/search/docs.go @@ -172,7 +172,7 @@ done: if matches == nil { matches = []sdk.Knowledge{} } - meta := &output.Meta{Count: len(matches), HasMore: truncated} + meta := &output.Meta{Count: output.IntPtr(len(matches)), HasMore: truncated} return fopts.Emit(iostreams.IO.Out, matches, meta) } if len(matches) == 0 { diff --git a/cli/cmd/search/kb.go b/cli/cmd/search/kb.go index 7065f407..90ecf0e9 100644 --- a/cli/cmd/search/kb.go +++ b/cli/cmd/search/kb.go @@ -80,8 +80,9 @@ content, use ` + "`weknora search chunks`" + `.`, cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum results to return") cmdutil.AddFormatFlag(cmd, kbSearchFields...) cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{ - UsedFor: "Find knowledge bases by name or description (client-side case-insensitive substring match). Results come with meta.count; use --limit to cap. For searching content inside a KB, use 'search chunks' instead.", - Examples: []string{`weknora search kb "engineering" --format json`}, + UsedFor: "Find knowledge bases by name or description (client-side case-insensitive substring match). Results come with meta.count; use --limit to cap. For searching content inside a KB, use 'search chunks' instead.", + RequiredFlags: []string{"<query> (positional)"}, + Examples: []string{`weknora search kb "engineering" --format json`}, Output: "envelope.data is an array of KnowledgeBase objects with id, name, knowledge_count; meta.count is the returned count; meta.has_more=true if more matched than --limit", }) return cmd @@ -102,7 +103,7 @@ func runKBSearch(ctx context.Context, opts *KBSearchOptions, fopts *cmdutil.Form if matches == nil { matches = []sdk.KnowledgeBase{} } - meta := &output.Meta{Count: len(matches), HasMore: truncated} + meta := &output.Meta{Count: output.IntPtr(len(matches)), HasMore: truncated} return fopts.Emit(iostreams.IO.Out, matches, meta) } if len(matches) == 0 { diff --git a/cli/cmd/search/sessions.go b/cli/cmd/search/sessions.go index ed4f72bc..d0177ba8 100644 --- a/cli/cmd/search/sessions.go +++ b/cli/cmd/search/sessions.go @@ -93,8 +93,9 @@ to stop after one page.`, cmd.Flags().BoolVar(&opts.AllPages, "all-pages", true, "Walk every server page until exhausted or --limit hit") cmdutil.AddFormatFlag(cmd, sessionsSearchFields...) cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{ - UsedFor: "Find chat sessions by title or description (client-side case-insensitive substring match). Results come with meta.count; use --limit to cap and --all-pages=false to stop after one page.", - Examples: []string{`weknora search sessions "onboarding" --format json`}, + UsedFor: "Find chat sessions by title or description (client-side case-insensitive substring match). Results come with meta.count; use --limit to cap and --all-pages=false to stop after one page.", + RequiredFlags: []string{"<query> (positional)"}, + Examples: []string{`weknora search sessions "onboarding" --format json`}, Output: "envelope.data is an array of Session objects with id, title, updated_at; meta.count is the returned count; meta.has_more=true if more matched than --limit", }) return cmd @@ -146,7 +147,7 @@ done: if matches == nil { matches = []sdk.Session{} } - meta := &output.Meta{Count: len(matches), HasMore: truncated} + meta := &output.Meta{Count: output.IntPtr(len(matches)), HasMore: truncated} return fopts.Emit(iostreams.IO.Out, matches, meta) } if len(matches) == 0 { diff --git a/cli/cmd/session/ask.go b/cli/cmd/session/ask.go index adb4ba88..a7c0d9fa 100644 --- a/cli/cmd/session/ask.go +++ b/cli/cmd/session/ask.go @@ -98,7 +98,7 @@ both flags for the complete projected stream.`, cmdutil.AddFormatFlag(cmd, sessionAskFields...) cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{ UsedFor: "Invoke a custom agent in a session context. Default JSON returns a bounded answer-event projection. --reference adds indexed citations; --verbose adds reasoning, tools, and lifecycle events. --format ndjson streams raw SDK agent events; --format text renders the selected events live.", - RequiredFlags: []string{"--agent"}, + RequiredFlags: []string{"<text> (positional)", "--agent"}, Examples: []string{ `weknora session ask --agent ag_x "Summarize Q3 sales"`, `weknora session ask --agent ag_x "Summarize Q3 sales" --jq '[.data.events[].content] | join("")'`, diff --git a/cli/cmd/session/continue_stream.go b/cli/cmd/session/continue_stream.go index 704ffc7d..5309e3c1 100644 --- a/cli/cmd/session/continue_stream.go +++ b/cli/cmd/session/continue_stream.go @@ -23,12 +23,14 @@ package sessioncmd import ( "context" + "errors" "github.com/spf13/cobra" "github.com/Tencent/WeKnora/cli/internal/cmdutil" "github.com/Tencent/WeKnora/cli/internal/iostreams" "github.com/Tencent/WeKnora/cli/internal/output" + "github.com/Tencent/WeKnora/cli/internal/sse" sdk "github.com/Tencent/WeKnora/client" ) @@ -113,7 +115,7 @@ silently treated as NDJSON.`, cmdutil.AddFormatFlag(cmd, continueStreamFields...) cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{ UsedFor: "Resume an SSE event stream for an in-progress or completed assistant message. Produces an NDJSON event stream: init line (session_id, message_id) then raw SDK StreamResponse events.", - RequiredFlags: []string{"--message (message_id from prior init / agent_query event)"}, + RequiredFlags: []string{"<session-id> (positional)", "--message (message_id from prior init / agent_query event)"}, Examples: []string{ "weknora session continue-stream sess_xyz --message msg_abc --format json", "# Network-blip recovery: replay with same session_id + message_id from the original 'session ask' init event", @@ -163,10 +165,40 @@ func runContinueStream(ctx context.Context, opts *ContinueStreamOptions, _ *cmdu // 2. Open the SDK replay stream and pass each event through as a bare // NDJSON line. The SDK's StreamResponse is the source of truth for // the event vocabulary; the CLI does not reshape it. + var streamErrMsg string cb := func(r *sdk.StreamResponse) error { - return output.EmitSDKEvent(w, r) + isErr := r != nil && r.ResponseType == sdk.ResponseTypeError + if isErr && streamErrMsg == "" { + if r.Content != "" { + streamErrMsg = r.Content + } else if r.Data != nil { + if e, ok := r.Data["error"].(string); ok { + streamErrMsg = e + } + } + } + if err := output.EmitSDKEvent(w, r); err != nil { + return err + } + // Stop once the error frame is emitted rather than blocking on the SDK + // read until the ~30s transport timeout (the server holds the stream + // open after the error). streamErrMsg carries the real reason. + if isErr { + return sse.ErrTerminate + } + return nil } - if err := svc.ContinueStream(ctx, opts.SessionID, opts.MessageID, cb); err != nil { + err := svc.ContinueStream(ctx, opts.SessionID, opts.MessageID, cb) + if errors.Is(err, sse.ErrTerminate) { + err = nil + } + // A server-delivered error frame is the authoritative failure reason — + // surface it as operation.failed (exit 1) instead of the transport timeout + // the server's stream-close triggers afterwards. Mirrors chat / session ask. + if streamErrMsg != "" { + return cmdutil.NewError(cmdutil.CodeOperationFailed, "continue stream failed: "+streamErrMsg) + } + if err != nil { // Ctrl-C / SIGTERM lineage (operator gave up on the resume). if cmdutil.IsCancelled(ctx, err) { return cmdutil.Wrapf(cmdutil.CodeOperationCancelled, err, "session continue-stream cancelled") diff --git a/cli/cmd/session/delete.go b/cli/cmd/session/delete.go index 5fbdccae..bcae031d 100644 --- a/cli/cmd/session/delete.go +++ b/cli/cmd/session/delete.go @@ -3,7 +3,6 @@ package sessioncmd import ( "context" "fmt" - "strings" "github.com/spf13/cobra" @@ -84,7 +83,9 @@ without the user's explicit go-ahead.`, if len(args) == 1 { return runDelete(c.Context(), opts, fopts, cli, f.Prompter(), args[0]) } - if err := cmdutil.ConfirmDestructiveBatch(f.Prompter(), opts.Yes, fopts.WantsJSON(), "delete", "session", len(args), "session.delete", "weknora session delete "+strings.Join(args, " ")+" -y"); err != nil { + batchRetry := append([]string{"weknora", "session", "delete"}, args...) + batchRetry = append(batchRetry, "-y") + if err := cmdutil.ConfirmDestructiveBatch(f.Prompter(), opts.Yes, fopts.WantsJSON(), "delete", "session", len(args), "session.delete", batchRetry); err != nil { return err } outcomes, runErr := cmdutil.RunBatch(c.Context(), args, func(ctx context.Context, id string) error { @@ -115,6 +116,7 @@ without the user's explicit go-ahead.`, "weknora session delete s_a s_b s_c -y", "weknora session delete s_abc -y --format json", }, + Output: "envelope.data shape depends on the form: a single session id -> {id, deleted:true}; multiple ids -> batch envelope (top-level status success|partial|error, ok=(failures==0), data per-item [{id, ok, error?}], meta.successes/failures — read data[].ok per id).", Warnings: []string{ "Requires explicit user approval (exit 10 / input.confirmation_required); never auto-add -y.", "session delete is irreversible; loses the conversation + all messages + tool call history.", @@ -124,7 +126,7 @@ without the user's explicit go-ahead.`, } 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(), "delete", "session", id, "session.delete", "weknora session delete "+id+" -y"); err != nil { + if err := cmdutil.ConfirmDestructive(p, opts.Yes, fopts.WantsJSON(), "delete", "session", id, "session.delete", []string{"weknora", "session", "delete", id, "-y"}); err != nil { return err } diff --git a/cli/cmd/session/delete_test.go b/cli/cmd/session/delete_test.go index 0716998c..2b09e383 100644 --- a/cli/cmd/session/delete_test.go +++ b/cli/cmd/session/delete_test.go @@ -146,7 +146,7 @@ func TestMultiDelete_NonTTY_NoYes_RequiresConfirmation(t *testing.T) { _, _ = iostreams.SetForTest(t) svc := &fakeMultiDeleteSvc{} fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatText} - err := cmdutil.ConfirmDestructiveBatch(&testutil.ConfirmPrompter{}, false, fopts.WantsJSON(), "delete", "session", 2, "session.delete", "") + err := cmdutil.ConfirmDestructiveBatch(&testutil.ConfirmPrompter{}, false, fopts.WantsJSON(), "delete", "session", 2, "session.delete", nil) require.Error(t, err) var typed *cmdutil.Error require.ErrorAs(t, err, &typed) diff --git a/cli/cmd/session/list.go b/cli/cmd/session/list.go index 0103bfec..2e970079 100644 --- a/cli/cmd/session/list.go +++ b/cli/cmd/session/list.go @@ -57,6 +57,12 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { return err } fopts.ResolveDefault(iostreams.IO.IsStdoutTTY()) + // Validate static input before building the client so a bad + // --page-size/--limit returns input.invalid_argument (exit 5), not + // an auth error (exit 3). + if err := validateListOpts(opts); err != nil { + return err + } cli, err := f.Client() if err != nil { return err @@ -65,19 +71,22 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command { }, } cmd.Flags().IntVar(&opts.PageSize, "page-size", defaultPageSize, "Items per server batch (1..1000)") - cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum results to return (1..10000)") + cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum results to return — client-side cap; meta.has_more/total_count report the full size (1..10000)") cmd.Flags().BoolVar(&opts.AllPages, "all-pages", false, "Walk all server pages until exhausted (or --limit hit)") cmd.Flags().StringVar(&opts.Since, "since", "", "Only show sessions updated within `duration` (e.g. 7d, 24h, 30m)") cmdutil.AddFormatFlag(cmd, sessionListFields...) cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{ UsedFor: "List chat sessions for the active profile. Results come with meta.count; use --limit to cap, --all-pages to walk every server page, --since to filter by recency (e.g. 7d).", Examples: []string{"weknora session list --format json", "weknora session list --all-pages --since 7d --format json"}, - Output: "envelope.data is an array of Session objects with id, title, updated_at; meta.count is the total returned", + Output: "envelope.data is an array of Session objects with id, title, updated_at; meta.count is the total returned; meta.total_count is the server-side total before --since filtering", }) return cmd } -func runList(ctx context.Context, opts *ListOptions, fopts *cmdutil.FormatOptions, svc ListService) error { +// validateListOpts checks --page-size / --limit. Called from RunE before the +// client is built (so a bad value surfaces as exit 5, not an auth error) and at +// runList's top for direct callers; idempotent. +func validateListOpts(opts *ListOptions) error { if opts.PageSize < 1 || opts.PageSize > maxPageSize { return &cmdutil.Error{ Code: cmdutil.CodeInputInvalidArgument, @@ -90,6 +99,13 @@ func runList(ctx context.Context, opts *ListOptions, fopts *cmdutil.FormatOption Message: fmt.Sprintf("--limit must be in 1..10000, got %d", opts.Limit), } } + return nil +} + +func runList(ctx context.Context, opts *ListOptions, fopts *cmdutil.FormatOptions, svc ListService) error { + if err := validateListOpts(opts); err != nil { + return err + } var since time.Duration if opts.Since != "" { d, err := parseSinceDuration(opts.Since) @@ -100,6 +116,7 @@ func runList(ctx context.Context, opts *ListOptions, fopts *cmdutil.FormatOption } var items []sdk.Session + var serverTotal int if opts.AllPages { accum := make([]sdk.Session, 0) for page := 1; ; page++ { @@ -107,6 +124,7 @@ func runList(ctx context.Context, opts *ListOptions, fopts *cmdutil.FormatOption if err != nil { return cmdutil.WrapHTTP(err, "list sessions") } + serverTotal = total accum = append(accum, chunk...) if opts.Limit > 0 && len(accum) >= opts.Limit { accum = accum[:opts.Limit] @@ -118,10 +136,11 @@ func runList(ctx context.Context, opts *ListOptions, fopts *cmdutil.FormatOption } items = accum } else { - chunk, _, err := svc.GetSessionsByTenant(ctx, 1, opts.PageSize) + chunk, total, err := svc.GetSessionsByTenant(ctx, 1, opts.PageSize) if err != nil { return cmdutil.WrapHTTP(err, "list sessions") } + serverTotal = total items = chunk } if items == nil { @@ -149,7 +168,7 @@ func runList(ctx context.Context, opts *ListOptions, fopts *cmdutil.FormatOption } if fopts.WantsJSON() { - meta := &output.Meta{Count: len(items), HasMore: truncated} + meta := &output.Meta{Count: output.IntPtr(len(items)), HasMore: truncated, TotalCount: output.IntPtr(serverTotal)} return fopts.Emit(iostreams.IO.Out, items, meta) } diff --git a/cli/cmd/session/list_test.go b/cli/cmd/session/list_test.go index 26b716e1..57581d8e 100644 --- a/cli/cmd/session/list_test.go +++ b/cli/cmd/session/list_test.go @@ -271,3 +271,45 @@ func TestList_AllPages_WithLimit_StopsAtLimit(t *testing.T) { assert.Equal(t, 50, got) assert.LessOrEqual(t, len(svc.calls), 3, "must not fetch beyond what fills --limit") } + +// TestList_JSON_TotalCount_SinglePage asserts that meta.total_count is populated +// from the server total when doing a single-page fetch. +func TestList_JSON_TotalCount_SinglePage(t *testing.T) { + out, _ := iostreams.SetForTest(t) + svc := &fakeListService{ + items: []sdk.Session{{ID: "s_1", Title: "T1", UpdatedAt: "2026-05-12T14:00:00Z"}}, + total: 42, + } + require.NoError(t, runList(context.Background(), + &ListOptions{PageSize: 10, Limit: 30}, + &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc)) + body := out.String() + assert.Contains(t, body, `"total_count":42`, "single-page fetch must surface server total in meta.total_count") +} + +// TestList_JSON_TotalCount_AllPages asserts meta.total_count is populated +// from the server total when walking all pages. +func TestList_JSON_TotalCount_AllPages(t *testing.T) { + out, _ := iostreams.SetForTest(t) + svc := &pagedSessionSvc{all: makeSessions(45)} + require.NoError(t, runList(context.Background(), + &ListOptions{PageSize: 20, AllPages: true, Limit: 10000}, + &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc)) + body := out.String() + assert.Contains(t, body, `"total_count":45`, "--all-pages fetch must surface server total in meta.total_count") +} + +// TestList_JSON_TotalCount_Zero_Present asserts that when server returns total=0 +// on an empty list, meta.total_count (and meta.count) still serialize as 0. The +// *int + omitempty pattern omits only nil, so the agent contract stays stable: +// an empty result reports 0, not a missing key. +func TestList_JSON_TotalCount_Zero_Present(t *testing.T) { + out, _ := iostreams.SetForTest(t) + svc := &fakeListService{items: nil, total: 0} + require.NoError(t, runList(context.Background(), + &ListOptions{PageSize: 30, Limit: 30}, + &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc)) + body := out.String() + assert.Contains(t, body, `"total_count":0`, "zero server total must serialize as 0 on empty list") + assert.Contains(t, body, `"count":0`, "empty list must report count:0, not omit it") +} diff --git a/cli/cmd/session/session.go b/cli/cmd/session/session.go index 9084df84..d30da35e 100644 --- a/cli/cmd/session/session.go +++ b/cli/cmd/session/session.go @@ -24,5 +24,6 @@ func NewCmd(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(NewCmdAsk(f)) cmd.AddCommand(NewCmdContinueStream(f)) cmd.AddCommand(NewCmdStop(f)) + cmd.AddCommand(NewCmdToolApproval(f)) return cmd } diff --git a/cli/cmd/session/stop.go b/cli/cmd/session/stop.go index 8e4863c3..db74ad11 100644 --- a/cli/cmd/session/stop.go +++ b/cli/cmd/session/stop.go @@ -82,7 +82,7 @@ Symmetric with 'session continue-stream': both key on (session_id, message_id).` cmdutil.AddDryRunFlag(cmd, &opts.DryRun) cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{ UsedFor: "Stop server-side generation for an in-flight assistant message (counterpart to continue-stream). The message_id comes from the init event of the chat / session ask / continue-stream stream you're stopping.", - RequiredFlags: []string{"--message (message_id from the init event of the stream you're stopping)"}, + RequiredFlags: []string{"<session-id> (positional)", "--message (message_id from the init event of the stream you're stopping)"}, Examples: []string{"weknora session stop sess_xyz --message msg_abc"}, Output: "envelope {session_id, message_id, stopped:true}", }) diff --git a/cli/cmd/session/tool_approval.go b/cli/cmd/session/tool_approval.go new file mode 100644 index 00000000..44c18ca6 --- /dev/null +++ b/cli/cmd/session/tool_approval.go @@ -0,0 +1,219 @@ +package sessioncmd + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + + "github.com/Tencent/WeKnora/cli/internal/cmdutil" + "github.com/Tencent/WeKnora/cli/internal/iostreams" + "github.com/Tencent/WeKnora/cli/internal/prompt" + sdk "github.com/Tencent/WeKnora/client" +) + +var resolveFields = []string{"pending_id", "decision", "resolved"} + +// decision values sent to the server and surfaced in the result/dry-run JSON. +const ( + decisionApprove = "approve" + decisionReject = "reject" +) + +// ResolveOptions holds the parsed flags and positional argument for +// `session tool-approval resolve`. +type ResolveOptions struct { + PendingID string + Reject bool + Reason string + ModifiedArgs string + Yes bool + DryRun bool +} + +// ResolveService is the narrow SDK surface this command depends on. +type ResolveService interface { + ResolveToolApproval(ctx context.Context, pendingID string, req *sdk.ResolveToolApprovalRequest) error +} + +type resolveResult struct { + PendingID string `json:"pending_id"` + Decision string `json:"decision"` + Resolved bool `json:"resolved"` +} + +const resolveLong = `Resolve a pending tool approval raised during an agent run. + +When a server-side agent run (weknora session ask) needs to call a tool +that requires approval, the stream emits a tool-approval event carrying a +pending id and the run blocks. This command unblocks it: approve (default) +lets the tool call execute, --reject cancels it. After resolving, resume +the answer with weknora session continue-stream. + +--modified-args replaces the tool call arguments on approve (JSON object). +It conflicts with --reject (rejected calls never execute). + +AI agents: approving a tool call is the human-approval step itself — that +is exactly what the exit-10 protocol guards. Without -y/--yes the CLI +exits 10 and writes input.confirmation_required to stderr. Surface the +pending tool call to the user and only pass -y after their explicit +go-ahead.` + +// NewCmdToolApproval builds the `session tool-approval` subtree. +func NewCmdToolApproval(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "tool-approval", + Short: "Resolve pending tool approvals from agent runs", + } + cmd.AddCommand(newCmdResolve(f)) + return cmd +} + +func newCmdResolve(f *cmdutil.Factory) *cobra.Command { + opts := &ResolveOptions{} + cmd := &cobra.Command{ + Use: "resolve <pending-id>", + Short: "Approve or reject a pending tool call (high-risk write)", + Long: resolveLong, + Example: ` weknora session tool-approval resolve pend_abc -y # approve + weknora session tool-approval resolve pend_abc --reject --reason "wrong target" -y + weknora session tool-approval resolve pend_abc -y --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()) + opts.PendingID = args[0] + opts.Yes, _ = c.Flags().GetBool("yes") + // Validate inputs BEFORE the dry-run gate so --dry-run previews a + // valid action — an invalid --modified-args (e.g. "{}") must error + // the same way whether or not --dry-run is passed. + if _, err := validateModifiedArgs(opts); err != nil { + return err + } + dryRunArgs := map[string]any{ + "pending_id": opts.PendingID, + "decision": decisionOf(opts.Reject), + "reason": opts.Reason, + } + if opts.ModifiedArgs != "" { + dryRunArgs["modified_args"] = opts.ModifiedArgs + } + if handled, err := cmdutil.HandleDryRun(c, opts.DryRun, cmdutil.DryRunPlan{ + Action: "session.tool_approval.resolve", + Args: dryRunArgs, + }); handled { + return err + } + cli, err := f.Client() + if err != nil { + return err + } + return runResolve(c.Context(), opts, fopts, cli, f.Prompter()) + }, + } + cmd.Flags().BoolVar(&opts.Reject, "reject", false, "Reject the pending tool call instead of approving") + cmd.Flags().StringVar(&opts.Reason, "reason", "", "Reason recorded with the decision") + cmd.Flags().StringVar(&opts.ModifiedArgs, "modified-args", "", "Replace tool call arguments on approve (JSON object; conflicts with --reject)") + cmdutil.AddFormatFlag(cmd, resolveFields...) + cmdutil.AddDryRunFlag(cmd, &opts.DryRun) + cmdutil.SetRisk(cmd, "session.tool_approval.resolve") + cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{ + UsedFor: "approve or reject a pending tool call from an agent run; then resume with session continue-stream", + RequiredFlags: []string{"<pending-id> (positional)"}, + Examples: []string{ + "weknora session tool-approval resolve pend_abc -y", + "weknora session tool-approval resolve pend_abc --reject -y", + }, + Output: "envelope.data is {pending_id, decision (approve|reject), resolved:true}", + Warnings: []string{ + "Requires explicit user approval (exit 10 / input.confirmation_required); never auto-add -y.", + "Approving executes the blocked tool call with the user's authority — show the user what tool/args are pending before resolving.", + }, + }) + return cmd +} + +func decisionOf(reject bool) string { + if reject { + return decisionReject + } + return decisionApprove +} + +// decisionPastTense returns the past tense of the decision verb for human output. +func decisionPastTense(decision string) string { + switch decision { + case decisionReject: + return "Rejected" + default: // decisionApprove + return "Approved" + } +} + +// retryArgOf returns the extra --reject flag element to include in the retry +// argv when the decision is reject; nil otherwise. +func retryArgOf(reject bool) []string { + if reject { + return []string{"--reject"} + } + return nil +} + +// validateModifiedArgs checks the --reject/--modified-args invariants and +// returns the validated raw JSON (nil when --modified-args is unset). Shared +// by the RunE pre-dry-run gate and runResolve so both paths reject the same +// inputs identically. +func validateModifiedArgs(opts *ResolveOptions) (json.RawMessage, error) { + if opts.Reject && opts.ModifiedArgs != "" { + return nil, &cmdutil.Error{ + Code: cmdutil.CodeInputInvalidArgument, + Message: "--modified-args conflicts with --reject (rejected calls never execute)", + } + } + if opts.ModifiedArgs == "" { + return nil, nil + } + var probe map[string]any + if err := json.Unmarshal([]byte(opts.ModifiedArgs), &probe); err != nil || probe == nil { + return nil, &cmdutil.Error{ + Code: cmdutil.CodeInputInvalidArgument, + Message: "--modified-args must be a non-null JSON object", + } + } + if len(probe) == 0 { + return nil, &cmdutil.Error{ + Code: cmdutil.CodeInputInvalidArgument, + Message: "--modified-args must not be an empty object (it would wipe the original tool arguments); omit the flag to keep them", + } + } + return json.RawMessage(opts.ModifiedArgs), nil +} + +func runResolve(ctx context.Context, opts *ResolveOptions, fopts *cmdutil.FormatOptions, svc ResolveService, p prompt.Prompter) error { + modified, err := validateModifiedArgs(opts) + if err != nil { + return err + } + decision := decisionOf(opts.Reject) + retryCmd := append([]string{"weknora", "session", "tool-approval", "resolve", opts.PendingID}, retryArgOf(opts.Reject)...) + retryCmd = append(retryCmd, "-y") + if err := cmdutil.ConfirmDestructive(p, opts.Yes, fopts.WantsJSON(), decision, "tool call", opts.PendingID, "session.tool_approval.resolve", retryCmd); err != nil { + return err + } + if err := svc.ResolveToolApproval(ctx, opts.PendingID, &sdk.ResolveToolApprovalRequest{ + Decision: decision, + Reason: opts.Reason, + ModifiedArgs: modified, + }); err != nil { + return cmdutil.WrapHTTP(err, "resolve tool approval %s", opts.PendingID) + } + if fopts.WantsJSON() { + return fopts.Emit(iostreams.IO.Out, resolveResult{PendingID: opts.PendingID, Decision: decision, Resolved: true}, nil) + } + fmt.Fprintf(iostreams.IO.Out, "✓ %s tool call %s\n", decisionPastTense(decision), opts.PendingID) + return nil +} diff --git a/cli/cmd/session/tool_approval_test.go b/cli/cmd/session/tool_approval_test.go new file mode 100644 index 00000000..12c33b07 --- /dev/null +++ b/cli/cmd/session/tool_approval_test.go @@ -0,0 +1,184 @@ +package sessioncmd + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/Tencent/WeKnora/cli/internal/cmdutil" + "github.com/Tencent/WeKnora/cli/internal/iostreams" + "github.com/Tencent/WeKnora/cli/internal/testutil" + sdk "github.com/Tencent/WeKnora/client" +) + +type fakeResolveSvc struct { + err error + gotPendingID string + gotReq *sdk.ResolveToolApprovalRequest +} + +func (s *fakeResolveSvc) ResolveToolApproval(_ context.Context, pendingID string, req *sdk.ResolveToolApprovalRequest) error { + s.gotPendingID, s.gotReq = pendingID, req + return s.err +} + +func TestRunResolve_ApproveWithYes(t *testing.T) { + out, _ := iostreams.SetForTest(t) + svc := &fakeResolveSvc{} + opts := &ResolveOptions{PendingID: "p1", Yes: true} + require.NoError(t, runResolve(context.Background(), opts, jsonOpts(), svc, &testutil.ConfirmPrompter{})) + assert.Equal(t, "p1", svc.gotPendingID) + assert.Equal(t, "approve", svc.gotReq.Decision) + assert.Contains(t, out.String(), `"decision":"approve"`) +} + +func TestRunResolve_ApproveWithYes_ConfirmationRequiredCode(t *testing.T) { + _, _ = iostreams.SetForTest(t) + svc := &fakeResolveSvc{} + // Verify the "no-yes" path yields CodeInputConfirmationRequired. + err := runResolve(context.Background(), &ResolveOptions{PendingID: "p1", Yes: false}, jsonOpts(), svc, &testutil.ConfirmPrompter{}) + require.Error(t, err) + var typed *cmdutil.Error + require.ErrorAs(t, err, &typed) + assert.Equal(t, cmdutil.CodeInputConfirmationRequired, typed.Code) + assert.Empty(t, svc.gotPendingID, "SDK must not be called without confirmation") +} + +func TestRunResolve_RejectWithReason(t *testing.T) { + _, _ = iostreams.SetForTest(t) + svc := &fakeResolveSvc{} + opts := &ResolveOptions{PendingID: "p1", Reject: true, Reason: "wrong target", Yes: true} + require.NoError(t, runResolve(context.Background(), opts, jsonOpts(), svc, &testutil.ConfirmPrompter{})) + assert.Equal(t, "reject", svc.gotReq.Decision) + assert.Equal(t, "wrong target", svc.gotReq.Reason) +} + +func TestRunResolve_NoYesIsConfirmationRequired(t *testing.T) { + _, _ = iostreams.SetForTest(t) + svc := &fakeResolveSvc{} + err := runResolve(context.Background(), &ResolveOptions{PendingID: "p1"}, jsonOpts(), svc, &testutil.ConfirmPrompter{}) + require.Error(t, err) + var typed *cmdutil.Error + require.ErrorAs(t, err, &typed) + assert.Equal(t, cmdutil.CodeInputConfirmationRequired, typed.Code) + assert.Empty(t, svc.gotPendingID, "SDK must not be called without confirmation") +} + +func TestRunResolve_ModifiedArgsMustBeJSONObject(t *testing.T) { + _, _ = iostreams.SetForTest(t) + err := runResolve(context.Background(), &ResolveOptions{PendingID: "p1", ModifiedArgs: `["not","an","object"]`, Yes: true}, jsonOpts(), &fakeResolveSvc{}, &testutil.ConfirmPrompter{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "JSON object") + var typed *cmdutil.Error + require.ErrorAs(t, err, &typed) + assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code) +} + +func TestRunResolve_RejectAndModifiedArgsConflict(t *testing.T) { + _, _ = iostreams.SetForTest(t) + err := runResolve(context.Background(), &ResolveOptions{PendingID: "p1", Reject: true, ModifiedArgs: `{"a":1}`, Yes: true}, jsonOpts(), &fakeResolveSvc{}, &testutil.ConfirmPrompter{}) + require.Error(t, err) + var typed *cmdutil.Error + require.ErrorAs(t, err, &typed) + assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code) +} + +// TestRunResolve_TTY_ConfirmYes verifies that on a TTY with Answer=true the SDK is called. +func TestRunResolve_TTY_ConfirmYes(t *testing.T) { + out, _ := iostreams.SetForTestWithTTY(t) + svc := &fakeResolveSvc{} + p := &testutil.ConfirmPrompter{Answer: true} + require.NoError(t, runResolve(context.Background(), &ResolveOptions{PendingID: "p1"}, textOpts(), svc, p)) + assert.True(t, p.Asked) + assert.Equal(t, "p1", svc.gotPendingID) + assert.Contains(t, out.String(), "Approved") +} + +// TestRunResolve_TTY_ConfirmNo verifies that on a TTY with Answer=false the SDK is NOT called. +func TestRunResolve_TTY_ConfirmNo(t *testing.T) { + _, errBuf := iostreams.SetForTestWithTTY(t) + svc := &fakeResolveSvc{} + p := &testutil.ConfirmPrompter{Answer: false} + err := runResolve(context.Background(), &ResolveOptions{PendingID: "p1"}, textOpts(), svc, p) + require.Error(t, err) + var typed *cmdutil.Error + require.ErrorAs(t, err, &typed) + assert.Equal(t, cmdutil.CodeUserAborted, typed.Code) + assert.Empty(t, svc.gotPendingID, "answer=false must not call SDK") + assert.Contains(t, errBuf.String(), "Aborted") +} + +// TestRunResolve_ServiceError_Propagates verifies that SDK errors are wrapped and propagated. +func TestRunResolve_ServiceError_Propagates(t *testing.T) { + _, _ = iostreams.SetForTest(t) + svc := &fakeResolveSvc{err: errors.New("HTTP error 404: not found")} + err := runResolve(context.Background(), &ResolveOptions{PendingID: "p1", Yes: true}, jsonOpts(), svc, &testutil.ConfirmPrompter{}) + require.Error(t, err) + var typed *cmdutil.Error + require.ErrorAs(t, err, &typed) + assert.Equal(t, cmdutil.CodeResourceNotFound, typed.Code) +} + +// TestRunResolve_ModifiedArgs_LegalObject verifies that a valid JSON object is passed through to the SDK. +func TestRunResolve_ModifiedArgs_LegalObject(t *testing.T) { + _, _ = iostreams.SetForTest(t) + svc := &fakeResolveSvc{} + opts := &ResolveOptions{PendingID: "p1", ModifiedArgs: `{"key":"val"}`, Yes: true} + require.NoError(t, runResolve(context.Background(), opts, jsonOpts(), svc, &testutil.ConfirmPrompter{})) + require.NotNil(t, svc.gotReq.ModifiedArgs) + var m map[string]any + require.NoError(t, json.Unmarshal(svc.gotReq.ModifiedArgs, &m)) + assert.Equal(t, "val", m["key"]) +} + +// TestRunResolve_ModifiedArgsEmptyObjectRejected verifies that {} is rejected before the SDK is called, +// because an empty object would silently wipe the original tool arguments (downstream executor uses len>0 to decide replacement). +func TestRunResolve_ModifiedArgsEmptyObjectRejected(t *testing.T) { + _, _ = iostreams.SetForTest(t) + svc := &fakeResolveSvc{} + opts := &ResolveOptions{PendingID: "p1", ModifiedArgs: `{}`, Yes: true} + err := runResolve(context.Background(), opts, jsonOpts(), svc, &testutil.ConfirmPrompter{}) + require.Error(t, err) + var typed *cmdutil.Error + require.ErrorAs(t, err, &typed) + assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code) + assert.Contains(t, typed.Message, "empty object") + assert.Empty(t, svc.gotPendingID, "SDK must not be called when --modified-args is an empty object") +} + +// TestValidateModifiedArgs covers the shared input validator that now runs +// before the dry-run gate (so --dry-run --modified-args '{}' errors instead +// of previewing an arg-wiping action). Each case mirrors a runResolve guard. +func TestValidateModifiedArgs(t *testing.T) { + cases := []struct { + name string + opts *ResolveOptions + wantErr string // substring; "" means expect success + }{ + {"unset is ok", &ResolveOptions{}, ""}, + {"valid object", &ResolveOptions{ModifiedArgs: `{"path":"/x"}`}, ""}, + {"empty object rejected", &ResolveOptions{ModifiedArgs: `{}`}, "empty object"}, + {"array rejected", &ResolveOptions{ModifiedArgs: `[1,2]`}, "non-null JSON object"}, + {"null rejected", &ResolveOptions{ModifiedArgs: `null`}, "non-null JSON object"}, + {"reject+modified conflict", &ResolveOptions{Reject: true, ModifiedArgs: `{"a":1}`}, "conflicts with --reject"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := validateModifiedArgs(tc.opts) + if tc.wantErr == "" { + require.NoError(t, err) + return + } + require.Error(t, err) + var typed *cmdutil.Error + require.ErrorAs(t, err, &typed) + assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code) + assert.Contains(t, typed.Message, tc.wantErr) + assert.Nil(t, got) + }) + } +} diff --git a/cli/cmd/session/view.go b/cli/cmd/session/view.go index a82d6d3a..0681fc24 100644 --- a/cli/cmd/session/view.go +++ b/cli/cmd/session/view.go @@ -70,6 +70,11 @@ Pass --full to also load the chat history (LoadMessages SDK call). Use return err } fopts.ResolveDefault(iostreams.IO.IsStdoutTTY()) + // Validate static input before building the client so a bad --limit + // returns input.invalid_argument (exit 5), not an auth error (exit 3). + if err := validateViewOpts(opts); err != nil { + return err + } cli, err := f.Client() if err != nil { return err @@ -89,7 +94,10 @@ Pass --full to also load the chat history (LoadMessages SDK call). Use return cmd } -func runView(ctx context.Context, opts *ViewOptions, fopts *cmdutil.FormatOptions, svc ViewService, id string) error { +// validateViewOpts checks the --limit/--full invariants. Called from RunE +// before the client is built (so a bad value surfaces as exit 5, not an auth +// error) and at runView's top for direct callers; idempotent. +func validateViewOpts(opts *ViewOptions) error { if !opts.Full && opts.LimitSet { return &cmdutil.Error{ Code: cmdutil.CodeInputInvalidArgument, @@ -104,6 +112,13 @@ func runView(ctx context.Context, opts *ViewOptions, fopts *cmdutil.FormatOption } } } + return nil +} + +func runView(ctx context.Context, opts *ViewOptions, fopts *cmdutil.FormatOptions, svc ViewService, id string) error { + if err := validateViewOpts(opts); err != nil { + return err + } s, err := svc.GetSession(ctx, id) if err != nil { diff --git a/cli/cmd/skills/skills.go b/cli/cmd/skills/skills.go new file mode 100644 index 00000000..883f7649 --- /dev/null +++ b/cli/cmd/skills/skills.go @@ -0,0 +1,191 @@ +// Package skillscmd holds the `weknora skills` command tree: list the bundled +// Agent Skills and install (write) them to an agent's skills directory. The +// skill markdown is embedded in the binary (see cli/skills), so install works +// without a source checkout — replacing the manual symlink-from-checkout MVP. +// +// Package name `skillscmd` avoids colliding with the embed package `skills`. +package skillscmd + +import ( + "fmt" + "os" + "path/filepath" + "text/tabwriter" + + "github.com/spf13/cobra" + + "github.com/Tencent/WeKnora/cli/internal/cmdutil" + "github.com/Tencent/WeKnora/cli/internal/iostreams" + "github.com/Tencent/WeKnora/cli/internal/output" + skillsfs "github.com/Tencent/WeKnora/cli/skills" +) + +// NewCmd builds the `weknora skills` parent command. +func NewCmd(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "skills", + Short: "List and install the bundled Agent Skills", + } + cmd.AddCommand(newCmdList()) + cmd.AddCommand(newCmdInstall()) + return cmd +} + +var skillsListFields = []string{"name", "description", "files"} + +func newCmdList() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List the Agent Skills bundled in this binary", + Args: cobra.NoArgs, + RunE: func(c *cobra.Command, _ []string) error { + fopts, err := cmdutil.CheckFormatFlag(c) + if err != nil { + return err + } + fopts.ResolveDefault(iostreams.IO.IsStdoutTTY()) + skills, err := skillsfs.List() + if err != nil { + return cmdutil.Wrapf(cmdutil.CodeInternalError, err, "read embedded skills") + } + if fopts.WantsJSON() { + return fopts.Emit(iostreams.IO.Out, skills, &output.Meta{Count: output.IntPtr(len(skills))}) + } + tw := tabwriter.NewWriter(iostreams.IO.Out, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "NAME\tFILES\tDESCRIPTION") + for _, s := range skills { + fmt.Fprintf(tw, "%s\t%d\t%s\n", s.Name, len(s.Files), s.Description) + } + return tw.Flush() + }, + } + cmdutil.AddFormatFlag(cmd, skillsListFields...) + cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{ + UsedFor: "list the Agent Skills bundled in this CLI binary (name, description, file count) — discover what `weknora skills install` would write", + Examples: []string{"weknora skills list --format json"}, + Output: "envelope.data is an array of {name, description, files[]}", + }) + return cmd +} + +type installResult struct { + Dir string `json:"dir"` + Installed []string `json:"installed"` + Skills []string `json:"skills"` +} + +func newCmdInstall() *cobra.Command { + var dir string + var force bool + var dryRun bool + cmd := &cobra.Command{ + Use: "install", + Short: "Write the bundled Agent Skills to an agent's skills directory", + Long: `Write the embedded Agent Skills to disk so an agent (e.g. Claude Code) can +load them. Defaults to ~/.claude/skills; override with --dir for other agents. +Existing files are left untouched unless --force is passed.`, + Args: cobra.NoArgs, + RunE: func(c *cobra.Command, _ []string) error { + fopts, err := cmdutil.CheckFormatFlag(c) + if err != nil { + return err + } + fopts.ResolveDefault(iostreams.IO.IsStdoutTTY()) + + target, err := resolveDir(dir) + if err != nil { + return err + } + skills, err := skillsfs.List() + if err != nil { + return cmdutil.Wrapf(cmdutil.CodeInternalError, err, "read embedded skills") + } + + var planned []string + names := make([]string, 0, len(skills)) + for _, s := range skills { + names = append(names, s.Name) + for _, rel := range s.Files { + planned = append(planned, filepath.Join(target, rel)) + } + } + + if handled, err := cmdutil.HandleDryRun(c, dryRun, cmdutil.DryRunPlan{ + Action: "skills.install", + Args: map[string]any{ + "dir": target, + "skills": names, + "files": planned, + }, + }); handled { + return err + } + + installed, err := writeSkills(skills, target, force) + if err != nil { + return err + } + res := installResult{Dir: target, Installed: installed, Skills: names} + if fopts.WantsJSON() { + return fopts.Emit(iostreams.IO.Out, res, nil) + } + fmt.Fprintf(iostreams.IO.Out, "✓ Installed %d skill(s) to %s\n", len(names), target) + for _, p := range installed { + fmt.Fprintf(iostreams.IO.Out, " %s\n", p) + } + return nil + }, + } + cmd.Flags().StringVar(&dir, "dir", "", "Target skills directory (default: ~/.claude/skills)") + cmd.Flags().BoolVar(&force, "force", false, "Overwrite existing skill files") + cmdutil.AddFormatFlag(cmd, "dir", "installed", "skills") + cmdutil.AddDryRunFlag(cmd, &dryRun) + cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{ + UsedFor: "write the bundled Agent Skills to an agent's skills directory (default ~/.claude/skills) so the agent can load them; --dry-run previews the file list", + Examples: []string{"weknora skills install --dry-run --format json", "weknora skills install --dir ~/.claude/skills --force"}, + Output: "envelope.data is {dir, installed:[paths], skills:[names]}", + }) + return cmd +} + +// resolveDir returns the explicit --dir or the default ~/.claude/skills. +func resolveDir(dir string) (string, error) { + if dir != "" { + return dir, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", cmdutil.NewError(cmdutil.CodeInputInvalidArgument, + "could not determine home directory; pass --dir explicitly") + } + return filepath.Join(home, ".claude", "skills"), nil +} + +// writeSkills writes every embedded skill file under target, creating parent +// dirs. Without --force, an existing file is skipped (not overwritten). Returns +// the paths actually written. +func writeSkills(skills []skillsfs.Skill, target string, force bool) ([]string, error) { + var written []string + for _, s := range skills { + for _, rel := range s.Files { + dst := filepath.Join(target, rel) + if !force { + if _, err := os.Stat(dst); err == nil { + continue // exists; leave it + } + } + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return written, cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "create dir for %s", dst) + } + b, err := skillsfs.ReadFile(rel) + if err != nil { + return written, cmdutil.Wrapf(cmdutil.CodeInternalError, err, "read embedded %s", rel) + } + if err := os.WriteFile(dst, b, 0o644); err != nil { + return written, cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "write %s", dst) + } + written = append(written, dst) + } + } + return written, nil +} diff --git a/cli/cmd/skills/skills_test.go b/cli/cmd/skills/skills_test.go new file mode 100644 index 00000000..79e86d3e --- /dev/null +++ b/cli/cmd/skills/skills_test.go @@ -0,0 +1,75 @@ +package skillscmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + skillsfs "github.com/Tencent/WeKnora/cli/skills" +) + +// TestEmbeddedSkillsPresent: the binary embeds the bundled skills with their +// SKILL.md (the parity tests guard the command vocabulary; this guards the +// embed itself stays wired). +func TestEmbeddedSkillsPresent(t *testing.T) { + skills, err := skillsfs.List() + require.NoError(t, err) + require.NotEmpty(t, skills, "at least one skill must be embedded") + for _, s := range skills { + assert.NotEmpty(t, s.Files, "%s must embed files", s.Name) + var hasMD bool + for _, f := range s.Files { + if filepath.Base(f) == "SKILL.md" { + hasMD = true + } + } + assert.True(t, hasMD, "%s must embed a SKILL.md", s.Name) + } +} + +// TestWriteSkills_WritesAndIsIdempotent: install writes every embedded file +// preserving the dir structure; a second run without --force overwrites +// nothing (returns no newly-written paths); --force rewrites them. +func TestWriteSkills_WritesAndIsIdempotent(t *testing.T) { + dir := t.TempDir() + skills, err := skillsfs.List() + require.NoError(t, err) + + // First install: every file written. + written, err := writeSkills(skills, dir, false) + require.NoError(t, err) + var total int + for _, s := range skills { + total += len(s.Files) + } + assert.Len(t, written, total, "first install writes every embedded file") + for _, p := range written { + _, statErr := os.Stat(p) + assert.NoError(t, statErr, "written file must exist: %s", p) + } + + // Second install without --force: nothing overwritten. + again, err := writeSkills(skills, dir, false) + require.NoError(t, err) + assert.Empty(t, again, "without --force, existing files are left untouched") + + // With --force: all rewritten. + forced, err := writeSkills(skills, dir, true) + require.NoError(t, err) + assert.Len(t, forced, total, "--force rewrites every file") +} + +// TestResolveDir: explicit --dir wins; empty falls back to ~/.claude/skills. +func TestResolveDir(t *testing.T) { + got, err := resolveDir("/custom/path") + require.NoError(t, err) + assert.Equal(t, "/custom/path", got) + + def, err := resolveDir("") + require.NoError(t, err) + assert.True(t, filepath.IsAbs(def), "default dir must be absolute") + assert.Contains(t, def, filepath.Join(".claude", "skills")) +} diff --git a/cli/internal/cmdutil/agenthelp.go b/cli/internal/cmdutil/agenthelp.go index 5eafb4dc..7eb65443 100644 --- a/cli/internal/cmdutil/agenthelp.go +++ b/cli/internal/cmdutil/agenthelp.go @@ -31,6 +31,12 @@ type AgentHelp struct { Warnings []string `json:"warnings,omitempty"` } +// agentHelpAnnotation stores the marshaled AgentHelp on the command so the +// `weknora schema` command can introspect any command's contract without +// invoking its help func (and toggling WEKNORA_AGENT_HELP). Shares the +// annotations map with SetRisk (distinct keys). +const agentHelpAnnotation = "weknora.agent_help" + // SetAgentHelp attaches agent-targeted help metadata to a command. // // Routing: @@ -39,7 +45,17 @@ type AgentHelp struct { // - Otherwise (human help path): if cmd has risk annotations from SetRisk, // prepend "Risk: <action> (<level>)" line; then render the normal human // help via origHelp; then append the "AI agents:" Warnings block. +// +// The AgentHelp is also recorded as a JSON annotation so `weknora schema` can +// surface it as a first-class, discoverable command (not only via the +// WEKNORA_AGENT_HELP env toggle on --help). func SetAgentHelp(cmd *cobra.Command, ah AgentHelp) { + if b, err := json.Marshal(ah); err == nil { + if cmd.Annotations == nil { + cmd.Annotations = make(map[string]string) + } + cmd.Annotations[agentHelpAnnotation] = string(b) + } origHelp := cmd.HelpFunc() cmd.SetHelpFunc(func(c *cobra.Command, args []string) { if os.Getenv("WEKNORA_AGENT_HELP") == "1" { @@ -64,6 +80,22 @@ func SetAgentHelp(cmd *cobra.Command, ah AgentHelp) { }) } +// AgentHelpFor returns the AgentHelp registered on cmd via SetAgentHelp. +// ok is false when the command carries none (e.g. cobra-generated subtrees). +func AgentHelpFor(cmd *cobra.Command) (ah AgentHelp, ok bool) { + if cmd.Annotations == nil { + return ah, false + } + raw, present := cmd.Annotations[agentHelpAnnotation] + if !present { + return ah, false + } + if err := json.Unmarshal([]byte(raw), &ah); err != nil { + return ah, false + } + return ah, true +} + func emitAgentHelp(w io.Writer, ah AgentHelp) { enc := json.NewEncoder(w) enc.SetEscapeHTML(false) diff --git a/cli/internal/cmdutil/batch_test.go b/cli/internal/cmdutil/batch_test.go index d87b226a..b832072d 100644 --- a/cli/internal/cmdutil/batch_test.go +++ b/cli/internal/cmdutil/batch_test.go @@ -70,6 +70,39 @@ func TestRunBatch_PartialFailure(t *testing.T) { } } +// TestRunBatch_StatusExitTriState verifies the batch tri-state exit mapping: +// all-success → exit 0 (nil summaryErr), partial → exit 1, all-fail → exit 1 +// (operation.failed fall-through). Pairs with the envelope-status tri-state in +// output.TestWriteBatchEnvelope_StatusTriState. +func TestRunBatch_StatusExitTriState(t *testing.T) { + failIf := func(fails map[string]bool) func(context.Context, string) error { + return func(_ context.Context, id string) error { + if fails[id] { + return errors.New("boom") + } + return nil + } + } + cases := []struct { + name string + ids []string + fails map[string]bool + wantExit int + }{ + {"all_success", []string{"a", "b"}, nil, 0}, + {"partial", []string{"a", "b"}, map[string]bool{"b": true}, 1}, + {"all_fail", []string{"a", "b"}, map[string]bool{"a": true, "b": true}, 1}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, summaryErr := RunBatch(context.Background(), tc.ids, failIf(tc.fails)) + if got := ExitCode(summaryErr); got != tc.wantExit { + t.Errorf("ExitCode = %d, want %d (summaryErr=%v)", got, tc.wantExit, summaryErr) + } + }) + } +} + // TestRunBatch_ContextCancellation verifies that once the context is cancelled, // remaining ids are marked with the context error without calling op. func TestRunBatch_ContextCancellation(t *testing.T) { diff --git a/cli/internal/cmdutil/charcheck.go b/cli/internal/cmdutil/charcheck.go new file mode 100644 index 00000000..92a820ff --- /dev/null +++ b/cli/internal/cmdutil/charcheck.go @@ -0,0 +1,113 @@ +package cmdutil + +import ( + "fmt" + "strings" + + "github.com/spf13/pflag" +) + +// unsafeRune reports whether r is a control / formatting character that has no +// place in a CLI-supplied value: C0 control codes (except the ordinary +// whitespace \t \n \r), DEL, Unicode Bidi overrides/isolates, and zero-width +// characters. +// +// These are rejected because CLI arguments are untrusted — in an agent-first +// CLI they frequently originate from an AI agent or upstream content. A +// smuggled ANSI escape (ESC, U+001B) or Bidi override stored in a resource +// name would execute in, or visually mislead, a human's terminal the moment +// the value is rendered by a later `list` / `view`. Blocking at the input +// boundary mirrors the input-hygiene stance of other agent-first CLIs. +func unsafeRune(r rune) bool { + switch { + case r == '\t' || r == '\n' || r == '\r': + return false // ordinary whitespace is allowed (multi-line --text etc.) + case r < 0x20 || r == 0x7f: + return true // C0 controls (includes ESC U+001B) and DEL + case r >= 0x202a && r <= 0x202e: + return true // LRE RLE PDF LRO RLO — Bidi overrides + case r >= 0x2066 && r <= 0x2069: + return true // LRI RLI FSI PDI — Bidi isolates + case r == 0x200b || r == 0x200c || r == 0x200d || r == 0xfeff: + return true // zero-width space / non-joiner / joiner / BOM + } + return false +} + +// CheckSafeText returns an input.invalid_argument error if value contains a +// control / Bidi / zero-width character (see unsafeRune). label names the +// offending input so an agent can fix the right argument. Returns nil for +// ordinary text (including multi-line content with \t \n \r). +func CheckSafeText(value, label string) error { + for i, r := range value { + if unsafeRune(r) { + return &Error{ + Code: CodeInputInvalidArgument, + Message: fmt.Sprintf( + "%s contains a disallowed control character (U+%04X at byte %d); strip control / ANSI-escape / Bidi / zero-width characters", + label, r, i, + ), + } + } + } + return nil +} + +// CheckSafeArgs is the command-boundary input gate, hooked into the root +// PersistentPreRunE so every command inherits it without per-command wiring. +// It enforces two independent rules, each in its own helper so the rule is +// discoverable: +// +// - checkNonEmptyArgs: every positional argument must be non-empty. +// - CheckSafeText: no positional argument or explicitly-set flag value may +// carry control / ANSI / Bidi / zero-width characters. +// +// Default (unchanged) flag values are not scanned — only what the caller +// actually supplied. +func CheckSafeArgs(args []string, flags *pflag.FlagSet) error { + if err := checkNonEmptyArgs(args); err != nil { + return err + } + for i, a := range args { + if err := CheckSafeText(a, fmt.Sprintf("argument %d", i+1)); err != nil { + return err + } + } + if flags == nil { + return nil + } + var ferr error + flags.Visit(func(f *pflag.Flag) { + if ferr == nil { + ferr = CheckSafeText(f.Value.String(), "--"+f.Name) + } + }) + return ferr +} + +// checkNonEmptyArgs rejects empty / whitespace-only positional arguments before +// any command logic runs — including the dry-run and exit-10 confirmation gates. +// Every positional in this CLI is an id, name, query, or path; none is +// meaningfully empty. The common agent trigger is capturing an id from a failed +// prior step (KB=$(weknora kb create … --jq .data.id) when the create failed → +// ""), which otherwise reaches the server as an empty path segment: GET/DELETE +// /resource/ falls through to the LIST route, the array response fails to +// unmarshal into a single-object struct, and the failure is mis-reported as +// network.error ("check base URL"). Worse, an empty-id delete reaches the +// confirmation gate with a malformed `weknora kb delete -y` retry_argv. +// Caught here as a clear input.invalid_argument (exit 5) instead. +// +// This is intentionally a global invariant (no command exempts itself); flag +// VALUES are not covered, so `--description ""` still legitimately clears a field. +func checkNonEmptyArgs(args []string) error { + for i, a := range args { + if strings.TrimSpace(a) == "" { + return &Error{ + Code: CodeInputInvalidArgument, + Message: fmt.Sprintf("argument %d is empty", i+1), + Hint: "an empty id / name / query is never valid — verify the value you captured (e.g. from a prior --jq) is non-empty", + } + } + } + return nil +} diff --git a/cli/internal/cmdutil/charcheck_test.go b/cli/internal/cmdutil/charcheck_test.go new file mode 100644 index 00000000..b1642618 --- /dev/null +++ b/cli/internal/cmdutil/charcheck_test.go @@ -0,0 +1,111 @@ +package cmdutil + +import ( + "errors" + "testing" + + "github.com/spf13/pflag" +) + +func TestCheckSafeText(t *testing.T) { + cases := []struct { + name string + value string + wantErr bool + }{ + {"plain ascii", "my-knowledge-base", false}, + {"unicode letters", "测试知识库 café", false}, + {"emoji", "docs 🔥", false}, + {"multiline content allowed", "line1\nline2\twith tab\r\n", false}, + {"ansi escape rejected", "inject\x1b[31mRED", true}, + {"bell rejected", "name\x07bell", true}, + {"null byte rejected", "a\x00b", true}, + {"DEL rejected", "a\x7fb", true}, + {"bidi override rejected", "abc\u202ervd", true}, + {"zero-width rejected", "ab\u200bc", true}, + {"BOM rejected", "\ufeffname", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := CheckSafeText(tc.value, "name") + if tc.wantErr && err == nil { + t.Errorf("expected rejection for %q, got nil", tc.value) + } + if !tc.wantErr && err != nil { + t.Errorf("expected %q to be allowed, got %v", tc.value, err) + } + if tc.wantErr { + var typed *Error + if !errors.As(err, &typed) || typed.Code != CodeInputInvalidArgument { + t.Errorf("want *Error with CodeInputInvalidArgument, got %v", err) + } + } + }) + } +} + +func TestCheckSafeArgs_PositionalAndFlags(t *testing.T) { + fs := pflag.NewFlagSet("t", pflag.ContinueOnError) + name := fs.String("name", "", "") + _ = fs.Parse([]string{"--name", "clean-name"}) + + // Clean positional + clean flag → ok. + if err := CheckSafeArgs([]string{"good-arg"}, fs); err != nil { + t.Errorf("clean args should pass, got %v", err) + } + + // Dirty positional arg → rejected, names the argument. + if err := CheckSafeArgs([]string{"bad\x1b[0marg"}, fs); err == nil { + t.Error("control char in positional arg must be rejected") + } + + // Dirty flag value → rejected. + *name = "x" // touch to avoid unused + fs2 := pflag.NewFlagSet("t2", pflag.ContinueOnError) + fs2.String("title", "", "") + _ = fs2.Parse([]string{"--title", "evil\x07"}) + if err := CheckSafeArgs(nil, fs2); err == nil { + t.Error("control char in flag value must be rejected") + } +} + +// TestCheckSafeArgs_RejectsEmptyPositional pins that empty / whitespace-only +// positional args are rejected as input.invalid_argument before any command +// logic — the common agent failure of capturing an id from a failed prior step +// (KB=$(... --jq .data.id) → "") must surface clearly, not reach the server as +// an empty path segment (mis-classified network.error) or a malformed +// `delete -y` confirmation. Empty FLAG values stay allowed (--description "" +// legitimately clears a field). +func TestCheckSafeArgs_RejectsEmptyPositional(t *testing.T) { + for _, arg := range []string{"", " ", "\t", "\n"} { + err := CheckSafeArgs([]string{arg}, nil) + var ce *Error + if !errors.As(err, &ce) || ce.Code != CodeInputInvalidArgument { + t.Errorf("empty positional %q must be rejected as input.invalid_argument, got %v", arg, err) + } + } + // A non-empty positional alongside an empty one still trips on the empty. + if err := CheckSafeArgs([]string{"ok", ""}, nil); err == nil { + t.Error("a later empty positional must be rejected") + } + // Empty flag value is NOT rejected (clearing a field is valid). + fs := pflag.NewFlagSet("t", pflag.ContinueOnError) + fs.String("description", "", "") + _ = fs.Parse([]string{"--description", ""}) + if err := CheckSafeArgs([]string{"id1"}, fs); err != nil { + t.Errorf("empty flag value must stay allowed, got %v", err) + } +} + +// TestCheckSafeArgs_UnchangedFlagsNotScanned pins that default (unset) flag +// values are not scanned — only what the caller supplied. +func TestCheckSafeArgs_UnchangedFlagsNotScanned(t *testing.T) { + fs := pflag.NewFlagSet("t", pflag.ContinueOnError) + // A default that (hypothetically) contains a control char must not trip + // the check when the user never set the flag. + fs.String("weird", "default\x1bvalue", "") + _ = fs.Parse(nil) + if err := CheckSafeArgs(nil, fs); err != nil { + t.Errorf("unset flag default must not be scanned, got %v", err) + } +} diff --git a/cli/internal/cmdutil/confirm.go b/cli/internal/cmdutil/confirm.go index 6e8a1d53..73243204 100644 --- a/cli/internal/cmdutil/confirm.go +++ b/cli/internal/cmdutil/confirm.go @@ -5,10 +5,33 @@ import ( "unicode" "unicode/utf8" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/Tencent/WeKnora/cli/internal/iostreams" "github.com/Tencent/WeKnora/cli/internal/prompt" ) +// BuildRetryArgv assembles the directly-executable retry argv for a +// confirmation-gated command: head (e.g. []string{"weknora","kb","update",id}) +// followed by each *changed* flag named in allow (as "--name", "value") in +// pflag's visit order, plus a trailing "-y". Flags not in allow — multi-value +// or file-based ones where a precise single argv is impractical — are skipped; +// the human reconstructs those. Replaces the per-command build*RetryCmd helpers. +func BuildRetryArgv(c *cobra.Command, head []string, allow ...string) []string { + allowed := make(map[string]struct{}, len(allow)) + for _, a := range allow { + allowed[a] = struct{}{} + } + parts := append([]string{}, head...) + c.Flags().Visit(func(f *pflag.Flag) { + if _, ok := allowed[f.Name]; ok { + parts = append(parts, "--"+f.Name, f.Value.String()) + } + }) + return append(parts, "-y") +} + // confirmCaveat returns the trailing safety note shown after the interactive // prompt, tailored to the verb. Deletes/removals are irreversible; edits // overwrite prior values but are recoverable if you know the old value. @@ -38,9 +61,10 @@ func titleFirst(s string) string { // // Pass n = total count of items about to be deleted. // action is the namespaced action verb (e.g. "doc.delete") for the risk envelope. -// retryCmd is the directly-executable retry argv (e.g. "weknora doc delete a b -y"); -// pass "" when no clean retry argv is available. -func ConfirmDestructiveBatch(p prompt.Prompter, yes, jsonOut bool, verb, what string, n int, action, retryCmd string) error { +// retryArgv is the directly-executable retry argv array +// (e.g. []string{"weknora","doc","delete","a","b","-y"}); pass nil when no +// clean retry argv is available. +func ConfirmDestructiveBatch(p prompt.Prompter, yes, jsonOut bool, verb, what string, n int, action string, retryArgv []string) error { if yes { return nil } @@ -49,7 +73,7 @@ func ConfirmDestructiveBatch(p prompt.Prompter, yes, jsonOut bool, verb, what st CodeInputConfirmationRequired, fmt.Sprintf("%s %d %s(s) requires explicit confirmation: re-run with -y/--yes", verb, n, what), ). - WithRetryCommand(retryCmd). + WithRetryArgv(retryArgv). WithRisk("destructive", action) } ok, err := p.Confirm(fmt.Sprintf("%s %d %s(s)? %s", titleFirst(verb), n, what, confirmCaveat(verb)), false) @@ -81,9 +105,33 @@ func ConfirmDestructiveBatch(p prompt.Prompter, yes, jsonOut bool, verb, what st // // `yes` should be sourced from the persistent global -y/--yes flag. // action is the namespaced action verb (e.g. "kb.delete") for the risk envelope. -// retryCmd is the directly-executable retry argv (e.g. "weknora kb delete kb_x -y"); -// pass "" when no clean retry argv is available. -func ConfirmDestructive(p prompt.Prompter, yes, jsonOut bool, verb, what, id, action, retryCmd string) error { +// retryArgv is the directly-executable retry argv array +// (e.g. []string{"weknora","kb","delete","kb_x","-y"}); pass nil when no +// clean retry argv is available. +func ConfirmDestructive(p prompt.Prompter, yes, jsonOut bool, verb, what, id, action string, retryArgv []string) error { + return confirmGated(p, yes, jsonOut, verb, what, id, action, RiskDestructive, retryArgv) +} + +// ConfirmWrite guards a reversible metadata write (kb / agent / doc update). +// Same confirmation gate as ConfirmDestructive — a labeling-accuracy variant, +// NOT a weaker gate — but tags the risk envelope at the "write" level so an +// agent can distinguish a recoverable edit from an irreversible delete. +func ConfirmWrite(p prompt.Prompter, yes, jsonOut bool, verb, what, id, action string, retryArgv []string) error { + return confirmGated(p, yes, jsonOut, verb, what, id, action, RiskWrite, retryArgv) +} + +// confirmGated is the shared single-resource confirmation gate behind +// ConfirmDestructive / ConfirmWrite. `level` is the risk.level reported on the +// exit-10 envelope (RiskDestructive for irreversible ops, RiskWrite for +// recoverable edits). Behavior matrix: +// +// yes=true → proceed (explicit user opt-in via -y/--yes) +// non-TTY OR jsonOut → CodeInputConfirmationRequired (exit 10) + risk{level,action}; +// no UI to prompt, agent/CI must re-invoke with -y after +// the human explicitly approves +// TTY + interactive → prompt; user-yes proceeds, user-no returns CodeUserAborted +// prompter error → CodeInputMissingFlag (rare; stdin closed mid-prompt) +func confirmGated(p prompt.Prompter, yes, jsonOut bool, verb, what, id, action, level string, retryArgv []string) error { if yes { return nil } @@ -92,16 +140,16 @@ func ConfirmDestructive(p prompt.Prompter, yes, jsonOut bool, verb, what, id, ac CodeInputConfirmationRequired, fmt.Sprintf("%s %s %s requires explicit confirmation: re-run with -y/--yes", verb, what, id), ). - WithRetryCommand(retryCmd). - WithRisk("destructive", action) + WithRetryArgv(retryArgv). + WithRisk(level, action) } ok, err := p.Confirm(fmt.Sprintf("%s %s %s? %s", titleFirst(verb), what, id, confirmCaveat(verb)), false) if err != nil { - return Wrapf(CodeInputMissingFlag, err, "confirm delete") + return Wrapf(CodeInputMissingFlag, err, "confirm %s", verb) } if !ok { fmt.Fprintln(iostreams.IO.Err, "Aborted.") - return NewError(CodeUserAborted, "delete aborted") + return NewError(CodeUserAborted, fmt.Sprintf("%s aborted", verb)) } return nil } diff --git a/cli/internal/cmdutil/confirm_test.go b/cli/internal/cmdutil/confirm_test.go index 9ff66110..ac16a621 100644 --- a/cli/internal/cmdutil/confirm_test.go +++ b/cli/internal/cmdutil/confirm_test.go @@ -11,7 +11,7 @@ import ( // The confirmation message verb must match the actual operation: an `edit` // must not be described as `delete`. Regression for the hardcoded-"delete" -// confirmation message that mislabeled kb/agent edits. +// confirmation message that mislabeled kb/agent updates. func TestConfirmDestructive_VerbMatchesOperation(t *testing.T) { iostreams.SetForTest(t) // non-TTY buffers ⇒ the jsonOut/non-TTY branch @@ -25,7 +25,7 @@ func TestConfirmDestructive_VerbMatchesOperation(t *testing.T) { {"remove", "current profile", "prod", "remove current profile prod requires", "delete"}, } for _, tc := range cases { - err := cmdutil.ConfirmDestructive(&testutil.ConfirmPrompter{}, false, true, tc.verb, tc.what, tc.id, tc.what+"."+tc.verb, "") + err := cmdutil.ConfirmDestructive(&testutil.ConfirmPrompter{}, false, true, tc.verb, tc.what, tc.id, tc.what+"."+tc.verb, nil) if err == nil { t.Fatalf("verb %q: expected confirmation_required error", tc.verb) } @@ -45,7 +45,7 @@ func TestConfirmDestructive_VerbMatchesOperation(t *testing.T) { // The batch flavor must likewise honor the verb. func TestConfirmDestructiveBatch_VerbMatchesOperation(t *testing.T) { iostreams.SetForTest(t) - err := cmdutil.ConfirmDestructiveBatch(&testutil.ConfirmPrompter{}, false, true, "delete", "document", 3, "doc.delete", "") + err := cmdutil.ConfirmDestructiveBatch(&testutil.ConfirmPrompter{}, false, true, "delete", "document", 3, "doc.delete", nil) if err == nil { t.Fatal("expected confirmation_required error") } diff --git a/cli/internal/cmdutil/dryrun.go b/cli/internal/cmdutil/dryrun.go index 3c4265dd..b209e18f 100644 --- a/cli/internal/cmdutil/dryrun.go +++ b/cli/internal/cmdutil/dryrun.go @@ -43,8 +43,8 @@ func AddDryRunFlag(cmd *cobra.Command, dest *bool) { } // EmitDryRun writes the dry-run envelope to w via FormatOptions.Emit, so the -// envelope inherits the standard _notice injection, TTY indent, --jq filter, -// and profile fields like every other success envelope. fopts may be nil for +// envelope inherits the standard TTY indent, --jq filter, and profile fields +// like every other success envelope. fopts may be nil for // callers that have no resolved FormatOptions (tests, error paths); a JSON-mode // default is used in that case. // diff --git a/cli/internal/cmdutil/dryrun_test.go b/cli/internal/cmdutil/dryrun_test.go index e7186d67..8e773246 100644 --- a/cli/internal/cmdutil/dryrun_test.go +++ b/cli/internal/cmdutil/dryrun_test.go @@ -10,7 +10,6 @@ import ( "github.com/stretchr/testify/require" "github.com/Tencent/WeKnora/cli/internal/iostreams" - "github.com/Tencent/WeKnora/cli/internal/output" ) func TestAddDryRunFlag_RegistersFlag(t *testing.T) { @@ -78,27 +77,6 @@ func TestEmitDryRun_ApiPlanShape(t *testing.T) { assert.Equal(t, "foo", body["name"]) } -func TestEmitDryRun_InjectsNotice(t *testing.T) { - // Regression: dry-run envelope must carry the same _notice channel as - // every other success envelope. PendingNotice is the system-level hook - // used for deprecation / version_skew / security signals. - output.PendingNotice = func() map[string]any { - return map[string]any{"deprecation": "use weknora kb create-v2"} - } - t.Cleanup(func() { output.PendingNotice = nil }) - - var buf bytes.Buffer - err := EmitDryRun(&buf, &FormatOptions{Mode: FormatJSON}, - DryRunPlan{Action: "kb.create", Args: map[string]any{"name": "x"}}) - require.NoError(t, err) - - var got map[string]any - require.NoError(t, json.Unmarshal(buf.Bytes(), &got)) - notice, ok := got["_notice"].(map[string]any) - require.True(t, ok, "expected _notice field on dry-run envelope; got %v", got) - assert.Equal(t, "use weknora kb create-v2", notice["deprecation"]) -} - func TestEmitDryRun_HonorsTTYIndent(t *testing.T) { var buf bytes.Buffer err := EmitDryRun(&buf, &FormatOptions{Mode: FormatJSON, TTY: true}, diff --git a/cli/internal/cmdutil/errors.go b/cli/internal/cmdutil/errors.go index 1c35a6db..cf74db4e 100644 --- a/cli/internal/cmdutil/errors.go +++ b/cli/internal/cmdutil/errors.go @@ -77,6 +77,13 @@ const ( // contexts so the user-visible exit follows Unix signal convention. CodeOperationCancelled ErrorCode = "operation.cancelled" + // CodeInternalError is the catch-all for an error that reached the top + // without a typed code (a bug, or an unmapped dependency error). Maps to + // exit 1 via the fall-through bucket. Documented so the type is never a + // surprise on the wire; a recurring internal.error is a classification gap + // worth fixing at the source. + CodeInternalError ErrorCode = "internal.error" + // local.* - config / file / keychain on the user's machine CodeLocalConfigCorrupt ErrorCode = "local.config_corrupt" CodeLocalKeychainDenied ErrorCode = "local.keychain_denied" @@ -117,8 +124,8 @@ type Error struct { // own output (e.g. bulk operations reporting partial-success data on // stdout) but still need to surface a non-zero exit code. Silent bool - RetryCommand string // Directly-executable argv, distinct from prose Hint - RetryAfterSeconds int // HTTP Retry-After header semantics (transport-level retry hint) + RetryArgv []string // Directly-executable argv array, distinct from prose Hint + RetryAfterSeconds int // HTTP Retry-After header semantics (transport-level retry hint) Detail any // Structured detail for envelope.error.detail (e.g. unknown-subcommand available[]) Risk *RiskInfo } @@ -148,11 +155,11 @@ func (e *Error) WithHint(hint string) *Error { return e } -// WithRetryCommand sets the directly-executable retry argv string so agents -// don't have to regex-extract argv from the prose Hint. -// Empty string for codes without a canonical retry command. -func (e *Error) WithRetryCommand(cmd string) *Error { - e.RetryCommand = cmd +// WithRetryArgv sets the directly-executable retry argv array so agents can +// exec it directly instead of regex-extracting argv from the prose Hint. +// Empty for codes without a canonical retry command. +func (e *Error) WithRetryArgv(argv []string) *Error { + e.RetryArgv = argv return e } @@ -194,8 +201,8 @@ func AsError(err error) *Error { // ErrorToDetail converts a typed cmdutil.Error (or fallback) into // output.ErrDetail for embedding in success-envelope batch items or -// MCP CallToolResult StructuredContent. Hint / RetryCommand fall back -// to defaultHint / defaultRetryCommand when typed value is empty. +// MCP CallToolResult StructuredContent. Hint / RetryArgv fall back +// to defaultHint / defaultRetryArgv when typed value is empty. // Returns nil when err is nil. func ErrorToDetail(err error) *output.ErrDetail { if err == nil { @@ -206,9 +213,9 @@ func ErrorToDetail(err error) *output.ErrDetail { if hint == "" { hint = defaultHint(typed.Code) } - retry := typed.RetryCommand - if retry == "" { - retry = defaultRetryCommand(typed.Code) + retry := typed.RetryArgv + if len(retry) == 0 { + retry = defaultRetryArgv(typed.Code) } // Build message without the code prefix — the envelope's separate // "type" field already carries the code, so repeating it in "message" @@ -222,7 +229,7 @@ func ErrorToDetail(err error) *output.ErrDetail { Type: string(typed.Code), Message: msg, Hint: hint, - RetryCommand: retry, + RetryArgv: retry, RetryAfterSeconds: typed.RetryAfterSeconds, Detail: typed.Detail, } @@ -439,6 +446,8 @@ func AllCodes() []ErrorCode { CodeSSEStreamAborted, CodeSessionCreateFailed, // operation CodeOperationTimeout, CodeOperationFailed, CodeOperationCancelled, + // internal catch-all + CodeInternalError, } } diff --git a/cli/internal/cmdutil/errors_retry_test.go b/cli/internal/cmdutil/errors_retry_test.go index 40b34e22..5ad6fcdb 100644 --- a/cli/internal/cmdutil/errors_retry_test.go +++ b/cli/internal/cmdutil/errors_retry_test.go @@ -1,6 +1,7 @@ package cmdutil import ( + "reflect" "testing" ) @@ -10,22 +11,22 @@ func TestErrorToDetail_NilSafe(t *testing.T) { } } -func TestError_WithRetryCommand(t *testing.T) { +func TestError_WithRetryArgv(t *testing.T) { err := NewError(CodeAuthUnauthenticated, "session expired"). WithHint("run `weknora auth login`"). - WithRetryCommand("weknora auth login") + WithRetryArgv([]string{"weknora", "auth", "login"}) - if err.RetryCommand != "weknora auth login" { - t.Errorf("RetryCommand not set; got %q", err.RetryCommand) + if !reflect.DeepEqual(err.RetryArgv, []string{"weknora", "auth", "login"}) { + t.Errorf("RetryArgv not set; got %v", err.RetryArgv) } if err.Hint != "run `weknora auth login`" { t.Errorf("Hint changed unexpectedly; got %q", err.Hint) } } -func TestError_RetryCommand_EmptyByDefault(t *testing.T) { +func TestError_RetryArgv_EmptyByDefault(t *testing.T) { err := NewError(CodeResourceAlreadyExists, "kb name exists") - if err.RetryCommand != "" { - t.Errorf("RetryCommand should default empty; got %q", err.RetryCommand) + if len(err.RetryArgv) != 0 { + t.Errorf("RetryArgv should default empty; got %v", err.RetryArgv) } } diff --git a/cli/internal/cmdutil/exit.go b/cli/internal/cmdutil/exit.go index 832e39bf..2f189194 100644 --- a/cli/internal/cmdutil/exit.go +++ b/cli/internal/cmdutil/exit.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "io" + "strings" "github.com/Tencent/WeKnora/cli/internal/output" ) @@ -93,7 +94,7 @@ func ExitCode(err error) int { // PrintError writes err to w (typically stderr) in dual mode: // - text: code: msg\nhint: ...\nretry: ... -// - json/ndjson: {ok:false, error:{...}, _notice?:...} +// - json/ndjson: {ok:false, error:{...}} // // Mode is read from globalFormatMode (set by root PersistentPreRunE). func PrintError(w io.Writer, err error) { @@ -126,12 +127,12 @@ func printErrorProse(w io.Writer, err error) { if hint != "" { fmt.Fprintf(w, "hint: %s\n", hint) } - retry := typed.RetryCommand - if retry == "" { - retry = defaultRetryCommand(typed.Code) + retry := typed.RetryArgv + if len(retry) == 0 { + retry = defaultRetryArgv(typed.Code) } - if retry != "" { - fmt.Fprintf(w, "retry: %s\n", retry) + if len(retry) > 0 { + fmt.Fprintf(w, "retry: %s\n", strings.Join(retry, " ")) } } } @@ -198,30 +199,58 @@ func defaultHint(code ErrorCode) string { return "" } -// defaultRetryCommand returns canonical retry argv for known codes. -// Empty string for codes without a stable canonical retry. +// defaultRetryArgv returns canonical retry argv for known codes. +// nil for codes without a stable canonical retry. // Symmetric counterpart to defaultHint. -func defaultRetryCommand(code ErrorCode) string { +func defaultRetryArgv(code ErrorCode) []string { switch code { case CodeAuthUnauthenticated, CodeAuthBadCredential, CodeAuthTokenExpired: - return "weknora auth login" + return []string{"weknora", "auth", "login"} case CodeKBIDRequired: - return "weknora link" + return []string{"weknora", "link"} case CodeNetworkError, CodeServerTimeout: - return "weknora doctor" + return []string{"weknora", "doctor"} case CodeProjectLinkCorrupt: - return "weknora link" // re-bind the project to a KB + return []string{"weknora", "link"} // re-bind the project to a KB case CodeLocalConfigCorrupt: // Recovery is two steps (delete config + re-login); the prose hint - // already spells it out, so the retry argv stays empty. - return "" + // already spells it out, so the retry argv stays nil. + return nil } - return "" + return nil } -// DefaultHint and DefaultRetryCommand are exported wrappers so that -// cross-package callers (MCP handlers, batch envelope helpers) can -// resolve hint/retry without duplicating the typed-code → string table. -// Avoids drift between cmdutil and copies elsewhere. -func DefaultHint(code ErrorCode) string { return defaultHint(code) } -func DefaultRetryCommand(code ErrorCode) string { return defaultRetryCommand(code) } +// boolPtr returns a pointer to b. Used by retryableForCode so the *bool can +// distinguish true / false / unknown(nil) on the wire. +func boolPtr(b bool) *bool { return &b } + +// retryableForCode classifies whether re-running the SAME command may succeed. +// Pointer-to-true for transient failures, pointer-to-false for deterministic +// ones, nil (omitted) when genuinely unknown — notably CodeInternalError and +// any unlisted code (do NOT default to false). +func retryableForCode(code ErrorCode) *bool { + switch code { + // Transient — retrying the same command may succeed. + case CodeServerRateLimited, CodeServerTimeout, CodeNetworkError, + CodeOperationTimeout, CodeSSEStreamAborted: + return boolPtr(true) + // CodeServerError (generic 500) is deliberately NOT here: a 500 may be a + // transient server hiccup OR a deterministic server-side bug / mislabeled + // bad request, so blindly retrying risks a loop. Left unlisted → nil + // (unknown), so the agent decides rather than reading an optimistic true. + // Deterministic — same command will fail the same way. + case CodeAuthUnauthenticated, CodeAuthTokenExpired, CodeAuthBadCredential, + CodeAuthForbidden, CodeAuthCrossTenantBlocked, CodeAuthTenantMismatch, + CodeInputInvalidArgument, CodeInputMissingFlag, + CodeInputConfirmationRequired, CodeInputUnknownSubcommand, + CodeResourceNotFound, CodeResourceAlreadyExists, CodeResourceLocked, + CodeOperationFailed, CodeOperationCancelled, CodeUserAborted, + CodeUploadFileNotFound, CodeKBIDRequired, CodeKBNotFound, + CodeProjectLinkCorrupt, CodeLocalConfigCorrupt, CodeLocalKeychainDenied, + CodeLocalFileIO, CodeLocalProfileNotFound, CodeSessionCreateFailed, + CodeServerIncompatibleVersion: + return boolPtr(false) + } + // CodeInternalError and any unlisted code: genuinely unknown → omit. + return nil +} diff --git a/cli/internal/cmdutil/exit_test.go b/cli/internal/cmdutil/exit_test.go index 9007d18b..cce1f6f5 100644 --- a/cli/internal/cmdutil/exit_test.go +++ b/cli/internal/cmdutil/exit_test.go @@ -66,7 +66,7 @@ func TestPrintError_JSONMode_WritesEnvelope(t *testing.T) { err := NewError(CodeInputConfirmationRequired, "kb delete kb_x requires confirmation"). WithHint("re-run with -y/--yes"). - WithRetryCommand("weknora kb delete kb_x -y") + WithRetryArgv([]string{"weknora", "kb", "delete", "kb_x", "-y"}) var buf bytes.Buffer PrintError(&buf, err) @@ -78,8 +78,8 @@ func TestPrintError_JSONMode_WritesEnvelope(t *testing.T) { if !strings.Contains(got, `"type":"input.confirmation_required"`) { t.Errorf("expected typed code; got %q", got) } - if !strings.Contains(got, `"retry_command":"weknora kb delete kb_x -y"`) { - t.Errorf("expected retry_command; got %q", got) + if !strings.Contains(got, `"retry_argv":["weknora","kb","delete","kb_x","-y"]`) { + t.Errorf("expected retry_argv; got %q", got) } } @@ -105,7 +105,7 @@ func TestPrintError_TextMode_WritesProse(t *testing.T) { err := NewError(CodeInputConfirmationRequired, "kb delete kb_x requires confirmation"). WithHint("re-run with -y/--yes"). - WithRetryCommand("weknora kb delete kb_x -y") + WithRetryArgv([]string{"weknora", "kb", "delete", "kb_x", "-y"}) var buf bytes.Buffer PrintError(&buf, err) diff --git a/cli/internal/cmdutil/factory.go b/cli/internal/cmdutil/factory.go index dba21519..e59ba12c 100644 --- a/cli/internal/cmdutil/factory.go +++ b/cli/internal/cmdutil/factory.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "os" + "strings" "sync" "github.com/spf13/cobra" @@ -113,6 +114,13 @@ func New() *Factory { // credentials are available so the user gets the right hint to run // `weknora auth login`. func buildClient(f *Factory) (*sdk.Client, error) { + // Env-credential injection: WEKNORA_TOKEN (bearer) or WEKNORA_API_KEY, with + // WEKNORA_HOST (or the active profile's host), builds an ephemeral client + // that bypasses config.yaml + the keyring — the stateless headless / CI / + // agent path (no disk writes, no `auth login`). + if c, handled, err := buildClientFromEnv(f); handled { + return c, err + } cfg, err := f.Config() if err != nil { return nil, err @@ -130,7 +138,7 @@ func buildClient(f *Factory) (*sdk.Client, error) { return nil, NewError(CodeInputInvalidArgument, fmt.Sprintf("profile %q not configured", profileName)). WithHint("list available profiles with `weknora profile list`"). - WithRetryCommand("weknora profile list") + WithRetryArgv([]string{"weknora", "profile", "list"}) } // ProfileOverride is empty: config.CurrentProfile points at a missing entry. // That's a genuinely corrupt config file. @@ -187,6 +195,58 @@ func buildClient(f *Factory) (*sdk.Client, error) { return sdk.NewClient(prof.Host, opts...), nil } +// EnvCredential reports whether stateless env credentials are in effect and +// which kind. Used by `auth status` / `config view` so their host / profile +// output reflects that the env credential (and WEKNORA_HOST) — not the config +// profile — is what actually authenticated the client. Mirrors buildClientFromEnv's +// precedence (WEKNORA_TOKEN wins over WEKNORA_API_KEY). +func EnvCredential() (active bool, kind string) { + if strings.TrimSpace(os.Getenv("WEKNORA_TOKEN")) != "" { + return true, "WEKNORA_TOKEN" + } + if strings.TrimSpace(os.Getenv("WEKNORA_API_KEY")) != "" { + return true, "WEKNORA_API_KEY" + } + return false, "" +} + +// buildClientFromEnv builds an ephemeral SDK client from WEKNORA_TOKEN (bearer +// JWT) or WEKNORA_API_KEY when either is set, bypassing config.yaml + the +// keyring entirely — the stateless path for headless / CI / agent use. Returns +// handled=false (fall through to the profile path) when neither var is set. +// +// Host resolution: WEKNORA_HOST, else the active profile's host (so env creds +// can target an already-configured host without re-specifying it). When a token +// is supplied, no 401→refresh transport is attached — env creds are ephemeral, +// so a 401 propagates for the caller to supply a fresh token. WEKNORA_TOKEN +// wins over WEKNORA_API_KEY if both are set. +func buildClientFromEnv(f *Factory) (client *sdk.Client, handled bool, err error) { + token := strings.TrimSpace(os.Getenv("WEKNORA_TOKEN")) + apiKey := strings.TrimSpace(os.Getenv("WEKNORA_API_KEY")) + if token == "" && apiKey == "" { + return nil, false, nil + } + host := strings.TrimSpace(os.Getenv("WEKNORA_HOST")) + if host == "" { + // Best-effort fallback to the active profile's host; ignore config + // errors so env creds + WEKNORA_HOST stay usable with no config at all. + if cfg, cerr := f.Config(); cerr == nil && cfg != nil { + if prof, ok := cfg.Profiles[cfg.CurrentProfile]; ok { + host = prof.Host + } + } + } + if host == "" { + return nil, true, NewError(CodeInputInvalidArgument, + "WEKNORA_TOKEN / WEKNORA_API_KEY is set but no host is available"). + WithHint("set WEKNORA_HOST (e.g. https://kb.example.com) or configure a profile host") + } + if token != "" { + return sdk.NewClient(host, sdk.WithBearerToken(token)), true, nil + } + return sdk.NewClient(host, sdk.WithAPIKey(apiKey)), true, nil +} + // AddKBFlag registers the standard `--kb` flag that ResolveKB reads. Use this // instead of duplicating the flag declaration in every command that scopes to // a knowledge base — one source of truth for flag name and help text. @@ -212,22 +272,7 @@ func (f *Factory) ResolveKB(cmd *cobra.Command) (string, error) { } return ResolveKBNameToID(cmd.Context(), c, v) } - if v := os.Getenv("WEKNORA_KB_ID"); v != "" { - return v, nil - } - cwd, err := os.Getwd() - if err == nil { - if path, found, derr := projectlink.Discover(cwd); derr == nil && found { - p, lerr := projectlink.Load(path) - if lerr != nil { - return "", Wrapf(CodeProjectLinkCorrupt, lerr, "read project link") - } - if p.KBID != "" { - return p.KBID, nil - } - } - } - return "", NewError(CodeKBIDRequired, "kb is required") + return f.resolveKBFromEnvOrLink() } // ResolveKBLocal mirrors ResolveKB but never calls the SDK. When --kb is a @@ -239,6 +284,14 @@ func (f *Factory) ResolveKBLocal(cmd *cobra.Command) (string, error) { if v, _ := cmd.Flags().GetString("kb"); v != "" { return v, nil } + return f.resolveKBFromEnvOrLink() +} + +// resolveKBFromEnvOrLink is the shared fallback tail of ResolveKB / +// ResolveKBLocal (which differ only in how they treat the --kb flag): it reads +// WEKNORA_KB_ID, then the walk-up .weknora/project.yaml link, and returns +// CodeKBIDRequired when neither is set. Never calls the SDK. +func (f *Factory) resolveKBFromEnvOrLink() (string, error) { if v := os.Getenv("WEKNORA_KB_ID"); v != "" { return v, nil } diff --git a/cli/internal/cmdutil/factory_test.go b/cli/internal/cmdutil/factory_test.go index c17d2230..31a00e87 100644 --- a/cli/internal/cmdutil/factory_test.go +++ b/cli/internal/cmdutil/factory_test.go @@ -415,3 +415,56 @@ func TestResolveKB_Chain(t *testing.T) { assert.Equal(t, CodeKBIDRequired, typed.Code) }) } + +// TestBuildClientFromEnv covers the stateless env-credential path: WEKNORA_TOKEN +// / WEKNORA_API_KEY build an ephemeral client with no config/keyring access. +func TestBuildClientFromEnv(t *testing.T) { + emptyCfg := &Factory{Config: func() (*config.Config, error) { return &config.Config{}, nil }} + + t.Run("no env vars falls through to profile path", func(t *testing.T) { + t.Setenv("WEKNORA_TOKEN", "") + t.Setenv("WEKNORA_API_KEY", "") + c, handled, err := buildClientFromEnv(emptyCfg) + assert.False(t, handled, "no env creds must fall through") + assert.Nil(t, c) + assert.NoError(t, err) + }) + + t.Run("api key + WEKNORA_HOST builds a client", func(t *testing.T) { + t.Setenv("WEKNORA_TOKEN", "") + t.Setenv("WEKNORA_API_KEY", "sk-test") + t.Setenv("WEKNORA_HOST", "https://kb.example.com") + c, handled, err := buildClientFromEnv(emptyCfg) + assert.True(t, handled) + require.NoError(t, err) + assert.NotNil(t, c) + }) + + t.Run("token set but no host is a typed input error", func(t *testing.T) { + t.Setenv("WEKNORA_API_KEY", "") + t.Setenv("WEKNORA_TOKEN", "jwt-token") + t.Setenv("WEKNORA_HOST", "") + c, handled, err := buildClientFromEnv(emptyCfg) + assert.True(t, handled, "env creds set → handled even on host error") + assert.Nil(t, c) + var ce *Error + require.ErrorAs(t, err, &ce) + assert.Equal(t, CodeInputInvalidArgument, ce.Code) + }) + + t.Run("host falls back to the active profile when WEKNORA_HOST unset", func(t *testing.T) { + t.Setenv("WEKNORA_API_KEY", "") + t.Setenv("WEKNORA_TOKEN", "jwt-token") + t.Setenv("WEKNORA_HOST", "") + f := &Factory{Config: func() (*config.Config, error) { + return &config.Config{ + CurrentProfile: "p", + Profiles: map[string]config.Profile{"p": {Host: "https://prof.example.com"}}, + }, nil + }} + c, handled, err := buildClientFromEnv(f) + assert.True(t, handled) + require.NoError(t, err, "should use the active profile's host") + assert.NotNil(t, c) + }) +} diff --git a/cli/internal/cmdutil/format.go b/cli/internal/cmdutil/format.go index b97ff577..7e60398c 100644 --- a/cli/internal/cmdutil/format.go +++ b/cli/internal/cmdutil/format.go @@ -99,7 +99,7 @@ func (o *FormatOptions) WantsJSON() bool { // path) or as bare NDJSON lines (FormatNDJSON path). meta is optional // (pass nil for mutation commands without batch counts). // -// FormatJSON path: envelope is {ok:true, data:..., meta?:..., _notice?:...}. +// FormatJSON path: envelope is {ok:true, data:..., meta?:..., profile?:...}. // Indent is determined by o.TTY (populated by ResolveDefault). // When o.JQ is set, jq evaluates against the full envelope JSON, so users // project with ".data[]", ".meta.count", etc. @@ -149,13 +149,23 @@ func mapJQError(err error) error { // - For human-readable rendering, pass --format text explicitly func (o *FormatOptions) ResolveDefault(tty bool) { o.TTY = tty + // Apply WEKNORA_FORMAT before the hard default so the documented + // precedence holds: explicit --format (already set on o.Mode by + // CheckFormatFlag) > WEKNORA_FORMAT > DefaultFormatMode. FromEnv is a + // no-op when --format was passed. Folded in here because nearly every + // command calls ResolveDefault but only a couple called FromEnv, so the + // env var was silently ignored on success output across the CLI. + o.FromEnv() if o.Mode == "" { o.Mode = DefaultFormatMode } } // FromEnv reads WEKNORA_FORMAT and applies it when Mode hasn't been set -// by --format. Call between CheckFormatFlag and ResolveDefault. +// by --format. ResolveDefault now calls this, so commands get the env var +// applied automatically; explicit callers (e.g. the root PersistentPreRunE, +// which resolves the error-envelope mode before any command RunE) remain +// valid and idempotent. // // Invalid env values are silently ignored (the user's --format on a // later invocation will still take precedence). diff --git a/cli/internal/cmdutil/format_envelope_test.go b/cli/internal/cmdutil/format_envelope_test.go index 2886ed75..2263cac1 100644 --- a/cli/internal/cmdutil/format_envelope_test.go +++ b/cli/internal/cmdutil/format_envelope_test.go @@ -28,7 +28,7 @@ func TestEmit_WrapsInEnvelope(t *testing.T) { func TestEmit_WithMeta(t *testing.T) { var buf bytes.Buffer fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON} - meta := &output.Meta{Count: 2, HasMore: true} + meta := &output.Meta{Count: output.IntPtr(2), HasMore: true} if err := fopts.Emit(&buf, []string{"a", "b"}, meta); err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/cli/internal/cmdutil/format_test.go b/cli/internal/cmdutil/format_test.go index 0b848e90..c0e4a84d 100644 --- a/cli/internal/cmdutil/format_test.go +++ b/cli/internal/cmdutil/format_test.go @@ -236,3 +236,38 @@ func TestFromEnv_InvalidValueIgnored(t *testing.T) { t.Errorf("invalid env should be ignored; got %v", o.Mode) } } + +// TestResolveDefault_AppliesEnv pins that ResolveDefault honours +// WEKNORA_FORMAT. Regression: commands call CheckFormatFlag + ResolveDefault +// but (nearly) none called FromEnv, so the documented env var was silently +// ignored on success output across the whole CLI until ResolveDefault folded +// it in. This is the path every command's RunE actually takes. +func TestResolveDefault_AppliesEnv(t *testing.T) { + t.Setenv("WEKNORA_FORMAT", "text") + o := &FormatOptions{} // no --format → Mode empty, as after CheckFormatFlag + o.ResolveDefault(false) + if o.Mode != FormatText { + t.Errorf("WEKNORA_FORMAT=text must drive ResolveDefault to text; got %v", o.Mode) + } +} + +// TestResolveDefault_FlagBeatsEnv pins precedence: an explicit --format +// (Mode already set) wins over WEKNORA_FORMAT. +func TestResolveDefault_FlagBeatsEnv(t *testing.T) { + t.Setenv("WEKNORA_FORMAT", "text") + o := &FormatOptions{Mode: FormatJSON} // --format json supplied + o.ResolveDefault(false) + if o.Mode != FormatJSON { + t.Errorf("explicit --format json must beat WEKNORA_FORMAT=text; got %v", o.Mode) + } +} + +// TestResolveDefault_NoEnvNoFlag_UsesDefault pins the fallback. +func TestResolveDefault_NoEnvNoFlag_UsesDefault(t *testing.T) { + t.Setenv("WEKNORA_FORMAT", "") + o := &FormatOptions{} + o.ResolveDefault(false) + if o.Mode != DefaultFormatMode { + t.Errorf("no flag + no env must fall back to default %v; got %v", DefaultFormatMode, o.Mode) + } +} diff --git a/cli/internal/cmdutil/input.go b/cli/internal/cmdutil/input.go index 2f5a4b7d..50e71be8 100644 --- a/cli/internal/cmdutil/input.go +++ b/cli/internal/cmdutil/input.go @@ -1,12 +1,79 @@ package cmdutil import ( + "fmt" "io" "os" + "strings" + "time" "github.com/Tencent/WeKnora/cli/internal/iostreams" ) +// rfc3339Example is the canonical hint shown when a time-flag value isn't RFC3339. +const rfc3339Example = "2006-01-02T15:04:05Z" + +// ParseTimeFlag parses an RFC3339 time-flag value. An empty value is treated as +// unset and returns (nil, nil) — callers use nil to mean "no bound". A +// malformed value returns a CodeInputInvalidArgument error naming the label; +// on success it returns the parsed time. label is used verbatim in the message +// (e.g. "--start-time" for a CLI flag, "start_time" for an MCP tool field). +// +// One place owns the RFC3339 time-flag shape — the parse, error code, and +// wording consistency sibling of ValidateEnum. +func ParseTimeFlag(label, value string) (*time.Time, error) { + if value == "" { + return nil, nil + } + t, err := time.Parse(time.RFC3339, value) + if err != nil { + return nil, &Error{ + Code: CodeInputInvalidArgument, + Message: fmt.Sprintf("%s must be RFC3339 (e.g. %s), got %q", + label, rfc3339Example, value), + } + } + return &t, nil +} + +// ValidateEnum checks a closed-set flag value, case-insensitively. An empty +// value is treated as unset and returns "" with no error (callers use the empty +// case to mean "no filter" / "server default"). A non-empty value that matches +// no allowed entry returns CodeInputInvalidArgument naming the flag and the +// valid set; on a match it returns the canonical spelling from allowed (so the +// server, which may be case-sensitive, receives a normalized value). +// +// This is the one place the "validate a flag against a closed enum" shape +// lives — keeping the membership test, error code, and message wording +// consistent across every such flag instead of hand-rolling each. +func ValidateEnum(flagName, value string, allowed []string) (string, error) { + if value == "" { + return "", nil + } + for _, a := range allowed { + if strings.EqualFold(value, a) { + return a, nil + } + } + return "", &Error{ + Code: CodeInputInvalidArgument, + Message: fmt.Sprintf("--%s must be one of: %s - got %q", + flagName, strings.Join(allowed, " | "), value), + } +} + +// EnumStrings converts a slice of a ~string enum type (e.g. the SDK's +// AllModelTypes / AllMessageSearchModes enumerators) to plain strings for +// ValidateEnum and flag-help wording. Sourcing the closed set from the SDK +// this way keeps the CLI from drifting from the server vocabulary. +func EnumStrings[T ~string](xs []T) []string { + out := make([]string, len(xs)) + for i, x := range xs { + out[i] = string(x) + } + return out +} + // OpenInput returns a reader for path. If path == "-", returns stdin // (iostreams.IO.In). Otherwise opens the file. Caller is responsible // for closing if needed — for typical "-input <file>"/"--input -" CLI diff --git a/cli/internal/cmdutil/input_test.go b/cli/internal/cmdutil/input_test.go new file mode 100644 index 00000000..070b0ae4 --- /dev/null +++ b/cli/internal/cmdutil/input_test.go @@ -0,0 +1,66 @@ +package cmdutil + +import ( + "errors" + "strings" + "testing" + "time" +) + +func TestParseTimeFlag(t *testing.T) { + // Empty → unset (nil, nil). + if got, err := ParseTimeFlag("--before", ""); err != nil || got != nil { + t.Errorf(`empty: got (%v, %v), want (nil, nil)`, got, err) + } + + // Valid RFC3339 → parsed time. + want, _ := time.Parse(time.RFC3339, "2026-06-01T00:00:00Z") + got, err := ParseTimeFlag("--before", "2026-06-01T00:00:00Z") + if err != nil || got == nil || !got.Equal(want) { + t.Errorf("valid: got (%v, %v)", got, err) + } + + // Malformed → CodeInputInvalidArgument naming the label. + _, err = ParseTimeFlag("start_time", "2026-06-01") + var typed *Error + if !errors.As(err, &typed) || typed.Code != CodeInputInvalidArgument { + t.Fatalf("malformed: expected CodeInputInvalidArgument, got %v", err) + } + if !strings.Contains(typed.Message, "start_time") || !strings.Contains(typed.Message, "RFC3339") { + t.Errorf("message %q missing label/RFC3339 hint", typed.Message) + } +} + +func TestValidateEnum(t *testing.T) { + allowed := []string{"keyword", "vector", "hybrid"} + + // Empty value is "unset" — allowed, returns "". + if got, err := ValidateEnum("mode", "", allowed); err != nil || got != "" { + t.Errorf(`empty: got (%q, %v), want ("", nil)`, got, err) + } + + // Exact and case-insensitive matches return the canonical spelling. + for _, in := range []string{"hybrid", "Hybrid", "HYBRID"} { + got, err := ValidateEnum("mode", in, allowed) + if err != nil || got != "hybrid" { + t.Errorf(`%q: got (%q, %v), want ("hybrid", nil)`, in, got, err) + } + } + + // Canonical spelling comes from `allowed`, not the input casing. + if got, _ := ValidateEnum("type", "embedding", []string{"Embedding", "Rerank"}); got != "Embedding" { + t.Errorf(`canonical: got %q, want "Embedding"`, got) + } + + // Unknown value → CodeInputInvalidArgument naming the flag + valid set. + _, err := ValidateEnum("mode", "hybird", allowed) + var typed *Error + if !errors.As(err, &typed) || typed.Code != CodeInputInvalidArgument { + t.Fatalf("unknown: expected CodeInputInvalidArgument, got %v", err) + } + for _, want := range []string{"--mode", "keyword | vector | hybrid", `"hybird"`} { + if !strings.Contains(typed.Message, want) { + t.Errorf("message %q missing %q", typed.Message, want) + } + } +} diff --git a/cli/internal/cmdutil/model.go b/cli/internal/cmdutil/model.go new file mode 100644 index 00000000..d9d499f7 --- /dev/null +++ b/cli/internal/cmdutil/model.go @@ -0,0 +1,53 @@ +package cmdutil + +import ( + "context" + "fmt" + + sdk "github.com/Tencent/WeKnora/client" +) + +// ModelLister is the narrow SDK surface ResolveModelRef needs. *sdk.Client +// satisfies it. +type ModelLister interface { + ListModels(ctx context.Context) ([]sdk.Model, error) +} + +// ResolveModelRef returns ref unchanged when it is already a model id (UUID); +// otherwise it resolves ref as a model NAME among models of wantType (pass "" +// to match any type) and returns that model's id. Mirrors the id-or-name policy +// of --kb so model-referencing flags accept either. +// +// Errors with resource.not_found when nothing matches, and +// input.invalid_argument when the name is ambiguous (so the caller passes an id +// instead). The empty ref is returned as-is for callers to treat as "unset". +func ResolveModelRef(ctx context.Context, lister ModelLister, ref, wantType string) (string, error) { + if ref == "" || IsKBID(ref) { // IsKBID is a generic UUID check + return ref, nil + } + models, err := lister.ListModels(ctx) + if err != nil { + return "", WrapHTTP(err, "list models to resolve %q", ref) + } + var matches []sdk.Model + for _, m := range models { + if m.Name == ref && (wantType == "" || string(m.Type) == wantType) { + matches = append(matches, m) + } + } + switch len(matches) { + case 1: + return matches[0].ID, nil + case 0: + msg := fmt.Sprintf("no model named %q", ref) + if wantType != "" { + msg = fmt.Sprintf("no %s model named %q", wantType, ref) + } + return "", NewError(CodeResourceNotFound, msg). + WithHint("discover models with `weknora model list`") + default: + return "", NewError(CodeInputInvalidArgument, + fmt.Sprintf("%q matches %d models — pass the model id instead", ref, len(matches))). + WithHint("`weknora model list` shows the ids") + } +} diff --git a/cli/internal/cmdutil/model_test.go b/cli/internal/cmdutil/model_test.go new file mode 100644 index 00000000..575a5571 --- /dev/null +++ b/cli/internal/cmdutil/model_test.go @@ -0,0 +1,62 @@ +package cmdutil + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + sdk "github.com/Tencent/WeKnora/client" +) + +type fakeModelLister struct { + models []sdk.Model + err error +} + +func (f *fakeModelLister) ListModels(_ context.Context) ([]sdk.Model, error) { + return f.models, f.err +} + +func TestResolveModelRef(t *testing.T) { + lister := &fakeModelLister{models: []sdk.Model{ + {ID: "11111111-1111-1111-1111-111111111111", Name: "qwen2", Type: sdk.ModelTypeKnowledgeQA}, + {ID: "22222222-2222-2222-2222-222222222222", Name: "nomic", Type: sdk.ModelTypeEmbedding}, + {ID: "33333333-3333-3333-3333-333333333333", Name: "dup", Type: sdk.ModelTypeEmbedding}, + {ID: "44444444-4444-4444-4444-444444444444", Name: "dup", Type: sdk.ModelTypeKnowledgeQA}, + }} + + // A UUID passes through untouched without hitting the lister. + got, err := ResolveModelRef(context.Background(), &fakeModelLister{err: errors.New("must not call")}, + "99999999-9999-9999-9999-999999999999", "Embedding") + require.NoError(t, err) + assert.Equal(t, "99999999-9999-9999-9999-999999999999", got) + + // Empty passes through (caller treats as unset). + got, err = ResolveModelRef(context.Background(), lister, "", "") + require.NoError(t, err) + assert.Equal(t, "", got) + + // Name + type → id. + got, err = ResolveModelRef(context.Background(), lister, "qwen2", "KnowledgeQA") + require.NoError(t, err) + assert.Equal(t, "11111111-1111-1111-1111-111111111111", got) + + // Type filter disambiguates same-named models across types. + got, err = ResolveModelRef(context.Background(), lister, "dup", "Embedding") + require.NoError(t, err) + assert.Equal(t, "33333333-3333-3333-3333-333333333333", got) + + // No match → resource.not_found. + _, err = ResolveModelRef(context.Background(), lister, "missing", "Embedding") + var ce *Error + require.ErrorAs(t, err, &ce) + assert.Equal(t, CodeResourceNotFound, ce.Code) + + // Ambiguous (same name, no type filter) → input.invalid_argument. + _, err = ResolveModelRef(context.Background(), lister, "dup", "") + require.ErrorAs(t, err, &ce) + assert.Equal(t, CodeInputInvalidArgument, ce.Code) +} diff --git a/cli/internal/cmdutil/profilename.go b/cli/internal/cmdutil/profilename.go index 4cc21259..78fdbc59 100644 --- a/cli/internal/cmdutil/profilename.go +++ b/cli/internal/cmdutil/profilename.go @@ -12,9 +12,9 @@ import ( // keyring namespace. // // Crucially this also rejects shell metacharacters (space, `;`, `&`, `|`, -// `$`, quotes, backticks, etc.), so the profile name remains safe to -// interpolate into envelope.error.retry_command — an agent that exec's -// `sh -c <retry_command>` cannot be tricked via a maliciously-named profile. +// `$`, quotes, backticks, etc.), so the profile name remains safe to embed +// in envelope.error.retry_argv — even an agent that joins the argv and exec's +// `sh -c <joined>` cannot be tricked via a maliciously-named profile. func ValidateProfileName(name string) error { if name == "" { return &Error{ diff --git a/cli/internal/cmdutil/profilename_test.go b/cli/internal/cmdutil/profilename_test.go index eb702890..18576d18 100644 --- a/cli/internal/cmdutil/profilename_test.go +++ b/cli/internal/cmdutil/profilename_test.go @@ -37,9 +37,9 @@ func TestValidateProfileName_RejectsEmpty(t *testing.T) { } // TestValidateProfileName_RejectsShellMetachars is the security-critical -// case: anything that could break retry_command shell interpolation must be +// case: anything that could break retry_argv shell interpolation must be // rejected at the entry point. If this test ever loosens, an agent that -// exec()s retry_command becomes injectable via a malicious profile name. +// joins-and-exec()s retry_argv becomes injectable via a malicious profile name. func TestValidateProfileName_RejectsShellMetachars(t *testing.T) { cases := []string{ "evil; rm -rf /", diff --git a/cli/internal/cmdutil/risk.go b/cli/internal/cmdutil/risk.go index 173604e1..15a339b8 100644 --- a/cli/internal/cmdutil/risk.go +++ b/cli/internal/cmdutil/risk.go @@ -1,30 +1,51 @@ // Package-level note: // -// SetRisk attaches risk metadata to a destructive cobra command via cobra +// SetRisk / SetWriteRisk attach risk metadata to a cobra command via cobra // annotations. The SetAgentHelp wrapper in agenthelp.go reads these // annotations and prepends a "Risk: <action> (<level>)" line at the top -// of human help output. +// of human help output. Destructive ops use SetRisk ("destructive"); +// reversible update commands use SetWriteRisk ("write"). "read" is reserved. // // envelope.error.risk.action is emitted separately by the -// ConfirmDestructive callsite argument and does NOT read these annotations. +// ConfirmDestructive / ConfirmWrite callsite argument and does NOT read +// these annotations. package cmdutil import "github.com/spf13/cobra" -// RiskDestructive is the only level value currently emitted. The annotation -// key reserves "read" / "write" for future use. -const RiskDestructive = "destructive" +// Risk levels emitted in the annotation / envelope: +// - RiskDestructive: irreversible ops (delete, kb init clobber). +// - RiskWrite: reversible metadata edits (kb / agent / doc update). +// +// "read" remains reserved (read-only commands carry no risk annotation). +const ( + RiskDestructive = "destructive" + RiskWrite = "write" +) -// SetRisk writes risk metadata to a cobra command's annotations. +// SetRisk writes destructive risk metadata to a cobra command's annotations. // Idempotent: re-calling with the same action overwrites cleanly. // // nil-map guard: cobra.Command.Annotations is `map[string]string` and // defaults to nil. Writing to a nil map panics, so we allocate first. func SetRisk(cmd *cobra.Command, action string) { + setRisk(cmd, RiskDestructive, action) +} + +// SetWriteRisk mirrors SetRisk but tags the command at the "write" level — +// used by update commands (kb / agent / doc update), which are reversible +// metadata edits rather than irreversible destructive ops. They remain +// confirmation-gated; only the level label differs. +func SetWriteRisk(cmd *cobra.Command, action string) { + setRisk(cmd, RiskWrite, action) +} + +// setRisk is the shared writer behind SetRisk / SetWriteRisk. +func setRisk(cmd *cobra.Command, level, action string) { if cmd.Annotations == nil { cmd.Annotations = make(map[string]string) } - cmd.Annotations["risk.level"] = RiskDestructive + cmd.Annotations["risk.level"] = level cmd.Annotations["risk.action"] = action } diff --git a/cli/internal/cmdutil/suggest.go b/cli/internal/cmdutil/suggest.go new file mode 100644 index 00000000..30d0a98a --- /dev/null +++ b/cli/internal/cmdutil/suggest.go @@ -0,0 +1,117 @@ +package cmdutil + +import "sort" + +// levenshtein returns the rune-aware edit distance between a and b. Operates +// on runes (not bytes) so multi-byte names compare correctly. +func levenshtein(a, b string) int { + ra, rb := []rune(a), []rune(b) + la, lb := len(ra), len(rb) + if la == 0 { + return lb + } + if lb == 0 { + return la + } + prev := make([]int, lb+1) + curr := make([]int, lb+1) + for j := 0; j <= lb; j++ { + prev[j] = j + } + for i := 1; i <= la; i++ { + curr[0] = i + for j := 1; j <= lb; j++ { + cost := 1 + if ra[i-1] == rb[j-1] { + cost = 0 + } + del := prev[j] + 1 + ins := curr[j-1] + 1 + sub := prev[j-1] + cost + m := del + if ins < m { + m = ins + } + if sub < m { + m = sub + } + curr[j] = m + } + prev, curr = curr, prev + } + return prev[lb] +} + +// SuggestOne returns the single candidate closest to target by edit distance +// when it is a plausible typo (distance ≤ 2), or "" when none qualifies. Ties +// are broken lexicographically so the result is deterministic regardless of +// candidate ordering (Go randomizes map-range, so callers passing map keys +// would otherwise get flaky "did you mean" output). Use when a single +// "did you mean: X?" is wanted (e.g. profile lookup); SuggestClosest returns +// the ranked list instead. +func SuggestOne(target string, candidates []string) string { + if target == "" { + return "" + } + sorted := append([]string(nil), candidates...) + sort.Strings(sorted) + best := "" + bestD := 3 + for _, c := range sorted { + if d := levenshtein(target, c); d < bestD { + bestD = d + best = c + } + } + if bestD > 2 { + return "" + } + return best +} + +// SuggestClosest returns the candidates nearest to target by edit distance, +// closest first, limited to genuinely-plausible typos. Used to turn an +// "unknown subcommand"/"unknown name" error into an actionable "did you +// mean: X?" instead of dumping the whole list. Returns nil when nothing is +// close enough. +// +// Threshold scales with the target length (max(2, len/3)) so short names +// need an exact-ish match while longer names tolerate more slips. At most 3 +// suggestions are returned. +func SuggestClosest(target string, candidates []string) []string { + if target == "" { + return nil + } + threshold := len([]rune(target)) / 3 + if threshold < 2 { + threshold = 2 + } + type scored struct { + name string + dist int + } + var matches []scored + for _, c := range candidates { + d := levenshtein(target, c) + if d <= threshold { + matches = append(matches, scored{c, d}) + } + } + if len(matches) == 0 { + return nil + } + sort.SliceStable(matches, func(i, j int) bool { + if matches[i].dist != matches[j].dist { + return matches[i].dist < matches[j].dist + } + return matches[i].name < matches[j].name + }) + var out []string + for i, m := range matches { + if i >= 3 { + break + } + out = append(out, m.name) + } + return out +} diff --git a/cli/internal/cmdutil/suggest_test.go b/cli/internal/cmdutil/suggest_test.go new file mode 100644 index 00000000..f020f720 --- /dev/null +++ b/cli/internal/cmdutil/suggest_test.go @@ -0,0 +1,59 @@ +package cmdutil + +import ( + "reflect" + "testing" +) + +func TestLevenshtein(t *testing.T) { + cases := []struct { + a, b string + want int + }{ + {"", "", 0}, + {"abc", "", 3}, + {"", "abc", 3}, + {"list", "list", 0}, + {"list", "listt", 1}, + {"list", "lst", 1}, + {"limit", "limti", 2}, + {"café", "cafe", 1}, // rune-aware: é vs e is one edit + } + for _, tc := range cases { + if got := levenshtein(tc.a, tc.b); got != tc.want { + t.Errorf("levenshtein(%q,%q) = %d, want %d", tc.a, tc.b, got, tc.want) + } + } +} + +func TestSuggestClosest(t *testing.T) { + subs := []string{"create", "delete", "edit", "list", "pin", "status", "unpin", "view"} + cases := []struct { + target string + want []string + }{ + {"lst", []string{"list"}}, // 1 edit + {"listt", []string{"list"}}, // 1 edit + {"vieww", []string{"view"}}, // 1 edit + {"creat", []string{"create"}}, // 1 edit + {"xyzzy", nil}, // nothing close + {"", nil}, // empty + {"pinn", []string{"pin"}}, // 1 edit (pin), unpin is 2 + } + for _, tc := range cases { + got := SuggestClosest(tc.target, subs) + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("SuggestClosest(%q) = %v, want %v", tc.target, got, tc.want) + } + } +} + +// TestSuggestClosest_RanksByDistance pins closest-first ordering when +// candidates are at different distances. +func TestSuggestClosest_RanksByDistance(t *testing.T) { + // "creat" → "create" is 1 edit (append e); "delete" is far. create first. + got := SuggestClosest("creat", []string{"delete", "create"}) + if len(got) == 0 || got[0] != "create" { + t.Errorf("closest to 'creat' should be 'create' first; got %v", got) + } +} diff --git a/cli/internal/config/config.go b/cli/internal/config/config.go index 20c2756c..a9937d19 100644 --- a/cli/internal/config/config.go +++ b/cli/internal/config/config.go @@ -23,9 +23,8 @@ type Config struct { // Defaults holds CLI-wide defaults; fields opt-in. Defaults struct { - Format string `yaml:"format,omitempty"` - NoVersionCheck bool `yaml:"no_version_check,omitempty"` - RequestIDPrefix string `yaml:"request_id_prefix,omitempty"` + Format string `yaml:"format,omitempty"` + NoVersionCheck bool `yaml:"no_version_check,omitempty"` } `yaml:"defaults,omitempty"` } diff --git a/cli/internal/iostreams/iostreams.go b/cli/internal/iostreams/iostreams.go index 7e0f45a5..739b64ff 100644 --- a/cli/internal/iostreams/iostreams.go +++ b/cli/internal/iostreams/iostreams.go @@ -43,18 +43,6 @@ func (s *IOStreams) IsStdoutTTY() bool { return s.stdoutTTY } // IsStderrTTY reports whether stderr is a terminal. func (s *IOStreams) IsStderrTTY() bool { return s.stderrTTY } -// ColorEnabled honors NO_COLOR (https://no-color.org) and TERM=dumb. -// Computed on demand so env changes after init are picked up by callers. -func (s *IOStreams) ColorEnabled() bool { - if !s.stdoutTTY { - return false - } - if os.Getenv("NO_COLOR") != "" { - return false - } - return os.Getenv("TERM") != "dumb" -} - // SetForTest replaces the package singleton with in-memory buffers for the // duration of t. Call it once per test. Not safe under t.Parallel(). func SetForTest(t *testing.T) (out, errBuf *bytes.Buffer) { diff --git a/cli/internal/iostreams/iostreams_test.go b/cli/internal/iostreams/iostreams_test.go index 410b5692..f2abb5b9 100644 --- a/cli/internal/iostreams/iostreams_test.go +++ b/cli/internal/iostreams/iostreams_test.go @@ -32,5 +32,4 @@ func TestProductionGetters(t *testing.T) { a, b := p.IsStdoutTTY(), p.IsStderrTTY() assert.Equal(t, a, p.IsStdoutTTY()) assert.Equal(t, b, p.IsStderrTTY()) - _ = p.ColorEnabled() // smoke } diff --git a/cli/internal/mcp/server.go b/cli/internal/mcp/server.go index f03d6054..39301186 100644 --- a/cli/internal/mcp/server.go +++ b/cli/internal/mcp/server.go @@ -17,7 +17,10 @@ package mcp import ( "context" + "errors" "fmt" + "io" + "strings" mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" @@ -53,7 +56,24 @@ func RunStdio(ctx context.Context, svc ServiceClient) error { ) registerTools(server, svc) if err := server.Run(ctx, &mcpsdk.StdioTransport{}); err != nil { + // An MCP host shuts the server down by closing stdin (EOF) or cancelling + // the context — that is normal termination, not a failure, so exit 0. + // Only a genuine transport fault is surfaced as an error. + if isCleanShutdown(err) { + return nil + } return fmt.Errorf("mcp serve: %w", err) } return nil } + +// isCleanShutdown reports whether err is the ordinary "client went away" +// signal: EOF on stdin or a cancelled/expired context. The go-sdk wraps the +// stdin EOF in a formatted string ("server is closing: EOF") that does not +// always unwrap to io.EOF, so we also match the message suffix as a fallback. +func isCleanShutdown(err error) bool { + if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return true + } + return strings.HasSuffix(strings.TrimSpace(err.Error()), "EOF") +} diff --git a/cli/internal/mcp/server_test.go b/cli/internal/mcp/server_test.go new file mode 100644 index 00000000..06623215 --- /dev/null +++ b/cli/internal/mcp/server_test.go @@ -0,0 +1,37 @@ +package mcp + +import ( + "context" + "errors" + "fmt" + "io" + "testing" +) + +// TestIsCleanShutdown pins that an MCP host disconnecting (stdin EOF) or a +// cancelled context is treated as normal termination (exit 0), while a genuine +// transport fault is not. +func TestIsCleanShutdown(t *testing.T) { + clean := []error{ + io.EOF, + context.Canceled, + context.DeadlineExceeded, + fmt.Errorf("jsonrpc: %w", io.EOF), // wrapped EOF + errors.New("server is closing: EOF"), // go-sdk formatted string (no unwrap) + } + for _, e := range clean { + if !isCleanShutdown(e) { + t.Errorf("isCleanShutdown(%v) = false, want true", e) + } + } + notClean := []error{ + errors.New("connection reset by peer"), + errors.New("write: broken pipe"), + errors.New("EOF while reading header but more expected"), // EOF not at end + } + for _, e := range notClean { + if isCleanShutdown(e) { + t.Errorf("isCleanShutdown(%v) = true, want false", e) + } + } +} diff --git a/cli/internal/mcp/tools.go b/cli/internal/mcp/tools.go index b194edad..b1913eeb 100644 --- a/cli/internal/mcp/tools.go +++ b/cli/internal/mcp/tools.go @@ -34,8 +34,8 @@ func toolErrorResult(err error) *mcpsdk.CallToolResult { if detail.Hint != "" { textLine += "\nhint: " + detail.Hint } - if detail.RetryCommand != "" { - textLine += "\nretry: " + detail.RetryCommand + if len(detail.RetryArgv) != 0 { + textLine += "\nretry: " + strings.Join(detail.RetryArgv, " ") } // StructuredContent accepts any; pass *ErrDetail directly (no round-trip). return &mcpsdk.CallToolResult{ diff --git a/cli/internal/mcp/tools_error_test.go b/cli/internal/mcp/tools_error_test.go index 1e119849..6aff8d6e 100644 --- a/cli/internal/mcp/tools_error_test.go +++ b/cli/internal/mcp/tools_error_test.go @@ -1,6 +1,7 @@ package mcp import ( + "reflect" "strings" "testing" @@ -29,9 +30,9 @@ func TestToolErrorResult_TypedError_PopulatesStructuredContent(t *testing.T) { if sc.Type != "auth.unauthenticated" { t.Errorf("type: got %v want auth.unauthenticated", sc.Type) } - // retry_command should fall back to "weknora auth login" via cmdutil default - if sc.RetryCommand != "weknora auth login" { - t.Errorf("retry_command: got %v", sc.RetryCommand) + // retry_argv should fall back to ["weknora","auth","login"] via cmdutil default + if !reflect.DeepEqual(sc.RetryArgv, []string{"weknora", "auth", "login"}) { + t.Errorf("retry_argv: got %v", sc.RetryArgv) } } diff --git a/cli/internal/output/batch.go b/cli/internal/output/batch.go index e5287ee8..9fc7de9a 100644 --- a/cli/internal/output/batch.go +++ b/cli/internal/output/batch.go @@ -30,15 +30,29 @@ func WriteBatchEnvelope(w io.Writer, items []BatchItem, indent bool, profile str } } env := Envelope{ - OK: failures == 0, - Data: items, + OK: failures == 0, + Status: batchStatus(successes, failures), + Data: items, Meta: &Meta{ - Count: len(items), + Count: IntPtr(len(items)), Successes: &successes, Failures: &failures, }, - Notice: GetNotice(), Profile: profile, } return writeJSON(w, env, indent) } + +// batchStatus maps the (successes, failures) counts to the tri-state envelope +// status: "success" when no failures, "error" when no successes, "partial" +// when both occur. +func batchStatus(successes, failures int) string { + switch { + case failures == 0: + return "success" + case successes == 0: + return "error" + default: + return "partial" + } +} diff --git a/cli/internal/output/batch_test.go b/cli/internal/output/batch_test.go index de251e4d..900c4f32 100644 --- a/cli/internal/output/batch_test.go +++ b/cli/internal/output/batch_test.go @@ -74,6 +74,53 @@ func TestWriteBatchEnvelope_EmptyItems_EmitsEmptyArray(t *testing.T) { } } +// TestWriteBatchEnvelope_StatusTriState verifies the additive tri-state +// envelope.status: "success" (all ok), "partial" (mixed), "error" (all fail). +func TestWriteBatchEnvelope_StatusTriState(t *testing.T) { + cases := []struct { + name string + items []output.BatchItem + status string + }{ + { + name: "all_success", + items: []output.BatchItem{ + {ID: "a", OK: true}, {ID: "b", OK: true}, + }, + status: "success", + }, + { + name: "partial", + items: []output.BatchItem{ + {ID: "a", OK: true}, + {ID: "b", OK: false, Error: &output.ErrDetail{Type: "resource.not_found", Message: "missing"}}, + }, + status: "partial", + }, + { + name: "all_fail", + items: []output.BatchItem{ + {ID: "a", OK: false, Error: &output.ErrDetail{Type: "auth.forbidden", Message: "no perm"}}, + {ID: "b", OK: false, Error: &output.ErrDetail{Type: "resource.not_found", Message: "missing"}}, + }, + status: "error", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var buf bytes.Buffer + if err := output.WriteBatchEnvelope(&buf, tc.items, false, ""); err != nil { + t.Fatalf("unexpected error: %v", err) + } + got := buf.String() + want := `"status":"` + tc.status + `"` + if !strings.Contains(got, want) { + t.Errorf("expected %s; got %q", want, got) + } + }) + } +} + func TestWriteBatchEnvelope_AllFail(t *testing.T) { items := []output.BatchItem{ {ID: "id1", OK: false, Error: &output.ErrDetail{Type: "auth.forbidden", Message: "no perm"}}, diff --git a/cli/internal/output/envelope.go b/cli/internal/output/envelope.go index b5fc6d28..ce73033c 100644 --- a/cli/internal/output/envelope.go +++ b/cli/internal/output/envelope.go @@ -11,28 +11,35 @@ import ( // Envelope is the success-path stdout envelope. See AGENTS.md // "Stdout (success path)" for the full wire contract. type Envelope struct { - OK bool `json:"ok"` - Data any `json:"data,omitempty"` - Meta *Meta `json:"meta,omitempty"` - Notice map[string]any `json:"_notice,omitempty"` - Profile string `json:"profile,omitempty"` + OK bool `json:"ok"` + // Status is the batch tri-state outcome: "success" (all items ok), + // "partial" (some ok, some failed), or "error" (all failed). Set only by + // batch commands; omitted on ordinary single-result envelopes. `ok` stays + // authoritative — Status is an additive convenience for agents triaging + // multi-target results. + Status string `json:"status,omitempty"` + Data any `json:"data,omitempty"` + Meta *Meta `json:"meta,omitempty"` + Profile string `json:"profile,omitempty"` } // ErrorEnvelope is the error-path stderr envelope. See AGENTS.md // "Stderr (error path)" for the full wire contract. type ErrorEnvelope struct { - OK bool `json:"ok"` - Error *ErrDetail `json:"error"` - Notice map[string]any `json:"_notice,omitempty"` + OK bool `json:"ok"` + Error *ErrDetail `json:"error"` } // Meta carries optional metadata in success envelopes. type Meta struct { - Count int `json:"count,omitempty"` - HasMore bool `json:"has_more,omitempty"` - NextCursor string `json:"next_cursor,omitempty"` - TotalCount int `json:"total_count,omitempty"` - RequestID string `json:"request_id,omitempty"` + // Count and TotalCount are *int so zero is serialized when explicitly set + // by list commands (omitempty on *int omits only nil, not zero). This keeps + // the agent contract stable: an empty list still emits count/total_count as 0 + // instead of dropping the key. Non-list / dry-run metas leave them nil so they + // are omitted. Mirrors the Successes/Failures pointer pattern below. + Count *int `json:"count,omitempty"` + HasMore bool `json:"has_more,omitempty"` + TotalCount *int `json:"total_count,omitempty"` // Successes and Failures are *int so zero is serialized when explicitly set // by the batch path (omitempty on *int omits only nil, not zero). // Non-batch commands leave these nil so they are omitted from the envelope. @@ -47,13 +54,21 @@ type Meta struct { // ErrDetail describes a structured error. Embedded in ErrorEnvelope.Error // and also surfaced in batch envelope per-item failures. type ErrDetail struct { - Type string `json:"type"` - Message string `json:"message"` - Hint string `json:"hint,omitempty"` - RetryCommand string `json:"retry_command,omitempty"` - RetryAfterSeconds int `json:"retry_after_seconds,omitempty"` - Risk *RiskDetail `json:"risk,omitempty"` - Detail any `json:"detail,omitempty"` + Type string `json:"type"` + Message string `json:"message"` + Hint string `json:"hint,omitempty"` + // RetryArgv is a directly-executable argv array (e.g. + // ["weknora","auth","login"]) so an agent can exec it without + // shell-splitting or quote-handling. Distinct from the prose Hint. + RetryArgv []string `json:"retry_argv,omitempty"` + RetryAfterSeconds int `json:"retry_after_seconds,omitempty"` + // Retryable indicates whether re-running the SAME command may succeed: + // true for transient failures (timeouts, rate limits, transport), false + // for deterministic ones (auth, bad input, not-found), omitted (nil) when + // genuinely unknown. + Retryable *bool `json:"retryable,omitempty"` + Risk *RiskDetail `json:"risk,omitempty"` + Detail any `json:"detail,omitempty"` } // RiskDetail tags high-risk writes for the agent protocol. Surfaces in @@ -64,35 +79,20 @@ type RiskDetail struct { Action string `json:"action"` } -// PendingNotice, if set, returns system-level notices to inject as the -// "_notice" field on every envelope. Currently nil — Task 4.x deferred -// the registration. Tests may set this directly. -var PendingNotice func() map[string]any - -// GetNotice returns the current pending notice. Nil when nothing to report. -func GetNotice() map[string]any { - if PendingNotice == nil { - return nil - } - return PendingNotice() -} - // NewEnvelope assembles a success Envelope with the given data + optional -// meta + profile, injecting the pending _notice automatically. Single source -// of construction so callers that need the envelope value (e.g. jq filtering) -// stay in sync with WriteEnvelope when fields are added. +// meta + profile. Single source of construction so callers that need the +// envelope value (e.g. jq filtering) stay in sync with WriteEnvelope when +// fields are added. func NewEnvelope(data any, meta *Meta, profile string) Envelope { return Envelope{ OK: true, Data: data, Meta: meta, - Notice: GetNotice(), Profile: profile, } } -// WriteEnvelope writes a success envelope to w. Caller sets data + optional meta; -// notice is injected from GetNotice() automatically. +// WriteEnvelope writes a success envelope to w. Caller sets data + optional meta. // // When profile is non-empty, the envelope includes a "profile" field. // indent: if true, output is multi-line (TTY mode); else compact (pipe mode). @@ -103,13 +103,17 @@ func WriteEnvelope(w io.Writer, data any, meta *Meta, indent bool, profile strin // WriteErrorEnvelope writes an error envelope to w (typically stderr). func WriteErrorEnvelope(w io.Writer, err *ErrDetail, indent bool) error { env := ErrorEnvelope{ - OK: false, - Error: err, - Notice: GetNotice(), + OK: false, + Error: err, } return writeJSON(w, env, indent) } +// IntPtr returns a pointer to i. Used by list commands to set Meta.Count / +// Meta.TotalCount so that a zero count still serializes (omitempty on *int +// omits only nil). Mirrors the Successes/Failures pointer pattern. +func IntPtr(i int) *int { return &i } + func writeJSON(w io.Writer, v any, indent bool) error { enc := json.NewEncoder(w) enc.SetEscapeHTML(false) diff --git a/cli/internal/output/envelope_test.go b/cli/internal/output/envelope_test.go index cab8423e..892021d8 100644 --- a/cli/internal/output/envelope_test.go +++ b/cli/internal/output/envelope_test.go @@ -40,7 +40,7 @@ func TestWriteEnvelope_OmitDataWhenNil(t *testing.T) { func TestWriteEnvelope_WithMeta(t *testing.T) { var buf bytes.Buffer - meta := &output.Meta{Count: 2, HasMore: false} + meta := &output.Meta{Count: output.IntPtr(2), HasMore: false} if err := output.WriteEnvelope(&buf, []string{"a", "b"}, meta, false, ""); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -51,13 +51,36 @@ func TestWriteEnvelope_WithMeta(t *testing.T) { } } +// TestWriteEnvelope_ZeroCountSerializes pins the *int fix: a list command that +// sets Count to 0 (empty result) must still emit "count":0, while a meta that +// leaves Count nil (non-list / dry-run) must omit the key entirely. This is the +// agent-contract guarantee that omitempty on a plain int silently broke. +func TestWriteEnvelope_ZeroCountSerializes(t *testing.T) { + var bufZero bytes.Buffer + zero := &output.Meta{Count: output.IntPtr(0)} + if err := output.WriteEnvelope(&bufZero, []string{}, zero, false, ""); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotZero := bufZero.String(); !strings.Contains(gotZero, `"count":0`) { + t.Errorf("explicit zero count must serialize; got %q", gotZero) + } + + var bufNil bytes.Buffer + if err := output.WriteEnvelope(&bufNil, []string{}, &output.Meta{}, false, ""); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotNil := bufNil.String(); strings.Contains(gotNil, "count") { + t.Errorf("nil count must be omitted; got %q", gotNil) + } +} + func TestWriteErrorEnvelope_FullShape(t *testing.T) { var buf bytes.Buffer errDetail := &output.ErrDetail{ - Type: "input.confirmation_required", - Message: "kb delete kb_x requires confirmation", - Hint: "re-run with -y/--yes", - RetryCommand: "weknora kb delete kb_x -y", + Type: "input.confirmation_required", + Message: "kb delete kb_x requires confirmation", + Hint: "re-run with -y/--yes", + RetryArgv: []string{"weknora", "kb", "delete", "kb_x", "-y"}, Risk: &output.RiskDetail{ Level: "destructive", Action: "kb.delete", @@ -73,8 +96,8 @@ func TestWriteErrorEnvelope_FullShape(t *testing.T) { if !strings.Contains(got, `"type":"input.confirmation_required"`) { t.Errorf("missing typed code; got %q", got) } - if !strings.Contains(got, `"retry_command":"weknora kb delete kb_x -y"`) { - t.Errorf("missing retry_command; got %q", got) + if !strings.Contains(got, `"retry_argv":["weknora","kb","delete","kb_x","-y"]`) { + t.Errorf("missing retry_argv; got %q", got) } if !strings.Contains(got, `"risk":{"level":"destructive","action":"kb.delete"}`) { t.Errorf("missing risk; got %q", got) @@ -91,21 +114,3 @@ func TestWriteEnvelope_IndentedTTYMode(t *testing.T) { t.Errorf("expected indented multi-line output; got %q", got) } } - -func TestGetNotice_NilSafe(t *testing.T) { - output.PendingNotice = nil - if got := output.GetNotice(); got != nil { - t.Errorf("GetNotice with nil PendingNotice should return nil; got %v", got) - } -} - -func TestGetNotice_WithSetter(t *testing.T) { - output.PendingNotice = func() map[string]any { - return map[string]any{"deprecation": "foo is deprecated"} - } - defer func() { output.PendingNotice = nil }() - got := output.GetNotice() - if got["deprecation"] != "foo is deprecated" { - t.Errorf("notice not populated; got %v", got) - } -} diff --git a/cli/internal/sse/accumulator.go b/cli/internal/sse/accumulator.go index 26546402..ba4f755c 100644 --- a/cli/internal/sse/accumulator.go +++ b/cli/internal/sse/accumulator.go @@ -9,11 +9,21 @@ package sse import ( + "errors" "strings" sdk "github.com/Tencent/WeKnora/client" ) +// ErrTerminate is the sentinel a stream callback returns to stop consuming the +// stream immediately after a terminal error frame (response_type=error). The +// server holds the SSE connection open after such a frame, so a callback that +// keeps returning nil blocks the SDK read until a ~30s transport timeout — +// turning a sub-second server error into a 30s hang for the (agent) caller. +// Callers derive the real result from the captured error message / accumulator +// and unwrap this sentinel (errors.Is) to a clean stop. +var ErrTerminate = errors.New("sse: stream terminated by terminal error event") + // Accumulator buffers a KnowledgeQAStream callback sequence. // // Zero value is ready to use. Not safe for concurrent Append calls - the SDK diff --git a/cli/scripts/check-secret-tokens.sh b/cli/scripts/check-secret-tokens.sh new file mode 100755 index 00000000..31589885 --- /dev/null +++ b/cli/scripts/check-secret-tokens.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# check-secret-tokens.sh — fail if a real-looking credential was committed to +# the CLI's human-facing docs. An agent-first CLI's docs are where a live +# API key or JWT most easily gets pasted by accident (copying a working +# session into a how-to). Mirrors the safety net lark CLI ships as +# .gitleaks.toml + check-doc-tokens.sh. +# +# Heuristic, low false-positive: a real WeKnora API key is `sk-` followed by a +# long high-entropy body that CONTAINS A DIGIT (e.g. +# sk-bVd4ebLoyn-DKevdkgw527XAakwv4G6Tz6FhgXPlOpBO-Ico); placeholder words like +# `sk-specific` / `sk-staging` have no digit and are short, so they pass. JWTs +# are three base64url segments. Lines that look like placeholders +# (EXAMPLE / REDACTED / <...> / YOUR_ / xxxx) are ignored. +set -euo pipefail +cd "$(dirname "$0")/.." + +# Doc surface to scan (where a pasted live token is the real risk). +FILES=$(git ls-files 'skills/**/*.md' 'README.md' 'AGENTS.md' 'CHANGELOG.md' 'ROADMAP.md' 2>/dev/null || true) +[ -z "$FILES" ] && { echo "no doc files to scan"; exit 0; } + +# sk- key: prefix + >=24 body chars including at least one digit. +KEY_RE='sk-[A-Za-z0-9_-]*[0-9][A-Za-z0-9_-]*' +# Only flag long bodies; require total match length >= 27 (sk- + 24). +JWT_RE='eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}' +PLACEHOLDER='EXAMPLE|REDACTED|PLACEHOLDER|YOUR_|xxxx|<[a-z]' + +fail=0 +for f in $FILES; do + while IFS=: read -r line content; do + [ -z "$content" ] && continue + # Skip lines that are clearly illustrative placeholders. + echo "$content" | grep -qE "$PLACEHOLDER" && continue + # API key: require >=24 chars after sk- (matched token length >= 27). + if echo "$content" | grep -oE "$KEY_RE" | grep -qE '^.{27,}$'; then + echo "POSSIBLE API KEY in $f:$line" + echo " $content" | sed -E 's/(sk-[A-Za-z0-9_-]{6})[A-Za-z0-9_-]+/\1…REDACTED/g' + fail=1 + fi + if echo "$content" | grep -qE "$JWT_RE"; then + echo "POSSIBLE JWT in $f:$line" + fail=1 + fi + done < <(grep -nE "sk-[A-Za-z0-9_-]|eyJ[A-Za-z0-9_-]" "$f" 2>/dev/null || true) +done + +if [ "$fail" -ne 0 ]; then + echo + echo "A real-looking credential is committed in docs. Replace it with a" + echo "placeholder (e.g. sk-EXAMPLE / <api-key> / \$(weknora auth token))." + exit 1 +fi +echo "no committed credentials found in docs" diff --git a/cli/scripts/check-skill-wire-vocab.sh b/cli/scripts/check-skill-wire-vocab.sh new file mode 100755 index 00000000..c6020cb5 --- /dev/null +++ b/cli/scripts/check-skill-wire-vocab.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# check-skill-wire-vocab.sh — fail if cli/skills/ still references wire +# vocabulary that the CLI has renamed or removed. Wire-shape changes must +# sweep skills in the same PR (pattern borrowed from larksuite/cli). +set -euo pipefail +cd "$(dirname "$0")/.." + +# legacy_term:replacement — extend this list in the SAME commit that +# renames/removes a flag, command, tool, or error code. +BANNED=( + "agent_invoke:session_ask (MCP tool renamed in v0.9)" + "agent invoke:session ask --agent (moved in v0.9)" + "mcp.readonly_mode:removed in v0.9 (never emitted)" + "mcp.tool_not_allowed:removed in v0.9 (never emitted)" + "mcp.schema_unknown_command:removed in v0.9 (never emitted)" + "auth login --host:profile add --host (auth login dropped --host in v0.9)" + "auth login --name:profile add <name> (auth login dropped --name in v0.9)" + "agent create --kb:agent create --attach-kb (renamed in v0.9)" +) + +fail=0 +for entry in "${BANNED[@]}"; do + term="${entry%%:*}" + why="${entry#*:}" + if hits=$(grep -rn --include='*.md' -F "$term" skills/ 2>/dev/null); then + echo "BANNED wire vocab '$term' found (use: $why):" + echo "$hits" + fail=1 + fi +done +exit $fail diff --git a/cli/skills/embed.go b/cli/skills/embed.go new file mode 100644 index 00000000..36108be6 --- /dev/null +++ b/cli/skills/embed.go @@ -0,0 +1,115 @@ +// Package skills embeds the bundled Agent Skills into the CLI binary so +// `weknora skills install` can write them out without a source checkout +// (replacing the manual symlink-from-checkout MVP). The markdown under each +// skill dir is the single source of truth — embedding it keeps the shipped +// binary and the docs in lockstep (the parity tests guard the command/flag +// vocabulary the skills reference). +package skills + +import ( + "embed" + "io/fs" + "path" + "sort" + "strings" +) + +//go:embed all:weknora-shared all:weknora-rag-search +var fsys embed.FS + +// Skill is one bundled skill: its directory name, the description parsed from +// its SKILL.md frontmatter, and the relative file paths it contains. +type Skill struct { + Name string `json:"name"` + Description string `json:"description"` + Files []string `json:"files"` +} + +// FS exposes the embedded skill tree for callers that want raw access. +func FS() fs.FS { return fsys } + +// List returns the bundled skills (top-level dirs that contain a SKILL.md), +// sorted by name. +func List() ([]Skill, error) { + entries, err := fs.ReadDir(fsys, ".") + if err != nil { + return nil, err + } + var skills []Skill + for _, e := range entries { + if !e.IsDir() { + continue + } + files, err := skillFiles(e.Name()) + if err != nil { + return nil, err + } + if !hasSkillMD(files) { + continue + } + skills = append(skills, Skill{ + Name: e.Name(), + Description: descriptionOf(e.Name()), + Files: files, + }) + } + sort.Slice(skills, func(i, j int) bool { return skills[i].Name < skills[j].Name }) + return skills, nil +} + +// skillFiles returns every file path under a skill dir (relative to the FS +// root, e.g. "weknora-shared/SKILL.md"), sorted. +func skillFiles(dir string) ([]string, error) { + var files []string + err := fs.WalkDir(fsys, dir, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() { + files = append(files, p) + } + return nil + }) + if err != nil { + return nil, err + } + sort.Strings(files) + return files, nil +} + +func hasSkillMD(files []string) bool { + for _, f := range files { + if path.Base(f) == "SKILL.md" { + return true + } + } + return false +} + +// descriptionOf reads the `description:` line from a skill's SKILL.md YAML +// frontmatter. Best-effort: returns "" if absent or unreadable. +func descriptionOf(dir string) string { + b, err := fs.ReadFile(fsys, path.Join(dir, "SKILL.md")) + if err != nil { + return "" + } + inFrontmatter := false + for _, line := range strings.Split(string(b), "\n") { + t := strings.TrimSpace(line) + if t == "---" { + if inFrontmatter { + break // end of frontmatter + } + inFrontmatter = true + continue + } + if inFrontmatter && strings.HasPrefix(t, "description:") { + return strings.TrimSpace(strings.TrimPrefix(t, "description:")) + } + } + return "" +} + +// ReadFile returns the bytes of one embedded file path (as reported in +// Skill.Files), for `skills install` to write to disk. +func ReadFile(p string) ([]byte, error) { return fsys.ReadFile(p) } diff --git a/cli/skills/weknora-shared/SKILL.md b/cli/skills/weknora-shared/SKILL.md index 85d796fe..81a6b46b 100644 --- a/cli/skills/weknora-shared/SKILL.md +++ b/cli/skills/weknora-shared/SKILL.md @@ -51,7 +51,7 @@ Default output is `--format json`: a single envelope. | `ok` | `true`/`false` — branch on this first (see batch/wait caveat below) | | `data` | success payload (object or array; absent on mutation-only success) | | `meta` | `count` / `has_more` / `dry_run` / `plan` … | -| `error` | on failure: `{type, message, hint?, retry_command?, risk?}` | +| `error` | on failure: `{type, message, hint?, retry_argv?, retryable?, risk?}` | | `profile` | active profile name | - `error.type` is a **stable typed code** (e.g. `local.kb_not_found`, @@ -98,14 +98,14 @@ exits **5** (adjust the value, retry). Branch on the exit code to tell them apar ## 5. Destructive writes (exit 10) — hard rule -Destructive commands (`kb/doc/chunk/session/agent delete`, `kb/agent edit`, +Destructive commands (`kb/doc/chunk/session/agent delete`, `kb/agent update`, `auth logout`, `doc delete --all`, …) without `-y` exit **10** with `error.type = input.confirmation_required` and `error.risk = {level, action}`: ```jsonc {"ok":false,"error":{"type":"input.confirmation_required", "message":"delete knowledge base X requires explicit confirmation: re-run with -y", - "retry_command":"weknora kb delete X -y","risk":{"level":"destructive","action":"kb.delete"}}} + "retry_argv":["weknora","kb","delete","X","-y"],"risk":{"level":"destructive","action":"kb.delete"}}} ``` **Surface this to the user and get explicit approval. Re-run with `-y` ONLY @@ -127,14 +127,31 @@ KB=$(weknora kb create "Docs" --jq '.data.id' --format json | tr -d '"') weknora doc upload ./manual.pdf --kb "$KB" ``` +## 7a. Reliability patterns for long-running agent runs + +### Inspecting prior messages +Use `weknora message list --session <sess-id>` to review the message history of a session (e.g., after a stream drops) before deciding whether to re-ask or continue. Use `weknora message search "<query>"` to locate a prior Q&A exchange across all sessions — prefer this over re-running an expensive query when the answer may already exist. + +### Tool-approval unlock +An agent run pauses mid-stream on a tool-approval event when the server requires human sign-off before executing a tool call. The pattern: + +1. The stream emits a tool-approval event; capture the `pending_id`. +2. **Surface the pending tool call to the user** (show tool name + proposed args). Do not auto-approve. +3. After explicit user go-ahead: `weknora session tool-approval resolve <pending-id> -y` to approve, or add `--reject --reason "..."` to reject. +4. Resume the answer: `weknora session continue-stream <sess-id> --message <msg-id>`. + +`--modified-args '{"key":"val"}'` replaces the tool arguments on approve (non-empty JSON object required). This is an exit-10 interaction — see §5. + ## 8. Resource model & command map ``` -kb knowledge bases list/view/create/edit/delete/pin/unpin/status/check -doc documents in a KB list/view/create/upload/fetch/download/delete/wait +kb knowledge bases list/view/create/update/delete/pin/unpin/status/check +doc documents in a KB list/view/create/upload/fetch/download/reparse/update/delete/wait chunk retrieval units list/view/delete (RAG debug; not search) -session conversations list/view/delete/ask/stop/continue-stream -agent custom agents list/view/create/edit/delete/status/check +session conversations list/view/delete/ask/stop/continue-stream/tool-approval resolve +message session messages list/search/delete +agent custom agents list/view/create/update/delete/status/check +model configured models list/view (read-only; find the id for agent create --model) search retrieval chunks / docs / kb / sessions chat one-shot KB RAG Q&A (streaming) api raw HTTP passthrough to any server endpoint (escape hatch) @@ -165,7 +182,7 @@ that to learn a command without scraping the human help table. | Mistake | Fix | |---|---| | `weknora auth login` before any profile exists | `profile add <n> --host <url> --use` first (§1) | -| Expecting `auth login --host/--name` flags | Gone — host comes from the profile; use global `--profile` | +| Passing `--host` or `--name` to `auth login` | `auth login` takes neither — host comes from the profile; use `profile add <n> --host <url> --use`, then `auth login` | | Auto-adding `-y` to clear an exit-10 | Never; get user approval first (§5) | | Need the raw `chat` event stream | pass `--format ndjson`; use `--format text` for a live projected transcript | | `search chunks "q"` → exit 1 `local.kb_id_required` | Pass `--kb <name-or-id>`, set `WEKNORA_KB_ID`, or `weknora link` the dir |