feat(cli)!: v0.10 reliability, agent-UX, and command-surface hardening

A hardening + finalization pass over the agent-first CLI: correctness fixes,
richer machine-readable signals, flag/naming consistency, and a symmetric
config surface. Pre-1.0, so it includes breaking renames.

Correctness:
- agent update: resolve/validate --model/--rerank-model (was storing a bogus
  name verbatim, corrupting config.model_id).
- doctor: honor WEKNORA_HOST / WEKNORA_API_KEY (headless path no longer reports
  "no host configured").
- session ask / MCP session_ask: text answer was empty on non-TTY — the agent
  stream sets Done=true on an intermediate agent_query frame before the answer,
  and AgentAccumulator treated the first Done as terminal. Terminate on the
  `complete` event (new sdk.AgentResponseTypeComplete), not a per-frame Done.
- batch exit codes: any per-item failure collapses to operation.failed (exit 1),
  including `doc upload --recursive` partial failures — a permanent per-file
  failure (e.g. a duplicate) no longer surfaces as a retryable exit 7 an agent
  would loop on; per-item typed errors stay in the envelope.

Agent-first signals & discovery:
- error.exit_code in the envelope (type + exit_code disambiguate the
  input.invalid_argument exit-2-vs-5 split in one JSON read).
- meta.hint on empty content search and on draft doc create; doc wait fails fast
  on a never-parsing draft instead of hanging to --timeout.
- retrieval-readiness is visible in the natural flow: kb status / kb check emit
  retrieval_ready, and kb create hints the fix when no embedding model is bound
  — an unconfigured KB no longer looks silently healthy.
- schema contract completeness: every leaf declares output + >=1 example
  (drift-guarded); output strings match the meta actually emitted; chunk list
  and search docs now emit meta.total_count (both previously dropped it).
- schema tolerates a quoted multi-word command label; zero-state auth — and
  `link` with no profile — point at profile setup / the headless WEKNORA_KB_ID
  path instead of looping on `auth login`.
- id-addressed reads tolerate a redundant --kb (doc view/wait, chunk list/view
  accept and ignore it, declared in schema) so a carried-over --kb doesn't
  exit 2; streaming commands warn that --jq does not apply to an NDJSON stream.
- keep JSON-always as the default; --jq hints spell out the .data path.

Consistency & gating:
- doc create: drop the deprecated --name alias (--title only; pre-1.0 break).
- chunk list --limit aligned to 1..10000; model list --limit/-L with
  has_more/total_count; api write-gates -X PUT/PATCH (exit 10); skills install
  expands a leading ~.
- docs corrected: search docs / doc list --keyword help is case-insensitive
  (server does LOWER LIKE); AGENTS.md risk-action list (no phantom kb.init; add
  model.update / kb.config.set) and batch example (failed item carries `error`);
  session resume --message id comes from `message list`, not the stream.
- auth/profile ergonomics: env credentials are now first-class — `auth token`
  prints the active WEKNORA_API_KEY / WEKNORA_TOKEN, and auth login/logout/refresh
  give an env-aware message instead of looping on "run auth login". `auth logout`
  clears credentials but keeps the profile registered (host preserved for
  re-login); deleting a profile is `profile remove`'s job (clean logout/remove
  separation, matching gh / lark).

Config surface (symmetric read/write, in-place model edits):
- kb config now returns a secret-free KBModelConfigView (was {}); `kb config`
  reads, new `kb config set` writes; `kb init` removed (misnomer).
- kb create --chat-model: retrieval-ready in one step.
- model update: edit a model in place (id preserved, references survive) —
  rotate --api-key-stdin, change base-url / display-name / etc.
- session continue-stream renamed to session resume.

