chore(cli): polish + docs sync + pre-PR audit fixes

Code-reuse polish (post-implementation review pass):
- Extract text.OneLine(maxWidth, s) helper combining preview-row
  normalization (newline/CR/tab → space) with text.Truncate's
  UTF-8-safe truncation. Replaces agent/view.go truncate1Line (ASCII
  '...' + byte-slice CJK-unsafe) and chunk/list.go singleLine.
- Lift cmdutil.OpenInput(path) for the '-' = stdin / else os.Open
  pattern shared across agent create/edit and the api command.
  Replaces agent/create.go's private openInput.
- Strip inline doc-spec parentheticals from source comments — those
  belong in commit messages and project docs, not in source where
  they rot.

Pre-PR audit fixes:
- doc upload: reject `--metadata` paired with `--from-url` as
  input.invalid_argument up-front (the URL-ingest request type has
  no metadata field server-side, so the pair would otherwise silently
  drop). Long help and CHANGELOG updated to call out the asymmetry.
- doc upload (file path): map sdk.ErrDuplicateFile sentinel to
  resource.already_exists. The sentinel arrives with no "HTTP error <n>:"
  prefix because the SDK short-circuits on file-hash before reading the
  HTTP status, so the previous WrapHTTP fall-through misclassified it
  as network.error with a misleading "check base URL reachability" hint.
  The --from-url branch already handled ErrDuplicateURL this way; this
  closes the asymmetry. Caught by e2e re-upload of an already-ingested
  file; regression test added.
- README exit-10 enumeration adds `agent delete` and `chunk delete`
  (these were missing alongside the v0.5 destructive verbs they were
  meant to gate).

Docs sync:
- cli/README.md: command tree now includes the chunk subtree; adds
  agent / chunk lines to the 5-minute quickstart; adds a "Contributing
  / Reporting issues" section pointing at the repo's SECURITY.md and
  AGENTS.md; drops third-party CLI parallels from the surface
  description.
- cli/AGENTS.md: "Command surface design SOP" gains the
  flag-vs-escape-hatch step. "CRUD command flag canon" renamed to the
  hard-required-flags pattern with the contrast (TTY-prompts-fill)
  defined inline rather than via opaque shorthand.
- cli/CHANGELOG.md: search docs case-sensitivity shift promoted to its
  own #### Breaking changes subsection. MCP doc_list filter count
  corrected from 5 to 6. Drops the bogus go.mod yaml.v3 entry (yaml.v3
  was already a dependency on main; v0.5 added zero go.mod lines).
  Replaces internal-Go identifiers (fuzzyTime, NoOptDefVal) with
  user-language and drops the § section-symbol jargon.
