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.
11 KiB
AGENTS.md
WeKnora CLI (weknora) is a noun-verb wrapper around the WeKnora server API; module path github.com/Tencent/WeKnora/cli. This file is the developer guide for coding agents and human contributors editing the CLI. The user-facing wire contract (output shape, exit codes, error format) lives in README.md.
Build, Test, and Lint
go build -o weknora . # build (from cli/)
go test -count=1 ./... # unit + contract tests
go test -run TestFoo ./internal/format/ # single test
go test ./acceptance/contract/ -args -update # refresh wire goldens
go test -tags acceptance_e2e ./acceptance/e2e/... # live-server e2e (gated by env)
go vet ./...
Both go test -count=1 ./... and go vet ./... must pass before committing.
Architecture
Entry point: cmd/main.go → cmd.Execute() → cmd.NewRootCmd(cmdutil.New()).
Key packages:
cmd/<noun>/— cobra command implementations, one subdir per nouninternal/cmdutil/—Factory,JSONOptions, typedError, exit-code mapping, destructive-write confirm, KB id-or-name resolveinternal/format/— bare JSON emitter (WriteJSON/WriteJSONFiltered)internal/iostreams/— global IO singleton + TTY detection +SetForTestswapinternal/secrets/—Storeinterface;KeyringStoreprimary,FileStore0600 fallback,MemStorefor testsinternal/prompt/—TTYPrompter(password no-echo) +AgentPrompter(non-TTY no-prompt sentinel)internal/sse/—Accumulatorfor chat / agent invoke SSE streamsinternal/mcp/— curated 10-tool stdio MCP server (wired bycmd/mcp/serve.go); see MCP tool surface for the curation rationale and inventoryclient/(parent module) — generated SDK
Command Structure
A command weknora foo bar lives in cmd/foo/bar.go with bar_test.go.
Canonical Examples
- Command + tests:
cmd/kb/list.goandlist_test.go - Destructive write + confirm protocol:
cmd/kb/delete.go - SSE streaming command:
cmd/chat/chat.go - Factory wiring:
internal/cmdutil/factory.go
The Options + Narrow Service Pattern
Every command follows this structure (see cmd/kb/list.go):
Optionsstruct with flag-bound fieldsServiceinterface declaring only the SDK methods this command calls.*sdk.Clientsatisfies it implicitly via duck typing.NewCmd<Verb>(f *cmdutil.Factory) *cobra.Commandconstructor — flag registration +cmdutil.AddJSONFlags- Separate
run<Verb>(ctx, opts, jopts, svc, args...)with the business logic — the test injection point
Key rules:
- Each command owns its own
Serviceinterface; do NOT share interfaces acrosscmd/*packages. Per-file dependency graph is the goal. - Lazy-init
f.Client()/f.Secrets()/f.Prompter()insideRunE, not the constructor (else--helpforces auth). - Required flags:
_ = cmd.MarkFlagRequired("name")— cobra returns the error only on registration-time typo. - New subtrees register in
cmd/root.go NewRootCmd. Verb subtrees register their leaves in the subtree's ownNewCmd.
Command Examples and Help Text
Use a Go raw string with weknora as the example prefix. Keep one-line Short ≤ 70 chars; Long may run multi-paragraph; Example always includes weknora so copy-paste works:
Example: ` weknora kb view <id>
weknora kb view kb_abc --json
weknora kb view kb_abc --json=id,name`,
JSON Output
Add --json / --jq via cmdutil.AddJSONFlags(cmd, fieldNames). In RunE:
if jopts.Enabled() {
return jopts.Emit(iostreams.IO.Out, result)
}
Emit is the single source for the bare-JSON contract — it honors --json=fields,... projection and --jq <expr> filtering. Never call format.WriteJSON* directly from a command. See cmd/kb/list.go.
Destructive Writes
Commands that delete / empty / overwrite call cmdutil.ConfirmDestructive(p, opts.Yes, jopts.Enabled(), what, id) before mutation. In non-TTY OR --json mode without -y, it returns CodeInputConfirmationRequired → exit 10. See internal/cmdutil/confirm.go.
Testing
Narrow Service Fakes
Each command's runX(ctx, opts, jopts, svc, ...) takes its interface, not *sdk.Client. Tests inject plain-struct fakes:
type fakeBarSvc struct {
gotID string
resp *sdk.Bar
err error
}
func (f *fakeBarSvc) GetBar(_ context.Context, id string) (*sdk.Bar, error) {
f.gotID = id
return f.resp, f.err
}
No mocking library; the narrow-interface design makes fakes 5 lines each.
IOStreams in Tests
out, errBuf := iostreams.SetForTest(t) // bytes.Buffer sinks, non-TTY
ios, _ := iostreams.SetForTestWithTTY(t) // simulate terminal
Confirm Prompts
Use testutil.ConfirmPrompter{Answer: bool, Err: error} from internal/testutil/. Single source for the prompt double — do NOT re-define confirmPrompter per package.
Assertions
Use testify. Prefer require (not assert) for error checks so the test halts immediately, and assert for value comparisons:
require.NoError(t, err)
require.ErrorAs(t, err, &typed)
assert.Equal(t, "expected", actual)
Acceptance: Wire-Shape Goldens
acceptance/contract/wire_test.go drives the in-process cobra tree against httptest.Server fixtures and compares stdout to acceptance/testdata/wire/<case>.json. Error-path cases also assert stderr contains the typed code substring (e.g. auth.unauthenticated). Update goldens with go test ./acceptance/contract/ -args -update.
Table-Driven Tests
Use for flag validation, error classification, parser edge cases. See internal/cmdutil/exit_test.go and cmd/kb/list_test.go.
tests := []struct{ name string; ...}{
{name: "descriptive case", ...},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { /* arrange, act, assert */ })
}
Code Style
- Add godoc to every exported function, type, and constant. Explain why, not what — the name already says what.
- Don't comment to restate the code. Delete comments that narrate the next line.
- Don't reference task numbers, commit SHAs, or version tags in inline comments — they belong in CHANGELOG or git log.
- Never paste em-dashes (—) into Go source; use ASCII
-or rewrite. (Markdown docs may use em-dashes.) - Don't add a helper for a single caller — inline.
Error Handling
Typed error helpers in internal/cmdutil/errors.go:
cmdutil.NewError(code, msg)— fresh typed errorcmdutil.WrapHTTP(err, format, args...)— wrap an SDK error + classify from HTTP status (404 →resource.not_found, 401 →auth.unauthenticated, …). Use at every SDK call site.cmdutil.Wrapf(code, err, format, args...)— explicit wrap with a chosen codecmdutil.NewFlagError(err)— flag / argument problem → exit 2cmdutil.SilentError— exit 1 without printing (when output already emitted)cmd.MarkFlagsMutuallyExclusive("a", "b")— cobra-level mutex
Errors print to STDERR via cmdutil.PrintError(w, err) as code: msg\nhint: .... STDOUT stays bare JSON or empty on failure, so --json | jq pipelines never have to filter error shapes.
User-facing exit-code mapping lives in README.md "Exit codes". When adding a new ErrorCode constant, also append to AllCodes() so the acceptance contract picks it up.
MCP Tool Surface
WeKnora's MCP server exposes a curated read-only tool surface. Many MCP servers in the wild ship write / mutation operations on by default and rely on credential-scope or sandbox restrictions for safety. WeKnora opts for curation instead: the server side doesn't yet enforce per-token scope, so an agent holding a user's token has full write access. Until server-side scope ships, the CLI keeps mutation tools out of the MCP surface as a belt-and-braces second line of defense. When server scope arrives this stance can loosen.
The curated 10 tools (cli/internal/mcp/tools.go):
| Tool | Purpose |
|---|---|
kb_list |
list knowledge bases |
kb_view |
fetch a knowledge base by id |
doc_list |
list documents in a kb (paginated, status-filterable) |
doc_view |
fetch a document by id |
doc_download |
download raw bytes (1 MiB cap, base64 for binary) |
chunk_list |
list chunks of a document for RAG retrieval debug |
search_chunks |
hybrid (vector + keyword) retrieval |
chat |
stream a RAG answer; auto-creates a session if absent |
agent_list |
list custom agents |
agent_invoke |
run a query through a custom agent |
Adding a tool is a deliberate API expansion — the agent-callable surface is the reason this CLI ships an MCP server, not its CLI command list, so the registration list in registerTools is maintained by hand.
Command surface design SOP
Before specifying any CLI command, do this in order:
grep -A 50 "type Foo struct" client/foo.go— dump SDK request/response schemas.- List every field with type and source line.
- For each field, decide: hot-path flag / config-file only / hidden / never-expose.
- Cross-check pagination signatures: an SDK
(ctx, id, page, pageSize)shape demands--limit+--all-pages+--page-sizeon the CLI side. - ONLY THEN consult mainstream CLI conventions to choose flag names, positionals, mutex, and confirm semantics.
- Decide which fields are "top use case" (flag) / "advanced" (
--config-fileor escape hatch viaweknora 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
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)orcobra.MinimumNArgs(1) - Flag required:
cmd.MarkFlagRequired("flag") - Custom required (e.g.,
agent editneeds at-least-one-edit-flag): RunE-level validation that returnsinput.invalid_argument - Mutex:
cmd.MarkFlagsMutuallyExclusive("a", "b")
Reasons hard-required-flags is the v0.5+ default:
- 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.