Docs: AGENTS.md is the single wire-contract source; CHANGELOG slimmed; stale
kb-init / continue-stream references removed; skill wire-vocab guard extended.
AGENTS.md / weknora-shared SKILL document retrieval_ready (a KB needs an
embedding model to be searchable), that --jq does not apply to NDJSON
streams, and the env-credential-first auth path; the KB quickstart example
now creates a retrieval-ready KB.
This commit is contained in:
nullkey
2026-07-02 15:53:51 +08:00
committed by lyingbug
parent 040a734766
commit d3c03b2f34
95 changed files with 1711 additions and 436 deletions
+9 -5
View File
@@ -128,8 +128,12 @@ func resolveActiveProfile(f *cmdutil.Factory) (name, host string, err error) {
}
active := cfg.CurrentProfile
if active == "" {
return "", "", cmdutil.NewError(cmdutil.CodeAuthUnauthenticated,
"no active profile; run `weknora profile add <name> --host <h> --use` first")
msg := "no active profile; run `weknora profile add <name> --host <h> --use` first"
if envActive, kind := cmdutil.EnvCredential(); envActive {
msg = "no active profile to log in — you are already authenticated this session via " + kind +
"; `auth login` only persists a named profile, so run `weknora profile add <name> --host <h> --use` first if you want one"
}
return "", "", cmdutil.NewError(cmdutil.CodeAuthUnauthenticated, msg)
}
prof, ok := cfg.Profiles[active]
if !ok {
@@ -310,9 +314,9 @@ func applyUser(prof *config.Profile, user *sdk.AuthUser) {
// loginResult is the typed payload emitted by `--format json`. mode is derived from
// whether the server returned a user (password flow) vs API-key flow.
type loginResult struct {
Profile string `json:"profile"`
Host string `json:"host"`
Mode string `json:"mode"` // ModeBearer or ModeAPIKey
Profile string `json:"profile"`
Host string `json:"host"`
Mode string `json:"mode"` // ModeBearer or ModeAPIKey
// 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.
+18 -9
View File
@@ -61,6 +61,10 @@ accepted until it expires.`,
return cfgErr
}
if len(cfg.Profiles) == 0 {
if active, kind := cmdutil.EnvCredential(); active {
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated,
"authenticated via "+kind+" (stateless env credential) — nothing is stored to log out; unset "+kind+" to drop it")
}
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated, "no profiles configured; nothing to log out")
}
if _, err := pickLogoutTargets(opts, cfg); err != nil {
@@ -86,7 +90,7 @@ accepted until it expires.`,
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
cmdutil.SetRisk(cmd, "auth.logout")
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "clear stored credentials for the active profile (or all) and remove the profile from config",
UsedFor: "clear stored credentials for the active profile (or --all); the profile itself stays registered (host preserved) so `auth login` can re-auth it — use `profile remove` to delete the profile entirely",
Examples: []string{
"weknora auth logout",
"weknora --profile staging auth logout",
@@ -107,6 +111,10 @@ func runLogout(opts *LogoutOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Fac
return err
}
if len(cfg.Profiles) == 0 {
if active, kind := cmdutil.EnvCredential(); active {
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated,
"authenticated via "+kind+" (stateless env credential) — nothing is stored to log out; unset "+kind+" to drop it")
}
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated, "no profiles configured; nothing to log out")
}
@@ -131,14 +139,15 @@ func runLogout(opts *LogoutOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Fac
}
for _, name := range targets {
clearProfileSecrets(store, cfg.Profiles[name], name)
delete(cfg.Profiles, name)
}
// If we removed the active profile, pick a remaining one (deterministic by
// map order would be flaky - leave CurrentProfile empty so the next
// invocation surfaces a clear "no current profile" error rather than
// silently switching).
if _, stillExists := cfg.Profiles[cfg.CurrentProfile]; !stillExists {
cfg.CurrentProfile = ""
// Keep the profile registered (its host stays) — only clear the
// credential refs so it reads as logged-out and can be re-authed with
// `auth login`. Deleting the profile entirely is `profile remove`'s job;
// that clean logout(who)/remove(what) split matches gh / lark.
p := cfg.Profiles[name]
p.APIKeyRef = ""
p.TokenRef = ""
p.RefreshRef = ""
cfg.Profiles[name] = p
}
if err := config.Save(cfg); err != nil {
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "save config")
+16 -5
View File
@@ -48,8 +48,12 @@ func TestLogout_CurrentProfile(t *testing.T) {
}
require.NoError(t, runLogout(&LogoutOptions{Yes: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newLogoutFactory(t, cfg, store)))
assert.Empty(t, cfg.CurrentProfile, "current_profile should clear when removed")
assert.NotContains(t, cfg.Profiles, "prod")
assert.Equal(t, "prod", cfg.CurrentProfile, "active profile stays selected — logout clears creds, not the profile")
require.Contains(t, cfg.Profiles, "prod", "profile stays registered after logout")
assert.Empty(t, cfg.Profiles["prod"].APIKeyRef, "credential ref cleared")
assert.Empty(t, cfg.Profiles["prod"].TokenRef, "credential ref cleared")
assert.Empty(t, cfg.Profiles["prod"].RefreshRef, "credential ref cleared")
assert.Equal(t, "https://prod", cfg.Profiles["prod"].Host, "host preserved for re-login")
assert.Contains(t, cfg.Profiles, "staging", "non-target profile untouched")
// Secrets gone for the removed profile, kept for the survivor.
@@ -81,7 +85,8 @@ func TestLogout_ActiveProfileViaOverride(t *testing.T) {
}
require.NoError(t, runLogout(&LogoutOptions{Yes: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newLogoutFactory(t, cfg, store)))
assert.NotContains(t, cfg.Profiles, "staging", "active profile (staging) is the target")
require.Contains(t, cfg.Profiles, "staging", "target profile stays registered (creds cleared, not removed)")
assert.Empty(t, cfg.Profiles["staging"].APIKeyRef, "target's credential ref cleared")
assert.Contains(t, cfg.Profiles, "prod", "non-target profile untouched")
}
@@ -98,8 +103,14 @@ func TestLogout_All(t *testing.T) {
}
require.NoError(t, runLogout(&LogoutOptions{All: true, Yes: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newLogoutFactory(t, cfg, store)))
assert.Empty(t, cfg.Profiles)
assert.Empty(t, cfg.CurrentProfile)
// --all clears every profile's credentials but keeps the profiles registered.
require.NotEmpty(t, cfg.Profiles, "profiles stay registered after logout --all")
for name, p := range cfg.Profiles {
assert.Empty(t, p.APIKeyRef, "%s api-key ref cleared", name)
assert.Empty(t, p.TokenRef, "%s token ref cleared", name)
assert.Empty(t, p.RefreshRef, "%s refresh ref cleared", name)
}
assert.Equal(t, "prod", cfg.CurrentProfile, "active selection is preserved; logout clears creds, not the profile")
}
func TestLogout_NoProfiles(t *testing.T) {
+12
View File
@@ -62,6 +62,10 @@ refresh semantic. Rotate the key in the server UI instead.`,
}
name := cfg.CurrentProfile
if name == "" {
if active, kind := cmdutil.EnvCredential(); active {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument,
"authenticated via "+kind+" (stateless env credential): there is no stored JWT to refresh — env credentials are supplied fresh each call, so no refresh is needed")
}
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated,
"no active profile configured; run `weknora auth login` to set one up")
}
@@ -99,6 +103,10 @@ refresh semantic. Rotate the key in the server UI instead.`,
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "Renew the JWT access token for the active profile (override with the global --profile) using the stored refresh token. API-key profiles are rejected.",
Output: "envelope.data has profile name that was refreshed",
Examples: []string{
"weknora auth refresh",
"weknora --profile staging auth refresh",
},
})
return cmd
}
@@ -117,6 +125,10 @@ func runRefresh(ctx context.Context, opts *RefreshOptions, fopts *cmdutil.Format
}
name := cfg.CurrentProfile
if name == "" {
if active, kind := cmdutil.EnvCredential(); active {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument,
"authenticated via "+kind+" (stateless env credential): there is no stored JWT to refresh — env credentials are supplied fresh each call, so no refresh is needed")
}
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated,
"no active profile configured; run `weknora auth login` to set one up")
}
+22 -1
View File
@@ -2,6 +2,7 @@ package auth
import (
"fmt"
"os"
"github.com/spf13/cobra"
@@ -90,6 +91,16 @@ to see which mode each profile uses, and construct the matching HTTP header:
}
func runToken(f *cmdutil.Factory, fopts *cmdutil.FormatOptions) error {
// Env credentials are the active credential on the headless path — `auth
// token` must surface them (they ARE the token / api key) instead of
// erroring on the absence of a stored profile.
if active, kind := cmdutil.EnvCredential(); active {
mode := ModeBearer
if kind == "WEKNORA_API_KEY" {
mode = ModeAPIKey
}
return emitToken(fopts, os.Getenv(kind), mode, "(env)")
}
cfg, err := f.Config()
if err != nil {
return err
@@ -140,8 +151,18 @@ func runToken(f *cmdutil.Factory, fopts *cmdutil.FormatOptions) error {
fmt.Sprintf("profile %q credential is empty in keyring; run `weknora auth login`", profileName))
}
return emitToken(fopts, token, mode, profileName)
}
// emitToken renders a resolved credential: the {token, mode, profile} envelope
// under --format json, else the raw token on stdout (no trailing newline, for
// clean $(weknora auth token) capture) with a TTY-only leak hint on stderr.
func emitToken(fopts *cmdutil.FormatOptions, token, mode, profile string) error {
if token == "" {
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated, "active credential is empty")
}
if fopts.WantsJSON() {
return fopts.Emit(iostreams.IO.Out, tokenResult{Token: token, Mode: mode, Profile: profileName}, nil)
return fopts.Emit(iostreams.IO.Out, tokenResult{Token: token, Mode: mode, Profile: profile}, nil)
}
// No trailing newline - clean $(weknora auth token) substitution.