This commit is contained in:
nullkey
2026-05-16 02:42:18 +08:00
committed by lyingbug
parent f89d54362d
commit c87e35b34b
35 changed files with 339 additions and 182 deletions
+7 -4
View File
@@ -26,7 +26,7 @@ Key packages:
- `internal/format/` — bare JSON emitter (`WriteJSON` / `WriteJSONFiltered`)
- `internal/iostreams/` — global IO singleton + TTY detection + `SetForTest` swap
- `internal/secrets/``Store` interface; `KeyringStore` primary, `FileStore` 0600 fallback, `MemStore` for tests
- `internal/prompt/``TTYPrompter` (huh-based, password no-echo) + `AgentPrompter` (non-TTY no-prompt sentinel)
- `internal/prompt/``TTYPrompter` (password no-echo) + `AgentPrompter` (non-TTY no-prompt sentinel)
- `internal/sse/``Accumulator` for chat / agent invoke SSE streams
- `internal/mcp/` — curated 10-tool stdio MCP server (wired by `cmd/mcp/serve.go`); see [MCP tool surface](#mcp-tool-surface) for the curation rationale and inventory
- `client/` (parent module) — generated SDK
@@ -195,21 +195,24 @@ Before specifying any CLI command, do this in order:
3. For each field, decide: hot-path flag / config-file only / hidden / never-expose.
4. Cross-check pagination signatures: an SDK `(ctx, id, page, pageSize)` shape demands `--limit` + `--all-pages` + `--page-size` on the CLI side.
5. ONLY THEN consult mainstream CLI conventions to choose flag names, positionals, mutex, and confirm semantics.
6. Decide which fields are "top use case" (flag) / "advanced" (`--config-file` or escape hatch via `weknora api`). Don't try to flag-cover every SDK field — mature CLIs that curate ship a tighter surface; CLIs that 1:1 mirror their API pay the UX cost.
Rationale: earlier drafts produced three categories of schema errors — fields that didn't exist on the underlying SDK, wrong field counts in user-facing docs, and missing pagination flags — that all stemmed from "design from convention, not from SDK." The fix is canonical: the SDK schema is the ground truth; convention decides names and shapes around that ground truth.
## CRUD command flag canon
v0.5+ follows **Mode A: hard-required + immediate flag error** for CRUD commands, not Mode B: TTY-prompts-fill (used by `auth login` only). Mode A is the standard pattern for scripted / agent-friendly CLI usage.
CRUD commands follow the **hard-required-flags** pattern: every required input is a flag or positional, and a missing one yields an immediate `input.invalid_argument` exit. The contrast is **TTY-prompts-fill**, where missing input opens an interactive prompt; that pattern is reserved for `auth login` (the one command where a human must be at the terminal).
Required-input idioms in this codebase:
- Positional required: `cobra.ExactArgs(N)` or `cobra.MinimumNArgs(1)`
- Flag required: `cmd.MarkFlagRequired("flag")`
- Custom required (e.g., `agent edit` needs at-least-one-edit-flag): RunE-level validation that returns `input.invalid_argument`
- Mutex: `cmd.MarkFlagsMutuallyExclusive("a", "b")`
Reasons for Mode A:
Reasons hard-required-flags is the v0.5+ default:
- Admin/debug commands are not `auth login` — there is no human-interactive prompt to lean on.
- Admin / debug commands have no natural human-interactive prompt to lean on.
- Agent-friendly: MCP callers do not stall waiting for stdin prompts.
- Consistent with every existing non-auth WeKnora command.
+53 -25
View File
@@ -16,9 +16,10 @@ CLI history before v0.3 is recorded in the project root
#### Added
- `weknora agent create <name> --model <id>` / `agent edit <id>` /
`agent delete <id>` — hybrid surface (8 hot-path flags + `--config-file`
YAML/JSON tail + `--generate-skeleton` template emit). `--from <agent-id>`
copies from an existing agent.
`agent delete <id>` — hybrid surface (hot-path flags for the common
fields + `--config-file` YAML/JSON for the long tail +
`--generate-skeleton` template emit). `--from <agent-id>` copies
from an existing agent.
- `weknora chunk list --doc <doc-id>` / `chunk view <chunk-id>` /
`chunk delete <chunk-id> --doc <doc-id>` — new subtree for RAG retrieval
debug. Paginated with v0.4 `--limit` / `--page-size` / `--all-pages` canon.
@@ -31,7 +32,7 @@ CLI history before v0.3 is recorded in the project root
`--tag-id` / `--start-time` / `--end-time` (RFC3339) — matches the
SDK's `KnowledgeListFilter` surface. Time flags reject malformed
input with `input.invalid_argument`.
- MCP `doc_list` tool gains the same 5 filter fields (`keyword`,
- MCP `doc_list` tool gains the same 6 filter fields (`keyword`,
`file_type`, `source`, `tag_id`, `start_time`, `end_time`) so agents
have parity with the CLI.
- `weknora session view --full` (with `--limit`, default 50, bounds
@@ -47,29 +48,57 @@ CLI history before v0.3 is recorded in the project root
distinct from filename), `DESC`, `SOURCE`, `CHANNEL`, `TAG`,
`STORAGE` (human-readable bytes), `SUMMARY`, `ENABLED`, and `HASH`
(12-char prefix). All omit-empty.
- `weknora doc upload` gains `--enable-multimodel` (tri-state:
unset/true/false), repeatable `--metadata key=value`, and
`--channel` flags. `--enable-multimodel` and `--channel` apply to
file / `--recursive` / `--from-url`; `--metadata` is file /
`--recursive` only (the URL-ingest request carries no metadata
field server-side, so passing it with `--from-url` is rejected
up-front as `input.invalid_argument`). URL mode additionally
accepts `--title`, `--file-type`, and `--tag-id`. Threads through
to the SDK's `CreateKnowledgeFromFile` / `CreateKnowledgeFromURL`
signatures (previously hardcoded to nil/"api" and dropped URL
extras).
#### Fixed
- MCP `search_chunks` tool: `limit` arg now correctly threads into
`SearchParams.MatchCount`. Previously the server's default cap won,
silently capping below the requested limit.
- `search sessions` human time format: now uses `fuzzyTime` like
`session list` instead of raw RFC3339.
- `search sessions` human time format: now renders a relative
duration ("2 hours ago") matching `session list`, instead of raw
RFC3339.
- `doc upload` (file path): re-uploading a file already ingested into
the KB now surfaces as `resource.already_exists` (exit 1) instead of
the misleading `network.error` ("check base URL reachability"). The
SDK returns its `ErrDuplicateFile` sentinel with no `HTTP error <n>:`
prefix because the duplicate is detected via file-hash short-circuit,
not by HTTP status; the previous fall-through to `WrapHTTP` therefore
misclassified it. The `--from-url` branch already handled the
symmetric `ErrDuplicateURL` correctly.
#### Breaking changes
- `weknora search docs` now applies the keyword filter server-side via
`ListKnowledgeWithFilter` (was: page through every doc and
substring-match client-side). Smaller wire payload on large KBs.
**The match is now case-sensitive** (server uses `LIKE %keyword%`),
whereas the previous client-side path lowered both sides. Callers
that relied on case-insensitive matching (e.g. `search docs Q3`
finding `q3 retro`) must lower-case the query themselves, or fall
back to `weknora api` with a custom filter.
#### Changed
- `cli/AGENTS.md` MCP curation rationale rewritten: curated read-only
is a deliberate product call gated on server-side token scope, not
MCP industry canon.
- `cli/AGENTS.md` adds §"Command surface design SOP" and
§"CRUD command flag canon" for v0.6+ contributors.
- `cli/go.mod`: adds `gopkg.in/yaml.v3` for `agent create --config-file`.
- `weknora search docs` now applies the keyword filter server-side via
`ListKnowledgeWithFilter` (was: page through every doc and substring-
match client-side). Smaller wire payload on large KBs. **Semantics
shift**: the match is now case-sensitive (server uses `LIKE %keyword%`),
whereas the previous client-side path lowered both sides. Callers that
relied on case-insensitive matching (e.g. `search docs Q3` finding
`q3 retro`) must lower-case the query, or fall back to `weknora api`
with a custom filter.
is a deliberate product call gated on the absence of server-side
per-token scope. When server-side scope ships, mutation tools can
land in the MCP surface.
- `cli/AGENTS.md` adds "Command surface design SOP" and "CRUD command
flag canon" sections for future contributors. The design-SOP
section includes a step reminding contributors to decide
flag-vs-escape-hatch per field rather than trying to flag-mirror
every SDK capability.
- `cli/README.md` now documents the `weknora api` raw HTTP passthrough
as the canonical escape hatch for deep KB config, per-request `chat`
/ `agent invoke` overrides, and operations without a CLI verb.
### v0.4 — output contract hardening and mainstream alignment
@@ -82,10 +111,10 @@ CLI history before v0.3 is recorded in the project root
non-TTY callers that omit `-y` exit with code 10 and
`input.confirmation_required` so an agent must surface the prompt
to a human before retrying.
- Dropped the per-command AI footer that rendered when `CLAUDECODE`
or `CURSOR_AGENT` was set. The same machine-readable guidance now
lives in the standard `--help` (visible to all callers) and in
`mcp serve`'s tool descriptions.
- Dropped the per-command AI footer that rendered when AI-coding-agent
env detection fired. The same machine-readable guidance now lives in
the standard `--help` (visible to all callers) and in `mcp serve`'s
tool descriptions.
#### Added
- `weknora mcp serve` — curated read-only stdio MCP server exposing 9
@@ -152,8 +181,7 @@ CLI history before v0.3 is recorded in the project root
passthrough (file or stdin); mutually exclusive with `--data`.
- `unlink` — remove the cwd's `.weknora/project.yaml` so subsequent
commands stop auto-resolving `--kb` from it. Walks up from cwd so a
user in a subdirectory can unlink without cd-ing to the project root
(mirrors `vercel unlink` / `netlify unlink`).
user in a subdirectory can unlink without cd-ing to the project root.
- Completion smoke test guards against cobra bumps silently breaking
bash / zsh / fish / powershell completion.
+54 -9
View File
@@ -14,6 +14,7 @@ Available Commands:
api Make a raw API request to the WeKnora server
auth Manage authentication credentials and contexts
chat Ask a streaming RAG question against a knowledge base
chunk Manage document chunks (RAG retrieval debug)
completion Generate the autocompletion script for the specified shell
context Manage CLI contexts (named connection targets)
doc Manage documents in a knowledge base
@@ -28,10 +29,9 @@ Available Commands:
version Show CLI build metadata
```
The command surface mirrors `gh` CLI's `<noun> <verb>` convention. The
wire contract for AI agents (Claude Code, Cursor, Aider, …) is documented
[below](#wire-contract). For contributing to the CLI source, see
[AGENTS.md](AGENTS.md).
The command surface follows a `<noun> <verb>` convention. The wire
contract for AI agents is documented [below](#wire-contract). For
contributing to the CLI source, see [AGENTS.md](AGENTS.md).
---
@@ -78,6 +78,13 @@ weknora search chunks "what is reciprocal rank fusion?"
# 7. Ask the LLM (streams to terminal)
weknora chat "summarise the design doc"
# 8. Manage custom agents (full hybrid surface: see `weknora agent --help`)
weknora agent list
weknora agent invoke ag_abc "what's our q4 retention plan?"
# 9. Inspect a document's chunks for RAG retrieval debug
weknora chunk list --doc doc_xyz
```
---
@@ -179,9 +186,9 @@ The full code registry is in `cli/internal/cmdutil/errors.go`
**Exit 10** is the wire-level signal for "destructive write needs
explicit confirmation". Pass `-y/--yes` on `kb delete` / `kb empty` /
`doc delete` / `session delete` / `context remove` (on the current
context) when running headless. **Never auto-add `-y` without the
user's explicit go-ahead** — exit 10 is the guard against unintended
writes.
context) / `agent delete` / `chunk delete` when running headless.
**Never auto-add `-y` without the user's explicit go-ahead** — exit 10
is the guard against unintended writes.
### Other agent ergonomics
@@ -189,8 +196,32 @@ writes.
— streaming tokens to stdout makes JSON parsing impossible.
- `--json` composes with the global `--context <name>` for single-shot
context overrides without disk writes.
- `weknora mcp serve` exposes a curated readonly tool surface over
stdio MCP for Claude Desktop / Code / custom MCP clients.
- `weknora mcp serve` exposes a curated read-only tool surface over
stdio MCP for any MCP-compatible client.
---
## Advanced operations not exposed as flags
WeKnora CLI exposes top use cases as polished commands; deep
configuration goes through the raw HTTP passthrough. CLI flag coverage
targets common workflows, not 1:1 API parity. Examples of deep
operations that intentionally go through `weknora api`:
- **Tuning a KB's nested config** — chunking strategy, summary model,
multimodal extraction defaults, FAQ thresholds, VLM model, storage
provider. Use `weknora api PUT /api/v1/knowledge-bases/<id> --input -`
with a JSON body matching the server's `UpdateKnowledgeBaseRequest`.
- **Per-request `chat` parameters** — multi-KB scope, summary model
override, image attachments, web search toggle. Use `weknora api POST
/api/v1/knowledge-chat/<session-id> --input -`.
- **Per-request `agent invoke` overrides** — same shape via
`weknora api POST /api/v1/agent-chat/<session-id> --input -`.
- **Operations without a CLI verb** — register / change-password /
OIDC flows, organization / sharing endpoints, tenant management.
`weknora api --help` documents the raw passthrough. Run
`weknora doctor` first to verify auth and base URL.
---
@@ -221,6 +252,20 @@ macOS / Windows × Go 1.26, path-filtered to changes under `cli/`.
---
## Contributing / Reporting issues
- **Bugs and feature requests**: file an issue at
[github.com/Tencent/WeKnora/issues](https://github.com/Tencent/WeKnora/issues).
- **Security disclosures**: see the repository-level
[SECURITY.md](../SECURITY.md). Do not file public issues for
security findings.
- **Pull requests**: the developer guide for editing the CLI lives in
[AGENTS.md](AGENTS.md) (build / test / command-surface design SOP /
CRUD flag canon). Run `go test ./... -race -count=1` and `go vet ./...`
before submitting.
---
## License
MIT — see the repository [LICENSE](../LICENSE).
-2
View File
@@ -35,8 +35,6 @@ func TestMain(m *testing.M) {
// update is the standard Go test golden-update flag.
//
// go test -update ./acceptance/contract/...
//
// Mirrors gh / kubectl / golang-migrate convention.
var update = flag.Bool("update", false, "update golden files")
// newTestFactory builds a Factory whose Client returns mockClient.
+4 -4
View File
@@ -157,10 +157,10 @@ var wireCases = []wireCase{
wantStderrSubstring: "auth.unauthenticated",
},
// 11-13. search chunks - verb-noun shape (gh search parity), positional query, --kb required.
// --kb accepts either kb_<id> (passed through) or a name (resolved via
// list); UUID-format detection happens client-side, mirroring gcloud
// --project's id-or-name auto-detection.
// 11-13. search chunks - verb-noun shape, positional query, --kb required.
// --kb accepts either a kb_<id> (passed through) or a name (resolved via
// list); UUID-format detection happens client-side so callers can use
// either form interchangeably.
{
name: "search.success",
args: []string{"search", "chunks", "query", "--kb=11111111-1111-4111-8111-111111111111", "--limit=3", "--json"},
+2 -2
View File
@@ -4,8 +4,8 @@
// server to validate the RAG closing loop end-to-end.
//
// Build tag isolation: //go:build acceptance_e2e excludes this file from
// the default `go test ./...` (mirrors gh's acceptance/ build tag pattern;
// see https://github.com/cli/cli/tree/trunk/acceptance). To run:
// the default `go test ./...` so the e2e suite only runs when explicitly
// requested. To run:
//
// cd cli
// WEKNORA_E2E_HOST=https://kb.example.com \
+10 -20
View File
@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
@@ -58,11 +57,11 @@ type createFlagSet struct {
kbsSet bool
}
const agentCreateExample = ` weknora agent create "Support Bot" --model gpt-4
weknora agent create "Code Reviewer" --model gpt-4 --system-prompt-file ./prompt.md --kb kb_eng --kb kb_arch
weknora agent create "From Template" --model gpt-4 --from ag_existing
const agentCreateExample = ` weknora agent create "Support Bot" --model <model-id>
weknora agent create "Code Reviewer" --model <model-id> --system-prompt-file ./prompt.md --kb kb_eng --kb kb_arch
weknora agent create "From Template" --model <model-id> --from ag_existing
weknora agent create --generate-skeleton > my-agent.yaml
weknora agent create "Tuned" --model gpt-4 --config-file ./my-agent.yaml`
weknora agent create "Tuned" --model <model-id> --config-file ./my-agent.yaml`
const agentCreateLong = `Create a new custom agent.
@@ -114,17 +113,16 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command {
opts.flags.kbSelectionModeSet = cmd.Flags().Changed("kb-selection-mode")
opts.flags.kbsSet = cmd.Flags().Changed("kb")
// L-5 / §1.5.1: --temperature is bounded 0.0..2.0. Reject
// out-of-range early with a typed input.invalid_argument so
// users don't burn a roundtrip on a value the server would
// also reject.
// --temperature is bounded 0.0..2.0. Reject out-of-range
// early with a typed input.invalid_argument so users don't
// burn a roundtrip on a value the server would also reject.
if opts.flags.temperatureSet && (opts.Temperature < 0.0 || opts.Temperature > 2.0) {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument,
fmt.Sprintf("--temperature must be in 0.0..2.0, got %g", opts.Temperature))
}
if systemPromptFile != "" {
r, err := openInput(systemPromptFile)
r, err := cmdutil.OpenInput(systemPromptFile)
if err != nil {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, fmt.Sprintf("--system-prompt-file: %v", err))
}
@@ -219,7 +217,7 @@ func runCreate(ctx context.Context, opts *CreateOptions, jopts *cmdutil.JSONOpti
base = *copied.Config
}
// --kb on --from REPLACES the copied KB list (per spec); when --kb
// --kb on --from REPLACES the copied KB list; when --kb
// is not set, KnowledgeBasesSet stays false inside applyCreateOverrides
// and the copy's KB list passes through unchanged.
cfg := applyCreateOverrides(&base, opts)
@@ -278,18 +276,10 @@ func applyCreateOverrides(base *sdk.AgentConfig, opts *CreateOptions) *sdk.Agent
return cfg
}
// openInput returns a Reader for path; "-" means the global stdin.
func openInput(path string) (io.Reader, error) {
if path == "-" {
return iostreams.IO.In, nil
}
return os.Open(path)
}
// openConfigFile returns a Reader, the detected kind ("yaml"/"json"), or
// an error. Format is inferred from file extension; "-" defaults to YAML.
func openConfigFile(path string) (io.Reader, string, error) {
r, err := openInput(path)
r, err := cmdutil.OpenInput(path)
if err != nil {
return nil, "", err
}
+21 -18
View File
@@ -48,18 +48,18 @@ func (f *fakeCreateSvc) UpdateAgent(_ context.Context, id string, req *sdk.Updat
func TestCreate_HappyPath_MinimalRequired(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeCreateSvc{createResp: &sdk.Agent{ID: "ag_new", Name: "Test"}}
opts := &CreateOptions{Name: "Test", Model: "gpt-4"}
opts := &CreateOptions{Name: "Test", Model: "model-x"}
err := runCreate(context.Background(), opts, &cmdutil.JSONOptions{}, svc)
require.NoError(t, err)
require.NotNil(t, svc.createReq)
assert.Equal(t, "Test", svc.createReq.Name)
require.NotNil(t, svc.createReq.Config)
assert.Equal(t, "gpt-4", svc.createReq.Config.ModelID)
assert.Equal(t, "model-x", svc.createReq.Config.ModelID)
}
func TestCreate_MissingName_FlagError(t *testing.T) {
cmd := NewCmdCreate(nil)
cmd.SetArgs([]string{"--model", "gpt-4"})
cmd.SetArgs([]string{"--model", "model-x"})
cmd.SilenceUsage = true
cmd.SilenceErrors = true
err := cmd.Execute()
@@ -84,31 +84,31 @@ func TestCreate_ConfigFile_FlagsOverrideFile(t *testing.T) {
svc := &fakeCreateSvc{createResp: &sdk.Agent{ID: "ag_new"}}
opts := &CreateOptions{
Name: "Test",
Model: "gpt-4", // override file
ConfigFileBody: bytes.NewBufferString(`{"agent_mode":"smart-reasoning","model_id":"gpt-3.5","temperature":0.5}`),
Model: "model-x", // override file
ConfigFileBody: bytes.NewBufferString(`{"agent_mode":"smart-reasoning","model_id":"model-y","temperature":0.5}`),
ConfigFileKind: "json",
}
require.NoError(t, runCreate(context.Background(), opts, &cmdutil.JSONOptions{}, svc))
require.NotNil(t, svc.createReq.Config)
assert.Equal(t, "smart-reasoning", svc.createReq.Config.AgentMode, "file value preserved when no flag override")
assert.Equal(t, "gpt-4", svc.createReq.Config.ModelID, "flag overrides file")
assert.Equal(t, "model-x", svc.createReq.Config.ModelID, "flag overrides file")
assert.InDelta(t, 0.5, svc.createReq.Config.Temperature, 0.001)
}
func TestCreate_From_CopiesThenUpdates(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeCreateSvc{
copyResp: &sdk.Agent{ID: "ag_clone", Name: "Source", Config: &sdk.AgentConfig{ModelID: "gpt-3.5"}},
copyResp: &sdk.Agent{ID: "ag_clone", Name: "Source", Config: &sdk.AgentConfig{ModelID: "model-y"}},
updateResp: &sdk.Agent{ID: "ag_clone", Name: "Renamed"},
}
opts := &CreateOptions{Name: "Renamed", Model: "gpt-4", From: "ag_source"}
opts := &CreateOptions{Name: "Renamed", Model: "model-x", From: "ag_source"}
require.NoError(t, runCreate(context.Background(), opts, &cmdutil.JSONOptions{}, svc))
assert.Equal(t, "ag_source", svc.copySrcID)
require.True(t, svc.updateCalled, "must Update after Copy when overrides present")
assert.Equal(t, "ag_clone", svc.updateID)
assert.Equal(t, "Renamed", svc.updateReq.Name)
require.NotNil(t, svc.updateReq.Config)
assert.Equal(t, "gpt-4", svc.updateReq.Config.ModelID)
assert.Equal(t, "model-x", svc.updateReq.Config.ModelID)
}
func TestCreate_GenerateSkeleton_NoAPICall(t *testing.T) {
@@ -126,7 +126,7 @@ func TestCreate_RepeatedKB_ImpliesSelectedMode(t *testing.T) {
svc := &fakeCreateSvc{createResp: &sdk.Agent{ID: "ag_new"}}
opts := &CreateOptions{
Name: "Test",
Model: "gpt-4",
Model: "model-x",
KBs: []string{"kb_a", "kb_b"},
flags: createFlagSet{kbsSet: true},
}
@@ -140,7 +140,7 @@ func TestCreate_SystemPromptFile_ReaderRead(t *testing.T) {
svc := &fakeCreateSvc{createResp: &sdk.Agent{ID: "ag_new"}}
opts := &CreateOptions{
Name: "Test",
Model: "gpt-4",
Model: "model-x",
SystemPromptReader: strings.NewReader("You are a helpful assistant.\n"),
flags: createFlagSet{systemPromptSet: true},
}
@@ -157,7 +157,7 @@ func TestCreate_From_PreservesSourceFieldsNotOverridden(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeCreateSvc{
copyResp: &sdk.Agent{ID: "ag_clone", Config: &sdk.AgentConfig{
ModelID: "gpt-3.5",
ModelID: "model-y",
SystemPrompt: "Source prompt",
AgentMode: "smart-reasoning",
Temperature: 0.5,
@@ -167,7 +167,7 @@ func TestCreate_From_PreservesSourceFieldsNotOverridden(t *testing.T) {
}
// Only --temperature overridden; other fields should round-trip.
opts := &CreateOptions{
Name: "Renamed", Model: "gpt-3.5", From: "ag_source",
Name: "Renamed", Model: "model-y", From: "ag_source",
Temperature: 0.9,
flags: createFlagSet{temperatureSet: true},
}
@@ -181,17 +181,20 @@ func TestCreate_From_PreservesSourceFieldsNotOverridden(t *testing.T) {
}
func TestCreate_From_KBReplacesSourceList(t *testing.T) {
// --kb on --from REPLACES the copied agent's KB list (spec §2.1).
// --kb on --from REPLACES the copied agent's KB list (instead of
// merging with it). The override semantic matches a from-scratch
// `agent create --kb a --kb b`: whatever was on the source agent is
// discarded for KBs the caller explicitly listed.
_, _ = iostreams.SetForTest(t)
svc := &fakeCreateSvc{
copyResp: &sdk.Agent{ID: "ag_clone", Config: &sdk.AgentConfig{
ModelID: "gpt-3.5",
ModelID: "model-y",
KnowledgeBases: []string{"kb_src_a", "kb_src_b"},
}},
updateResp: &sdk.Agent{ID: "ag_clone"},
}
opts := &CreateOptions{
Name: "X", Model: "gpt-3.5", From: "ag_source",
Name: "X", Model: "model-y", From: "ag_source",
KBs: []string{"kb_new"},
flags: createFlagSet{kbsSet: true},
}
@@ -205,7 +208,7 @@ func TestCreate_Temperature_Bounds(t *testing.T) {
for _, badT := range []float64{-0.1, 2.1, 100.0} {
t.Run(fmt.Sprintf("t=%g", badT), func(t *testing.T) {
cmd := NewCmdCreate(nil)
cmd.SetArgs([]string{"Test", "--model", "gpt-4", "--temperature", fmt.Sprintf("%f", badT)})
cmd.SetArgs([]string{"Test", "--model", "model-x", "--temperature", fmt.Sprintf("%f", badT)})
cmd.SilenceUsage = true
cmd.SilenceErrors = true
err := cmd.Execute()
@@ -218,7 +221,7 @@ func TestCreate_Temperature_Bounds(t *testing.T) {
func TestCreate_CopyAgent_NotFound(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeCreateSvc{copyErr: errBadHTTP404}
opts := &CreateOptions{Name: "X", Model: "gpt-4", From: "ag_missing"}
opts := &CreateOptions{Name: "X", Model: "model-x", From: "ag_missing"}
err := runCreate(context.Background(), opts, &cmdutil.JSONOptions{}, svc)
require.Error(t, err)
assert.Contains(t, err.Error(), "resource.not_found")
-1
View File
@@ -15,7 +15,6 @@ import (
// Result payload is a tiny {id, deleted} object — mirrors `kb delete`.
var agentDeleteFields = []string{"id", "deleted"}
// DeleteOptions captures `agent delete` flag state.
type DeleteOptions struct {
AgentID string
Yes bool // sourced from the global -y/--yes persistent flag
+4 -4
View File
@@ -117,15 +117,15 @@ func NewCmdEdit(f *cmdutil.Factory) *cobra.Command {
opts.flags.kbSelectionModeSet = cmd.Flags().Changed("kb-selection-mode")
opts.flags.configFileSet = cmd.Flags().Changed("config-file")
// L-5 / §1.5.1: --temperature is bounded 0.0..2.0. Reject
// out-of-range early with a typed input.invalid_argument.
// --temperature is bounded 0.0..2.0. Reject out-of-range
// early with a typed input.invalid_argument.
if opts.flags.temperatureSet && (opts.Temperature < 0.0 || opts.Temperature > 2.0) {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument,
fmt.Sprintf("--temperature must be in 0.0..2.0, got %g", opts.Temperature))
}
if systemPromptFile != "" {
r, err := openInput(systemPromptFile)
r, err := cmdutil.OpenInput(systemPromptFile)
if err != nil {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, fmt.Sprintf("--system-prompt-file: %v", err))
}
@@ -194,7 +194,7 @@ func runEdit(ctx context.Context, opts *EditOptions, jopts *cmdutil.JSONOptions,
}
}
// L-2 fetch-then-update so omitted fields round-trip unchanged through
// Fetch-then-update so omitted fields round-trip unchanged through
// the full PUT body.
current, err := svc.GetAgent(ctx, opts.AgentID)
if err != nil {
+2 -2
View File
@@ -44,7 +44,7 @@ func TestEdit_FetchThenUpdate_PreservesUntouchedFields(t *testing.T) {
svc := &fakeEditSvc{
getResp: &sdk.Agent{
ID: "ag_abc", Name: "Original", Description: "Keep me",
Config: &sdk.AgentConfig{ModelID: "gpt-4", Temperature: 0.7, KnowledgeBases: []string{"kb_a"}},
Config: &sdk.AgentConfig{ModelID: "model-x", Temperature: 0.7, KnowledgeBases: []string{"kb_a"}},
},
updateResp: &sdk.Agent{ID: "ag_abc"},
}
@@ -59,7 +59,7 @@ func TestEdit_FetchThenUpdate_PreservesUntouchedFields(t *testing.T) {
assert.Equal(t, "Original", svc.updateReq.Name, "Name must round-trip unchanged")
assert.Equal(t, "Updated", svc.updateReq.Description)
require.NotNil(t, svc.updateReq.Config)
assert.Equal(t, "gpt-4", svc.updateReq.Config.ModelID, "ModelID must round-trip")
assert.Equal(t, "model-x", svc.updateReq.Config.ModelID, "ModelID must round-trip")
assert.Equal(t, []string{"kb_a"}, svc.updateReq.Config.KnowledgeBases, "KBs must round-trip")
assert.InDelta(t, 0.7, svc.updateReq.Config.Temperature, 0.001)
}
+12 -18
View File
@@ -10,9 +10,15 @@ import (
"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"
)
// promptPreviewWidth caps inline KV row prompt previews. Multi-line prompts
// collapse to one line via text.OneLine; the Templates section gets the
// full multi-line treatment instead.
const promptPreviewWidth = 80
// agentViewFields enumerates fields surfaced for `--json=` field discovery
// on `agent view`. Only top-level Agent keys are listed because the
// `--json=foo,bar` field-projection filter matches flat top-level keys
@@ -77,9 +83,9 @@ func runView(ctx context.Context, jopts *cmdutil.JSONOptions, svc ViewService, a
}
// renderAgent prints a single agent in human-readable form, grouped into
// 10 presentation sections (spec §2.9). Zero-value fields are omitted;
// a section header prints only when at least one of its fields is set.
// Group labels and order match the spec exactly so spec drift surfaces
// 10 presentation sections. Zero-value fields are omitted; a section
// header prints only when at least one of its fields is set. Group
// labels and order are pinned by snapshot tests so future drift surfaces
// as test failure rather than silent divergence.
func renderAgent(w io.Writer, a *sdk.Agent) {
// Identity is always rendered — id/name/created_at/updated_at are
@@ -186,10 +192,10 @@ func renderAgent(w io.Writer, a *sdk.Agent) {
qr = append(qr, row{"Query understand model ID", c.QueryUnderstandModelID})
}
if c.RewritePromptSystem != "" {
qr = append(qr, row{"Rewrite prompt (system)", truncate1Line(c.RewritePromptSystem)})
qr = append(qr, row{"Rewrite prompt (system)", text.OneLine(promptPreviewWidth, c.RewritePromptSystem)})
}
if c.RewritePromptUser != "" {
qr = append(qr, row{"Rewrite prompt (user)", truncate1Line(c.RewritePromptUser)})
qr = append(qr, row{"Rewrite prompt (user)", text.OneLine(promptPreviewWidth, c.RewritePromptUser)})
}
emit("Query rewrite", qr)
@@ -251,7 +257,7 @@ func renderAgent(w io.Writer, a *sdk.Agent) {
fb = append(fb, row{"Response", c.FallbackResponse})
}
if c.FallbackPrompt != "" {
fb = append(fb, row{"Prompt", truncate1Line(c.FallbackPrompt)})
fb = append(fb, row{"Prompt", text.OneLine(promptPreviewWidth, c.FallbackPrompt)})
}
emit("Fallback", fb)
@@ -271,18 +277,6 @@ func renderAgent(w io.Writer, a *sdk.Agent) {
}
}
// truncate1Line collapses newlines and clips long values for inline KV
// rows. Templates section gets full multi-line treatment instead.
func truncate1Line(s string) string {
s = strings.ReplaceAll(s, "\n", " ")
s = strings.ReplaceAll(s, "\r", " ")
const max = 80
if len(s) > max {
return s[:max-3] + "..."
}
return s
}
// writeIndented prints s with the given prefix on every line. Trailing
// newline always added so the next section starts on its own line.
func writeIndented(w io.Writer, s, prefix string) {
+2 -2
View File
@@ -82,7 +82,7 @@ func TestRenderAgent_RendersAllGroupsWithOmitEmpty(t *testing.T) {
Config: &sdk.AgentConfig{
AgentMode: "smart-reasoning",
SystemPrompt: "You help users.",
ModelID: "gpt-4",
ModelID: "model-x",
Temperature: 0.7,
KBSelectionMode: "selected",
KnowledgeBases: []string{"kb_a"},
@@ -101,7 +101,7 @@ func TestRenderAgent_RendersAllGroupsWithOmitEmpty(t *testing.T) {
}
}
// Set fields rendered:
for _, want := range []string{"smart-reasoning", "gpt-4", "You help users."} {
for _, want := range []string{"smart-reasoning", "model-x", "You help users."} {
if !strings.Contains(body, want) {
t.Errorf("missing value %q in:\n%s", want, body)
}
+2 -2
View File
@@ -43,7 +43,7 @@ type LoginService interface {
// apiKeyValidator probes /auth/me with the supplied API key so a bad key
// fails fast at `auth login --with-token` time rather than on the next
// authenticated call. Mirrors gh CLI's pre-persist token verification.
// authenticated call.
//
// Returns the resolved user (used to populate context.User / TenantID at
// rest, so later `auth list` reflects who owns the key).
@@ -123,7 +123,7 @@ func runLogin(ctx context.Context, opts *LoginOptions, jopts *cmdutil.JSONOption
}
opts.APIKey = key
// Validate against the server before persisting so a typo'd /
// expired / wrong-host key fails fast (gh CLI parity). The probe
// expired / wrong-host key fails fast at login time. The probe
// is /auth/me - read-only, side-effect-free.
user, err := defaultAPIKeyValidator(ctx, opts.Host, key)
if err != nil {
+3 -3
View File
@@ -63,9 +63,9 @@ type chatData struct {
Answer string `json:"answer"`
References []*sdk.SearchResult `json:"references"`
// Thinking holds the reasoning / reflection text emitted by reasoning
// models (GPT-5, Claude extended thinking) via response_type=thinking
// frames. Omitted when empty (non-reasoning model or model didn't
// surface reasoning for this query).
// models via response_type=thinking frames. Omitted when empty
// (non-reasoning model or model didn't surface reasoning for this
// query).
Thinking string `json:"thinking,omitempty"`
SessionID string `json:"session_id"`
AssistantMessageID string `json:"assistant_message_id,omitempty"`
-1
View File
@@ -16,7 +16,6 @@ import (
// `agent delete`.
var chunkDeleteFields = []string{"id", "deleted"}
// DeleteOptions captures `chunk delete` flag state.
type DeleteOptions struct {
ChunkID string
DocID string // required: SDK DeleteChunk takes both ids in the route.
+2 -9
View File
@@ -3,7 +3,6 @@ package chunkcmd
import (
"context"
"fmt"
"strings"
"text/tabwriter"
"time"
@@ -39,7 +38,6 @@ type ListService interface {
ListKnowledgeChunks(ctx context.Context, knowledgeID string, page, pageSize int) ([]sdk.Chunk, int64, error)
}
// ListOptions captures `chunk list` flag state.
type ListOptions struct {
// DocID scopes the listing to a single knowledge document (SDK
// `knowledge_id`). The chunks SDK does not expose a KB-wide route.
@@ -79,7 +77,7 @@ const chunkListExample = ` weknora chunk list --doc doc_abc
// NewCmdList builds `weknora chunk list --doc <doc-id>`.
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
opts := &ListOptions{PageSize: defaultPageSize, Limit: defaultLimit}
opts := &ListOptions{}
cmd := &cobra.Command{
Use: "list",
Short: "List chunks of a document (admin/debug, not retrieval)",
@@ -169,7 +167,7 @@ func runList(ctx context.Context, opts *ListOptions, jopts *cmdutil.JSONOptions,
if c.IsEnabled {
enabled = "yes"
}
preview := text.Truncate(previewWidth, singleLine(c.Content))
preview := text.OneLine(previewWidth, c.Content)
if preview == "" {
preview = "-"
}
@@ -182,8 +180,3 @@ func runList(ctx context.Context, opts *ListOptions, jopts *cmdutil.JSONOptions,
}
return tw.Flush()
}
// singleLine collapses newlines/carriage-returns/tabs to spaces so the
// chunk preview fits on one row of the human table. Without this a
// multi-line chunk would smear across rows and break tabwriter alignment.
var singleLine = strings.NewReplacer("\n", " ", "\r", " ", "\t", " ").Replace
+1 -2
View File
@@ -29,7 +29,6 @@ type ViewService interface {
GetChunkByIDOnly(ctx context.Context, chunkID string) (*sdk.Chunk, error)
}
// ViewOptions captures `chunk view` flag state. Chunk id is the sole input.
type ViewOptions struct {
ChunkID string
}
@@ -96,7 +95,7 @@ func runView(ctx context.Context, opts *ViewOptions, jopts *cmdutil.JSONOptions,
return nil
}
// renderChunk prints a single chunk in human-readable KV form per spec §1.5.2.
// renderChunk prints a single chunk in human-readable KV form.
// Field order: id / seq_id / chunk_index / doc_id / kb_id / type / enabled /
// status (omit-zero) / start_at (omit-zero) / end_at (omit-zero) /
// tag_id (omit-empty) / image_info (omit-empty) / created_at / updated_at /
+2 -1
View File
@@ -53,7 +53,8 @@ func TestView_HumanLabels_DocAndKB(t *testing.T) {
}}
require.NoError(t, runView(context.Background(), &ViewOptions{ChunkID: "c1"}, nil, svc))
body := out.String()
// Human KV must use friendlier DOC_ID / KB_ID labels (spec §1.5.2), not raw SDK names.
// Human KV uses friendlier DOC_ID / KB_ID labels (the SDK's
// knowledge_id / knowledge_base_id are kept only in --json output).
assert.Contains(t, body, "doc_id")
assert.Contains(t, body, "kb_id")
assert.NotContains(t, body, "knowledge_id")
+29 -5
View File
@@ -93,15 +93,19 @@ The three input modes (positional file / --recursive directory walk /
--from-url remote ingest) are mutually exclusive - pass exactly one.
Use --recursive --glob to upload a directory tree (see Examples).
Server-side ingestion knobs apply to all modes:
Server-side ingestion knobs:
--enable-multimodel Toggle multimodal extraction (image-in-PDF → text).
Unset ⇒ server default; pass true or false to override.
Applies to file / --recursive / --from-url.
--metadata key=value Attach arbitrary key/value metadata. Repeatable.
Empty value allowed; duplicate keys ⇒ last-wins.
Malformed values (no '=', empty key) ⇒
input.invalid_argument.
input.invalid_argument. File and --recursive modes
only; rejected on --from-url because the URL-ingest
request type carries no metadata field.
--channel <name> Override the ingestion-channel tag (default "api").
Applies to file / --recursive / --from-url.
URL mode (--from-url) additionally accepts --title, --file-type, and --tag-id.
Passing any of those without --from-url is rejected as input.invalid_argument.`,
@@ -239,15 +243,28 @@ func validateUploadFlags(opts *UploadOptions, args []string) error {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument,
"--recursive cannot be combined with --from-url")
}
// The server's URL-ingest request type has no Metadata field; a
// --metadata pair would be silently dropped on the wire. Reject
// up-front so callers don't think they've set metadata when they
// haven't.
if len(opts.Metadata) > 0 {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument,
"--metadata is not supported with --from-url (server URL-ingest has no metadata field)")
}
return cmdutil.ValidateHTTPURL("--from-url", opts.FromURL)
}
if !hasPath {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument,
"a file path is required (or pass --from-url)")
}
// --title / --file-type / --tag-id are URL-mode only. Reject silently
// ignoring them in file mode to avoid the "set X, server did nothing"
// surprise.
return rejectURLOnlyFlags(opts)
}
// rejectURLOnlyFlags errors on --title / --file-type / --tag-id when
// --from-url is NOT set. Shared between validateUploadFlags (file mode)
// and runUploadRecursive (directory walk) so a future URL-mode-only flag
// only needs to add one entry here instead of two parallel checks.
func rejectURLOnlyFlags(opts *UploadOptions) error {
if opts.Title != "" {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument,
"--title is only valid with --from-url")
@@ -343,6 +360,13 @@ func runUpload(ctx context.Context, opts *UploadOptions, jopts *cmdutil.JSONOpti
}
k, err := svc.CreateKnowledgeFromFile(ctx, kbID, path, meta, opts.EnableMultimodel, opts.Name, effectiveChannel(opts))
if err != nil {
if errors.Is(err, sdk.ErrDuplicateFile) {
// SDK returns sentinel without an "HTTP error <status>:" prefix
// (the duplicate is detected by file hash, not by status code),
// so WrapHTTP would misclassify it as network.error.
return cmdutil.Wrapf(cmdutil.CodeResourceAlreadyExists, err,
"file already uploaded to this knowledge base")
}
return cmdutil.WrapHTTP(err, "upload %s", path)
}
return renderUploadSuccess(k, jopts, "Uploaded", opts.Name, path)
+5 -20
View File
@@ -32,26 +32,11 @@ func runUploadRecursive(ctx context.Context, opts *UploadOptions, jopts *cmdutil
Hint: "drop --name or upload files one at a time",
}
}
// URL-mode-only flags are not meaningful for a directory walk; reject
// them so misuse fails fast (mirrors the file-mode path's check in
// validateUploadFlags - that path runs before --recursive dispatches).
if opts.Title != "" {
return &cmdutil.Error{
Code: cmdutil.CodeInputInvalidArgument,
Message: "--title is only valid with --from-url",
}
}
if opts.FileType != "" {
return &cmdutil.Error{
Code: cmdutil.CodeInputInvalidArgument,
Message: "--file-type is only valid with --from-url",
}
}
if opts.TagID != "" {
return &cmdutil.Error{
Code: cmdutil.CodeInputInvalidArgument,
Message: "--tag-id is only valid with --from-url",
}
// URL-mode-only flags are not meaningful for a directory walk;
// rejectURLOnlyFlags is the single source of truth shared with
// file-mode upload.
if err := rejectURLOnlyFlags(opts); err != nil {
return err
}
// Parse --metadata up front so a malformed value aborts before the
// first SDK call - otherwise a typo in `key=value` would only surface
+36 -2
View File
@@ -132,6 +132,25 @@ func TestUpload_HTTPError_409Conflict(t *testing.T) {
assert.Equal(t, cmdutil.CodeResourceAlreadyExists, typed.Code)
}
// TestUpload_DuplicateFileMaps_resource_already_exists pins the contract that
// the SDK's sentinel sdk.ErrDuplicateFile (returned with no "HTTP error <n>:"
// prefix because the duplicate is detected by file-hash short-circuit, not by
// status code) is mapped to resource.already_exists. Prior regression: the
// file-upload path forwarded the sentinel to WrapHTTP, which classified the
// prefix-less message as network.error — symmetric with the --from-url branch
// which already handled ErrDuplicateURL.
func TestUpload_DuplicateFileMaps_resource_already_exists(t *testing.T) {
_, _ = iostreams.SetForTest(t)
path := writeTempFile(t, "dup.md")
svc := &fakeUploadSvc{err: sdk.ErrDuplicateFile}
err := runUpload(context.Background(), &UploadOptions{}, nil, svc, "kb_xxx", path)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeResourceAlreadyExists, typed.Code)
}
func TestValidateUploadPath_NotFound(t *testing.T) {
missing := filepath.Join(t.TempDir(), "does-not-exist.pdf")
err := validateUploadPath(missing)
@@ -238,6 +257,21 @@ func TestValidateUploadFlags_FromURL_WithRecursive_Rejected(t *testing.T) {
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
}
// The server's URL-ingest request type has no Metadata field; the CLI must
// reject --metadata + --from-url upfront so callers don't think they've set
// metadata that the server then silently drops on the wire.
func TestValidateUploadFlags_FromURL_WithMetadata_Rejected(t *testing.T) {
err := validateUploadFlags(&UploadOptions{
FromURL: "https://example.com/x.pdf",
Metadata: []string{"team=alpha"},
}, nil)
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, "--metadata is not supported with --from-url")
}
func TestValidateUploadFlags_FromURL_BadScheme(t *testing.T) {
err := validateUploadFlags(&UploadOptions{FromURL: "file:///etc/passwd"}, nil)
require.Error(t, err)
@@ -300,8 +334,8 @@ func TestParseTriBool(t *testing.T) {
{"false", false, false},
{"0", false, false},
{"no", false, false},
{"", false, true}, // explicit empty rejected
{" ", false, true}, // whitespace rejected
{"", false, true}, // explicit empty rejected
{" ", false, true}, // whitespace rejected
{"maybe", false, true},
} {
t.Run(c.in, func(t *testing.T) {
+2 -2
View File
@@ -23,8 +23,8 @@ func NewCmd(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "mcp",
Short: "Run weknora as a Model Context Protocol server",
Long: `Exposes weknora's read surface as MCP tools so agentic IDE clients
(Claude Code, Cursor, Continue, Zed) can call them over JSON-RPC.
Long: `Exposes weknora's read surface as MCP tools so any
MCP-compatible client can call them over JSON-RPC.
Initial tool surface is read-only and curated: kb_list / kb_view /
doc_list / doc_view / doc_download / search_chunks / chat / agent_list /
+2 -2
View File
@@ -22,8 +22,8 @@ configured, the process exits with auth.unauthenticated before any MCP
handshake. This way an IDE-side agent sees a clear failure mode rather
than a server that handshakes successfully then errors on every tool.
To register with your MCP client (Claude Desktop / Code / Cursor / etc.),
add an entry pointing at this binary under "mcpServers":
To register with your MCP client, add an entry pointing at this binary
under "mcpServers":
{
"mcpServers": {
+1 -1
View File
@@ -30,7 +30,7 @@ import (
func Execute() int {
root := NewRootCmd(cmdutil.New())
if err := root.Execute(); err != nil {
// Errors go to stderr (matches gh/aws/stripe). Stdout stays
// Errors go to stderr. Stdout stays
// empty (or holds partial success the command produced) so
// downstream `--json | jq` pipelines never filter error shapes
// out of the success stream. The typed exit code (3/4/5/6/7/10)
+1 -1
View File
@@ -51,7 +51,7 @@ func NewCmdChunks(f *cmdutil.Factory) *cobra.Command {
Short: "Hybrid (vector + keyword) chunk retrieval against a knowledge base",
Example: ` weknora search chunks "what is RAG?" --kb engineering
weknora search chunks "embedding model" --kb kb_abc --limit 20
weknora search chunks "k8s" --kb engineering --no-keyword # vector-only`,
weknora search chunks "retry policy" --kb engineering --no-keyword # vector-only`,
Long: `Hybrid (vector + keyword) retrieval against the knowledge base. Pass
--no-vector or --no-keyword to disable one channel; you cannot disable both.
--limit caps the returned slice client-side.`,
+12 -12
View File
@@ -13,7 +13,7 @@ import (
func TestLoadConfigFile_YAML(t *testing.T) {
yaml := strings.NewReader(`
agent_mode: smart-reasoning
model_id: gpt-4
model_id: model-x
temperature: 0.7
knowledge_bases:
- kb_abc
@@ -22,17 +22,17 @@ knowledge_bases:
cfg, err := LoadAgentConfig(yaml, "yaml")
require.NoError(t, err)
assert.Equal(t, "smart-reasoning", cfg.AgentMode)
assert.Equal(t, "gpt-4", cfg.ModelID)
assert.Equal(t, "model-x", cfg.ModelID)
assert.InDelta(t, 0.7, cfg.Temperature, 0.001)
assert.Equal(t, []string{"kb_abc", "kb_def"}, cfg.KnowledgeBases)
}
func TestLoadConfigFile_JSON(t *testing.T) {
js := strings.NewReader(`{"agent_mode":"quick-answer","model_id":"gpt-3.5"}`)
js := strings.NewReader(`{"agent_mode":"quick-answer","model_id":"model-y"}`)
cfg, err := LoadAgentConfig(js, "json")
require.NoError(t, err)
assert.Equal(t, "quick-answer", cfg.AgentMode)
assert.Equal(t, "gpt-3.5", cfg.ModelID)
assert.Equal(t, "model-y", cfg.ModelID)
}
func TestLoadConfigFile_UnknownKind(t *testing.T) {
@@ -52,17 +52,17 @@ func TestLoadConfigFile_BadJSON(t *testing.T) {
}
func TestMergeAgentConfig_FlagsOverrideFile(t *testing.T) {
base := &sdk.AgentConfig{ModelID: "gpt-3.5"}
overrides := AgentConfigFlags{ModelIDSet: true, ModelID: "gpt-4"}
base := &sdk.AgentConfig{ModelID: "model-y"}
overrides := AgentConfigFlags{ModelIDSet: true, ModelID: "model-x"}
merged := MergeAgentConfig(base, overrides)
assert.Equal(t, "gpt-4", merged.ModelID)
assert.Equal(t, "model-x", merged.ModelID)
}
func TestMergeAgentConfig_UnsetFlagsPreserveBase(t *testing.T) {
base := &sdk.AgentConfig{ModelID: "gpt-3.5", Temperature: 0.5}
base := &sdk.AgentConfig{ModelID: "model-y", Temperature: 0.5}
overrides := AgentConfigFlags{} // nothing set
merged := MergeAgentConfig(base, overrides)
assert.Equal(t, "gpt-3.5", merged.ModelID)
assert.Equal(t, "model-y", merged.ModelID)
assert.InDelta(t, 0.5, merged.Temperature, 0.001)
}
@@ -70,7 +70,7 @@ func TestMergeAgentConfig_EveryFieldOverlay(t *testing.T) {
base := &sdk.AgentConfig{
AgentMode: "quick-answer",
SystemPrompt: "old",
ModelID: "gpt-3.5",
ModelID: "model-y",
RerankModelID: "rerank-old",
Temperature: 0.1,
KBSelectionMode: "all",
@@ -79,7 +79,7 @@ func TestMergeAgentConfig_EveryFieldOverlay(t *testing.T) {
overrides := AgentConfigFlags{
AgentMode: "smart-reasoning", AgentModeSet: true,
SystemPrompt: "new", SystemPromptSet: true,
ModelID: "gpt-4", ModelIDSet: true,
ModelID: "model-x", ModelIDSet: true,
RerankModelID: "rerank-new", RerankModelIDSet: true,
Temperature: 0.9, TemperatureSet: true,
KBSelectionMode: "selected", KBSelectionModeSet: true,
@@ -88,7 +88,7 @@ func TestMergeAgentConfig_EveryFieldOverlay(t *testing.T) {
merged := MergeAgentConfig(base, overrides)
assert.Equal(t, "smart-reasoning", merged.AgentMode)
assert.Equal(t, "new", merged.SystemPrompt)
assert.Equal(t, "gpt-4", merged.ModelID)
assert.Equal(t, "model-x", merged.ModelID)
assert.Equal(t, "rerank-new", merged.RerankModelID)
assert.InDelta(t, 0.9, merged.Temperature, 0.001)
assert.Equal(t, "selected", merged.KBSelectionMode)
+20
View File
@@ -0,0 +1,20 @@
package cmdutil
import (
"io"
"os"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
)
// 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
// patterns the file is fully read before the command exits and OS
// reclaims the FD, so closing is cosmetic.
func OpenInput(path string) (io.Reader, error) {
if path == "-" {
return iostreams.IO.In, nil
}
return os.Open(path)
}
+2 -3
View File
@@ -53,9 +53,8 @@ func TestAddJSONFlags_BareYieldsEnabledOptsWithNoFields(t *testing.T) {
func TestAddJSONFlags_FieldsFlagParsing(t *testing.T) {
// NoOptDefVal sentinel means the `=` form is required for value passing.
// Space form `--json id,name` parses as bare + positional, which is a
// documented divergence from gh CLI: weknora keeps bare `--json` as a
// shortcut for "full payload".
// Space form `--json id,name` parses as bare + positional; bare `--json`
// (no value) is reserved as a shortcut for the unfiltered payload.
cases := []struct {
args []string
want []string
+1 -1
View File
@@ -21,7 +21,7 @@ func WriteJSON(w io.Writer, v any) error {
// - len(fields) == 0 → no field filter
// - jqExpr == "" → no jq filter
//
// Field filter rules (mirrors gh CLI's `--json field,field` semantics):
// Field filter rules:
//
// - v marshals to a top-level array → each [*] object is restricted to
// the named keys
+2 -1
View File
@@ -105,7 +105,8 @@ func TestWriteJSONFiltered_JQOnly(t *testing.T) {
if err := format.WriteJSONFiltered(buf, items, nil, ".[].id"); err != nil {
t.Fatalf("err = %v", err)
}
// gh CLI parity: string results render without JSON quotes.
// String / scalar results render without JSON quotes so scalar
// projections (e.g. `--jq '.[].id'`) pipe cleanly into shell tools.
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
if len(lines) != 2 || lines[0] != "1" || lines[1] != "2" {
t.Errorf("jq output mismatch: %q", buf.String())
+1 -1
View File
@@ -451,7 +451,7 @@ func addAgentInvoke(server *mcpsdk.Server, svc agentInvokeService) {
Channel: "api",
}
// Auto-create session if not supplied. Sessions are agent-
// agnostic at creation (Q3 - verified against server source).
// agnostic at creation (verified against server source).
sessionID := in.SessionID
if sessionID == "" {
sess, err := svc.CreateSession(ctx, &sdk.CreateSessionRequest{Title: "weknora mcp agent_invoke"})
+2 -2
View File
@@ -23,8 +23,8 @@ import (
// Demuxes by ResponseType so the answer string is not polluted by thinking
// or reflection fragments - the model layer (internal/models/chat/
// remote_api.go) emits ResponseTypeThinking events whenever the upstream
// LLM produces reasoning_content (GPT-5 / Claude extended thinking), and
// without demux those would be silently concatenated into Result().
// LLM produces reasoning_content frames, and without demux those would
// be silently concatenated into Result().
type Accumulator struct {
answer strings.Builder
thinking strings.Builder
+13
View File
@@ -0,0 +1,13 @@
package text
import "strings"
// OneLine collapses newlines/carriage-returns/tabs in s to single spaces,
// then truncates to maxDisplayWidth columns (UTF-8 safe via Truncate).
// Use for human-readable preview rows where multiline content would
// break tabular layout.
func OneLine(maxDisplayWidth int, s string) string {
return Truncate(maxDisplayWidth, lineReplacer.Replace(s))
}
var lineReplacer = strings.NewReplacer("\n", " ", "\r", " ", "\t", " ")
+29
View File
@@ -0,0 +1,29 @@
package text_test
import (
"testing"
"github.com/Tencent/WeKnora/cli/internal/text"
)
func TestOneLine(t *testing.T) {
cases := []struct {
name string
max int
in, want string
}{
{"empty", 10, "", ""},
{"no-collapse", 10, "hello", "hello"},
{"collapse-newline", 20, "hello\nworld", "hello world"},
{"collapse-cr-tab", 20, "hello\r\tworld", "hello world"},
{"truncate", 8, "hello world long", "hello w…"},
{"truncate-after-collapse", 8, "hello\nworld\nlong", "hello w…"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := text.OneLine(c.max, c.in); got != c.want {
t.Errorf("OneLine(%d, %q) = %q, want %q", c.max, c.in, got, c.want)
}
})
}
}