mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-09-01 14:53:07 +08:00
feat(cli): add agent-first affordance — envelope, exit-10, --dry-run
Borrows the lark-cli agent-affordance model (https://github.com/larksuite/cli/blob/main/AGENTS.md + skills/lark-shared/SKILL.md) so weknora is designed to be agent-friendly: error messages, output format, and flag design follow conventions agents can rely on. cli/AGENTS.md (operational reference for LLM agents invoking weknora): Public document covering envelope schema, exit-code protocol (0/1/2/10/130), stdout/stderr separation, and behavioral rules. Sensitive commands (\`context use\`, \`kb delete\`, \`doc delete\`, \`init\`) gain "AI agents:" paragraphs in their cobra Long descriptions so guidance shows in --help. format.Envelope schema additions: Risk per-operation classification (read / write / high-risk-write + action description), populated by write commands on both success and failure paths. Notice system advisories (CLI update available, server-CLI version skew); type defined, emit sites land in v0.3. DryRun marker for envelopes returned from --dry-run preview paths. RiskLevel constants realigned to lark's taxonomy: read / write / high-risk-write (was: read / mutating / destructive — not yet wired by any command). cmdutil.Error gains OperationRisk; PrintErrorEnvelope auto-attaches it to envelope.Risk so destructive failure paths surface uniformly. Exit-10 confirmation protocol: New ErrorCode \`input.confirmation_required\` mapped to exit code 10 in cmdutil.ExitCode. ConfirmDestructive now returns this code (with OperationRisk attached) when stdout is non-TTY or --json was set, with -y/--yes absent. Previous behavior — silent proceed in non-TTY — was unsafe: scripts and agents could delete resources with no explicit approval. Three test cases re-pinned around the new contract. This is a wire-contract change for any caller who relied on silent proceed; v0.0/v0.1 had no destructive commands, so the blast radius is contained to v0.2 itself. --dry-run global flag: cmd write paths (kb create/delete, doc upload/delete, api POST/PUT/PATCH/ DELETE) check cmdutil.IsDryRun(cmd) and skip the SDK call, emitting an envelope with dry_run=true plus a Risk classification. Read commands ignore --dry-run by design (no side effect to preview). Human-mode prints \`[dry-run] would <action>\` to stdout. Command discovery: agents introspect via the existing \`--help\` surface (consistent with gh / kubectl / aws / gcloud / terraform — none of them ship a CLI-tree self-description command). An earlier draft added a \`weknora schema\` reflection command; dropped after a mainstream survey found it has no stable analog (lark-cli's schema describes Lark API methods, not its own CLI tree). Tests: 27 cli packages pass at this commit. Added two new tests covering envelope.risk and envelope._notice serialization.
This commit is contained in:
@@ -30,6 +30,8 @@ type Options struct {
|
||||
Data string
|
||||
DataFile string
|
||||
JSONOut bool
|
||||
DryRun bool
|
||||
Yes bool
|
||||
}
|
||||
|
||||
// Service is the narrow SDK surface this command depends on. The production
|
||||
@@ -58,6 +60,20 @@ Examples:
|
||||
weknora api DELETE /api/v1/knowledge-bases/kb_xxx`,
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
opts.DryRun = cmdutil.IsDryRun(c)
|
||||
opts.Yes, _ = c.Flags().GetBool("yes")
|
||||
method := strings.ToUpper(args[0])
|
||||
// Escape-hatch DELETE through `weknora api` is just as destructive
|
||||
// as `weknora kb delete` — exit-10 protocol must apply (AGENTS.md).
|
||||
// Dry-run is read-only preview, so it skips confirmation.
|
||||
if !opts.DryRun && method == "DELETE" {
|
||||
if err := cmdutil.ConfirmDestructive(f.Prompter(), opts.Yes, opts.JSONOut, "endpoint", args[1]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if opts.DryRun {
|
||||
return runAPI(c.Context(), opts, nil, args[0], args[1])
|
||||
}
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -100,6 +116,21 @@ func runAPI(ctx context.Context, opts *Options, svc Service, methodArg, path str
|
||||
body = json.RawMessage(contents)
|
||||
}
|
||||
|
||||
// --dry-run only meaningful for write methods; GET/HEAD have no side
|
||||
// effect to preview, so we proceed normally even with --dry-run.
|
||||
if opts.DryRun && method != "GET" && method != "HEAD" {
|
||||
level := format.RiskWrite
|
||||
if method == "DELETE" {
|
||||
level = format.RiskHighRiskWrite
|
||||
}
|
||||
preview := map[string]any{"method": method, "path": path}
|
||||
if body != nil {
|
||||
preview["body"] = body
|
||||
}
|
||||
return cmdutil.EmitDryRun(opts.JSONOut, preview, nil,
|
||||
&format.Risk{Level: level, Action: fmt.Sprintf("%s %s", method, path)})
|
||||
}
|
||||
|
||||
resp, err := svc.Raw(ctx, method, path, body)
|
||||
if err != nil {
|
||||
// Transport / DNS failure (Raw never returns a typed HTTP error of its
|
||||
|
||||
@@ -11,8 +11,11 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -179,6 +182,73 @@ func TestAPI_PathWithoutSlash(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// withRootHarness wraps `weknora api ...` under a synthetic root cmd that
|
||||
// registers the global `-y/--yes` persistent flag (mirrors addGlobalFlags in
|
||||
// cmd/root.go). Required because api's NewCmd doesn't register --yes itself
|
||||
// — it inherits from root in production.
|
||||
func withRootHarness(api *cobra.Command, args ...string) *cobra.Command {
|
||||
root := &cobra.Command{Use: "weknora"}
|
||||
root.PersistentFlags().BoolP("yes", "y", false, "")
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.AddCommand(api)
|
||||
root.SetArgs(append([]string{"api"}, args...))
|
||||
root.SetContext(context.Background())
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
return root
|
||||
}
|
||||
|
||||
// TestAPI_DELETE_RequiresConfirmation pins the exit-10 protocol on the
|
||||
// escape-hatch DELETE path: agent invokes `weknora api DELETE /...` without
|
||||
// -y/--yes, must get input.confirmation_required + exit 10. Confirmation is
|
||||
// enforced in NewCmd.RunE (not runAPI), so the test drives the cobra cmd.
|
||||
func TestAPI_DELETE_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 := withRootHarness(NewCmd(f), "DELETE", "/api/v1/knowledge-bases/kb_xxx")
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected confirmation_required error for DELETE without -y")
|
||||
}
|
||||
var ce *cmdutil.Error
|
||||
if !asTypedError(err, &ce) || ce.Code != cmdutil.CodeInputConfirmationRequired {
|
||||
t.Errorf("want input.confirmation_required, got %v", err)
|
||||
}
|
||||
if got := cmdutil.ExitCode(err); got != 10 {
|
||||
t.Errorf("exit code = %d, want 10", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPI_DELETE_WithYes_Proceeds: -y/--yes opt-in skips confirmation and
|
||||
// dispatches to the SDK. Server returns 200 to verify the happy-path lands
|
||||
// on the response body emit.
|
||||
func TestAPI_DELETE_WithYes_Proceeds(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
called := false
|
||||
cli, stop := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
t.Errorf("expected DELETE, got %s", r.Method)
|
||||
}
|
||||
called = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
defer stop()
|
||||
f := &cmdutil.Factory{
|
||||
Client: func() (*sdk.Client, error) { return cli, nil },
|
||||
Prompter: func() prompt.Prompter { return prompt.AgentPrompter{} },
|
||||
}
|
||||
root := withRootHarness(NewCmd(f), "DELETE", "/api/v1/knowledge-bases/kb_xxx", "-y")
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if !called {
|
||||
t.Error("DELETE handler not called — confirmation may have blocked")
|
||||
}
|
||||
}
|
||||
|
||||
// asTypedError is a tiny wrapper around errors.As that keeps the call sites
|
||||
// concise. Returns true on success, populating dst.
|
||||
func asTypedError(err error, dst **cmdutil.Error) bool {
|
||||
|
||||
@@ -22,7 +22,11 @@ func NewCmdUse(f *cmdutil.Factory) *cobra.Command {
|
||||
|
||||
The active context is what every subsequent command uses for auth + host. The
|
||||
global --context flag (e.g. weknora --context staging kb list) overrides for
|
||||
one command without writing to disk.`,
|
||||
one command without writing to disk.
|
||||
|
||||
AI agents: Do NOT switch the active context unless the user explicitly asked
|
||||
you to. Context selection is a user preference; one-shot overrides should use
|
||||
the global --context flag instead, which writes nothing to disk.`,
|
||||
Example: ` weknora context use staging # persist switch
|
||||
weknora --context staging kb list # one-shot override (no disk write)
|
||||
weknora context use --help # this help`,
|
||||
|
||||
+18
-2
@@ -18,6 +18,7 @@ import (
|
||||
type DeleteOptions struct {
|
||||
Yes bool
|
||||
JSONOut bool
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// DeleteService is the narrow SDK surface this command depends on.
|
||||
@@ -41,13 +42,21 @@ func NewCmdDelete(f *cmdutil.Factory) *cobra.Command {
|
||||
Short: "Delete a document from a knowledge base",
|
||||
Long: `Permanently deletes one document. Prompts for confirmation by default
|
||||
when stdout is a TTY and --json is not set; pass -y/--yes (global flag) to skip
|
||||
the prompt (required in agent / CI / piped contexts).`,
|
||||
the prompt (required in agent / CI / piped contexts).
|
||||
|
||||
AI agents: This is a high-risk write. Without -y/--yes the CLI exits 10 and
|
||||
returns an envelope describing the missing confirmation. NEVER auto-pass -y
|
||||
without the user's explicit go-ahead.`,
|
||||
Example: ` weknora doc delete doc_abc # interactive confirm
|
||||
weknora doc delete doc_abc -y # no prompt
|
||||
weknora doc delete doc_abc -y --json # envelope output`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
opts.Yes, _ = c.Flags().GetBool("yes")
|
||||
opts.DryRun = cmdutil.IsDryRun(c)
|
||||
if opts.DryRun {
|
||||
return runDelete(c.Context(), opts, nil, f.Prompter(), args[0])
|
||||
}
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -61,6 +70,12 @@ the prompt (required in agent / CI / piped contexts).`,
|
||||
}
|
||||
|
||||
func runDelete(ctx context.Context, opts *DeleteOptions, svc DeleteService, p prompt.Prompter, id string) error {
|
||||
if opts.DryRun {
|
||||
return cmdutil.EmitDryRun(opts.JSONOut,
|
||||
deleteResult{ID: id, Deleted: false}, nil,
|
||||
&format.Risk{Level: format.RiskHighRiskWrite, Action: fmt.Sprintf("delete document %s", id)})
|
||||
}
|
||||
|
||||
if err := cmdutil.ConfirmDestructive(p, opts.Yes, opts.JSONOut, "document", id); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -70,7 +85,8 @@ func runDelete(ctx context.Context, opts *DeleteOptions, svc DeleteService, p pr
|
||||
}
|
||||
|
||||
if opts.JSONOut {
|
||||
return format.WriteEnvelope(iostreams.IO.Out, format.Success(deleteResult{ID: id, Deleted: true}, nil))
|
||||
risk := &format.Risk{Level: format.RiskHighRiskWrite, Action: fmt.Sprintf("deleted document %s", id)}
|
||||
return format.WriteEnvelope(iostreams.IO.Out, format.SuccessWithRisk(deleteResult{ID: id, Deleted: true}, nil, risk))
|
||||
}
|
||||
fmt.Fprintf(iostreams.IO.Out, "✓ Deleted document %s\n", id)
|
||||
return nil
|
||||
|
||||
@@ -128,15 +128,18 @@ func TestDelete_AgentPrompterErrors(t *testing.T) {
|
||||
assert.Equal(t, cmdutil.CodeInputMissingFlag, typed.Code)
|
||||
}
|
||||
|
||||
// TestDelete_NoForce_NonTTY_Proceeds: when stdout isn't a TTY (typical agent
|
||||
// pipe / CI), the confirm guard is skipped. This documents the existing
|
||||
// contract — destructive ops in a pipe rely on the caller having chosen to
|
||||
// pipe (intent expressed by the redirection) and on agents passing --force
|
||||
// explicitly. Mirrors `weknora kb delete`.
|
||||
func TestDelete_NoForce_NonTTY_Proceeds(t *testing.T) {
|
||||
// TestDelete_NoYes_NonTTY_RequiresConfirmation: when stdout isn't a TTY
|
||||
// (typical agent pipe / CI), the lark-cli skill protocol requires explicit
|
||||
// -y/--yes. The CLI exits 10 with input.confirmation_required, never
|
||||
// silently proceeds. See cli/AGENTS.md "Exit codes".
|
||||
func TestDelete_NoYes_NonTTY_RequiresConfirmation(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeDeleteSvc{}
|
||||
err := runDelete(context.Background(), &DeleteOptions{Yes: false}, svc, errPrompter{}, "doc_abc")
|
||||
require.NoError(t, err, "non-TTY non-json invocation should proceed without prompting")
|
||||
assert.Equal(t, 1, svc.calls)
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeInputConfirmationRequired, typed.Code)
|
||||
assert.Equal(t, 0, svc.calls, "non-TTY without -y must not call DeleteKnowledge")
|
||||
assert.Equal(t, 10, cmdutil.ExitCode(err))
|
||||
}
|
||||
|
||||
+14
-1
@@ -23,6 +23,7 @@ const uploadChannel = "api"
|
||||
type UploadOptions struct {
|
||||
Name string
|
||||
JSONOut bool
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// UploadService is the narrow SDK surface this command depends on.
|
||||
@@ -62,10 +63,14 @@ planned for v0.3.`,
|
||||
if err := validateUploadPath(path); err != nil {
|
||||
return err
|
||||
}
|
||||
opts.DryRun = cmdutil.IsDryRun(c)
|
||||
kbID, err := f.ResolveKB(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.DryRun {
|
||||
return runUpload(c.Context(), opts, nil, kbID, path)
|
||||
}
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -104,13 +109,21 @@ func validateUploadPath(path string) error {
|
||||
}
|
||||
|
||||
func runUpload(ctx context.Context, opts *UploadOptions, svc UploadService, kbID, path string) error {
|
||||
if opts.DryRun {
|
||||
return cmdutil.EmitDryRun(opts.JSONOut,
|
||||
map[string]string{"file": path, "kb_id": kbID, "name": opts.Name},
|
||||
&format.Meta{KBID: kbID},
|
||||
&format.Risk{Level: format.RiskWrite, Action: fmt.Sprintf("upload %s to kb %s", path, kbID)})
|
||||
}
|
||||
|
||||
k, err := svc.CreateKnowledgeFromFile(ctx, kbID, path, nil /*metadata*/, nil /*enableMultimodel*/, opts.Name, uploadChannel)
|
||||
if err != nil {
|
||||
return cmdutil.Wrapf(cmdutil.ClassifyHTTPError(err), err, "upload %s", path)
|
||||
}
|
||||
|
||||
if opts.JSONOut {
|
||||
return format.WriteEnvelope(iostreams.IO.Out, format.Success(k, &format.Meta{KBID: kbID}))
|
||||
risk := &format.Risk{Level: format.RiskWrite, Action: fmt.Sprintf("uploaded %s", path)}
|
||||
return format.WriteEnvelope(iostreams.IO.Out, format.SuccessWithRisk(k, &format.Meta{KBID: kbID}, risk))
|
||||
}
|
||||
displayed := opts.Name
|
||||
if displayed == "" {
|
||||
|
||||
@@ -52,7 +52,11 @@ directory (or any subdirectory) automatically resolve --kb-id from the link
|
||||
unless overridden by the --kb-id / --kb flags or WEKNORA_KB_ID env var.
|
||||
|
||||
Mirrors the npm init / cargo init / git init UX pattern: one-time setup that
|
||||
removes the need to re-pass --kb-id on every command.`,
|
||||
removes the need to re-pass --kb-id on every command.
|
||||
|
||||
AI agents: ` + "`init`" + ` writes to the user's working directory. Only run
|
||||
it when the user explicitly asked to link this directory — don't run it as a
|
||||
side effect of unrelated automation.`,
|
||||
Example: ` weknora init --kb-id kb_abc # explicit id
|
||||
weknora init --kb engineering --yes # name → id, no prompt
|
||||
weknora init # interactive (TTY)
|
||||
|
||||
+12
-3
@@ -20,6 +20,7 @@ type CreateOptions struct {
|
||||
Description string
|
||||
EmbeddingModel string
|
||||
JSONOut bool
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// CreateService is the narrow SDK surface this command depends on.
|
||||
@@ -36,6 +37,10 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command {
|
||||
Short: "Create a new knowledge base",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(c *cobra.Command, _ []string) error {
|
||||
opts.DryRun = cmdutil.IsDryRun(c)
|
||||
if opts.DryRun {
|
||||
return runCreate(c.Context(), opts, nil) // service unused on dry-run
|
||||
}
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -66,15 +71,19 @@ func runCreate(ctx context.Context, opts *CreateOptions, svc CreateService) erro
|
||||
req.EmbeddingModelID = opts.EmbeddingModel
|
||||
}
|
||||
|
||||
if opts.DryRun {
|
||||
return cmdutil.EmitDryRun(opts.JSONOut, req, nil,
|
||||
&format.Risk{Level: format.RiskWrite, Action: fmt.Sprintf("create knowledge base %q", opts.Name)})
|
||||
}
|
||||
|
||||
created, err := svc.CreateKnowledgeBase(ctx, req)
|
||||
if err != nil {
|
||||
return cmdutil.Wrapf(cmdutil.ClassifyHTTPError(err), err, "create knowledge base")
|
||||
}
|
||||
|
||||
if opts.JSONOut {
|
||||
return format.WriteEnvelope(iostreams.IO.Out, format.Success(created, &format.Meta{
|
||||
KBID: created.ID,
|
||||
}))
|
||||
risk := &format.Risk{Level: format.RiskWrite, Action: fmt.Sprintf("created knowledge base %s", created.ID)}
|
||||
return format.WriteEnvelope(iostreams.IO.Out, format.SuccessWithRisk(created, &format.Meta{KBID: created.ID}, risk))
|
||||
}
|
||||
fmt.Fprintf(iostreams.IO.Out, "✓ Created knowledge base %q (id: %s)\n", created.Name, created.ID)
|
||||
return nil
|
||||
|
||||
+19
-4
@@ -18,6 +18,7 @@ import (
|
||||
type DeleteOptions struct {
|
||||
Yes bool
|
||||
JSONOut bool
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// DeleteService is the narrow SDK surface this command depends on.
|
||||
@@ -44,13 +45,22 @@ func NewCmdDelete(f *cmdutil.Factory) *cobra.Command {
|
||||
Long: `Permanently deletes a knowledge base and all its contents.
|
||||
|
||||
Prompts for confirmation by default when stdout is a TTY and --json is not set.
|
||||
Pass -y/--yes (global flag) to skip the prompt (required in agent / CI / piped contexts).`,
|
||||
Pass -y/--yes (global flag) to skip the prompt (required in agent / CI / piped contexts).
|
||||
|
||||
AI agents: This is a high-risk write. Without -y/--yes the CLI exits 10 and
|
||||
returns an envelope describing the missing confirmation. NEVER auto-pass -y
|
||||
without the user's explicit go-ahead — the exit-10 protocol exists exactly to
|
||||
guard against unintended deletes.`,
|
||||
Example: ` weknora kb delete kb_abc # interactive confirm
|
||||
weknora kb delete kb_abc -y # no prompt
|
||||
weknora kb delete kb_abc -y --json # envelope output`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
opts.Yes, _ = c.Flags().GetBool("yes")
|
||||
opts.DryRun = cmdutil.IsDryRun(c)
|
||||
if opts.DryRun {
|
||||
return runDelete(c.Context(), opts, nil, f.Prompter(), args[0])
|
||||
}
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -64,6 +74,12 @@ Pass -y/--yes (global flag) to skip the prompt (required in agent / CI / piped c
|
||||
}
|
||||
|
||||
func runDelete(ctx context.Context, opts *DeleteOptions, svc DeleteService, p prompt.Prompter, id string) error {
|
||||
if opts.DryRun {
|
||||
return cmdutil.EmitDryRun(opts.JSONOut,
|
||||
deleteResult{ID: id, Deleted: false}, &format.Meta{KBID: id},
|
||||
&format.Risk{Level: format.RiskHighRiskWrite, Action: fmt.Sprintf("delete knowledge base %s", id)})
|
||||
}
|
||||
|
||||
if err := cmdutil.ConfirmDestructive(p, opts.Yes, opts.JSONOut, "knowledge base", id); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -73,9 +89,8 @@ func runDelete(ctx context.Context, opts *DeleteOptions, svc DeleteService, p pr
|
||||
}
|
||||
|
||||
if opts.JSONOut {
|
||||
return format.WriteEnvelope(iostreams.IO.Out, format.Success(deleteResult{ID: id, Deleted: true}, &format.Meta{
|
||||
KBID: id,
|
||||
}))
|
||||
risk := &format.Risk{Level: format.RiskHighRiskWrite, Action: fmt.Sprintf("deleted knowledge base %s", id)}
|
||||
return format.WriteEnvelope(iostreams.IO.Out, format.SuccessWithRisk(deleteResult{ID: id, Deleted: true}, &format.Meta{KBID: id}, risk))
|
||||
}
|
||||
fmt.Fprintf(iostreams.IO.Out, "✓ Deleted knowledge base %s\n", id)
|
||||
return nil
|
||||
|
||||
+35
-11
@@ -67,17 +67,22 @@ func TestDelete_NotFound(t *testing.T) {
|
||||
assert.Equal(t, cmdutil.CodeResourceNotFound, typed.Code)
|
||||
}
|
||||
|
||||
func TestDelete_NonTTY_NoPrompt_NoForce(t *testing.T) {
|
||||
// SetForTest uses bytes.Buffer for Out — IsStdoutTTY() = false. Confirm
|
||||
// path must be skipped entirely, so DeleteKnowledgeBase should run.
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
func TestDelete_NonTTY_NoYes_RequiresConfirmation(t *testing.T) {
|
||||
// SetForTest uses bytes.Buffer for Out — IsStdoutTTY() = false. Without
|
||||
// -y/--yes, exit-10 protocol fires (lark-cli skill protocol; AGENTS.md):
|
||||
// the CLI must NOT silently proceed in scripted contexts.
|
||||
iostreams.SetForTest(t)
|
||||
svc := &fakeDeleteSvc{}
|
||||
p := &confirmPrompter{}
|
||||
require.NoError(t, runDelete(context.Background(), &DeleteOptions{}, svc, p, "kb_nontty"))
|
||||
err := runDelete(context.Background(), &DeleteOptions{}, svc, p, "kb_nontty")
|
||||
|
||||
assert.True(t, svc.called, "non-TTY must skip prompt and proceed")
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeInputConfirmationRequired, typed.Code)
|
||||
assert.False(t, svc.called, "non-TTY without -y must not call DeleteKnowledgeBase")
|
||||
assert.False(t, p.asked, "non-TTY ⇒ Confirm is never invoked")
|
||||
assert.Contains(t, out.String(), "kb_nontty")
|
||||
assert.Equal(t, 10, cmdutil.ExitCode(err), "exit code 10 per lark-cli skill protocol")
|
||||
}
|
||||
|
||||
func TestDelete_JSONOutput(t *testing.T) {
|
||||
@@ -137,15 +142,34 @@ func TestDelete_ConfirmPrompterError(t *testing.T) {
|
||||
assert.False(t, svc.called)
|
||||
}
|
||||
|
||||
func TestDelete_JSONOut_SkipsPrompt(t *testing.T) {
|
||||
// Even on a TTY, --json indicates a scripted caller; don't prompt.
|
||||
out, _ := iostreams.SetForTestWithTTY(t)
|
||||
func TestDelete_JSONOut_NoYes_RequiresConfirmation(t *testing.T) {
|
||||
// Even on a TTY, --json indicates a scripted caller; cannot prompt.
|
||||
// Exit-10 protocol must fire when -y is absent.
|
||||
iostreams.SetForTestWithTTY(t)
|
||||
svc := &fakeDeleteSvc{}
|
||||
p := &confirmPrompter{}
|
||||
opts := &DeleteOptions{JSONOut: true}
|
||||
err := runDelete(context.Background(), opts, svc, p, "kb_jtty")
|
||||
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeInputConfirmationRequired, typed.Code)
|
||||
assert.False(t, p.asked, "--json must skip the prompt even on TTY")
|
||||
assert.False(t, svc.called, "--json without -y must not call DeleteKnowledgeBase")
|
||||
assert.Equal(t, 10, cmdutil.ExitCode(err))
|
||||
}
|
||||
|
||||
func TestDelete_JSONOut_WithYes_Proceeds(t *testing.T) {
|
||||
// --json + -y is the agent happy-path: scripted caller with explicit
|
||||
// approval. Must call SDK and emit envelope.
|
||||
out, _ := iostreams.SetForTestWithTTY(t)
|
||||
svc := &fakeDeleteSvc{}
|
||||
p := &confirmPrompter{}
|
||||
opts := &DeleteOptions{Yes: true, JSONOut: true}
|
||||
require.NoError(t, runDelete(context.Background(), opts, svc, p, "kb_jtty"))
|
||||
|
||||
assert.False(t, p.asked, "--json must skip the prompt even on TTY")
|
||||
assert.False(t, p.asked, "-y must skip the prompt")
|
||||
assert.True(t, svc.called)
|
||||
assert.Contains(t, out.String(), `"deleted":true`)
|
||||
}
|
||||
|
||||
+5
-2
@@ -1,8 +1,10 @@
|
||||
// Package cmd holds the cobra command tree. main.go calls Execute().
|
||||
//
|
||||
// v0.0 shipped: version / auth / search.
|
||||
// v0.1 adds: whoami / doctor / kb (list + get) / context (use).
|
||||
// v0.2 adds: init / link / kb (create + delete) / doc (list + upload + delete) / api / chat.
|
||||
// v0.1 adds: whoami / doctor / kb (list + view) / context (use).
|
||||
// v0.2 adds: init / link / kb (create + delete) / doc (list + upload + delete)
|
||||
// / api / chat. The kb view command is the primary; "get"
|
||||
// is preserved as a cobra alias for v0.0/v0.1 callers.
|
||||
package cmd
|
||||
|
||||
import (
|
||||
@@ -197,6 +199,7 @@ func addGlobalFlags(cmd *cobra.Command) {
|
||||
pf := cmd.PersistentFlags()
|
||||
pf.BoolP("yes", "y", false, "Skip confirmation prompts on destructive operations")
|
||||
pf.String("context", "", "Override the active context for this invocation (no disk write)")
|
||||
pf.Bool("dry-run", false, "Preview the operation without executing (write commands only; read commands ignore)")
|
||||
}
|
||||
|
||||
// agentAwareHelpFunc wraps cobra's default help to append the AI agent guidance
|
||||
|
||||
Reference in New Issue
Block a user