WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s

This commit is contained in:
yawan
2026-09-22 16:19:06 +08:00
commit f7fc992f57
3929 changed files with 1006607 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
bin/
coverage.out
ROADMAP.md
ROADMAP.html
+622
View File
@@ -0,0 +1,622 @@
# AGENTS.md
This is the WeKnora CLI (`weknora`), a command-line client for the WeKnora RAG server. The module path is `github.com/Tencent/WeKnora/cli`.
The wire contract for AI agents *consuming* `weknora` output (JSON shape, exit codes, error format) is documented below and in [README.md](README.md). Read this file if you're integrating with the CLI binary — build / test / architecture details follow the wire contract sections.
## Wire contract for AI agents
This CLI's primary consumers include AI agents (Claude Code, Cursor, Gemini CLI,
etc.). Output format is the agent-facing API. **Every error message and every
JSON field you write becomes part of an agent's decision-making input.**
### Stdout (success path)
All `--format json` (default) commands emit a symmetric envelope. Optional
fields are `omitempty` — they only appear when populated:
```json
// list (kb list, doc list, ...) — data is an array, meta carries count
{
"ok": true,
"data": [ {"id": "kb_abc", "name": "prod"} ],
"meta": {"count": 1},
"profile": "prod"
}
// single resource (kb view, doc view, ...) — data is an object
{
"ok": true,
"data": {"id": "kb_abc", "name": "prod", "description": "..."},
"profile": "prod"
}
// mutation success with no payload (some delete / edit paths)
{"ok": true, "profile": "prod"}
```
`data` is omitted on mutation-only success (no payload). `meta` carries list
counters (`count`, `has_more`) and batch successes/failures, and is omitted
when empty. `meta.next_cursor`, `meta.total_count`, and `meta.request_id` are
reserved — not currently populated; planned for v0.8 when the SDK exposes
pagination cursors and response headers. `_notice` is reserved — open-map
infrastructure is in place for deprecation / version_skew / security notices;
the field is omitted until a producer is wired in v0.8. `profile` echoes the
resolved profile name and is omitted when no profile is configured.
### Stderr (error path)
Errors emit an error envelope on stderr (`--format json`) or prose
`code: message\nhint: ...\nretry: ...` (`--format text`):
```json
{
"ok": false,
"error": {
"type": "auth.unauthenticated",
"message": "fetch current user: HTTP error 401",
"hint": "run `weknora auth login`",
"retry_argv": ["weknora", "auth", "login"],
"retry_after_seconds": 0,
"risk": {"level": "destructive", "action": "noun.verb"},
"detail": {}
},
"_notice": {}
}
```
`type` is the typed code (see [Error code reference](#error-code-reference)
below). `hint` is prose; `retry_argv` is the suggested next argv as a JSON
string array. For non-destructive errors agents may execute it; on exit-10
(`input.confirmation_required`) it is informational only — the human must
approve the destructive write explicitly. See "Exit-10 anti-patterns" for
details.
`retry_after_seconds` mirrors HTTP `Retry-After`. `risk` tags high-risk writes.
`detail` carries structured per-error context (e.g. `unknown_subcommand`'s
`available[]` list).
### NDJSON event stream (chat / session ask)
`--format json` and `--format ndjson` both produce one JSON event per line —
no envelope wrapping. The CLI injects exactly one event (`init`) at the head;
all subsequent events pass through verbatim from the SDK:
```
{"type":"init","session_id":"...","kb_id":"...","profile":"...","agent_id":"..."}
{"type":"thinking","content":"..."}
{"type":"answer","content":"Hello"}
{"type":"tool_call","name":"...","input":{}}
{"type":"complete","done":true}
```
For prose rendering, pass `--format text`.
### `_notice` evolution policy
`_notice` is an open map. New keys are **additive non-breaking**; agents MUST
ignore unknown keys. v0.7 reserves three keys: `deprecation` / `version_skew` /
`security`. New keys follow snake_case convention. The `_notice` field is
currently always empty — producer wiring is planned for v0.8 when the SDK
exposes version metadata. The wire infrastructure is in place so adding a
producer in v0.8 will not change the envelope shape.
### CLI vs server SDK contract boundary
CLI 1.0 contract covers:
- ✅ Envelope wire shape (success + error)
- ✅ CLI-injected events (`init` only)
- ✅ NDJSON line shape (bare `{type:...,...}`)
- ✅ Passthrough discipline (CLI doesn't rename SDK events)
CLI 1.0 contract does NOT cover:
- ❌ Specific SDK event names (`answer` / `tool_call` / `complete` / ...)
- ❌ SDK event field shapes (server's own version contract)
The CLI contract and the server's wire contract are versioned independently:
agents pin against the CLI surface, while server-side event shape evolves on
its own track.
### The one rule
Every error message you write will be parsed by an AI to decide its next action.
Make errors structured, actionable, and specific.
### Environment variables
| Variable | Purpose |
|---|---|
| `WEKNORA_PROFILE` | Active profile name. Equivalent to the global `--profile <name>` flag; overridden by `--profile`. Useful in CI scripts that cannot pass global flags. |
| `WEKNORA_FORMAT` | Default `--format` value (`text \| json \| ndjson`). Overridden by explicit `--format`. Invalid values ignored silently. |
| `WEKNORA_KB_ID` | Default KB ID for commands that accept `--kb`. Overridden by `--kb`. |
| `WEKNORA_LOG_LEVEL` | SDK debug log level (`error \| warn \| info \| debug`). Overridden by `--log-level`. |
| `WEKNORA_AGENT_HELP` | Set to `1` to emit structured JSON agent-help (machine-readable) instead of human help text when `--help` is invoked. |
> **Note for agents — machine-readable version and help:** The `--version` flag
> and `--help` flag on the root command bypass `--format json` and always emit
> prose (cobra built-in paths). For machine-readable output use the `version`
> subcommand (`weknora version --format json`) or `WEKNORA_AGENT_HELP=1
> weknora <cmd> --help`. Planned fix for v0.8.
## Design decisions worth flagging
Five design decisions readers may want context on: where WeKnora picks an
opinionated default, what the trade-off is, and what mainstream practice it
is or isn't aligned with.
### 1. Channel split: success → stdout, error → stderr
| | |
|---|---|
| **WeKnora** | success envelope → stdout; error envelope → stderr |
| **Rationale** | `weknora ... --format json \| jq '.data[]'` must not mix error objects into the data stream. Channel split lets pipeline consumers suppress errors with `2>/dev/null` and still get clean JSON on stdout. |
### 2. `weknora api DELETE` triggers exit-10 confirmation
| | |
|---|---|
| **WeKnora** | DELETE triggers exit-10 (`input.confirmation_required`); user bypasses with `-y/--yes` |
| **Rationale** | DELETE is irreversible. Most raw-API CLI commands rely on restricted credentials for safety, but self-hosted deployments may not have restricted-credential infrastructure available. Defensive default because agents are common consumers. |
### 3. `retry_argv` distinct from `hint`
| | |
|---|---|
| **WeKnora** | two separate fields: `retry_argv` (suggested next argv as a string array, directly executable for non-destructive errors; informational only on exit-10) + `hint` (prose) |
| **Rationale** | Agents don't regex-extract argv from prose — known fragility. Trade-off: one extra envelope field. On exit-10, the user must approve the destructive write; agents surface `retry_argv` for human review, not auto-execution. |
### 4. NDJSON event stream has no envelope wrapping
| | |
|---|---|
| **WeKnora** | streaming commands (`chat`, `session ask`) emit bare `{type:...}` per line; no envelope |
| **Rationale** | This matches established practice across NDJSON-emitting CLIs and webhook protocols. A streaming envelope requires unwrap before dispatch — net burden with no benefit. |
### 5. No `schema_version` field in payload
| | |
|---|---|
| **Mainstream** | some APIs (Anthropic / OpenAI) embed a `version` field in payload |
| **WeKnora** | version identity via CLI binary semver + CHANGELOG `### BREAKING` + skill `tested_against` + CI parity tests |
| **Rationale** | Mainstream CLIs don't embed version in payload. Agents have complete version awareness via `weknora --version` and skill version binding. |
## Pre-1.0 breaking policy
CLI is in `v0.x` pre-release. Breaking changes ship together in concentrated
batches (v0.5 / v0.6 / v0.7) rather than scattered across patch releases.
After 1.0, any breaking change requires a 2-version deprecation period.
`CHANGELOG.md` `### BREAKING` section is authoritative.
For agents: pin the CLI version in your skill's `tested_against` field; bump
`tested_against` only after manually validating against the new CLI.
## Build, Test, and Lint
```bash
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/<name>/` — cobra command implementations, one subdir per top-level command
- `internal/cmdutil/` — `Factory`, `FormatOptions`, typed `Error`, exit-code mapping, destructive-write confirm, KB id-or-name resolve
- `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` (password no-echo) + `AgentPrompter` (non-TTY no-prompt sentinel)
- `internal/sse/` — `Accumulator` for chat / session ask 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
## Command Structure
A command `weknora foo bar` lives in `cmd/foo/bar.go` with `bar_test.go`.
### Canonical Examples
- **Command + tests**: `cmd/kb/list.go` and `list_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`):
1. `Options` struct with flag-bound fields
2. `Service` interface declaring only the SDK methods this command calls. `*sdk.Client` satisfies it implicitly via duck typing.
3. `NewCmd<Verb>(f *cmdutil.Factory) *cobra.Command` constructor — flag registration + `cmdutil.AddFormatFlag`
4. Separate `run<Verb>(ctx, opts, fopts, svc, args...)` with the business logic — the test injection point
Key rules:
- Each command owns its own `Service` interface; do NOT share interfaces across `cmd/*` packages. Per-file dependency graph is the goal.
- Lazy-init `f.Client()` / `f.Secrets()` / `f.Prompter()` inside `RunE`, not the constructor (else `--help` forces 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 own `NewCmd`.
### 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:
```go
Example: ` weknora kb view <id>
weknora kb view kb_abc --format json
weknora kb view kb_abc --format json --jq '{id, name}'`,
```
### JSON Output
Add `--format` / `--jq` via `cmdutil.AddFormatFlag(cmd, fieldNames...)`. In `RunE`:
```go
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil { return err }
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
// ...
if fopts.WantsJSON() {
return fopts.Emit(iostreams.IO.Out, result)
}
```
`Emit` is the single source for the bare-JSON contract — it honors `--format json|ndjson` 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, fopts.WantsJSON(), what, id)` before mutation. In non-TTY OR JSON-output mode without `-y`, it returns `CodeInputConfirmationRequired` → exit 10. See `internal/cmdutil/confirm.go`.
## Testing
### Narrow Service Fakes
Each command's `runX(ctx, opts, fopts, svc, ...)` takes its interface, not `*sdk.Client`. Tests inject plain-struct fakes:
```go
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
```go
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:
```go
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`.
```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 error
- `cmdutil.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 code
- `cmdutil.NewFlagError(err)` — flag / argument problem → exit 2
- `cmdutil.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"](README.md#exit-codes). When adding a new `ErrorCode` constant, also append to `AllCodes()` so the acceptance contract picks it up.
## Error code reference
> **Audience:** AI agents and scripted callers parsing `weknora` stderr.
> Code authors writing new error sites — see [`## Error Handling`](#error-handling) above.
When `weknora` exits non-zero, stderr carries a structured triplet:
```
<code>: <message>
hint: <actionable next step>
```
Agents parse the first colon to extract the typed code. The exit code class (see [`README.md` "Exit codes"](README.md#exit-codes)) controls retry / surface decisions; the typed code disambiguates within a class.
<!-- ERROR_REFERENCE_START -->
<!-- DO NOT EDIT manually below this marker. Add new codes to
internal/cmdutil/errors.go + register in AllCodes() + add a row here.
The CI scan in errors_doc_test.go enforces parity. -->
| Code | Exit | Retryable | Default hint |
|---|---|---|---|
| `auth.unauthenticated` | 3 | no (run `auth login`) | run `weknora auth login` |
| `auth.token_expired` | 3 | yes (after refresh) | your session expired; run `weknora auth login` to re-authenticate |
| `auth.bad_credential` | 3 | no (re-login) | run `weknora auth login` |
| `auth.forbidden` | 3 | no | active profile lacks permission for this resource |
| `auth.cross_tenant_blocked` | 3 | no | verify tenant context with `weknora auth status` |
| `auth.tenant_mismatch` | 3 | no | verify tenant context with `weknora auth status` |
| `input.invalid_argument` | 5 | no | see `weknora <command> --help` for valid usage |
| `input.missing_flag` | 5 | no | see `weknora <command> --help` for valid usage |
| `input.confirmation_required` | 10 | **NO automatic retry** | high-risk write - re-run with `-y/--yes` after the user explicitly approves |
| `input.unknown_subcommand` | 5 | no | invocation reached a command path with no matching subcommand. Detail includes `available` list; retry with `<path> --help`. |
| `resource.not_found` | 4 | no | verify the resource ID and try again |
| `resource.already_exists` | 1 | no | use a different name or fetch the existing resource |
| `resource.locked` | 1 | maybe (transient lock) | (no canonical hint; check resource state) |
| `server.error` | 7 | yes (with backoff for 5xx) | (no canonical hint) |
| `server.timeout` | 7 | yes (with backoff) | request timed out; retry, or run `weknora doctor` to check connectivity |
| `server.rate_limited` | 6 | yes (back off, then retry) | rate-limited; retry after a few seconds |
| `server.session_create_failed` | 1 | yes (with backoff) | could not create a chat session; pass `--session` to reuse an existing session |
| `server.incompatible_version` | 7 | no (upgrade required) | run `weknora doctor` to see version skew details |
| `network.error` | 7 | yes (with backoff) | check base URL reachability with `weknora doctor` |
| `operation.timeout` | 124 | yes (raise `--timeout`) | wait timed out; raise `--timeout` or check the underlying job |
| `operation.failed` | 1 | no (target reached terminal failure) | one or more targets reached a terminal failure (e.g. doc parse_status=failed) |
| `operation.cancelled` | 1 (main overrides to 130) | no | command interrupted by SIGINT / SIGTERM. The typed code maps to exit 1, but `main` raises the exit to 130 when the root context was signal-cancelled so the user-visible exit follows Unix signal convention. |
| `local.config_corrupt` | 1 | no (manual fix) | remove `~/.config/weknora/config.yaml` and re-run `weknora auth login` |
| `local.profile_not_found` | 1 | no | (no canonical hint; check `weknora profile list`) |
| `local.file_io` | 1 | no | check file permissions under `$XDG_CONFIG_HOME/weknora/` |
| `local.kb_id_required` | 1 | no | run `weknora link` to bind this directory to a knowledge base, or pass `--kb` |
| `local.kb_not_found` | 1 | no | list available with `weknora kb list` |
| `local.keychain_denied` | 1 | no (system-level) | verify keyring access; falls back to file storage |
| `local.project_link_corrupt` | 1 | no | remove `.weknora/project.yaml` and run `weknora link` again |
| `local.sse_stream_aborted` | 1 | yes (rerun chat / session ask) | the streaming answer was cut off mid-flight; retry, or pass `--format json` to buffer the full response |
| `local.unimplemented` | 1 | no | (planned in a future release) |
| `local.upload_file_not_found` | 1 | no | verify the path is correct and readable |
| `local.user_aborted` | 1 | no (user said no) | no action taken; pass `-y/--yes` to skip the confirmation prompt |
| `internal.error` | 1 | no | catch-all for an untyped error that reached the top (a bug or unmapped dependency error); a recurring one is a classification gap worth reporting |
<!-- ERROR_REFERENCE_END -->
### AI agent decision shortcuts
For common retry patterns, AI agents can hardcode:
- `network.*` → retry with exponential backoff
- `auth.token_expired` → run `weknora auth refresh`, then retry once
- `server.rate_limited` → back off (Retry-After if present) then retry
- `operation.timeout` → raise `--timeout` and retry, or surface to user
- `input.confirmation_required` → **NEVER** auto-pass `-y` without explicit user authorization
- `*.invalid_argument` / `*.missing_flag` → surface to user (don't retry)
## Exit-10 anti-patterns
Exit code 10 (`input.confirmation_required`) marks a destructive write where the
CLI refused to proceed without explicit user approval. The retry envelope includes
`retry_argv` showing the exact argv that would proceed. AI agents must NEVER
auto-retry this exit code — every exit 10 is a user-in-the-loop decision.
**Don't do these:**
1. **Auto-add `-y/--yes` and retry.** The flag exists for the user, not the agent.
Surface the exit-10 envelope to the user verbatim and wait for explicit go-ahead.
2. **Execute `retry_argv`.** The argv is *informational* --
showing what *would* execute. Running it without user input collapses two steps
the user is supposed to see.
3. **Wrap the call in a retry-with-backoff loop.** Exit 10 is not transient. It's
a "this needs human approval" signal, not a transient transport error.
4. **Treat exit 10 as a generic error and fall back to a less-destructive verb.**
The user asked for the destructive verb. If they want something else they'll
say so. Don't substitute.
5. **Auto-add `-y` because the *previous* exit-10 was approved.** Each invocation
stands on its own. The user's prior approval doesn't extend to similar calls.
6. **Skip the prompt by switching to `--format json`.** JSON mode still emits
`input.confirmation_required` (just in envelope form). It's the same gate.
## Stream recovery
The `weknora session resume <session-id> --message <msg-id>` command resumes an SSE event stream for an existing assistant message. Use cases: network-blip recovery, long-running agent invocation polling, completed-stream inspection.
### Server semantics: replay-from-0, not cursor-resume
The server **replays all stored events from the start** of the assistant message, then **tails new events**. This is NOT a cursor-from-disconnect resume. Agents reconnecting mid-stream will receive ALL previously-emitted events again.
### Agent contract
1. **Dedupe by message_id** (or maintain a per-message event hash set). Naively processing all received events causes duplicate side effects (re-running tool calls, re-rendering answers).
2. **Capture message_id from the init event** of the original `chat` or `session ask` invocation — the CLI injects `{"event":"init", "session_id":"...", "message_id":"..."}` as the first NDJSON line.
3. **Handle `local.sse_stream_aborted` typed error**: server-side buffer expired (TTL exceeded) or process restarted (memory mode). The message is no longer recoverable; restart the original query.
### Server-side buffer TTL
| Mode | TTL |
|---|---|
| `STREAM_MANAGER_TYPE=redis` | **1 hour** (server-side; not configurable from the CLI) |
| `STREAM_MANAGER_TYPE=memory` (default) | **Process lifetime** (server restart = data loss; no explicit cleanup logic) |
After TTL, `weknora session resume` returns the typed error `local.sse_stream_aborted`, which maps to exit code 1 per the Error code reference.
## Dry-run contract
The `--dry-run` flag is available on every mutation cobra command (`kb create/edit/delete`, `agent create/edit/delete`, `doc create/upload/fetch/delete`, `chunk delete`, `session delete`, `auth refresh/logout`, `link/unlink`, `profile add/remove`) and on `weknora api` (POST/PUT/PATCH/DELETE only; GET rejected with FlagError exit 2).
### Envelope shape on dry-run
Success envelope (exit 0) with `data` field omitted (omitempty) and `meta.dry_run=true`:
```json
{"ok":true,"meta":{"dry_run":true,"plan":{"action":"kb.create","args":{"name":"foo","description":"bar"}}},"profile":"prod"}
```
`api` command additionally includes `method` / `path` / `body` in plan:
```json
{"ok":true,"meta":{"dry_run":true,"plan":{"action":"api.post","method":"POST","path":"/api/v1/knowledge-bases","body":{"name":"foo"}}}}
```
### Side effects suppressed
The dry-run path is **offline** — no SDK calls, no Factory.Client() init, no ResolveKB network query, no writes (keyring / `.weknora/project.yaml` / files). Works without active profile or network access.
### Interactions
| Combination | Behavior |
|---|---|
| `--dry-run` (destructive cmd, no `-y`) | NO exit-10; emits plan + exit 0 (preview implies no execution, so the confirmation gate is irrelevant) |
| `--dry-run` + `-y` | Equivalent to single `--dry-run`; `-y` is no-op (dry-run early-exits before ConfirmDestructive) |
| `--dry-run` + `api -X GET` (or default GET) | FlagError exit 2: "--dry-run requires explicit -X POST/PUT/PATCH/DELETE; default GET is read-only with no side effect to preview" |
| `--dry-run` + `--jq <expr>` | jq applied to envelope output normally |
| `--dry-run` + `kb edit my-kb` | plan.args contains user raw input (NOT ResolveKB-resolved); agent verifies kb name correctness |
| `--dry-run` + fetch-then-update (`kb edit / agent edit`) | plan.args contains user-explicit fields ONLY; agent infers server-side fetch-then-update preserves unmentioned fields |
| `--dry-run` + body containing secrets (`--input` payload) | **plan.body echoes the full body to stdout** so the agent can verify what would be sent; avoid piping secret-bearing bodies through dry-run for inspection |
### Streaming commands explicitly excluded
`session ask` and `chat` do NOT support `--dry-run` (streaming and dry-run have a semantic mismatch). For prompt-formation preview, use:
```bash
echo '{"query":"...","kb":"..."}' | weknora api -X POST /api/v1/sessions/<id>/agent-qa --input - --dry-run
```
## Risk metadata
Agents see the same `risk.action` string (in the form `noun.verb`) on three independent surfaces:
1. **Error envelope** — `envelope.error.risk.action` on an exit-10 confirmation-required error, so the agent can decide whether to escalate to the user. 11 unique values: `kb.delete`, `kb.edit`, `agent.delete`, `agent.edit`, `doc.delete`, `doc.delete_all`, `session.delete`, `chunk.delete`, `profile.remove`, `auth.logout`, `api.delete`.
2. **Help text** — a `Risk: <action> (destructive)` line prepended to the top of `--help` output on the 9 destructive cobra commands. `weknora api` is intentionally excluded: it is a generic HTTP passthrough whose risk depends on the method, so a static Risk: line would mislead for non-DELETE methods.
3. **MCP tool annotations** — `Tool.Annotations.destructiveHint` / `readOnlyHint` / `idempotentHint` / `openWorldHint` on every tool returned by `weknora mcp serve`.
The three surfaces do not auto-sync: each is wired separately so agents that only consume one surface still get the signal, but contributors adding a new destructive command must touch all three.
## 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 |
| `session_ask` | run a query through a custom agent |
Adding a tool is a deliberate API expansion — the AI-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:
1. `grep -A 50 "type Foo struct" client/foo.go` — dump SDK request/response schemas.
2. List every field with type and source line.
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 conventions
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 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.
- **Agent help blob**: Commands MAY call
`cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{...})` to expose a stable
JSON used_for / required_flags / examples / output / warnings shape.
Activated by `WEKNORA_AGENT_HELP=1` at `--help` time. Warnings are
always rendered in human help (stderr, not env-gated). Applied to
`chat`, `kb list`, `session ask`, and all destructive commands.
Extending to another command requires touching only that command's `NewCmd`.
## Status / check verb pair pattern
When a resource has both a cheap "is it alive?" probe and a deeper
"verify its dependencies / aggregate state" probe, expose them as two
verbs so the verb itself communicates cost:
- `status <id>` — single HTTP, returns reachable + cheap fields.
- `check <id>` — 1 + N HTTP, adds derived state that needs follow-up
calls (e.g., aggregating `failed_count` via doc-list page-walk,
probing every KB in an agent's scope).
Current pairs: `kb status` / `kb check`, `agent status` / `agent check`.
The deep verb's `Long` help text must enumerate the extra HTTP calls so
cost is predictable.
## Long-poll wait commands
`doc wait <doc-id> [<doc-id>...]` is the model for any future
`wait` command:
- Always wait-all on multi-target (no fail-fast flag); compose in shell
(`wait id1 && wait id2`) when fail-fast is needed.
- Exponential backoff with jitter (initial `--interval`, cap 15s).
- Concurrency capped (5 in flight); large fan-out via `xargs -P`.
- Exit-code priority: failed (1) > timeout (124) > completed (0). The
failed bucket is `operation.failed`, not `server.error` — a target's
own terminal failure is not a transient transport issue.
- Validate `--format` / `--jq` before polling so an invalid flag does
not cost the caller a multi-minute poll.
+575
View File
@@ -0,0 +1,575 @@
# Changelog — `weknora` CLI
All notable changes to the `weknora` CLI (the binary under `cli/` in this
repository) will be documented in this file.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
and the CLI follows [Semantic Versioning](https://semver.org/) independently
of the WeKnora server / frontend release cadence.
CLI history before v0.3 is recorded in the project root
[CHANGELOG.md](../CHANGELOG.md) under the release that introduced the CLI.
## [Unreleased]
### Breaking
- `chat` and `session ask` now distinguish JSON from NDJSON: the default
`--format json` buffers a bounded answer-event projection into one
`{ok:true,data:{events:[...]}}` envelope; use `--format ndjson` for the
complete raw event stream.
- JSON, text, and MCP chat/session output hide reasoning, tools, lifecycle
frames, and references by default. `--reference` adds bounded `kb_id` /
`chunk_id` / `parent_chunk_id` indexes; `--verbose` adds execution events.
- `session continue-stream` renamed to `session resume`.
- `kb init` renamed to `kb config set`.
- Error envelope: `retry_command` (a shell string) replaced by `retry_argv`
(a directly-executable argv array — no shell-splitting or quoting).
### Added
- `chat` / `session ask --reference` includes indexed citations, while
`--verbose` includes reasoning, tools, and lifecycle events. MCP `chat` /
`session_ask` expose the same controls through `reference` / `verbose` inputs.
- Buffered chat/session errors include the auto-created `session_id` in
`error.detail` so interrupted sessions remain recoverable.
- `error.exit_code` embeds the process exit code in the JSON error envelope,
disambiguating the two `input.invalid_argument` cases (parse error → 2,
typed-value error → 5).
- `kb status` / `kb check` / `kb create` report `retrieval_ready` (whether an
embedding model is bound); `kb create` hints the fix when it is false.
- `model create` / `model update` / `model delete` (`update` rotates key /
base-url in place, preserving the id).
- Stateless env-credential auth: `WEKNORA_API_KEY` / `WEKNORA_TOKEN` +
`WEKNORA_HOST`, a zero-disk path for headless / agent use. `auth logout` now
keeps the profile registered (use `profile remove` to delete it entirely).
- `meta.total_count` on paginated list / search output (full result size before
client-side `--limit` truncation).
### Changed
- JSON, text, and MCP now share one event projector and filtering policy.
- Projected references contain lookup indexes only; fetch full
passages with `chunk view <chunk_id>` or `chunk view <parent_chunk_id>`.
- NDJSON remains an unmodified SDK event trace, including reasoning and full
reference payloads.
### Fixed
- Streaming SDK calls are no longer cut off by the client's default 30-second
timeout (explicit `WithTimeout` values remain honored), and SSE data lines
up to 4 MiB are accepted.
- Terminal `response_type=error, done=true` frames now end SDK stream calls
even when the server leaves the HTTP connection open.
- Agent accumulation now waits for `response_type=complete` instead of treating
per-event `done:true` markers as completion of the whole run.
- Reference `knowledge_base_id`, `parent_chunk_id`, and `sub_chunk_id` fields
now survive SDK unmarshal.
- E2E chat step parses the bounded JSON envelope; MCP stream errors include
`session_id` in `error.detail`; terminal SSE errors classify as
`server.error` instead of `network.error` or `local.sse_stream_aborted`.
## [0.9.0] - 2026-06-10
### v0.9 — auth/profile model harmonization + flag cleanup
#### Added
- `weknora session stop <session-id>` command to abort an in-flight agent run.
- `profile add --use`: switch to the newly-added profile immediately (instead of only auto-selecting the first profile added).
- `-L` shorthand on `session view` (alias for `--limit`).
- `doc download --format json` now emits a success envelope (was bare).
- `SetAgentHelp` coverage extended across create / list / search commands.
#### Changed
- `auth login` now authenticates the **active profile** (resolved from config / global `--profile`) instead of creating a profile. Re-login MERGES credential refs into the existing record — host and an existing user are preserved, never clobbered.
#### Breaking
- **`--kb` now accepts a knowledge-base name *or* id** on `doc delete --all` and `search chunks` / `search docs`; it stays required (no silent project-link fallback on these commands).
- **`agent create --kb` renamed to `--attach-kb`** to disambiguate from the global `--kb` scope flag.
- **MCP tool `agent_invoke` renamed to `session_ask`** (clean rename — external MCP clients must update cached tool schemas).
- **`auth login` drops `--host` and `--name`.** It authenticates the active profile; create it first with `weknora profile add <name> --host <h> --use`. Target a non-active profile with the global `weknora --profile <name> auth login`.
- **`auth logout` and `auth refresh` drop `--name`.** They act on the active profile; target another with the global `--profile <name>`.
#### Removed
- Dead MCP error codes `mcp.readonly_mode`, `mcp.tool_not_allowed`, and `mcp.schema_unknown_command` (never emitted by the current tool surface).
---
### v0.8 — Agent safety nets + MCP annotations
#### Added
- `--dry-run` flag on every mutation cobra command (`kb create/edit/delete`, `agent create/edit/delete`, `doc create/upload/fetch/delete`, `chunk delete`, `session delete`, `auth refresh/logout`, `link/unlink`, `profile add/remove`) and on `weknora api` (POST/PUT/PATCH/DELETE only; GET returns FlagError exit 2).
- envelope `meta.dry_run: true` + `meta.plan: {action, args | method+path+body}` open-map fields (omitempty in non-dry-run envelopes).
- `weknora session continue-stream <session-id> --message <msg-id>` command for SSE event stream replay/recovery.
- MCP `Tool.Annotations` on all 10 MCP serve tools (`destructiveHint` / `readOnlyHint` / `idempotentHint` / `openWorldHint` + `Title`) per MCP spec 2025-06-18.
- `cli/internal/cmdutil/risk.go`: `SetRisk(cmd, action)` helper + `RiskDestructive` const + `GetRisk(cmd)` reader.
- Help output "Risk: <action> (<level>)" line at top of 9 destructive commands' `--help` (via modified `SetAgentHelp` wrapper).
- `cli/AGENTS.md` sections: Stream recovery / Dry-run contract / Risk metadata.
- `cli/README.md` sections: Dry-run preview / Resuming streams.
#### Changed
- `SetAgentHelp` wrapper in `cli/internal/cmdutil/agenthelp.go` now prepends "Risk:" line in default (non-JSON) help branch when `cmd.Annotations["risk.action"]` is set. WEKNORA_AGENT_HELP=1 JSON path unchanged.
- 9 destructive commands' `SetAgentHelp` Warnings standardized: line 1 is a verbatim exit-10 / `-y` reminder; line 2 carries per-command destructive context.
- `cli/cmd/api/api.go` now has `SetAgentHelp` with runtime exit-10 note for `-X DELETE/PUT/PATCH`.
- `cli/cmd/doc/delete.go` Warnings adds a 3rd line describing `--all` blast radius.
- Bumped `github.com/modelcontextprotocol/go-sdk` v1.6.0 → v1.6.1 (patch; opt-in `MCPGODEBUG` env var).
#### Breaking
*(none — v0.8 is fully additive on top of v0.7 envelope shape, NDJSON vocab, and typed code contracts; existing consumers continue to work and the new fields are optional via `omitempty`)*
---
### v0.7 — Agent-first wire contract + command-surface cleanup
#### BREAKING (v0.6 → v0.7)
- **All JSON output now wrapped in symmetric envelope.**
- Success on stdout: `{ok:true, data?:<T>, meta?, _notice?, profile?}` (`data`
omitted on mutation-only success).
- Error on stderr (json mode): `{ok:false, error:{type, message, hint?,
retry_command?, retry_after_seconds?, risk?, detail?}, _notice?}`.
- `meta.count` / `meta.has_more` surface list totals and server-side
pagination state. `meta.next_cursor` / `meta.total_count` /
`meta.request_id` are reserved — populated when the SDK exposes them
(planned for v0.8).
- Migration: replace `jq '.[]'` with `jq '.data[]'`; `.id` → `.data.id`;
list-count consumers read `.meta.count`.
- **`--format` default flips to `json` regardless of TTY.**
- v0.6: smart default (text on TTY, json on pipe).
- v0.7: always json; TTY only affects indent (compact in pipe). Enum
`text | json | ndjson` unchanged.
- Migration: humans on a TTY pass `--format text` (or set
`WEKNORA_FORMAT=text` env) for the prior auto-text behavior.
- **`chat` / `session ask` default to NDJSON event-stream (SDK passthrough).**
- v0.6: TTY rendered a live SSE animation; `--format json` produced a buffered
object; NDJSON was opt-in.
- v0.7: `--format json` and `--format ndjson` both emit one JSON event per line
(no envelope wrapping). CLI injects exactly one `init` event at stream head;
all subsequent events pass through verbatim from the SDK (`answer` /
`tool_call` / `tool_result` / `references` / `thinking` / `reflection` /
`error` / `complete` for chat; agent vocab is a subset).
- For prose rendering: `--format text`.
- **`weknora context` command group renamed to `weknora profile`.**
- Subcommands `context list/add/remove/use` → `profile list/add/remove/use`.
- Global flag `--context` → `--profile`.
- On-disk config `~/.config/weknora/config.yaml` keys `current_context:` /
`contexts:` → `current_profile:` / `profiles:` (no backwards-compat
alias; delete the file or rename the keys by hand to migrate).
- Binding file `.weknora/project.yaml` field `context:` → `profile:`
(re-run `weknora link` to regenerate).
- `profile use` JSON fields `current_context` / `previous_context` →
`current_profile` / `previous_profile`.
- `weknora link` JSON field `context` → `profile`.
- Rationale: `context` collided with LLM "context window" / RAG "context" /
Go `context.Context`. Mainstream multi-credential CLIs (AWS, Stripe,
OpenAI, Anthropic) settle on `profile` as the term of art.
- **`weknora agent invoke` removed; use `weknora session ask --agent <id>`.**
- Server route is `POST /sessions/{session_id}/agent-qa` — session-anchored.
- `weknora agent` keeps CRUD only (list / view / create / edit / delete /
status / check).
- Migration: `weknora agent invoke ag_x "Q"` →
`weknora session ask --agent ag_x "Q"` (auto-creates session if none given).
- **`weknora doc upload` split into three commands.**
- `weknora doc upload <file>` — local file only.
- `weknora doc fetch <url>` — server-side remote fetch (was `upload --from-url`).
- `weknora doc create --text "..."` — direct text knowledge.
- URL-only flags (`--title`, `--file-type`, `--tag-id`) moved to `doc fetch`.
- Rationale: `upload --from-url` mixed semantics ("send out" vs "pull in");
the three-verb split matches the server's three endpoints and gives each
one a single unambiguous shape.
- **`weknora kb empty` removed; use `weknora doc delete --all --kb=<id>`.**
- Atomic server `ClearKnowledgeBaseContents` (no list-then-delete race).
- Same exit-10 `-y/--yes` guard as `kb delete`.
- Migration: `weknora kb empty kb_x -y` →
`weknora doc delete --all --kb=kb_x -y`.
- **`weknora api -d/--data` flag removed; use `--input <file>` or `--input -`
(stdin).**
- `weknora api` now accepts any non-empty HTTP method (whitelist removed)
so the escape hatch can hit endpoints the CLI doesn't natively model.
- Migration: `weknora api -d '{"foo":1}' /endpoint` →
`echo '{"foo":1}' | weknora api --input - /endpoint`.
- **Batch operations envelope shape — per-item `ok` pattern.**
- `weknora doc delete id1,id2,id3` and similar multi-id mutations now emit:
`{ok, data:[{id, ok, result?|error?}, ...], meta:{count, successes, failures}}`.
- Top-level `ok` = AND-aggregate of per-item `ok` (false on partial failure).
- All-fail stays in batch shape (not error envelope) — agents can iterate
detail per id.
- jq pattern: `jq '.data[] | select(.ok == false) | .id'`.
- **MCP server tool errors now return `StructuredContent`.**
- `CallToolResult{IsError: true, Content:[text-fallback],
StructuredContent:{type, message, hint?, retry_command?, risk?, detail?}}`.
- Shape mirrors stderr `envelope.error` sub-object — one parser handles both.
- **Unknown subcommand emits typed envelope.**
- `input.unknown_subcommand` with `detail.{unknown, command_path, available[]}`
+ `retry_command: "<parent> --help"`. Replaces v0.6's free-form
`"unknown command \"x\" for \"weknora\""` prose.
- **`weknora chat` requires the query as a single quoted argument.**
- v0.6: `MinimumNArgs(1)` silently joined `weknora chat hello world` into
`"hello world"`.
- v0.7: `ExactArgs(1)` rejects multi-arg with exit 2; matches
`weknora session ask`. Quote the query: `weknora chat "hello world"`.
#### Added
- **`WEKNORA_PROFILE` env var** selects the active profile for a single
invocation (equivalent to `--profile <name>` global flag). Overridden by
explicit `--profile`. Useful for CI scripts that cannot pass global flags.
- **`WEKNORA_FORMAT` env var** sets the default `--format`. Values:
`text | json | ndjson`. Overridden by explicit `--format`. Invalid values
ignored.
- **`error.retry_command`** — directly-executable retry argv, distinct from
prose `hint`. Agents read `retry_command` without regex-parsing `hint`.
- **`error.retry_after_seconds`** — `server.rate_limited` / `server.timeout`
surface server `Retry-After` header verbatim. CLI-direct (`weknora api`)
parses HTTP `Retry-After` headers; SDK-mediated paths will gain coverage
as the SDK exposes typed transport errors.
- **`error.risk.{level, action}`** — destructive writes carry
`{level:"destructive", action:"<noun.verb>"}` (e.g. `doc.delete_all`,
`kb.delete`). Reserved levels `"read"` / `"write"` not yet emitted.
- **`_notice` envelope channel reserved** — open-map infrastructure in place
for deprecation / version_skew / security notices. Producer wiring planned
for v0.8 when the SDK exposes version metadata. Additive non-breaking;
unknown keys must be ignored.
- **`meta.count` / `meta.has_more`** on list commands. `meta.next_cursor` /
`meta.total_count` / `meta.request_id` reserved — populated when the SDK
exposes them (planned for v0.8).
- **`weknora doc fetch <url>`** — new command (see split above).
- **`weknora doc create --text "..."`** — new command (see split above).
- **`weknora session ask --agent <id> "..."`** — new command (replaces
`agent invoke`).
- **`weknora doc delete --all --kb=<id>`** — new mode of `doc delete`
(replaces `kb empty`).
- **NDJSON `init` event** at stream head for `chat` / `session ask` —
carries `session_id` + optional `kb_id` / `agent_id` / `model` / `profile`.
`request_id` field is reserved (not currently populated; planned for v0.8
when the SDK exposes response headers).
- **`AgentHelp.Warnings`** — destructive commands (`kb delete`, `doc delete`,
`agent delete`, `session delete`, `chunk delete`, `profile remove`,
`kb edit`, `agent edit`, `auth logout`) render an "AI agents:" warnings
block in `--help` to set explicit expectations around `-y/--yes`.
#### Changed
- `AGENTS.md` adds `## Wire contract for AI agents`, `## Deliberate deviations
+ mainstream alignments`, `## Pre-1.0 breaking policy`, `## Exit-10
anti-patterns` sections.
- `README.md` adds `### Agent quick start` under `## Wire contract`.
- `chunk` command group help: disambiguation prose vs `search chunks` removed
in favour of plain verb descriptions.
#### Deprecated (will remove in v0.8+)
- *(none — pre-release breaking release; no deprecation alias period.)*
---
### v0.6 — agent runtime hardening: --format, doc wait, --log-level, status, multi-id delete, paginate
#### BREAKING (v0.5 → v0.6)
- **`--json` flag removed** → use **`--format json`** (with optional
`--jq '<expr>'` for projection / filtering). The v0.5 `--json=fields,...`
per-field projection drops entirely; rewrite as
`--format json --jq '.[] | {id, name}'` (jq is the canonical projection
mechanism going forward).
- **`--no-stream` flag removed** on `chat` / `agent invoke` → use
**`--format json`** to buffer the full answer before printing. The bare
text-accumulate use case (TTY but no streaming) is dropped.
- **`WEKNORA_SDK_DEBUG=1` env removed** → use **`WEKNORA_LOG_LEVEL=debug`**.
- **`kb create --name <name>` flag removed** → use positional
**`kb create <name>`** (consistent with `agent create <name>`).
#### Added
- **`--format text|json|ndjson`** flag selecting the stdout serialization.
Registered per-command (only commands that honor `--format` register it;
others reject it with `unknown flag` / exit 2). Output mode auto-resolved
to `text` on a TTY and `json` when stdout was piped (v0.7 promoted the
flag to a persistent global and made the default always `json`).
- **`--jq '<expr>'`** flag pairs with `--format json|ndjson` to filter or
project the JSON output via a jq expression.
- **`weknora doc wait <id> [<id>...]`** — block until every document reaches a
terminal `parse_status`. Always wait-all — use shell composition
(`wait id1 && wait id2`) for fail-fast.
- `--timeout DURATION` (default 10m; exit 124 on hit)
- `--interval DURATION` (default 2s; exponential backoff to 15s + jitter)
- Multi-id concurrent (max 5 parallel); exit code priority 1 > 124 > 0
- **`--log-level error|warn|info|debug`** persistent flag + `WEKNORA_LOG_LEVEL`
env. Wires into the SDK's debug logger via the additive
`client.SetDebugLevel(level string)` function.
- **`kb create --storage-provider <local|minio|cos|tos|s3|oss|ks3>`** —
sets the new KB's `storage_provider_config.provider` at creation time
(server only accepts it on create, not update). Required on self-hosted
deployments where the server-side default doesn't pre-populate a
provider — without it, subsequent `doc upload` returns `kb not found`.
- **`weknora kb status <id>`** — fast health snapshot (1 HTTP). Returns
reachable / counts / is_processing.
- **`weknora kb check <id>`** — deep verification: status fields + `failed_count`
aggregated via doc list page-walk (1 + N HTTP). The verb split between
`status` (read state cheaply) and `check` (actively verify) communicates
cost to the caller.
- **`weknora agent status <id>`** — fast health snapshot (1 HTTP):
reachable / model_id.
- **`weknora agent check <id>`** — deep verification: status fields +
`kb_scope_all_reachable` from probing each KB in scope (1 + N HTTP). Same
status/check verb split as kb status/check.
- **`weknora doc delete <doc-id> [<doc-id>...]`** — positional multi-id.
Default keep-going on failure. Single `-y/--yes` confirms the entire
batch; non-TTY without `-y` still exits 10.
- **`weknora session delete <session-id> [<session-id>...]`** — positional
multi-id with the same keep-going semantics as `doc delete`.
- **`weknora chunk delete <chunk-id> [<chunk-id>...] --doc <doc-id>`** — positional
multi-id, all chunks share the same `--doc` parent (server route requires it).
- **`weknora api <path> --paginate`** — follows weknora's offset-based
pagination (`?page=N&page_size=M`) and merges all pages into a single
`{data, total}` JSON response.
- **MCP `chat` and `agent_invoke` tools** output schemas extended with
`thinking` / `tool_calls` / `assistant_message_id`. Tool descriptions
callout "server-side accumulated, NOT streaming" (MCP tools/call has
no standard partial-response).
- **`SetAgentHelp` pattern** — `cmdutil.SetAgentHelp(cmd, AgentHelp{...})`
exposes a stable JSON used_for / required_flags / examples / output
shape, activated by `WEKNORA_AGENT_HELP=1` at `--help` time. Applied
to `chat` and `kb list` as proof-of-pattern; extending to another
command requires touching only that command's `NewCmd`.
- **`cli/AGENTS.md`** gains an "Error code reference" section (35 typed
codes + exit codes + retryable / hint), with `<!-- ERROR_REFERENCE_START -->`
markers and CI parity test (`errors_doc_test.go`) — every new typed
code in `AllCodes()` must be documented or CI fails.
- New `operation.*` typed error namespace for CLI-level wait/poll outcomes:
- `operation.timeout` → exit 124 (distinct from `server.timeout` → exit 7;
matches the convention from GNU `timeout(1)`). Used by `doc wait` and
any future CLI-level wait/poll surfaces.
- `operation.failed` → exit 1. Emitted when one or more wait targets
reach a terminal failure (`doc wait` finds `parse_status=failed`) or
when multi-id `delete` rolls up partial failures. Distinct from
`server.error` because the failure is the target's own terminal state,
not a transient transport issue — `server.error`'s "retry with backoff"
hint would be misleading.
- `operation.cancelled` → exit 1, raised to **130** by `main.go` when the
root context was signal-cancelled. Surfaced by chat / agent invoke /
doc wait on Ctrl-C or SIGTERM. Carries a hint pointing at the signal,
not at `-y/--yes` (which would have been the misleading
`local.user_aborted` hint).
- **Signal-aware root context** — `main.go` wires `signal.NotifyContext` for
SIGINT and SIGTERM so long-running commands observe `ctx.Done()` and run
their cancellation cleanup (re-emit auto-created session id, return
`operation.cancelled`); the process exits 130 whenever the context was
signal-cancelled, matching Unix signal convention.
- **MCP tool input renames for consistency**: `doc_view` and `doc_download`
now accept `doc_id` (was `knowledge_id`) so every MCP tool that
references a document uses the same parameter name as `chunk_list` and
the CLI's `<doc-id>` positional.
- `WriteNDJSON` helper in `internal/format/` (per http://ndjson.org:
arrays split per-line, single records emit one line).
#### Changed
- `cli/README.md` "Exit codes" subsection extended with `124`
(`operation.timeout`); rows for `1` and `130` now name `operation.failed`
and `operation.cancelled` alongside the existing groupings.
- `cli/README.md` gains a "Status / check verb pair" subtable under "Health
check" and a `doc wait` paragraph with full exit-code list (0/1/124/130).
- `cli/AGENTS.md` gains design SOPs for **Status / check verb pair pattern**
and **Long-poll wait commands**, plus a note on the SetAgentHelp pattern
and current coverage (chat / kb list).
- **Multi-id delete partial-failure exit code**: `doc delete` /
`session delete` / `chunk delete` (multi-id mode) now exit `1`
(`operation.failed`) when some targets fail, rather than exit `7`
(`server.error`). The retry-with-backoff hint for server.* would have
misled callers when the actual cause is a target's terminal state.
- **`doc upload` with no path / no `--from-url`** now exits `2`
(`FlagError`, matching cobra's `MinimumNArgs` convention for commands
that need a positional), rather than `5` (`input.invalid_argument`).
- **`--log-level` invalid value** exits `2` (`FlagError`) for consistency
with `--format` invalid-value behaviour. Env values still fall through
silently (env is best-effort).
- **Multi-id delete stdout contract**: pre-flight failures (e.g. missing
`-y` confirmation) no longer emit the empty `{ok, failed}` envelope to
stdout — stdout stays empty per the wire contract in README.md, the
typed error goes to stderr only.
- **Positional id help strings now namespaced** for clarity in both human
help and agent `--help` parsing: `<id>` → `<kb-id>` / `<doc-id>` /
`<session-id>` on kb / doc / session subtrees. `agent` and `chunk`
subtrees were already namespaced. Pure help-text change — argument
parsing is unchanged.
- `chat "<text>"` Use string now shows quotes — matches `agent invoke` and
`search chunks` quoting hint for queries that contain spaces.
#### SDK additions (strictly additive)
- `client.SetDebugLevel(level string)` — programmatic control over the SDK's
internal slog debug logger.
### v0.5 — agent CRUD, chunk subtree, MCP chunk_list, audit-driven cleanup
#### Added
- `weknora agent create <name> --model <id>` / `agent edit <id>` /
`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.
- `weknora mcp serve` adds `chunk_list` as the 10th curated tool.
- `weknora agent view <id>` human output now renders all 34 AgentConfig
fields (previously 7), grouped into 10 presentation sections.
- `--all-pages` / `--page-size` on `search docs` and `search sessions`
(catching up with `session list` / `doc list` canon from v0.3+v0.4).
- `weknora doc list` gains `--keyword` / `--file-type` / `--source` /
`--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 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
1..1000) loads chat history via `LoadMessages` and renders messages
inline after session metadata. JSON mode projects messages into a
`messages` array. `--limit` without `--full` errors with
`input.invalid_argument`.
- `weknora kb view` human render now includes `TYPE`, `PINNED` (badge,
only when set), `TEMPORARY` (badge), `PROCESSING` (with doc count,
only when active), `SUMMARY MODEL`, and `CREATED`. Nested config
structs stay JSON-only.
- `weknora doc view` human render expands to include `TITLE` (when
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 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 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
#### Breaking changes
- Dropped the JSON envelope. `stdout` now emits bare typed data
(`{...}` or `[...]`); errors are written to `stderr` as `code: msg`
with an actionable `hint:` line. Pipelines using `--json | jq` no
longer have to filter out an envelope wrapper.
- Dropped `--dry-run`. Destructive writes still require `-y/--yes`;
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 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
tools (`kb_list`, `kb_view`, `doc_list`, `doc_view`, `doc_download`,
`search_chunks`, `chat`, `agent_list`, `agent_invoke`). Destructive
verbs are intentionally excluded.
- `weknora agent list` / `agent view` / `agent invoke` — manage and
call WeKnora's server-side Custom Agent resources.
- `weknora auth token` — print the active credential to `stdout` for
scripting (raw secret by default; `--json` emits `{token, mode, context}`).
- `weknora doc upload --from-url` — ingest a remote URL.
- `--json=fields,...` field projection and `--jq <expr>` filtering on
every command that emits JSON.
- `--limit` and `--all-pages` on list / search commands for bounded
output and explicit pagination control.
- Per-resource filter flags: `kb list --pinned`, `doc list --status`,
`session list --since`.
#### Changed
- Go toolchain bumped from 1.24 to 1.26.
- `auth login --with-token` validates the supplied key against
`/auth/me` before persisting, and prints an advisory if the keyring
is unavailable and credentials fall back to a 0600 file under
`$XDG_CONFIG_HOME/weknora/secrets/`.
- AGENTS.md rewritten as a developer guide (~170 lines, 6 H2 sections).
### v0.3 — extended management surface and a `session` subtree
#### Added
- `context add` / `context list` / `context remove` — first-class CRUD over
connection targets (previously implicit via `auth login --name`).
Removing the *current* context requires explicit `-y` (exit-10 protocol)
because subsequent commands have no default target.
- `auth refresh` — exchanges the stored refresh token for a new access +
refresh pair (OAuth refresh-token grant). Transparent 401 → refresh →
retry is also wired into the SDK transport with singleflight de-dup, so
most callers never need to invoke this explicitly.
- `kb edit` — partial-update edit with only-sent-fields semantics
(`*string` options so unset fields stay unset in the PUT body).
- `kb pin` / `kb unpin` — idempotent pin/unpin toggle; no-op when already
in the target state (emits `_meta.warnings`, no server call).
- `kb empty` — bulk-delete documents while preserving the KB record and
its config. High-risk-write; exit-10 confirmation in non-TTY / `--json`
paths.
- `doc view <id>` — show one document's metadata (title, file name,
type, size, parse status, embedding model, processed-at, error
message). Counterpart to `kb view` and `session view`.
- `doc download` — stream a knowledge file to disk (`-O FILE` /
`-O -` for stdout) with `--clobber` controlling overwrite. Rejects
server-supplied path-like filenames; partial writes on error are
cleaned up.
- `doc upload --recursive --glob '*.md'` — walk a directory and upload
every match. Per-file `OK` / `FAIL` progress lines on the human path;
aggregated `uploaded[]` / `failed[]` envelope on `--json`. Exit code
typed to the first failure's class on partial failure.
- `search chunks` / `search kb` / `search docs` / `search sessions` —
verb-noun subtree (gh `search code/repos/issues/…` shape). `search
chunks` is hybrid (vector + keyword) retrieval; the other three are
client-side substring filters useful for discovering identifiers.
All four take `--limit N` / `-L N` (1..1000) to cap returned rows.
- `session list` / `session view` / `session delete` — chat session
management.
- `api --input FILE` / `api --input -` — body source for raw HTTP
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.
- Completion smoke test guards against cobra bumps silently breaking
bash / zsh / fish / powershell completion.
#### SDK additions (Go client at `client/`, strictly additive)
- `OpenKnowledgeFile(ctx, id) (filename, body io.ReadCloser, err)` — new
primitive returning the body as a stream plus the server-suggested
Content-Disposition filename. `DownloadKnowledgeFile` is now a thin
wrapper (signature unchanged, gained partial-file-on-error cleanup).
- `WithTransport(http.RoundTripper) ClientOption` — lets the CLI install
the 401-retry transport.
- `PathAuthLogin` / `PathAuthRefresh` constants — so HTTP middleware
doesn't re-hardcode the literals.
- `IsPinned bool` field on `KnowledgeBase` (server already returned it;
SDK just hadn't modeled it).
+47
View File
@@ -0,0 +1,47 @@
# Makefile for the WeKnora CLI (cli/ Go module).
#
# Usage:
# make build compile to ./bin/weknora with version metadata
# make test go test ./...
# make test-coverage same with coverage report
# make lint go vet (golangci-lint planned)
# make tidy go mod tidy
# make clean remove ./bin
BINARY := weknora
BUILD_DIR := bin
PKG := github.com/Tencent/WeKnora/cli
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo none)
DATE := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
LDFLAGS := -s -w \
-X $(PKG)/internal/build.Version=$(VERSION) \
-X $(PKG)/internal/build.Commit=$(COMMIT) \
-X $(PKG)/internal/build.Date=$(DATE)
.PHONY: build test test-coverage lint tidy clean help
build:
@mkdir -p $(BUILD_DIR)
go build -ldflags="$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY) .
test:
go test ./...
test-coverage:
go test -coverprofile=coverage.out ./...
go tool cover -func=coverage.out
lint:
go vet ./...
tidy:
go mod tidy
clean:
rm -rf $(BUILD_DIR) coverage.out
help:
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
+466
View File
@@ -0,0 +1,466 @@
# weknora — WeKnora CLI
A command-line interface for the WeKnora RAG knowledge-base server. Lets you
authenticate, manage knowledge bases and documents, run hybrid search, and
ask streaming RAG questions from your terminal or from an AI agent.
```bash
$ weknora --help
Command-line client for the WeKnora RAG server. Manage knowledge bases
and documents, run hybrid search, chat with grounded answers, or expose
a curated read-only MCP tool surface for AI agents.
Available Commands:
agent Manage custom agents (CRUD + status/check)
api Make a raw API request to the WeKnora server
auth Manage authentication credentials and profiles
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
config Inspect the CLI's resolved configuration
doc Manage documents in a knowledge base
doctor Run 4 self-checks: base URL, auth, server version, credential storage
exit-codes Exit code matrix and the agent action for each
help Help about any command
kb Manage knowledge bases
link Bind the current directory to a knowledge base
mcp Run weknora as a Model Context Protocol server
message Inspect and manage messages inside chat sessions
model Manage models (list / view / create / update / delete)
profile Manage CLI profiles (named connection targets)
schema Machine-readable contract for a command (or the whole surface)
search Search across chunks, knowledge bases, documents, or sessions
session Manage chat sessions
skills List and install the bundled Agent Skills
unlink Remove the directory's knowledge-base binding
version Show CLI build metadata
```
The wire contract for AI agents is documented [below](#wire-contract).
For contributing to the CLI source, see [AGENTS.md](AGENTS.md).
---
## Install
### From source
Requires Go 1.26+.
```bash
git clone https://github.com/Tencent/WeKnora.git
cd WeKnora/cli
go build -o weknora .
sudo mv weknora /usr/local/bin/ # or anywhere on $PATH
```
### Pre-built binaries
Building from source is the supported install method today. Pre-built binaries,
`go install`, and a Homebrew formula are planned to accompany a tagged release;
until then, use the from-source build above.
---
## 5-minute quickstart
```bash
# 1. Register your WeKnora server as a profile and make it active
weknora profile add prod --host https://kb.example.com --use
# 2. Authenticate the active profile (interactive password prompt)
weknora auth login
# 2b. Or pipe an API key from stdin (for CI / AI agents)
echo "sk-..." | weknora auth login --with-token
# 3. List knowledge bases
weknora kb list
# 4. Bind this directory to a knowledge base — subsequent commands auto-resolve --kb
weknora link --kb my-knowledge-base
# 5. Upload a document, then block until parsing finishes
weknora doc upload notes.md
weknora doc wait doc_abc # exit 0 completed, 1 failed, 124 --timeout, 130 ^C
weknora doc reparse doc_abc # re-trigger parsing if it failed, then wait again
# 6. Search
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 and run them (see `weknora agent --help` / `weknora session --help`)
weknora model list # discover a model id for --model
weknora agent list
weknora session ask --agent ag_abc "what's our q4 retention plan?"
# 9. Inspect a document's chunks for RAG retrieval debug
weknora chunk list --doc doc_xyz
# 10. Inspect messages in a session / search across sessions
weknora message list --session sess_abc
weknora message search "retry policy" # cross-session Q&A retrieval
# 11. Resolve a pending tool approval (agent run blocked on approval event)
weknora session tool-approval resolve pend_xxx -y # approve (after user go-ahead)
weknora session resume sess_abc --message msg_xyz # resume the blocked stream
# 12. Health & verification verbs
weknora kb status kb_abc # fast snapshot: reachable / counts / processing flag (1 HTTP)
weknora kb check kb_abc # deep verify: also aggregates failed_count via doc list (1+N HTTP)
weknora agent status ag_abc # fast: reachable / model_id
weknora agent check ag_abc # deep: probes every KB in the agent's scope
```
---
### Agent quick start
For AI agents (any MCP-capable host) integrating WeKnora:
1. Install: build from source (see [Install](#install))
2. Authenticate. In a sandbox / CI, the **stateless** path needs no `auth login`
and writes nothing to disk — just set two env vars:
```bash
export WEKNORA_API_KEY="sk-…" # or WEKNORA_TOKEN for a bearer JWT
export WEKNORA_HOST="https://kb.example.com"
weknora kb list # already authenticated
```
Or, for a persisted local profile:
```bash
weknora profile add prod --host <server-url> --use
weknora auth login
```
3. Register MCP in the host's MCP config:
```json
{"mcpServers": {"weknora": {"command": "weknora", "args": ["mcp", "serve"]}}}
```
4. Read the [wire contract](AGENTS.md#wire-contract-for-ai-agents) before
parsing `--format json` output.
5. Read the [exit-10 anti-patterns](AGENTS.md#exit-10-anti-patterns) before
any destructive call.
**Bundled Agent Skills.** This CLI ships [Agent Skills](https://agentskills.io/specification)
under [`skills/`](skills/) that teach an agent to drive WeKnora without trial and error:
- [`weknora-shared`](skills/weknora-shared/SKILL.md) — **read first**: auth/profile
sequence, `--kb` resolution, the JSON-envelope + exit-code contract, the exit-10
protocol, `--dry-run`, and CLI-vs-MCP selection.
- [`weknora-rag-search`](skills/weknora-rag-search/SKILL.md) — when to use `chat`
vs `session ask` vs `search chunks`, plus retrieval gotchas.
Install them with the CLI (the skills are embedded in the binary, no checkout
needed):
```bash
weknora skills install # writes to ~/.claude/skills
weknora skills install --dir <agent-skills-dir> --force # other agents / overwrite
weknora skills list --format json # what would be installed
```
Existing files are left untouched without `--force`; `--dry-run` previews the
file list. Each skill's frontmatter records the CLI version it was
`tested_against`; a CI parity test (`internal/skillparity`) fails if a skill
ever references a command, flag, or MCP tool the CLI no longer has.
---
## Multi-profile
`profile.*` manages profile *records* (positional `<name>`); `auth.*` operates
on the *active* profile (override per-invocation with the global `--profile`
flag). Create a profile first, then authenticate it:
```bash
weknora profile add prod --host https://prod.example.com --use # add + switch
weknora auth login # authenticate active (prod)
weknora profile add staging --host https://staging.example.com # add (stays inactive)
echo "sk-..." | weknora --profile staging auth login --with-token # authenticate staging
weknora auth list
weknora profile use prod # switch back
```
Credentials are persisted to your OS keyring (Keychain on macOS, libsecret on
Linux, Wincred on Windows) when available, otherwise to a 0600-mode file
under `$XDG_CONFIG_HOME/weknora/secrets/`. The active profile lives in
`~/.config/weknora/config.yaml`.
To remove a profile's stored credentials:
```bash
weknora auth logout # active profile
weknora --profile staging auth logout # specific profile
weknora auth logout --all
```
---
## Wire contract
Designed to be AI-agent-first. Stable across minor releases; breaking
changes announced in the changelog and the corresponding
`weknora --version` bump. This section is the human overview; the complete,
authoritative contract (envelope field stability, error taxonomy, streaming,
confirmation and dry-run protocols) lives in **[AGENTS.md](AGENTS.md)**.
### Streams
- **stdout** is the data channel: bare JSON with `--format json`, or
human-formatted output. Never carries error text.
- **stderr** is logs, progress, warnings, and errors. A non-empty
stderr does **not** mean failure — read the exit code.
### JSON output
Every command supports `--format json`, wrapping the resource in the
symmetric envelope `{ok, data, meta?}` — `data` is an array for `list` /
`search`, a single object for `view` and write outcomes. `--jq` runs
against the whole envelope, so reach into list items with `.data[]`:
```bash
weknora kb list --format json # {"ok":true,"data":[{"id":"kb_x",…}],"meta":{"count":1,…}}
weknora kb view kb_x --format json # {"ok":true,"data":{"id":"kb_x","name":"Eng",…}}
weknora kb list --format json --jq '.data[] | {id, name}' # project listed fields out of each item
weknora kb list --format json --jq '.data[].id' # ids only
weknora kb list --format json --jq '.meta.count' # number returned
```
`--format ndjson` is also accepted for streaming list commands; each
element is emitted as its own JSON line. `--format json` is the default
regardless of TTY — running `weknora kb list | jq` works without an
explicit flag. Use `--format text` for human-readable output.
### Errors
On failure, stdout stays empty and the typed error goes to stderr in
this format:
```
<code.namespace>: <message>[: <wrapped cause>]
hint: <actionable next-step>
```
Example:
```
auth.unauthenticated: fetch current user: HTTP error 401: ...
hint: run `weknora auth login`
```
Under `--format json` the same failure is the typed error envelope on stderr
(`{ok:false, error:{type, exit_code, hint?, retry_argv?, …}}`) — see
[AGENTS.md §1.4](AGENTS.md) for the field-by-field contract and the full code
taxonomy.
### Exit codes
| Code | Meaning | Agent action |
|---|---|---|
| `0` | success | continue |
| `1` | typed `local.*` / `operation.failed` / unclassified | read stderr, decide retry/abort |
| `2` | flag / argument validation error | re-check `weknora <cmd> --help` |
| `3` | `auth.*` (token missing / expired / forbidden) | re-auth, then retry |
| `4` | `resource.not_found` | verify the resource id |
| `5` | `input.*` (other than `confirmation_required`) | adjust args, retry |
| `6` | `server.rate_limited` | back off, retry |
| `7` | `server.*` / `network.*` | transient — retry with backoff |
| `10` | **`input.confirmation_required`** (high-risk write) | ask the human, retry with `-y` only after explicit approval |
| `124` | `operation.timeout` (e.g. `doc wait --timeout` reached) | raise `--timeout` or check the underlying job |
| `130` | cancelled by signal — typed `operation.cancelled` errors exit 1; `main.go` promotes the process exit to 130 when the root context was signal-cancelled (SIGINT / SIGTERM) | stop, do not retry |
Run `weknora exit-codes` for the machine-readable matrix (JSON); `weknora help exit-codes` for the human-readable table.
**Exit 10** is the wire-level signal for "destructive write needs
explicit confirmation". Pass `-y/--yes` on `kb delete` /
`doc delete` (including `--all --kb=<id>`) / `session delete` /
`profile remove` (on the current profile) / `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 AI-agent ergonomics
- For chat / session ask in AI-agent contexts, pass `--format json` for a
bounded answer-event envelope. Add `--reference` for indexed citations,
`--verbose` for reasoning/tools/lifecycle events, or `--format ndjson` for
the unmodified raw stream.
- `--format json` composes with the global `--profile <name>` for
single-shot profile overrides without disk writes.
- `weknora mcp serve` exposes a curated read-only tool surface over
stdio MCP for any MCP-compatible client.
- `weknora schema` enumerates every command with its `used_for`, and
`weknora schema <cmd path>` (e.g. `weknora schema doc update`) prints that
command's full contract — `used_for`, `required_flags`, `examples`,
`output`, `warnings`, `risk`, and local `flags` — as the standard envelope,
so an agent can discover the surface without scraping `--help` prose.
---
## 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. Use
`weknora api PUT /api/v1/knowledge-bases/<id> --input -` with a JSON
body matching the server's `UpdateKnowledgeBaseRequest`. (Note: the
storage provider is set once at create time via
`kb create --storage-provider <name>` and is not updatable.)
- **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 `session ask --agent` 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.
---
## Dry-run preview
Add `--dry-run` to any mutation command to preview the would-be action without executing it. Useful for verifying flag/arg parsing before committing to a destructive operation, or for agent-side action planning.
```bash
# Preview a kb create without actually creating
weknora kb create --name "test-kb" --description "for review" --dry-run
# Output (single line; pretty-printed here for readability):
# {
# "ok": true,
# "meta": {
# "dry_run": true,
# "plan": {
# "action": "kb.create",
# "args": {"name": "test-kb", "description": "for review"}
# }
# }
# }
# Exit code: 0
```
dry-run is **offline**: no network calls, no file IO, no credential touches. Works without an active profile.
For destructive commands, dry-run does NOT trigger the exit-10 confirmation flow:
```bash
weknora kb delete kb_xxxx --dry-run # exit 0, no prompt
weknora kb delete kb_xxxx # exit 10, prompts for -y
```
For the `api` command, dry-run requires explicit write method (POST/PUT/PATCH/DELETE); GET returns FlagError:
```bash
echo '{"name":"foo"}' | weknora api -X POST /api/v1/knowledge-bases --input - --dry-run # OK
weknora api /api/v1/knowledge-bases --dry-run # exit 2: requires explicit -X
```
---
## Resuming streams
The `weknora session resume` command resumes an SSE event stream for an existing assistant message. Useful for network-blip recovery or polling long-running agent invocations:
```bash
# Original streaming call captures session_id + message_id from init event:
weknora session ask "..." --agent ag_xxxx --format ndjson | tee /tmp/stream.ndjson
# {"type":"init","session_id":"sess_abc","message_id":"msg_xyz"}
# ... events flow ...
# [network blip]
# Resume the same stream:
weknora session resume sess_abc --message msg_xyz
# Server REPLAYS all stored events from the start, then tails new ones.
# Agent must dedupe (by message_id or event hash) to avoid double-processing.
```
### Tool-approval unlock chain
An agent run may pause the stream on a tool-approval event until a human approves or rejects the pending tool call. The unlock sequence:
```bash
# 1. Stream pauses with a tool-approval event carrying a pending_id.
# 2. Surface the pending tool call to the user; get explicit go-ahead.
weknora session tool-approval resolve pend_xxx -y # approve
# weknora session tool-approval resolve pend_xxx --reject --reason "..." -y # reject
# 3. Resume the stream — server replays + tails from where the run was blocked.
weknora session resume sess_abc --message msg_xyz
```
Pass `--modified-args '{"key":"value"}'` to replace tool arguments on approve (must be a non-empty JSON object). Never auto-pass `-y` — the approval is the exit-10 human-in-the-loop gate.
Server-side buffer TTL: 1 hour for redis mode; process lifetime for memory mode (default). After TTL, expect `local.sse_stream_aborted` typed error.
See `cli/AGENTS.md` "Stream recovery" section for the full agent contract.
---
## Health check
Run `weknora doctor` for a 4-status diagnostic (OK / warn / fail /
skip) covering base URL reachability, authentication, server-CLI
version skew, and credential storage backend. Add `--format json` for
machine-readable output, `--offline` to skip network checks.
For per-resource verification, the `status` / `check` verb pair gives
a fast vs deep choice:
| Verb | Cost | Use |
|---|---|---|
| `weknora kb status <kb-id>` | 1 HTTP | live counts / processing flag |
| `weknora kb check <kb-id>` | 1+N HTTP | adds `failed_count` via doc-list page-walk |
| `weknora agent status <agent-id>` | 1 HTTP | reachable / model_id |
| `weknora agent check <agent-id>` | 1+N HTTP | also probes every KB in the agent's scope |
`weknora doc wait <doc-id> [<doc-id>...]` blocks until each document
reaches a terminal `parse_status` (completed or failed). Exit codes:
0 (all completed), 1 (any failed), 124 (`--timeout` reached), 130
(Ctrl-C / SIGTERM). Multi-target is polled concurrently (max 5 in
flight; pipe through `xargs -P` for more).
---
## Development
```bash
# Run unit + contract tests
go test ./...
# Run the real-server e2e suite (requires WEKNORA_E2E_HOST + token env vars)
go test -tags acceptance_e2e ./acceptance/e2e/...
# Static analysis
go vet ./...
```
CI (`.github/workflows/cli.yml`) runs build + unit + contract tests on Linux /
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 conventions). Run `go test ./... -race -count=1` and `go vet ./...`
before submitting.
---
## License
MIT — see the repository [LICENSE](../LICENSE).
+214
View File
@@ -0,0 +1,214 @@
// cli/acceptance/contract/errorcodes_test.go
package contract_test
import (
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"strings"
"testing"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
)
// TestAllReferencedCodesAreRegistered scans cli/cmd/ for every literal use of
// cmdutil.NewError(codeXxx, ...) and cmdutil.Wrapf(codeXxx, ...) and verifies
// that codeXxx is registered in cmdutil.AllCodes().
//
// ClassifyHTTPError is dynamic - callers don't pass a literal CodeXxx
// ident. Most SDK call sites go through cmdutil.WrapHTTP(err, ...) which
// the scanner skips entirely (it only inspects NewError / Wrapf selector
// names). A few sites still call ClassifyHTTPError directly to inspect
// or remap the code; those are call-expression args the scanner also
// skips. Either way, the codes those paths can yield are bridged via
// cmdutil.ClassifyHTTPErrorOutputs(), which enumerates every code the
// switch can return - added to the registered set so the AST scanner
// doesn't false-positive on them.
//
// Limitations:
// - Only literal cmdutil.CodeXxx idents are detected; codes assigned to
// a local variable then passed are NOT scanned (rare pattern).
// - cmdutil.WrapHTTP(...) and cmdutil.ClassifyHTTPError(...) call
// expressions are skipped - the ClassifyHTTPErrorOutputs bridge
// covers their dynamic codes.
func TestAllReferencedCodesAreRegistered(t *testing.T) {
registered := make(map[cmdutil.ErrorCode]struct{})
for _, c := range cmdutil.AllCodes() {
registered[c] = struct{}{}
}
// Bridge: ClassifyHTTPError returns these dynamically; treat them as
// "registered" for the purposes of the AST scan since callers pass the
// function call (not a literal ident) as arg0.
for _, c := range cmdutil.ClassifyHTTPErrorOutputs() {
registered[c] = struct{}{}
}
referenced := scanCmdDir(t, "../../cmd")
for code, locs := range referenced {
if _, ok := registered[code]; !ok {
for _, loc := range locs {
t.Errorf("%s: error code %q referenced but not registered in cmdutil.AllCodes()", loc, code)
}
}
}
}
// scanCmdDir walks cli/cmd/** for *.go files (excluding tests) and collects
// every literal cmdutil.CodeXxx ident passed as the first arg to
// cmdutil.NewError / cmdutil.Wrapf.
//
// Returns map keyed by ErrorCode value (the const's underlying string), with
// a slice of source positions for nicer error messages.
func scanCmdDir(t *testing.T, dir string) map[cmdutil.ErrorCode][]token.Position {
t.Helper()
out := make(map[cmdutil.ErrorCode][]token.Position)
fset := token.NewFileSet()
walkAndScan(t, fset, dir, out)
return out
}
func walkAndScan(t *testing.T, fset *token.FileSet, root string, out map[cmdutil.ErrorCode][]token.Position) {
t.Helper()
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
if filepath.Ext(path) != ".go" {
return nil
}
if strings.HasSuffix(path, "_test.go") {
return nil
}
f, err := parser.ParseFile(fset, path, nil, 0)
if err != nil {
t.Fatalf("parse %s: %v", path, err)
}
collectErrorCodes(fset, f, out)
return nil
})
if err != nil {
t.Fatalf("walk %s: %v", root, err)
}
}
func collectErrorCodes(fset *token.FileSet, f *ast.File, out map[cmdutil.ErrorCode][]token.Position) {
ast.Inspect(f, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok || len(call.Args) == 0 {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
x, ok := sel.X.(*ast.Ident)
if !ok || x.Name != "cmdutil" {
return true
}
if sel.Sel.Name != "NewError" && sel.Sel.Name != "Wrapf" {
return true
}
// First arg should be cmdutil.CodeXxx (SelectorExpr ident).
// If it's a function call (e.g. cmdutil.ClassifyHTTPError(err)), skip
// - bridge handles those.
arg0Sel, ok := call.Args[0].(*ast.SelectorExpr)
if !ok {
return true
}
argX, ok := arg0Sel.X.(*ast.Ident)
if !ok || argX.Name != "cmdutil" {
return true
}
code, ok := identToErrorCode(arg0Sel.Sel.Name)
if !ok {
// Unknown ident name - record as bogus so the test fails with
// a clear "code referenced but not registered" message.
code = cmdutil.ErrorCode("UNKNOWN_REF:" + arg0Sel.Sel.Name)
}
pos := fset.Position(call.Pos())
out[code] = append(out[code], pos)
return true
})
}
// identToErrorCode maps an ident name like "CodeAuthUnauthenticated" to its
// underlying ErrorCode value via a simple switch. Avoids reflect.
// Keep in sync with cmdutil.AllCodes() - adding a new const here is the same
// bookkeeping as adding it to AllCodes().
func identToErrorCode(name string) (cmdutil.ErrorCode, bool) {
switch name {
case "CodeAuthUnauthenticated":
return cmdutil.CodeAuthUnauthenticated, true
case "CodeAuthTokenExpired":
return cmdutil.CodeAuthTokenExpired, true
case "CodeAuthBadCredential":
return cmdutil.CodeAuthBadCredential, true
case "CodeAuthForbidden":
return cmdutil.CodeAuthForbidden, true
case "CodeAuthCrossTenantBlocked":
return cmdutil.CodeAuthCrossTenantBlocked, true
case "CodeAuthTenantMismatch":
return cmdutil.CodeAuthTenantMismatch, true
case "CodeResourceNotFound":
return cmdutil.CodeResourceNotFound, true
case "CodeResourceAlreadyExists":
return cmdutil.CodeResourceAlreadyExists, true
case "CodeResourceLocked":
return cmdutil.CodeResourceLocked, true
case "CodeInputInvalidArgument":
return cmdutil.CodeInputInvalidArgument, true
case "CodeInputMissingFlag":
return cmdutil.CodeInputMissingFlag, true
case "CodeInputConfirmationRequired":
return cmdutil.CodeInputConfirmationRequired, true
case "CodeInputUnknownSubcommand":
return cmdutil.CodeInputUnknownSubcommand, true
case "CodeServerError":
return cmdutil.CodeServerError, true
case "CodeServerTimeout":
return cmdutil.CodeServerTimeout, true
case "CodeServerRateLimited":
return cmdutil.CodeServerRateLimited, true
case "CodeServerIncompatibleVersion":
return cmdutil.CodeServerIncompatibleVersion, true
case "CodeNetworkError":
return cmdutil.CodeNetworkError, true
case "CodeLocalConfigCorrupt":
return cmdutil.CodeLocalConfigCorrupt, true
case "CodeLocalKeychainDenied":
return cmdutil.CodeLocalKeychainDenied, true
case "CodeLocalFileIO":
return cmdutil.CodeLocalFileIO, true
case "CodeLocalProfileNotFound":
return cmdutil.CodeLocalProfileNotFound, true
case "CodeKBIDRequired":
return cmdutil.CodeKBIDRequired, true
case "CodeKBNotFound":
return cmdutil.CodeKBNotFound, true
case "CodeProjectLinkCorrupt":
return cmdutil.CodeProjectLinkCorrupt, true
case "CodeUserAborted":
return cmdutil.CodeUserAborted, true
case "CodeUploadFileNotFound":
return cmdutil.CodeUploadFileNotFound, true
case "CodeSSEStreamAborted":
return cmdutil.CodeSSEStreamAborted, true
case "CodeSessionCreateFailed":
return cmdutil.CodeSessionCreateFailed, true
case "CodeOperationTimeout":
return cmdutil.CodeOperationTimeout, true
case "CodeOperationFailed":
return cmdutil.CodeOperationFailed, true
case "CodeOperationCancelled":
return cmdutil.CodeOperationCancelled, true
case "CodeInternalError":
return cmdutil.CodeInternalError, true
}
return "", false
}
+118
View File
@@ -0,0 +1,118 @@
// cli/acceptance/contract/helpers_test.go
package contract_test
import (
"bytes"
"context"
"flag"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/Tencent/WeKnora/cli/cmd"
"github.com/Tencent/WeKnora/cli/cmd/doctor"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/secrets"
sdk "github.com/Tencent/WeKnora/client"
)
// TestMain pins the doctor credential-storage outcome for the whole suite.
// Otherwise the check probes the real OS keyring, which differs between
// macOS dev machines (Keychain present → ok) and Linux CI runners without
// libsecret (file fallback → warn), making golden outputs host-dependent.
// MemStore is neither *FileStore nor a real keyring, so the doctor's
// type-switch hits the StatusOK branch.
func TestMain(m *testing.M) {
restore := doctor.SetCredStoreFactoryForTest(func() (secrets.Store, error) {
return secrets.NewMemStore(), nil
})
defer restore()
os.Exit(m.Run())
}
// update is the standard Go test golden-update flag.
//
// go test -update ./acceptance/contract/...
var update = flag.Bool("update", false, "update golden files")
// newTestFactory builds a Factory whose Client returns mockClient.
// Caller must NOT use t.Parallel() - see iostreams.SetForTest contract.
//
// WEKNORA_BASE_URL is set when mockServer is non-nil. buildClient does not
// currently honor this env var (it reads from config.Host); commands that
// need the mock URL rely on the mockClient injection above. The env is set
// anyway for any direct net/http callers (e.g. doctor's HEAD /health).
func newTestFactory(t *testing.T, mockServer *httptest.Server, mockClient *sdk.Client) *cmdutil.Factory {
t.Helper()
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("XDG_CACHE_HOME", t.TempDir())
if mockServer != nil {
t.Setenv("WEKNORA_BASE_URL", mockServer.URL)
}
f := cmdutil.New()
if mockClient != nil {
f.Client = func() (*sdk.Client, error) { return mockClient, nil }
}
return f
}
// runCmd executes the root command in-process and returns captured stdout/stderr.
// Replaces iostreams.IO singleton via SetForTest (auto-restored in t.Cleanup).
//
// Mirrors cmd.Execute(): wires the cobra Out / Err sinks to the same buffers
// it returns, and re-runs cmdutil.PrintError on stderr for failure cases so
// the contract assertion sees the typed `code: message\nhint: ...` line.
func runCmd(t *testing.T, f *cmdutil.Factory, args ...string) (stdout, stderr string, exitCode int) {
t.Helper()
out, errBuf := iostreams.SetForTest(t)
root := cmd.NewRootCmd(f) // exported in cli/cmd/root.go (Task 16)
root.SetArgs(args)
root.SetContext(context.Background())
root.SetOut(out)
root.SetErr(errBuf)
_, err := root.ExecuteC()
if err != nil {
err = cmd.MapCobraError(err)
cmdutil.PrintError(iostreams.IO.Err, err)
}
return out.String(), errBuf.String(), cmdutil.ExitCode(err)
}
// assertGolden compares got against the JSON golden file at path.
// With -update, writes got to path.
//
// CRLF normalization: Windows checkouts with the default core.autocrlf=true
// turn LF in tracked text files into CRLF on disk. The command output is
// always LF, so byte-equal would fail despite identical content.
// .gitattributes is the primary defense (forcing LF on testdata/**/*.json),
// but we also strip CR here so a misconfigured contributor checkout doesn't
// break the suite locally before they push.
func assertGolden(t *testing.T, got []byte, path string) {
t.Helper()
if *update {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
t.Fatalf("mkdir testdata: %v", err)
}
if err := os.WriteFile(path, got, 0644); err != nil {
t.Fatalf("write golden: %v", err)
}
return
}
want, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read golden %s: %v (run with -update to create)", path, err)
}
want = stripCR(want)
got = stripCR(got)
if !bytes.Equal(want, got) {
t.Errorf("stdout mismatch for %s\nwant:\n%s\ngot:\n%s", path, want, got)
}
}
// stripCR removes CR bytes so CRLF golden files (from Windows autocrlf
// checkout) compare equal to LF runtime output.
func stripCR(b []byte) []byte {
return bytes.ReplaceAll(b, []byte{'\r'}, nil)
}
+372
View File
@@ -0,0 +1,372 @@
// cli/acceptance/contract/wire_test.go
//
// Wire contract test. Drives root cobra in-process for each scenario,
// captures stdout + stderr, and asserts:
//
// - stdout matches a JSON golden in cli/acceptance/testdata/wire/
// - on wantErr cases, stderr contains the expected typed error code
//
// Successful cases produce bare JSON on stdout (no envelope wrapper);
// failure cases produce empty stdout (or, for `doctor`, the data object
// the command writes before returning SilentError) and a `code: msg`
// line on stderr.
//
// Cases intentionally omitted (with reason):
// - doctor.success - non-offline path emits
// unstable timing
// ("reachable in 2ms").
// Unit tests in cli/cmd/doctor
// cover the all-pass shape;
// doctor.success_offline is
// the deterministic sibling
// kept here.
// - auth_login.success - requires stdin pipe
// (--with-token) + keyring-
// aware Secrets store; the
// helper does not yet expose
// a stdin hook.
// - auth_login.error_auth_unauthenticated - same setup as above.
//
// All cases use leaf-positioned --format json (e.g. `version --format json`). --format is a
// per-leaf flag, not a global persistent flag.
package contract_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"github.com/Tencent/WeKnora/cli/internal/config"
sdk "github.com/Tencent/WeKnora/client"
)
// wireCase declares one row in the contract matrix. Optional fields:
//
// server - mock /api/v1/* endpoints; nil means no network.
// preConfig - seed config.yaml under the per-test XDG_CONFIG_HOME
// (set by newTestFactory); use for cases like
// `profile use` that read local state without an
// SDK round-trip.
// wantErr - non-zero exit expected.
// wantStderrSubstring - stderr must contain this substring (typically the
// typed error code, e.g. "auth.unauthenticated").
// Only meaningful when wantErr=true.
type wireCase struct {
name string
args []string
server http.HandlerFunc
preConfig func(t *testing.T)
wantErr bool
wantStderrSubstring string
}
// wireCases enumerates every contract scenario whose stdout is golden-pinned.
// Order is illustrative, not load-bearing.
var wireCases = []wireCase{
// 1. version.success - pure local; no client touched.
{
name: "version.success",
args: []string{"version", "--format", "json"},
},
// 2. doctor.success_offline - only credential_storage runs; the three
// network checks are skipped. Stable details + summary.
{
name: "doctor.success_offline",
args: []string{"doctor", "--offline", "--format", "json"},
server: doctorReachable, // ensures buildServices succeeds even if probed
},
// 3. doctor.error_network - base_url returns 500 → ping fail → cascade
// skip on auth_credential + server_version. credential_storage still
// runs (independent). Contract: any check=fail bumps summary.failed
// and RunE returns SilentError → exit 1 with the data object
// written by emit() as the only stdout content.
{
name: "doctor.error_network",
args: []string{"doctor", "--format", "json"},
server: alwaysServerError,
wantErr: true,
},
// 4-7. kb list / get - SDK paths /api/v1/knowledge-bases[/<id>]
{
name: "kb_list.success",
args: []string{"kb", "list", "--format", "json"},
server: kbListTwo,
},
{
name: "kb_list.success_empty",
args: []string{"kb", "list", "--format", "json"},
server: kbListEmpty,
},
{
name: "kb_list.error_auth_forbidden",
args: []string{"kb", "list", "--format", "json"},
server: always403,
wantErr: true,
wantStderrSubstring: "auth.forbidden",
},
{
name: "kb_view.success",
args: []string{"kb", "view", "kb1", "--format", "json"},
server: kbGetOne,
},
{
name: "kb_view.error_resource_not_found",
args: []string{"kb", "view", "missing", "--format", "json"},
server: always404,
wantErr: true,
wantStderrSubstring: "resource.not_found",
},
// 8. profile use - pure local I/O against config.yaml.
{
name: "profile_use.success",
args: []string{"profile", "use", "production", "--format", "json"},
preConfig: func(t *testing.T) {
cfg := &config.Config{
CurrentProfile: "staging",
Profiles: map[string]config.Profile{
"staging": {Host: "https://staging.example.com"},
"production": {Host: "https://prod.example.com"},
},
}
if err := config.Save(cfg); err != nil {
t.Fatalf("seed config: %v", err)
}
},
},
// (profile_use.error_local_context_not_found dropped - see file header.)
// 9-10. auth status - SDK /api/v1/auth/me, plus config inspection.
{
name: "auth_status.success",
args: []string{"auth", "status", "--format", "json"},
server: whoamiOK,
},
{
name: "auth_status.error_auth_unauthenticated",
args: []string{"auth", "status", "--format", "json"},
server: always401,
wantErr: true,
wantStderrSubstring: "auth.unauthenticated",
},
// 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", "--format", "json"},
server: searchTwoResults,
},
{
name: "search.error_resource_not_found",
args: []string{"search", "chunks", "query", "--kb=eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", "--format", "json"},
server: always404,
wantErr: true,
wantStderrSubstring: "resource.not_found",
},
{
// --no-vector + --no-keyword is the input.invalid case; the KB UUID
// is just there to satisfy MarkFlagRequired so validation runs deep
// enough to hit the mutex-channel check.
name: "search.error_input_invalid",
args: []string{"search", "chunks", "query", "--kb=11111111-1111-4111-8111-111111111111", "--no-vector", "--no-keyword", "--format", "json"},
wantErr: true,
wantStderrSubstring: "input.invalid_argument",
},
}
// TestWireGolden is the matrix-runner. Cases are sequential (the
// iostreams singleton swap inside helpers.runCmd is package-global;
// t.Parallel is contractually forbidden - see helpers_test.go).
func TestWireGolden(t *testing.T) {
for _, tc := range wireCases {
t.Run(tc.name, func(t *testing.T) {
var ts *httptest.Server
var mockClient *sdk.Client
if tc.server != nil {
ts = httptest.NewServer(tc.server)
defer ts.Close()
mockClient = sdk.NewClient(ts.URL)
}
f := newTestFactory(t, ts, mockClient)
if tc.preConfig != nil {
tc.preConfig(t)
}
stdout, stderr, exit := runCmd(t, f, tc.args...)
if tc.wantErr && exit == 0 {
t.Errorf("expected non-zero exit, got 0; stdout=%q stderr=%q", stdout, stderr)
}
if !tc.wantErr && exit != 0 {
t.Errorf("unexpected non-zero exit %d; stdout=%q stderr=%q", exit, stdout, stderr)
}
if tc.wantStderrSubstring != "" && !strings.Contains(stderr, tc.wantStderrSubstring) {
t.Errorf("stderr missing %q; got %q", tc.wantStderrSubstring, stderr)
}
path := filepath.Join("..", "testdata", "wire", tc.name+".json")
assertGolden(t, []byte(stdout), path)
})
}
}
// ---------------------------------------------------------------------------
// HTTP fixtures
//
// Handlers are intentionally permissive on path matching (HasSuffix) so they
// work whether the SDK adds the /api/v1 prefix or not. The SDK pins the
// /api/v1 prefix today; the suffix match keeps the fixtures resilient to
// future route renames as long as the leaf path stays stable.
// fixedTime is the deterministic timestamp embedded in KnowledgeBase fixtures.
// time.Time marshals to RFC3339; using a fixed value keeps the golden stable.
var fixedTime = time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)
func whoamiOK(w http.ResponseWriter, r *http.Request) {
if !strings.HasSuffix(r.URL.Path, "/auth/me") {
w.WriteHeader(http.StatusNotFound)
return
}
resp := sdk.CurrentUserResponse{Success: true}
resp.Data.User = &sdk.AuthUser{ID: "usr_abc", Email: "user@example.com", TenantID: 42}
resp.Data.Tenant = &sdk.AuthTenant{ID: 42, Name: "Acme"}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
func always401(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"unauthenticated"}`))
}
func always403(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"error":"forbidden"}`))
}
func always404(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"error":"not found"}`))
}
func alwaysServerError(w http.ResponseWriter, _ *http.Request) {
// 5xx triggers PingBaseURL failure path and SDK transport error.
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`internal error`))
}
// doctorReachable serves /health 200 (so PingBaseURL would succeed if it
// were called). doctor.success_offline still skips ping, so this handler
// is a no-op for that case but keeps buildServices on a happy path.
func doctorReachable(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}
func kbListTwo(w http.ResponseWriter, r *http.Request) {
if !strings.HasSuffix(r.URL.Path, "/knowledge-bases") {
w.WriteHeader(http.StatusNotFound)
return
}
resp := sdk.KnowledgeBaseListResponse{
Success: true,
Data: []sdk.KnowledgeBase{
{
ID: "kb1",
Name: "Onboarding Docs",
TenantID: 42,
EmbeddingModelID: "text-embedding-3",
CreatedAt: fixedTime,
UpdatedAt: fixedTime,
KnowledgeCount: 5,
ChunkCount: 128,
},
{
ID: "kb2",
Name: "API Reference",
TenantID: 42,
EmbeddingModelID: "text-embedding-3",
CreatedAt: fixedTime,
UpdatedAt: fixedTime,
KnowledgeCount: 12,
ChunkCount: 340,
},
},
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
func kbListEmpty(w http.ResponseWriter, r *http.Request) {
if !strings.HasSuffix(r.URL.Path, "/knowledge-bases") {
w.WriteHeader(http.StatusNotFound)
return
}
resp := sdk.KnowledgeBaseListResponse{Success: true, Data: []sdk.KnowledgeBase{}}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
func kbGetOne(w http.ResponseWriter, r *http.Request) {
if !strings.HasSuffix(r.URL.Path, "/knowledge-bases/kb1") {
w.WriteHeader(http.StatusNotFound)
return
}
resp := sdk.KnowledgeBaseResponse{
Success: true,
Data: sdk.KnowledgeBase{
ID: "kb1",
Name: "Onboarding Docs",
Description: "Internal onboarding handbook",
TenantID: 42,
EmbeddingModelID: "text-embedding-3",
CreatedAt: fixedTime,
UpdatedAt: fixedTime,
KnowledgeCount: 5,
ChunkCount: 128,
},
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
func searchTwoResults(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.URL.Path, "/knowledge-bases/11111111-1111-4111-8111-111111111111/hybrid-search") {
w.WriteHeader(http.StatusNotFound)
return
}
resp := sdk.HybridSearchResponse{
Success: true,
Data: []*sdk.SearchResult{
{
ID: "chunk-1",
Content: "first chunk content",
KnowledgeID: "doc-1",
ChunkIndex: 0,
KnowledgeTitle: "Doc 1",
Score: 0.92,
MatchType: sdk.MatchTypeVector,
},
{
ID: "chunk-2",
Content: "second chunk content",
KnowledgeID: "doc-2",
ChunkIndex: 1,
KnowledgeTitle: "Doc 2",
Score: 0.81,
MatchType: sdk.MatchTypeKeyword,
},
},
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
+11
View File
@@ -0,0 +1,11 @@
// cli/acceptance/doc.go
//
// Package acceptance is the cli/ subtree top-level home for cross-cutting
// contract / integration tests. Lives outside cli/internal/ so a future
// reviewer immediately sees "this is the contract surface - change with care".
//
// Sub-packages:
//
// contract/ - envelope JSON shape golden + error.code registry consistency
// e2e/ - real WeKnora server blackbox tests (testscript-style)
package acceptance
+279
View File
@@ -0,0 +1,279 @@
//go:build acceptance_e2e
// Package e2e_test drives the WeKnora CLI binary against a real running
// 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 ./...` so the e2e suite only runs when explicitly
// requested. To run:
//
// cd cli
// WEKNORA_E2E_HOST=https://kb.example.com \
// WEKNORA_E2E_TOKEN=eyJhbGc... \
// go test -tags=acceptance_e2e -v ./acceptance/e2e/...
//
// Optional WEKNORA_E2E_KB_NAME_PREFIX customizes the throwaway KB name (default
// "cli-e2e-"). Cleanup runs even on test failure via t.Cleanup so the server
// doesn't accumulate test debris.
package e2e_test
import (
"bytes"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
// TestRAGFullLoop walks the demo MVP path: link a profile, create a KB,
// upload a doc, wait for indexing, search it, then chat against it. Each
// step parses the CLI's bare JSON to extract IDs for the next step -
// validating both functional behavior and wire-contract stability.
func TestRAGFullLoop(t *testing.T) {
host := mustEnv(t, "WEKNORA_E2E_HOST")
token := mustEnv(t, "WEKNORA_E2E_TOKEN")
prefix := envOr("WEKNORA_E2E_KB_NAME_PREFIX", "cli-e2e-")
bin := buildBinary(t)
xdg := t.TempDir()
// Inject host + token as WEKNORA_HOST / WEKNORA_TOKEN env vars. The
// CLI's env-credential path (buildClientFromEnv) takes precedence
// over profile config, so this bypasses the secrets-store dance
// entirely — no keychain access, no file:// ref plumbing. This is
// the most robust path for CI and local e2e alike.
env := append(os.Environ(),
"XDG_CONFIG_HOME="+xdg,
"XDG_CACHE_HOME="+filepath.Join(xdg, "cache"),
"WEKNORA_HOST="+host,
"WEKNORA_TOKEN="+token,
// SDK debug off - explicit so the CI run isn't noisy.
"WEKNORA_LOG_LEVEL=error",
)
// 1. kb create → KnowledgeBase object (envelope-wrapped: {ok, data})
// The CLI now requires explicit model binding so uploaded docs are
// immediately searchable. Use the server's builtin model IDs (they
// exist on every deployment that configures builtin_models.yaml).
embeddingModel := envOr("WEKNORA_E2E_EMBEDDING_MODEL", "Qwen/Qwen3-Embedding-8B")
chatModel := envOr("WEKNORA_E2E_CHAT_MODEL", "deepseek-ai/DeepSeek-V3.2")
kbName := prefix + fmt.Sprintf("%d", time.Now().UnixNano())
var created struct {
OK bool `json:"ok"`
Data struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"data"`
}
runJSONInto(t, bin, env, &created, "kb", "create", kbName,
"--embedding-model", embeddingModel,
"--chat-model", chatModel,
"--format", "json")
if created.Data.ID == "" {
t.Fatalf("kb create returned no id")
}
t.Logf("created KB: %s (%s)", created.Data.ID, kbName)
t.Cleanup(func() {
// Best-effort cleanup; a 404 means the KB was already gone.
out, err := run(bin, env, "kb", "delete", created.Data.ID, "-y", "--format", "json")
if err != nil {
t.Logf("cleanup kb delete: %v\n%s", err, out)
}
})
// 2. doc upload → Knowledge object (envelope-wrapped: {ok, data})
docPath := writeSampleDoc(t)
var uploaded struct {
OK bool `json:"ok"`
Data struct {
ID string `json:"id"`
} `json:"data"`
}
runJSONInto(t, bin, env, &uploaded, "doc", "upload", docPath, "--kb", created.Data.ID, "--format", "json")
if uploaded.Data.ID == "" {
t.Fatalf("doc upload returned no id")
}
t.Logf("uploaded doc: %s", uploaded.Data.ID)
// 3. poll until indexing finishes (status changes from "pending" / "processing" to "ready" / similar)
waitDocReady(t, bin, env, created.Data.ID, uploaded.Data.ID, 90*time.Second)
// 4. search chunks → SearchResult list (envelope-wrapped: {ok, data})
var searchResp struct {
OK bool `json:"ok"`
Data []map[string]any `json:"data"`
}
runJSONInto(t, bin, env, &searchResp, "search", "chunks", "sample", "--kb", created.Data.ID, "--limit", "5", "--format", "json")
results := searchResp.Data
if len(results) == 0 {
t.Fatalf("search returned no results")
}
t.Logf("search returned %d results", len(results))
// 5. chat --format json → bounded answer-event envelope (--reference for citations)
var chatEnv struct {
OK bool `json:"ok"`
Data struct {
Events []struct {
ResponseType string `json:"response_type"`
Content string `json:"content"`
KnowledgeReferences []struct {
ChunkID string `json:"chunk_id"`
} `json:"knowledge_references"`
} `json:"events"`
SessionID string `json:"session_id"`
} `json:"data"`
}
runJSONInto(t, bin, env, &chatEnv, "chat", "summarize the document briefly", "--kb", created.Data.ID, "--format", "json", "--reference")
if !chatEnv.OK {
t.Fatal("chat ok=false")
}
var answer strings.Builder
refCount := 0
for _, ev := range chatEnv.Data.Events {
if ev.ResponseType == "answer" {
answer.WriteString(ev.Content)
}
refCount += len(ev.KnowledgeReferences)
}
if strings.TrimSpace(answer.String()) == "" {
t.Fatalf("chat returned empty answer")
}
t.Logf("chat answer (%d chars), %d reference indexes", len(answer.String()), refCount)
if refCount == 0 {
// Soft warning - some servers may not surface references for every
// question, but the demo flow is supposed to.
t.Logf("warning: chat returned 0 reference indexes (server may have a different config)")
}
}
func mustEnv(t *testing.T, key string) string {
t.Helper()
v := os.Getenv(key)
if v == "" {
t.Skipf("e2e: %s not set; skipping (set the env var or run `gh workflow run cli-e2e.yml`)", key)
}
return v
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// buildBinary compiles the CLI to a temp dir once per test run. Re-using a
// single binary across sub-cases avoids the multi-second linker cost on each
// step and matches gh acceptance/ build behavior.
func buildBinary(t *testing.T) string {
t.Helper()
dir := t.TempDir()
out := filepath.Join(dir, "weknora")
// Repo layout: this test sits at cli/acceptance/e2e/, so cli/ is two levels up.
cmd := exec.Command("go", "build", "-o", out, ".")
cmd.Dir = filepath.Join("..", "..")
cmd.Stdout = os.Stderr
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
t.Fatalf("build cli: %v", err)
}
return out
}
// writeSampleDoc emits a small bilingual doc that gives the embedder enough
// signal for retrieval but stays tiny so indexing finishes within the poll
// window.
func writeSampleDoc(t *testing.T) string {
t.Helper()
content := `WeKnora E2E Sample Document
This sample document is used by the WeKnora CLI acceptance test suite to
validate the end-to-end retrieval-augmented generation pipeline.
向量检索的核心思想是把文本通过 embedding 模型映射到高维向量空间,然后通过余弦相似度
等度量找出语义最接近的内容片段。WeKnora 支持 vector + keyword 的混合检索模式。
The hybrid search mode combines vector similarity (semantic) with keyword
matching (lexical) to balance recall and precision.
`
dir := t.TempDir()
p := filepath.Join(dir, "sample.txt")
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
t.Fatalf("write sample: %v", err)
}
return p
}
// waitDocReady polls `doc list` until the uploaded document's status indicates
// indexing is complete. WeKnora server uses a few status values across versions
// ("ready", "completed", "ok") - accept any non-pending/non-processing/non-failed
// state so we don't break on a server-side rename. Failed status fails the test
// fast.
func waitDocReady(t *testing.T, bin string, env []string, kbID, docID string, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
tick := 2 * time.Second
type docItem struct {
ID string `json:"id"`
ParseStatus string `json:"parse_status"`
}
for time.Now().Before(deadline) {
// doc list is envelope-wrapped: {ok, data: [...]} (bare array)
var resp struct {
OK bool `json:"ok"`
Data []docItem `json:"data"`
}
runJSONInto(t, bin, env, &resp, "doc", "list", "--kb", kbID, "--page-size", "100", "--format", "json")
for _, d := range resp.Data {
if d.ID != docID {
continue
}
low := strings.ToLower(d.ParseStatus)
switch {
case low == "failed", low == "error":
t.Fatalf("doc %s indexing failed: status=%q", docID, d.ParseStatus)
case low == "pending", low == "processing", low == "finalizing", low == "":
// keep waiting
default:
t.Logf("doc %s ready (status=%q)", docID, d.ParseStatus)
return
}
}
time.Sleep(tick)
}
t.Fatalf("doc %s did not reach ready within %s", docID, timeout)
}
// run executes the CLI and returns combined stdout. Errors include stderr +
// exit code so failures are debuggable without re-running.
func run(bin string, env []string, args ...string) ([]byte, error) {
cmd := exec.Command(bin, args...)
cmd.Env = env
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return stdout.Bytes(), fmt.Errorf("%s %s: %v\nstderr:\n%s", filepath.Base(bin), strings.Join(args, " "), err, stderr.String())
}
return stdout.Bytes(), nil
}
// runJSONInto runs the CLI expecting bare JSON output and decodes stdout
// into out (a struct, slice, or map pointer). Test fails on non-zero exit
// or unparseable JSON.
func runJSONInto(t *testing.T, bin string, env []string, out any, args ...string) {
t.Helper()
stdout, err := run(bin, env, args...)
if err != nil {
t.Fatalf("%v", err)
}
if err := json.Unmarshal(stdout, out); err != nil {
t.Fatalf("parse JSON from %v: %v\nstdout:\n%s", args, err, string(stdout))
}
}
+1
View File
@@ -0,0 +1 @@
{"ok":true,"data":{"profile":"","auth_source":"profile + keyring","user_id":"usr_abc","email":"user@example.com","tenant_id":42,"tenant_name":"Acme"}}
@@ -0,0 +1 @@
{"ok":true,"data":{"summary":{"all_passed":false,"passed":1,"failed":1,"skipped":2},"checks":[{"name":"base_url_reachable","status":"fail","details":"server returned 500","hint":"verify the host configured for the active profile (run `weknora profile list` / `weknora profile add <n> --host=...`) and network reachability"},{"name":"auth_credential","status":"skip","details":"prereq failed: base_url_reachable"},{"name":"server_version","status":"skip","details":"prereq failed: auth_credential"},{"name":"credential_storage","status":"ok","details":"keyring or file storage available"}]}}
@@ -0,0 +1 @@
{"ok":true,"data":{"summary":{"all_passed":false,"passed":1,"failed":0,"skipped":3},"checks":[{"name":"base_url_reachable","status":"skip","details":"offline mode"},{"name":"auth_credential","status":"skip","details":"offline mode"},{"name":"server_version","status":"skip","details":"offline mode"},{"name":"credential_storage","status":"ok","details":"keyring or file storage available"}]}}
+1
View File
@@ -0,0 +1 @@
{"ok":true,"data":[{"id":"kb1","name":"Onboarding Docs","type":"","is_temporary":false,"is_pinned":false,"description":"","tenant_id":42,"chunking_config":{"chunk_size":0,"chunk_overlap":0,"separators":null},"image_processing_config":{"model_id":""},"faq_config":null,"embedding_model_id":"text-embedding-3","summary_model_id":"","vlm_config":{"enabled":false,"model_id":""},"storage_provider_config":null,"storage_config":{"secret_id":"","secret_key":"","region":"","bucket_name":"","app_id":"","path_prefix":"","provider":""},"extract_config":null,"auto_tag_config":null,"created_at":"2025-01-01T12:00:00Z","updated_at":"2025-01-01T12:00:00Z","knowledge_count":5,"chunk_count":128,"is_processing":false,"processing_count":0},{"id":"kb2","name":"API Reference","type":"","is_temporary":false,"is_pinned":false,"description":"","tenant_id":42,"chunking_config":{"chunk_size":0,"chunk_overlap":0,"separators":null},"image_processing_config":{"model_id":""},"faq_config":null,"embedding_model_id":"text-embedding-3","summary_model_id":"","vlm_config":{"enabled":false,"model_id":""},"storage_provider_config":null,"storage_config":{"secret_id":"","secret_key":"","region":"","bucket_name":"","app_id":"","path_prefix":"","provider":""},"extract_config":null,"auto_tag_config":null,"created_at":"2025-01-01T12:00:00Z","updated_at":"2025-01-01T12:00:00Z","knowledge_count":12,"chunk_count":340,"is_processing":false,"processing_count":0}],"meta":{"count":2,"total_count":2}}
@@ -0,0 +1 @@
{"ok":true,"data":[],"meta":{"count":0,"total_count":0}}
+1
View File
@@ -0,0 +1 @@
{"ok":true,"data":{"id":"kb1","name":"Onboarding Docs","type":"","is_temporary":false,"is_pinned":false,"description":"Internal onboarding handbook","tenant_id":42,"chunking_config":{"chunk_size":0,"chunk_overlap":0,"separators":null},"image_processing_config":{"model_id":""},"faq_config":null,"embedding_model_id":"text-embedding-3","summary_model_id":"","vlm_config":{"enabled":false,"model_id":""},"storage_provider_config":null,"storage_config":{"secret_id":"","secret_key":"","region":"","bucket_name":"","app_id":"","path_prefix":"","provider":""},"extract_config":null,"auto_tag_config":null,"created_at":"2025-01-01T12:00:00Z","updated_at":"2025-01-01T12:00:00Z","knowledge_count":5,"chunk_count":128,"is_processing":false,"processing_count":0}}
+1
View File
@@ -0,0 +1 @@
{"ok":true,"data":{"current_profile":"production","previous_profile":"staging"},"profile":"staging"}
+1
View File
@@ -0,0 +1 @@
{"ok":true,"data":[{"id":"chunk-1","content":"first chunk content","knowledge_id":"doc-1","chunk_index":0,"knowledge_title":"Doc 1","start_at":0,"end_at":0,"seq":0,"score":0.92,"match_type":0,"chunk_type":"","image_info":"","metadata":null,"knowledge_filename":"","knowledge_source":"","knowledge_channel":""},{"id":"chunk-2","content":"second chunk content","knowledge_id":"doc-2","chunk_index":1,"knowledge_title":"Doc 2","start_at":0,"end_at":0,"seq":0,"score":0.81,"match_type":1,"chunk_type":"","image_info":"","metadata":null,"knowledge_filename":"","knowledge_source":"","knowledge_channel":""}],"meta":{"count":2}}
+1
View File
@@ -0,0 +1 @@
{"ok":true,"data":{"commit":"none","date":"unknown","version":"dev"}}
+36
View File
@@ -0,0 +1,36 @@
// Package agentcmd holds the `weknora agent` command tree:
// list / view / create / update / delete / status / check. The directory is
// named `agent/` to match the cobra subcommand; the Go package is `agentcmd`
// to avoid colliding with cobra's *cobra.Command identifier.
//
// "agent" in this subtree refers to WeKnora's user-defined Custom
// Agents (server resource: GET/POST /agents/...) and handles CRUD
// operations only. Agent invocation has moved to `weknora session ask
// --agent <id>` (see cli/cmd/session/ask.go).
package agentcmd
import (
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
)
// NewCmd builds the `weknora agent` parent and registers leaves. Called
// from cli/cmd/root.go.
func NewCmd(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "agent",
Short: "Manage custom agents (CRUD + status/check)",
Long: `Custom Agents bundle a system prompt, model, tool allow-list, and KB
scope into an addressable resource. Create, update, list, view, check,
or delete agents. To invoke an agent, use: weknora session ask --agent <id>`,
}
cmd.AddCommand(NewCmdList(f))
cmd.AddCommand(NewCmdView(f))
cmd.AddCommand(NewCmdCreate(f))
cmd.AddCommand(NewCmdEdit(f))
cmd.AddCommand(NewCmdDelete(f))
cmd.AddCommand(NewCmdStatus(f))
cmd.AddCommand(NewCmdCheck(f))
return cmd
}
+134
View File
@@ -0,0 +1,134 @@
package agentcmd
import (
"context"
"fmt"
"io"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
// AgentCheckResult is the deep-verification response for `agent check <id>`.
// Superset of AgentStatusResult: status fields + KBScopeAllReachable from
// probing each KB in agent.Config.KnowledgeBases. Verb split with
// `agent status`: status reads existing state cheaply, check actively
// verifies dependencies.
type AgentCheckResult struct {
ID string `json:"id"`
Reachable bool `json:"reachable"`
ModelID string `json:"model_id,omitempty"`
KBScopeAllReachable *bool `json:"kb_scope_all_reachable,omitempty"` // pointer so we can distinguish "not applicable" (e.g. agent unreachable) from "false"
}
// AgentCheckService is the narrow SDK surface needed for agent check.
type AgentCheckService interface {
GetAgent(ctx context.Context, id string) (*sdk.Agent, error)
GetKnowledgeBase(ctx context.Context, id string) (*sdk.KnowledgeBase, error)
}
var agentCheckFields = []string{"id", "reachable", "model_id", "kb_scope_all_reachable"}
// NewCmdCheck builds `weknora agent check <id>`.
func NewCmdCheck(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "check <agent-id>",
Short: "Verify a custom agent end-to-end (status + kb_scope reachability)",
Long: `Active verification of a custom agent.
Performs 1 + N HTTP calls:
1 GET /agents/{id} — reachable + model_id
N GET /kb/{kb_id} for each id in agent.config.knowledge_bases
— sets kb_scope_all_reachable = true iff every probe succeeds
Use 'weknora agent status <id>' for a fast read-only health snapshot
(1 HTTP call, no KB probing). Use 'agent check' when you need to verify
the agent's downstream dependencies are all reachable.`,
Example: ` weknora agent check ag_abc
weknora agent check ag_abc --format json`,
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
cli, err := f.Client()
if err != nil {
return err
}
res, err := runAgentCheck(c.Context(), cli, args[0])
if err != nil {
return err
}
return emitAgentCheck(res, fopts, iostreams.IO.Out)
},
}
cmdutil.AddFormatFlag(cmd, agentCheckFields...)
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "verify a custom agent end-to-end: status plus kb_scope reachability",
RequiredFlags: []string{"<agent-id> (positional)"},
Examples: []string{"weknora agent check agent_abc"},
Output: "envelope.data is {id, reachable, ...} with kb_scope reachability folded in",
})
return cmd
}
// runAgentCheck is the testable core. Never errors for "agent not
// reachable" — Reachable=false carries that signal.
func runAgentCheck(ctx context.Context, svc AgentCheckService, id string) (*AgentCheckResult, error) {
a, err := svc.GetAgent(ctx, id)
if err != nil {
return &AgentCheckResult{ID: id, Reachable: false}, nil
}
res := &AgentCheckResult{ID: a.ID, Reachable: true}
if a.Config != nil {
res.ModelID = a.Config.ModelID
}
// Probe each KB in scope. Vacuously true when scope is empty or
// config is nil.
allOK := true
if a.Config != nil {
for _, kbID := range a.Config.KnowledgeBases {
if _, err := svc.GetKnowledgeBase(ctx, kbID); err != nil {
allOK = false
break
}
}
}
res.KBScopeAllReachable = &allOK
return res, nil
}
// emitAgentCheck renders res. Same dispatch as emitAgentStatus.
func emitAgentCheck(res *AgentCheckResult, fopts *cmdutil.FormatOptions, w io.Writer) error {
switch fopts.Mode {
case cmdutil.FormatJSON, cmdutil.FormatNDJSON:
return fopts.Emit(w, res, nil)
case cmdutil.FormatText, "":
return writeAgentCheckText(w, res)
default:
return fmt.Errorf("unsupported --format %q for agent check", fopts.Mode)
}
}
func writeAgentCheckText(w io.Writer, res *AgentCheckResult) error {
fmt.Fprintf(w, "ID: %s\n", res.ID)
fmt.Fprintf(w, "Reachable: %v\n", res.Reachable)
if !res.Reachable {
return nil
}
if res.ModelID != "" {
fmt.Fprintf(w, "Model: %s\n", res.ModelID)
}
if res.KBScopeAllReachable != nil {
fmt.Fprintf(w, "KB scope OK: %v\n", *res.KBScopeAllReachable)
}
return nil
}
// compile-time check: SDK client satisfies AgentCheckService.
var _ AgentCheckService = (*sdk.Client)(nil)
+154
View File
@@ -0,0 +1,154 @@
package agentcmd
import (
"bytes"
"context"
"encoding/json"
"fmt"
"strings"
"testing"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
sdk "github.com/Tencent/WeKnora/client"
)
type fakeAgentCheckSvc struct {
agent *sdk.Agent
agentErr error
kbProbeOK map[string]bool // kb_id → reachable
kbErr error // applied for any KB id not in the map
}
func (f *fakeAgentCheckSvc) GetAgent(_ context.Context, id string) (*sdk.Agent, error) {
if f.agentErr != nil {
return nil, f.agentErr
}
return f.agent, nil
}
func (f *fakeAgentCheckSvc) GetKnowledgeBase(_ context.Context, id string) (*sdk.KnowledgeBase, error) {
if ok, found := f.kbProbeOK[id]; found {
if !ok {
return nil, fmt.Errorf("kb %s unreachable", id)
}
return &sdk.KnowledgeBase{ID: id}, nil
}
if f.kbErr != nil {
return nil, f.kbErr
}
return &sdk.KnowledgeBase{ID: id}, nil
}
func TestRunAgentCheck_AllReachable(t *testing.T) {
svc := &fakeAgentCheckSvc{
agent: &sdk.Agent{ID: "ag_x", Config: &sdk.AgentConfig{
ModelID: "m_x",
KnowledgeBases: []string{"kb_a", "kb_b"},
}},
kbProbeOK: map[string]bool{"kb_a": true, "kb_b": true},
}
res, err := runAgentCheck(context.Background(), svc, "ag_x")
if err != nil {
t.Fatalf("%v", err)
}
if !res.Reachable {
t.Error("Reachable=false, want true")
}
if res.ModelID != "m_x" {
t.Errorf("ModelID=%q, want m_x", res.ModelID)
}
if res.KBScopeAllReachable == nil || !*res.KBScopeAllReachable {
t.Errorf("KBScopeAllReachable should be true (pointer set), got %v", res.KBScopeAllReachable)
}
}
func TestRunAgentCheck_OneKBFails(t *testing.T) {
svc := &fakeAgentCheckSvc{
agent: &sdk.Agent{ID: "ag_x", Config: &sdk.AgentConfig{
ModelID: "m_x",
KnowledgeBases: []string{"kb_a", "kb_b"},
}},
kbProbeOK: map[string]bool{"kb_a": true, "kb_b": false},
}
res, _ := runAgentCheck(context.Background(), svc, "ag_x")
if res.KBScopeAllReachable == nil || *res.KBScopeAllReachable {
t.Errorf("KBScopeAllReachable should be false; got %v", res.KBScopeAllReachable)
}
}
func TestRunAgentCheck_Unreachable(t *testing.T) {
svc := &fakeAgentCheckSvc{agentErr: fmt.Errorf("404")}
res, err := runAgentCheck(context.Background(), svc, "ag_x")
if err != nil {
t.Fatalf("%v", err)
}
if res.Reachable {
t.Error("Reachable=true on 404; want false")
}
if res.ID != "ag_x" {
t.Errorf("ID=%q, want ag_x (echoed even on unreachable)", res.ID)
}
// KBScopeAllReachable should be nil when agent is unreachable
if res.KBScopeAllReachable != nil {
t.Errorf("KBScopeAllReachable should be nil when agent unreachable; got %v", res.KBScopeAllReachable)
}
}
func TestRunAgentCheck_NilConfig(t *testing.T) {
// Defensive: Agent.Config is a pointer; nil should not panic and
// KBScopeAllReachable should be vacuously true (no KBs to probe).
svc := &fakeAgentCheckSvc{agent: &sdk.Agent{ID: "ag_x", Config: nil}}
res, err := runAgentCheck(context.Background(), svc, "ag_x")
if err != nil {
t.Fatalf("%v", err)
}
if !res.Reachable {
t.Error("Reachable=false, want true on nil config")
}
if res.ModelID != "" {
t.Errorf("ModelID=%q, want empty (no config)", res.ModelID)
}
// Vacuously true: no KBs to probe
if res.KBScopeAllReachable == nil || !*res.KBScopeAllReachable {
t.Errorf("KBScopeAllReachable should be vacuously true for nil config; got %v", res.KBScopeAllReachable)
}
}
func TestEmitAgentCheck_JSON(t *testing.T) {
trueP := true
var buf bytes.Buffer
res := &AgentCheckResult{ID: "ag_x", Reachable: true, ModelID: "m_x", KBScopeAllReachable: &trueP}
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}
if err := emitAgentCheck(res, fopts, &buf); err != nil {
t.Fatalf("%v", err)
}
var env struct {
OK bool `json:"ok"`
Data AgentCheckResult `json:"data"`
}
if err := json.Unmarshal(buf.Bytes(), &env); err != nil {
t.Fatalf("%v", err)
}
got := env.Data
if got.ModelID != "m_x" {
t.Errorf("ModelID=%q, want m_x", got.ModelID)
}
if got.KBScopeAllReachable == nil || !*got.KBScopeAllReachable {
t.Errorf("KBScopeAllReachable should be true in JSON output; got %v", got.KBScopeAllReachable)
}
}
func TestEmitAgentCheck_Text(t *testing.T) {
trueP := true
var buf bytes.Buffer
res := &AgentCheckResult{ID: "ag_x", Reachable: true, ModelID: "m_x", KBScopeAllReachable: &trueP}
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatText}
if err := emitAgentCheck(res, fopts, &buf); err != nil {
t.Fatalf("%v", err)
}
for _, want := range []string{"ag_x", "m_x", "true"} {
if !strings.Contains(buf.String(), want) {
t.Errorf("output missing %q:\n%s", want, buf.String())
}
}
}
+384
View File
@@ -0,0 +1,384 @@
package agentcmd
import (
"context"
"fmt"
"io"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
// CreateService is the narrow SDK surface this command depends on.
// *sdk.Client satisfies it via duck typing.
type CreateService interface {
CreateAgent(ctx context.Context, req *sdk.CreateAgentRequest) (*sdk.Agent, error)
CopyAgent(ctx context.Context, id string) (*sdk.Agent, error)
UpdateAgent(ctx context.Context, id string, req *sdk.UpdateAgentRequest) (*sdk.Agent, error)
}
// CreateOptions captures flag state. SystemPromptReader and ConfigFileBody
// are populated from --system-prompt-file and --config-file respectively
// (or stdin when the value is "-"). The embedded flags struct tracks "was
// set" bits so MergeAgentConfig can distinguish "user passed --foo zero"
// from "user did not pass --foo".
type CreateOptions struct {
Name string
Model string
Description string
SystemPrompt string
SystemPromptReader io.Reader
AgentMode string
KBs []string
KBSelectionMode string
RerankModel string
Temperature float64
From string
ConfigFileBody io.Reader
ConfigFileKind string // "yaml" or "json"
GenerateSkeleton bool
DryRun bool
flags createFlagSet // populated in PreRunE for *Set bits
}
// createFlagSet records which hot-path flags the user explicitly passed so
// MergeAgentConfig knows which fields to overlay onto the base config.
type createFlagSet struct {
agentModeSet bool
systemPromptSet bool
rerankModelSet bool
temperatureSet bool
kbSelectionModeSet bool
kbsSet bool
}
const agentCreateExample = ` weknora agent create "Support Bot" --model <model-id>
weknora agent create "Code Reviewer" --model <model-id> --system-prompt-file ./prompt.md --attach-kb kb_eng --attach-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 <model-id> --config-file ./my-agent.yaml`
const agentCreateLong = `Create a new custom agent.
--model is required (an agent without a model cannot invoke); discover a
valid model id with 'weknora model list'. The 7
optional hot-path flags cover the most frequently set AgentConfig fields;
for the remaining 27 use --config-file with a YAML or JSON document
matching the AgentConfig schema (run --generate-skeleton to get a
ready-to-edit template).
Precedence when both a config file and hot-path flags are supplied:
hot-path flag > config-file value > server default
--from <id> copies an existing agent (SDK CopyAgent) then applies any
hot-path overrides via UpdateAgent. --attach-kb on --from REPLACES the copied
agent's KB list (not merge) — matches surgical-flag semantics.
AI agents: writes a new resource server-side. Failure surfaces as a
typed code on stderr: input.invalid_argument (bad flags, bad file, or
bad model), resource.not_found (--from <missing>), auth.unauthenticated.`
// NewCmdCreate builds `weknora agent create <name> --model <id>`.
func NewCmdCreate(f *cmdutil.Factory) *cobra.Command {
opts := &CreateOptions{}
var systemPromptFile, configFile string
cmd := &cobra.Command{
Use: "create <name>",
Short: "Create a new custom agent",
Long: agentCreateLong,
Example: agentCreateExample,
// Allow 0 args for --generate-skeleton; PreRunE enforces the real
// arity rule once it knows whether skeleton mode is active.
Args: cobra.MaximumNArgs(1),
PreRunE: func(cmd *cobra.Command, args []string) error {
if opts.GenerateSkeleton {
return nil
}
if len(args) != 1 {
return cmdutil.NewFlagError(fmt.Errorf("accepts 1 arg, received %d", len(args)))
}
opts.Name = args[0]
if opts.Model == "" {
return cmdutil.NewFlagError(fmt.Errorf(`required flag(s) "model" not set`))
}
opts.flags.agentModeSet = cmd.Flags().Changed("agent-mode")
opts.flags.systemPromptSet = cmd.Flags().Changed("system-prompt") || cmd.Flags().Changed("system-prompt-file")
opts.flags.rerankModelSet = cmd.Flags().Changed("rerank-model")
opts.flags.temperatureSet = cmd.Flags().Changed("temperature")
opts.flags.kbSelectionModeSet = cmd.Flags().Changed("kb-selection-mode")
opts.flags.kbsSet = cmd.Flags().Changed("attach-kb")
// --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))
}
// Reject an unknown --agent-mode / --kb-selection-mode up front
// (typed exit 5) rather than letting the server fail the create.
if err := validateAgentModeFlags(&opts.AgentMode, &opts.KBSelectionMode); err != nil {
return err
}
if systemPromptFile != "" {
r, err := cmdutil.OpenInput(systemPromptFile)
if err != nil {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, fmt.Sprintf("--system-prompt-file: %v", err))
}
opts.SystemPromptReader = r
}
if configFile != "" {
r, kind, err := openConfigFile(configFile)
if err != nil {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, fmt.Sprintf("--config-file: %v", err))
}
opts.ConfigFileBody = r
opts.ConfigFileKind = kind
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
fopts, err := cmdutil.CheckFormatFlag(cmd)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
planArgs := map[string]any{"name": opts.Name}
if configFile != "" {
planArgs["config"] = configFile
}
if handled, err := cmdutil.HandleDryRun(cmd, opts.DryRun, cmdutil.DryRunPlan{
Action: "agent.create",
Args: planArgs,
}); handled {
return err
}
cli, err := f.Client()
if err != nil {
return err
}
// Resolve --model / --rerank-model (id or name) and validate they
// exist, matching the id-or-name policy of `kb config set`. A bogus name
// fails fast here instead of creating an agent whose model never
// resolves at run time.
if opts.Model, err = cmdutil.ResolveModelRef(cmd.Context(), cli, opts.Model, "KnowledgeQA"); err != nil {
return err
}
if opts.RerankModel, err = cmdutil.ResolveModelRef(cmd.Context(), cli, opts.RerankModel, "Rerank"); err != nil {
return err
}
// Resolve --attach-kb values (id or name) to canonical ids,
// matching the --kb flag's id-or-name policy. Without this a raw
// name is stored verbatim as a kb_id, silently creating an agent
// whose KB reference never resolves at run time.
if len(opts.KBs) > 0 {
resolved := make([]string, 0, len(opts.KBs))
for _, raw := range opts.KBs {
id, rerr := cmdutil.ResolveKBFlag(cmd.Context(), cli, raw)
if rerr != nil {
return rerr
}
resolved = append(resolved, id)
}
opts.KBs = resolved
}
return runCreate(cmd.Context(), opts, fopts, cli)
},
}
// Required (enforced in PreRunE rather than cmd.MarkFlagRequired so
// --generate-skeleton can bypass it — cobra's MarkFlagRequired runs
// before PreRunE and would otherwise block the skeleton path).
cmd.Flags().StringVar(&opts.Model, "model", "", "LLM model id or name (required, except with --generate-skeleton)")
// Hot-path (8 flag names, 7 distinct config fields)
cmd.Flags().StringVar(&opts.Description, "description", "", "Agent description")
cmd.Flags().StringVar(&opts.SystemPrompt, "system-prompt", "", "System prompt text (mutex with --system-prompt-file)")
cmd.Flags().StringVar(&systemPromptFile, "system-prompt-file", "", "Read system prompt from FILE, or '-' for stdin")
cmd.MarkFlagsMutuallyExclusive("system-prompt", "system-prompt-file")
cmd.Flags().StringVar(&opts.AgentMode, "agent-mode", "", "Agent operating mode: "+strings.Join(agentModeValues, " | "))
cmd.Flags().StringSliceVar(&opts.KBs, "attach-kb", nil, "Attach a knowledge base id (repeatable); aligns with 'agent update --add-kb'")
cmd.Flags().StringVar(&opts.KBSelectionMode, "kb-selection-mode", "", "KB selection mode: "+strings.Join(kbSelectionModeValues, " | "))
cmd.Flags().StringVar(&opts.RerankModel, "rerank-model", "", "Rerank model id or name")
cmd.Flags().Float64Var(&opts.Temperature, "temperature", 0.0, "Generation temperature (0.0..2.0)")
// Power-user / utility
cmd.Flags().StringVar(&opts.From, "from", "", "Copy from existing agent id (then apply other flags)")
cmd.Flags().StringVar(&configFile, "config-file", "", "Full AgentConfig YAML or JSON (use '-' for stdin)")
cmd.Flags().BoolVar(&opts.GenerateSkeleton, "generate-skeleton", false, "Emit blank AgentConfig YAML to stdout and exit")
cmdutil.AddFormatFlag(cmd, agentViewFields...)
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "Create a new custom agent. --model is required and accepts a model id or name; --attach-kb (repeatable) accepts KB ids or names. Emits the created Agent object.",
RequiredFlags: []string{"<name> (positional)", "--model"},
Examples: []string{
`weknora agent create "Support Bot" --model gpt-4o`,
`weknora agent create "Support Bot" --model gpt-4o --attach-kb kb_eng --system-prompt "You are a support assistant."`,
`weknora agent create --generate-skeleton # print a blank config to fill in (no --model needed)`,
},
Output: "envelope.data is the created Agent object with id, name, config",
})
return cmd
}
func runCreate(ctx context.Context, opts *CreateOptions, fopts *cmdutil.FormatOptions, svc CreateService) error {
if opts.GenerateSkeleton {
return cmdutil.GenerateAgentSkeleton(iostreams.IO.Out)
}
// 1. Build base AgentConfig from --config-file (if any), else zero.
// (For --from, the copied agent's existing config becomes the base
// instead — set after CopyAgent below, before MergeAgentConfig.)
var base sdk.AgentConfig
if opts.ConfigFileBody != nil {
parsed, err := cmdutil.LoadAgentConfig(opts.ConfigFileBody, opts.ConfigFileKind)
if err != nil {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, err.Error())
}
base = *parsed
}
// 2. Resolve system prompt (file/stdin > flag string)
if opts.SystemPromptReader != nil {
body, err := io.ReadAll(opts.SystemPromptReader)
if err != nil {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, fmt.Sprintf("--system-prompt-file read: %v", err))
}
opts.SystemPrompt = strings.TrimSpace(string(body))
}
// 3. Either copy-then-update, or create from scratch. For --from we
// must seed `base` from the copied agent's existing config FIRST so
// MergeAgentConfig preserves source fields the user did not override
// (e.g. SystemPrompt, AgentMode, KB list). Without this seeding the
// surgical overrides would ship UpdateAgent with the other 33 fields
// zeroed, clobbering source state.
if opts.From != "" {
copied, err := svc.CopyAgent(ctx, opts.From)
if err != nil {
return cmdutil.WrapHTTP(err, "copy agent %s", opts.From)
}
if copied.Config != nil {
base = *copied.Config
}
// --attach-kb on --from REPLACES the copied KB list; when --attach-kb
// is not set, KnowledgeBasesSet stays false inside applyCreateOverrides
// and the copy's KB list passes through unchanged.
cfg := applyCreateOverrides(&base, opts)
// Apply overrides on top of copied state via UpdateAgent (no
// server-side template-parameters route, so we do two roundtrips).
updateReq := &sdk.UpdateAgentRequest{
Name: opts.Name,
Description: opts.Description,
Config: cfg,
}
updated, err := svc.UpdateAgent(ctx, copied.ID, updateReq)
if err != nil {
return cmdutil.WrapHTTP(err, "update copied agent %s", copied.ID)
}
return emitAgent(fopts, updated)
}
// 4. Plain create path: apply hot-path flag overrides onto base
// (zero-valued unless --config-file supplied content).
cfg := applyCreateOverrides(&base, opts)
req := &sdk.CreateAgentRequest{
Name: opts.Name,
Description: opts.Description,
Config: cfg,
}
created, err := svc.CreateAgent(ctx, req)
if err != nil {
return cmdutil.WrapHTTP(err, "create agent")
}
return emitAgent(fopts, created)
}
// agentModeValues / kbSelectionModeValues are the closed server enums for the
// --agent-mode / --kb-selection-mode flags, sourced from the SDK enumerators so
// the CLI can't drift from the server vocabulary.
var (
agentModeValues = cmdutil.EnumStrings(sdk.AllAgentModes())
kbSelectionModeValues = cmdutil.EnumStrings(sdk.AllKBSelectionModes())
)
// validateAgentModeFlags validates and canonicalises --agent-mode and
// --kb-selection-mode in place against the SDK enums. An empty value passes
// through (flag unset → no override). Rejecting a typo here gives a fast,
// typed input.invalid_argument instead of a confusing server-side failure.
// Shared by `agent create` and `agent update`.
func validateAgentModeFlags(agentMode, kbSelectionMode *string) error {
v, err := cmdutil.ValidateEnum("agent-mode", *agentMode, agentModeValues)
if err != nil {
return err
}
*agentMode = v
v, err = cmdutil.ValidateEnum("kb-selection-mode", *kbSelectionMode, kbSelectionModeValues)
if err != nil {
return err
}
*kbSelectionMode = v
return nil
}
// applyCreateOverrides merges hot-path flag overrides into the base config,
// then applies the "--attach-kb implies --kb-selection-mode=selected" fallback.
// Shared by the --from path (base=copied agent's config) and the plain
// create path (base=zero or --config-file). Keeping both paths on one
// helper prevents silent divergence when future flags are added.
func applyCreateOverrides(base *sdk.AgentConfig, opts *CreateOptions) *sdk.AgentConfig {
overrides := cmdutil.AgentConfigFlags{
AgentMode: opts.AgentMode, AgentModeSet: opts.flags.agentModeSet,
SystemPrompt: opts.SystemPrompt, SystemPromptSet: opts.flags.systemPromptSet,
ModelID: opts.Model, ModelIDSet: true, // --model is required
RerankModelID: opts.RerankModel, RerankModelIDSet: opts.flags.rerankModelSet,
Temperature: opts.Temperature, TemperatureSet: opts.flags.temperatureSet,
KBSelectionMode: opts.KBSelectionMode, KBSelectionModeSet: opts.flags.kbSelectionModeSet,
KnowledgeBases: opts.KBs, KnowledgeBasesSet: opts.flags.kbsSet,
}
cfg := cmdutil.MergeAgentConfig(base, overrides)
// --attach-kb without explicit --kb-selection-mode implies "selected".
if opts.flags.kbsSet && !opts.flags.kbSelectionModeSet && cfg.KBSelectionMode == "" {
cfg.KBSelectionMode = "selected"
}
return cfg
}
// 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 := cmdutil.OpenInput(path)
if err != nil {
return nil, "", err
}
kind := "yaml"
if strings.EqualFold(filepath.Ext(path), ".json") {
kind = "json"
}
return r, kind, nil
}
// emitAgent writes the Agent to stdout (bare SDK shape for --format json,
// text KV otherwise). Shared by create and edit; defined here for proximity
// to the create flow.
func emitAgent(fopts *cmdutil.FormatOptions, ag *sdk.Agent) error {
if fopts.WantsJSON() {
return fopts.Emit(iostreams.IO.Out, ag, nil)
}
renderAgent(iostreams.IO.Out, ag)
return nil
}
+291
View File
@@ -0,0 +1,291 @@
package agentcmd
import (
"bytes"
"context"
"fmt"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
// fakeCreateSvc records all three SDK methods this command may invoke.
type fakeCreateSvc struct {
createReq *sdk.CreateAgentRequest
createResp *sdk.Agent
createErr error
copySrcID string
copyResp *sdk.Agent
copyErr error
updateID string
updateReq *sdk.UpdateAgentRequest
updateResp *sdk.Agent
updateErr error
updateCalled bool
}
func (f *fakeCreateSvc) CreateAgent(_ context.Context, req *sdk.CreateAgentRequest) (*sdk.Agent, error) {
f.createReq = req
return f.createResp, f.createErr
}
func (f *fakeCreateSvc) CopyAgent(_ context.Context, id string) (*sdk.Agent, error) {
f.copySrcID = id
return f.copyResp, f.copyErr
}
func (f *fakeCreateSvc) UpdateAgent(_ context.Context, id string, req *sdk.UpdateAgentRequest) (*sdk.Agent, error) {
f.updateCalled = true
f.updateID = id
f.updateReq = req
return f.updateResp, f.updateErr
}
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: "model-x"}
err := runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, 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, "model-x", svc.createReq.Config.ModelID)
}
func TestCreate_MissingName_FlagError(t *testing.T) {
cmd := NewCmdCreate(nil)
cmd.SetArgs([]string{"--model", "model-x"})
cmd.SilenceUsage = true
cmd.SilenceErrors = true
err := cmd.Execute()
require.Error(t, err)
// PreRunE rejects "0 args" with our flag-error sentinel; the message
// always carries the canonical "accepts 1 arg" phrase.
assert.Contains(t, err.Error(), "accepts 1 arg")
}
func TestCreate_MissingModel_FlagError(t *testing.T) {
cmd := NewCmdCreate(nil)
cmd.SetArgs([]string{"Test"})
cmd.SilenceUsage = true
cmd.SilenceErrors = true
err := cmd.Execute()
require.Error(t, err)
assert.Contains(t, err.Error(), `required flag(s) "model" not set`)
}
func TestCreate_ConfigFile_FlagsOverrideFile(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeCreateSvc{createResp: &sdk.Agent{ID: "ag_new"}}
opts := &CreateOptions{
Name: "Test",
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.FormatOptions{Mode: cmdutil.FormatJSON}, 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, "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: "model-y"}},
updateResp: &sdk.Agent{ID: "ag_clone", Name: "Renamed"},
}
opts := &CreateOptions{Name: "Renamed", Model: "model-x", From: "ag_source"}
require.NoError(t, runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, 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, "model-x", svc.updateReq.Config.ModelID)
}
func TestCreate_GenerateSkeleton_NoAPICall(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeCreateSvc{}
opts := &CreateOptions{GenerateSkeleton: true}
require.NoError(t, runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
assert.Nil(t, svc.createReq, "must not call CreateAgent")
assert.Equal(t, "", svc.copySrcID, "must not call CopyAgent")
assert.Contains(t, out.String(), "agent_mode:", "skeleton emitted to stdout")
}
func TestCreate_RepeatedKB_ImpliesSelectedMode(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeCreateSvc{createResp: &sdk.Agent{ID: "ag_new"}}
opts := &CreateOptions{
Name: "Test",
Model: "model-x",
KBs: []string{"kb_a", "kb_b"},
flags: createFlagSet{kbsSet: true},
}
require.NoError(t, runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
assert.Equal(t, []string{"kb_a", "kb_b"}, svc.createReq.Config.KnowledgeBases)
assert.Equal(t, "selected", svc.createReq.Config.KBSelectionMode, "passing --attach-kb implies selected mode")
}
func TestCreate_SystemPromptFile_ReaderRead(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeCreateSvc{createResp: &sdk.Agent{ID: "ag_new"}}
opts := &CreateOptions{
Name: "Test",
Model: "model-x",
SystemPromptReader: strings.NewReader("You are a helpful assistant.\n"),
flags: createFlagSet{systemPromptSet: true},
}
require.NoError(t, runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
assert.Equal(t, "You are a helpful assistant.", svc.createReq.Config.SystemPrompt, "TrimSpace removes trailing newline")
}
func TestCreate_From_PreservesSourceFieldsNotOverridden(t *testing.T) {
// Regression: with --from X and only --temperature overridden, the
// other 33 AgentConfig fields must round-trip from the copied agent.
// Pre-fix, runCreate built `cfg` from a zero AgentConfig{} baseline,
// so UpdateAgent shipped temperature=0.9 plus every other field
// zeroed — clobbering source SystemPrompt / AgentMode / KBs.
_, _ = iostreams.SetForTest(t)
svc := &fakeCreateSvc{
copyResp: &sdk.Agent{ID: "ag_clone", Config: &sdk.AgentConfig{
ModelID: "model-y",
SystemPrompt: "Source prompt",
AgentMode: "smart-reasoning",
Temperature: 0.5,
KnowledgeBases: []string{"kb_src_a", "kb_src_b"},
}},
updateResp: &sdk.Agent{ID: "ag_clone"},
}
// Only --temperature overridden; other fields should round-trip.
opts := &CreateOptions{
Name: "Renamed", Model: "model-y", From: "ag_source",
Temperature: 0.9,
flags: createFlagSet{temperatureSet: true},
}
require.NoError(t, runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
require.NotNil(t, svc.updateReq)
require.NotNil(t, svc.updateReq.Config)
assert.Equal(t, "Source prompt", svc.updateReq.Config.SystemPrompt, "source SystemPrompt must round-trip")
assert.Equal(t, "smart-reasoning", svc.updateReq.Config.AgentMode, "source AgentMode must round-trip")
assert.Equal(t, []string{"kb_src_a", "kb_src_b"}, svc.updateReq.Config.KnowledgeBases, "source KB list must round-trip when --attach-kb not passed")
assert.InDelta(t, 0.9, svc.updateReq.Config.Temperature, 0.001, "Temperature overridden")
}
func TestCreate_From_KBReplacesSourceList(t *testing.T) {
// --attach-kb on --from REPLACES the copied agent's KB list (instead of
// merging with it). The override semantic matches a from-scratch
// `agent create --attach-kb a --attach-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: "model-y",
KnowledgeBases: []string{"kb_src_a", "kb_src_b"},
}},
updateResp: &sdk.Agent{ID: "ag_clone"},
}
opts := &CreateOptions{
Name: "X", Model: "model-y", From: "ag_source",
KBs: []string{"kb_new"},
flags: createFlagSet{kbsSet: true},
}
require.NoError(t, runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
require.NotNil(t, svc.updateReq.Config)
assert.Equal(t, []string{"kb_new"}, svc.updateReq.Config.KnowledgeBases, "--attach-kb replaces source KB list")
assert.Equal(t, "selected", svc.updateReq.Config.KBSelectionMode, "--attach-kb on --from implies selected mode")
}
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", "model-x", "--temperature", fmt.Sprintf("%f", badT)})
cmd.SilenceUsage = true
cmd.SilenceErrors = true
err := cmd.Execute()
require.Error(t, err, "expected error for --temperature %g", badT)
assert.Contains(t, err.Error(), "0.0..2.0")
})
}
}
// TestCreate_AgentModeValidation: an unknown --agent-mode / --kb-selection-mode
// is rejected up front (PreRunE) with a typed input.invalid_argument (exit 5),
// while a valid value passes the gate. The closed set is sourced from the SDK
// enumerators, so this also guards against CLI/SDK drift.
func TestCreate_AgentModeValidation(t *testing.T) {
cases := []struct {
name string
args []string
wantErr string // substring; "" means the mode gate must pass
}{
{"bad agent-mode", []string{"--agent-mode", "bogus"}, "agent-mode"},
{"bad kb-selection-mode", []string{"--kb-selection-mode", "every"}, "kb-selection-mode"},
{"valid agent-mode", []string{"--agent-mode", "smart-reasoning", "--dry-run"}, ""},
{"valid kb-selection-mode", []string{"--kb-selection-mode", "selected", "--dry-run"}, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, _ = iostreams.SetForTest(t)
cmd := NewCmdCreate(nil)
cmd.SetArgs(append([]string{"Test", "--model", "model-x"}, tc.args...))
cmd.SilenceUsage, cmd.SilenceErrors = true, true
err := cmd.Execute()
if tc.wantErr == "" {
// Valid mode + --dry-run: must clear the mode gate. (--dry-run
// short-circuits before any client call, so nil factory is fine.)
require.NoError(t, err)
return
}
require.Error(t, err)
var ce *cmdutil.Error
require.ErrorAs(t, err, &ce)
assert.Equal(t, cmdutil.CodeInputInvalidArgument, ce.Code)
assert.Equal(t, 5, cmdutil.ExitCode(err))
assert.Contains(t, err.Error(), tc.wantErr)
})
}
}
func TestCreate_CopyAgent_NotFound(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeCreateSvc{copyErr: errBadHTTP404}
opts := &CreateOptions{Name: "X", Model: "model-x", From: "ag_missing"}
err := runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc)
require.Error(t, err)
assert.Contains(t, err.Error(), "resource.not_found")
}
// errBadHTTP404 simulates the SDK's "HTTP error 404: not found" format that
// ClassifyHTTPError parses. Defined here so create_test and edit_test/delete_test
// can share it via package scope without spinning up an HTTP server.
var errBadHTTP404 = &simpleErr{msg: "HTTP error 404: not found"}
type simpleErr struct{ msg string }
func (e *simpleErr) Error() string { return e.msg }
// ---------------------------------------------------------------------------
// --attach-kb (renamed from --kb)
// ---------------------------------------------------------------------------
// TestCreate_AttachKBFlagExists asserts that `--attach-kb` is a registered flag
// on `agent create` and that the old `--kb` flag no longer exists.
func TestCreate_AttachKBFlagExists(t *testing.T) {
cmd := NewCmdCreate(nil)
// --attach-kb must exist
f := cmd.Flags().Lookup("attach-kb")
require.NotNil(t, f, "--attach-kb flag must be registered on 'agent create'")
// bare --kb must NOT exist (renamed)
old := cmd.Flags().Lookup("kb")
assert.Nil(t, old, "old --kb flag must not exist on 'agent create' (renamed to --attach-kb)")
}
+121
View File
@@ -0,0 +1,121 @@
package agentcmd
import (
"context"
"fmt"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/prompt"
)
// agentDeleteFields enumerates the JSON discovery fields for `agent delete`.
// Result payload is a tiny {id, deleted} object — mirrors `kb delete`.
var agentDeleteFields = []string{"id", "deleted"}
type DeleteOptions struct {
AgentID string
Yes bool // sourced from the global -y/--yes persistent flag
DryRun bool
}
// DeleteService is the narrow SDK surface this command depends on.
type DeleteService interface {
DeleteAgent(ctx context.Context, id string) error
}
// deleteResult is the typed payload emitted on success in JSON mode.
type deleteResult struct {
ID string `json:"id"`
Deleted bool `json:"deleted"`
}
// Delete is NOT idempotent on a missing id — it surfaces resource.not_found
// rather than silently exiting 0. Idempotent-already-true semantics are
// reserved for unlink-style local cleanups, not server-side resource removal.
const agentDeleteLong = `Permanently delete a custom agent.
Prompts for confirmation by default when stdout is a TTY and --format json is
not set. Pass -y/--yes (the global flag) to skip the prompt (required in
agent / CI / piped contexts).
Typed exit codes:
resource.not_found no agent with the given id (exit 4)
auth.forbidden caller lacks delete permission on the agent (exit 3)
input.confirmation_required destructive op without -y on a TTY (exit 10)
AI agents: This is a high-risk write. Without -y/--yes the CLI exits 10
and writes input.confirmation_required to stderr. NEVER auto-pass -y
without the user's explicit go-ahead — the exit-10 protocol exists
exactly to guard against unintended deletes.`
const agentDeleteExample = ` weknora agent delete ag_abc # interactive confirm
weknora agent delete ag_abc -y # no prompt
weknora agent delete ag_abc -y --format json # bare {id, deleted:true} JSON`
// NewCmdDelete builds `weknora agent delete <agent-id>`.
func NewCmdDelete(f *cmdutil.Factory) *cobra.Command {
opts := &DeleteOptions{}
cmd := &cobra.Command{
Use: "delete <agent-id>",
Short: "Delete a custom agent",
Long: agentDeleteLong,
Example: agentDeleteExample,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
fopts, err := cmdutil.CheckFormatFlag(cmd)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
opts.AgentID = args[0]
opts.Yes, _ = cmd.Flags().GetBool("yes")
if handled, err := cmdutil.HandleDryRun(cmd, opts.DryRun, cmdutil.DryRunPlan{
Action: "agent.delete",
Args: map[string]any{
"agent_id": opts.AgentID,
},
}); handled {
return err
}
cli, err := f.Client()
if err != nil {
return err
}
return runDelete(cmd.Context(), opts, fopts, cli, f.Prompter())
},
}
cmdutil.AddFormatFlag(cmd, agentDeleteFields...)
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
cmdutil.SetRisk(cmd, "agent.delete")
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "permanently delete a custom agent",
RequiredFlags: []string{"<agent-id> (positional)"},
Output: "envelope.data is {id, deleted:true}",
Examples: []string{
"weknora agent delete ag_abc -y",
"weknora agent delete ag_abc -y --format json",
},
Warnings: []string{
"Requires explicit user approval (exit 10 / input.confirmation_required); never auto-add -y.",
"agent delete is irreversible; loses the agent + its KB binding + custom skills.",
},
})
return cmd
}
func runDelete(ctx context.Context, opts *DeleteOptions, fopts *cmdutil.FormatOptions, svc DeleteService, p prompt.Prompter) error {
if err := cmdutil.ConfirmDestructive(p, opts.Yes, fopts.WantsJSON(), "delete", "agent", opts.AgentID, "agent.delete", []string{"weknora", "agent", "delete", opts.AgentID, "-y"}); err != nil {
return err
}
if err := svc.DeleteAgent(ctx, opts.AgentID); err != nil {
return cmdutil.WrapHTTP(err, "delete agent %s", opts.AgentID)
}
if fopts.WantsJSON() {
return fopts.Emit(iostreams.IO.Out, deleteResult{ID: opts.AgentID, Deleted: true}, nil)
}
fmt.Fprintf(iostreams.IO.Out, "✓ Deleted agent %s\n", opts.AgentID)
return nil
}
+107
View File
@@ -0,0 +1,107 @@
package agentcmd
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/testutil"
)
type fakeDeleteSvc struct {
gotID string
err error
}
func (f *fakeDeleteSvc) DeleteAgent(_ context.Context, id string) error {
f.gotID = id
return f.err
}
func TestDelete_NonTTY_NoYes_ExitTen(t *testing.T) {
_, _ = iostreams.SetForTest(t) // non-TTY
svc := &fakeDeleteSvc{}
err := runDelete(
context.Background(),
&DeleteOptions{AgentID: "ag_abc", Yes: false},
&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, &testutil.ConfirmPrompter{},
)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeInputConfirmationRequired, typed.Code)
assert.Empty(t, svc.gotID, "must not call DeleteAgent without confirm")
assert.Equal(t, 10, cmdutil.ExitCode(err), "exit code 10 per destructive-write protocol")
}
func TestDelete_NonTTY_WithYes_Direct(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeDeleteSvc{}
require.NoError(t, runDelete(
context.Background(),
&DeleteOptions{AgentID: "ag_abc", Yes: true},
&cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{},
))
assert.Equal(t, "ag_abc", svc.gotID)
assert.Contains(t, out.String(), "ag_abc")
}
func TestDelete_404_PropagatesNotFound(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeDeleteSvc{err: errBadHTTP404}
err := runDelete(
context.Background(),
&DeleteOptions{AgentID: "ag_missing", Yes: true},
&cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{},
)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeResourceNotFound, typed.Code)
}
func TestDelete_TTY_ConfirmYes(t *testing.T) {
_, _ = iostreams.SetForTestWithTTY(t)
svc := &fakeDeleteSvc{}
p := &testutil.ConfirmPrompter{Answer: true}
require.NoError(t, runDelete(
context.Background(),
&DeleteOptions{AgentID: "ag_abc"},
&cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, p,
))
assert.True(t, p.Asked)
assert.Equal(t, "ag_abc", svc.gotID)
}
func TestDelete_TTY_ConfirmNo(t *testing.T) {
_, errBuf := iostreams.SetForTestWithTTY(t)
svc := &fakeDeleteSvc{}
p := &testutil.ConfirmPrompter{Answer: false}
err := runDelete(
context.Background(),
&DeleteOptions{AgentID: "ag_abc"},
&cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, p,
)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeUserAborted, typed.Code)
assert.Empty(t, svc.gotID, "answer=no must not call DeleteAgent")
assert.Contains(t, errBuf.String(), "Aborted")
}
func TestDelete_JSON_BareObject(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeDeleteSvc{}
require.NoError(t, runDelete(
context.Background(),
&DeleteOptions{AgentID: "ag_abc", Yes: true},
&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, &testutil.ConfirmPrompter{},
))
assert.Contains(t, out.String(), `"id":"ag_abc"`)
assert.Contains(t, out.String(), `"deleted":true`)
}
+453
View File
@@ -0,0 +1,453 @@
package agentcmd
import (
"context"
"fmt"
"io"
"sort"
"strings"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
// EditService is the narrow SDK surface this command depends on. The fetch
// half (GetAgent) is mandatory because UpdateAgent is a full PUT — without
// the pre-fetch baseline, any field not passed as a flag would silently
// clobber to the zero value.
type EditService interface {
GetAgent(ctx context.Context, id string) (*sdk.Agent, error)
UpdateAgent(ctx context.Context, id string, req *sdk.UpdateAgentRequest) (*sdk.Agent, error)
// ListModels backs --model / --rerank-model id-or-name resolution so a
// bogus name fails fast instead of clobbering config.model_id with an
// unresolvable string (which never resolves at run time).
ListModels(ctx context.Context) ([]sdk.Model, error)
}
// EditOptions captures the surgical flag state. Both string fields and
// reader-based file inputs are tracked alongside per-flag *Set bits in
// editFlagSet so empty strings are distinguishable from "unset".
type EditOptions struct {
AgentID string
Name string
Description string
Model string
SystemPrompt string
SystemPromptReader io.Reader
AgentMode string
RerankModel string
Temperature float64
AddKBs []string
RemoveKBs []string
KBSelectionMode string
ConfigFileBody io.Reader
ConfigFileKind string // "yaml" or "json"
DryRun bool
flags editFlagSet
}
// editFlagSet tracks which surgical flags the user passed. Empty-string
// values are valid (clear semantics) so cmd.Flags().Changed() is the only
// reliable signal of "user supplied this flag."
type editFlagSet struct {
nameSet bool
descriptionSet bool
modelSet bool
systemPromptSet bool
agentModeSet bool
rerankModelSet bool
temperatureSet bool
addKBsSet bool
removeKBsSet bool
kbSelectionModeSet bool
configFileSet bool
}
const agentEditLong = `Update a custom agent's fields surgically.
At least one update flag is required; flags you omit preserve the current
server-side value via fetch-then-update. Pass --description "" to clear
the description (empty string is a valid value, not "unset").
KB list operations are list-shaped: --add-kb and --remove-kb are
idempotent (re-adding an already-attached KB is silent success; removing
an unattached KB is silent success). Passing the same id to both flags
nets out to no-op and prints a stderr warning.
--config-file fully replaces the AgentConfig baseline (the same shape
GenerateAgentSkeleton emits). Surgical flags then apply on top of that
replaced baseline. To partially update one or two fields without
touching the rest, use surgical flags alone — that path L-2 fetches
current state and only mutates what's set. Precedence within a single
invocation:
surgical flag > config-file value > zero value
AI agents: this is a high-risk write. Without -y/--yes the CLI exits 10
with input.confirmation_required. Surface the prompt to the user and only
retry with -y after explicit approval. Other failure codes: resource.not_found
(agent id or KB id), auth.forbidden, input.invalid_argument (no flags, bad file).`
const agentEditExample = ` weknora agent update ag_abc --name "Renamed" -y
weknora agent update ag_abc --description "" -y # clear description
weknora agent update ag_abc --add-kb kb_new --remove-kb kb_old -y
weknora agent update ag_abc --system-prompt-file ./prompt.md -y
weknora agent update ag_abc --config-file ./tuned.yaml --temperature 0.9 -y`
// NewCmdEdit builds `weknora agent update <agent-id>`.
func NewCmdEdit(f *cmdutil.Factory) *cobra.Command {
opts := &EditOptions{}
var systemPromptFile, configFile string
cmd := &cobra.Command{
Use: "update <agent-id>",
Short: "Update a custom agent's configuration",
Long: agentEditLong,
Example: agentEditExample,
Args: cobra.ExactArgs(1),
PreRunE: func(cmd *cobra.Command, args []string) error {
opts.AgentID = args[0]
opts.flags.nameSet = cmd.Flags().Changed("name")
opts.flags.descriptionSet = cmd.Flags().Changed("description")
opts.flags.modelSet = cmd.Flags().Changed("model")
opts.flags.systemPromptSet = cmd.Flags().Changed("system-prompt") || cmd.Flags().Changed("system-prompt-file")
opts.flags.agentModeSet = cmd.Flags().Changed("agent-mode")
opts.flags.rerankModelSet = cmd.Flags().Changed("rerank-model")
opts.flags.temperatureSet = cmd.Flags().Changed("temperature")
opts.flags.addKBsSet = cmd.Flags().Changed("add-kb")
opts.flags.removeKBsSet = cmd.Flags().Changed("remove-kb")
opts.flags.kbSelectionModeSet = cmd.Flags().Changed("kb-selection-mode")
opts.flags.configFileSet = cmd.Flags().Changed("config-file")
// --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))
}
// Reject an unknown --agent-mode / --kb-selection-mode up front
// (typed exit 5) rather than letting the server fail the update.
if err := validateAgentModeFlags(&opts.AgentMode, &opts.KBSelectionMode); err != nil {
return err
}
if systemPromptFile != "" {
r, err := cmdutil.OpenInput(systemPromptFile)
if err != nil {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, fmt.Sprintf("--system-prompt-file: %v", err))
}
opts.SystemPromptReader = r
}
if configFile != "" {
r, kind, err := openConfigFile(configFile)
if err != nil {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, fmt.Sprintf("--config-file: %v", err))
}
opts.ConfigFileBody = r
opts.ConfigFileKind = kind
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
fopts, err := cmdutil.CheckFormatFlag(cmd)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
// Validate "at least one update flag" before the dry-run gate so
// --dry-run rejects identically to the live path. Same typed
// Error as runEdit (kept there for direct-call callers).
if !editHasAnyFlag(opts) {
return &cmdutil.Error{
Code: cmdutil.CodeInputInvalidArgument,
Message: "agent update requires at least one flag",
Hint: "pass at least one update flag (e.g., --name, --add-kb, --description) or --config-file",
}
}
if opts.DryRun {
planArgs := map[string]any{"agent_id": opts.AgentID}
if opts.flags.nameSet {
planArgs["name"] = opts.Name
}
if opts.flags.descriptionSet {
planArgs["description"] = opts.Description
}
if opts.flags.modelSet {
planArgs["model"] = opts.Model
}
if opts.flags.agentModeSet {
planArgs["agent_mode"] = opts.AgentMode
}
if opts.flags.rerankModelSet {
planArgs["rerank_model"] = opts.RerankModel
}
if opts.flags.temperatureSet {
planArgs["temperature"] = opts.Temperature
}
if opts.flags.addKBsSet {
planArgs["add_kb"] = opts.AddKBs
}
if opts.flags.removeKBsSet {
planArgs["remove_kb"] = opts.RemoveKBs
}
if opts.flags.kbSelectionModeSet {
planArgs["kb_selection_mode"] = opts.KBSelectionMode
}
if opts.flags.configFileSet {
planArgs["config_file"] = configFile
}
if opts.flags.systemPromptSet {
if systemPromptFile != "" {
planArgs["system_prompt_file"] = systemPromptFile
} else {
planArgs["system_prompt"] = opts.SystemPrompt
}
}
if handled, err := cmdutil.HandleDryRun(cmd, true, cmdutil.DryRunPlan{
Action: "agent.update",
Args: planArgs,
}); handled {
return err
}
}
yes, _ := cmd.Flags().GetBool("yes")
// Build the retry command from the flags the user actually passed.
// Include list-shaped and file-path flags so exit-10 retry_argv reproduces
// the original update (BuildRetryArgv expands StringSlice as repeats).
retryCmd := cmdutil.BuildRetryArgv(cmd, []string{"weknora", "agent", "update", opts.AgentID},
"name", "description", "model", "system-prompt", "system-prompt-file",
"agent-mode", "rerank-model", "temperature", "add-kb", "remove-kb",
"kb-selection-mode", "config-file", "format")
if err := cmdutil.ConfirmWrite(f.Prompter(), yes, fopts.WantsJSON(), "update", "agent", opts.AgentID, "agent.update", retryCmd); err != nil {
return err
}
cli, err := f.Client()
if err != nil {
return err
}
// Resolve --add-kb values (id or name) to canonical ids, matching
// the --kb id-or-name policy and agent create --attach-kb. Avoids
// silently attaching an unresolvable name as a kb_id.
if len(opts.AddKBs) > 0 {
resolved := make([]string, 0, len(opts.AddKBs))
for _, raw := range opts.AddKBs {
id, rerr := cmdutil.ResolveKBFlag(cmd.Context(), cli, raw)
if rerr != nil {
return rerr
}
resolved = append(resolved, id)
}
opts.AddKBs = resolved
}
return runEdit(cmd.Context(), opts, fopts, cli)
},
}
// Surgical flags
cmd.Flags().StringVar(&opts.Name, "name", "", "New agent name")
cmd.Flags().StringVar(&opts.Description, "description", "", `New description (use "" to clear)`)
cmd.Flags().StringVar(&opts.Model, "model", "", "LLM model id")
cmd.Flags().StringVar(&opts.SystemPrompt, "system-prompt", "", "System prompt text (mutex with --system-prompt-file)")
cmd.Flags().StringVar(&systemPromptFile, "system-prompt-file", "", "Read system prompt from FILE, or '-' for stdin")
cmd.MarkFlagsMutuallyExclusive("system-prompt", "system-prompt-file")
cmd.Flags().StringVar(&opts.AgentMode, "agent-mode", "", "Agent operating mode: "+strings.Join(agentModeValues, " | "))
cmd.Flags().StringVar(&opts.RerankModel, "rerank-model", "", "Rerank model id")
cmd.Flags().Float64Var(&opts.Temperature, "temperature", 0.0, "Generation temperature (0.0..2.0)")
cmd.Flags().StringSliceVar(&opts.AddKBs, "add-kb", nil, "Attach knowledge base id (repeatable, idempotent)")
cmd.Flags().StringSliceVar(&opts.RemoveKBs, "remove-kb", nil, "Detach knowledge base id (repeatable, idempotent)")
cmd.Flags().StringVar(&opts.KBSelectionMode, "kb-selection-mode", "", "KB selection mode: "+strings.Join(kbSelectionModeValues, " | "))
// Full-replace
cmd.Flags().StringVar(&configFile, "config-file", "", "Full AgentConfig YAML or JSON (REPLACES current config baseline; surgical flags then apply on top)")
cmdutil.AddFormatFlag(cmd, agentViewFields...)
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
cmdutil.SetWriteRisk(cmd, "agent.update")
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "surgically update a custom agent's configuration",
RequiredFlags: []string{"<agent-id> (positional)", "at least one update flag (--name, --add-kb, etc.)"},
Examples: []string{
"weknora agent update ag_abc --name \"Renamed\"",
"weknora agent update ag_abc --add-kb kb_new --remove-kb kb_old",
"weknora agent update ag_abc --config-file ./tuned.yaml",
},
Output: "envelope.data is the updated Agent object (id, name, config) after the update is applied",
Warnings: []string{
"Requires explicit user approval (exit 10 / input.confirmation_required); never auto-add -y.",
"agent update overwrites config; fetch-then-update protects unmentioned fields, but bad input still saved.",
},
})
return cmd
}
// editHasAnyFlag reports whether opts carries at least one surgical update
// signal. Required-flag validation lives in runEdit (not PreRunE) so unit
// tests can invoke runEdit with a hand-built EditOptions directly.
func editHasAnyFlag(opts *EditOptions) bool {
fl := opts.flags
return fl.nameSet || fl.descriptionSet || fl.modelSet || fl.systemPromptSet ||
fl.agentModeSet || fl.rerankModelSet || fl.temperatureSet ||
fl.addKBsSet || fl.removeKBsSet || fl.kbSelectionModeSet || fl.configFileSet
}
func runEdit(ctx context.Context, opts *EditOptions, fopts *cmdutil.FormatOptions, svc EditService) error {
if !editHasAnyFlag(opts) {
return &cmdutil.Error{
Code: cmdutil.CodeInputInvalidArgument,
Message: "agent update requires at least one flag",
Hint: "pass at least one update flag (e.g., --name, --add-kb, --description) or --config-file",
}
}
// Fetch-then-update so omitted fields round-trip unchanged through
// the full PUT body.
current, err := svc.GetAgent(ctx, opts.AgentID)
if err != nil {
return cmdutil.WrapHTTP(err, "fetch agent %s", opts.AgentID)
}
// Build base config: server state, then overlay --config-file (if any).
base := sdk.AgentConfig{}
if current.Config != nil {
base = *current.Config
}
if opts.ConfigFileBody != nil {
parsed, err := cmdutil.LoadAgentConfig(opts.ConfigFileBody, opts.ConfigFileKind)
if err != nil {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, err.Error())
}
base = *parsed
}
// Resolve --system-prompt-file before flag overlay.
if opts.SystemPromptReader != nil {
body, err := io.ReadAll(opts.SystemPromptReader)
if err != nil {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, fmt.Sprintf("--system-prompt-file read: %v", err))
}
opts.SystemPrompt = strings.TrimSpace(string(body))
}
// Resolve --model / --rerank-model (id or name) and validate they exist,
// mirroring agent create. Without this a bogus name is stored verbatim as
// config.model_id and the agent silently never resolves at run time.
if opts.flags.modelSet {
if opts.Model, err = cmdutil.ResolveModelRef(ctx, svc, opts.Model, "KnowledgeQA"); err != nil {
return err
}
}
if opts.flags.rerankModelSet {
if opts.RerankModel, err = cmdutil.ResolveModelRef(ctx, svc, opts.RerankModel, "Rerank"); err != nil {
return err
}
}
// Compute KB list from current + add/remove with a stderr warning when
// the same id appears in both flags (net no-op, still idempotent).
kbs := computeKBList(base.KnowledgeBases, opts.AddKBs, opts.RemoveKBs)
overrides := cmdutil.AgentConfigFlags{
AgentMode: opts.AgentMode, AgentModeSet: opts.flags.agentModeSet,
SystemPrompt: opts.SystemPrompt, SystemPromptSet: opts.flags.systemPromptSet,
ModelID: opts.Model, ModelIDSet: opts.flags.modelSet,
RerankModelID: opts.RerankModel, RerankModelIDSet: opts.flags.rerankModelSet,
Temperature: opts.Temperature, TemperatureSet: opts.flags.temperatureSet,
KBSelectionMode: opts.KBSelectionMode, KBSelectionModeSet: opts.flags.kbSelectionModeSet,
// KB list is always replaced (its add/remove was already merged
// into kbs); we only signal "set" when the user actually touched
// the list so a plain --name edit doesn't churn the field.
KnowledgeBases: kbs,
KnowledgeBasesSet: opts.flags.addKBsSet || opts.flags.removeKBsSet,
}
cfg := cmdutil.MergeAgentConfig(&base, overrides)
// Build the full PUT body. Name/Description default to the current
// server values so the surgical-flag-only path preserves them.
req := &sdk.UpdateAgentRequest{
Name: current.Name,
Description: current.Description,
Config: cfg,
}
if opts.flags.nameSet {
req.Name = opts.Name
}
if opts.flags.descriptionSet {
req.Description = opts.Description
}
updated, err := svc.UpdateAgent(ctx, opts.AgentID, req)
if err != nil {
return cmdutil.WrapHTTP(err, "update agent %s", opts.AgentID)
}
return emitAgent(fopts, updated)
}
// computeKBList applies --add-kb / --remove-kb to current with idempotent
// semantics. Ids present in both add and remove cancel out and surface a
// stderr warning so users notice the conflict but don't see a hard error.
// Stderr is the right channel here (not stdout) because callers piping
// --format json | jq would otherwise see corrupted JSON.
func computeKBList(current, add, remove []string) []string {
// Detect ids in both add and remove; they net out to no-op and are
// excluded from both operations.
canceledSet := map[string]struct{}{}
addSeen := map[string]struct{}{}
for _, id := range add {
addSeen[id] = struct{}{}
}
for _, id := range remove {
if _, both := addSeen[id]; both {
canceledSet[id] = struct{}{}
}
}
if len(canceledSet) > 0 {
canceled := make([]string, 0, len(canceledSet))
for id := range canceledSet {
canceled = append(canceled, id)
}
// Sort for deterministic test output; map iteration is random.
sort.Strings(canceled)
fmt.Fprintf(iostreams.IO.Err, "warning: --add-kb and --remove-kb cancel out for: %s\n", strings.Join(canceled, ", "))
}
// Compute effective remove set (excluding canceled).
removeEff := map[string]struct{}{}
for _, id := range remove {
if _, c := canceledSet[id]; c {
continue
}
removeEff[id] = struct{}{}
}
// Filter removals out of the current list (idempotent: unattached id
// simply isn't in current).
out := make([]string, 0, len(current))
for _, id := range current {
if _, drop := removeEff[id]; drop {
continue
}
out = append(out, id)
}
// Append any add ids not already present (idempotent: already-attached
// id silently de-dupes).
present := map[string]struct{}{}
for _, id := range out {
present[id] = struct{}{}
}
for _, id := range add {
if _, c := canceledSet[id]; c {
continue
}
if _, dup := present[id]; dup {
continue
}
out = append(out, id)
present[id] = struct{}{}
}
return out
}
+46
View File
@@ -0,0 +1,46 @@
package agentcmd
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/prompt"
sdk "github.com/Tencent/WeKnora/client"
)
// agentDryRunFactory builds a Factory whose Client closure panics if invoked —
// dry-run must early-exit before any SDK call.
func agentDryRunFactory(t *testing.T) *cmdutil.Factory {
t.Helper()
return &cmdutil.Factory{
Client: func() (*sdk.Client, error) {
t.Fatal("dry-run path must not call Factory.Client(); SDK side effect leaked")
return nil, nil
},
Prompter: func() prompt.Prompter {
t.Fatal("dry-run path must not call Factory.Prompter(); confirm-prompt side effect leaked")
return nil
},
}
}
// TestAgentEdit_DryRun_RejectsNoFlag: --dry-run must reject the same
// "no update flag supplied" invocation the live path rejects. Before the
// fix the editHasAnyFlag check lived in runEdit() (live-only), reached only
// after HandleDryRun early-exited.
func TestAgentEdit_DryRun_RejectsNoFlag(t *testing.T) {
iostreams.SetForTest(t)
f := agentDryRunFactory(t)
root := withRootHarnessAgent(NewCmdEdit(f), "ag_abc", "--dry-run", "--format", "json")
err := root.Execute()
require.Error(t, err, "dry-run must reject identically to live path")
var typed *cmdutil.Error
require.True(t, errors.As(err, &typed), "expected *cmdutil.Error, got %T %v", err, err)
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
}
+355
View File
@@ -0,0 +1,355 @@
package agentcmd
import (
"bytes"
"context"
"fmt"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/prompt"
sdk "github.com/Tencent/WeKnora/client"
)
// fakeEditSvc scripts GetAgent (fetch baseline) + UpdateAgent (apply
// surgical overlays). updateCalls lets tests verify that no-flag invocations
// don't reach the wire.
type fakeEditSvc struct {
getResp *sdk.Agent
getErr error
updateReq *sdk.UpdateAgentRequest
updateID string
updateResp *sdk.Agent
updateErr error
updateCalls int
models []sdk.Model
modelsErr error
}
func (f *fakeEditSvc) GetAgent(_ context.Context, _ string) (*sdk.Agent, error) {
return f.getResp, f.getErr
}
func (f *fakeEditSvc) UpdateAgent(_ context.Context, id string, req *sdk.UpdateAgentRequest) (*sdk.Agent, error) {
f.updateReq = req
f.updateID = id
f.updateCalls++
return f.updateResp, f.updateErr
}
func (f *fakeEditSvc) ListModels(_ context.Context) ([]sdk.Model, error) {
return f.models, f.modelsErr
}
func TestEdit_ModelName_ResolvedToID(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeEditSvc{
getResp: &sdk.Agent{ID: "ag_abc", Name: "A", Config: &sdk.AgentConfig{ModelID: "old-id"}},
updateResp: &sdk.Agent{ID: "ag_abc"},
models: []sdk.Model{{ID: "m-real", Name: "good-llm", Type: "KnowledgeQA"}},
}
opts := &EditOptions{AgentID: "ag_abc", Model: "good-llm", flags: editFlagSet{modelSet: true}}
require.NoError(t, runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
require.NotNil(t, svc.updateReq)
require.NotNil(t, svc.updateReq.Config)
assert.Equal(t, "m-real", svc.updateReq.Config.ModelID, "--model name must resolve to the model id")
}
func TestEdit_BogusModelName_RejectedNoWrite(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeEditSvc{
getResp: &sdk.Agent{ID: "ag_abc", Name: "A", Config: &sdk.AgentConfig{ModelID: "old-id"}},
updateResp: &sdk.Agent{ID: "ag_abc"},
models: []sdk.Model{{ID: "m-real", Name: "good-llm", Type: "KnowledgeQA"}},
}
opts := &EditOptions{AgentID: "ag_abc", Model: "totally-bogus-model-xyz", flags: editFlagSet{modelSet: true}}
err := runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc)
require.Error(t, err, "a --model name matching no model must fail")
var e *cmdutil.Error
require.ErrorAs(t, err, &e)
assert.Equal(t, cmdutil.CodeResourceNotFound, e.Code)
assert.Equal(t, 0, svc.updateCalls, "must not write an agent with an unresolvable model")
}
func TestEdit_FetchThenUpdate_PreservesUntouchedFields(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeEditSvc{
getResp: &sdk.Agent{
ID: "ag_abc", Name: "Original", Description: "Keep me",
Config: &sdk.AgentConfig{ModelID: "model-x", Temperature: 0.7, KnowledgeBases: []string{"kb_a"}},
},
updateResp: &sdk.Agent{ID: "ag_abc"},
}
// Only --description passed; everything else should round-trip.
opts := &EditOptions{
AgentID: "ag_abc",
Description: "Updated",
flags: editFlagSet{descriptionSet: true},
}
require.NoError(t, runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
require.NotNil(t, svc.updateReq)
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, "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)
}
func TestEdit_AddRemoveKB_SameID_NetNoOpWithWarning(t *testing.T) {
_, errBuf := iostreams.SetForTest(t)
svc := &fakeEditSvc{
getResp: &sdk.Agent{Config: &sdk.AgentConfig{KnowledgeBases: []string{"kb_a"}}},
updateResp: &sdk.Agent{},
}
opts := &EditOptions{
AgentID: "ag_abc",
AddKBs: []string{"kb_b"},
RemoveKBs: []string{"kb_b"},
flags: editFlagSet{addKBsSet: true, removeKBsSet: true},
}
require.NoError(t, runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
assert.Equal(t, []string{"kb_a"}, svc.updateReq.Config.KnowledgeBases, "net no-op preserves original list")
assert.Contains(t, errBuf.String(), "cancel out", "warning emitted to stderr")
}
func TestEdit_NoFlags_InvalidArgument(t *testing.T) {
svc := &fakeEditSvc{}
err := runEdit(context.Background(), &EditOptions{AgentID: "ag_abc"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
assert.Equal(t, 0, svc.updateCalls, "must not call UpdateAgent")
}
func TestEdit_AddKB_AlreadyAttached_Silent(t *testing.T) {
_, errBuf := iostreams.SetForTest(t)
svc := &fakeEditSvc{
getResp: &sdk.Agent{Config: &sdk.AgentConfig{KnowledgeBases: []string{"kb_a", "kb_b"}}},
updateResp: &sdk.Agent{},
}
opts := &EditOptions{
AgentID: "ag_abc",
AddKBs: []string{"kb_a"}, // already attached
flags: editFlagSet{addKBsSet: true},
}
require.NoError(t, runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
assert.Equal(t, []string{"kb_a", "kb_b"}, svc.updateReq.Config.KnowledgeBases, "no duplicate")
assert.NotContains(t, errBuf.String(), "warning", "already-attached is silent success")
}
func TestEdit_RemoveKB_Unattached_Silent(t *testing.T) {
_, errBuf := iostreams.SetForTest(t)
svc := &fakeEditSvc{
getResp: &sdk.Agent{Config: &sdk.AgentConfig{KnowledgeBases: []string{"kb_a"}}},
updateResp: &sdk.Agent{},
}
opts := &EditOptions{
AgentID: "ag_abc",
RemoveKBs: []string{"kb_zzz"},
flags: editFlagSet{removeKBsSet: true},
}
require.NoError(t, runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
assert.Equal(t, []string{"kb_a"}, svc.updateReq.Config.KnowledgeBases)
assert.NotContains(t, errBuf.String(), "warning")
}
func TestEdit_ClearDescription_EmptyString(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeEditSvc{
getResp: &sdk.Agent{Name: "X", Description: "old", Config: &sdk.AgentConfig{ModelID: "m"}},
updateResp: &sdk.Agent{},
}
opts := &EditOptions{
AgentID: "ag_abc",
Description: "",
flags: editFlagSet{descriptionSet: true},
}
require.NoError(t, runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
assert.Equal(t, "", svc.updateReq.Description, "explicit empty must clear server-side")
assert.Equal(t, "X", svc.updateReq.Name, "Name round-trip unchanged")
}
func TestEdit_ConfigFile_OverridesByFlag(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeEditSvc{
getResp: &sdk.Agent{
Name: "X",
Config: &sdk.AgentConfig{ModelID: "old-model", Temperature: 0.1},
},
updateResp: &sdk.Agent{},
}
opts := &EditOptions{
AgentID: "ag_abc",
Temperature: 0.9,
ConfigFileBody: bytes.NewBufferString(`{"temperature":0.5,"model_id":"file-model"}`),
ConfigFileKind: "json",
flags: editFlagSet{temperatureSet: true, configFileSet: true},
}
require.NoError(t, runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
require.NotNil(t, svc.updateReq.Config)
assert.Equal(t, "file-model", svc.updateReq.Config.ModelID, "file overrides current state")
assert.InDelta(t, 0.9, svc.updateReq.Config.Temperature, 0.001, "flag overrides file")
}
// TestEdit_ConfigFile_FullReplacesBaseline pins the documented behavior:
// --config-file fully replaces the AgentConfig baseline; current-server
// fields not mentioned in the file are zeroed. The Long help directs
// users to surgical flags when they want a partial update.
func TestEdit_ConfigFile_FullReplacesBaseline(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeEditSvc{
getResp: &sdk.Agent{
Name: "X",
Config: &sdk.AgentConfig{
SystemPrompt: "Existing prompt",
ModelID: "old-model",
Temperature: 0.1,
AgentMode: "smart-reasoning",
KnowledgeBases: []string{"kb_existing"},
},
},
updateResp: &sdk.Agent{},
}
opts := &EditOptions{
AgentID: "ag_abc",
ConfigFileBody: bytes.NewBufferString(`{"model_id":"file-only"}`),
ConfigFileKind: "json",
flags: editFlagSet{configFileSet: true},
}
require.NoError(t, runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
require.NotNil(t, svc.updateReq.Config)
assert.Equal(t, "file-only", svc.updateReq.Config.ModelID, "file's model_id applied")
assert.Equal(t, "", svc.updateReq.Config.SystemPrompt, "file fully replaces baseline; unset fields are zeroed")
assert.InDelta(t, 0.0, svc.updateReq.Config.Temperature, 0.001, "unset fields zeroed")
assert.Equal(t, "", svc.updateReq.Config.AgentMode, "unset fields zeroed")
assert.Empty(t, svc.updateReq.Config.KnowledgeBases, "unset fields zeroed")
}
func TestEdit_NotFound(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeEditSvc{getErr: errBadHTTP404}
opts := &EditOptions{AgentID: "ag_missing", Name: "x", flags: editFlagSet{nameSet: true}}
err := runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc)
require.Error(t, err)
assert.Contains(t, err.Error(), "resource.not_found")
}
func TestEdit_AddKB_AppendsToExisting(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeEditSvc{
getResp: &sdk.Agent{Config: &sdk.AgentConfig{KnowledgeBases: []string{"kb_a"}}},
updateResp: &sdk.Agent{},
}
opts := &EditOptions{
AgentID: "ag_abc",
AddKBs: []string{"kb_b", "kb_c"},
flags: editFlagSet{addKBsSet: true},
}
require.NoError(t, runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
assert.Equal(t, []string{"kb_a", "kb_b", "kb_c"}, svc.updateReq.Config.KnowledgeBases)
}
func TestEdit_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 := NewCmdEdit(nil)
cmd.SetArgs([]string{"ag_abc", "--temperature", fmt.Sprintf("%f", badT)})
cmd.SilenceUsage = true
cmd.SilenceErrors = true
err := cmd.Execute()
require.Error(t, err, "expected error for --temperature %g", badT)
assert.Contains(t, err.Error(), "0.0..2.0")
})
}
}
func TestEdit_SystemPromptFile(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeEditSvc{
getResp: &sdk.Agent{Config: &sdk.AgentConfig{ModelID: "m"}},
updateResp: &sdk.Agent{},
}
opts := &EditOptions{
AgentID: "ag_abc",
SystemPromptReader: strings.NewReader("new prompt\n"),
flags: editFlagSet{systemPromptSet: true},
}
require.NoError(t, runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
assert.Equal(t, "new prompt", svc.updateReq.Config.SystemPrompt)
}
// withRootHarnessAgent wraps `weknora agent update ...` under a synthetic root
// cmd that registers the global persistent flags (mirrors addGlobalFlags in
// cmd/root.go).
func withRootHarnessAgent(edit *cobra.Command, args ...string) *cobra.Command {
root := &cobra.Command{Use: "weknora"}
pf := root.PersistentFlags()
pf.BoolP("yes", "y", false, "")
pf.String("format", "", "Output format: text | json | ndjson")
pf.StringP("jq", "q", "", "")
ag := &cobra.Command{Use: "agent"}
ag.AddCommand(edit)
root.AddCommand(ag)
root.SetArgs(append([]string{"agent", "update"}, args...))
root.SetContext(context.Background())
root.SilenceErrors = true
root.SilenceUsage = true
return root
}
// TestAgentEdit_RequiresConfirmation asserts that without -y (non-TTY / JSON
// mode), agent update returns input.confirmation_required (exit 10).
func TestAgentEdit_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 := withRootHarnessAgent(NewCmdEdit(f), "ag_abc", "--name", "Renamed", "--format", "json")
err := root.Execute()
require.Error(t, err)
var ce *cmdutil.Error
require.ErrorAs(t, err, &ce)
assert.Equal(t, cmdutil.CodeInputConfirmationRequired, ce.Code)
assert.Equal(t, 10, cmdutil.ExitCode(err), "exit code 10 per destructive-write protocol")
// retry argv must include -y and the agent id
assert.Contains(t, ce.RetryArgv, "-y")
assert.Contains(t, ce.RetryArgv, "ag_abc")
}
// TestAgentEdit_RetryArgvPreservesAddKB is a regression for #2597: exit-10
// retry_argv must keep --add-kb / --remove-kb / --config-file so re-running
// after human approval reproduces the original update.
func TestAgentEdit_RetryArgvPreservesAddKB(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{} },
}
// Avoid --config-file / --system-prompt-file here: PreRunE opens paths and
// would fail before ConfirmWrite. Those flags still go through the same
// BuildRetryArgv scalar path covered in cmdutil tests.
root := withRootHarnessAgent(NewCmdEdit(f),
"ag_abc", "--add-kb", "kb_new", "--remove-kb", "kb_old", "--format", "json")
err := root.Execute()
require.Error(t, err)
var ce *cmdutil.Error
require.ErrorAs(t, err, &ce)
assert.Equal(t, cmdutil.CodeInputConfirmationRequired, ce.Code)
// pflag Visit is lexicographical among changed flags.
assert.Equal(t, []string{
"weknora", "agent", "update", "ag_abc",
"--add-kb", "kb_new",
"--format", "json",
"--remove-kb", "kb_old",
"-y",
}, ce.RetryArgv)
}
+145
View File
@@ -0,0 +1,145 @@
package agentcmd
import (
"context"
"fmt"
"sort"
"text/tabwriter"
"time"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/output"
"github.com/Tencent/WeKnora/cli/internal/text"
sdk "github.com/Tencent/WeKnora/client"
)
// agentListFields enumerates the fields surfaced for `--format json` discovery
// on `agent list`. Mirrors the json tags on sdk.Agent - nested Config is
// omitted because its sub-fields make filtering noisy (use `--jq` instead).
var agentListFields = []string{
"id", "name", "description", "avatar",
"is_builtin", "tenant_id", "created_by",
"created_at", "updated_at",
}
// ListService is the narrow SDK surface this command depends on.
type ListService interface {
ListAgents(ctx context.Context) ([]sdk.Agent, error)
}
// ListOptions captures `agent list` filter flag state.
type ListOptions struct {
// Limit caps the returned slice client-side. 0 = no cap, 1..10000 = explicit.
// The agent list SDK is unpaginated; --all-pages is intentionally not
// exposed because it would be a no-op.
Limit int
}
// NewCmdList builds `weknora agent list`.
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
opts := &ListOptions{}
cmd := &cobra.Command{
Use: "list",
Short: "List custom agents visible to the active tenant",
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, _ []string) error {
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
// Validate static input before building the client so a bad --limit
// returns input.invalid_argument (exit 5), not an auth error (exit 3).
if err := validateListOpts(opts); err != nil {
return err
}
cli, err := f.Client()
if err != nil {
return err
}
return runList(c.Context(), opts, fopts, cli)
},
}
cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum results to return — client-side cap; meta.has_more/total_count report the full size (1..10000)")
cmdutil.AddFormatFlag(cmd, agentListFields...)
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "List custom agents visible to the active tenant. The SDK returns all agents in one call (no server-side pagination); meta.count reflects the full tenant set, --limit caps client-side.",
Examples: []string{"weknora agent list --format json", "weknora agent list --limit 10 --format json"},
Output: "envelope.data is an array of Agent objects with id, name, is_builtin; meta.total_count is the full tenant set and meta.has_more=true means --limit truncated it (raise --limit to get the rest)",
})
return cmd
}
// validateListOpts checks --limit. Called from RunE before the client is built
// (so a bad value surfaces as exit 5, not an auth error) and at runList's top
// for direct callers; idempotent and nil-safe.
func validateListOpts(opts *ListOptions) error {
if opts == nil {
return nil
}
if opts.Limit < 1 || opts.Limit > 10000 {
return &cmdutil.Error{
Code: cmdutil.CodeInputInvalidArgument,
Message: fmt.Sprintf("--limit must be in 1..10000, got %d", opts.Limit),
}
}
return nil
}
func runList(ctx context.Context, opts *ListOptions, fopts *cmdutil.FormatOptions, svc ListService) error {
if opts == nil {
opts = &ListOptions{}
}
if err := validateListOpts(opts); err != nil {
return err
}
items, err := svc.ListAgents(ctx)
if err != nil {
return cmdutil.WrapHTTP(err, "list agents")
}
if items == nil {
items = []sdk.Agent{} // ensure JSON [] not null
}
// Default sort: updated_at desc - most recently-edited agents surface
// first. Mirrors kb list / doc list behavior.
sort.Slice(items, func(i, j int) bool {
return items[i].UpdatedAt.After(items[j].UpdatedAt)
})
// The agent list SDK is unpaginated — it returns every agent in one
// call — so the CLI holds the true total and can tell the caller
// whether the client-side --limit dropped any. total_count is the full
// count; has_more flags that --limit truncated it (raise --limit to get
// the rest — there is no server cursor to continue with).
total := len(items)
truncated := false
if opts.Limit > 0 && len(items) > opts.Limit {
items = items[:opts.Limit]
truncated = true
}
if fopts.WantsJSON() {
meta := &output.Meta{Count: output.IntPtr(len(items)), HasMore: truncated, TotalCount: output.IntPtr(total)}
return fopts.Emit(iostreams.IO.Out, items, meta)
}
if len(items) == 0 {
fmt.Fprintln(iostreams.IO.Out, "(no agents)")
return nil
}
tw := tabwriter.NewWriter(iostreams.IO.Out, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "ID\tNAME\tBUILTIN\tUPDATED")
now := time.Now()
for _, a := range items {
name := text.Truncate(40, a.Name)
builtin := "-"
if a.IsBuiltin {
builtin = "yes"
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", a.ID, name, builtin, text.FuzzyAgo(now, a.UpdatedAt))
}
return tw.Flush()
}
+205
View File
@@ -0,0 +1,205 @@
package agentcmd
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"time"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
type fakeListSvc struct {
items []sdk.Agent
err error
}
func (f *fakeListSvc) ListAgents(_ context.Context) ([]sdk.Agent, error) {
return f.items, f.err
}
func TestList_Empty_Text(t *testing.T) {
out, _ := iostreams.SetForTest(t)
if err := runList(context.Background(), &ListOptions{Limit: 30}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &fakeListSvc{}); err != nil {
t.Fatalf("runList: %v", err)
}
if !strings.Contains(out.String(), "(no agents)") {
t.Errorf("expected '(no agents)', got %q", out.String())
}
}
func TestList_Empty_JSON(t *testing.T) {
out, _ := iostreams.SetForTest(t)
if err := runList(context.Background(), &ListOptions{Limit: 30}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, &fakeListSvc{}); err != nil {
t.Fatalf("runList: %v", err)
}
var env struct {
OK bool `json:"ok"`
Data []sdk.Agent `json:"data"`
}
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("parse envelope: %v\n%s", err, out.String())
}
if !env.OK {
t.Error("envelope.ok=false")
}
if len(env.Data) != 0 {
t.Errorf("expected empty data, got %d items", len(env.Data))
}
}
func TestList_NonEmpty_Text_RendersColumns(t *testing.T) {
out, _ := iostreams.SetForTest(t)
now := time.Now()
items := []sdk.Agent{
{ID: "ag_a", Name: "Research", IsBuiltin: true, UpdatedAt: now.Add(-1 * time.Hour)},
{ID: "ag_b", Name: "Triage", UpdatedAt: now.Add(-3 * 24 * time.Hour)},
}
if err := runList(context.Background(), &ListOptions{Limit: 30}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &fakeListSvc{items: items}); err != nil {
t.Fatalf("runList: %v", err)
}
got := out.String()
for _, w := range []string{"ID", "NAME", "BUILTIN", "ag_a", "Research", "yes", "ag_b", "Triage"} {
if !strings.Contains(got, w) {
t.Errorf("output missing %q in:\n%s", w, got)
}
}
}
func TestList_NonEmpty_JSON_SortsByUpdatedAtDesc(t *testing.T) {
out, _ := iostreams.SetForTest(t)
now := time.Now()
items := []sdk.Agent{
{ID: "ag_old", Name: "old", UpdatedAt: now.Add(-7 * 24 * time.Hour)},
{ID: "ag_new", Name: "new", UpdatedAt: now},
{ID: "ag_mid", Name: "mid", UpdatedAt: now.Add(-1 * time.Hour)},
}
if err := runList(context.Background(), &ListOptions{Limit: 30}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, &fakeListSvc{items: items}); err != nil {
t.Fatalf("runList: %v", err)
}
var env struct {
OK bool `json:"ok"`
Data []sdk.Agent `json:"data"`
}
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("parse: %v", err)
}
got := env.Data
if len(got) != 3 {
t.Fatalf("len = %d, want 3", len(got))
}
wantOrder := []string{"ag_new", "ag_mid", "ag_old"}
for i, w := range wantOrder {
if got[i].ID != w {
t.Errorf("position %d: got %s, want %s (updated_at desc)", i, got[i].ID, w)
}
}
}
func TestList_JSON_JQProjection(t *testing.T) {
out, _ := iostreams.SetForTest(t)
items := []sdk.Agent{
{ID: "ag_x", Name: "Foo", Description: "long description"},
}
// --jq projects from the envelope; .data[] | ... extracts from the array inside envelope.
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON, JQ: ".data[] | {id, name}"}
if err := runList(context.Background(), &ListOptions{Limit: 30}, fopts, &fakeListSvc{items: items}); err != nil {
t.Fatalf("runList: %v", err)
}
var got map[string]any
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("parse: %v", err)
}
if _, has := got["description"]; has {
t.Errorf("description should be filtered out: %+v", got)
}
}
// makeAgents returns N Agents with distinct IDs and descending UpdatedAt.
func makeAgents(n int) []sdk.Agent {
base := time.Now()
out := make([]sdk.Agent, n)
for i := 0; i < n; i++ {
out[i] = sdk.Agent{
ID: fmt.Sprintf("ag_%02d", i),
Name: fmt.Sprintf("name-%02d", i),
UpdatedAt: base.Add(-time.Duration(i) * time.Hour),
}
}
return out
}
func TestList_Limit_CapsResults(t *testing.T) {
out, _ := iostreams.SetForTest(t)
if err := runList(context.Background(), &ListOptions{Limit: 5}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, &fakeListSvc{items: makeAgents(20)}); err != nil {
t.Fatalf("runList: %v", err)
}
got := strings.Count(out.String(), `"id":"ag_`)
if got != 5 {
t.Errorf("--limit 5 must slice 20 down to 5; got %d", got)
}
}
// TestList_Truncation_SignalsHasMoreAndTotal pins that --limit truncation
// tells the agent it did not get everything (has_more + total_count).
// Regression: agent list silently dropped agents past --limit with no signal.
func TestList_Truncation_SignalsHasMoreAndTotal(t *testing.T) {
out, _ := iostreams.SetForTest(t)
if err := runList(context.Background(), &ListOptions{Limit: 5}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, &fakeListSvc{items: makeAgents(20)}); err != nil {
t.Fatalf("runList: %v", err)
}
if !strings.Contains(out.String(), `"has_more":true`) {
t.Errorf("truncated list must set has_more:true; got:\n%s", out.String())
}
if !strings.Contains(out.String(), `"total_count":20`) {
t.Errorf("truncated list must report total_count:20; got:\n%s", out.String())
}
}
func TestList_NoTruncation_OmitsHasMore(t *testing.T) {
out, _ := iostreams.SetForTest(t)
if err := runList(context.Background(), &ListOptions{Limit: 30}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, &fakeListSvc{items: makeAgents(3)}); err != nil {
t.Fatalf("runList: %v", err)
}
if strings.Contains(out.String(), `"has_more"`) {
t.Errorf("non-truncated list must omit has_more; got:\n%s", out.String())
}
if !strings.Contains(out.String(), `"total_count":3`) {
t.Errorf("list must report total_count:3; got:\n%s", out.String())
}
}
func TestList_Limit_Zero_Rejected(t *testing.T) {
_, _ = iostreams.SetForTest(t)
err := runList(context.Background(), &ListOptions{Limit: 0}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, &fakeListSvc{items: makeAgents(7)})
if err == nil {
t.Fatal("expected error for --limit 0")
}
var typed *cmdutil.Error
if !errors.As(err, &typed) {
t.Fatalf("expected *cmdutil.Error, got %T: %v", err, err)
}
if typed.Code != cmdutil.CodeInputInvalidArgument {
t.Errorf("expected CodeInputInvalidArgument, got %v", typed.Code)
}
}
func TestList_Limit_Negative_Rejected(t *testing.T) {
_, _ = iostreams.SetForTest(t)
err := runList(context.Background(), &ListOptions{Limit: -1}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &fakeListSvc{items: makeAgents(2)})
if err == nil {
t.Fatal("expected error for negative --limit")
}
var typed *cmdutil.Error
if !errors.As(err, &typed) {
t.Fatalf("expected *cmdutil.Error, got %T: %v", err, err)
}
if typed.Code != cmdutil.CodeInputInvalidArgument {
t.Errorf("expected CodeInputInvalidArgument, got %v", typed.Code)
}
}
+117
View File
@@ -0,0 +1,117 @@
package agentcmd
import (
"context"
"fmt"
"io"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
// AgentStatusResult is the shallow health snapshot for `agent status <id>`.
//
// One HTTP call: reachable + model_id. For downstream KB reachability
// verification use 'agent check <id>' (1 + N HTTP).
//
// model_reachable is intentionally OMITTED: the server has no per-agent
// test endpoint, so we cannot verify model reachability from the agent
// resource alone.
type AgentStatusResult struct {
ID string `json:"id"`
Reachable bool `json:"reachable"`
ModelID string `json:"model_id,omitempty"`
}
// AgentStatusService is the narrow SDK surface needed for agent status.
type AgentStatusService interface {
GetAgent(ctx context.Context, id string) (*sdk.Agent, error)
}
var agentStatusFields = []string{"id", "reachable", "model_id"}
func NewCmdStatus(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "status <agent-id>",
Short: "Show health status of a custom agent",
Long: `Shallow health snapshot for an agent (1 HTTP call).
Returns: reachable / model_id.
For downstream KB reachability verification use 'weknora agent check <id>'
(active verification, 1 + N HTTP). For full agent config / metadata use
'weknora agent view <id>'.`,
Example: ` weknora agent status ag_abc
weknora agent status ag_abc --format json`,
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
cli, err := f.Client()
if err != nil {
return err
}
res, err := runAgentStatus(c.Context(), cli, args[0])
if err != nil {
return err
}
return emitAgentStatus(res, fopts, iostreams.IO.Out)
},
}
cmdutil.AddFormatFlag(cmd, agentStatusFields...)
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "shallow health probe of a custom agent: reachability without kb_scope verification",
RequiredFlags: []string{"<agent-id> (positional)"},
Examples: []string{"weknora agent status agent_abc"},
Output: "envelope.data is {id, reachable, ...}; use `agent check` for deep kb_scope verification",
})
return cmd
}
// runAgentStatus is the testable core. Never errors for "agent not
// reachable" — Reachable=false carries that signal.
func runAgentStatus(ctx context.Context, svc AgentStatusService, id string) (*AgentStatusResult, error) {
a, err := svc.GetAgent(ctx, id)
if err != nil {
return &AgentStatusResult{ID: id, Reachable: false}, nil
}
res := &AgentStatusResult{ID: a.ID, Reachable: true}
if a.Config != nil {
res.ModelID = a.Config.ModelID
}
return res, nil
}
// emitAgentStatus renders res per --format. Mirrors emitStatus pattern from
// cli/cmd/kb/status.go (C3).
func emitAgentStatus(res *AgentStatusResult, fopts *cmdutil.FormatOptions, w io.Writer) error {
switch fopts.Mode {
case cmdutil.FormatJSON, cmdutil.FormatNDJSON:
return fopts.Emit(w, res, nil)
case cmdutil.FormatText, "":
return writeAgentStatusText(w, res)
default:
return fmt.Errorf("unsupported --format %q for agent status", fopts.Mode)
}
}
func writeAgentStatusText(w io.Writer, res *AgentStatusResult) error {
fmt.Fprintf(w, "ID: %s\n", res.ID)
fmt.Fprintf(w, "Reachable: %v\n", res.Reachable)
if !res.Reachable {
return nil
}
if res.ModelID != "" {
fmt.Fprintf(w, "Model: %s\n", res.ModelID)
}
return nil
}
// compile-time check: SDK client satisfies AgentStatusService.
var _ AgentStatusService = (*sdk.Client)(nil)
+108
View File
@@ -0,0 +1,108 @@
package agentcmd
import (
"bytes"
"context"
"encoding/json"
"fmt"
"strings"
"testing"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
sdk "github.com/Tencent/WeKnora/client"
)
type fakeAgentStatusSvc struct {
agent *sdk.Agent
agentErr error
}
func (f *fakeAgentStatusSvc) GetAgent(_ context.Context, id string) (*sdk.Agent, error) {
if f.agentErr != nil {
return nil, f.agentErr
}
return f.agent, nil
}
func TestRunAgentStatus_ShallowFields(t *testing.T) {
svc := &fakeAgentStatusSvc{agent: &sdk.Agent{
ID: "ag_x",
Config: &sdk.AgentConfig{
ModelID: "m_x",
KnowledgeBases: []string{"kb_a", "kb_b"},
},
}}
res, err := runAgentStatus(context.Background(), svc, "ag_x")
if err != nil {
t.Fatalf("%v", err)
}
if !res.Reachable {
t.Error("Reachable=false, want true")
}
if res.ModelID != "m_x" {
t.Errorf("ModelID=%q, want m_x", res.ModelID)
}
}
func TestRunAgentStatus_Unreachable(t *testing.T) {
svc := &fakeAgentStatusSvc{agentErr: fmt.Errorf("404")}
res, err := runAgentStatus(context.Background(), svc, "ag_x")
if err != nil {
t.Fatalf("%v", err)
}
if res.Reachable {
t.Error("Reachable=true on 404; want false")
}
if res.ID != "ag_x" {
t.Errorf("ID=%q, want ag_x (echoed even on unreachable)", res.ID)
}
}
func TestRunAgentStatus_NilConfig(t *testing.T) {
// Defensive: Agent.Config is a pointer; nil should not panic
svc := &fakeAgentStatusSvc{agent: &sdk.Agent{ID: "ag_x", Config: nil}}
res, err := runAgentStatus(context.Background(), svc, "ag_x")
if err != nil {
t.Fatalf("%v", err)
}
if !res.Reachable {
t.Error("Reachable=false, want true on nil config")
}
if res.ModelID != "" {
t.Errorf("ModelID=%q, want empty (no config)", res.ModelID)
}
}
func TestEmitAgentStatus_JSON(t *testing.T) {
var buf bytes.Buffer
res := &AgentStatusResult{ID: "ag_x", Reachable: true, ModelID: "m_x"}
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}
if err := emitAgentStatus(res, fopts, &buf); err != nil {
t.Fatalf("%v", err)
}
var env struct {
OK bool `json:"ok"`
Data AgentStatusResult `json:"data"`
}
if err := json.Unmarshal(buf.Bytes(), &env); err != nil {
t.Fatalf("%v", err)
}
got := env.Data
if got.ModelID != "m_x" {
t.Errorf("ModelID=%q, want m_x", got.ModelID)
}
}
func TestEmitAgentStatus_Text(t *testing.T) {
var buf bytes.Buffer
res := &AgentStatusResult{ID: "ag_x", Reachable: true, ModelID: "m_x"}
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatText}
if err := emitAgentStatus(res, fopts, &buf); err != nil {
t.Fatalf("%v", err)
}
for _, want := range []string{"ag_x", "m_x", "true"} {
if !strings.Contains(buf.String(), want) {
t.Errorf("output missing %q:\n%s", want, buf.String())
}
}
}
+291
View File
@@ -0,0 +1,291 @@
package agentcmd
import (
"context"
"fmt"
"io"
"strings"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/text"
sdk "github.com/Tencent/WeKnora/client"
)
// 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 the top-level Agent keys surfaced in `--help`
// as a hint for `--jq` projection. Nested AgentConfig fields are reachable
// via `--jq '.config.system_prompt'` or by selecting `config` whole and
// post-processing.
var agentViewFields = []string{
"id", "name", "description", "avatar",
"is_builtin", "tenant_id", "created_by", "config",
"created_at", "updated_at",
}
// ViewService is the narrow SDK surface this command depends on.
type ViewService interface {
GetAgent(ctx context.Context, agentID string) (*sdk.Agent, error)
}
// NewCmdView builds `weknora agent view <agent-id>`.
func NewCmdView(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "view <agent-id>",
Short: "Show a custom agent's configuration",
Long: `Renders the agent's metadata and full AgentConfig as grouped KV
sections (Identity / LLM / KB attachment / Retrieval / Query rewrite /
Tools / FAQ / Web search / Multi-turn / Fallback / Templates). Zero-value
fields are omitted; sections with no set fields are suppressed entirely.
Pass --format json for the bare SDK Agent object (config nested, not flattened).
Use --jq to project specific fields or reach into nested config.`,
Example: ` weknora agent view ag_abc
weknora agent view ag_abc --format json --jq '{id, name, config}' # top-level projection
weknora agent view ag_abc --format json --jq '.config.system_prompt'`,
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
cli, err := f.Client()
if err != nil {
return err
}
return runView(c.Context(), fopts, cli, args[0])
},
}
cmdutil.AddFormatFlag(cmd, agentViewFields...)
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "fetch one custom agent's full configuration by id",
RequiredFlags: []string{"<agent-id> (positional)"},
Examples: []string{"weknora agent view agent_abc"},
Output: "envelope.data is the custom agent (id, name, description, is_builtin, and a nested config object holding model/kb_scope/...)",
})
return cmd
}
func runView(ctx context.Context, fopts *cmdutil.FormatOptions, svc ViewService, agentID string) error {
a, err := svc.GetAgent(ctx, agentID)
if err != nil {
return cmdutil.WrapHTTP(err, "fetch agent %s", agentID)
}
if fopts.WantsJSON() {
return fopts.Emit(iostreams.IO.Out, a, nil)
}
renderAgent(iostreams.IO.Out, a)
return nil
}
// renderAgent prints a single agent in human-readable form, grouped into
// 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
// never meaningfully empty for a fetched Agent.
fmt.Fprintln(w, "Identity:")
fmt.Fprintf(w, " ID: %s\n", a.ID)
fmt.Fprintf(w, " Name: %s\n", a.Name)
if a.Description != "" {
fmt.Fprintf(w, " Description: %s\n", a.Description)
}
if a.IsBuiltin {
fmt.Fprintln(w, " Builtin: yes")
}
if a.CreatedBy != "" {
fmt.Fprintf(w, " Created by: %s\n", a.CreatedBy)
}
if a.TenantID != 0 {
fmt.Fprintf(w, " Tenant ID: %d\n", a.TenantID)
}
fmt.Fprintf(w, " Created at: %s\n", a.CreatedAt.Format("2006-01-02 15:04:05"))
fmt.Fprintf(w, " Updated at: %s\n", a.UpdatedAt.Format("2006-01-02 15:04:05"))
if a.Config == nil {
return
}
c := a.Config
// Each group is rendered via a tiny helper: collect its set rows
// upfront, suppress the whole section if empty. Avoids the
// "header printed but no body" failure mode that plagues naive
// conditional rendering.
type row struct{ k, v string }
emit := func(label string, rows []row) {
if len(rows) == 0 {
return
}
fmt.Fprintln(w)
fmt.Fprintf(w, "%s:\n", label)
for _, r := range rows {
fmt.Fprintf(w, " %-32s %s\n", r.k+":", r.v)
}
}
// LLM
llm := []row{}
if c.ModelID != "" {
llm = append(llm, row{"Model ID", c.ModelID})
}
if c.RerankModelID != "" {
llm = append(llm, row{"Rerank model ID", c.RerankModelID})
}
if c.Temperature != 0 {
llm = append(llm, row{"Temperature", fmt.Sprintf("%g", c.Temperature)})
}
if c.MaxCompletionTokens != 0 {
llm = append(llm, row{"Max completion tokens", fmt.Sprintf("%d", c.MaxCompletionTokens)})
}
if c.MaxIterations < 0 {
llm = append(llm, row{"Max iterations", "unlimited"})
} else if c.MaxIterations != 0 {
llm = append(llm, row{"Max iterations", fmt.Sprintf("%d", c.MaxIterations)})
}
if c.AgentMode != "" {
llm = append(llm, row{"Mode", c.AgentMode})
}
emit("LLM", llm)
// KB attachment
kb := []row{}
if c.KBSelectionMode != "" {
kb = append(kb, row{"KB selection mode", c.KBSelectionMode})
}
if len(c.KnowledgeBases) > 0 {
kb = append(kb, row{"Knowledge bases", strings.Join(c.KnowledgeBases, ", ")})
}
emit("KB attachment", kb)
// Retrieval
retr := []row{}
if c.EmbeddingTopK != 0 {
retr = append(retr, row{"Embedding top K", fmt.Sprintf("%d", c.EmbeddingTopK)})
}
if c.KeywordThreshold != 0 {
retr = append(retr, row{"Keyword threshold", fmt.Sprintf("%g", c.KeywordThreshold)})
}
if c.VectorThreshold != 0 {
retr = append(retr, row{"Vector threshold", fmt.Sprintf("%g", c.VectorThreshold)})
}
if c.RerankTopK != 0 {
retr = append(retr, row{"Rerank top K", fmt.Sprintf("%d", c.RerankTopK)})
}
if c.RerankThreshold != 0 {
retr = append(retr, row{"Rerank threshold", fmt.Sprintf("%g", c.RerankThreshold)})
}
emit("Retrieval", retr)
// Query rewrite
qr := []row{}
if c.EnableQueryExpansion {
qr = append(qr, row{"Query expansion", "enabled"})
}
if c.EnableRewrite {
qr = append(qr, row{"Rewrite", "enabled"})
}
if c.QueryUnderstandModelID != "" {
qr = append(qr, row{"Query understand model ID", c.QueryUnderstandModelID})
}
if c.RewritePromptSystem != "" {
qr = append(qr, row{"Rewrite prompt (system)", text.OneLine(promptPreviewWidth, c.RewritePromptSystem)})
}
if c.RewritePromptUser != "" {
qr = append(qr, row{"Rewrite prompt (user)", text.OneLine(promptPreviewWidth, c.RewritePromptUser)})
}
emit("Query rewrite", qr)
// Tools
tools := []row{}
if len(c.AllowedTools) > 0 {
tools = append(tools, row{"Allowed tools", strings.Join(c.AllowedTools, ", ")})
}
if c.MCPSelectionMode != "" {
tools = append(tools, row{"MCP selection mode", c.MCPSelectionMode})
}
if len(c.MCPServices) > 0 {
tools = append(tools, row{"MCP services", strings.Join(c.MCPServices, ", ")})
}
if len(c.SupportedFileTypes) > 0 {
tools = append(tools, row{"Supported file types", strings.Join(c.SupportedFileTypes, ", ")})
}
emit("Tools", tools)
// FAQ
faq := []row{}
if c.FAQPriorityEnabled {
faq = append(faq, row{"FAQ priority", "enabled"})
}
if c.FAQDirectAnswerThreshold != 0 {
faq = append(faq, row{"FAQ direct-answer threshold", fmt.Sprintf("%g", c.FAQDirectAnswerThreshold)})
}
if c.FAQScoreBoost != 0 {
faq = append(faq, row{"FAQ score boost", fmt.Sprintf("%g", c.FAQScoreBoost)})
}
emit("FAQ", faq)
// Web search
web := []row{}
if c.WebSearchEnabled {
web = append(web, row{"Web search", "enabled"})
}
if c.WebSearchMaxResults != 0 {
web = append(web, row{"Web search max results", fmt.Sprintf("%d", c.WebSearchMaxResults)})
}
emit("Web search", web)
// Multi-turn
mt := []row{}
if c.MultiTurnEnabled {
mt = append(mt, row{"Multi-turn", "enabled"})
}
if c.HistoryTurns != 0 {
mt = append(mt, row{"History turns", fmt.Sprintf("%d", c.HistoryTurns)})
}
emit("Multi-turn", mt)
// Fallback
fb := []row{}
if c.FallbackStrategy != "" {
fb = append(fb, row{"Strategy", c.FallbackStrategy})
}
if c.FallbackResponse != "" {
fb = append(fb, row{"Response", c.FallbackResponse})
}
if c.FallbackPrompt != "" {
fb = append(fb, row{"Prompt", text.OneLine(promptPreviewWidth, c.FallbackPrompt)})
}
emit("Fallback", fb)
// Templates — system_prompt and context_template can be multi-line;
// render headed blocks rather than KV rows for readability.
if c.SystemPrompt != "" || c.ContextTemplate != "" {
fmt.Fprintln(w)
fmt.Fprintln(w, "Templates:")
if c.SystemPrompt != "" {
fmt.Fprintln(w, " System prompt:")
writeIndented(w, c.SystemPrompt, " ")
}
if c.ContextTemplate != "" {
fmt.Fprintln(w, " Context template:")
writeIndented(w, c.ContextTemplate, " ")
}
}
}
// 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) {
for _, line := range strings.Split(strings.TrimRight(s, "\n"), "\n") {
fmt.Fprintf(w, "%s%s\n", prefix, line)
}
}
+183
View File
@@ -0,0 +1,183 @@
package agentcmd
import (
"context"
"encoding/json"
"errors"
"slices"
"strings"
"testing"
"time"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
type fakeViewSvc struct {
resp *sdk.Agent
err error
}
func (f *fakeViewSvc) GetAgent(_ context.Context, _ string) (*sdk.Agent, error) {
return f.resp, f.err
}
func TestView_Text_RendersMetadataAndConfig(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeViewSvc{resp: &sdk.Agent{
ID: "ag_abc",
Name: "Research",
Description: "deep-dive helper",
IsBuiltin: true,
CreatedAt: time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC),
UpdatedAt: time.Date(2026, 5, 2, 13, 30, 0, 0, time.UTC),
Config: &sdk.AgentConfig{
AgentMode: "smart-reasoning",
ModelID: "model_42",
KBSelectionMode: "selected",
KnowledgeBases: []string{"kb_x", "kb_y"},
AllowedTools: []string{"knowledge_search", "web_search"},
WebSearchEnabled: true,
},
}}
if err := runView(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "ag_abc"); err != nil {
t.Fatalf("runView: %v", err)
}
got := out.String()
for _, want := range []string{"ag_abc", "Research", "deep-dive helper", "Builtin:", "Identity", "LLM", "KB attachment", "Tools", "smart-reasoning", "model_42", "selected", "kb_x", "knowledge_search", "Web search"} {
if !strings.Contains(got, want) {
t.Errorf("missing %q in:\n%s", want, got)
}
}
}
// TestAgentViewFields_TopLevelOnly pins the contract that the field-hint
// list on `agent view` only lists top-level Agent keys (including `config`
// as a whole). Nested AgentConfig fields are reachable via --jq.
func TestAgentViewFields_TopLevelOnly(t *testing.T) {
for _, want := range []string{"id", "name", "config", "created_at"} {
if !slices.Contains(agentViewFields, want) {
t.Errorf("agentViewFields missing top-level key %q", want)
}
}
for _, dotted := range []string{"config.system_prompt", "config.model_id", "config.fallback_strategy"} {
if slices.Contains(agentViewFields, dotted) {
t.Errorf("agentViewFields must not list dotted nested key %q (the --jq projection path handles nesting directly)", dotted)
}
}
}
// TestRenderAgent_RendersAllGroupsWithOmitEmpty validates the grouped
// text rendering: present groups print, zero-value fields omit, and an
// entire section is suppressed when all of its fields are zero.
func TestRenderAgent_RendersAllGroupsWithOmitEmpty(t *testing.T) {
out, _ := iostreams.SetForTest(t)
ag := &sdk.Agent{
ID: "ag_abc",
Name: "Test",
Config: &sdk.AgentConfig{
AgentMode: "smart-reasoning",
SystemPrompt: "You help users.",
ModelID: "model-x",
Temperature: 0.7,
KBSelectionMode: "selected",
KnowledgeBases: []string{"kb_a"},
FAQPriorityEnabled: true,
WebSearchEnabled: false, // zero — omitted in text
FallbackStrategy: "fixed",
FallbackResponse: "I don't know.",
},
}
renderAgent(iostreams.IO.Out, ag)
body := out.String()
// Group labels appear:
for _, label := range []string{"Identity", "LLM", "KB attachment", "FAQ", "Fallback", "Templates"} {
if !strings.Contains(body, label) {
t.Errorf("missing group label %q in:\n%s", label, body)
}
}
// Set fields rendered:
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)
}
}
// Zero-value fields omitted (web search disabled → max_results not shown):
if strings.Contains(body, "web_search_max_results") {
t.Errorf("zero-valued web_search_max_results leaked into:\n%s", body)
}
// Section with all zero values must be suppressed entirely (no Retrieval
// fields were set in this fixture).
if strings.Contains(body, "Retrieval") {
t.Errorf("Retrieval section rendered with all-zero fields in:\n%s", body)
}
// Multi-turn section is all-zero too — must be suppressed.
if strings.Contains(body, "Multi-turn") {
t.Errorf("Multi-turn section rendered with all-zero fields in:\n%s", body)
}
}
func TestView_Text_OmitsEmptyFields(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeViewSvc{resp: &sdk.Agent{
ID: "ag_min",
Name: "Minimal",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}}
if err := runView(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "ag_min"); err != nil {
t.Fatalf("runView: %v", err)
}
got := out.String()
if strings.Contains(got, "Description:") {
t.Errorf("empty description should be omitted, got:\n%s", got)
}
if strings.Contains(got, "Builtin:") {
t.Errorf("non-builtin should not render Builtin: line, got:\n%s", got)
}
// With Config==nil none of the grouped config sections should appear.
for _, label := range []string{"LLM:", "KB attachment:", "Retrieval:", "Query rewrite:", "Tools:", "FAQ:", "Web search:", "Multi-turn:", "Fallback:", "Templates:"} {
if strings.Contains(got, label) {
t.Errorf("nil Config should not render %q, got:\n%s", label, got)
}
}
}
func TestView_JSON_BareObject(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeViewSvc{resp: &sdk.Agent{ID: "ag_json", Name: "JSONy"}}
if err := runView(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "ag_json"); err != nil {
t.Fatalf("runView: %v", err)
}
var env struct {
OK bool `json:"ok"`
Data sdk.Agent `json:"data"`
}
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("parse: %v", err)
}
got := env.Data
if got.ID != "ag_json" || got.Name != "JSONy" {
t.Errorf("envelope.data shape wrong: id=%s name=%s", got.ID, got.Name)
}
if !env.OK {
t.Errorf("envelope.ok must be true, got %q", out.String())
}
}
func TestView_404_MapsToResourceNotFound(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeViewSvc{err: errors.New("HTTP error 404: agent not found")}
err := runView(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "ag_missing")
if err == nil {
t.Fatal("expected error, got nil")
}
var typed *cmdutil.Error
if !errors.As(err, &typed) {
t.Fatalf("expected *cmdutil.Error, got %T", err)
}
if typed.Code != cmdutil.CodeResourceNotFound {
t.Errorf("expected resource.not_found, got %s", typed.Code)
}
}
+109
View File
@@ -0,0 +1,109 @@
package cmd
import (
"bytes"
"encoding/json"
"sort"
"testing"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
)
// TestEveryLeafCommandHasAgentHelp enforces the agent-first contract: every
// leaf (runnable, no subcommands) command must emit a structured AgentHelp JSON
// blob under WEKNORA_AGENT_HELP=1, so an agent never has to scrape human prose.
// Drift guard in the spirit of the K6/K7 skill-parity tests: a new leaf command
// added without SetAgentHelp fails CI here.
//
// Exemptions: cobra's generated `completion`/`help` subtrees carry no
// domain semantics worth a machine blob.
func TestEveryLeafCommandHasAgentHelp(t *testing.T) {
t.Setenv("WEKNORA_AGENT_HELP", "1")
root := NewRootCmd(cmdutil.New())
var missing []string
eachLeafCommand(root, func(c *cobra.Command) {
// Invoke the leaf's help func with the agent env set and require JSON.
var buf bytes.Buffer
c.SetOut(&buf)
c.Help()
var ah struct {
UsedFor string `json:"used_for"`
}
if err := json.Unmarshal(buf.Bytes(), &ah); err != nil || ah.UsedFor == "" {
missing = append(missing, c.CommandPath())
}
})
sort.Strings(missing)
if len(missing) > 0 {
t.Errorf("leaf commands missing agent-help JSON (register cmdutil.SetAgentHelp):\n %v\n(%d commands)",
missing, len(missing))
}
}
// renderAgentHelp runs a leaf's help under WEKNORA_AGENT_HELP=1 and decodes the
// machine blob (used_for / output / examples) an agent would read.
func renderAgentHelp(t *testing.T, c *cobra.Command) struct {
UsedFor string `json:"used_for"`
Output string `json:"output"`
Examples []string `json:"examples"`
} {
t.Helper()
var buf bytes.Buffer
c.SetOut(&buf)
c.Help()
var ah struct {
UsedFor string `json:"used_for"`
Output string `json:"output"`
Examples []string `json:"examples"`
}
if err := json.Unmarshal(buf.Bytes(), &ah); err != nil {
t.Fatalf("%s: agent-help is not JSON: %v", c.CommandPath(), err)
}
return ah
}
// TestEveryLeafCommandDeclaresOutput enforces that every leaf command tells an
// agent what its stdout carries. Even side-effect commands describe their
// envelope (e.g. deletes emit {id, deleted:true}); an empty Output is a contract
// gap, not a valid state. Sibling drift guard to the agent-help test above.
func TestEveryLeafCommandDeclaresOutput(t *testing.T) {
t.Setenv("WEKNORA_AGENT_HELP", "1")
root := NewRootCmd(cmdutil.New())
var missing []string
eachLeafCommand(root, func(c *cobra.Command) {
if renderAgentHelp(t, c).Output == "" {
missing = append(missing, c.CommandPath())
}
})
sort.Strings(missing)
if len(missing) > 0 {
t.Errorf("leaf commands missing agent-help Output (declare AgentHelp.Output):\n %v\n(%d commands)",
missing, len(missing))
}
}
// TestEveryLeafCommandHasExample enforces that every leaf ships at least one
// runnable example — agents learn invocation shape from examples, not prose.
func TestEveryLeafCommandHasExample(t *testing.T) {
t.Setenv("WEKNORA_AGENT_HELP", "1")
root := NewRootCmd(cmdutil.New())
var missing []string
eachLeafCommand(root, func(c *cobra.Command) {
if len(renderAgentHelp(t, c).Examples) == 0 {
missing = append(missing, c.CommandPath())
}
})
sort.Strings(missing)
if len(missing) > 0 {
t.Errorf("leaf commands missing agent-help Examples (declare AgentHelp.Examples):\n %v\n(%d commands)",
missing, len(missing))
}
}
+517
View File
@@ -0,0 +1,517 @@
// Package api implements the `weknora api` raw HTTP passthrough command.
//
// Shape: one positional (path) + `-X/--method` flag, default GET (auto-
// promoted to POST when a body is supplied via -d/--input). Text mode writes
// the raw response body to stdout; --format json places the parsed server
// response directly under envelope.data (no {status, headers, body} wrapper),
// so `--jq '.data...'` projects at the server's own depth. Reuses
// sdk.Client.Raw which already applies tenant + auth headers.
package api
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
// apiFields is intentionally a marker - api wraps arbitrary HTTP responses
// whose schema the CLI doesn't know, so field hints are meaningless here.
// The marker shows up in --help so users can tell.
var apiFields = []string{"<response-shape-varies>"}
type Options struct {
Method string
Input string // --input: file path, "-" for stdin
Data string // -d/--data: inline JSON body (mutually exclusive with --input)
Fields []string // -F/--field: key=value pairs assembled into a JSON object body (mutually exclusive with -d/--input)
Yes bool
DryRun bool
StdinReader io.Reader // overridden by tests; defaults to iostreams.IO.In
}
// Service is the narrow SDK surface this command depends on. The production
// implementation is *sdk.Client, whose Raw method already injects auth /
// tenant / request-id headers (see client.applyAuthHeaders). Tests substitute
// either a fake or a real client pointed at httptest.Server.
type Service interface {
Raw(ctx context.Context, method, path string, body any) (*http.Response, error)
}
// NewCmd returns the `weknora api` command.
func NewCmd(f *cmdutil.Factory) *cobra.Command {
opts := &Options{}
cmd := &cobra.Command{
Use: "api <path>",
Short: "Make a raw API request to the WeKnora server",
Long: `Raw HTTP API access. JSON body via -d/--data (inline) or --input <file>/- (stdin).
The default method is GET; supplying a body (-d or --input) auto-promotes it
to POST. Use -X/--method to override (any non-empty method is accepted:
DELETE / PUT / PATCH / HEAD / OPTIONS / TRACE / custom).
Auth, tenant, and request-id headers are applied automatically from the
active profile. In text mode (default) the raw server response body is written
to stdout. In --format json the parsed server response is placed directly
under envelope.data — drill in with --jq '.data...' at the server's own depth
(e.g. '.data.data[]' for a list endpoint). Only -X DELETE is confirmation-gated.
Examples:
weknora api /api/v1/knowledge-bases # GET
weknora api /api/v1/knowledge-bases -d '{"name":"foo"}' # POST (auto)
weknora api /api/v1/knowledge-bases -F name=foo -F enabled=true # POST, typed body
echo '{"name":"foo"}' | weknora api /api/v1/knowledge-bases --input - # POST via stdin
weknora api /api/v1/knowledge-bases/kb_xxx -X DELETE`,
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
opts.Yes, _ = c.Flags().GetBool("yes")
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
if opts.DryRun {
// --dry-run on the raw escape-hatch is only meaningful when a
// would-be mutation exists to preview. GET (default or explicit)
// is read-only with no side effect, so previewing it is a
// likely user error — signal it via FlagError exit 2 with the
// concrete repair. Use resolveMethod so --input auto-promotes
// GET → POST identically to the live path.
method := resolveMethod(opts)
if method == http.MethodGet {
return cmdutil.NewFlagError(fmt.Errorf(
"--dry-run requires explicit -X POST/PUT/PATCH/DELETE; default GET is read-only with no side effect to preview"))
}
var body any
if hasBody(opts) {
contents, err := resolveBody(opts)
if err != nil {
return err
}
// Surface the parsed body in the plan so agents can grep
// meta.plan.body for shape; resolveBody has already validated
// it as JSON.
var parsed any
if json.Unmarshal(contents, &parsed) == nil {
body = parsed
} else {
body = string(contents)
}
}
if handled, err := cmdutil.HandleDryRun(c, true, cmdutil.DryRunPlan{
Action: "api." + strings.ToLower(method),
Method: method,
Path: args[0],
Body: body,
}); handled {
return err
}
}
method := resolveMethod(opts)
// Escape-hatch DELETE through `weknora api` is just as destructive
// as `weknora kb delete` - exit-10 destructive protocol must apply
// (cli/README.md). PUT/PATCH mutate server state like a typed
// `kb/agent/doc update`, so they get the same exit-10 WRITE gate;
// without it the raw escape hatch bypassed the "an agent cannot
// silently mutate" guarantee. POST stays ungated to match typed
// `create` (also ungated). GET/HEAD are reads.
switch method {
case http.MethodDelete:
if err := cmdutil.ConfirmDestructive(f.Prompter(), opts.Yes, fopts.WantsJSON(), "delete", "endpoint", args[0], "api.delete", []string{"weknora", "api", "-X", "DELETE", args[0], "-y"}); err != nil {
return err
}
case http.MethodPut, http.MethodPatch:
if err := cmdutil.ConfirmWrite(f.Prompter(), opts.Yes, fopts.WantsJSON(), "write", "endpoint", args[0], "api."+strings.ToLower(method), apiRetryArgv(opts, method, args[0])); err != nil {
return err
}
}
cli, err := f.Client()
if err != nil {
return err
}
paginate, _ := c.Flags().GetBool("paginate")
return runAPI(c.Context(), opts, fopts, cli, method, args[0], paginate)
},
}
cmd.Flags().StringVarP(&opts.Method, "method", "X", "", "HTTP method (default: GET, or POST when a body is supplied via -d/--input). Any non-empty method is accepted.")
cmd.Flags().StringVarP(&opts.Data, "data", "d", "", "Inline JSON request body (e.g. -d '{\"name\":\"x\"}'). Mutually exclusive with --input.")
cmd.Flags().StringVar(&opts.Input, "input", "", "Read JSON request body from file (use `-` for stdin). Mutually exclusive with -d/--data.")
cmd.Flags().StringArrayVarP(&opts.Fields, "field", "F", nil, "Add a key=value to a JSON object body (repeatable; e.g. -F name=foo -F enabled=true). true/false/null and numbers are typed; everything else is a string. Flat keys only. Mutually exclusive with -d/--input.")
cmd.Flags().Bool("paginate", false, "Follow offset-based pagination (?page=N&page_size=M), merging all pages into a single {data, total} JSON response.")
cmdutil.AddFormatFlag(cmd, apiFields...)
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "raw HTTP passthrough to weknora-server API endpoints when typed subcommands are insufficient",
RequiredFlags: []string{"path (positional)"},
Examples: []string{
"weknora api /api/v1/knowledge-bases",
"weknora api /api/v1/knowledge-bases -d '{\"name\":\"foo\"}'",
"weknora api -X POST /api/v1/knowledge-bases -F name=\"Eng Docs\" -F enabled=true",
"weknora api -X DELETE /api/v1/knowledge-bases/kb_x -y",
"echo '{\"name\":\"foo\"}' | weknora api /api/v1/knowledge-bases --input -",
},
Output: "text mode (default): the raw server response body on stdout. json mode: the parsed server response is placed directly under envelope.data — project with --jq '.data...' at the server's own depth (e.g. '.data.data[]' for a list endpoint, '.data.data.id' for a created object). With --paginate, envelope.data is the merged {data, total}.",
Warnings: []string{
"-X DELETE is destructive-gated and -X PUT/PATCH are write-gated (exit 10 / input.confirmation_required unless -y), matching typed delete/update. -X POST (create-shaped) and GET are unguarded — you own the safety of creates made through this escape hatch.",
"Raw passthrough: the typed error envelope does NOT fully apply. The server's own response goes under envelope.data at its native depth; a non-2xx HTTP status surfaces via the exit code, not a typed error.type/retry_argv. Do not rely on error.type/retryable for `api` the way you do for typed subcommands.",
"Raw HTTP passthrough; agents should prefer typed subcommands (kb/doc/session/...) when available.",
},
})
return cmd
}
// readInput reads opts.Input and returns its contents. "-" reads from
// opts.StdinReader (or iostreams.IO.In as the production default) for
// piped JSON payloads.
//
// The body is sent as a JSON document (the SDK marshals it as json.RawMessage),
// so a non-JSON / malformed payload is validated and rejected here as
// input.invalid_argument (exit 5). Without this, the failure surfaced from the
// SDK's marshal step as a confusing network.error (exit 7, "retryable") —
// looping an agent on a body that can never be sent.
func readInput(opts *Options) ([]byte, error) {
var b []byte
if opts.Input == "-" {
r := opts.StdinReader
if r == nil {
r = iostreams.IO.In
}
var err error
b, err = io.ReadAll(r)
if err != nil {
return nil, cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "read request body from stdin")
}
} else {
var err error
b, err = os.ReadFile(opts.Input)
if err != nil {
return nil, cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "read input file %s", opts.Input)
}
}
if len(bytes.TrimSpace(b)) > 0 && !json.Valid(b) {
return nil, cmdutil.NewError(cmdutil.CodeInputInvalidArgument,
"--input must be valid JSON (the request body is sent as a JSON document)")
}
return b, nil
}
// resolveBody returns the request body bytes from -d/--data (inline) or
// --input (file/stdin), or nil when neither is set. Inline --data is
// validated as JSON here; --input is validated inside readInput. The two
// flags are mutually exclusive — checked here (rather than cobra's
// MarkFlagsMutuallyExclusive, whose violation surfaces as an unclassified
// internal.error / exit 1) so the conflict reports input.invalid_argument.
func resolveBody(opts *Options) ([]byte, error) {
set := 0
if opts.Data != "" {
set++
}
if opts.Input != "" {
set++
}
if len(opts.Fields) > 0 {
set++
}
if set > 1 {
return nil, cmdutil.NewError(cmdutil.CodeInputInvalidArgument,
"-d/--data, --input, and -F/--field are mutually exclusive; supply the body one way")
}
if len(opts.Fields) > 0 {
return buildFieldBody(opts.Fields)
}
if opts.Data != "" {
if !json.Valid([]byte(opts.Data)) {
return nil, cmdutil.NewError(cmdutil.CodeInputInvalidArgument,
"-d/--data must be valid JSON (the request body is sent as a JSON document)")
}
return []byte(opts.Data), nil
}
if opts.Input != "" {
return readInput(opts)
}
return nil, nil
}
// hasBody reports whether a request body was supplied via -d or --input.
func hasBody(opts *Options) bool {
return opts.Data != "" || opts.Input != "" || len(opts.Fields) > 0
}
// inferFieldValue maps a -F value string to a typed JSON scalar: literal
// true/false/null and integer/float literals become typed JSON; everything
// else stays a string. A value that looks numeric but must stay a string
// (e.g. a zero-padded id "007") can't be forced here — use -d/--data or
// --input for full control.
func inferFieldValue(v string) any {
switch v {
case "true":
return true
case "false":
return false
case "null":
return nil
}
if i, err := strconv.ParseInt(v, 10, 64); err == nil {
return i
}
if f, err := strconv.ParseFloat(v, 64); err == nil {
return f
}
return v
}
// buildFieldBody assembles -F/--field key=value pairs into a JSON object with
// type inference. Flat keys only. Returns input.invalid_argument (exit 5) for
// a malformed pair or an unsupported @file value.
func buildFieldBody(fields []string) ([]byte, error) {
obj := make(map[string]any, len(fields))
for _, f := range fields {
k, v, ok := strings.Cut(f, "=")
if !ok || k == "" {
return nil, cmdutil.NewError(cmdutil.CodeInputInvalidArgument,
fmt.Sprintf("-F/--field must be key=value, got %q", f))
}
if strings.HasPrefix(v, "@") {
return nil, cmdutil.NewError(cmdutil.CodeInputInvalidArgument,
fmt.Sprintf("-F %q: @file values are not supported; read a file body with --input", f))
}
obj[k] = inferFieldValue(v)
}
return json.Marshal(obj)
}
// resolveMethod implements the auto-method behavior: explicit -X wins;
// otherwise body presence (-d/--data or --input) promotes GET → POST.
func resolveMethod(opts *Options) string {
if opts.Method != "" {
return strings.ToUpper(opts.Method)
}
if hasBody(opts) {
return "POST"
}
return "GET"
}
// apiRetryArgv reconstructs a directly-executable `weknora api` argv (with -y)
// for the write-confirmation gate, preserving the method, path and body flags
// the caller passed so an agent can re-run the exact mutation after approval.
func apiRetryArgv(opts *Options, method, path string) []string {
argv := []string{"weknora", "api", "-X", method, path}
switch {
case opts.Data != "":
argv = append(argv, "-d", opts.Data)
case opts.Input != "":
argv = append(argv, "--input", opts.Input)
default:
for _, f := range opts.Fields {
argv = append(argv, "-F", f)
}
}
return append(argv, "-y")
}
// runAPI is the testable core: validate inputs, dispatch via Service.Raw,
// classify status, and emit either the raw body or a JSON object. The
// caller is responsible for resolving the method (defaults / auto-POST)
// and uppercasing it; runAPI guards against unsupported values like
// `-X PATCH-INVALID` reaching the wire.
//
// When paginate is true and method is GET, all offset-based pages are
// fetched and merged into a single {data, total} JSON response. For
// non-GET methods paginate is silently ignored (no offset semantic).
func runAPI(ctx context.Context, opts *Options, fopts *cmdutil.FormatOptions, svc Service, method, path string, paginate bool) error {
if paginate && method == http.MethodGet {
return runAPIPaginated(ctx, opts, fopts, svc, path)
}
return runAPISingle(ctx, opts, fopts, svc, method, path)
}
// runAPISingle is the original single-call implementation of runAPI.
func runAPISingle(ctx context.Context, opts *Options, fopts *cmdutil.FormatOptions, svc Service, method, path string) error {
if method == "" {
return cmdutil.NewFlagError(fmt.Errorf("--method cannot be empty"))
}
if !strings.HasPrefix(path, "/") {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, fmt.Sprintf("path must start with /: %s", path))
}
// Resolve request body from -d/--data (inline) or --input (file / `-`).
// Empty input is treated as no body rather than an empty JSON document
// (which fails to marshal).
contents, err := resolveBody(opts)
if err != nil {
return err
}
var body any
if len(bytes.TrimSpace(contents)) > 0 {
body = json.RawMessage(contents)
}
resp, err := svc.Raw(ctx, method, path, body)
if err != nil {
// Transport / DNS failure (Raw never returns a typed HTTP error of its
// own; non-2xx responses still surface as resp != nil, err == nil).
return cmdutil.WrapHTTP(err, "%s %s", method, path)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return cmdutil.Wrapf(cmdutil.CodeNetworkError, err, "read response body")
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
code := cmdutil.ClassifyHTTPStatus(resp.StatusCode)
ce := cmdutil.NewError(code, fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody))))
if v := resp.Header.Get("Retry-After"); v != "" {
if s, perr := strconv.Atoi(v); perr == nil && s > 0 {
ce = ce.WithRetryAfter(s)
}
}
return ce
}
return emitRawBody(respBody, fopts)
}
// emitRawBody writes a server response body under the active format. JSON mode
// places the parsed body directly under envelope.data — no {status, headers,
// body} wrapper — so `--jq '.data...'` projects at the server's own shape
// (e.g. `.data.data[]` for a list endpoint) rather than a triple-nested
// `.data.body.data[]`; non-JSON decodes best-effort to a raw string. Text mode
// writes the body verbatim with a trailing newline. Shared by the single and
// paginated-fallback paths so both project identically.
func emitRawBody(body []byte, fopts *cmdutil.FormatOptions) error {
out := iostreams.IO.Out
if fopts.WantsJSON() {
var bodyAny any
if len(body) > 0 {
if err := json.Unmarshal(body, &bodyAny); err != nil {
bodyAny = string(body)
}
}
return fopts.Emit(out, bodyAny, nil)
}
if _, err := out.Write(body); err != nil {
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "write response body")
}
if len(body) > 0 && body[len(body)-1] != '\n' {
_, _ = out.Write([]byte{'\n'})
}
return nil
}
// runAPIPaginated fetches all offset-based pages for a GET request and writes
// a single merged {data, total} JSON object to stdout. If the first page
// response does not carry pagination metadata (total + page_size), the raw
// response is passed through via passThroughFallback which respects the
// --format envelope contract (same shape as runAPISingle's fallback path).
func runAPIPaginated(ctx context.Context, opts *Options, fopts *cmdutil.FormatOptions, svc Service, path string) error {
if !strings.HasPrefix(path, "/") {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, fmt.Sprintf("path must start with /: %s", path))
}
pageSize := extractPageSize(path)
if pageSize == 0 {
pageSize = 50
}
allData := []json.RawMessage{}
var lastTotal int64
page := 1
for {
curPath := setPageParam(path, page, pageSize)
resp, err := svc.Raw(ctx, http.MethodGet, curPath, nil)
if err != nil {
return cmdutil.WrapHTTP(err, "GET %s", curPath)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
code := cmdutil.ClassifyHTTPStatus(resp.StatusCode)
return cmdutil.NewError(code, fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))))
}
var pageResp struct {
Data []json.RawMessage `json:"data"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
if err := json.Unmarshal(body, &pageResp); err != nil {
// Non-JSON response on first page — fall back through the envelope.
if page == 1 {
return emitRawBody(body, fopts)
}
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument,
fmt.Sprintf("--paginate: page %d response not in expected shape: %v", page, err))
}
// Heuristic: if the first page lacks pagination metadata, treat the
// response as non-paginated and fall back through the envelope.
if page == 1 && pageResp.Total == 0 && pageResp.PageSize == 0 {
return emitRawBody(body, fopts)
}
allData = append(allData, pageResp.Data...)
lastTotal = pageResp.Total
// Termination: accumulated count (not page*pageSize) handles server-capped page sizes.
if int64(len(allData)) >= pageResp.Total || len(pageResp.Data) == 0 {
break
}
page++
}
merged := map[string]any{
"data": allData,
"total": lastTotal,
}
return fopts.Emit(iostreams.IO.Out, merged, nil)
}
// extractPageSize parses the page_size query parameter from path, returning 0
// if absent or unparseable.
func extractPageSize(path string) int {
u, err := url.Parse(path)
if err != nil {
return 0
}
if v := u.Query().Get("page_size"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
return n
}
}
return 0
}
// setPageParam rewrites the page and page_size query parameters in path,
// preserving all other query parameters.
func setPageParam(path string, page, pageSize int) string {
u, err := url.Parse(path)
if err != nil {
return path
}
q := u.Query()
q.Set("page", strconv.Itoa(page))
q.Set("page_size", strconv.Itoa(pageSize))
u.RawQuery = q.Encode()
return u.String()
}
// compile-time check: the production SDK client implements Service.
var _ Service = (*sdk.Client)(nil)
+199
View File
@@ -0,0 +1,199 @@
package api
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/prompt"
sdk "github.com/Tencent/WeKnora/client"
)
// apiDryRunFactory builds a Factory whose Client closure panics if invoked —
// dry-run must early-exit before any SDK call.
func apiDryRunFactory(t *testing.T) *cmdutil.Factory {
t.Helper()
return &cmdutil.Factory{
Client: func() (*sdk.Client, error) {
t.Fatal("dry-run path must not call Factory.Client(); SDK side effect leaked")
return nil, nil
},
Prompter: func() prompt.Prompter {
t.Fatal("dry-run path must not call Factory.Prompter(); confirm-prompt side effect leaked")
return nil
},
}
}
// TestApi_DryRunWithGet_FlagError: default-method GET + --dry-run must return
// FlagError (exit 2). The only meaningful target for --dry-run on `api` is a
// mutation method; allowing GET to silently succeed would let agents waste a
// round-trip previewing a no-op.
func TestApi_DryRunWithGet_FlagError(t *testing.T) {
iostreams.SetForTest(t)
root := withRootHarness(NewCmd(apiDryRunFactory(t)),
"/api/v1/knowledge-bases", "--dry-run")
err := root.Execute()
require.Error(t, err, "GET + --dry-run must error")
var fe *cmdutil.FlagError
require.True(t, errors.As(err, &fe), "expected *cmdutil.FlagError, got %T %v", err, err)
assert.Equal(t, 2, cmdutil.ExitCode(err), "FlagError must map to exit 2")
assert.Contains(t, err.Error(), "explicit -X POST/PUT/PATCH/DELETE",
"error message must point users to the concrete repair")
}
// TestApi_DryRunWithExplicitGet_FlagError: explicit `-X GET` + --dry-run is
// also rejected. The reject condition is "method is GET", not "method flag is
// unset"; passing -X GET explicitly must produce the same error so users
// can't bypass the guard by being verbose.
func TestApi_DryRunWithExplicitGet_FlagError(t *testing.T) {
iostreams.SetForTest(t)
root := withRootHarness(NewCmd(apiDryRunFactory(t)),
"/api/v1/knowledge-bases", "-X", "GET", "--dry-run")
err := root.Execute()
require.Error(t, err, "explicit -X GET + --dry-run must error")
var fe *cmdutil.FlagError
require.True(t, errors.As(err, &fe), "expected *cmdutil.FlagError, got %T %v", err, err)
assert.Equal(t, 2, cmdutil.ExitCode(err))
}
// TestApi_DryRunWithInputAutoPromotes_EmitsPlan: --input on its own (no
// explicit -X) must auto-promote GET → POST in the dry-run path too,
// matching the live behavior in resolveMethod. Before the fix the dry-run
// branch built `method` directly from `strings.ToUpper(opts.Method)`, which
// is empty when -X is unset, then defaulted to GET — so --input was ignored
// at preview time, producing the misleading "GET is read-only" error.
func TestApi_DryRunWithInputAutoPromotes_EmitsPlan(t *testing.T) {
out, _ := iostreams.SetForTest(t)
iostreams.IO.In = strings.NewReader(`{}`)
root := withRootHarness(NewCmd(apiDryRunFactory(t)),
"/api/v1/knowledge-bases", "--input", "-", "--dry-run", "--format", "json")
require.NoError(t, root.Execute(), "--input + --dry-run must succeed (POST auto-promotion)")
var env struct {
OK bool `json:"ok"`
Meta struct {
DryRun bool `json:"dry_run"`
Plan map[string]any `json:"plan"`
} `json:"meta"`
}
require.NoError(t, json.Unmarshal(out.Bytes(), &env), "expected valid JSON envelope, got %q", out.String())
assert.True(t, env.OK)
assert.True(t, env.Meta.DryRun)
assert.Equal(t, "api.post", env.Meta.Plan["action"], "plan.action must reflect auto-promoted POST")
assert.Equal(t, "POST", env.Meta.Plan["method"])
}
// TestApi_DryRunWithPost_EmitsPlan: POST + --dry-run + --input - must emit
// the standard envelope with action=api.post, method/path echoed, and the
// stdin body parsed as JSON under plan.body. No SDK call expected (factory
// would panic if Client() were touched).
func TestApi_DryRunWithPost_EmitsPlan(t *testing.T) {
out, _ := iostreams.SetForTest(t)
cmd := NewCmd(apiDryRunFactory(t))
// StdinReader is on Options; set it via the recovered options binding by
// running through the root harness with --input - and trusting iostreams.IO.In.
// Simplest path: write to IO.In directly via SetForTest's reader swap.
iostreams.IO.In = strings.NewReader(`{"name":"foo"}`)
root := withRootHarness(cmd,
"/api/v1/knowledge-bases", "-X", "POST", "--input", "-", "--dry-run", "--format", "json")
require.NoError(t, root.Execute(), "POST + --dry-run must succeed (exit 0) without SDK")
var env struct {
OK bool `json:"ok"`
Meta struct {
DryRun bool `json:"dry_run"`
Plan map[string]any `json:"plan"`
} `json:"meta"`
}
require.NoError(t, json.Unmarshal(out.Bytes(), &env), "expected valid JSON envelope, got %q", out.String())
assert.True(t, env.OK)
assert.True(t, env.Meta.DryRun)
assert.Equal(t, "api.post", env.Meta.Plan["action"], "plan.action must lowercase the method")
assert.Equal(t, "POST", env.Meta.Plan["method"], "plan.method must be uppercase")
assert.Equal(t, "/api/v1/knowledge-bases", env.Meta.Plan["path"])
// Body decoded as JSON object (best-effort) for downstream agent inspection.
body, ok := env.Meta.Plan["body"].(map[string]any)
require.True(t, ok, "plan.body must be a JSON object when --input is valid JSON, got %T", env.Meta.Plan["body"])
assert.Equal(t, "foo", body["name"])
}
// TestApi_FieldBody_TypedDryRun: -F/--field builds a typed JSON object body —
// true/false/null and numbers are typed, everything else stays a string — and
// the resolved body is surfaced in the dry-run plan for agent inspection.
func TestApi_FieldBody_TypedDryRun(t *testing.T) {
out, _ := iostreams.SetForTest(t)
root := withRootHarness(NewCmd(apiDryRunFactory(t)),
"/api/v1/x", "-X", "POST",
"-F", "name=Hello", "-F", "enabled=true", "-F", "count=3",
"-F", "ratio=1.5", "-F", "empty=null",
"--dry-run", "--format", "json")
require.NoError(t, root.Execute(), "-F + --dry-run must succeed without SDK")
var env struct {
Meta struct {
Plan map[string]any `json:"plan"`
} `json:"meta"`
}
require.NoError(t, json.Unmarshal(out.Bytes(), &env), "envelope: %q", out.String())
body, ok := env.Meta.Plan["body"].(map[string]any)
require.True(t, ok, "plan.body must be a JSON object, got %T", env.Meta.Plan["body"])
assert.Equal(t, "Hello", body["name"], "non-numeric stays string")
assert.Equal(t, true, body["enabled"], "true -> JSON bool")
assert.Equal(t, float64(3), body["count"], "integer -> JSON number")
assert.Equal(t, 1.5, body["ratio"], "float -> JSON number")
assert.Nil(t, body["empty"], "null -> JSON null")
}
// TestApi_FieldBody_AutoPromotesPost: -F on its own (no -X) auto-promotes
// GET -> POST, matching -d/--input body behavior.
func TestApi_FieldBody_AutoPromotesPost(t *testing.T) {
out, _ := iostreams.SetForTest(t)
root := withRootHarness(NewCmd(apiDryRunFactory(t)),
"/api/v1/x", "-F", "a=b", "--dry-run", "--format", "json")
require.NoError(t, root.Execute())
var env struct {
Meta struct {
Plan map[string]any `json:"plan"`
} `json:"meta"`
}
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
assert.Equal(t, "POST", env.Meta.Plan["method"], "-F must auto-promote GET -> POST")
}
// TestApi_FieldBody_ConflictsWithData: -F and -d are mutually exclusive — the
// conflict is reported as input.invalid_argument (exit 5), consistent with the
// existing -d/--input conflict.
func TestApi_FieldBody_ConflictsWithData(t *testing.T) {
iostreams.SetForTest(t)
root := withRootHarness(NewCmd(apiDryRunFactory(t)),
"/api/v1/x", "-X", "POST", "-F", "a=b", "-d", "{}", "--dry-run", "--format", "json")
err := root.Execute()
require.Error(t, err, "-F + -d must conflict")
var ce *cmdutil.Error
require.ErrorAs(t, err, &ce)
assert.Equal(t, cmdutil.CodeInputInvalidArgument, ce.Code)
assert.Equal(t, 5, cmdutil.ExitCode(err))
}
// TestApi_FieldBody_Malformed: a -F value without '=' (or empty key) is a typed
// input error, not a panic or a silently-dropped field.
func TestApi_FieldBody_Malformed(t *testing.T) {
iostreams.SetForTest(t)
root := withRootHarness(NewCmd(apiDryRunFactory(t)),
"/api/v1/x", "-X", "POST", "-F", "nope", "--dry-run", "--format", "json")
err := root.Execute()
require.Error(t, err)
var ce *cmdutil.Error
require.ErrorAs(t, err, &ce)
assert.Equal(t, cmdutil.CodeInputInvalidArgument, ce.Code)
assert.Contains(t, err.Error(), "key=value")
}
+605
View File
@@ -0,0 +1,605 @@
package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"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"
)
// fakeAPISvc is a test double for Service that delegates each call to a
// caller-supplied do function, giving full control over per-call responses.
type fakeAPISvc struct {
do func(method, path string, body any) (*http.Response, error)
}
func (f *fakeAPISvc) Raw(_ context.Context, method, path string, body any) (*http.Response, error) {
return f.do(method, path, body)
}
// newTestClient stands up an httptest server with the supplied handler and
// returns an *sdk.Client targeting it plus a teardown closure. The real SDK is
// used so we exercise the same Raw() code path as production (header
// injection, JSON marshalling, etc.).
func newTestClient(t *testing.T, h http.HandlerFunc) (*sdk.Client, func()) {
t.Helper()
srv := httptest.NewServer(h)
return sdk.NewClient(srv.URL), srv.Close
}
func TestAPI_GetSuccess(t *testing.T) {
out, _ := iostreams.SetForTest(t)
cli, stop := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet || r.URL.Path != "/api/v1/foo" {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"hello":"world"}`))
})
defer stop()
if err := runAPI(context.Background(), &Options{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, cli, "GET", "/api/v1/foo", false); err != nil {
t.Fatalf("runAPI: %v", err)
}
got := out.String()
if !strings.Contains(got, `"hello":"world"`) {
t.Errorf("expected raw JSON body in stdout, got %q", got)
}
if !strings.HasSuffix(got, "\n") {
t.Errorf("expected trailing newline appended, got %q", got)
}
}
func TestAPI_GetSuccess_JSON(t *testing.T) {
out, _ := iostreams.SetForTest(t)
cli, stop := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Request-Id", "req-123")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"value":42}`))
})
defer stop()
if err := runAPI(context.Background(), &Options{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, cli, "GET", "/api/v1/foo", false); err != nil {
t.Fatalf("runAPI: %v", err)
}
// The server response is passed through directly under envelope.data — no
// {status, headers, body} wrapper — so consumers project at the server's
// own depth (e.g. .data.value here).
var env struct {
OK bool `json:"ok"`
Data map[string]any `json:"data"`
}
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("decode envelope JSON: %v\n%s", err, out.String())
}
if !env.OK {
t.Errorf("envelope ok: want true, got false")
}
if v, ok := env.Data["value"]; !ok || v.(float64) != 42 {
t.Errorf("data.value: want 42 directly under .data (no .body nesting), got %v", env.Data)
}
}
// TestAPI_InlineDataBody verifies -d/--data sends an inline JSON body and
// auto-promotes the method to POST.
func TestAPI_InlineDataBody(t *testing.T) {
_, _ = iostreams.SetForTest(t)
var seenBody []byte
var seenMethod string
cli, stop := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
seenMethod = r.Method
seenBody, _ = io.ReadAll(r.Body)
_, _ = w.Write([]byte(`{"id":"new"}`))
})
defer stop()
opts := &Options{Data: `{"name":"foo"}`}
if err := runAPI(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, cli, resolveMethod(opts), "/api/v1/things", false); err != nil {
t.Fatalf("runAPI: %v", err)
}
if seenMethod != http.MethodPost {
t.Errorf("inline -d should auto-promote to POST, got %s", seenMethod)
}
if string(seenBody) != `{"name":"foo"}` {
t.Errorf("server received body %q, want the inline --data JSON", seenBody)
}
}
// TestAPI_InlineDataMalformed_RejectedAsInput pins that a malformed -d body is
// rejected as input.invalid_argument before any request.
func TestAPI_InlineDataMalformed_RejectedAsInput(t *testing.T) {
_, _ = iostreams.SetForTest(t)
called := false
cli, stop := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
called = true
_, _ = w.Write([]byte(`{}`))
})
defer stop()
opts := &Options{Data: `{bad`}
err := runAPI(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, cli, "POST", "/api/v1/things", false)
var typed *cmdutil.Error
if !errors.As(err, &typed) || typed.Code != cmdutil.CodeInputInvalidArgument {
t.Errorf("want input.invalid_argument for malformed -d, got %v", err)
}
if called {
t.Error("server must not be called with a malformed inline body")
}
}
func TestAPI_PostWithStdinInput(t *testing.T) {
_, _ = iostreams.SetForTest(t)
var seenBody []byte
var seenMethod, seenPath string
cli, stop := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
seenMethod = r.Method
seenPath = r.URL.Path
seenBody, _ = io.ReadAll(r.Body)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"new"}`))
})
defer stop()
opts := &Options{Input: "-", StdinReader: strings.NewReader(`{"name":"foo"}`)}
if err := runAPI(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, cli, "POST", "/api/v1/things", false); err != nil {
t.Fatalf("runAPI: %v", err)
}
if seenMethod != http.MethodPost || seenPath != "/api/v1/things" {
t.Errorf("server saw %s %s, want POST /api/v1/things", seenMethod, seenPath)
}
if string(seenBody) != `{"name":"foo"}` {
t.Errorf("server received body %q, want %q", seenBody, `{"name":"foo"}`)
}
}
// TestAPI_MalformedInputJSON_RejectedAsInput pins that a non-JSON --input body
// is rejected with input.invalid_argument (exit 5) at the boundary, not the
// confusing network.error (exit 7, "retryable") the SDK's marshal step
// produced. The server must never be called.
func TestAPI_MalformedInputJSON_RejectedAsInput(t *testing.T) {
_, _ = iostreams.SetForTest(t)
called := false
cli, stop := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
called = true
_, _ = w.Write([]byte(`{}`))
})
defer stop()
opts := &Options{Input: "-", StdinReader: strings.NewReader(`{bad json`)}
err := runAPI(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, cli, "POST", "/api/v1/things", false)
if err == nil {
t.Fatal("expected an error for malformed --input JSON")
}
var typed *cmdutil.Error
if !errors.As(err, &typed) || typed.Code != cmdutil.CodeInputInvalidArgument {
t.Errorf("want input.invalid_argument, got %v", err)
}
if called {
t.Error("server must not be called with a malformed body")
}
}
// TestAPI_InputFile verifies --input <file> reads the request body from disk.
func TestAPI_InputFile(t *testing.T) {
_, _ = iostreams.SetForTest(t)
tmp := filepath.Join(t.TempDir(), "body.json")
payload := `{"k":"from-file"}`
if err := os.WriteFile(tmp, []byte(payload), 0o600); err != nil {
t.Fatalf("write temp: %v", err)
}
var seenBody []byte
cli, stop := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
seenBody, _ = io.ReadAll(r.Body)
_, _ = w.Write([]byte(`{}`))
})
defer stop()
opts := &Options{Input: tmp}
if err := runAPI(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, cli, "POST", "/api/v1/x", false); err != nil {
t.Fatalf("runAPI: %v", err)
}
if string(seenBody) != payload {
t.Errorf("body from --input: got %q, want %q", seenBody, payload)
}
}
// TestAPI_InputDash_Stdin verifies the "--input -" form: the payload comes
// from opts.StdinReader (production-default iostreams.IO.In).
func TestAPI_InputDash_Stdin(t *testing.T) {
_, _ = iostreams.SetForTest(t)
var seenBody []byte
cli, stop := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
seenBody, _ = io.ReadAll(r.Body)
_, _ = w.Write([]byte(`{}`))
})
defer stop()
payload := `{"k":"from-stdin"}`
opts := &Options{Input: "-", StdinReader: strings.NewReader(payload)}
if err := runAPI(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, cli, "POST", "/api/v1/x", false); err != nil {
t.Fatalf("runAPI: %v", err)
}
if string(seenBody) != payload {
t.Errorf("body from --input -: got %q, want %q", seenBody, payload)
}
}
func TestAPI_NotFound(t *testing.T) {
_, _ = iostreams.SetForTest(t)
cli, stop := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"error":"missing"}`))
})
defer stop()
err := runAPI(context.Background(), &Options{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, cli, "GET", "/api/v1/missing", false)
if err == nil {
t.Fatal("expected error for 404")
}
if !cmdutil.IsNotFound(err) {
t.Errorf("expected resource.not_found, got %v", err)
}
}
func TestAPI_AcceptsArbitraryMethod(t *testing.T) {
_, _ = iostreams.SetForTest(t)
var seenMethod string
cli, stop := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
seenMethod = r.Method
w.WriteHeader(http.StatusOK)
})
defer stop()
for _, m := range []string{"OPTIONS", "PATCH", "TRACE", "CUSTOM"} {
t.Run(m, func(t *testing.T) {
seenMethod = ""
err := runAPI(context.Background(), &Options{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, cli, m, "/api/v1/things", false)
if err != nil {
t.Fatalf("expected method %q to be accepted, got %v", m, err)
}
if seenMethod != m {
t.Errorf("server saw method %q, want %q", seenMethod, m)
}
})
}
}
func TestAPI_EmptyMethodRejected(t *testing.T) {
_, _ = iostreams.SetForTest(t)
err := runAPI(context.Background(), &Options{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, nil, "", "/api/v1/things", false)
if err == nil {
t.Fatal("expected error for empty method")
}
var fe *cmdutil.FlagError
if !errors.As(err, &fe) {
t.Errorf("expected FlagError, got %T %v", err, err)
}
}
func TestAPI_PathWithoutSlash(t *testing.T) {
_, _ = iostreams.SetForTest(t)
err := runAPI(context.Background(), &Options{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, nil, "GET", "api/v1/things", false)
if err == nil {
t.Fatal("expected error for missing leading slash")
}
var ce *cmdutil.Error
if !asTypedError(err, &ce) || ce.Code != cmdutil.CodeInputInvalidArgument {
t.Errorf("expected input.invalid_argument, got %v", err)
}
}
// withRootHarness wraps `weknora api ...` under a synthetic root cmd that
// registers the global persistent flags (mirrors addGlobalFlags in
// cmd/root.go). Required because api's NewCmd doesn't register --yes /
// --format / --jq itself — it inherits them from root in production.
func withRootHarness(api *cobra.Command, args ...string) *cobra.Command {
root := &cobra.Command{Use: "weknora"}
pf := root.PersistentFlags()
pf.BoolP("yes", "y", false, "")
pf.String("format", "", "")
pf.StringP("jq", "q", "", "")
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), "/api/v1/knowledge-bases/kb_xxx", "-X", "DELETE")
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), "/api/v1/knowledge-bases/kb_xxx", "-X", "DELETE", "-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 {
for e := err; e != nil; {
if t, ok := e.(*cmdutil.Error); ok {
*dst = t
return true
}
type unwrapper interface{ Unwrap() error }
u, ok := e.(unwrapper)
if !ok {
return false
}
e = u.Unwrap()
}
return false
}
func TestAPI_PaginateMergesPages(t *testing.T) {
pages := [][]byte{
[]byte(`{"success":true,"data":[{"id":"1"},{"id":"2"}],"total":5,"page":1,"page_size":2}`),
[]byte(`{"success":true,"data":[{"id":"3"},{"id":"4"}],"total":5,"page":2,"page_size":2}`),
[]byte(`{"success":true,"data":[{"id":"5"}],"total":5,"page":3,"page_size":2}`),
}
idx := 0
svc := &fakeAPISvc{do: func(method, path string, _ any) (*http.Response, error) {
if idx >= len(pages) {
return nil, fmt.Errorf("too many calls; idx=%d", idx)
}
body := pages[idx]
idx++
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewReader(body)),
Header: make(http.Header),
}, nil
}}
out, _ := iostreams.SetForTest(t)
opts := &Options{}
if err := runAPI(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "GET", "/api/v1/knowledge-base?page=1&page_size=2", true); err != nil {
t.Fatalf("runAPI: %v", err)
}
var env struct {
OK bool `json:"ok"`
Data struct {
Data []map[string]string `json:"data"`
Total int `json:"total"`
} `json:"data"`
}
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("unmarshal: %v\n%s", err, out.String())
}
got := env.Data
if len(got.Data) != 5 || got.Total != 5 {
t.Errorf("got %d records (total %d), want 5/5", len(got.Data), got.Total)
}
if idx != 3 {
t.Errorf("called %d times, want 3", idx)
}
}
func TestAPI_PaginateIgnoredForPOST(t *testing.T) {
// --paginate should be a no-op for non-GET methods (no pagination
// semantic for POST/PUT/DELETE). Single call expected.
called := 0
svc := &fakeAPISvc{do: func(method, path string, _ any) (*http.Response, error) {
called++
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewReader([]byte(`{"success":true,"data":[],"total":5,"page":1,"page_size":2}`))),
Header: make(http.Header),
}, nil
}}
_, _ = iostreams.SetForTest(t)
opts := &Options{Input: "-", StdinReader: strings.NewReader(`{"name":"foo"}`)}
if err := runAPI(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "POST", "/api/v1/knowledge-base", true); err != nil {
t.Fatalf("runAPI: %v", err)
}
if called != 1 {
t.Errorf("called %d times, want 1 (POST should not paginate)", called)
}
}
func TestAPI_PaginateNoMetadataPassesThrough(t *testing.T) {
// If response doesn't look paginated (no total/page/page_size), --paginate
// should fall back to single-call envelope behavior (same shape as api without --paginate).
called := 0
svc := &fakeAPISvc{do: func(method, path string, _ any) (*http.Response, error) {
called++
hdr := make(http.Header)
hdr.Set("Content-Type", "application/json")
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewReader([]byte(`{"hello":"world"}`))),
Header: hdr,
}, nil
}}
out, _ := iostreams.SetForTest(t)
opts := &Options{}
if err := runAPI(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "GET", "/api/v1/whoami", true); err != nil {
t.Fatalf("runAPI: %v", err)
}
if called != 1 {
t.Errorf("called %d times, want 1 (non-paginated response)", called)
}
// The non-paginated fallback must emit the SAME flat shape as a single
// call: the server response directly under .data (no {status,headers,body}
// wrapper), so --paginate and single-call project identically.
var env struct {
OK bool `json:"ok"`
Data map[string]any `json:"data"`
}
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("non-paginated fallback must emit envelope JSON, unmarshal failed: %v\n%s", err, out.String())
}
if !env.OK {
t.Errorf("envelope ok: want true, got false")
}
if v, ok := env.Data["hello"]; !ok || v.(string) != "world" {
t.Errorf("fallback must pass the body through flat under .data (.data.hello), got %v", env.Data)
}
}
// TestAPI_PaginateServerCapsPageSize covers the case where the user
// requests --page_size=50 but the server caps page_size at a smaller
// value (e.g. 2). Termination must count actually-collected records
// (len(allData)) not requested-page-count (page*pageSize) — otherwise
// we'd break early and silently truncate results.
func TestAPI_PaginateServerCapsPageSize(t *testing.T) {
// User asks page_size=10; server only ever returns 2 per page (cap).
// Total = 5 records; should make 3 calls (2+2+1) and return all 5.
pages := [][]byte{
[]byte(`{"success":true,"data":[{"id":"1"},{"id":"2"}],"total":5,"page":1,"page_size":2}`),
[]byte(`{"success":true,"data":[{"id":"3"},{"id":"4"}],"total":5,"page":2,"page_size":2}`),
[]byte(`{"success":true,"data":[{"id":"5"}],"total":5,"page":3,"page_size":2}`),
}
idx := 0
svc := &fakeAPISvc{do: func(_, _ string, _ any) (*http.Response, error) {
if idx >= len(pages) {
return nil, fmt.Errorf("too many calls; idx=%d", idx)
}
body := pages[idx]
idx++
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewReader(body)),
Header: make(http.Header),
}, nil
}}
var stdout bytes.Buffer
iostreams.IO.Out = &stdout
defer func() { iostreams.IO.Out = os.Stdout }()
opts := &Options{}
// User requests page_size=10; server caps at 2 each response.
if err := runAPI(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "GET", "/api/v1/items?page=1&page_size=10", true); err != nil {
t.Fatalf("runAPI: %v", err)
}
var env struct {
OK bool `json:"ok"`
Data struct {
Data []map[string]string `json:"data"`
Total int `json:"total"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("unmarshal: %v\n%s", err, stdout.String())
}
got := env.Data
if len(got.Data) != 5 {
t.Errorf("got %d records, want 5 (server-capped page_size should not cause truncation)", len(got.Data))
}
}
// TestAPI_PUT_RequiresConfirmation pins the exit-10 write gate on the
// escape-hatch PUT path: `weknora api -X PUT /...` mutates server state the
// same way a typed `kb update` does, so it must require -y (exit 10) rather
// than silently writing. Regression: only DELETE was gated, letting an agent
// bypass the write-confirmation protocol via raw PUT/PATCH.
func TestAPI_PUT_RequiresConfirmation(t *testing.T) {
for _, method := range []string{"PUT", "PATCH"} {
t.Run(method, func(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), "/api/v1/knowledge-bases/kb_xxx", "-X", method, "-F", "name=x")
err := root.Execute()
if err == nil {
t.Fatalf("expected confirmation_required for %s without -y", method)
}
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_POST_NotGated: POST is create-shaped and, like typed `kb create`,
// intentionally ungated — it must reach the SDK without a confirmation gate.
func TestAPI_POST_NotGated(t *testing.T) {
iostreams.SetForTest(t)
called := false
cli, stop := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
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), "/api/v1/knowledge-bases", "-X", "POST", "-F", "name=x")
if err := root.Execute(); err != nil {
t.Fatalf("execute: %v", err)
}
if !called {
t.Error("POST handler not called - POST must not be gated")
}
}
+51
View File
@@ -0,0 +1,51 @@
// Package auth holds the cobra commands for authentication
// (login / logout / list / refresh / status / token).
package auth
import (
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
)
// Credential-mode tokens used in the JSON output of auth list / login /
// status / token. The string names describe the HTTP credential type rather
// than the login flow (e.g. JWT → bearer regardless of whether it was
// obtained via password or refresh) so an agent can branch directly on the
// header to construct: `Authorization: Bearer <token>` (ModeBearer) or
// `X-API-Key: <token>` (ModeAPIKey).
const (
ModeBearer = "bearer"
ModeAPIKey = "api-key"
ModeUnknown = "unknown"
)
// modeFromRefs maps the per-profile TokenRef / APIKeyRef presence to a
// canonical credential-mode token. Bearer wins when both are present -
// matches the precedence in cmdutil.buildClient.
func modeFromRefs(apiKeyRef, tokenRef string) string {
switch {
case tokenRef != "":
return ModeBearer
case apiKeyRef != "":
return ModeAPIKey
default:
return ModeUnknown
}
}
// NewCmdAuth builds the `weknora auth` command tree and registers its
// subcommands. Called from cli/cmd/root.go.
func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "auth",
Short: "Manage authentication credentials and profiles",
}
cmd.AddCommand(NewCmdLogin(f, nil))
cmd.AddCommand(NewCmdLogout(f))
cmd.AddCommand(NewCmdList(f))
cmd.AddCommand(NewCmdRefresh(f))
cmd.AddCommand(NewCmdStatus(f))
cmd.AddCommand(NewCmdToken(f))
return cmd
}
+132
View File
@@ -0,0 +1,132 @@
package auth
import (
"context"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/config"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/prompt"
"github.com/Tencent/WeKnora/cli/internal/secrets"
"github.com/Tencent/WeKnora/cli/internal/testutil"
sdk "github.com/Tencent/WeKnora/client"
)
func TestNewCmdAuth_TreeShape(t *testing.T) {
cmd := NewCmdAuth(&cmdutil.Factory{})
assert.Equal(t, "auth", cmd.Use)
subs := map[string]*cobra.Command{}
for _, c := range cmd.Commands() {
subs[c.Use] = c
}
assert.Contains(t, subs, "login")
assert.Contains(t, subs, "status")
}
func TestNewCmdLogin_FlagsRegistered(t *testing.T) {
cmd := NewCmdLogin(&cmdutil.Factory{}, nil)
// --format is a persistent root flag (v0.7); only per-command flags here.
assert.NotNil(t, cmd.Flags().Lookup("with-token"), "flag with-token missing")
// login authenticates the active profile; host/name come from config
// (created via `profile add`), so these flags are gone.
assert.Nil(t, cmd.Flags().Lookup("host"), "auth login must not declare --host (v0.9)")
assert.Nil(t, cmd.Flags().Lookup("name"), "auth login must not declare --name (v0.9)")
// --profile is the global persistent override; local registration would
// silently shadow it.
assert.Nil(t, cmd.Flags().Lookup("profile"), "auth login must not declare a local --profile flag (use the global --profile)")
}
func TestNewCmdLogin_InvokesRunF(t *testing.T) {
iostreams.SetForTest(t)
called := false
store := secrets.NewMemStore()
f := &cmdutil.Factory{
Secrets: func() (secrets.Store, error) { return store, nil },
}
cmd := NewCmdLogin(f, func(_ context.Context, opts *LoginOptions, _ *cmdutil.FormatOptions, _ *cmdutil.Factory, _ LoginService) error {
called = true
// host/profile resolve inside runLogin from the active profile;
// the runF seam just receives the parsed flags.
assert.True(t, opts.WithToken)
return nil
})
cmd.SetArgs([]string{"--with-token"})
require.NoError(t, cmd.Execute())
assert.True(t, called)
}
func TestLoginServiceFor(t *testing.T) {
assert.Nil(t, loginServiceFor(""))
assert.NotNil(t, loginServiceFor("https://x"))
}
func TestPersistAPIKey_WritesContext(t *testing.T) {
iostreams.SetForTest(t)
testutil.XDGTempDir(t)
store := secrets.NewMemStore()
f := &cmdutil.Factory{
Config: func() (*config.Config, error) { return config.Load() },
Prompter: func() prompt.Prompter { return prompt.AgentPrompter{} },
Secrets: func() (secrets.Store, error) { return store, nil },
}
// persistAPIKey MERGES into the existing active profile record (created
// via `profile add`) rather than creating it; seed it first.
require.NoError(t, config.Save(&config.Config{
CurrentProfile: "ci",
Profiles: map[string]config.Profile{"ci": {Host: "https://kb.example.com"}},
}))
opts := &LoginOptions{
Host: "https://kb.example.com",
Profile: "ci",
APIKey: "sk-zzz",
}
require.NoError(t, persistAPIKey(opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, nil))
v, _ := store.Get("ci", "api_key")
assert.Equal(t, "sk-zzz", v)
cfg, _ := f.Config()
assert.Equal(t, "ci", cfg.CurrentProfile, "active profile unchanged by login")
assert.Equal(t, "https://kb.example.com", cfg.Profiles["ci"].Host, "existing host preserved by merge")
// APIKeyRef should be the mem:// URI from the store's Ref method.
assert.Equal(t, "mem://ci/api_key", cfg.Profiles["ci"].APIKeyRef)
}
func TestPersistJWT_StoresBothTokens(t *testing.T) {
iostreams.SetForTest(t)
testutil.XDGTempDir(t)
store := secrets.NewMemStore()
f := &cmdutil.Factory{
Config: func() (*config.Config, error) { return config.Load() },
Prompter: func() prompt.Prompter { return prompt.AgentPrompter{} },
Secrets: func() (secrets.Store, error) { return store, nil },
}
opts := &LoginOptions{
Host: "https://x",
Profile: "p",
}
resp := &sdk.LoginResponse{
Token: "jwt-acc",
RefreshToken: "jwt-ref",
User: &sdk.AuthUser{Email: "a@b.c", TenantID: 7},
}
require.NoError(t, persistJWT(opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, f, resp))
a, _ := store.Get("p", "access")
r, _ := store.Get("p", "refresh")
assert.Equal(t, "jwt-acc", a)
assert.Equal(t, "jwt-ref", r)
}
func TestReadStdinTrimmed(t *testing.T) {
out, err := readStdinTrimmed(strings.NewReader(" hello \n"))
require.NoError(t, err)
assert.Equal(t, "hello", out)
out, err = readStdinTrimmed(nil)
require.NoError(t, err)
assert.Equal(t, "", out)
}
+124
View File
@@ -0,0 +1,124 @@
// Package auth — dryrun_validation_test.go asserts that --dry-run on
// auth subcommands rejects identically to the live path. Before the
// surrounding fix, validation lived in runX() and was reached only after
// HandleDryRun short-circuited, so --dry-run accepted invocations the live
// path would reject.
package auth
import (
"context"
"errors"
"testing"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/config"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/prompt"
"github.com/Tencent/WeKnora/cli/internal/secrets"
sdk "github.com/Tencent/WeKnora/client"
)
// authDryRunFactory builds a Factory whose Client closure panics if invoked —
// dry-run must early-exit before any SDK call. Config is supplied via the cfg
// argument so each test gets isolated state.
func authDryRunFactory(t *testing.T, cfg *config.Config) *cmdutil.Factory {
t.Helper()
return &cmdutil.Factory{
Config: func() (*config.Config, error) { return cfg, nil },
Client: func() (*sdk.Client, error) {
t.Fatal("dry-run path must not call Factory.Client(); SDK side effect leaked")
return nil, nil
},
Prompter: func() prompt.Prompter {
t.Fatal("dry-run path must not call Factory.Prompter(); confirm-prompt side effect leaked")
return nil
},
Secrets: func() (secrets.Store, error) { return secrets.NewMemStore(), nil },
}
}
// withRootHarnessAuth wraps an auth subcommand under a synthetic root cmd
// that registers the global persistent flags.
func withRootHarnessAuth(sub *cobra.Command, args ...string) *cobra.Command {
root := &cobra.Command{Use: "weknora"}
pf := root.PersistentFlags()
pf.BoolP("yes", "y", false, "")
pf.String("format", "", "")
pf.StringP("jq", "q", "", "")
root.AddCommand(sub)
root.SetArgs(append([]string{sub.Name()}, args...))
root.SetContext(context.Background())
root.SilenceErrors = true
root.SilenceUsage = true
return root
}
// TestAuthLogout_DryRun_RejectsNoProfiles: empty config → live path returns
// auth.unauthenticated; --dry-run must do the same.
func TestAuthLogout_DryRun_RejectsNoProfiles(t *testing.T) {
iostreams.SetForTest(t)
cfg := &config.Config{}
root := withRootHarnessAuth(NewCmdLogout(authDryRunFactory(t, cfg)),
"--dry-run", "--format", "json")
err := root.Execute()
require.Error(t, err, "dry-run must reject empty config")
var typed *cmdutil.Error
require.True(t, errors.As(err, &typed), "expected *cmdutil.Error, got %T %v", err, err)
assert.Equal(t, cmdutil.CodeAuthUnauthenticated, typed.Code)
}
// TestAuthLogout_DryRun_RejectsMissingActiveProfile: the active profile name
// (e.g. set via the global --profile) has no config entry → live path returns
// local.profile_not_found; --dry-run must do the same.
func TestAuthLogout_DryRun_RejectsMissingActiveProfile(t *testing.T) {
iostreams.SetForTest(t)
cfg := &config.Config{
CurrentProfile: "ghost", // active profile with no matching entry
Profiles: map[string]config.Profile{"prod": {Host: "https://prod"}},
}
root := withRootHarnessAuth(NewCmdLogout(authDryRunFactory(t, cfg)),
"--dry-run", "--format", "json")
err := root.Execute()
require.Error(t, err, "dry-run must reject a missing active profile")
var typed *cmdutil.Error
require.True(t, errors.As(err, &typed), "expected *cmdutil.Error, got %T %v", err, err)
assert.Equal(t, cmdutil.CodeLocalProfileNotFound, typed.Code)
}
// TestAuthRefresh_DryRun_RejectsNoCurrentProfile: no current profile → live
// path returns auth.unauthenticated; --dry-run must do the same.
func TestAuthRefresh_DryRun_RejectsNoCurrentProfile(t *testing.T) {
iostreams.SetForTest(t)
cfg := &config.Config{}
root := withRootHarnessAuth(NewCmdRefresh(authDryRunFactory(t, cfg)),
"--dry-run", "--format", "json")
err := root.Execute()
require.Error(t, err)
var typed *cmdutil.Error
require.True(t, errors.As(err, &typed))
assert.Equal(t, cmdutil.CodeAuthUnauthenticated, typed.Code)
}
// TestAuthRefresh_DryRun_RejectsMissingRefreshToken: API-key-only profile →
// live path returns input.invalid_argument ("no refresh token"); --dry-run
// must do the same.
func TestAuthRefresh_DryRun_RejectsMissingRefreshToken(t *testing.T) {
iostreams.SetForTest(t)
cfg := &config.Config{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{
"prod": {Host: "https://prod", APIKeyRef: "k"},
},
}
root := withRootHarnessAuth(NewCmdRefresh(authDryRunFactory(t, cfg)),
"--dry-run", "--format", "json")
err := root.Execute()
require.Error(t, err)
var typed *cmdutil.Error
require.True(t, errors.As(err, &typed))
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
}
+94
View File
@@ -0,0 +1,94 @@
package auth
import (
"fmt"
"sort"
"text/tabwriter"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/format"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/output"
)
type ListOptions struct{}
// authListFields enumerates the fields surfaced for `--format json` discovery on
// `auth list`. Each entry is a per-profile summary row.
var authListFields = []string{
"name", "host", "user", "mode", "current",
}
type listEntry struct {
Name string `json:"name"`
Host string `json:"host"`
User string `json:"user,omitempty"`
Mode string `json:"mode"` // ModeBearer / ModeAPIKey / ModeUnknown
Current bool `json:"current"`
}
// NewCmdList builds `weknora auth list`. Per-host enumeration: render one
// row per registered profile, marking the active one. Reads only
// ~/.config/weknora/config.yaml - no network, no keyring touch.
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "List configured authentication profiles",
Long: `Show every configured profile (name, host, user, mode, current). Read-only; no network or keyring access.`,
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, _ []string) error {
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
return runList(fopts, f)
},
}
cmdutil.AddFormatFlag(cmd, authListFields...)
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "list configured profiles and their credential mode (bearer / api-key)",
Examples: []string{"weknora auth list", "weknora auth list --jq '.data[].name'"},
Output: "envelope.data is an array of profiles with name, host, mode, and which is active",
})
return cmd
}
func runList(fopts *cmdutil.FormatOptions, f *cmdutil.Factory) error {
cfg, err := f.Config()
if err != nil {
return err
}
entries := make([]listEntry, 0, len(cfg.Profiles))
for name, c := range cfg.Profiles {
entries = append(entries, listEntry{
Name: name,
Host: c.Host,
User: c.User,
Mode: modeFromRefs(c.APIKeyRef, c.TokenRef),
Current: name == cfg.CurrentProfile,
})
}
sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name })
if fopts.WantsJSON() {
meta := &output.Meta{Count: output.IntPtr(len(entries))}
return fopts.Emit(iostreams.IO.Out, entries, meta)
}
if len(entries) == 0 {
fmt.Fprintln(iostreams.IO.Out, "No profiles configured. Run `weknora auth login` to create one.")
return nil
}
tw := tabwriter.NewWriter(iostreams.IO.Out, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, " NAME\tHOST\tUSER\tMODE")
for _, e := range entries {
marker := " "
if e.Current {
marker = "* "
}
fmt.Fprintf(tw, "%s%s\t%s\t%s\t%s\n", marker, e.Name, e.Host, format.DashIfEmpty(e.User), e.Mode)
}
return tw.Flush()
}
+86
View File
@@ -0,0 +1,86 @@
package auth
import (
"encoding/json"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/config"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
)
func newListFactory(cfg *config.Config) *cmdutil.Factory {
return &cmdutil.Factory{
Config: func() (*config.Config, error) { return cfg, nil },
}
}
func TestList_TextRender(t *testing.T) {
out, _ := iostreams.SetForTest(t)
cfg := &config.Config{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{
"prod": {Host: "https://prod", User: "alice@example.com", TokenRef: "keychain://prod/access"},
"staging": {Host: "https://staging", APIKeyRef: "keychain://staging/api_key"},
},
}
require.NoError(t, runList(&cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newListFactory(cfg)))
got := out.String()
// One row per profile, current marked with `*`.
assert.Contains(t, got, "* prod")
assert.Contains(t, got, " staging")
// Mode column.
assert.Contains(t, got, ModeBearer)
assert.Contains(t, got, ModeAPIKey)
// Sorted alphabetically - prod after staging? No: "prod" < "staging".
assert.Less(t, strings.Index(got, "prod"), strings.Index(got, "staging"),
"profiles should render sorted by name")
}
func TestList_Empty(t *testing.T) {
out, _ := iostreams.SetForTest(t)
require.NoError(t, runList(&cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newListFactory(&config.Config{})))
assert.Contains(t, out.String(), "No profiles configured")
}
func TestList_JSON_BareArray(t *testing.T) {
out, _ := iostreams.SetForTest(t)
cfg := &config.Config{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{
"prod": {Host: "https://prod", User: "alice", TokenRef: "tok"},
"staging": {Host: "https://staging", APIKeyRef: "key"},
},
}
require.NoError(t, runList(&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, newListFactory(cfg)))
var env struct {
OK bool `json:"ok"`
Data []listEntry `json:"data"`
}
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
assert.True(t, env.OK)
got := env.Data
require.Len(t, got, 2)
// Sorted: prod < staging.
assert.Equal(t, "prod", got[0].Name)
assert.True(t, got[0].Current)
assert.Equal(t, ModeBearer, got[0].Mode)
assert.Equal(t, "staging", got[1].Name)
assert.False(t, got[1].Current)
assert.Equal(t, ModeAPIKey, got[1].Mode)
}
func TestModeFromRefs(t *testing.T) {
// Hand-edited config with neither ref set - surface "unknown" rather
// than pretending the profile is a valid login.
assert.Equal(t, ModeUnknown, modeFromRefs("", ""))
assert.Equal(t, ModeBearer, modeFromRefs("", "tok"))
assert.Equal(t, ModeAPIKey, modeFromRefs("key", ""))
assert.Equal(t, ModeBearer, modeFromRefs("key", "tok"), "JWT wins when both set")
}
+400
View File
@@ -0,0 +1,400 @@
package auth
import (
"context"
"fmt"
"io"
"strings"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/config"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/secrets"
sdk "github.com/Tencent/WeKnora/client"
)
// authLoginFields enumerates the fields surfaced for `--format json` discovery on
// `auth login`. The post-login summary has no token values - they stay in the
// keyring; agents who need to verify the credential should re-run
// `auth status`.
var authLoginFields = []string{
"profile", "host", "mode", "email", "tenant_id",
}
// LoginOptions is the configuration captured from flags + prompts. Host and
// Profile are resolved from the active profile in config (not from flags) -
// `auth login` authenticates the already-existing active profile, created
// beforehand with `weknora profile add <name> --host <h> --use`.
type LoginOptions struct {
Host string // resolved from the active profile's Host in config
Profile string // resolved active profile name (honors global --profile)
WithToken bool // --with-token: read api key from stdin instead of prompting password
APIKey string // populated by --with-token from stdin
Email string
Password string
StdinReader io.Reader // override for tests
}
// LoginService is the narrow SDK surface auth login depends on.
// *sdk.Client satisfies it implicitly via the new Login(ctx, LoginRequest)
// signature added in client/auth.go.
type LoginService interface {
Login(ctx context.Context, req sdk.LoginRequest) (*sdk.LoginResponse, error)
}
// 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.
//
// Returns the resolved user (used to populate Profile.User / TenantID at
// rest, so later `auth list` reflects who owns the key).
type apiKeyValidator func(ctx context.Context, host, apiKey string) (*sdk.AuthUser, error)
// defaultAPIKeyValidator builds a one-shot SDK client with the supplied key
// and calls /auth/me. Side-effect-free; no persistence.
var defaultAPIKeyValidator apiKeyValidator = func(ctx context.Context, host, apiKey string) (*sdk.AuthUser, error) {
resp, err := sdk.NewClient(host, sdk.WithAPIKey(apiKey)).GetCurrentUser(ctx)
if err != nil {
return nil, err
}
if !resp.Success || resp.Data.User == nil {
return nil, fmt.Errorf("server rejected the API key (no user returned)")
}
return resp.Data.User, nil
}
// NewCmdLogin builds the `weknora auth login` command. runF is the testable
// entrypoint (left nil for production; see cli/cmd/auth/login_test.go).
func NewCmdLogin(f *cmdutil.Factory, runF func(context.Context, *LoginOptions, *cmdutil.FormatOptions, *cmdutil.Factory, LoginService) error) *cobra.Command {
opts := &LoginOptions{}
cmd := &cobra.Command{
Use: "login",
Short: "Authenticate the active profile against its WeKnora server",
Long: `Authenticate the active profile by email + password (interactive prompt) or
pipe an API key with --with-token.
` + "`auth login`" + ` operates on the active profile (override with the global
--profile flag). Create the profile first with
` + "`weknora profile add <name> --host <h> --use`" + `, then run ` + "`weknora auth login`" + `.
Credentials are persisted to the OS keyring when available; otherwise to a
0600 file under $XDG_CONFIG_HOME/weknora/secrets.`,
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, args []string) error {
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
if opts.StdinReader == nil {
opts.StdinReader = iostreams.IO.In
}
if runF != nil {
// Test seam: the injected runF supplies its own service and
// (where needed) seeds opts.Host / opts.Profile itself.
return runF(c.Context(), opts, fopts, f, nil)
}
return runLogin(c.Context(), opts, fopts, f, nil)
},
}
cmd.Flags().BoolVar(&opts.WithToken, "with-token", false, "Read an API key from stdin instead of prompting for password")
cmdutil.AddFormatFlag(cmd, authLoginFields...)
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "authenticate the active profile; --with-token reads an API key from stdin (non-interactive)",
Examples: []string{`echo "$WEKNORA_API_KEY" | weknora auth login --with-token`},
Output: "envelope.data is {profile, host, mode, email, tenant_id} on success (email matches `auth status`)",
Warnings: []string{
"a profile must exist and be active first (`weknora profile add <n> --host <url> --use`)",
"password login is interactive-only (no flags) — agents must use --with-token with the key piped to stdin",
},
})
return cmd
}
// resolveActiveProfile loads config and resolves the active profile (honoring
// the global --profile override) plus its persisted Host. `auth login`
// authenticates an already-existing profile; profile creation is `profile
// add`'s job. Returns typed errors when no active profile is configured or
// the profile lacks a host.
func resolveActiveProfile(f *cmdutil.Factory) (name, host string, err error) {
// f.Config() already folds the global --profile / WEKNORA_PROFILE override
// into cfg.CurrentProfile (same source f.ActiveProfile reads), so one load
// resolves the active profile — matches logout/refresh.
cfg, err := f.Config()
if err != nil {
return "", "", err
}
active := cfg.CurrentProfile
if active == "" {
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 {
return "", "", cmdutil.NewError(cmdutil.CodeLocalProfileNotFound,
fmt.Sprintf("active profile %q not found in config", active))
}
if prof.Host == "" {
return "", "", cmdutil.NewError(cmdutil.CodeLocalConfigCorrupt,
fmt.Sprintf("profile %q has no host", active))
}
return active, prof.Host, nil
}
// loginServiceFor returns a fresh SDK client targeting host. login.go cannot
// reuse Factory.Client because that closure requires an existing profile.
func loginServiceFor(host string) LoginService {
if host == "" {
return nil
}
return sdk.NewClient(host)
}
func runLogin(ctx context.Context, opts *LoginOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Factory, svc LoginService) error {
// Resolve the target profile + host from the active profile in config.
// `auth login` no longer takes --host / --name; it authenticates the
// already-existing active profile (override via the global --profile).
name, host, err := resolveActiveProfile(f)
if err != nil {
return err
}
opts.Profile = name
opts.Host = host
if svc == nil {
svc = loginServiceFor(host)
}
if err := validateHost(opts.Host); err != nil {
return err
}
// Reject shell-metacharacter / path-like names up-front so opts.Profile
// stays safe to interpolate into the keyring namespace, config.yaml
// keys, and envelope.error.retry_argv. Matches `profile add`.
if err := cmdutil.ValidateProfileName(opts.Profile); err != nil {
return err
}
if opts.WithToken {
key, err := readStdinTrimmed(opts.StdinReader)
if err != nil {
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "read stdin")
}
if key == "" {
return cmdutil.NewError(cmdutil.CodeInputMissingFlag, "--with-token requires an API key piped to stdin")
}
opts.APIKey = key
// Validate against the server before persisting so a typo'd /
// 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 {
// Transport errors (connection refused, DNS failure) must not be
// surfaced as auth.bad_credential — the key may be fine but the
// host is unreachable. Classify via WrapHTTP so network errors
// get CodeNetworkError and the hint points at `weknora doctor`.
if cmdutil.ClassifyHTTPError(err) == cmdutil.CodeNetworkError {
return cmdutil.Wrapf(cmdutil.CodeNetworkError, err, "validate API key: check host reachability")
}
return cmdutil.Wrapf(cmdutil.CodeAuthBadCredential, err, "validate API key")
}
return persistAPIKey(opts, fopts, f, user)
}
// Interactive: prompt for email + password. svc is always set by now
// (loginServiceFor returns non-nil because resolveActiveProfile guarantees
// a non-empty host).
if opts.Email == "" || opts.Password == "" {
p := f.Prompter()
if opts.Email == "" {
email, err := p.Input("Email", "")
if err != nil {
return cmdutil.Wrapf(cmdutil.CodeInputMissingFlag, err, "email prompt")
}
opts.Email = email
}
if opts.Password == "" {
pw, err := p.Password("Password")
if err != nil {
return cmdutil.Wrapf(cmdutil.CodeInputMissingFlag, err, "password prompt")
}
opts.Password = pw
}
}
resp, err := svc.Login(ctx, sdk.LoginRequest{Email: opts.Email, Password: opts.Password})
if err != nil {
// Transport errors (connection refused, DNS failure) must not be
// surfaced as auth.bad_credential — credentials may be fine but the
// host is unreachable. Classify so network errors get CodeNetworkError.
if cmdutil.ClassifyHTTPError(err) == cmdutil.CodeNetworkError {
return cmdutil.Wrapf(cmdutil.CodeNetworkError, err, "login: check host reachability")
}
return cmdutil.Wrapf(cmdutil.CodeAuthBadCredential, err, "login")
}
if !resp.Success || resp.Token == "" {
return cmdutil.NewError(cmdutil.CodeAuthBadCredential, fmt.Sprintf("login refused: %s", resp.Message))
}
return persistJWT(opts, fopts, f, resp)
}
// persistAPIKey saves the --with-token API key and writes the profile.
// user is the principal returned by /auth/me during pre-persist validation,
// used to populate Profile.User / TenantID so `auth list` reflects who
// owns the key.
func persistAPIKey(opts *LoginOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Factory, user *sdk.AuthUser) error {
store, err := f.Secrets()
if err != nil {
return err
}
warnOnFileFallback(store)
if err := store.Set(opts.Profile, "api_key", opts.APIKey); err != nil {
return cmdutil.Wrapf(cmdutil.CodeLocalKeychainDenied, err, "save api key")
}
// Switching credential mode: this profile is now an API-key profile, so
// any leftover JWT refs (from a prior password login) are stale. Set the
// api-key ref and clear the token/refresh refs; keep Host + existing
// User/TenantID (merged in saveProfileRef via applyUser).
mutate := func(prof *config.Profile) {
prof.APIKeyRef = store.Ref(opts.Profile, "api_key")
prof.TokenRef = ""
prof.RefreshRef = ""
applyUser(prof, user)
}
return saveProfileRef(opts, fopts, f, mutate, ModeAPIKey, user)
}
// persistJWT saves access + refresh tokens and writes the profile.
func persistJWT(opts *LoginOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Factory, resp *sdk.LoginResponse) error {
store, err := f.Secrets()
if err != nil {
return err
}
warnOnFileFallback(store)
if err := store.Set(opts.Profile, "access", resp.Token); err != nil {
return cmdutil.Wrapf(cmdutil.CodeLocalKeychainDenied, err, "save access token")
}
if resp.RefreshToken != "" {
if err := store.Set(opts.Profile, "refresh", resp.RefreshToken); err != nil {
return cmdutil.Wrapf(cmdutil.CodeLocalKeychainDenied, err, "save refresh token")
}
}
// Switching to a JWT profile: set token refs and drop any stale api-key
// ref; keep Host + existing User/TenantID (merged in saveProfileRef via applyUser).
mutate := func(prof *config.Profile) {
prof.TokenRef = store.Ref(opts.Profile, "access")
prof.RefreshRef = store.Ref(opts.Profile, "refresh")
prof.APIKeyRef = ""
applyUser(prof, resp.User)
}
return saveProfileRef(opts, fopts, f, mutate, ModeBearer, resp.User)
}
// applyUser overwrites prof.User / prof.TenantID only when the server actually
// returned them. A nil user (or empty email) must NOT wipe an existing User
// that was set during `profile add` or a prior login.
func applyUser(prof *config.Profile, user *sdk.AuthUser) {
if user == nil {
return
}
if user.Email != "" {
prof.User = user.Email
}
if user.TenantID != 0 {
prof.TenantID = user.TenantID
}
}
// 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
// Email is the authenticated principal's email. Named "email" (not
// "user") so the identity field matches `auth status`, which also
// exposes it as `email` — one key for one concept across both commands.
Email string `json:"email,omitempty"`
TenantID uint64 `json:"tenant_id,omitempty"`
}
// saveProfileRef MERGES the new credential refs into the EXISTING profile
// record and prints success. The active profile already exists (created via
// `profile add`, carrying Host and possibly User/TenantID); mutate sets only
// the credential refs + any server-returned user, so re-login never clobbers
// the host or wipes an existing user. cfg.CurrentProfile is left untouched -
// `auth login` authenticates the already-active profile, it doesn't switch.
func saveProfileRef(opts *LoginOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Factory, mutate func(*config.Profile), mode string, user *sdk.AuthUser) error {
cfg, err := f.Config()
if err != nil {
return err
}
if cfg.Profiles == nil {
cfg.Profiles = map[string]config.Profile{}
}
prof := cfg.Profiles[opts.Profile] // existing record (Host, User, TenantID)
mutate(&prof)
cfg.Profiles[opts.Profile] = prof
if err := config.Save(cfg); err != nil {
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "save config")
}
if fopts.WantsJSON() {
// mode reflects the credential actually stored (passed by the caller),
// not whether the server happened to return a user — an API-key login
// validates via /auth/me and DOES get a user, so deriving mode from
// user != nil would mislabel it as bearer.
result := loginResult{Profile: opts.Profile, Host: opts.Host, Mode: mode}
if user != nil {
result.Email = user.Email
result.TenantID = user.TenantID
}
return fopts.Emit(iostreams.IO.Out, result, nil)
}
who := opts.Profile
if user != nil {
who = user.Email
}
fmt.Fprintf(iostreams.IO.Out, "✓ Logged in to %s as %s (profile=%s)\n", opts.Host, who, opts.Profile)
return nil
}
// validateHost rejects empty / non-http URLs early so we surface a clean
// flag error instead of failing inside the SDK transport.
func validateHost(host string) error {
_, err := cmdutil.NormalizeHost(host)
return err
}
// warnOnFileFallback prints a one-shot stderr advisory when the secrets
// store fell back to the plaintext 0600 file backend (keychain unavailable
// - typical on headless CI, WSL without DBus, agent containers). Helps
// users notice that credentials are NOT in the OS keychain before they're
// surprised by it later. doctor's credential_storage check carries the
// same info but agents that bypass doctor would otherwise miss it.
func warnOnFileFallback(store secrets.Store) {
if _, isFile := store.(*secrets.FileStore); !isFile {
return
}
fmt.Fprintln(iostreams.IO.Err, "warning: OS keychain unavailable - credentials will be saved to a 0600 file under $XDG_CONFIG_HOME/weknora/secrets/.")
fmt.Fprintln(iostreams.IO.Err, " install / unlock the keyring (or use `weknora doctor` to inspect) for OS-backed storage.")
}
// readStdinTrimmed reads all of r and returns the result with surrounding
// whitespace stripped. Empty result is returned as-is for the caller to
// validate.
func readStdinTrimmed(r io.Reader) (string, error) {
if r == nil {
return "", nil
}
b, err := io.ReadAll(r)
if err != nil {
return "", err
}
return strings.TrimSpace(string(b)), nil
}
+264
View File
@@ -0,0 +1,264 @@
package auth
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/config"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/prompt"
"github.com/Tencent/WeKnora/cli/internal/secrets"
"github.com/Tencent/WeKnora/cli/internal/testutil"
sdk "github.com/Tencent/WeKnora/client"
)
// fakeLoginService captures the email/password it received.
type fakeLoginService struct {
resp *sdk.LoginResponse
err error
got struct{ email, password string }
}
func (f *fakeLoginService) Login(_ context.Context, req sdk.LoginRequest) (*sdk.LoginResponse, error) {
f.got.email = req.Email
f.got.password = req.Password
return f.resp, f.err
}
// scriptedPrompter satisfies prompt.Prompter with predetermined values.
type scriptedPrompter struct{ email, password string }
func (s scriptedPrompter) Input(string, string) (string, error) { return s.email, nil }
func (s scriptedPrompter) Password(string) (string, error) { return s.password, nil }
func (s scriptedPrompter) Confirm(string, bool) (bool, error) { return true, nil }
func newTestFactoryWithConfig(t *testing.T, p prompt.Prompter) (*cmdutil.Factory, *secrets.MemStore) {
t.Helper()
testutil.XDGTempDir(t)
store := secrets.NewMemStore()
return &cmdutil.Factory{
Config: func() (*config.Config, error) { return config.Load() },
Client: func() (*sdk.Client, error) { panic("client") },
Prompter: func() prompt.Prompter { return p },
Secrets: func() (secrets.Store, error) { return store, nil },
}, store
}
// seedActiveProfile writes an active profile to the (XDG-isolated) config so
// runLogin can resolve host/name from it - `auth login` authenticates the
// already-existing active profile rather than creating one. Returns nothing;
// callers read back via f.Config() to assert merge behavior.
func seedActiveProfile(t *testing.T, name string, prof config.Profile) {
t.Helper()
cfg := &config.Config{
CurrentProfile: name,
Profiles: map[string]config.Profile{name: prof},
}
require.NoError(t, config.Save(cfg))
}
func TestRunLogin_PasswordMode(t *testing.T) {
iostreams.SetForTest(t)
f, store := newTestFactoryWithConfig(t, scriptedPrompter{email: "a@b.c", password: "secret"})
seedActiveProfile(t, "prod", config.Profile{Host: "https://kb.example.com"})
svc := &fakeLoginService{resp: &sdk.LoginResponse{
Success: true,
Token: "jwt-access",
User: &sdk.AuthUser{ID: "u1", Email: "a@b.c", TenantID: 7},
}}
require.NoError(t, runLogin(context.Background(), &LoginOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, svc))
assert.Equal(t, "a@b.c", svc.got.email)
assert.Equal(t, "secret", svc.got.password)
got, _ := store.Get("prod", "access")
assert.Equal(t, "jwt-access", got)
cfg, _ := f.Config()
assert.Equal(t, "https://kb.example.com", cfg.Profiles["prod"].Host, "host must be preserved from the seeded profile")
}
func TestRunLogin_WithToken(t *testing.T) {
iostreams.SetForTest(t)
f, store := newTestFactoryWithConfig(t, prompt.AgentPrompter{})
seedActiveProfile(t, "ci", config.Profile{Host: "https://kb.example.com"})
restore := stubAPIKeyValidator(func(_ context.Context, _, _ string) (*sdk.AuthUser, error) {
return &sdk.AuthUser{ID: "u1", Email: "ci@example.com", TenantID: 7}, nil
})
defer restore()
opts := &LoginOptions{
WithToken: true,
StdinReader: strings.NewReader(" sk-1234 \n"),
}
require.NoError(t, runLogin(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, nil))
got, _ := store.Get("ci", "api_key")
assert.Equal(t, "sk-1234", got)
cfg, _ := f.Config()
assert.Equal(t, "ci@example.com", cfg.Profiles["ci"].User, "validator-returned user should be persisted")
assert.Equal(t, uint64(7), cfg.Profiles["ci"].TenantID)
assert.Equal(t, "https://kb.example.com", cfg.Profiles["ci"].Host, "host preserved from seeded profile")
}
func TestRunLogin_WithToken_JSONReportsAPIKeyMode(t *testing.T) {
out, _ := iostreams.SetForTest(t)
f, _ := newTestFactoryWithConfig(t, prompt.AgentPrompter{})
seedActiveProfile(t, "ci", config.Profile{Host: "https://kb.example.com"})
restore := stubAPIKeyValidator(func(_ context.Context, _, _ string) (*sdk.AuthUser, error) {
// API-key validation hits /auth/me, which DOES return a user. mode
// must still be reported as api-key (derived from credential type,
// not from "did the server return a user").
return &sdk.AuthUser{ID: "u1", Email: "ci@example.com", TenantID: 7}, nil
})
defer restore()
opts := &LoginOptions{WithToken: true, StdinReader: strings.NewReader("sk-1234")}
require.NoError(t, runLogin(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, f, nil))
var env struct {
Data struct {
Mode string `json:"mode"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
assert.Equal(t, "api-key", env.Data.Mode, "api-key login must report mode=api-key, not bearer")
}
func TestRunLogin_WithToken_ServerRejects(t *testing.T) {
iostreams.SetForTest(t)
f, _ := newTestFactoryWithConfig(t, prompt.AgentPrompter{})
seedActiveProfile(t, "ci", config.Profile{Host: "https://kb.example.com"})
restore := stubAPIKeyValidator(func(_ context.Context, _, _ string) (*sdk.AuthUser, error) {
// Use the SDK-format HTTP error message so ClassifyHTTPError detects
// this as an HTTP 401, not a transport/network failure.
return nil, errors.New("HTTP error 401: invalid api key")
})
defer restore()
opts := &LoginOptions{
WithToken: true,
StdinReader: strings.NewReader("sk-bad"),
}
err := runLogin(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, nil)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeAuthBadCredential, typed.Code,
"server-side rejection must surface as auth.bad_credential, not persist the key")
}
// stubAPIKeyValidator swaps defaultAPIKeyValidator for the test and returns
// a restore func to defer.
func stubAPIKeyValidator(fn apiKeyValidator) func() {
saved := defaultAPIKeyValidator
defaultAPIKeyValidator = fn
return func() { defaultAPIKeyValidator = saved }
}
func TestRunLogin_WithToken_Empty(t *testing.T) {
iostreams.SetForTest(t)
f, _ := newTestFactoryWithConfig(t, prompt.AgentPrompter{})
seedActiveProfile(t, "ci", config.Profile{Host: "https://kb.example.com"})
// Validator must NOT be called when stdin is empty - verify by setting
// a panic-on-call sentinel.
restore := stubAPIKeyValidator(func(_ context.Context, _, _ string) (*sdk.AuthUser, error) {
t.Fatal("validator should not be called on empty stdin")
return nil, nil
})
defer restore()
opts := &LoginOptions{
WithToken: true,
StdinReader: strings.NewReader(""),
}
err := runLogin(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "input.missing_flag")
}
func TestRunLogin_BadHost(t *testing.T) {
iostreams.SetForTest(t)
f, _ := newTestFactoryWithConfig(t, prompt.AgentPrompter{})
// A non-http host stored on the active profile must be rejected by the
// post-resolution validateHost check (config corruption guard).
seedActiveProfile(t, "bad", config.Profile{Host: "ftp://nope"})
err := runLogin(context.Background(), &LoginOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "input.invalid_argument")
}
func TestRunLogin_LoginRefused(t *testing.T) {
iostreams.SetForTest(t)
f, _ := newTestFactoryWithConfig(t, scriptedPrompter{email: "a@b.c", password: "x"})
seedActiveProfile(t, "p", config.Profile{Host: "https://x"})
svc := &fakeLoginService{resp: &sdk.LoginResponse{Success: false, Message: "bad password"}}
err := runLogin(context.Background(), &LoginOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, svc)
require.Error(t, err)
assert.Contains(t, err.Error(), "auth.bad_credential")
}
func TestValidateHost(t *testing.T) {
require.NoError(t, validateHost("https://kb.example.com"))
require.NoError(t, validateHost("http://localhost:8080"))
require.Error(t, validateHost(""))
require.Error(t, validateHost("ftp://x"))
require.Error(t, validateHost("not a url"))
}
// TestLogin_NoHostNoName asserts the --host / --name flags are gone: `auth
// login` now authenticates the active profile (create it via `profile add`).
func TestLogin_NoHostNoName(t *testing.T) {
iostreams.SetForTest(t)
f, _ := newTestFactoryWithConfig(t, prompt.AgentPrompter{})
cmd := NewCmdLogin(f, nil)
assert.Nil(t, cmd.Flags().Lookup("host"), "--host flag must be removed")
assert.Nil(t, cmd.Flags().Lookup("name"), "--name flag must be removed")
assert.NotNil(t, cmd.Flags().Lookup("with-token"), "--with-token must remain")
}
// TestLogin_NoActiveProfile_Errors asserts that with no active profile
// configured, login returns a clear auth.unauthenticated error pointing the
// user at `profile add`.
func TestLogin_NoActiveProfile_Errors(t *testing.T) {
iostreams.SetForTest(t)
f, _ := newTestFactoryWithConfig(t, prompt.AgentPrompter{})
// No seedActiveProfile: empty config.
err := runLogin(context.Background(), &LoginOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, nil)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeAuthUnauthenticated, typed.Code)
assert.Contains(t, err.Error(), "profile add")
}
// TestLogin_UsesActiveProfileHost asserts login persists credentials under the
// active profile using that profile's stored host, and that an existing User
// is NOT clobbered when the server (API-key validator) returns it unchanged.
func TestLogin_UsesActiveProfileHost(t *testing.T) {
iostreams.SetForTest(t)
f, store := newTestFactoryWithConfig(t, prompt.AgentPrompter{})
// Pre-seed "prod" with a host AND a user that must survive re-login.
seedActiveProfile(t, "prod", config.Profile{Host: "https://prod.example.com", User: "existing@owner.com"})
restore := stubAPIKeyValidator(func(_ context.Context, host, _ string) (*sdk.AuthUser, error) {
assert.Equal(t, "https://prod.example.com", host, "validator must be called with the active profile's host")
// Server returns nil user (e.g. /auth/me gave no email) - must NOT
// wipe the existing User.
return &sdk.AuthUser{}, nil
})
defer restore()
opts := &LoginOptions{WithToken: true, StdinReader: strings.NewReader("sk-prod")}
require.NoError(t, runLogin(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, nil))
got, _ := store.Get("prod", "api_key")
assert.Equal(t, "sk-prod", got, "api key must be stored under the active profile")
cfg, _ := f.Config()
prof := cfg.Profiles["prod"]
assert.Equal(t, "https://prod.example.com", prof.Host, "host must be preserved, not clobbered")
assert.Equal(t, "existing@owner.com", prof.User, "existing User must NOT be wiped when server returns none")
assert.NotEmpty(t, prof.APIKeyRef, "api-key ref must be set")
assert.Equal(t, "prod", cfg.CurrentProfile, "active profile unchanged")
}
+197
View File
@@ -0,0 +1,197 @@
package auth
import (
"fmt"
"strings"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/config"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/secrets"
)
type LogoutOptions struct {
All bool // --all: clear every profile
Yes bool // sourced from the global -y/--yes persistent flag
DryRun bool
}
// authLogoutFields enumerates the fields surfaced for `--format json` discovery
// on `auth logout`. The result is the list of profile names that were
// logged out.
var authLogoutFields = []string{"removed"}
// logoutResult is the typed payload emitted under data.
type logoutResult struct {
Removed []string `json:"removed"`
}
// NewCmdLogout builds `weknora auth logout`. Clears stored credentials
// (keyring + file fallback) and removes the profile entry from config.yaml.
// No server-side revocation - local-only credential clear.
func NewCmdLogout(f *cmdutil.Factory) *cobra.Command {
opts := &LogoutOptions{}
cmd := &cobra.Command{
Use: "logout",
Short: "Remove stored credentials for a profile",
Long: `Clear keyring + file-fallback secrets for one profile (or all of
them with --all) and drop the profile entry from ~/.config/weknora/config.yaml.
Note: this does NOT revoke the credential server-side - for API keys, you
must rotate them in the server UI; for JWT, the token will continue to be
accepted until it expires.`,
Example: ` weknora auth logout # active profile
weknora --profile staging auth logout # specific profile
weknora auth logout --all`,
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, _ []string) error {
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
opts.Yes, _ = c.Flags().GetBool("yes")
// Pure-local validation runs before the dry-run gate so --dry-run
// rejects identically to the live path. Same typed errors as
// runLogout (kept there for direct-call callers).
cfg, cfgErr := f.Config()
if cfgErr != nil {
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 {
return err
}
if opts.DryRun {
planArgs := map[string]any{"profile": cfg.CurrentProfile}
if opts.All {
planArgs = map[string]any{"all": true}
}
if handled, err := cmdutil.HandleDryRun(c, true, cmdutil.DryRunPlan{
Action: "auth.logout",
Args: planArgs,
}); handled {
return err
}
}
return runLogout(opts, fopts, f)
},
}
cmd.Flags().BoolVar(&opts.All, "all", false, "Log out of every configured profile")
cmdutil.AddFormatFlag(cmd, authLogoutFields...)
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); 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",
"weknora auth logout --all",
},
Output: "envelope.data is {removed: [profile names cleared]}",
Warnings: []string{
"Requires explicit user approval (exit 10 / input.confirmation_required); never auto-add -y.",
"auth logout clears local credentials for this profile; server-side session continues until expiry.",
},
})
return cmd
}
func runLogout(opts *LogoutOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Factory) error {
cfg, err := f.Config()
if err != nil {
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")
}
targets, err := pickLogoutTargets(opts, cfg)
if err != nil {
return err
}
// Destructive-write protocol: confirm before clearing credentials.
scope := fmt.Sprintf("%d profile(s) [%s]", len(targets), strings.Join(targets, ", "))
retryCmd := []string{"weknora", "auth", "logout", "-y"}
if opts.All {
retryCmd = []string{"weknora", "auth", "logout", "--all", "-y"}
}
if err := cmdutil.ConfirmDestructive(f.Prompter(), opts.Yes, fopts.WantsJSON(), "delete", "auth credentials", scope, "auth.logout", retryCmd); err != nil {
return err
}
store, err := f.Secrets()
if err != nil {
return err
}
for _, name := range targets {
clearProfileSecrets(store, cfg.Profiles[name], name)
// 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")
}
if fopts.WantsJSON() {
return fopts.Emit(iostreams.IO.Out, logoutResult{Removed: targets}, nil)
}
fmt.Fprintf(iostreams.IO.Out, "✓ Logged out of %d profile(s): %s\n", len(targets), strings.Join(targets, ", "))
return nil
}
// pickLogoutTargets resolves the set of profiles to clear from flags + config.
func pickLogoutTargets(opts *LogoutOptions, cfg *config.Config) ([]string, error) {
if opts.All {
names := make([]string, 0, len(cfg.Profiles))
for n := range cfg.Profiles {
names = append(names, n)
}
return names, nil
}
name := cfg.CurrentProfile
if name == "" {
return nil, cmdutil.NewError(cmdutil.CodeInputMissingFlag,
"no active profile set; use the global --profile <profile> or --all")
}
if _, ok := cfg.Profiles[name]; !ok {
return nil, cmdutil.NewError(cmdutil.CodeLocalProfileNotFound,
fmt.Sprintf("profile %q not found in config", name))
}
return []string{name}, nil
}
// clearProfileSecrets best-effort deletes every secret slot the profile
// references. Errors are swallowed because a missing secret is a no-op
// (tested in keyring_test.go) - we don't want a stale ref to block logout.
func clearProfileSecrets(store secrets.Store, c config.Profile, name string) {
if c.TokenRef != "" {
_ = store.Delete(name, "access")
}
if c.RefreshRef != "" {
_ = store.Delete(name, "refresh")
}
if c.APIKeyRef != "" {
_ = store.Delete(name, "api_key")
}
}
+165
View File
@@ -0,0 +1,165 @@
package auth
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/config"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/prompt"
"github.com/Tencent/WeKnora/cli/internal/secrets"
)
// newLogoutFactory builds a Factory whose Config closure mutates the supplied
// cfg in place - runLogout writes back via config.Save which touches disk, so
// tests use t.Setenv("XDG_CONFIG_HOME", t.TempDir()) at the call site to
// isolate the on-disk file.
func newLogoutFactory(t *testing.T, cfg *config.Config, store secrets.Store) *cmdutil.Factory {
t.Helper()
return &cmdutil.Factory{
Config: func() (*config.Config, error) { return cfg, nil },
Secrets: func() (secrets.Store, error) { return store, nil },
Prompter: func() prompt.Prompter { return &prompt.AgentPrompter{} },
}
}
func isolateConfig(t *testing.T) {
t.Helper()
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
}
func TestLogout_CurrentProfile(t *testing.T) {
isolateConfig(t)
_, _ = iostreams.SetForTest(t)
store := secrets.NewMemStore()
require.NoError(t, store.Set("prod", "access", "jwt-prod"))
require.NoError(t, store.Set("prod", "refresh", "rfr-prod"))
require.NoError(t, store.Set("staging", "api_key", "sk-staging"))
cfg := &config.Config{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{
"prod": {Host: "https://prod", TokenRef: store.Ref("prod", "access"), RefreshRef: store.Ref("prod", "refresh")},
"staging": {Host: "https://staging", APIKeyRef: store.Ref("staging", "api_key")},
},
}
require.NoError(t, runLogout(&LogoutOptions{Yes: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newLogoutFactory(t, cfg, store)))
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.
if _, err := store.Get("prod", "access"); err == nil {
t.Error("prod access secret should be deleted")
}
if v, _ := store.Get("staging", "api_key"); v != "sk-staging" {
t.Errorf("staging secret unexpectedly cleared: %q", v)
}
}
// TestLogout_ActiveProfileViaOverride exercises targeting a non-default
// profile. Production resolves this via the global --profile flag (which
// rewrites cfg.CurrentProfile in Factory.Config); here we simulate that by
// setting CurrentProfile=staging directly, since runLogout's single target is
// the active profile.
func TestLogout_ActiveProfileViaOverride(t *testing.T) {
isolateConfig(t)
_, _ = iostreams.SetForTest(t)
store := secrets.NewMemStore()
require.NoError(t, store.Set("staging", "api_key", "sk-staging"))
cfg := &config.Config{
CurrentProfile: "staging", // global --profile staging resolves to this
Profiles: map[string]config.Profile{
"prod": {Host: "https://prod", TokenRef: "tok"},
"staging": {Host: "https://staging", APIKeyRef: store.Ref("staging", "api_key")},
},
}
require.NoError(t, runLogout(&LogoutOptions{Yes: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newLogoutFactory(t, cfg, store)))
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")
}
func TestLogout_All(t *testing.T) {
isolateConfig(t)
_, _ = iostreams.SetForTest(t)
store := secrets.NewMemStore()
cfg := &config.Config{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{
"prod": {Host: "https://prod"},
"staging": {Host: "https://staging"},
},
}
require.NoError(t, runLogout(&LogoutOptions{All: true, Yes: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newLogoutFactory(t, cfg, store)))
// --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) {
isolateConfig(t)
_, _ = iostreams.SetForTest(t)
cfg := &config.Config{}
err := runLogout(&LogoutOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newLogoutFactory(t, cfg, secrets.NewMemStore()))
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeAuthUnauthenticated, typed.Code)
}
// TestLogout_ActiveProfileMissing covers the corrupt-config case where the
// active profile name (e.g. set via --profile ghost) has no matching entry.
func TestLogout_ActiveProfileMissing(t *testing.T) {
isolateConfig(t)
_, _ = iostreams.SetForTest(t)
cfg := &config.Config{
CurrentProfile: "ghost", // points at a non-existent entry
Profiles: map[string]config.Profile{"prod": {Host: "https://prod"}},
}
err := runLogout(&LogoutOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newLogoutFactory(t, cfg, secrets.NewMemStore()))
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeLocalProfileNotFound, typed.Code)
}
func TestLogout_NoCurrentNoFlag(t *testing.T) {
isolateConfig(t)
_, _ = iostreams.SetForTest(t)
cfg := &config.Config{
Profiles: map[string]config.Profile{"prod": {Host: "https://prod"}},
}
err := runLogout(&LogoutOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newLogoutFactory(t, cfg, secrets.NewMemStore()))
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeInputMissingFlag, typed.Code)
}
// TestLogout_NoNameFlag asserts the --name flag is gone (logout now targets
// the active profile / global --profile) while --all stands alone.
func TestLogout_NoNameFlag(t *testing.T) {
isolateConfig(t)
_, _ = iostreams.SetForTest(t)
cfg := &config.Config{Profiles: map[string]config.Profile{"a": {}}}
cmd := NewCmdLogout(newLogoutFactory(t, cfg, secrets.NewMemStore()))
assert.Nil(t, cmd.Flags().Lookup("name"), "--name flag must be removed")
assert.NotNil(t, cmd.Flags().Lookup("all"), "--all flag must remain")
}
+169
View File
@@ -0,0 +1,169 @@
package auth
import (
"context"
"fmt"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
type RefreshOptions struct {
DryRun bool
}
// authRefreshFields enumerates the fields surfaced for `--format json` discovery
// on `auth refresh`. Token values are intentionally omitted - see refreshResult.
var authRefreshFields = []string{"profile"}
// refreshResult is the typed payload emitted under data on success. Token
// values are intentionally NOT included - emitting them would leak secrets
// into stdout / agent transcripts. Agents needing to verify the new token
// can re-run `weknora auth status` (live API check).
type refreshResult struct {
Profile string `json:"profile"`
}
// NewCmdRefresh builds `weknora auth refresh`. Renews the JWT access
// token by spending the stored refresh_token via POST /auth/refresh -
// the standard OAuth refresh-token grant.
//
// API-key profiles are rejected - they have no refresh semantic;
// rotate the key via the server UI instead.
func NewCmdRefresh(f *cmdutil.Factory) *cobra.Command {
opts := &RefreshOptions{}
cmd := &cobra.Command{
Use: "refresh",
Short: "Renew the JWT access token using the stored refresh token",
Long: `Reads the refresh token previously stored by ` + "`weknora auth login`" + ` and
exchanges it for a new access + refresh token pair via POST /api/v1/auth/refresh.
Both new tokens replace the existing entries in the OS keyring.
API-key profiles are rejected with input.invalid_argument - they have no
refresh semantic. Rotate the key in the server UI instead.`,
Example: ` weknora auth refresh # refresh the active profile
weknora --profile staging auth refresh # refresh a specific profile`,
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, _ []string) error {
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
// Pure-local validation runs before the dry-run gate so --dry-run
// rejects identically to the live path. Same typed errors as
// runRefresh (kept there for direct-call callers).
cfg, cfgErr := f.Config()
if cfgErr != nil {
return cfgErr
}
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")
}
prof, ok := cfg.Profiles[name]
if !ok {
return cmdutil.NewError(cmdutil.CodeLocalProfileNotFound,
fmt.Sprintf("profile not found: %s", name))
}
if prof.Host == "" {
return cmdutil.NewError(cmdutil.CodeLocalConfigCorrupt,
fmt.Sprintf("profile %q has no host", name))
}
if prof.RefreshRef == "" {
hint := "api-key profiles can't be refreshed - rotate the key in the server UI and run `weknora auth login --with-token`"
if prof.APIKeyRef == "" {
hint = "no refresh token stored - run `weknora auth login` to authenticate"
}
return &cmdutil.Error{
Code: cmdutil.CodeInputInvalidArgument,
Message: fmt.Sprintf("profile %q has no refresh token", name),
Hint: hint,
}
}
if handled, err := cmdutil.HandleDryRun(c, opts.DryRun, cmdutil.DryRunPlan{
Action: "auth.refresh",
Args: map[string]any{},
}); handled {
return err
}
return runRefresh(c.Context(), opts, fopts, f, defaultRefresher)
},
}
cmdutil.AddFormatFlag(cmd, authRefreshFields...)
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
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
}
// defaultRefresher constructs a fresh, unauthenticated SDK client targeting
// host - the /auth/refresh endpoint reads the refresh token from the body,
// so no bearer / api-key header is needed.
func defaultRefresher(host string) cmdutil.Refresher {
return sdk.NewClient(host)
}
func runRefresh(ctx context.Context, opts *RefreshOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Factory, refresherFor func(host string) cmdutil.Refresher) error {
cfg, err := f.Config()
if err != nil {
return err
}
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")
}
c, ok := cfg.Profiles[name]
if !ok {
return cmdutil.NewError(cmdutil.CodeLocalProfileNotFound,
fmt.Sprintf("profile not found: %s", name))
}
if c.Host == "" {
return cmdutil.NewError(cmdutil.CodeLocalConfigCorrupt,
fmt.Sprintf("profile %q has no host", name))
}
if c.RefreshRef == "" {
hint := "api-key profiles can't be refreshed - rotate the key in the server UI and run `weknora auth login --with-token`"
if c.APIKeyRef == "" {
hint = "no refresh token stored - run `weknora auth login` to authenticate"
}
return &cmdutil.Error{
Code: cmdutil.CodeInputInvalidArgument,
Message: fmt.Sprintf("profile %q has no refresh token", name),
Hint: hint,
}
}
store, err := f.Secrets()
if err != nil {
return err
}
if _, err := cmdutil.RefreshAndPersist(ctx, store, refresherFor(c.Host), name); err != nil {
return err
}
if fopts.WantsJSON() {
return fopts.Emit(iostreams.IO.Out, refreshResult{Profile: name}, nil)
}
fmt.Fprintf(iostreams.IO.Out, "✓ Refreshed access token for profile %s\n", name)
return nil
}
+237
View File
@@ -0,0 +1,237 @@
package auth
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/config"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/prompt"
"github.com/Tencent/WeKnora/cli/internal/secrets"
"github.com/Tencent/WeKnora/cli/internal/testutil"
sdk "github.com/Tencent/WeKnora/client"
)
// fakeRefreshService scripts a RefreshToken response.
type fakeRefreshService struct {
resp *sdk.RefreshTokenResponse
err error
gotTok string
}
func (f *fakeRefreshService) RefreshToken(_ context.Context, refreshToken string) (*sdk.RefreshTokenResponse, error) {
f.gotTok = refreshToken
return f.resp, f.err
}
// stubSvc returns a closure conforming to the refresherFor signature; it
// ignores host since the fake doesn't talk to the network.
func stubSvc(s cmdutil.Refresher) func(string) cmdutil.Refresher {
return func(string) cmdutil.Refresher { return s }
}
func newRefreshFactory(t *testing.T, cfg *config.Config, store *secrets.MemStore) *cmdutil.Factory {
t.Helper()
testutil.XDGTempDir(t)
require.NoError(t, config.Save(cfg))
return &cmdutil.Factory{
Config: func() (*config.Config, error) { return config.Load() },
Client: func() (*sdk.Client, error) { panic("client") },
Prompter: func() prompt.Prompter { return prompt.AgentPrompter{} },
Secrets: func() (secrets.Store, error) { return store, nil },
}
}
func TestRefresh_Happy(t *testing.T) {
iostreams.SetForTest(t)
store := secrets.NewMemStore()
require.NoError(t, store.Set("prod", "access", "old-access"))
require.NoError(t, store.Set("prod", "refresh", "old-refresh"))
cfg := &config.Config{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{
"prod": {
Host: "https://kb.example.com",
TokenRef: "mem://prod/access",
RefreshRef: "mem://prod/refresh",
User: "alice@example.com",
},
},
}
f := newRefreshFactory(t, cfg, store)
svc := &fakeRefreshService{resp: &sdk.RefreshTokenResponse{
Success: true,
AccessToken: "new-access",
RefreshToken: "new-refresh",
}}
require.NoError(t, runRefresh(context.Background(), &RefreshOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, stubSvc(svc)))
assert.Equal(t, "old-refresh", svc.gotTok, "must pass stored refresh token to SDK")
gotAccess, _ := store.Get("prod", "access")
gotRefresh, _ := store.Get("prod", "refresh")
assert.Equal(t, "new-access", gotAccess)
assert.Equal(t, "new-refresh", gotRefresh)
}
// TestRefresh_ActiveProfileViaOverride exercises refreshing a non-default
// profile. Production resolves this via the global --profile flag (which
// rewrites cfg.CurrentProfile in Factory.Config); here we set
// CurrentProfile=staging directly, since runRefresh's target is the active
// profile.
func TestRefresh_ActiveProfileViaOverride(t *testing.T) {
iostreams.SetForTest(t)
store := secrets.NewMemStore()
require.NoError(t, store.Set("staging", "refresh", "stg-refresh"))
cfg := &config.Config{
CurrentProfile: "staging", // global --profile staging resolves to this
Profiles: map[string]config.Profile{
"prod": {Host: "https://prod", TokenRef: "mem://prod/access", RefreshRef: "mem://prod/refresh"},
"staging": {Host: "https://stg", TokenRef: "mem://staging/access", RefreshRef: "mem://staging/refresh"},
},
}
f := newRefreshFactory(t, cfg, store)
svc := &fakeRefreshService{resp: &sdk.RefreshTokenResponse{
Success: true, AccessToken: "new-stg-access", RefreshToken: "new-stg-refresh",
}}
require.NoError(t, runRefresh(context.Background(), &RefreshOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, stubSvc(svc)))
assert.Equal(t, "stg-refresh", svc.gotTok, "active profile staging must be refreshed")
// prod (non-active) is untouched
if v, _ := store.Get("prod", "access"); v != "" {
t.Errorf("prod must not have been touched, got %q", v)
}
}
// TestRefresh_NoNameFlag asserts the --name flag is gone; refresh targets the
// active profile (override via the global --profile).
func TestRefresh_NoNameFlag(t *testing.T) {
iostreams.SetForTest(t)
cfg := &config.Config{Profiles: map[string]config.Profile{"a": {Host: "https://a"}}}
f := newRefreshFactory(t, cfg, secrets.NewMemStore())
cmd := NewCmdRefresh(f)
assert.Nil(t, cmd.Flags().Lookup("name"), "--name flag must be removed")
}
func TestRefresh_NoCurrentProfile(t *testing.T) {
iostreams.SetForTest(t)
f := newRefreshFactory(t, &config.Config{}, secrets.NewMemStore())
err := runRefresh(context.Background(), &RefreshOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, stubSvc(&fakeRefreshService{}))
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeAuthUnauthenticated, typed.Code)
}
func TestRefresh_APIKeyContext(t *testing.T) {
iostreams.SetForTest(t)
store := secrets.NewMemStore()
require.NoError(t, store.Set("ci", "api_key", "sk-123"))
cfg := &config.Config{
CurrentProfile: "ci",
Profiles: map[string]config.Profile{"ci": {Host: "https://kb", APIKeyRef: "mem://ci/api_key"}},
}
f := newRefreshFactory(t, cfg, store)
err := runRefresh(context.Background(), &RefreshOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, stubSvc(&fakeRefreshService{}))
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
assert.Contains(t, typed.Hint, "api-key", "hint should explain api-key profiles cannot be refreshed")
}
func TestRefresh_NoRefreshTokenStored(t *testing.T) {
iostreams.SetForTest(t)
cfg := &config.Config{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{
"prod": {Host: "https://kb", TokenRef: "mem://prod/access", RefreshRef: "mem://prod/refresh"},
},
}
// MemStore is empty - RefreshRef points to a slot that doesn't exist.
f := newRefreshFactory(t, cfg, secrets.NewMemStore())
err := runRefresh(context.Background(), &RefreshOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, stubSvc(&fakeRefreshService{}))
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeAuthTokenExpired, typed.Code)
assert.Contains(t, typed.Hint, "auth login")
}
func TestRefresh_ServerRefused(t *testing.T) {
iostreams.SetForTest(t)
store := secrets.NewMemStore()
require.NoError(t, store.Set("prod", "refresh", "stale-refresh"))
cfg := &config.Config{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{"prod": {Host: "https://kb", TokenRef: "mem://prod/access", RefreshRef: "mem://prod/refresh"}},
}
f := newRefreshFactory(t, cfg, store)
svc := &fakeRefreshService{resp: &sdk.RefreshTokenResponse{Success: false, Message: "refresh token expired"}}
err := runRefresh(context.Background(), &RefreshOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, stubSvc(svc))
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeAuthTokenExpired, typed.Code)
assert.Contains(t, typed.Hint, "auth login")
// stored access must NOT have been overwritten with empty
if v, _ := store.Get("prod", "access"); v == "" {
// Was never set in this test, that's fine - main thing is no panic.
_ = v
}
}
func TestRefresh_TransportError(t *testing.T) {
iostreams.SetForTest(t)
store := secrets.NewMemStore()
require.NoError(t, store.Set("prod", "refresh", "ok-refresh"))
cfg := &config.Config{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{"prod": {Host: "https://kb", TokenRef: "mem://prod/access", RefreshRef: "mem://prod/refresh"}},
}
f := newRefreshFactory(t, cfg, store)
svc := &fakeRefreshService{err: errors.New("connection reset")}
err := runRefresh(context.Background(), &RefreshOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, stubSvc(svc))
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
// network/transport classified as network.error (mirrors auth login's
// CodeAuthBadCredential mapping pattern; here we keep network errors
// distinct since auth/refresh treats them as retryable).
assert.Equal(t, cmdutil.CodeNetworkError, typed.Code)
}
func TestRefresh_JSONOutput(t *testing.T) {
out, _ := iostreams.SetForTest(t)
store := secrets.NewMemStore()
require.NoError(t, store.Set("prod", "refresh", "ok-refresh"))
cfg := &config.Config{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{"prod": {Host: "https://kb", TokenRef: "mem://prod/access", RefreshRef: "mem://prod/refresh"}},
}
f := newRefreshFactory(t, cfg, store)
svc := &fakeRefreshService{resp: &sdk.RefreshTokenResponse{Success: true, AccessToken: "a", RefreshToken: "r"}}
require.NoError(t, runRefresh(context.Background(), &RefreshOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, f, stubSvc(svc)))
body := out.String()
// payload must not leak the actual token values.
assert.NotContains(t, body, "ok-refresh", "output must not leak refresh token")
assert.NotContains(t, body, "\"a\"", "output must not leak the new access token")
assert.NotContains(t, body, "\"r\"", "output must not leak the new refresh token")
// must mention the profile name so agents can confirm what was refreshed
assert.True(t, strings.Contains(body, "prod"), "output should reference the refreshed profile")
// v0.7 envelope: ok:true is expected
var env struct {
OK bool `json:"ok"`
}
require.NoError(t, json.Unmarshal([]byte(body), &env))
assert.True(t, env.OK, "envelope.ok must be true")
}
+140
View File
@@ -0,0 +1,140 @@
package auth
import (
"context"
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
// authStatusFields enumerates the fields surfaced for `--format json` discovery
// on `auth status`. Single-resource shape: filter applies to data itself.
var authStatusFields = []string{
"profile", "host", "auth_source", "user_id", "username", "email", "is_active",
"can_access_all_tenants", "tenant_id", "tenant_name",
}
// StatusService is the narrow SDK surface auth status depends on.
type StatusService interface {
GetCurrentUser(ctx context.Context) (*sdk.CurrentUserResponse, error)
}
// statusResult is the typed payload emitted by `--format json`. Mirrors the
// SDK AuthUser + AuthTenant projection so agents can branch on
// can_access_all_tenants (cross-tenant admin) and is_active (disabled
// account) without a second round-trip.
type statusResult struct {
Profile string `json:"profile"`
Host string `json:"host,omitempty"`
AuthSource string `json:"auth_source,omitempty"`
UserID string `json:"user_id,omitempty"`
Username string `json:"username,omitempty"`
Email string `json:"email,omitempty"`
IsActive bool `json:"is_active,omitempty"`
CanAccessAllTenants bool `json:"can_access_all_tenants,omitempty"`
TenantID uint64 `json:"tenant_id,omitempty"`
TenantName string `json:"tenant_name,omitempty"`
}
// NewCmdStatus builds the `weknora auth status` command.
func NewCmdStatus(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "status",
Short: "Show the active profile, principal, and token state",
Long: `Live-check the active credential by calling /auth/me. Reports the user
and tenant the server resolves the credential to.
Exits with auth.unauthenticated when the token is invalid or missing - run
` + "`weknora auth login`" + ` (or ` + "`auth refresh`" + ` for JWT profiles) to recover.
For JWT profiles the SDK transparently refreshes on 401, so this command
usually only surfaces a hard auth failure.`,
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, args []string) error {
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
cli, err := f.Client()
if err != nil {
return err
}
return runStatus(c.Context(), fopts, f, cli)
},
}
cmdutil.AddFormatFlag(cmd, authStatusFields...)
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "show the active profile, the authenticated principal, and token state",
Examples: []string{"weknora auth status", "weknora auth status --jq .data.tenant_id"},
Output: "envelope.data is {profile, host, auth_source, user_id, username, email, tenant_id, tenant_name, ...}; auth_source is 'profile + keyring' or a stateless env credential; exit 3 if unauthenticated",
})
return cmd
}
func runStatus(ctx context.Context, fopts *cmdutil.FormatOptions, f *cmdutil.Factory, svc StatusService) error {
if svc == nil {
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated, "no SDK client available; run `weknora auth login`")
}
resp, err := svc.GetCurrentUser(ctx)
if err != nil {
return cmdutil.WrapHTTP(err, "fetch current user")
}
user := resp.Data.User
tenant := resp.Data.Tenant
cfg, err := f.Config()
if err != nil {
return err
}
// Effective host + auth source. Stateless env credentials
// (WEKNORA_TOKEN/WEKNORA_API_KEY) override the profile + keyring for the
// client, so report the host they authenticate against (WEKNORA_HOST) and
// that they are in effect — not the bypassed config profile's host.
host := ""
if c, ok := cfg.Profiles[cfg.CurrentProfile]; ok {
host = c.Host
}
authSource := "profile + keyring"
if active, kind := cmdutil.EnvCredential(); active {
authSource = kind + " env (stateless)"
if h := strings.TrimSpace(os.Getenv("WEKNORA_HOST")); h != "" {
host = h
}
}
if fopts.WantsJSON() {
result := statusResult{Profile: cfg.CurrentProfile, Host: host, AuthSource: authSource}
if user != nil {
result.UserID = user.ID
result.Username = user.Username
result.Email = user.Email
result.IsActive = user.IsActive
result.CanAccessAllTenants = user.CanAccessAllTenants
result.TenantID = user.TenantID
}
if tenant != nil {
result.TenantName = tenant.Name
}
return fopts.Emit(iostreams.IO.Out, result, nil)
}
fmt.Fprintf(iostreams.IO.Out, "profile: %s\n", cfg.CurrentProfile)
fmt.Fprintf(iostreams.IO.Out, "auth_source: %s\n", authSource)
fmt.Fprintf(iostreams.IO.Out, "host: %s\n", host)
if user != nil {
fmt.Fprintf(iostreams.IO.Out, "user: %s (%s)\n", user.Email, user.ID)
fmt.Fprintf(iostreams.IO.Out, "tenant: %d", user.TenantID)
if tenant != nil {
fmt.Fprintf(iostreams.IO.Out, " (%s)", tenant.Name)
}
fmt.Fprintln(iostreams.IO.Out)
}
return nil
}
+112
View File
@@ -0,0 +1,112 @@
package auth
import (
"context"
"encoding/json"
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/config"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/prompt"
"github.com/Tencent/WeKnora/cli/internal/testutil"
sdk "github.com/Tencent/WeKnora/client"
)
type fakeStatusService struct {
resp *sdk.CurrentUserResponse
err error
}
func (f *fakeStatusService) GetCurrentUser(_ context.Context) (*sdk.CurrentUserResponse, error) {
return f.resp, f.err
}
func newCurrentUserResponse(user *sdk.AuthUser, tenant *sdk.AuthTenant) *sdk.CurrentUserResponse {
r := &sdk.CurrentUserResponse{Success: true}
r.Data.User = user
r.Data.Tenant = tenant
return r
}
func TestRunStatus_TextOutput(t *testing.T) {
out, _ := iostreams.SetForTest(t)
testutil.XDGTempDir(t)
require.NoError(t, config.Save(&config.Config{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{
"prod": {Host: "https://kb.example.com", TenantID: 7},
},
}))
f := &cmdutil.Factory{
Config: func() (*config.Config, error) { return config.Load() },
Prompter: func() prompt.Prompter { return prompt.AgentPrompter{} },
}
svc := &fakeStatusService{
resp: newCurrentUserResponse(
&sdk.AuthUser{ID: "u1", Email: "alice@example.com", TenantID: 7},
&sdk.AuthTenant{ID: 7, Name: "Acme"},
),
}
require.NoError(t, runStatus(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, svc))
got := out.String()
assert.Contains(t, got, "profile: prod")
assert.Contains(t, got, "auth_source: profile + keyring")
assert.Contains(t, got, "host: https://kb.example.com")
assert.Contains(t, got, "alice@example.com")
assert.Contains(t, got, "Acme")
}
func TestRunStatus_JSONOutput(t *testing.T) {
out, _ := iostreams.SetForTest(t)
testutil.XDGTempDir(t)
require.NoError(t, config.Save(&config.Config{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{"prod": {Host: "https://x"}},
}))
f := &cmdutil.Factory{Config: func() (*config.Config, error) { return config.Load() }}
svc := &fakeStatusService{resp: newCurrentUserResponse(&sdk.AuthUser{ID: "u1", Email: "a@b.c", TenantID: 7}, nil)}
require.NoError(t, runStatus(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, f, svc))
got := out.String()
var env struct {
OK bool `json:"ok"`
Data map[string]any `json:"data"`
}
require.NoError(t, json.Unmarshal([]byte(got), &env), "expected valid JSON envelope, got: %q", got)
assert.True(t, env.OK, "envelope.ok must be true")
assert.Equal(t, "prod", env.Data["profile"], "profile field should be prod")
assert.Contains(t, got, `"email":"a@b.c"`)
}
func TestRunStatus_NoSDKClient(t *testing.T) {
iostreams.SetForTest(t)
err := runStatus(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &cmdutil.Factory{}, nil)
require.Error(t, err)
assert.True(t, cmdutil.IsAuthError(err))
}
func TestRunStatus_SDKError_Transport(t *testing.T) {
iostreams.SetForTest(t)
testutil.XDGTempDir(t)
require.NoError(t, config.Save(&config.Config{CurrentProfile: "p", Profiles: map[string]config.Profile{"p": {Host: "https://x"}}}))
f := &cmdutil.Factory{Config: func() (*config.Config, error) { return config.Load() }}
err := runStatus(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, &fakeStatusService{err: assert.AnError})
require.Error(t, err)
// Non-HTTP errors (DNS / TCP) are transport problems, not auth problems -
// classify network.error so retry logic / exit code 7 / IsTransient apply.
assert.True(t, cmdutil.IsTransient(err), "expected transient/network classification, got %v", err)
}
func TestRunStatus_SDKError_HTTP401(t *testing.T) {
iostreams.SetForTest(t)
testutil.XDGTempDir(t)
require.NoError(t, config.Save(&config.Config{CurrentProfile: "p", Profiles: map[string]config.Profile{"p": {Host: "https://x"}}}))
f := &cmdutil.Factory{Config: func() (*config.Config, error) { return config.Load() }}
err := runStatus(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, &fakeStatusService{err: errors.New("HTTP error 401: invalid token")})
require.Error(t, err)
assert.True(t, cmdutil.IsAuthError(err))
}
+183
View File
@@ -0,0 +1,183 @@
package auth
import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
)
// authTokenFields lists fields surfaced in `--help` as a hint for `--jq`
// projection. Single-resource shape: emits the bare token object directly.
var authTokenFields = []string{"token", "mode", "profile"}
type tokenResult struct {
Token string `json:"token"`
Mode string `json:"mode"` // ModeBearer (JWT) or ModeAPIKey
Profile string `json:"profile"`
}
// NewCmdToken builds `weknora auth token`. Prints the active profile's
// credential to stdout for use in shell pipelines, e.g.
//
// WEKNORA_TOKEN=$(weknora auth token)
// curl -H "Authorization: Bearer $WEKNORA_TOKEN" ... # JWT mode
// curl -H "X-API-Key: $WEKNORA_TOKEN" ... # api-key mode
//
// The user is responsible for constructing the appropriate header -
// `auth list` shows which mode each profile uses.
//
// Default output: raw token on stdout, no trailing newline (clean $(...)).
// `--format json[=fields]` emits a bare {token, mode, profile} object.
func NewCmdToken(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "token",
Short: "Print the active profile's credential to stdout",
Long: `Print the active profile's credential to stdout, with no trailing
newline, suitable for shell command substitution.
The credential is the long-lived API key (mode: api-key) or the access JWT
(mode: bearer), depending on how the profile was created. Run ` + "`weknora auth list`" + `
to see which mode each profile uses, and construct the matching HTTP header:
Authorization: Bearer <token> # bearer mode
X-API-Key: <token> # api-key mode
` + "`--profile <name>`" + ` (global flag) selects a non-active profile to read from.`,
Example: ` WEKNORA_TOKEN=$(weknora auth token)
weknora auth token --profile staging
weknora auth token --format json`,
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, _ []string) error {
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
// `auth token` is a scalar scripting helper (WEKNORA_TOKEN=$(...)),
// so it defaults to the raw token — overriding the global json
// default, the same way `doc download` streams raw bytes. Explicit
// --format json / WEKNORA_FORMAT=json still emit the
// {token,mode,profile} envelope.
fopts.FromEnv()
if fopts.Mode == "" {
// --jq implies JSON (it filters the envelope); without it,
// default to the raw token for clean $(...) capture.
if fopts.JQ != "" {
fopts.Mode = cmdutil.FormatJSON
} else {
fopts.Mode = cmdutil.FormatText
}
}
return runToken(f, fopts)
},
}
cmdutil.AddFormatFlag(cmd, authTokenFields...)
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "print the active profile's raw credential to stdout for shell capture",
Examples: []string{
"WEKNORA_TOKEN=$(weknora auth token)",
"weknora auth token --profile staging",
"weknora auth token --format json",
},
Output: "raw token on stdout (no envelope, no trailing newline) by default; --format json emits {token, mode, profile}",
Warnings: []string{
"default output is the bare token, NOT the JSON envelope — it is a scripting helper for shell capture",
},
})
return cmd
}
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
}
profileName := cfg.CurrentProfile
if f.ProfileOverride != "" {
profileName = f.ProfileOverride
}
if profileName == "" {
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated,
"no active profile configured")
}
ctx, ok := cfg.Profiles[profileName]
if !ok {
return cmdutil.NewError(cmdutil.CodeLocalProfileNotFound,
fmt.Sprintf("profile %q not found", profileName))
}
store, err := f.Secrets()
if err != nil {
return cmdutil.Wrapf(cmdutil.CodeLocalKeychainDenied, err, "init secrets store")
}
// Resolve the stored credential. Prefer bearer (JWT) when both refs
// are present - JWT is the more capable mode and what `buildClient`
// uses for the Authorization header (see factory.go buildClient).
var token, mode string
switch {
case ctx.TokenRef != "":
v, ferr := cmdutil.LoadSecret(store, profileName, "access")
if ferr != nil {
return ferr
}
token, mode = v, ModeBearer
case ctx.APIKeyRef != "":
v, ferr := cmdutil.LoadSecret(store, profileName, "api_key")
if ferr != nil {
return ferr
}
token, mode = v, ModeAPIKey
default:
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated,
fmt.Sprintf("profile %q has no stored credential; run `weknora auth login`", profileName))
}
if token == "" {
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated,
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: profile}, nil)
}
// No trailing newline - clean $(weknora auth token) substitution.
fmt.Fprint(iostreams.IO.Out, token)
// Defensive hint to stderr when stdout is an interactive terminal:
// the user likely didn't mean to display the secret on screen.
// stderr-only so scripts (always non-TTY) are unaffected. Mode-specific
// because api-key tokens are long-lived and rotation is the only
// recourse on leak - bearer tokens self-expire via refresh.
if iostreams.IO.IsStdoutTTY() {
fmt.Fprintln(iostreams.IO.Err)
fmt.Fprintln(iostreams.IO.Err, "hint: pipe to $(weknora auth token) to capture; this terminal scrollback now contains the secret")
if mode == ModeAPIKey {
fmt.Fprintln(iostreams.IO.Err, "note: api-key credentials are long-lived - rotate via your auth provider if exposed (no `auth refresh` path)")
}
}
return nil
}
+354
View File
@@ -0,0 +1,354 @@
package auth
import (
"encoding/json"
"strings"
"testing"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/config"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/secrets"
)
// tokenTestFactory wires a config + in-memory secrets store the same way
// the production Factory does, so runToken exercises the real LoadSecret
// path.
func tokenTestFactory(t *testing.T, cfg *config.Config, store *secrets.MemStore) *cmdutil.Factory {
t.Helper()
f := &cmdutil.Factory{
Config: func() (*config.Config, error) { return cfg, nil },
Secrets: func() (secrets.Store, error) { return store, nil },
}
return f
}
// TestAuthToken_DefaultIsRawToken locks the scripting contract: with no
// explicit --format, `weknora auth token` emits the raw token (so
// WEKNORA_TOKEN=$(weknora auth token) works), NOT the JSON envelope — even
// though the global --format default is json. Explicit --format json still
// emits the {token,mode,profile} envelope (covered by the JSON test below).
func TestAuthToken_DefaultIsRawToken(t *testing.T) {
cfg := &config.Config{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{
"prod": {Host: "https://kb.example.com", TokenRef: "prod:access"},
},
}
store := secrets.NewMemStore()
_ = store.Set("prod", "access", "jwt-default-xyz")
out, _ := iostreams.SetForTest(t)
cmd := NewCmdToken(tokenTestFactory(t, cfg, store))
cmd.PersistentFlags().String("format", "", "") // mirror the root persistent flag, unset
cmd.SetArgs([]string{})
if err := cmd.Execute(); err != nil {
t.Fatalf("execute: %v", err)
}
if got := out.String(); got != "jwt-default-xyz" {
t.Errorf("default output must be the raw token (not an envelope); got %q", got)
}
}
// TestAuthToken_JQImpliesJSON: --jq filters the envelope, so passing it without
// an explicit --format must resolve to JSON (not the raw-token default), else
// the filter would be silently dropped.
func TestAuthToken_JQImpliesJSON(t *testing.T) {
cfg := &config.Config{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{
"prod": {Host: "https://kb.example.com", TokenRef: "prod:access"},
},
}
store := secrets.NewMemStore()
_ = store.Set("prod", "access", "jwt-jq-xyz")
out, _ := iostreams.SetForTest(t)
cmd := NewCmdToken(tokenTestFactory(t, cfg, store))
cmd.PersistentFlags().String("format", "", "")
cmd.PersistentFlags().StringP("jq", "q", "", "")
// Structural projection: proves jq ran on the JSON envelope (object out),
// not the raw-token text path (which would print the bare string).
cmd.SetArgs([]string{"--jq", ".data | {token}"})
if err := cmd.Execute(); err != nil {
t.Fatalf("execute: %v", err)
}
var got map[string]any
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("--jq without --format must filter the JSON envelope, got non-JSON %q: %v", out.String(), err)
}
if got["token"] != "jwt-jq-xyz" {
t.Errorf("jq projection wrong: %+v", got)
}
}
func TestAuthToken_BearerMode_PlainOutput(t *testing.T) {
cfg := &config.Config{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{
"prod": {Host: "https://kb.example.com", TokenRef: "prod:access", RefreshRef: "prod:refresh"},
},
}
store := secrets.NewMemStore()
_ = store.Set("prod", "access", "jwt-token-xyz")
out, _ := iostreams.SetForTest(t)
err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.FormatOptions{Mode: cmdutil.FormatText})
if err != nil {
t.Fatalf("runToken: %v", err)
}
got := out.String()
if got != "jwt-token-xyz" {
t.Errorf("expected raw token, got %q", got)
}
if strings.HasSuffix(got, "\n") {
t.Errorf("output must NOT end with newline (clean $(...) substitution); got %q", got)
}
}
func TestAuthToken_APIKeyMode_PlainOutput(t *testing.T) {
cfg := &config.Config{
CurrentProfile: "ci",
Profiles: map[string]config.Profile{
"ci": {Host: "https://kb.example.com", APIKeyRef: "ci:api_key"},
},
}
store := secrets.NewMemStore()
_ = store.Set("ci", "api_key", "sk_test_apikey_42")
out, _ := iostreams.SetForTest(t)
if err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}); err != nil {
t.Fatalf("runToken: %v", err)
}
if got := out.String(); got != "sk_test_apikey_42" {
t.Errorf("expected api-key value, got %q", got)
}
}
func TestAuthToken_JSON(t *testing.T) {
cfg := &config.Config{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{
"prod": {Host: "https://kb.example.com", TokenRef: "prod:access"},
},
}
store := secrets.NewMemStore()
_ = store.Set("prod", "access", "jwt-xyz")
out, _ := iostreams.SetForTest(t)
if err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}); err != nil {
t.Fatalf("runToken: %v", err)
}
var env struct {
OK bool `json:"ok"`
Data struct {
Token string `json:"token"`
Mode string `json:"mode"`
Profile string `json:"profile"`
} `json:"data"`
}
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("parse: %v\n%s", err, out.String())
}
got := env.Data
if got.Token != "jwt-xyz" || got.Mode != "bearer" || got.Profile != "prod" {
t.Errorf("payload wrong: %+v", got)
}
}
func TestAuthToken_JSON_JQProjection(t *testing.T) {
cfg := &config.Config{
CurrentProfile: "ci",
Profiles: map[string]config.Profile{
"ci": {Host: "https://kb.example.com", APIKeyRef: "ci:api_key"},
},
}
store := secrets.NewMemStore()
_ = store.Set("ci", "api_key", "sk_42")
out, _ := iostreams.SetForTest(t)
// jq projects from the envelope; .data | {token} extracts from the data object inside envelope.
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON, JQ: ".data | {token}"}
if err := runToken(tokenTestFactory(t, cfg, store), fopts); err != nil {
t.Fatalf("runToken: %v", err)
}
var got map[string]any
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("parse: %v", err)
}
if _, has := got["mode"]; has {
t.Errorf("mode should be filtered out: %+v", got)
}
if got["token"] != "sk_42" {
t.Errorf("token wrong: %+v", got)
}
}
func TestAuthToken_NoCurrentProfile(t *testing.T) {
cfg := &config.Config{}
store := secrets.NewMemStore()
iostreams.SetForTest(t)
err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.FormatOptions{Mode: cmdutil.FormatText})
if err == nil {
t.Fatal("expected error, got nil")
}
if !cmdutil.IsAuthError(err) {
t.Errorf("want auth.* code, got %v", err)
}
}
func TestAuthToken_ProfileOverride(t *testing.T) {
cfg := &config.Config{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{
"prod": {Host: "https://prod.example.com", TokenRef: "prod:access"},
"staging": {Host: "https://staging.example.com", APIKeyRef: "staging:api_key"},
},
}
store := secrets.NewMemStore()
_ = store.Set("prod", "access", "prod-jwt")
_ = store.Set("staging", "api_key", "staging-key")
f := tokenTestFactory(t, cfg, store)
f.ProfileOverride = "staging"
out, _ := iostreams.SetForTest(t)
if err := runToken(f, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}); err != nil {
t.Fatalf("runToken: %v", err)
}
if got := out.String(); got != "staging-key" {
t.Errorf("expected staging-key (override), got %q", got)
}
}
func TestAuthToken_NoStoredCredential(t *testing.T) {
cfg := &config.Config{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{
"prod": {Host: "https://kb.example.com", TokenRef: "prod:access"},
},
}
store := secrets.NewMemStore()
// no Set - keyring is empty
iostreams.SetForTest(t)
err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.FormatOptions{Mode: cmdutil.FormatText})
if err == nil {
t.Fatal("expected auth.unauthenticated, got nil")
}
if !cmdutil.IsAuthError(err) {
t.Errorf("want auth.*, got %v", err)
}
}
func TestAuthToken_ContextWithNoCredentialRefs(t *testing.T) {
cfg := &config.Config{
CurrentProfile: "empty",
Profiles: map[string]config.Profile{
"empty": {Host: "https://kb.example.com"}, // no TokenRef or APIKeyRef
},
}
store := secrets.NewMemStore()
iostreams.SetForTest(t)
err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.FormatOptions{Mode: cmdutil.FormatText})
if err == nil {
t.Fatal("expected auth.unauthenticated, got nil")
}
if !cmdutil.IsAuthError(err) {
t.Errorf("want auth.*, got %v", err)
}
}
// --- stderr advisory tests --------------------------------------------------
//
// auth token prints the token to stdout unconditionally. When stdout is an
// interactive terminal, it ALSO writes a stderr advisory ("you just put the
// secret in your scrollback") + a mode-specific rotation note for api-key
// credentials. The stdout half must stay clean under all modes so $(...)
// substitution is unaffected - tests assert both axes.
func makeBearerCfg() (*config.Config, *secrets.MemStore) {
cfg := &config.Config{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{
"prod": {Host: "https://kb.example.com", TokenRef: "prod:access"},
},
}
store := secrets.NewMemStore()
_ = store.Set("prod", "access", "jwt-xyz")
return cfg, store
}
func makeAPIKeyCfg() (*config.Config, *secrets.MemStore) {
cfg := &config.Config{
CurrentProfile: "ci",
Profiles: map[string]config.Profile{
"ci": {Host: "https://kb.example.com", APIKeyRef: "ci:api_key"},
},
}
store := secrets.NewMemStore()
_ = store.Set("ci", "api_key", "sk_42")
return cfg, store
}
func TestAuthToken_NonTTY_NoStderrHint(t *testing.T) {
cfg, store := makeBearerCfg()
out, errBuf := iostreams.SetForTest(t)
if err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}); err != nil {
t.Fatalf("runToken: %v", err)
}
if out.String() != "jwt-xyz" {
t.Errorf("stdout = %q, want %q", out.String(), "jwt-xyz")
}
if errBuf.Len() != 0 {
t.Errorf("non-TTY stderr should be empty (scripts depend on this), got %q", errBuf.String())
}
}
func TestAuthToken_TTY_BearerMode_StderrHintNoRotationNote(t *testing.T) {
cfg, store := makeBearerCfg()
out, errBuf := iostreams.SetForTestWithTTY(t)
if err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}); err != nil {
t.Fatalf("runToken: %v", err)
}
if out.String() != "jwt-xyz" {
t.Errorf("stdout = %q, want raw token only", out.String())
}
if !strings.Contains(errBuf.String(), "scrollback") {
t.Errorf("expected stderr scrollback hint on TTY, got %q", errBuf.String())
}
if strings.Contains(errBuf.String(), "api-key") {
t.Errorf("bearer mode should not surface the api-key rotation note: %q", errBuf.String())
}
}
func TestAuthToken_TTY_APIKeyMode_IncludesRotationNote(t *testing.T) {
cfg, store := makeAPIKeyCfg()
out, errBuf := iostreams.SetForTestWithTTY(t)
if err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}); err != nil {
t.Fatalf("runToken: %v", err)
}
if out.String() != "sk_42" {
t.Errorf("stdout = %q, want raw token only", out.String())
}
stderr := errBuf.String()
if !strings.Contains(stderr, "scrollback") {
t.Errorf("api-key TTY stderr should still include the scrollback hint, got %q", stderr)
}
if !strings.Contains(stderr, "long-lived") || !strings.Contains(stderr, "rotate") {
t.Errorf("api-key mode should include rotation note, got %q", stderr)
}
}
func TestAuthToken_TTY_JSONMode_NoStderrHint(t *testing.T) {
// --format json output mode targets script/agent consumers even when stdout
// happens to be a TTY (e.g. an IDE running the CLI on the user's
// behalf). Hint would pollute their parsing - suppress.
cfg, store := makeBearerCfg()
_, errBuf := iostreams.SetForTestWithTTY(t)
if err := runToken(tokenTestFactory(t, cfg, store), &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}); err != nil {
t.Fatalf("runToken: %v", err)
}
if errBuf.Len() != 0 {
t.Errorf("JSON mode should not emit stderr hint, got %q", errBuf.String())
}
}
+358
View File
@@ -0,0 +1,358 @@
// Package chat implements `weknora chat <text>` - the streaming RAG answer
// entry point.
//
// Three output modes share a single SDK call:
//
// - JSON mode (--format json, the default): buffer a bounded projection of
// the stream into one success envelope. The default projection contains
// answer events only; --reference adds indexed citations and --verbose
// adds reasoning, tools, and lifecycle frames.
//
// - Text mode (--format text): render the same projection as JSON
// directly to iostreams.IO.Out as it arrives.
//
// - NDJSON mode (--format ndjson): inject a CLI "init" event at stream
// head, then pass through every SDK event verbatim as NDJSON lines —
// the raw protocol trace, for debugging / advanced consumers.
//
// The SDK's KnowledgeQAStream callback contract is invoked sequentially on
// one goroutine, so no mode needs locking. The runChat core takes a
// ChatService interface so tests inject a fake without standing up a real
// SSE server.
package chat
import (
"context"
"fmt"
"strings"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/output"
"github.com/Tencent/WeKnora/cli/internal/sse"
sdk "github.com/Tencent/WeKnora/client"
)
// chatFields enumerates the fields surfaced for `--jq` projection discovery
// on `chat`: the json object's data fields + the raw SDK event vocabulary
// used by --format ndjson.
var chatFields = []string{
"events", "session_id", "assistant_message_id", "kb_id", "query",
// NDJSON init + SDK event fields.
"type", "kb_id", "profile", "response_type", "content", "done", "knowledge_references", "data",
}
type Options struct {
Query string
KBID string
SessionID string
// Reference adds bounded kb_id/chunk_id reference events to JSON/text.
Reference bool
// Verbose surfaces the projected execution trace in JSON/text,
// including reasoning, tools, and lifecycle events.
// NDJSON is always raw and is unaffected by presentation flags.
Verbose bool
}
// ChatService is the narrow SDK surface this command depends on. *sdk.Client
// satisfies it; tests substitute a fake. Compile-time check is at the bottom
// of this file.
type ChatService interface {
CreateSession(ctx context.Context, req *sdk.CreateSessionRequest) (*sdk.Session, error)
KnowledgeQAStream(ctx context.Context, sessionID string, req *sdk.KnowledgeQARequest, cb func(*sdk.StreamResponse) error, opts ...sdk.ResourceURLOptions) error
}
// NewCmd builds `weknora chat <text>`.
func NewCmd(f *cmdutil.Factory) *cobra.Command {
opts := &Options{}
cmd := &cobra.Command{
Use: `chat "<text>"`,
Short: "Ask a streaming RAG question against a knowledge base",
Long: `Send a query to the WeKnora knowledge-chat endpoint and stream the
answer back. By default a fresh session is created on first invocation; pass
--session to continue an existing conversation.
Modes:
--format json (default): one JSON envelope with answer events
--format text: live human-readable answer stream
--format ndjson: raw NDJSON event stream — init line (session_id,
kb_id) then SDK events verbatim. Debug / advanced.
Pass --reference to include bounded kb_id/chunk_id reference indexes. Pass
--verbose to include reasoning, tool activity, and lifecycle frames. Combine
both flags for the complete projected stream.`,
Example: ` weknora chat "What is RRF?" --kb a32a63ff-fb36-4874-bcaa-30f48570a694
weknora chat "Summarise this design doc" --kb my-kb --format json
weknora chat "Continue?" --session sess_abc`,
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
opts.Query = strings.TrimSpace(args[0])
if opts.Query == "" {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "query argument cannot be empty")
}
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
kbID, err := f.ResolveKB(c)
if err != nil {
return err
}
opts.KBID = kbID
cli, err := f.Client()
if err != nil {
return err
}
return runChat(c.Context(), opts, fopts, cli)
},
}
cmdutil.AddKBFlag(cmd)
cmd.Flags().StringVar(&opts.SessionID, "session", "", "Continue an existing chat session (skip auto-create)")
cmd.Flags().BoolVar(&opts.Reference, "reference", false, "Include indexed references in JSON/text output")
cmd.Flags().BoolVar(&opts.Verbose, "verbose", false, "Include reasoning, tools, and lifecycle events in JSON/text output")
cmdutil.AddFormatFlag(cmd, chatFields...)
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "Ask a RAG question against a knowledge base. Default JSON returns a bounded answer-event projection. --reference adds indexed citations; --verbose adds reasoning, tools, and lifecycle events. --format ndjson streams raw SDK events; --format text renders the selected events live.",
RequiredFlags: []string{"<text> (positional)", "--kb"},
Examples: []string{
`weknora chat "What is RRF?" --kb kb_abc`,
`weknora chat "What is RRF?" --kb kb_abc --jq '[.data.events[].content] | join("")'`,
},
Output: "Default --format json: {ok,data:{events:[answer...],session_id,kb_id,query}}. --reference adds kb_id/chunk_id reference events; --verbose adds execution events. --format ndjson remains raw.",
})
return cmd
}
// runChat is the testable core: validate, ensure a session, dispatch the
// stream, and route output. Returns a typed error.
func runChat(ctx context.Context, opts *Options, fopts *cmdutil.FormatOptions, svc ChatService) error {
if opts.Query == "" {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "query argument cannot be empty")
}
if opts.KBID == "" {
// Defensive: the cobra layer resolves KB before runChat; this guards
// the direct-test entry point.
return cmdutil.NewError(cmdutil.CodeKBIDRequired, "kb id is required")
}
if svc == nil {
return cmdutil.NewError(cmdutil.CodeServerError, "chat: no SDK client available")
}
// --format selects the output shape: json (default) accumulates and
// emits one object, ndjson streams raw SDK events, text renders live.
sessionID := opts.SessionID
autoCreated := false
if sessionID == "" {
sess, err := svc.CreateSession(ctx, &sdk.CreateSessionRequest{Title: "weknora chat"})
if err != nil {
// Ctrl-C during session creation: classify as cancelled so the
// hint nudges the user toward retry-with-signal-clean, not
// "pass --session" as session_create_failed would.
if cmdutil.IsCancelled(ctx, err) {
return cmdutil.Wrapf(cmdutil.CodeOperationCancelled, err, "chat cancelled")
}
// Map HTTP-shaped failures, but tag generic transport / unknown
// errors as session_create_failed so the dedicated hint fires.
code := cmdutil.ClassifyHTTPError(err)
if code == cmdutil.CodeNetworkError || code == cmdutil.CodeServerError {
code = cmdutil.CodeSessionCreateFailed
}
return cmdutil.Wrapf(code, err, "create chat session")
}
sessionID = sess.ID
autoCreated = true
}
if fopts != nil && fopts.Mode == cmdutil.FormatNDJSON {
return runChatNDJSON(ctx, opts, sessionID, svc)
}
if fopts != nil && fopts.Mode == cmdutil.FormatJSON {
return runChatJSON(ctx, opts, fopts, sessionID, svc)
}
// Surface the auto-created session ID up-front so a user who hits ^C
// mid-stream still has the pointer to resume - no need to scroll back
// past tokens. Skipped in json/ndjson mode (session_id is in the output).
if autoCreated {
fmt.Fprintf(iostreams.IO.Err, "session: %s (use --session to continue)\n", sessionID)
}
return runChatText(ctx, opts, sessionID, autoCreated, svc)
}
// runChatNDJSON handles the --format ndjson path: emits a CLI init event at
// stream head, then passes every SDK event through verbatim as NDJSON lines.
// No buffering — callers parse the stream incrementally.
func runChatNDJSON(ctx context.Context, opts *Options, sessionID string, svc ChatService) error {
w := iostreams.IO.Out
// 1. Inject the CLI-managed init event at the head of the stream.
// Carries the session pointer + retrieval context callers need for
// follow-up threading.
initEv := output.InitEvent{
SessionID: sessionID,
KBID: opts.KBID,
Profile: cmdutil.GetProfile(),
}
if err := output.EmitInit(w, initEv); err != nil {
return err
}
// 2. Open SDK stream and pass each event through as a bare NDJSON line.
req := &sdk.KnowledgeQARequest{
Query: opts.Query,
KnowledgeBaseIDs: []string{opts.KBID},
AgentEnabled: false,
WebSearchEnabled: false,
Channel: "api",
}
cb := func(r *sdk.StreamResponse) error {
// NDJSON is the raw protocol/debug surface: do not filter events or
// mutate their payloads. JSON/text modes own presentation filtering.
return output.EmitSDKEvent(w, r)
}
if err := svc.KnowledgeQAStream(ctx, sessionID, req, cb); err != nil {
if cmdutil.IsCancelled(ctx, err) {
return cmdutil.Wrapf(cmdutil.CodeOperationCancelled, err, "chat cancelled")
}
return cmdutil.WrapStream(err, "knowledge qa stream")
}
return nil
}
// runChatText handles the --format text path. It renders the same
// projection as JSON immediately for both terminals and pipes.
func runChatText(ctx context.Context, opts *Options, sessionID string, autoCreated bool, svc ChatService) error {
req := &sdk.KnowledgeQARequest{
Query: opts.Query,
KnowledgeBaseIDs: []string{opts.KBID},
AgentEnabled: false,
WebSearchEnabled: false,
Channel: "api",
}
projector := sse.NewProjector(opts.Verbose, opts.Reference, opts.KBID)
renderer := sse.NewTextRenderer(iostreams.IO.Out, opts.Verbose)
cb := func(r *sdk.StreamResponse) error {
event, include := projector.Chat(r)
if !include {
return nil
}
return renderer.Write(event)
}
streamErr := svc.KnowledgeQAStream(ctx, sessionID, req, cb)
if streamErr != nil {
// Re-surface the auto-created session id on failure so a user who
// missed the start-of-stream notice (it scrolls past mid-stream
// tokens, especially on ^C) can still recover with --session.
if autoCreated {
fmt.Fprintf(iostreams.IO.Err, "session: %s (resume with --session %s)\n", sessionID, sessionID)
}
// Context cancelled (Ctrl-C) → user-aborted, exit 130 lineage.
if cmdutil.IsCancelled(ctx, streamErr) {
return cmdutil.Wrapf(cmdutil.CodeOperationCancelled, streamErr, "chat cancelled")
}
// Stream began (we observed at least one event) but never reached a
// terminal Done frame: typed as sse_stream_aborted so the hint
// nudges the user toward a retry.
if projector.Seen() && !projector.Done() {
return cmdutil.Wrapf(cmdutil.CodeSSEStreamAborted, streamErr, "stream aborted before completion")
}
// Pre-stream HTTP / transport failure: route through the canonical
// classifier so 401 / 404 / 5xx still surface their specific codes.
return cmdutil.WrapStream(streamErr, "knowledge qa stream")
}
// SDK returned nil but we never saw a Done event - server closed the
// connection cleanly mid-stream. Treat as aborted so the user sees the
// truncation rather than a silent partial answer. Includes the empty-body
// case (Done frame never arrived AND no content): better to surface the
// abort than emit ok=true with answer="" - agents can't distinguish the
// model genuinely had nothing to say from the stream getting cut.
if !projector.Done() {
return cmdutil.NewError(cmdutil.CodeSSEStreamAborted, "stream ended without a terminal event")
}
return renderer.Close()
}
// runChatJSON handles the --format json path (the default): collect the
// projection and emit it in one normal success envelope.
func runChatJSON(ctx context.Context, opts *Options, fopts *cmdutil.FormatOptions, sessionID string, svc ChatService) error {
req := &sdk.KnowledgeQARequest{
Query: opts.Query,
KnowledgeBaseIDs: []string{opts.KBID},
AgentEnabled: false,
WebSearchEnabled: false,
Channel: "api",
}
projector := sse.NewProjector(opts.Verbose, opts.Reference, opts.KBID)
events := make([]sse.ProjectedEvent, 0)
cb := func(r *sdk.StreamResponse) error {
if event, include := projector.Chat(r); include {
events = append(events, event)
}
return nil
}
if err := svc.KnowledgeQAStream(ctx, sessionID, req, cb); err != nil {
if cmdutil.IsCancelled(ctx, err) {
return chatStreamError(cmdutil.Wrapf(cmdutil.CodeOperationCancelled, err, "chat cancelled"), sessionID, projector.AssistantMessageID())
}
return chatStreamError(cmdutil.WrapStream(err, "knowledge qa stream"), sessionID, projector.AssistantMessageID())
}
if !projector.Done() {
return chatStreamError(
cmdutil.NewError(cmdutil.CodeSSEStreamAborted, "stream ended without a terminal event"),
sessionID,
projector.AssistantMessageID(),
)
}
sid := projector.SessionID()
if sid == "" {
sid = sessionID
}
data := chatResult{
Events: events,
SessionID: sid,
AssistantMessageID: projector.AssistantMessageID(),
KBID: opts.KBID,
Query: opts.Query,
}
// Reaching here means fopts.Mode is FormatJSON (the only caller). Route
// through FormatOptions.Emit so --jq projection and the success-envelope
// contract apply, matching every other non-streaming command. A nil fopts
// (direct-test entry) defaults to JSON so the object still emits.
if fopts == nil {
fopts = &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}
}
return fopts.Emit(iostreams.IO.Out, data, nil)
}
func chatStreamError(err *cmdutil.Error, sessionID, assistantMessageID string) *cmdutil.Error {
detail := map[string]any{"session_id": sessionID}
if assistantMessageID != "" {
detail["assistant_message_id"] = assistantMessageID
}
return err.WithDetail(detail)
}
// chatResult is the --format json data payload. The projector controls default
// versus verbose coverage.
type chatResult struct {
Events []sse.ProjectedEvent `json:"events"`
SessionID string `json:"session_id"`
AssistantMessageID string `json:"assistant_message_id,omitempty"`
KBID string `json:"kb_id"`
Query string `json:"query"`
}
// compile-time check: the production SDK client implements ChatService.
var _ ChatService = (*sdk.Client)(nil)
+656
View File
@@ -0,0 +1,656 @@
package chat
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/sse"
sdk "github.com/Tencent/WeKnora/client"
)
// fakeChatService implements ChatService for unit tests. Tests configure the
// callback driver via streamEvents (delivered in order) and observe captured
// inputs through the exported fields.
type fakeChatService struct {
createSessionResp *sdk.Session
createSessionErr error
createCalled bool
streamErr error
streamEvents []*sdk.StreamResponse
gotSessionID string
gotRequest *sdk.KnowledgeQARequest
streamCalled bool
cbReturnsError error // if set, callback aborts after first event with this error
}
func (f *fakeChatService) CreateSession(_ context.Context, req *sdk.CreateSessionRequest) (*sdk.Session, error) {
f.createCalled = true
if f.createSessionErr != nil {
return nil, f.createSessionErr
}
if f.createSessionResp != nil {
return f.createSessionResp, nil
}
// Default: return a deterministic session id derived from the title so
// JSON assertions don't depend on uuid generation.
return &sdk.Session{ID: "sess_auto", Title: req.Title}, nil
}
func (f *fakeChatService) KnowledgeQAStream(ctx context.Context, sessionID string, req *sdk.KnowledgeQARequest, cb func(*sdk.StreamResponse) error, opts ...sdk.ResourceURLOptions) error {
f.streamCalled = true
f.gotSessionID = sessionID
f.gotRequest = req
for _, ev := range f.streamEvents {
if err := cb(ev); err != nil {
return err
}
if f.cbReturnsError != nil {
return f.cbReturnsError
}
}
return f.streamErr
}
// Sanity: fakeChatService must satisfy ChatService. Mirrors the production
// var _ ChatService = (*sdk.Client)(nil) check at the bottom of chat.go.
var _ ChatService = (*fakeChatService)(nil)
// textOpts returns a FormatOptions configured for the text render path —
// the most common shape under test.
func textOpts() *cmdutil.FormatOptions {
return &cmdutil.FormatOptions{Mode: cmdutil.FormatText}
}
func TestChat_StreamMode(t *testing.T) {
out, errBuf := iostreams.SetForTestWithTTY(t)
svc := &fakeChatService{
streamEvents: []*sdk.StreamResponse{
{ResponseType: sdk.ResponseTypeAnswer, Content: "Hello "},
{ResponseType: sdk.ResponseTypeAnswer, Content: "world"},
{ResponseType: sdk.ResponseTypeReferences, KnowledgeReferences: []*sdk.SearchResult{
{KnowledgeID: "k1", KnowledgeTitle: "Doc One", Score: 0.42},
}},
{ResponseType: sdk.ResponseTypeComplete, Done: true},
},
}
opts := &Options{Query: "hi", KBID: "kb_1"}
if err := runChat(context.Background(), opts, textOpts(), svc); err != nil {
t.Fatalf("runChat: %v", err)
}
got := out.String()
if !strings.Contains(got, "Hello world") {
t.Errorf("stdout missing streamed content: %q", got)
}
if strings.Contains(got, "references") || strings.Contains(got, "Doc One") {
t.Errorf("default text output leaked references: %q", got)
}
// auto-created session must announce itself on stderr
if !strings.Contains(errBuf.String(), "session: sess_auto") {
t.Errorf("expected stderr session hint, got %q", errBuf.String())
}
if !svc.createCalled {
t.Error("expected CreateSession invocation when SessionID empty")
}
if svc.gotSessionID != "sess_auto" {
t.Errorf("stream sessionID: got %q want sess_auto", svc.gotSessionID)
}
if svc.gotRequest == nil || svc.gotRequest.Channel != "api" {
t.Errorf("expected Channel=api, got %+v", svc.gotRequest)
}
}
// TestChat_NDJSON_FirstLineIsInit verifies that the NDJSON path (--format ndjson)
// always injects an "init" line first carrying session_id and kb_id.
func TestChat_NDJSON_FirstLineIsInit(t *testing.T) {
out, errBuf := iostreams.SetForTest(t)
svc := &fakeChatService{
streamEvents: []*sdk.StreamResponse{
{ResponseType: sdk.ResponseTypeAnswer, Content: "answer"},
{ResponseType: sdk.ResponseTypeComplete, Done: true},
},
}
opts := &Options{Query: "q", KBID: "kb_42"}
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatNDJSON}
if err := runChat(context.Background(), opts, fopts, svc); err != nil {
t.Fatalf("runChat: %v", err)
}
// NDJSON mode must NOT print the session hint to stderr.
if errBuf.Len() != 0 {
t.Errorf("expected empty stderr in NDJSON mode, got %q", errBuf.String())
}
lines := strings.Split(strings.TrimRight(out.String(), "\n"), "\n")
if len(lines) == 0 {
t.Fatal("no output")
}
var first struct {
Type string `json:"type"`
SessionID string `json:"session_id"`
KBID string `json:"kb_id"`
}
if err := json.Unmarshal([]byte(lines[0]), &first); err != nil {
t.Fatalf("first line not JSON: %v\n %s", err, lines[0])
}
if first.Type != "init" {
t.Errorf("first line type: got %q, want init", first.Type)
}
if first.SessionID != "sess_auto" {
t.Errorf("init.session_id: got %q, want sess_auto", first.SessionID)
}
if first.KBID != "kb_42" {
t.Errorf("init.kb_id: got %q, want kb_42", first.KBID)
}
}
// TestChat_NDJSON_PassthroughEvents verifies that the NDJSON path emits
// init + N SDK events = N+1 total lines (no buffering, no extra wrapping).
func TestChat_NDJSON_PassthroughEvents(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeChatService{
streamEvents: []*sdk.StreamResponse{
{ResponseType: sdk.ResponseTypeAnswer, Content: "hello"},
{ResponseType: sdk.ResponseTypeComplete, Done: true},
},
}
opts := &Options{Query: "q", KBID: "kb_x"}
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatNDJSON}
if err := runChat(context.Background(), opts, fopts, svc); err != nil {
t.Fatalf("runChat: %v", err)
}
lines := strings.Split(strings.TrimRight(out.String(), "\n"), "\n")
// 1 init + 2 SDK events = 3 lines.
if len(lines) != 3 {
t.Fatalf("got %d lines, want 3:\n%s", len(lines), out.String())
}
// Each must be valid JSON.
for i, line := range lines {
var obj map[string]any
if err := json.Unmarshal([]byte(line), &obj); err != nil {
t.Errorf("line %d not valid JSON: %v\n %s", i+1, err, line)
}
}
}
func TestChat_NonTTY_StreamsSameProjection(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeChatService{
streamEvents: []*sdk.StreamResponse{
{ResponseType: sdk.ResponseTypeAnswer, Content: "piped"},
{ResponseType: sdk.ResponseTypeComplete, Done: true},
},
}
opts := &Options{Query: "q", KBID: "kb"}
if err := runChat(context.Background(), opts, textOpts(), svc); err != nil {
t.Fatalf("runChat: %v", err)
}
if !strings.Contains(out.String(), "piped") {
t.Errorf("expected accumulated answer, got %q", out.String())
}
}
func TestChat_NonTTY_VerboseIncludesThinking(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeChatService{
streamEvents: []*sdk.StreamResponse{
{ResponseType: sdk.ResponseTypeThinking, Content: "REASONING"},
{ResponseType: sdk.ResponseTypeAnswer, Content: "answer"},
{ResponseType: sdk.ResponseTypeComplete, Done: true},
},
}
opts := &Options{Query: "q", KBID: "kb", Verbose: true}
if err := runChat(context.Background(), opts, textOpts(), svc); err != nil {
t.Fatalf("runChat: %v", err)
}
if got := out.String(); !strings.Contains(got, "REASONING") || !strings.Contains(got, "answer") {
t.Errorf("non-TTY verbose output missing thinking or answer: %q", got)
}
}
func TestChat_SessionIDProvided(t *testing.T) {
_, errBuf := iostreams.SetForTestWithTTY(t)
svc := &fakeChatService{
streamEvents: []*sdk.StreamResponse{{ResponseType: sdk.ResponseTypeComplete, Done: true}},
}
opts := &Options{Query: "q", KBID: "kb", SessionID: "sess_existing"}
if err := runChat(context.Background(), opts, textOpts(), svc); err != nil {
t.Fatalf("runChat: %v", err)
}
if svc.createCalled {
t.Error("CreateSession must NOT be invoked when --session is provided")
}
if svc.gotSessionID != "sess_existing" {
t.Errorf("stream sessionID: got %q want sess_existing", svc.gotSessionID)
}
// No auto-create message because the user supplied the id.
if strings.Contains(errBuf.String(), "session:") {
t.Errorf("unexpected session hint emitted with explicit --session: %q", errBuf.String())
}
}
func TestChat_KBIDRequired(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeChatService{}
// Run with KBID empty (bypassing the cobra resolver).
opts := &Options{Query: "q"}
err := runChat(context.Background(), opts, textOpts(), svc)
if err == nil {
t.Fatal("expected error, got nil")
}
var typed *cmdutil.Error
if !errors.As(err, &typed) {
t.Fatalf("expected *cmdutil.Error, got %T", err)
}
if typed.Code != cmdutil.CodeKBIDRequired {
t.Errorf("code: got %q want %q", typed.Code, cmdutil.CodeKBIDRequired)
}
if svc.createCalled || svc.streamCalled {
t.Error("KB validation must short-circuit before any SDK call")
}
}
func TestChat_EmptyQuery(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeChatService{}
opts := &Options{Query: "", KBID: "kb"}
err := runChat(context.Background(), opts, textOpts(), svc)
if err == nil {
t.Fatal("expected error, got nil")
}
var typed *cmdutil.Error
if !errors.As(err, &typed) {
t.Fatalf("expected *cmdutil.Error, got %T", err)
}
if typed.Code != cmdutil.CodeInputInvalidArgument {
t.Errorf("code: got %q want %q", typed.Code, cmdutil.CodeInputInvalidArgument)
}
}
func TestChat_SDKError_PreStream(t *testing.T) {
// SDK fails before any event arrives → ClassifyHTTPError mapping.
// "HTTP error 401: ..." → auth.unauthenticated.
_, _ = iostreams.SetForTest(t)
svc := &fakeChatService{
streamErr: errors.New("HTTP error 401: token rejected"),
}
opts := &Options{Query: "q", KBID: "kb"}
err := runChat(context.Background(), opts, textOpts(), svc)
if err == nil {
t.Fatal("expected error, got nil")
}
var typed *cmdutil.Error
if !errors.As(err, &typed) {
t.Fatalf("expected *cmdutil.Error, got %T", err)
}
if typed.Code != cmdutil.CodeAuthUnauthenticated {
t.Errorf("code: got %q want %q", typed.Code, cmdutil.CodeAuthUnauthenticated)
}
}
func TestChat_SDKError_MidStream_AbortsAsSSE(t *testing.T) {
// Some content arrived, then the stream errored without a Done event →
// CodeSSEStreamAborted (separate from generic transport failure).
_, _ = iostreams.SetForTest(t)
svc := &fakeChatService{
streamEvents: []*sdk.StreamResponse{{Content: "partial"}},
streamErr: errors.New("connection reset"),
}
opts := &Options{Query: "q", KBID: "kb"}
err := runChat(context.Background(), opts, textOpts(), svc)
if err == nil {
t.Fatal("expected error, got nil")
}
var typed *cmdutil.Error
if !errors.As(err, &typed) {
t.Fatalf("expected *cmdutil.Error, got %T", err)
}
if typed.Code != cmdutil.CodeSSEStreamAborted {
t.Errorf("code: got %q want %q", typed.Code, cmdutil.CodeSSEStreamAborted)
}
}
func TestChat_TerminalSSEError_ClassifiesAsServerError(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeChatService{
streamEvents: []*sdk.StreamResponse{
{ResponseType: sdk.ResponseTypeAnswer, Content: "partial"},
{ResponseType: sdk.ResponseTypeError, Content: "boom", Done: true},
},
streamErr: sdk.NewSSEStreamError("boom"),
}
opts := &Options{Query: "q", KBID: "kb"}
err := runChat(context.Background(), opts, textOpts(), svc)
if err == nil {
t.Fatal("expected error, got nil")
}
var typed *cmdutil.Error
if !errors.As(err, &typed) {
t.Fatalf("expected *cmdutil.Error, got %T", err)
}
if typed.Code != cmdutil.CodeServerError {
t.Errorf("code: got %q want %q", typed.Code, cmdutil.CodeServerError)
}
}
func TestChat_ContextCancelled(t *testing.T) {
_, _ = iostreams.SetForTest(t)
ctx, cancel := context.WithCancel(context.Background())
cancel() // simulate Ctrl-C delivered before the SDK returns.
svc := &fakeChatService{streamErr: context.Canceled}
opts := &Options{Query: "q", KBID: "kb"}
err := runChat(ctx, opts, textOpts(), svc)
if err == nil {
t.Fatal("expected error, got nil")
}
var typed *cmdutil.Error
if !errors.As(err, &typed) {
t.Fatalf("expected *cmdutil.Error, got %T", err)
}
if typed.Code != cmdutil.CodeOperationCancelled {
t.Errorf("code: got %q want %q", typed.Code, cmdutil.CodeOperationCancelled)
}
}
func TestChat_SessionCreateFails(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeChatService{
createSessionErr: errors.New("dial tcp: connection refused"),
}
opts := &Options{Query: "q", KBID: "kb"}
err := runChat(context.Background(), opts, textOpts(), svc)
if err == nil {
t.Fatal("expected error, got nil")
}
var typed *cmdutil.Error
if !errors.As(err, &typed) {
t.Fatalf("expected *cmdutil.Error, got %T", err)
}
if typed.Code != cmdutil.CodeSessionCreateFailed {
t.Errorf("code: got %q want %q", typed.Code, cmdutil.CodeSessionCreateFailed)
}
if svc.streamCalled {
t.Error("stream must not be invoked after session creation failed")
}
}
func TestChat_SessionCreate404SurfacesNotFound(t *testing.T) {
// HTTP-shaped session-create failures should NOT collapse into the
// session_create_failed bucket; they keep their canonical mapping so
// agents can react to e.g. resource.not_found.
_, _ = iostreams.SetForTest(t)
svc := &fakeChatService{
createSessionErr: errors.New("HTTP error 404: tenant not found"),
}
opts := &Options{Query: "q", KBID: "kb"}
err := runChat(context.Background(), opts, textOpts(), svc)
if err == nil {
t.Fatal("expected error, got nil")
}
var typed *cmdutil.Error
if !errors.As(err, &typed) {
t.Fatalf("expected *cmdutil.Error, got %T", err)
}
if typed.Code != cmdutil.CodeResourceNotFound {
t.Errorf("code: got %q want %q", typed.Code, cmdutil.CodeResourceNotFound)
}
}
// TestChat_NDJSON_InitIncludesProfile verifies that when a profile is set,
// the NDJSON init event carries the profile field.
func TestChat_NDJSON_InitIncludesProfile(t *testing.T) {
cmdutil.SetProfile("prod")
t.Cleanup(func() { cmdutil.SetProfile("") })
out, _ := iostreams.SetForTest(t)
svc := &fakeChatService{
streamEvents: []*sdk.StreamResponse{
{ResponseType: sdk.ResponseTypeComplete, Done: true},
},
}
opts := &Options{Query: "q", KBID: "kb_x"}
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatNDJSON}
if err := runChat(context.Background(), opts, fopts, svc); err != nil {
t.Fatalf("runChat: %v", err)
}
lines := strings.Split(strings.TrimRight(out.String(), "\n"), "\n")
if len(lines) == 0 {
t.Fatal("no output")
}
var initLine struct {
Type string `json:"type"`
Profile string `json:"profile"`
}
if err := json.Unmarshal([]byte(lines[0]), &initLine); err != nil {
t.Fatalf("first line not JSON: %v\n %s", err, lines[0])
}
if initLine.Type != "init" {
t.Errorf("type: got %q, want init", initLine.Type)
}
if initLine.Profile != "prod" {
t.Errorf("profile: got %q, want prod", initLine.Profile)
}
}
func TestChat_FormatNDJSON_PassthroughsSDKEvents(t *testing.T) {
// NDJSON is the raw protocol surface: every SDK event passes through,
// including reasoning, regardless of the presentation-oriented --verbose.
svc := &fakeChatService{
streamEvents: []*sdk.StreamResponse{
{ResponseType: sdk.ResponseTypeThinking, Content: "search KB"},
{ResponseType: sdk.ResponseTypeAnswer, Content: "hello"},
{ResponseType: sdk.ResponseTypeComplete, Done: true, SessionID: "sess_x"},
},
}
out, _ := iostreams.SetForTest(t)
opts := &Options{Query: "hi", KBID: "kb_x"}
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatNDJSON}
if err := runChat(context.Background(), opts, fopts, svc); err != nil {
t.Fatalf("runChat: %v", err)
}
lines := strings.Split(strings.TrimRight(out.String(), "\n"), "\n")
// 1 init + thinking + answer + complete = 4 lines.
if len(lines) != 4 {
t.Fatalf("got %d lines, want 4:\n%s", len(lines), out.String())
}
sawThinking := false
for i, line := range lines {
var ev map[string]any
if err := json.Unmarshal([]byte(line), &ev); err != nil {
t.Fatalf("line %d not JSON: %v", i+1, err)
}
if ev["response_type"] == "thinking" {
sawThinking = true
}
}
if !sawThinking {
t.Error("thinking event was filtered from raw NDJSON output")
}
// First line: CLI-injected init event.
var initLine map[string]any
if err := json.Unmarshal([]byte(lines[0]), &initLine); err != nil {
t.Fatalf("line 1 (init) not JSON: %v", err)
}
if initLine["type"] != "init" {
t.Errorf("first line type=%v, want init", initLine["type"])
}
}
func TestChat_FormatNDJSON_VerboseDoesNotAlterRawStream(t *testing.T) {
// --verbose is presentation-only; NDJSON remains the same raw stream and
// therefore still contains thinking alongside answer + complete.
svc := &fakeChatService{
streamEvents: []*sdk.StreamResponse{
{ResponseType: sdk.ResponseTypeThinking, Content: "search KB"},
{ResponseType: sdk.ResponseTypeAnswer, Content: "hello"},
{ResponseType: sdk.ResponseTypeComplete, Done: true, SessionID: "sess_x"},
},
}
out, _ := iostreams.SetForTest(t)
opts := &Options{Query: "hi", KBID: "kb_x", Verbose: true}
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatNDJSON}
if err := runChat(context.Background(), opts, fopts, svc); err != nil {
t.Fatalf("runChat: %v", err)
}
lines := strings.Split(strings.TrimRight(out.String(), "\n"), "\n")
// 1 init + thinking + answer + complete = 4 lines.
if len(lines) != 4 {
t.Fatalf("got %d lines, want 4:\n%s", len(lines), out.String())
}
var second map[string]any
if err := json.Unmarshal([]byte(lines[1]), &second); err != nil {
t.Fatalf("line 2 not JSON: %v", err)
}
if second["response_type"] != "thinking" {
t.Errorf("second event response_type=%v, want thinking", second["response_type"])
}
}
func TestChat_FormatJSON_ReferenceAddsIndexes(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeChatService{
streamEvents: []*sdk.StreamResponse{
{ResponseType: sdk.ResponseTypeAnswer, Content: "the answer"},
{ResponseType: sdk.ResponseTypeReferences, KnowledgeReferences: []*sdk.SearchResult{
{ID: "c1", Content: "BULKY FULL CHUNK CONTENT", ParentChunkID: "p1", KnowledgeTitle: "Doc One", Score: 0.5},
}},
{ResponseType: sdk.ResponseTypeComplete, Done: true, SessionID: "sess_auto"},
},
}
opts := &Options{Query: "hi", KBID: "kb_1", Reference: true}
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}
if err := runChat(context.Background(), opts, fopts, svc); err != nil {
t.Fatalf("runChat: %v", err)
}
// A single envelope, not multiple NDJSON lines.
outStr := strings.TrimRight(out.String(), "\n")
if strings.Contains(outStr, "\n") {
t.Fatalf("expected single-line envelope, got multiple lines:\n%s", outStr)
}
var env struct {
OK bool `json:"ok"`
Data struct {
Events []sse.ProjectedEvent `json:"events"`
SessionID string `json:"session_id"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(outStr), &env); err != nil {
t.Fatalf("envelope not JSON: %v\n%s", err, outStr)
}
if !env.OK {
t.Error("ok=false, want true")
}
wantTypes := []string{"answer", "references"}
if len(env.Data.Events) != len(wantTypes) {
t.Fatalf("events=%+v", env.Data.Events)
}
for i, want := range wantTypes {
if env.Data.Events[i].ResponseType != want {
t.Errorf("events[%d].response_type=%q, want %q", i, env.Data.Events[i].ResponseType, want)
}
}
refs := env.Data.Events[1].KnowledgeReferences
if len(refs) != 1 || refs[0].KBID != "kb_1" || refs[0].ChunkID != "c1" || refs[0].ParentChunkID != "p1" {
t.Errorf("reference indexes=%+v", refs)
}
if env.Data.SessionID == "" {
t.Error("session_id empty")
}
}
func TestChat_FormatNDJSON_PreservesReflection(t *testing.T) {
// Reflection is part of the raw SDK protocol and must not be filtered.
svc := &fakeChatService{
streamEvents: []*sdk.StreamResponse{
{ResponseType: sdk.ResponseTypeReflection, Content: "reflecting"},
{ResponseType: sdk.ResponseTypeAnswer, Content: "hello"},
{ResponseType: sdk.ResponseTypeComplete, Done: true, SessionID: "sess_x"},
},
}
out, _ := iostreams.SetForTest(t)
opts := &Options{Query: "hi", KBID: "kb_x"}
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatNDJSON}
if err := runChat(context.Background(), opts, fopts, svc); err != nil {
t.Fatalf("runChat: %v", err)
}
sawReflection := false
for i, line := range strings.Split(strings.TrimRight(out.String(), "\n"), "\n") {
var ev map[string]any
if err := json.Unmarshal([]byte(line), &ev); err != nil {
t.Fatalf("line %d not JSON: %v", i+1, err)
}
if ev["response_type"] == "reflection" {
sawReflection = true
}
}
if !sawReflection {
t.Error("reflection event was filtered from raw NDJSON output")
}
}
func TestChat_FormatJSON_HonorsJQ(t *testing.T) {
// --format json routes through FormatOptions.Emit, so --jq projects the
// envelope instead of dumping it whole. Guards the agent output protocol
// contract: consumers can join answer event fragments.
svc := &fakeChatService{
streamEvents: []*sdk.StreamResponse{
{ResponseType: sdk.ResponseTypeAnswer, Content: "the answer"},
{ResponseType: sdk.ResponseTypeReferences, KnowledgeReferences: []*sdk.SearchResult{
{ID: "c1", Content: "bulk", KnowledgeTitle: "Doc"},
}},
{ResponseType: sdk.ResponseTypeComplete, Done: true, SessionID: "sess_x"},
},
}
out, _ := iostreams.SetForTest(t)
opts := &Options{Query: "hi", KBID: "kb_1"}
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON, JQ: `[.data.events[].content] | join("")`}
if err := runChat(context.Background(), opts, fopts, svc); err != nil {
t.Fatalf("runChat: %v", err)
}
got := strings.TrimSpace(out.String())
if !strings.Contains(got, "the answer") {
t.Errorf("jq output missing projected answer: %q", got)
}
// A projection must not dump the full envelope scaffolding.
if strings.Contains(got, "references") || strings.Contains(got, "session_id") {
t.Errorf("jq output looks like a full envelope, not a projection: %q", got)
}
}
func TestChat_FormatJSON_StreamErrorIncludesSessionDetail(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeChatService{
streamEvents: []*sdk.StreamResponse{{
ResponseType: sdk.ResponseTypeAgentQuery,
AssistantMessageID: "msg_123",
}},
streamErr: errors.New("connection reset"),
}
err := runChat(
context.Background(),
&Options{Query: "q", KBID: "kb"},
&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON},
svc,
)
var typed *cmdutil.Error
if !errors.As(err, &typed) {
t.Fatalf("expected *cmdutil.Error, got %v", err)
}
detail, ok := typed.Detail.(map[string]any)
if !ok || detail["session_id"] != "sess_auto" || detail["assistant_message_id"] != "msg_123" {
t.Errorf("error detail=%v, want session and assistant message ids", typed.Detail)
}
}
+33
View File
@@ -0,0 +1,33 @@
// Package chunkcmd implements the `chunk` command subtree for managing
// document chunks in a knowledge base. The directory is named `chunk/`
// to match the cobra subcommand; the Go package is `chunkcmd` to avoid
// colliding with cobra's *cobra.Command identifier.
//
// "chunk" in this subtree refers to indexed pieces of a knowledge
// document (server resource: GET/DELETE /chunks/...). Each document
// has many chunks; the chunking pipeline produces them at ingest time.
package chunkcmd
import (
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
)
const chunkLong = `Manage and inspect document chunks.
Chunks are the indexed pieces of a knowledge document (1 doc → many chunks).
Use 'chunk list' to enumerate chunks in stored order (RAG admin / debug).`
// NewCmdChunk builds the parent `chunk` command. Called from cli/cmd/root.go.
func NewCmdChunk(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "chunk <subcommand>",
Short: "Manage document chunks (RAG retrieval debug)",
Long: chunkLong,
}
cmd.AddCommand(NewCmdList(f))
cmd.AddCommand(NewCmdView(f))
cmd.AddCommand(NewCmdDelete(f))
return cmd
}
+160
View File
@@ -0,0 +1,160 @@
package chunkcmd
import (
"context"
"fmt"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/prompt"
)
// chunkDeleteFields enumerates the JSON discovery fields for `chunk delete`.
// Tracks the single-id result struct; multi-id mode emits batch envelope.
var chunkDeleteFields = []string{"id", "deleted"}
type DeleteOptions struct {
ChunkID string // single-id path
DocID string // required: SDK DeleteChunk takes both ids in the route
Yes bool // sourced from the global -y/--yes persistent flag
DryRun bool
}
// DeleteService is the narrow SDK surface this command depends on.
type DeleteService interface {
DeleteChunk(ctx context.Context, docID, chunkID string) error
}
// deleteResult is the typed payload emitted on single-id success in JSON mode.
type deleteResult struct {
ID string `json:"id"`
Deleted bool `json:"deleted"`
}
const chunkDeleteLong = `Permanently delete one or more chunks from a document.
Requires both the chunk id(s) (positional, repeatable) and the parent
document id (--doc) because the server route encodes both:
DELETE /chunks/{doc}/{id}. All chunks in a multi-id call must share the
same --doc. The CLI does not auto-resolve doc id from chunk id because
that would add a round-trip and open a race with the ingest pipeline
(a chunk could move between documents between resolve and delete).
Single-id: one confirm prompt, exit 0/1.
Multi-id:
• Default keep-going: failed deletes do NOT stop the run; failures collected.
• One -y/--yes confirms all chunks.
• Exit 0 if all succeed; exit 1 if any failed.
Prompts for confirmation by default when stdout is a TTY and JSON output
is not set. Pass -y/--yes (the global flag) to skip the prompt (required
in agent / CI / piped contexts).
Typed exit codes:
resource.not_found no chunk with the given id under that doc (exit 4)
auth.forbidden caller lacks delete permission on the chunk (exit 3)
input.confirmation_required destructive op without -y on a TTY (exit 10)
AI agents: this is a high-risk write. Without -y/--yes the CLI exits 10
and writes input.confirmation_required to stderr. NEVER auto-pass -y
without the user's explicit go-ahead — the exit-10 protocol exists
exactly to guard against unintended deletes.`
const chunkDeleteExample = ` weknora chunk delete chunk_abc --doc doc_xyz # interactive confirm
weknora chunk delete chunk_abc --doc doc_xyz -y # no prompt
weknora chunk delete chunk_abc --doc doc_xyz -y --format json # bare {id, deleted:true} JSON
weknora chunk delete c1 c2 c3 --doc doc_xyz -y # delete 3 chunks under same doc, keep-going`
// NewCmdDelete builds `weknora chunk delete <chunk-id> [<chunk-id>...] --doc <doc-id>`.
func NewCmdDelete(f *cmdutil.Factory) *cobra.Command {
opts := &DeleteOptions{}
cmd := &cobra.Command{
Use: "delete <chunk-id> [<chunk-id>...] --doc <doc-id>",
Short: "Delete one or more chunks from a document (scoped)",
Long: chunkDeleteLong,
Example: chunkDeleteExample,
Args: cobra.MinimumNArgs(1),
RunE: func(c *cobra.Command, args []string) error {
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
opts.Yes, _ = c.Flags().GetBool("yes")
if handled, err := cmdutil.HandleDryRun(c, opts.DryRun, cmdutil.DryRunPlan{
Action: "chunk.delete",
Args: map[string]any{
"chunk_ids": args,
"doc": opts.DocID,
},
}); handled {
return err
}
cli, err := f.Client()
if err != nil {
return err
}
if len(args) == 1 {
opts.ChunkID = args[0]
return runDelete(c.Context(), opts, fopts, cli, f.Prompter())
}
batchRetry := append([]string{"weknora", "chunk", "delete"}, args...)
batchRetry = append(batchRetry, "--doc", opts.DocID, "-y")
if err := cmdutil.ConfirmDestructiveBatch(f.Prompter(), opts.Yes, fopts.WantsJSON(), "delete", "chunk", len(args), "chunk.delete", batchRetry); err != nil {
return err
}
outcomes, runErr := cmdutil.RunBatch(c.Context(), args, func(ctx context.Context, id string) error {
if err := cli.DeleteChunk(ctx, opts.DocID, id); err != nil {
return cmdutil.WrapHTTP(err, "delete chunk %s", id)
}
return nil
})
// Only emit when the operation actually ran. Pre-flight errors
// (e.g. confirmation_required) must leave stdout empty per the
// wire contract in README.md.
if len(outcomes) > 0 {
if emitErr := cmdutil.EmitBatch(outcomes, fopts, iostreams.IO.Out, cmdutil.DeletedAtNow); emitErr != nil {
return emitErr
}
}
return runErr
},
}
cmd.Flags().StringVar(&opts.DocID, "doc", "", "Parent document id (SDK knowledge_id) the chunks live under")
_ = cmd.MarkFlagRequired("doc")
cmdutil.AddIgnoredKBFlag(cmd)
cmdutil.AddFormatFlag(cmd, chunkDeleteFields...)
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
cmdutil.SetRisk(cmd, "chunk.delete")
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "permanently delete one or more chunks from a document",
RequiredFlags: []string{"<chunk-id>... (positional, at least one)", "--doc <doc-id>"},
Examples: []string{
"weknora chunk delete chunk_abc --doc doc_xyz -y",
"weknora chunk delete c1 c2 c3 --doc doc_xyz -y",
"weknora chunk delete chunk_abc --doc doc_xyz -y --format json",
},
Output: "envelope.data shape depends on the form: a single chunk id -> {id, deleted:true}; multiple ids -> batch envelope (top-level status success|partial|error, ok=(failures==0), data per-item [{id, ok, error?}], meta.successes/failures — read data[].ok per id).",
Warnings: []string{
"Requires explicit user approval (exit 10 / input.confirmation_required); never auto-add -y.",
"chunk delete is irreversible; loses the chunk + breaks RAG retrieval coherence for downstream queries.",
},
})
return cmd
}
func runDelete(ctx context.Context, opts *DeleteOptions, fopts *cmdutil.FormatOptions, svc DeleteService, p prompt.Prompter) error {
if err := cmdutil.ConfirmDestructive(p, opts.Yes, fopts.WantsJSON(), "delete", "chunk", opts.ChunkID, "chunk.delete", []string{"weknora", "chunk", "delete", opts.ChunkID, "--doc", opts.DocID, "-y"}); err != nil {
return err
}
if err := svc.DeleteChunk(ctx, opts.DocID, opts.ChunkID); err != nil {
return cmdutil.WrapHTTP(err, "delete chunk %s", opts.ChunkID)
}
if fopts.WantsJSON() {
return fopts.Emit(iostreams.IO.Out, deleteResult{ID: opts.ChunkID, Deleted: true}, nil)
}
fmt.Fprintf(iostreams.IO.Out, "✓ Deleted chunk %s\n", opts.ChunkID)
return nil
}
+243
View File
@@ -0,0 +1,243 @@
package chunkcmd
import (
"bytes"
"context"
"encoding/json"
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/testutil"
)
type fakeChunkDeleteSvc struct {
gotDocID, gotChunkID string
err error
}
func (f *fakeChunkDeleteSvc) DeleteChunk(_ context.Context, docID, chunkID string) error {
f.gotDocID = docID
f.gotChunkID = chunkID
return f.err
}
func TestDelete_NonTTY_NoYes_ExitTen(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeChunkDeleteSvc{}
err := runDelete(context.Background(),
&DeleteOptions{ChunkID: "c1", DocID: "doc_abc", Yes: false},
&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, &testutil.ConfirmPrompter{})
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeInputConfirmationRequired, typed.Code)
assert.Empty(t, svc.gotChunkID, "must not call DeleteChunk without confirm")
assert.Equal(t, 10, cmdutil.ExitCode(err), "exit 10 per destructive-write protocol")
}
func TestDelete_WithYes_PassesBothIDs(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeChunkDeleteSvc{}
require.NoError(t, runDelete(context.Background(),
&DeleteOptions{ChunkID: "c1", DocID: "doc_abc", Yes: true},
&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, &testutil.ConfirmPrompter{}))
assert.Equal(t, "doc_abc", svc.gotDocID)
assert.Equal(t, "c1", svc.gotChunkID)
}
func TestDelete_MissingDoc_FlagError(t *testing.T) {
cmd := NewCmdDelete(nil)
cmd.SetArgs([]string{"c1"}) // no --doc
cmd.SilenceUsage = true
cmd.SilenceErrors = true
require.Error(t, cmd.Execute())
}
func TestDelete_404_PropagatesNotFound(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeChunkDeleteSvc{err: errors.New("HTTP error 404: not found")}
err := runDelete(context.Background(),
&DeleteOptions{ChunkID: "missing", DocID: "doc_abc", Yes: true},
&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, &testutil.ConfirmPrompter{})
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeResourceNotFound, typed.Code)
}
func TestDelete_TTY_ConfirmYes_Calls(t *testing.T) {
_, _ = iostreams.SetForTestWithTTY(t)
svc := &fakeChunkDeleteSvc{}
p := &testutil.ConfirmPrompter{Answer: true}
require.NoError(t, runDelete(context.Background(),
&DeleteOptions{ChunkID: "c1", DocID: "doc_abc"},
&cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, p))
assert.True(t, p.Asked)
assert.Equal(t, "c1", svc.gotChunkID)
}
func TestDelete_TTY_ConfirmNo_Aborts(t *testing.T) {
_, errBuf := iostreams.SetForTestWithTTY(t)
svc := &fakeChunkDeleteSvc{}
p := &testutil.ConfirmPrompter{Answer: false}
err := runDelete(context.Background(),
&DeleteOptions{ChunkID: "c1", DocID: "doc_abc"},
&cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, p)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeUserAborted, typed.Code)
assert.Empty(t, svc.gotChunkID, "answer=no must not call DeleteChunk")
assert.Contains(t, errBuf.String(), "Aborted")
}
func TestDelete_JSON_BareObject(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeChunkDeleteSvc{}
require.NoError(t, runDelete(context.Background(),
&DeleteOptions{ChunkID: "c1", DocID: "doc_abc", Yes: true},
&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, &testutil.ConfirmPrompter{}))
body := out.String()
assert.Contains(t, body, `"id":"c1"`)
assert.Contains(t, body, `"deleted":true`)
}
// ---------------------------------------------------------------------------
// Multi-id (all chunks share --doc, keep-going on failure)
// ---------------------------------------------------------------------------
// fakeMultiChunkDeleteSvc records (docID, chunkID) pairs and can fail-on
// selected chunkIDs.
type fakeMultiChunkDeleteSvc struct {
deleted []string // chunk ids successfully deleted
docIDs []string // doc id observed for each call
failOn map[string]error
}
func (f *fakeMultiChunkDeleteSvc) DeleteChunk(_ context.Context, docID, chunkID string) error {
f.docIDs = append(f.docIDs, docID)
if e, ok := f.failOn[chunkID]; ok {
return e
}
f.deleted = append(f.deleted, chunkID)
return nil
}
func TestMultiDelete_AllSucceed_SharedDoc(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeMultiChunkDeleteSvc{}
docID := "doc_xyz"
outcomes, err := cmdutil.RunBatch(context.Background(),
[]string{"c1", "c2", "c3"},
func(ctx context.Context, id string) error {
if err := svc.DeleteChunk(ctx, docID, id); err != nil {
return cmdutil.WrapHTTP(err, "delete chunk %s", id)
}
return nil
})
require.NoError(t, err)
require.Len(t, outcomes, 3)
for _, oc := range outcomes {
assert.Nil(t, oc.Err, "expected no error for %s", oc.ID)
}
// All calls observed the same --doc.
for _, d := range svc.docIDs {
assert.Equal(t, "doc_xyz", d)
}
}
func TestMultiDelete_PartialFailure_KeepsGoing(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeMultiChunkDeleteSvc{failOn: map[string]error{"c2": errors.New("HTTP error 404: not found")}}
docID := "doc_xyz"
outcomes, err := cmdutil.RunBatch(context.Background(),
[]string{"c1", "c2", "c3"},
func(ctx context.Context, id string) error {
if err := svc.DeleteChunk(ctx, docID, id); err != nil {
return cmdutil.WrapHTTP(err, "delete chunk %s", id)
}
return nil
})
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeOperationFailed, typed.Code)
require.Len(t, outcomes, 3, "all three ids must appear in outcomes (argv order preserved)")
assert.Nil(t, outcomes[0].Err, "c1 should succeed")
assert.NotNil(t, outcomes[1].Err, "c2 should fail")
assert.Nil(t, outcomes[2].Err, "c3 should succeed (keep-going)")
assert.Equal(t, "c2", outcomes[1].ID)
}
func TestMultiDelete_NonTTY_NoYes_RequiresConfirmation(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeMultiChunkDeleteSvc{}
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatText}
err := cmdutil.ConfirmDestructiveBatch(&testutil.ConfirmPrompter{}, false, fopts.WantsJSON(), "delete", "chunk", 2, "chunk.delete", nil)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeInputConfirmationRequired, typed.Code)
assert.Equal(t, 10, cmdutil.ExitCode(err))
assert.Empty(t, svc.deleted)
}
// TestChunkDelete_MultiID_PartialFailure_BatchEnvelope verifies that the JSON
// output for a multi-id partial failure uses the batch envelope shape:
// {ok:false, data:[{id,ok,result?|error?}...], meta:{count,successes,failures}}.
func TestChunkDelete_MultiID_PartialFailure_BatchEnvelope(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeMultiChunkDeleteSvc{failOn: map[string]error{
"c2": errors.New("HTTP error 404: not found"),
}}
docID := "doc_xyz"
outcomes, runErr := cmdutil.RunBatch(context.Background(),
[]string{"c1", "c2", "c3"},
func(ctx context.Context, id string) error {
if err := svc.DeleteChunk(ctx, docID, id); err != nil {
return cmdutil.WrapHTTP(err, "delete chunk %s", id)
}
return nil
})
require.Error(t, runErr)
require.Len(t, outcomes, 3)
var buf bytes.Buffer
require.NoError(t, cmdutil.EmitBatch(outcomes, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, &buf, cmdutil.DeletedAtNow))
var env struct {
OK bool `json:"ok"`
Data []struct {
ID string `json:"id"`
OK bool `json:"ok"`
Error *struct {
Type string `json:"type"`
Message string `json:"message"`
} `json:"error,omitempty"`
} `json:"data"`
Meta struct {
Count int `json:"count"`
Successes int `json:"successes"`
Failures int `json:"failures"`
} `json:"meta"`
}
require.NoError(t, json.Unmarshal(buf.Bytes(), &env), "envelope must be valid JSON")
assert.False(t, env.OK, "top-level ok must be false when any item failed")
assert.Equal(t, 3, env.Meta.Count)
assert.Equal(t, 2, env.Meta.Successes)
assert.Equal(t, 1, env.Meta.Failures)
require.Len(t, env.Data, 3)
assert.Equal(t, "c1", env.Data[0].ID)
assert.True(t, env.Data[0].OK)
assert.Equal(t, "c2", env.Data[1].ID)
assert.False(t, env.Data[1].OK)
assert.NotNil(t, env.Data[1].Error)
assert.Equal(t, "c3", env.Data[2].ID)
assert.True(t, env.Data[2].OK)
}
+214
View File
@@ -0,0 +1,214 @@
package chunkcmd
import (
"context"
"fmt"
"text/tabwriter"
"time"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/output"
"github.com/Tencent/WeKnora/cli/internal/text"
sdk "github.com/Tencent/WeKnora/client"
)
const (
defaultPageSize = 50
maxPageSize = 1000
defaultLimit = 50
maxLimit = 10000
previewWidth = 80
)
// chunkListFields enumerates the fields surfaced for `--format json` discovery on
// `chunk list`. Mirrors sdk.Chunk json tags — all 23 fields are projectable
// because chunk list returns bare SDK objects.
var chunkListFields = []string{
"id", "seq_id", "knowledge_id", "knowledge_base_id", "tenant_id",
"tag_id", "content", "chunk_index", "is_enabled", "status",
"start_at", "end_at", "pre_chunk_id", "next_chunk_id", "chunk_type",
"parent_chunk_id", "relation_chunks", "indirect_relation_chunks",
"metadata", "content_hash", "image_info", "created_at", "updated_at",
}
// ListService is the narrow SDK surface this command depends on.
type ListService interface {
ListKnowledgeChunks(ctx context.Context, knowledgeID string, page, pageSize int, chunkTypes ...string) ([]sdk.Chunk, int64, error)
}
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.
DocID string
// PageSize is the server batch size (1..1000, default 50).
PageSize int
// Limit caps the client-side accumulated slice (1..10000, default 50).
// Default 50 chosen as domain-tuned for chunk enumeration (RAG debug).
Limit int
// AllPages walks server pages internally until total exhausted or
// --limit hit. Mirrors the session list / doc list pagination pattern.
AllPages bool
}
const chunkListLong = `List chunks under a document in stored order.
'chunk list --doc D' enumerates ALL chunks of document D in ChunkIndex
order (the per-doc ordinal assigned at ingest time). This is the
admin/debug surface for RAG retrieval — see what the chunking pipeline
produced, audit content, find a chunk id to view/delete.
For relevance-ranked retrieval (the RAG runtime surface), use
'search chunks "<query>" --kb K' instead. That command runs hybrid
vector + keyword scoring across all chunks of a knowledge base.
Typed exit codes:
input.invalid_argument --limit out of 1..10000 or --page-size out of 1..1000 (exit 5)
resource.not_found no document with the given id (exit 4)
AI agents: prefer 'search chunks' for retrieval tasks. Use 'chunk list'
only when you need to enumerate / verify the chunking output of a
specific document.`
const chunkListExample = ` weknora chunk list --doc doc_abc
weknora chunk list --doc doc_abc --all-pages --page-size 100
weknora chunk list --doc doc_abc --format json | jq '.[] | {id, chunk_index}'`
// NewCmdList builds `weknora chunk list --doc <doc-id>`.
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
opts := &ListOptions{}
cmd := &cobra.Command{
Use: "list",
Short: "List chunks of a document (admin/debug, not retrieval)",
Long: chunkListLong,
Example: chunkListExample,
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, _ []string) error {
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
// Validate static input before building the client so a bad
// --limit/--page-size returns input.invalid_argument (exit 5), not
// an auth error (exit 3).
if err := validateListOpts(opts); err != nil {
return err
}
cli, err := f.Client()
if err != nil {
return err
}
return runList(c.Context(), opts, fopts, cli)
},
}
cmd.Flags().StringVar(&opts.DocID, "doc", "", "Document id (SDK knowledge_id) to enumerate chunks for")
_ = cmd.MarkFlagRequired("doc")
cmd.Flags().IntVarP(&opts.Limit, "limit", "L", defaultLimit, "Maximum results to return — client-side cap; meta.has_more/total_count report truncation (1..10000)")
cmd.Flags().IntVar(&opts.PageSize, "page-size", defaultPageSize, "Items per server batch (1..1000)")
cmd.Flags().BoolVar(&opts.AllPages, "all-pages", false, "Walk all server pages until exhausted (or --limit hit)")
cmdutil.AddFormatFlag(cmd, chunkListFields...)
cmdutil.AddIgnoredKBFlag(cmd)
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "List chunks of a specific document in stored order (admin/debug). Results come with meta.count; use --limit (1..10000) and --all-pages to paginate. Prefer 'search chunks' for RAG retrieval.",
RequiredFlags: []string{"--doc"},
Examples: []string{"weknora chunk list --doc doc_abc --format json", "weknora chunk list --doc doc_abc --all-pages --format json"},
Output: "envelope.data is an array of Chunk objects with id, chunk_index, content, is_enabled; meta.count is the returned count, meta.total_count the document's full chunk count, meta.has_more true when --limit truncated",
})
return cmd
}
// validateListOpts checks --limit / --page-size. Called from RunE before the
// client is built (so a bad value surfaces as exit 5, not an auth error) and at
// runList's top for direct callers; idempotent.
func validateListOpts(opts *ListOptions) error {
if opts.Limit < 1 || opts.Limit > maxLimit {
return &cmdutil.Error{
Code: cmdutil.CodeInputInvalidArgument,
Message: fmt.Sprintf("--limit must be in 1..%d, got %d", maxLimit, opts.Limit),
}
}
if opts.PageSize < 1 || opts.PageSize > maxPageSize {
return &cmdutil.Error{
Code: cmdutil.CodeInputInvalidArgument,
Message: fmt.Sprintf("--page-size must be in 1..%d, got %d", maxPageSize, opts.PageSize),
}
}
return nil
}
func runList(ctx context.Context, opts *ListOptions, fopts *cmdutil.FormatOptions, svc ListService) error {
if err := validateListOpts(opts); err != nil {
return err
}
var items []sdk.Chunk
var serverTotal int64
truncated := false
if opts.AllPages {
accum := make([]sdk.Chunk, 0)
for page := 1; ; page++ {
chunks, total, err := svc.ListKnowledgeChunks(ctx, opts.DocID, page, opts.PageSize)
if err != nil {
return cmdutil.WrapHTTP(err, "list chunks for doc %s", opts.DocID)
}
serverTotal = total
accum = append(accum, chunks...)
if len(accum) >= opts.Limit {
accum = accum[:opts.Limit]
truncated = true
break
}
if len(chunks) == 0 || int64(len(accum)) >= total {
break
}
}
items = accum
} else {
chunks, total, err := svc.ListKnowledgeChunks(ctx, opts.DocID, 1, opts.PageSize)
if err != nil {
return cmdutil.WrapHTTP(err, "list chunks for doc %s", opts.DocID)
}
serverTotal = total
items = chunks
}
if items == nil {
items = []sdk.Chunk{} // JSON [] not null
}
if len(items) > opts.Limit {
items = items[:opts.Limit]
truncated = true
}
if fopts.WantsJSON() {
meta := &output.Meta{Count: output.IntPtr(len(items)), TotalCount: output.IntPtr(int(serverTotal)), HasMore: truncated}
return fopts.Emit(iostreams.IO.Out, items, meta)
}
if len(items) == 0 {
fmt.Fprintln(iostreams.IO.Out, "(no chunks)")
return nil
}
tw := tabwriter.NewWriter(iostreams.IO.Out, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "CHUNK_ID\tINDEX\tTYPE\tENABLED\tPREVIEW\tUPDATED")
now := time.Now()
for _, c := range items {
enabled := "-"
if c.IsEnabled {
enabled = "yes"
}
preview := text.OneLine(previewWidth, c.Content)
if preview == "" {
preview = "-"
}
typ := c.ChunkType
if typ == "" {
typ = "-"
}
fmt.Fprintf(tw, "%s\t%d\t%s\t%s\t%s\t%s\n",
c.ID, c.ChunkIndex, typ, enabled, preview, text.FuzzyAgoStr(now, c.UpdatedAt))
}
return tw.Flush()
}
+245
View File
@@ -0,0 +1,245 @@
package chunkcmd
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
type listCall struct {
docID string
page int
pageSize int
}
type fakeListSvc struct {
calls []listCall
pages [][]sdk.Chunk
totals []int64
errs []error
callIdx int
}
func (f *fakeListSvc) ListKnowledgeChunks(_ context.Context, docID string, page, pageSize int, _ ...string) ([]sdk.Chunk, int64, error) {
f.calls = append(f.calls, listCall{docID, page, pageSize})
defer func() { f.callIdx++ }()
if f.callIdx >= len(f.pages) {
return nil, 0, nil
}
return f.pages[f.callIdx], f.totals[f.callIdx], f.errs[f.callIdx]
}
func TestList_Happy(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeListSvc{
pages: [][]sdk.Chunk{{
{ID: "c1", ChunkIndex: 0, Content: "hello", ChunkType: "text", IsEnabled: true},
{ID: "c2", ChunkIndex: 1, Content: "world", ChunkType: "text", IsEnabled: true},
}},
totals: []int64{2}, errs: []error{nil},
}
opts := &ListOptions{DocID: "doc_abc", Limit: 50, PageSize: 50}
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
require.Len(t, svc.calls, 1)
assert.Equal(t, "doc_abc", svc.calls[0].docID)
assert.Equal(t, 1, svc.calls[0].page)
assert.Equal(t, 50, svc.calls[0].pageSize)
// JSON mode emits a bare array; both ids must be present.
body := out.String()
assert.Contains(t, body, `"c1"`)
assert.Contains(t, body, `"c2"`)
}
// TestList_AllPages_StopsOnEmptyPage exercises the empty-page stop branch:
// total is large enough that page*pageSize < total never trips, so the loop
// can only terminate when the server returns an empty page.
func TestList_AllPages_StopsOnEmptyPage(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeListSvc{
pages: [][]sdk.Chunk{
{{ID: "c1"}, {ID: "c2"}},
{{ID: "c3"}},
{}, // empty page → done
},
// Inflated total isolates the empty-page branch from the
// page*pageSize >= total branch.
totals: []int64{100, 100, 100},
errs: []error{nil, nil, nil},
}
opts := &ListOptions{DocID: "doc_abc", AllPages: true, PageSize: 2, Limit: 1000}
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
assert.Equal(t, 3, len(svc.calls), "must stop on empty page, not loop forever")
}
// TestList_AllPages_StopsOnTotalExhausted exercises the page*pageSize >= total
// stop branch: server never returns an empty page in the requested window, so
// the loop must exit when accumulated coverage reaches total.
func TestList_AllPages_StopsOnTotalExhausted(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeListSvc{
pages: [][]sdk.Chunk{
{{ID: "c1"}, {ID: "c2"}},
{{ID: "c3"}},
},
totals: []int64{3, 3},
errs: []error{nil, nil},
}
opts := &ListOptions{DocID: "doc_abc", AllPages: true, PageSize: 2, Limit: 1000}
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
// After page 2: page*pageSize=4 >= total=3 → stop. No 3rd request.
assert.Equal(t, 2, len(svc.calls), "must stop when total exhausted, no extra empty probe")
}
// TestList_AllPages_LimitTruncatesAccumulated exercises the limit-cap stop
// branch: pagination halts as soon as accumulated >= limit, and the result
// is sliced to exactly --limit items regardless of how the last page over-ran.
func TestList_AllPages_LimitTruncatesAccumulated(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeListSvc{
pages: [][]sdk.Chunk{
{{ID: "c1"}, {ID: "c2"}},
{{ID: "c3"}, {ID: "c4"}},
{{ID: "c5"}}, // should not be requested — limit hits first
},
totals: []int64{5, 5, 5},
errs: []error{nil, nil, nil},
}
opts := &ListOptions{DocID: "doc_abc", AllPages: true, PageSize: 2, Limit: 3}
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
// After page 2: accum=4 >= limit=3 → stop. Third page never requested.
assert.LessOrEqual(t, len(svc.calls), 2, "must not walk past limit-hit point")
// Result must be exactly --limit items (server returned 4, sliced to 3).
var env struct {
OK bool `json:"ok"`
Data []sdk.Chunk `json:"data"`
}
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
got := env.Data
assert.Len(t, got, 3, "accumulated must be sliced to exactly --limit")
// IDs preserve order: first 3 from the first 2 pages.
assert.Equal(t, []string{"c1", "c2", "c3"}, []string{got[0].ID, got[1].ID, got[2].ID})
}
// TestList_JSON_EmitsTotalCount pins that chunk list surfaces the document's
// full chunk count as meta.total_count (like doc/session/kb/model list), not
// just the returned count — an agent must be able to tell truncation from
// completeness.
func TestList_JSON_EmitsTotalCount(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeListSvc{
pages: [][]sdk.Chunk{{{ID: "c1"}, {ID: "c2"}}},
totals: []int64{7},
errs: []error{nil},
}
opts := &ListOptions{DocID: "d1", Limit: 50, PageSize: 50}
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
var env struct {
Meta struct {
Count *int `json:"count"`
TotalCount *int `json:"total_count"`
} `json:"meta"`
}
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
require.NotNil(t, env.Meta.TotalCount, "chunk list must emit meta.total_count")
assert.Equal(t, 7, *env.Meta.TotalCount)
require.NotNil(t, env.Meta.Count)
assert.Equal(t, 2, *env.Meta.Count)
}
func TestList_LimitInvalid(t *testing.T) {
svc := &fakeListSvc{}
for _, lim := range []int{0, -1, 10001} {
err := runList(context.Background(), &ListOptions{DocID: "d", Limit: lim, PageSize: 50}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc)
require.Error(t, err, "expect error for --limit %d", lim)
assert.Contains(t, err.Error(), "input.invalid_argument")
}
}
func TestList_PageSizeInvalid(t *testing.T) {
svc := &fakeListSvc{}
for _, ps := range []int{0, -1, 1001} {
err := runList(context.Background(), &ListOptions{DocID: "d", Limit: 50, PageSize: ps}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc)
require.Error(t, err)
assert.Contains(t, err.Error(), "input.invalid_argument")
}
}
func TestList_MissingDoc_FlagError(t *testing.T) {
cmd := NewCmdList(nil)
cmd.SetArgs([]string{})
cmd.SilenceUsage = true
cmd.SilenceErrors = true
require.Error(t, cmd.Execute(), "expect required-flag error for missing --doc")
}
func TestList_Text_TableHeader(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeListSvc{
pages: [][]sdk.Chunk{{
{ID: "c1", ChunkIndex: 0, Content: "the quick brown fox", ChunkType: "text", IsEnabled: true, UpdatedAt: "2026-05-15T12:00:00Z"},
}},
totals: []int64{1}, errs: []error{nil},
}
require.NoError(t, runList(context.Background(), &ListOptions{DocID: "doc_abc", Limit: 50, PageSize: 50}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
body := out.String()
for _, want := range []string{"CHUNK_ID", "INDEX", "TYPE", "ENABLED", "PREVIEW", "UPDATED", "c1", "text"} {
assert.Contains(t, body, want)
}
}
func TestList_Text_PreviewTruncatedTo80(t *testing.T) {
out, _ := iostreams.SetForTest(t)
long := strings.Repeat("a", 200)
svc := &fakeListSvc{
pages: [][]sdk.Chunk{{{ID: "c1", Content: long, ChunkType: "text", IsEnabled: true}}},
totals: []int64{1}, errs: []error{nil},
}
require.NoError(t, runList(context.Background(), &ListOptions{DocID: "doc_abc", Limit: 50, PageSize: 50}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
body := out.String()
// 80-col preview means we never see the 100th `a` from the content (only column truncation kicks in).
assert.NotContains(t, body, strings.Repeat("a", 100), "preview must be truncated to ~80 chars")
}
func TestList_JSON_BareArray(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeListSvc{
pages: [][]sdk.Chunk{{
{ID: "c1", KnowledgeID: "doc_abc", KnowledgeBaseID: "kb_x"},
}},
totals: []int64{1}, errs: []error{nil},
}
require.NoError(t, runList(context.Background(), &ListOptions{DocID: "doc_abc", Limit: 50, PageSize: 50}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
var env struct {
OK bool `json:"ok"`
Data []sdk.Chunk `json:"data"`
}
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
got := env.Data
require.Len(t, got, 1)
assert.Equal(t, "doc_abc", got[0].KnowledgeID)
assert.Equal(t, "kb_x", got[0].KnowledgeBaseID)
// SDK snake_case keys must be present.
assert.Contains(t, out.String(), `"knowledge_id":"doc_abc"`)
assert.NotContains(t, out.String(), `"doc_id"`)
}
func TestList_EmptyResultRendersBareArray(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeListSvc{pages: [][]sdk.Chunk{{}}, totals: []int64{0}, errs: []error{nil}}
require.NoError(t, runList(context.Background(), &ListOptions{DocID: "doc_abc", Limit: 50, PageSize: 50}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
var env struct {
OK bool `json:"ok"`
Data []sdk.Chunk `json:"data"`
}
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
assert.True(t, env.OK)
assert.Len(t, env.Data, 0, "expected empty data array")
}
+159
View File
@@ -0,0 +1,159 @@
package chunkcmd
import (
"context"
"fmt"
"io"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
// chunkViewFields enumerates the 23 SDK Chunk fields surfaced for `--format json`
// discovery. JSON is bare SDK pass-through, so keys are snake_case
// (`knowledge_id`, `knowledge_base_id`) even though the human KV labels
// them as `doc_id` / `kb_id`.
var chunkViewFields = []string{
"id", "seq_id", "knowledge_id", "knowledge_base_id", "tenant_id",
"tag_id", "content", "chunk_index", "is_enabled", "status",
"start_at", "end_at", "pre_chunk_id", "next_chunk_id", "chunk_type",
"parent_chunk_id", "relation_chunks", "indirect_relation_chunks",
"metadata", "content_hash", "image_info", "created_at", "updated_at",
}
// ViewService is the narrow SDK surface this command depends on.
type ViewService interface {
GetChunkByIDOnly(ctx context.Context, chunkID string) (*sdk.Chunk, error)
}
type ViewOptions struct {
ChunkID string
}
const chunkViewLong = `Show a single chunk with all SDK fields.
Text output is a key-value block; pass --format json for the bare 23-field SDK
Chunk object. Content renders verbatim regardless of size — pipe to
less or use --format json for large chunks. WeKnora chunks are typically bounded
by the ingest pipeline (~1000 tokens / a few KB), so unconditional full
rendering is reasonable.
Scope asymmetry with 'chunk delete':
view <id> scope-less (server: GET /chunks/by-id/{id})
delete <id> --doc D scoped (server: DELETE /chunks/{doc}/{id})
The asymmetry is deliberate. Auto-resolving doc id on delete would race
the ingest pipeline; forcing --doc on view would add friction with no
benefit. AI agents: a chunk id from any source (list, search, agent
invoke citation) is sufficient for view.
Typed exit codes:
resource.not_found no chunk with the given id (exit 4)`
const chunkViewExample = ` weknora chunk view chunk_abc
weknora chunk view chunk_abc --format json | jq '.content'
weknora chunk view chunk_abc --format json --jq '{id, chunk_index, is_enabled}'`
// NewCmdView builds `weknora chunk view <chunk-id>`.
func NewCmdView(f *cmdutil.Factory) *cobra.Command {
opts := &ViewOptions{}
cmd := &cobra.Command{
Use: "view <chunk-id>",
Short: "Show a chunk's fields and content (scope-less)",
Long: chunkViewLong,
Example: chunkViewExample,
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
opts.ChunkID = args[0]
cli, err := f.Client()
if err != nil {
return err
}
return runView(c.Context(), opts, fopts, cli)
},
}
cmdutil.AddFormatFlag(cmd, chunkViewFields...)
cmdutil.AddIgnoredKBFlag(cmd)
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "fetch one chunk's fields and content by id (scope-less; no --doc needed)",
RequiredFlags: []string{"<chunk-id> (positional)"},
Examples: []string{"weknora chunk view chunk_abc"},
Output: "envelope.data is the chunk object (id, content, knowledge_id, ...)",
})
return cmd
}
func runView(ctx context.Context, opts *ViewOptions, fopts *cmdutil.FormatOptions, svc ViewService) error {
ch, err := svc.GetChunkByIDOnly(ctx, opts.ChunkID)
if err != nil {
return cmdutil.WrapHTTP(err, "fetch chunk %s", opts.ChunkID)
}
if fopts.WantsJSON() {
return fopts.Emit(iostreams.IO.Out, ch, nil)
}
renderChunk(iostreams.IO.Out, ch)
return nil
}
// 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 /
// content (full, no truncation, last entry).
//
// `doc_id` / `kb_id` are the human-friendly labels for the SDK fields
// `knowledge_id` / `knowledge_base_id`; JSON output keeps the SDK names.
func renderChunk(w io.Writer, c *sdk.Chunk) {
fmt.Fprintf(w, "id: %s\n", c.ID)
if c.SeqID != 0 {
fmt.Fprintf(w, "seq_id: %d\n", c.SeqID)
}
fmt.Fprintf(w, "chunk_index: %d\n", c.ChunkIndex)
if c.KnowledgeID != "" {
fmt.Fprintf(w, "doc_id: %s\n", c.KnowledgeID)
}
if c.KnowledgeBaseID != "" {
fmt.Fprintf(w, "kb_id: %s\n", c.KnowledgeBaseID)
}
if c.ChunkType != "" {
fmt.Fprintf(w, "type: %s\n", c.ChunkType)
}
enabled := "no"
if c.IsEnabled {
enabled = "yes"
}
fmt.Fprintf(w, "enabled: %s\n", enabled)
if c.Status != 0 {
fmt.Fprintf(w, "status: %d\n", c.Status)
}
if c.StartAt != 0 {
fmt.Fprintf(w, "start_at: %d\n", c.StartAt)
}
if c.EndAt != 0 {
fmt.Fprintf(w, "end_at: %d\n", c.EndAt)
}
if c.TagID != "" {
fmt.Fprintf(w, "tag_id: %s\n", c.TagID)
}
if c.ImageInfo != "" {
fmt.Fprintf(w, "image_info: %s\n", c.ImageInfo)
}
if c.CreatedAt != "" {
fmt.Fprintf(w, "created_at: %s\n", c.CreatedAt)
}
if c.UpdatedAt != "" {
fmt.Fprintf(w, "updated_at: %s\n", c.UpdatedAt)
}
// Content rendered verbatim, last entry, no truncation.
fmt.Fprintln(w)
fmt.Fprintln(w, "content:")
fmt.Fprintln(w, c.Content)
}
+105
View File
@@ -0,0 +1,105 @@
package chunkcmd
import (
"context"
"encoding/json"
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
type fakeViewSvc struct {
resp *sdk.Chunk
err error
}
func (f *fakeViewSvc) GetChunkByIDOnly(_ context.Context, _ string) (*sdk.Chunk, error) {
return f.resp, f.err
}
func TestView_Happy_RendersAllFields(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeViewSvc{resp: &sdk.Chunk{
ID: "c1",
SeqID: 42,
ChunkIndex: 0,
KnowledgeID: "doc_abc",
KnowledgeBaseID: "kb_abc",
ChunkType: "text",
IsEnabled: true,
Content: "the quick brown fox",
CreatedAt: "2026-05-15T11:00:00Z",
UpdatedAt: "2026-05-15T12:00:00Z",
}}
require.NoError(t, runView(context.Background(), &ViewOptions{ChunkID: "c1"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
body := out.String()
assert.Contains(t, body, "c1")
assert.Contains(t, body, "doc_abc")
assert.Contains(t, body, "kb_abc")
assert.Contains(t, body, "text")
assert.Contains(t, body, "the quick brown fox", "content must render in full")
}
func TestView_HumanLabels_DocAndKB(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeViewSvc{resp: &sdk.Chunk{
ID: "c1", KnowledgeID: "doc_abc", KnowledgeBaseID: "kb_abc",
}}
require.NoError(t, runView(context.Background(), &ViewOptions{ChunkID: "c1"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
body := out.String()
// Text KV uses friendlier DOC_ID / KB_ID labels (the SDK's
// knowledge_id / knowledge_base_id are kept only in --format json output).
assert.Contains(t, body, "doc_id")
assert.Contains(t, body, "kb_id")
assert.NotContains(t, body, "knowledge_id")
assert.NotContains(t, body, "knowledge_base_id")
}
func TestView_OmitsZeroOrEmpty(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeViewSvc{resp: &sdk.Chunk{ID: "c_min", Content: "x"}}
require.NoError(t, runView(context.Background(), &ViewOptions{ChunkID: "c_min"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
body := out.String()
// status / start_at / end_at all zero → must be omitted from the human KV.
assert.NotContains(t, body, "status:")
assert.NotContains(t, body, "start_at:")
assert.NotContains(t, body, "end_at:")
// tag_id / image_info empty → omitted.
assert.NotContains(t, body, "tag_id:")
assert.NotContains(t, body, "image_info:")
}
func TestView_JSON_BareSDKShape(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeViewSvc{resp: &sdk.Chunk{
ID: "c_json", KnowledgeID: "doc_abc", KnowledgeBaseID: "kb_abc",
}}
require.NoError(t, runView(context.Background(), &ViewOptions{ChunkID: "c_json"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
var env struct {
OK bool `json:"ok"`
Data sdk.Chunk `json:"data"`
}
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
got := env.Data
assert.Equal(t, "c_json", got.ID)
assert.Equal(t, "doc_abc", got.KnowledgeID)
// JSON uses SDK snake_case keys (knowledge_id), not text relabel doc_id.
assert.Contains(t, out.String(), `"knowledge_id":"doc_abc"`)
assert.Contains(t, out.String(), `"knowledge_base_id":"kb_abc"`)
assert.NotContains(t, out.String(), `"doc_id"`)
assert.NotContains(t, out.String(), `"kb_id"`)
}
func TestView_404(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeViewSvc{err: errors.New("HTTP error 404: not found")}
err := runView(context.Background(), &ViewOptions{ChunkID: "missing"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc)
require.Error(t, err)
assert.Contains(t, err.Error(), "resource.not_found")
}
+46
View File
@@ -0,0 +1,46 @@
package cmd
import (
"bytes"
"strings"
"testing"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
)
// TestCompletion_AllShells smoke-tests cobra's auto-registered completion
// command. The output is shell-specific bytecode/script; the test just
// asserts each shell produces a non-trivially-sized script and references
// the binary name. Guards against future cobra bumps silently breaking
// completion for one shell.
func TestCompletion_AllShells(t *testing.T) {
cases := []struct {
shell string
mustContain string // signature byte sequence specific to the shell
minSizeBytes int
}{
{"bash", "bash completion", 1024},
{"zsh", "#compdef", 1024},
{"fish", "complete -c weknora", 1024},
{"powershell", "Register-ArgumentCompleter", 1024},
}
for _, tc := range cases {
t.Run(tc.shell, func(t *testing.T) {
cmd := NewRootCmd(&cmdutil.Factory{})
var buf bytes.Buffer
cmd.SetOut(&buf)
cmd.SetErr(&buf)
cmd.SetArgs([]string{"completion", tc.shell})
if err := cmd.Execute(); err != nil {
t.Fatalf("completion %s: %v", tc.shell, err)
}
if buf.Len() < tc.minSizeBytes {
t.Errorf("%s script suspiciously short: %d bytes", tc.shell, buf.Len())
}
if !strings.Contains(buf.String(), tc.mustContain) {
t.Errorf("%s script missing signature %q (first 200 bytes: %q)",
tc.shell, tc.mustContain, buf.String()[:min(200, buf.Len())])
}
})
}
}
+25
View File
@@ -0,0 +1,25 @@
// Package configcmd holds the `weknora config` command tree.
//
// Currently a single read-only `view` subcommand that prints the resolved
// CLI configuration and its resolution chain. No mutation, no network.
//
// Package name `configcmd` (not `config`) avoids colliding with the
// internal/config package and matches the cmd-subpackage naming pattern.
// The cobra Use: string is "config" — what users type.
package configcmd
import (
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
)
// NewCmd builds the `weknora config` parent command.
func NewCmd(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "config",
Short: "Inspect the CLI's resolved configuration",
}
cmd.AddCommand(NewCmdView(f))
return cmd
}
+275
View File
@@ -0,0 +1,275 @@
package configcmd
import (
"fmt"
"os"
"strings"
"text/tabwriter"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/config"
"github.com/Tencent/WeKnora/cli/internal/format"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/projectlink"
"github.com/Tencent/WeKnora/cli/internal/secrets"
"github.com/Tencent/WeKnora/cli/internal/xdg"
)
// viewFields enumerates the fields surfaced for `--format json` discovery on
// `config view`. Flat shape — each entry is a top-level key of envelope.data.
var viewFields = []string{
"active_profile", "profile_source", "auth_source", "host",
"kb_id", "kb_source",
"log_level", "log_level_source",
"format_default",
"config_file", "cache_dir", "secrets", "project_link",
}
// viewData is the flat JSON payload describing the resolved config and where
// each value came from. Sources are stable human strings (e.g. "--profile
// flag") so an agent can explain the resolution without re-deriving it.
type viewData struct {
ActiveProfile string `json:"active_profile"`
ProfileSource string `json:"profile_source"`
AuthSource string `json:"auth_source"`
Host string `json:"host"`
KBID string `json:"kb_id"`
KBSource string `json:"kb_source"`
LogLevel string `json:"log_level"`
LogLevelSource string `json:"log_level_source"`
FormatDefault string `json:"format_default"`
ConfigFile string `json:"config_file"`
CacheDir string `json:"cache_dir"`
Secrets string `json:"secrets"`
ProjectLink string `json:"project_link"`
}
// NewCmdView builds `weknora config view`. Read-only inspection of the
// resolved config and resolution chain. Never builds the SDK client and
// never issues a network request: KB resolution uses ResolveKBLocal, secrets
// storage is detected from the store type, and everything else is read from
// config.yaml / env / flags.
func NewCmdView(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "view",
Short: "Show the resolved configuration and where each value came from",
Long: `Print the configuration currently in effect and its resolution chain:
the active profile (and its source), the profile host, the resolved knowledge
base (and source), log level, default output format, and the on-disk paths for
config / cache / secrets / project link.
Read-only: no values are mutated and no network request is issued. Succeeds
even when no profile or KB is configured (sources report "(none)" /
"(unresolved)").`,
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, _ []string) error {
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
return runView(c, f, fopts)
},
}
cmdutil.AddFormatFlag(cmd, viewFields...)
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "inspect the resolved CLI config and resolution chain (active profile, host, kb, log level, default format, file paths) without mutating or hitting the network",
Examples: []string{
"weknora config view",
"weknora config view --format json",
"weknora config view --jq '.data.active_profile'",
},
Output: "envelope.data is {active_profile, profile_source, auth_source, host, kb_id, kb_source, log_level, log_level_source, format_default, config_file, cache_dir, secrets, project_link}. auth_source reports whether the active credential is the profile+keyring or a stateless WEKNORA_TOKEN/WEKNORA_API_KEY env override.",
})
return cmd
}
func runView(cmd *cobra.Command, f *cmdutil.Factory, fopts *cmdutil.FormatOptions) error {
d := resolveView(cmd, f)
if fopts.WantsJSON() {
return fopts.Emit(iostreams.IO.Out, d, nil)
}
tw := tabwriter.NewWriter(iostreams.IO.Out, 0, 0, 2, ' ', 0)
row := func(k, v string) { fmt.Fprintf(tw, "%s\t%s\n", k, format.DashIfEmpty(v)) }
row("active_profile", d.ActiveProfile)
row("profile_source", d.ProfileSource)
row("auth_source", d.AuthSource)
row("host", d.Host)
row("kb_id", d.KBID)
row("kb_source", d.KBSource)
row("log_level", d.LogLevel)
row("log_level_source", d.LogLevelSource)
row("format_default", d.FormatDefault)
row("config_file", d.ConfigFile)
row("cache_dir", d.CacheDir)
row("secrets", d.Secrets)
row("project_link", d.ProjectLink)
return tw.Flush()
}
// resolveView assembles the resolved-config snapshot. Pure read: reads
// config.yaml (no network), env vars, flags, and the local project link.
func resolveView(cmd *cobra.Command, f *cmdutil.Factory) viewData {
d := viewData{}
// Profile + its source.
d.ActiveProfile, d.ProfileSource = resolveProfile(f)
// Host: looked up from the active profile in config (best-effort; empty
// when unset, no profile, or config unreadable).
if d.ActiveProfile != "" && f.Config != nil {
if cfg, err := f.Config(); err == nil && cfg != nil {
if prof, ok := cfg.Profiles[d.ActiveProfile]; ok {
d.Host = prof.Host
}
}
}
// Auth source: stateless env credentials (WEKNORA_TOKEN/WEKNORA_API_KEY)
// override the profile + keyring for the actual client, so report that —
// otherwise host/profile above would silently describe a profile the env
// credential bypassed. WEKNORA_HOST (when set) is the host that env cred
// authenticates against.
if active, kind := cmdutil.EnvCredential(); active {
d.AuthSource = kind + " env (stateless; bypasses profile + keyring)"
if h := strings.TrimSpace(os.Getenv("WEKNORA_HOST")); h != "" {
d.Host = h
}
} else if d.ActiveProfile != "" {
d.AuthSource = "profile + keyring"
} else {
d.AuthSource = "(none)"
}
// KB id + source (never network — ResolveKBLocal).
d.KBID, d.KBSource = resolveKB(cmd, f)
// Log level + source.
d.LogLevel, d.LogLevelSource = resolveLogLevel(cmd)
// Default output format (configured default, not the per-invocation flag).
d.FormatDefault = resolveFormatDefault()
// Paths.
if p, err := config.Path(); err == nil {
d.ConfigFile = p
}
if p, err := xdg.Path("XDG_CACHE_HOME", ".cache"); err == nil {
d.CacheDir = p
}
d.Secrets = resolveSecrets(f)
d.ProjectLink = resolveProjectLink()
return d
}
// resolveProfile mirrors Factory.ActiveProfile's precedence but also reports
// which level supplied the value, so `config view` can explain the choice.
func resolveProfile(f *cmdutil.Factory) (name, source string) {
if f.ProfileOverride != "" {
return f.ProfileOverride, "--profile flag"
}
if v := os.Getenv("WEKNORA_PROFILE"); v != "" {
return v, "WEKNORA_PROFILE env"
}
if f.Config != nil {
if cfg, err := f.Config(); err == nil && cfg != nil && cfg.CurrentProfile != "" {
return cfg.CurrentProfile, "config current_profile"
}
}
return "", "(none)"
}
// resolveKB reports the locally-resolved KB id and its source. ResolveKBLocal
// never hits the SDK; a CodeKBIDRequired error means nothing is in scope, which
// config view reports as "(unresolved)" rather than failing.
func resolveKB(cmd *cobra.Command, f *cmdutil.Factory) (id, source string) {
id, err := f.ResolveKBLocal(cmd)
if err != nil {
return "", "(unresolved)"
}
// Re-derive which level supplied it (ResolveKBLocal returns only the value).
if v, _ := cmd.Flags().GetString("kb"); v != "" {
return id, "--kb flag"
}
if v := os.Getenv("WEKNORA_KB_ID"); v != "" {
return id, "WEKNORA_KB_ID env"
}
if cwd, werr := os.Getwd(); werr == nil {
if path, found, derr := projectlink.Discover(cwd); derr == nil && found {
return id, "project link " + path
}
}
return id, "(unresolved)"
}
// resolveLogLevel reports the effective level and its source, matching
// ResolveLogLevel's precedence (flag > env > default).
func resolveLogLevel(cmd *cobra.Command) (level, source string) {
level, _ = cmdutil.ResolveLogLevel(cmd, iostreams.IO.Err)
if cmd != nil {
if fl := cmd.Flags().Lookup("log-level"); fl != nil && fl.Changed && cmdutil.IsValidLogLevel(fl.Value.String()) {
return level, "--log-level flag"
}
}
if v := os.Getenv("WEKNORA_LOG_LEVEL"); v != "" && cmdutil.IsValidLogLevel(v) {
return level, "WEKNORA_LOG_LEVEL env"
}
return level, "default"
}
// resolveFormatDefault reports the configured default output format: the
// config.yaml defaults.format value, else WEKNORA_FORMAT, else the hard
// default. This is the *default* used when --format is unset — not the
// per-invocation --format flag value.
func resolveFormatDefault() string {
cfg, err := config.Load()
if err == nil && cfg != nil && cfg.Defaults.Format != "" {
return cfg.Defaults.Format
}
if v := os.Getenv("WEKNORA_FORMAT"); v == "text" || v == "json" || v == "ndjson" {
return v
}
return string(cmdutil.DefaultFormatMode)
}
// resolveSecrets reports where credentials are stored: "file <path-root>" when
// the keyring is unavailable and the layer fell back to a 0600 FileStore,
// otherwise "keyring". Detection mirrors doctor's credential_storage check
// (type-assert on the best-effort store). On construction failure, reports the
// file fallback root so the user knows where to look.
func resolveSecrets(f *cmdutil.Factory) string {
var store secrets.Store
var err error
if f.Secrets != nil {
store, err = f.Secrets()
} else {
store, err = secrets.NewBestEffortStore()
}
if err == nil && store != nil {
if _, isFile := store.(*secrets.FileStore); !isFile {
return "keyring"
}
}
if p, perr := xdg.Path("XDG_CONFIG_HOME", ".config", "secrets"); perr == nil {
return "file " + p
}
return "file"
}
// resolveProjectLink reports the discovered .weknora/project.yaml path or
// "(none)". Walks up from cwd like ResolveKBLocal does.
func resolveProjectLink() string {
cwd, err := os.Getwd()
if err != nil {
return "(none)"
}
if path, found, derr := projectlink.Discover(cwd); derr == nil && found {
return path
}
return "(none)"
}
+120
View File
@@ -0,0 +1,120 @@
package configcmd
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/config"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
type viewEnvelope struct {
OK bool `json:"ok"`
Data struct {
ActiveProfile string `json:"active_profile"`
ProfileSource string `json:"profile_source"`
AuthSource string `json:"auth_source"`
Host string `json:"host"`
KBID string `json:"kb_id"`
KBSource string `json:"kb_source"`
FormatDefault string `json:"format_default"`
ConfigFile string `json:"config_file"`
} `json:"data"`
}
func runViewJSON(t *testing.T, f *cmdutil.Factory) viewEnvelope {
t.Helper()
out, _ := iostreams.SetForTest(t)
// --format is a root-level persistent flag, so drive runView directly with
// a JSON FormatOptions rather than through standalone leaf flag parsing.
cmd := NewCmdView(f)
require.NoError(t, runView(cmd, f, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}))
var env viewEnvelope
require.NoError(t, json.Unmarshal(out.Bytes(), &env), "envelope: %q", out.String())
return env
}
// TestConfigView_ConfigProfileSource: with a current_profile set in config and
// no flag/env override, config view reports the profile, its source, and the
// host — and resolves the KB to "(unresolved)" (no --kb/env/link) without error.
func TestConfigView_ConfigProfileSource(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
cfg := &config.Config{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{"prod": {Host: "https://kb.example.com"}},
}
require.NoError(t, config.Save(cfg))
env := runViewJSON(t, networkFreeFactory(t))
assert.True(t, env.OK)
assert.Equal(t, "prod", env.Data.ActiveProfile)
assert.Equal(t, "config current_profile", env.Data.ProfileSource)
assert.Equal(t, "https://kb.example.com", env.Data.Host)
assert.Equal(t, "(unresolved)", env.Data.KBSource, "no --kb/env/link must report unresolved, not error")
assert.Equal(t, "json", env.Data.FormatDefault)
assert.NotEmpty(t, env.Data.ConfigFile)
}
// TestConfigView_ProfileOverrideSource: the --profile override (set on the
// Factory by root's PersistentPreRunE) wins over config and is reported as
// such, so an agent can see why a non-default profile is active.
func TestConfigView_ProfileOverrideSource(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
cfg := &config.Config{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{"prod": {Host: "https://kb.example.com"}},
}
require.NoError(t, config.Save(cfg))
f := networkFreeFactory(t)
f.ProfileOverride = "other"
env := runViewJSON(t, f)
assert.Equal(t, "other", env.Data.ActiveProfile)
assert.Equal(t, "--profile flag", env.Data.ProfileSource)
}
// TestConfigView_NoProfile: config view succeeds with an empty config —
// active_profile "" / source "(none)" — never erroring on a fresh install.
func TestConfigView_NoProfile(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
env := runViewJSON(t, networkFreeFactory(t))
assert.True(t, env.OK)
assert.Equal(t, "", env.Data.ActiveProfile)
assert.Equal(t, "(none)", env.Data.ProfileSource)
}
// networkFreeFactory builds a Factory whose Client closure fails the test if
// invoked — config view must resolve everything locally (ResolveKBLocal,
// ActiveProfile) and never build the SDK client.
func networkFreeFactory(t *testing.T) *cmdutil.Factory {
t.Helper()
f := cmdutil.New()
f.Client = func() (*sdk.Client, error) {
t.Fatal("config view must not build the SDK client / hit the network")
return nil, nil
}
return f
}
// TestConfigView_EnvCredentialSurfaced: when WEKNORA_API_KEY + WEKNORA_HOST are
// set, config view reports the env override (auth_source + the env host), not
// the bypassed config profile's host.
func TestConfigView_EnvCredentialSurfaced(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
cfg := &config.Config{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{"prod": {Host: "https://configured.example.com"}},
}
require.NoError(t, config.Save(cfg))
t.Setenv("WEKNORA_API_KEY", "sk-test")
t.Setenv("WEKNORA_HOST", "https://env-override.example.com")
env := runViewJSON(t, networkFreeFactory(t))
assert.Contains(t, env.Data.AuthSource, "WEKNORA_API_KEY env")
assert.Equal(t, "https://env-override.example.com", env.Data.Host, "host must be the env override, not the profile host")
}
+28
View File
@@ -0,0 +1,28 @@
package cmd
import "github.com/spf13/cobra"
// eachLeafCommand invokes fn for every runnable leaf command (no real
// subcommands) in the tree rooted at root, skipping cobra's generated
// `completion`/`help` subtrees and hidden commands. Shared by the agent-help
// and dry-run coverage drift guards so they agree, byte-for-byte, on what
// counts as a leaf.
func eachLeafCommand(root *cobra.Command, fn func(*cobra.Command)) {
exempt := map[string]bool{"completion": true, "help": true}
var walk func(c *cobra.Command)
walk = func(c *cobra.Command) {
realKids := 0
for _, k := range c.Commands() {
if exempt[k.Name()] || k.Hidden {
continue
}
realKids++
walk(k)
}
if c == root || realKids > 0 || exempt[c.Name()] {
return // group command or exempt — not a leaf
}
fn(c)
}
walk(root)
}
+163
View File
@@ -0,0 +1,163 @@
// Package doc — create.go implements `weknora doc create --text "..."`.
// Allows creating a knowledge entry directly from inline text content without
// uploading a file or fetching a remote URL.
package doc
import (
"cmp"
"context"
"fmt"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/output"
sdk "github.com/Tencent/WeKnora/client"
)
// docCreateFields enumerates the fields surfaced for `--format json` discovery
// on `doc create`.
var docCreateFields = []string{
"id", "knowledge_base_id", "tag_id", "type", "title", "description",
"source", "channel", "parse_status", "summary_status", "enable_status",
"created_at", "updated_at", "error_message",
}
// CreateOptions holds CLI flag values for `doc create`.
type CreateOptions struct {
Text string // --text (required): document text content (Markdown)
Title string // --title: document title (matches `doc update --title`)
TagID string // --tag-id: associate with a tag
Channel string // --channel: ingestion-channel tag (default "api")
DryRun bool
}
// CreateService is the narrow SDK surface for `doc create`.
// *sdk.Client satisfies it.
type CreateService interface {
CreateManualKnowledge(
ctx context.Context,
kbID string,
req *sdk.CreateManualKnowledgeRequest,
) (*sdk.Knowledge, error)
}
// NewCmdCreate builds `weknora doc create --text "..."`.
func NewCmdCreate(f *cmdutil.Factory) *cobra.Command {
opts := &CreateOptions{}
cmd := &cobra.Command{
Use: "create",
Short: "Create a knowledge entry from inline text content",
Long: `Create a new knowledge entry by passing Markdown content directly via --text.
Useful for short snippets, agent-generated content, or structured notes that
don't require a file upload or remote URL. KB resolution follows the standard
4-level chain: --kb flag > WEKNORA_KB_ID env > .weknora/project.yaml > error.
--text <content> Document text in Markdown format (required).
--title <title> Display title stored with the entry (matches doc update --title).
--tag-id <id> Associate the new entry with a tag.
--channel <name> Override the ingestion-channel tag (default "api").`,
Example: ` weknora doc create --text "# Meeting Notes\n\nAction items: ..."
weknora doc create --text "$(cat notes.md)" --title "Sprint Notes" --kb my-kb
weknora doc create --text "API usage guide" --title "API Guide" --tag-id tag_abc`,
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, _ []string) error {
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
if opts.DryRun {
// ResolveKBLocal validates the KB is set via flag / env /
// project link without an SDK call; the plan reports the
// raw --kb value (UUID or name) for agent inspection.
kbID, err := f.ResolveKBLocal(c)
if err != nil {
return err
}
if handled, err := cmdutil.HandleDryRun(c, true, cmdutil.DryRunPlan{
Action: "doc.create",
Args: map[string]any{
"text": opts.Text,
"title": opts.Title,
"kb": kbID,
},
}); handled {
return err
}
}
cli, err := f.Client()
if err != nil {
return err
}
// Live path resolves --kb name → id via the SDK.
kbID, err := f.ResolveKB(c)
if err != nil {
return err
}
return runCreate(c.Context(), opts, fopts, cli, kbID)
},
}
cmdutil.AddKBFlag(cmd)
cmd.Flags().StringVar(&opts.Text, "text", "", "Document text content in Markdown format (required)")
cmd.Flags().StringVar(&opts.Title, "title", "", "Document title")
cmd.Flags().StringVar(&opts.TagID, "tag-id", "", "Tag id to associate with the new entry")
cmd.Flags().StringVar(&opts.Channel, "channel", "", "Ingestion-channel tag recorded server-side (default \"api\")")
_ = cmd.MarkFlagRequired("text")
cmdutil.AddFormatFlag(cmd, docCreateFields...)
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "Create a knowledge entry from inline Markdown text. KB resolved via --kb flag, WEKNORA_KB_ID env, or project link. Emits the created Knowledge object with its id.",
RequiredFlags: []string{"--text", "--kb (or WEKNORA_KB_ID / project link)"},
Examples: []string{
`weknora doc create --kb kb_eng --text "# Runbook\n\nRestart steps: ..."`,
`weknora doc create --kb kb_eng --text "$(cat notes.md)" --title "Sprint Notes"`,
},
Output: "envelope.data is the created Knowledge object with id, knowledge_base_id, title, parse_status",
})
return cmd
}
// runCreate creates a manual knowledge entry via SDK CreateManualKnowledge.
func runCreate(ctx context.Context, opts *CreateOptions, fopts *cmdutil.FormatOptions, svc CreateService, kbID string) error {
// Guard against empty text: cobra's MarkFlagRequired enforces this for
// normal CLI invocations; this check protects tests that call runCreate
// directly and any future callers that bypass cobra.
if opts.Text == "" {
return cmdutil.NewFlagError(fmt.Errorf("--text is required"))
}
req := &sdk.CreateManualKnowledgeRequest{
Title: opts.Title,
Content: opts.Text,
TagID: opts.TagID,
Channel: cmp.Or(opts.Channel, uploadChannel),
}
k, err := svc.CreateManualKnowledge(ctx, kbID, req)
if err != nil {
return cmdutil.WrapHTTP(err, "create document")
}
// Inline-created docs land in parse_status=draft and are NOT auto-queued
// for parsing (unlike `doc upload`), so they aren't searchable until
// reparsed. Surface the next step so an agent doesn't `doc wait` into a
// timeout or `search` into empty results.
var meta *output.Meta
if k.ParseStatus == "draft" {
meta = &output.Meta{Hint: "document created in parse_status=draft (not yet indexed) — run `weknora doc reparse " + k.ID + "` to parse & make it searchable"}
}
if fopts.WantsJSON() {
return fopts.Emit(iostreams.IO.Out, k, meta)
}
displayed := opts.Title
if displayed == "" {
displayed = k.Title
}
if displayed == "" {
displayed = k.ID
}
fmt.Fprintf(iostreams.IO.Out, "✓ Created %q (id: %s)\n", displayed, k.ID)
if meta != nil {
fmt.Fprintf(iostreams.IO.Out, " ⚠ %s\n", meta.Hint)
}
return nil
}
+170
View File
@@ -0,0 +1,170 @@
package doc
import (
"bytes"
"context"
"encoding/json"
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
// fakeCreateSvc captures call arguments and returns canned responses.
type fakeCreateSvc struct {
resp *sdk.Knowledge
err error
got struct {
kbID string
req *sdk.CreateManualKnowledgeRequest
}
}
func (f *fakeCreateSvc) CreateManualKnowledge(
_ context.Context,
kbID string,
req *sdk.CreateManualKnowledgeRequest,
) (*sdk.Knowledge, error) {
f.got.kbID = kbID
f.got.req = req
return f.resp, f.err
}
// TestCreate_TitleSetsRequestTitle: --title is the sole title flag and flows
// straight into the create request.
func TestCreate_TitleSetsRequestTitle(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeCreateSvc{resp: &sdk.Knowledge{ID: "d1"}}
require.NoError(t, runCreate(context.Background(),
&CreateOptions{Text: "x", Title: "FromTitle"},
&cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb1"))
assert.Equal(t, "FromTitle", svc.got.req.Title)
}
func TestCreate_Success_Text(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeCreateSvc{resp: &sdk.Knowledge{ID: "doc_manual_1", Title: "Sprint Notes"}}
opts := &CreateOptions{Text: "# Sprint Notes\n\nAction items: ...", Title: "Sprint Notes"}
require.NoError(t, runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
assert.Equal(t, "kb_xxx", svc.got.kbID)
assert.Equal(t, "# Sprint Notes\n\nAction items: ...", svc.got.req.Content)
assert.Equal(t, "Sprint Notes", svc.got.req.Title)
assert.Contains(t, out.String(), "Created")
assert.Contains(t, out.String(), "doc_manual_1")
}
func TestCreate_Success_NoName_FallsBackToTitle(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeCreateSvc{resp: &sdk.Knowledge{ID: "doc_manual_2", Title: "Server Title"}}
opts := &CreateOptions{Text: "Some content"}
require.NoError(t, runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
// When --name is omitted the display falls back to k.Title from the server response.
assert.Contains(t, out.String(), "Server Title")
}
func TestCreate_Success_NoName_NoTitle_FallsBackToID(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeCreateSvc{resp: &sdk.Knowledge{ID: "doc_manual_3"}}
opts := &CreateOptions{Text: "Some content"}
require.NoError(t, runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
assert.Contains(t, out.String(), "doc_manual_3")
}
func TestCreate_TagID_Forwarded(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeCreateSvc{resp: &sdk.Knowledge{ID: "doc_t"}}
opts := &CreateOptions{Text: "content", TagID: "tag_42"}
require.NoError(t, runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
assert.Equal(t, "tag_42", svc.got.req.TagID)
}
func TestCreate_Channel_Override(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeCreateSvc{resp: &sdk.Knowledge{ID: "doc_ch"}}
opts := &CreateOptions{Text: "content", Channel: "browser_extension"}
require.NoError(t, runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
assert.Equal(t, "browser_extension", svc.got.req.Channel)
}
func TestCreate_Channel_DefaultIsAPI(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeCreateSvc{resp: &sdk.Knowledge{ID: "doc_ch"}}
opts := &CreateOptions{Text: "content"}
require.NoError(t, runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
assert.Equal(t, uploadChannel, svc.got.req.Channel)
}
func TestCreate_JSON_Envelope(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeCreateSvc{resp: &sdk.Knowledge{ID: "doc_manual_json", Title: "My Note"}}
opts := &CreateOptions{Text: "# My Note", Title: "My Note"}
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}
require.NoError(t, runCreate(context.Background(), opts, fopts, svc, "kb_xxx"))
got := out.String()
var env struct {
OK bool `json:"ok"`
Data sdk.Knowledge `json:"data"`
}
require.NoError(t, json.Unmarshal([]byte(got), &env), "expected valid JSON envelope, got %q", got)
assert.True(t, env.OK, "envelope.ok must be true")
assert.Equal(t, "doc_manual_json", env.Data.ID, "envelope.data.id must match")
}
func TestCreate_ServerError_Wraps(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeCreateSvc{err: errors.New("HTTP error 500: internal server error")}
err := runCreate(context.Background(),
&CreateOptions{Text: "content"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx")
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeServerError, typed.Code)
}
func TestCreate_HTTPError_400_Wraps(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeCreateSvc{err: errors.New("HTTP error 400: bad request")}
err := runCreate(context.Background(),
&CreateOptions{Text: "content"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx")
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
}
func TestCreate_EmptyText_RejectsBeforeSDK(t *testing.T) {
_, _ = iostreams.SetForTest(t)
// The SDK should never be called when --text is empty (runCreate has a
// defensive guard; cobra's MarkFlagRequired catches it earlier in RunE).
svc := &fakeCreateSvc{resp: &sdk.Knowledge{ID: "should-not-reach"}}
err := runCreate(context.Background(),
&CreateOptions{Text: ""}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx")
require.Error(t, err)
// Verify the SDK was NOT called.
assert.Nil(t, svc.got.req, "SDK must not be called when --text is empty")
}
// TestCreate_AgentHelp_EmitsUsedFor verifies that when WEKNORA_AGENT_HELP=1
// the `doc create --help` path emits a JSON blob containing "used_for".
// This is the representative test for 3.2; the mechanism is covered by
// internal/cmdutil/agenthelp_test.go — we test the wiring here.
func TestCreate_AgentHelp_EmitsUsedFor(t *testing.T) {
t.Setenv("WEKNORA_AGENT_HELP", "1")
_, _ = iostreams.SetForTest(t)
f := &cmdutil.Factory{}
cmd := NewCmdCreate(f)
var buf bytes.Buffer
cmd.SetOut(&buf)
cmd.HelpFunc()(cmd, nil)
assert.Contains(t, buf.String(), `"used_for"`)
}
+227
View File
@@ -0,0 +1,227 @@
package doc
import (
"context"
"fmt"
"github.com/spf13/cobra"
sdk "github.com/Tencent/WeKnora/client"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/prompt"
)
// docDeleteFields enumerates the fields surfaced for `--format json` discovery
// on `doc delete`. The result payload is a small {id, deleted} object.
var docDeleteFields = []string{"id", "deleted"}
type DeleteOptions struct {
Yes bool // sourced from the global -y/--yes persistent flag (see cli/cmd/root.go)
All bool // delete all docs in --kb
KB string // required when --all
DryRun bool
}
// DeleteService is the narrow SDK surface this command depends on.
// *sdk.Client satisfies it.
type DeleteService interface {
DeleteKnowledge(ctx context.Context, id string) error
}
// AllService is the narrow SDK surface for --all mode.
// *sdk.Client satisfies it.
type AllService interface {
ClearKnowledgeBaseContents(ctx context.Context, kbID string) (*sdk.ClearKnowledgeBaseContentsResponse, error)
}
// deleteResult is the typed payload emitted under data on success (single-id).
type deleteResult struct {
ID string `json:"id"`
Deleted bool `json:"deleted"`
}
// NewCmdDelete builds `weknora doc delete`. Single-id keeps the simpler
// code path (one confirm prompt, exit 0/1); multi-id uses keep-going
// semantics (one -y confirms all, failures collected, exit 1 if any fail);
// --all --kb=<id> atomically clears every document in a knowledge base.
func NewCmdDelete(f *cmdutil.Factory) *cobra.Command {
opts := &DeleteOptions{}
cmd := &cobra.Command{
Use: "delete <doc-id> [<doc-id>...] | --all --kb=<kb-id>",
Short: "Delete one or more documents from a knowledge base",
Long: `Permanently deletes one or more documents. Prompts for confirmation by
default when stdout is a TTY and JSON output is not set; pass -y/--yes
(global flag) to skip the prompt (required in agent / CI / piped contexts).
Single-id: one confirm prompt, exit 0/1.
Multi-id:
• Default keep-going: failed deletes do NOT stop the run; failures collected.
• One -y/--yes confirms all documents.
• TTY prompt shows total: "Delete N document(s)? This cannot be undone."
• Exit 0 if all succeed; exit 1 if any failed.
All-in-KB (--all --kb=<kb-id>):
• Atomically clears every document in the named knowledge base.
• The KB record itself (name, config) is preserved.
• Mutually exclusive with positional doc ids.
• Exit 0 on success; exit 10 without -y in non-interactive/JSON mode.
AI agents: This is a high-risk write. Without -y/--yes the CLI exits 10
and writes input.confirmation_required to stderr. NEVER auto-pass -y
without the user's explicit go-ahead.`,
Example: ` weknora doc delete doc_abc # interactive confirm
weknora doc delete doc_abc -y # no prompt
weknora doc delete doc_abc -y --format json # bare {id, deleted:true} JSON
weknora doc delete doc_a doc_b doc_c -y # delete 3, keep-going
weknora doc delete doc_a doc_b --format json # multi-id JSON output
weknora doc delete --all --kb=kb_x -y # clear all docs in kb_x
weknora doc delete --all --kb=kb_x -y --format json # agent-friendly`,
Args: cobra.ArbitraryArgs,
RunE: func(c *cobra.Command, args []string) error {
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
opts.Yes, _ = c.Flags().GetBool("yes")
// Structural validation (pure-local) must run before the dry-run
// gate so --dry-run rejects the same invalid invocations the live
// path rejects. Same typed errors are kept below the gate for
// safety (in case of future callers that bypass RunE).
if opts.All {
if opts.KB == "" {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "--all requires --kb=<id>").
WithHint("specify --kb=<uuid-or-name> to scope the delete-all operation").
WithRetryArgv([]string{"weknora", "doc", "delete", "--all", "--kb=<kb-id>", "-y"})
}
if len(args) > 0 {
return cmdutil.NewFlagError(fmt.Errorf("--all is exclusive with positional doc ids"))
}
} else if len(args) == 0 {
return cmdutil.NewFlagError(fmt.Errorf("doc id(s) required (or use --all --kb=<id>)"))
}
if opts.DryRun {
plan := cmdutil.DryRunPlan{
Action: "doc.delete",
Args: map[string]any{"doc_ids": args},
}
if opts.All {
plan.Action = "doc.delete_all"
plan.Args = map[string]any{"all": true, "kb": opts.KB}
}
if handled, err := cmdutil.HandleDryRun(c, true, plan); handled {
return err
}
}
cli, err := f.Client()
if err != nil {
return err
}
if opts.All {
// Resolve KB name → id before the destructive SDK call.
// Must use cmdutil.ResolveKBFlag (id-or-name, lists KBs and
// matches by name) rather than Factory.ResolveKB, which walks
// the project link — a destructive "empty the KB" must never
// inherit an implicit linked KB.
kbID, err := cmdutil.ResolveKBFlag(c.Context(), cli, opts.KB)
if err != nil {
return err
}
opts.KB = kbID
return runDeleteAll(c.Context(), opts, fopts, cli, f.Prompter())
}
// Single-id uses the simpler code path (bare {id, deleted}).
if len(args) == 1 {
return runDelete(c.Context(), opts, fopts, cli, f.Prompter(), args[0])
}
batchRetry := append([]string{"weknora", "doc", "delete"}, args...)
batchRetry = append(batchRetry, "-y")
if err := cmdutil.ConfirmDestructiveBatch(f.Prompter(), opts.Yes, fopts.WantsJSON(), "delete", "document", len(args), "doc.delete", batchRetry); err != nil {
return err
}
outcomes, runErr := cmdutil.RunBatch(c.Context(), args, func(ctx context.Context, id string) error {
if err := cli.DeleteKnowledge(ctx, id); err != nil {
return cmdutil.WrapHTTP(err, "delete document %s", id)
}
return nil
})
// Only emit when the operation actually ran. Pre-flight errors
// (e.g. confirmation_required) must leave stdout empty per the
// wire contract in README.md.
if len(outcomes) > 0 {
if emitErr := cmdutil.EmitBatch(outcomes, fopts, iostreams.IO.Out, cmdutil.DeletedAtNow); emitErr != nil {
return emitErr
}
}
return runErr
},
}
cmd.Flags().BoolVar(&opts.All, "all", false, "delete all documents in the KB specified by --kb")
cmd.Flags().StringVar(&opts.KB, "kb", "", "Knowledge base UUID or name (required with --all)")
cmdutil.AddFormatFlag(cmd, docDeleteFields...)
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
cmdutil.SetRisk(cmd, "doc.delete")
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "permanently delete one or more documents from a knowledge base",
RequiredFlags: []string{"<doc-id>... (positional) | --all --kb=<uuid-or-name>"},
Examples: []string{
"weknora doc delete doc_abc -y",
"weknora doc delete doc_a doc_b doc_c -y",
"weknora doc delete --all --kb=kb_x -y --format json",
},
Output: "envelope.data shape depends on the form: a single doc id -> {id, deleted:true}; multiple doc ids -> batch envelope (top-level status success|partial|error, ok=(failures==0), data per-item [{id, ok, error?}], meta.successes/failures — read data[].ok per id, ok:true overall does not survive a partial failure / exit 1); --all --kb -> {kb_id, deleted_count}.",
Warnings: []string{
"Requires explicit user approval (exit 10 / input.confirmation_required); never auto-add -y.",
"doc delete is irreversible; loses the document + its chunks + embeddings.",
"--all empties the entire KB; thousands of documents may be lost in one operation.",
},
})
return cmd
}
func runDelete(ctx context.Context, opts *DeleteOptions, fopts *cmdutil.FormatOptions, svc DeleteService, p prompt.Prompter, id string) error {
if err := cmdutil.ConfirmDestructive(p, opts.Yes, fopts.WantsJSON(), "delete", "document", id, "doc.delete", []string{"weknora", "doc", "delete", id, "-y"}); err != nil {
return err
}
if err := svc.DeleteKnowledge(ctx, id); err != nil {
return cmdutil.WrapHTTP(err, "delete document %s", id)
}
if fopts.WantsJSON() {
return fopts.Emit(iostreams.IO.Out, deleteResult{ID: id, Deleted: true}, nil)
}
fmt.Fprintf(iostreams.IO.Out, "✓ Deleted document %s\n", id)
return nil
}
// runDeleteAll atomically clears every document in opts.KB via a single
// ClearKnowledgeBaseContents call. Non-TTY/JSON mode without -y returns
// CodeInputConfirmationRequired (exit 10) with risk metadata so agents can
// surface the risk to the user before re-invoking with -y.
func runDeleteAll(ctx context.Context, opts *DeleteOptions, fopts *cmdutil.FormatOptions, svc AllService, p prompt.Prompter) error {
if err := cmdutil.ConfirmDestructive(p, opts.Yes, fopts.WantsJSON(), "delete", "all docs in KB", opts.KB, "doc.delete_all", []string{"weknora", "doc", "delete", "--all", "--kb=" + opts.KB, "-y"}); err != nil {
return err
}
resp, err := svc.ClearKnowledgeBaseContents(ctx, opts.KB)
if err != nil {
return cmdutil.WrapHTTP(err, "clear KB %s", opts.KB)
}
deleted := 0
if resp != nil {
deleted = resp.DeletedCount
}
if !fopts.WantsJSON() {
fmt.Fprintf(iostreams.IO.Out, "✓ Deleted %d document(s) from KB %s\n", deleted, opts.KB)
return nil
}
return fopts.Emit(iostreams.IO.Out, map[string]any{
"kb_id": opts.KB,
"deleted_count": deleted,
}, nil)
}
+584
View File
@@ -0,0 +1,584 @@
package doc
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
sdk "github.com/Tencent/WeKnora/client"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/config"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/prompt"
"github.com/Tencent/WeKnora/cli/internal/testutil"
)
// fakeAllSvc implements AllService for --all mode tests.
type fakeAllSvc struct {
err error
gotID string
called bool
resp *sdk.ClearKnowledgeBaseContentsResponse
}
func (f *fakeAllSvc) ClearKnowledgeBaseContents(_ context.Context, kbID string) (*sdk.ClearKnowledgeBaseContentsResponse, error) {
f.called = true
f.gotID = kbID
if f.err != nil {
return nil, f.err
}
if f.resp == nil {
return &sdk.ClearKnowledgeBaseContentsResponse{DeletedCount: 0}, nil
}
return f.resp, nil
}
// fakeDeleteSvc captures calls and returns canned errors.
// errFor maps id → error for per-id failure injection (used in multi-id tests).
type fakeDeleteSvc struct {
err error
errFor map[string]error
got string
calls int
// deleted tracks all successfully deleted ids (multi-id tests).
deleted []string
}
func (f *fakeDeleteSvc) DeleteKnowledge(_ context.Context, id string) error {
f.calls++
f.got = id
if f.errFor != nil {
if err, ok := f.errFor[id]; ok {
return err
}
f.deleted = append(f.deleted, id)
return nil
}
if f.err != nil {
return f.err
}
f.deleted = append(f.deleted, id)
return nil
}
// ---------------------------------------------------------------------------
// Single-id tests — runDelete uses the simpler {id, deleted} payload.
// ---------------------------------------------------------------------------
func TestDelete_Success_WithForce(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeDeleteSvc{}
opts := &DeleteOptions{Yes: true}
// Force=true short-circuits the confirm path; the prompter must not be
// consulted, so any value works.
require.NoError(t, runDelete(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{Answer: false}, "doc_abc"))
assert.Equal(t, "doc_abc", svc.got)
assert.Equal(t, 1, svc.calls)
assert.Contains(t, out.String(), "✓")
assert.Contains(t, out.String(), "doc_abc")
}
func TestDelete_Success_JSON(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeDeleteSvc{}
opts := &DeleteOptions{Yes: true}
require.NoError(t, runDelete(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, &testutil.ConfirmPrompter{Answer: true}, "doc_abc"))
got := out.String()
var env struct {
OK bool `json:"ok"`
Data map[string]any `json:"data"`
}
require.NoError(t, json.Unmarshal([]byte(got), &env), "expected valid JSON envelope, got %q", got)
assert.True(t, env.OK, "envelope.ok must be true")
assert.Equal(t, "doc_abc", env.Data["id"], "envelope.data.id must be doc_abc")
assert.Equal(t, true, env.Data["deleted"], "envelope.data.deleted must be true")
}
func TestDelete_NotFound_404(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeDeleteSvc{err: errors.New("HTTP error 404: not found")}
err := runDelete(context.Background(), &DeleteOptions{Yes: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{}, "doc_missing")
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeResourceNotFound, typed.Code)
}
func TestDelete_HTTPError_500(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeDeleteSvc{err: errors.New("HTTP error 500: internal")}
err := runDelete(context.Background(), &DeleteOptions{Yes: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{}, "doc_x")
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
// Single-id delete WrapHTTP-classifies the SDK error; HTTP 500 → server.error.
// (The multi-id path rolls up failures as operation.failed; this is the
// single-id path so it stays server.error.)
assert.Equal(t, cmdutil.CodeServerError, typed.Code)
}
func TestDelete_ConfirmYes(t *testing.T) {
out, _ := iostreams.SetForTestWithTTY(t)
svc := &fakeDeleteSvc{}
err := runDelete(context.Background(), &DeleteOptions{Yes: false}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{Answer: true}, "doc_abc")
require.NoError(t, err)
assert.Equal(t, 1, svc.calls, "user said yes ⇒ delete proceeds")
assert.Contains(t, out.String(), "✓")
}
func TestDelete_ConfirmNo(t *testing.T) {
_, errBuf := iostreams.SetForTestWithTTY(t)
svc := &fakeDeleteSvc{}
err := runDelete(context.Background(), &DeleteOptions{Yes: false}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{Answer: false}, "doc_abc")
require.Error(t, err)
assert.Equal(t, 0, svc.calls, "user said no ⇒ SDK must NOT be called")
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeUserAborted, typed.Code)
assert.Contains(t, errBuf.String(), "Aborted.")
}
// TestDelete_AgentPrompterErrors covers the path where the prompter itself
// returns an error (e.g. AgentPrompter, broken stdin). runDelete maps this to
// CodeInputMissingFlag so the user sees "pass --force" in the hint.
func TestDelete_AgentPrompterErrors(t *testing.T) {
_, _ = iostreams.SetForTestWithTTY(t)
svc := &fakeDeleteSvc{}
err := runDelete(context.Background(), &DeleteOptions{Yes: false}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{Err: errors.New("no tty")}, "doc_abc")
require.Error(t, err)
assert.Equal(t, 0, svc.calls)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeInputMissingFlag, typed.Code)
}
// TestDelete_NoYes_NonTTY_RequiresConfirmation: when stdout isn't a TTY
// (typical agent pipe / CI), the destructive-write protocol requires
// explicit -y/--yes. The CLI exits 10 with input.confirmation_required,
// never silently proceeds. See cli/README.md "Exit codes".
func TestDelete_NoYes_NonTTY_RequiresConfirmation(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeDeleteSvc{}
err := runDelete(context.Background(), &DeleteOptions{Yes: false}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{Err: errors.New("no tty")}, "doc_abc")
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))
}
// ---------------------------------------------------------------------------
// Multi-id tests (cmdutil.RunBatch, keep-going semantics)
// ---------------------------------------------------------------------------
func TestRunMultiDelete_AllSucceed(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeDeleteSvc{}
outcomes, err := cmdutil.RunBatch(
context.Background(),
[]string{"a", "b", "c"},
func(ctx context.Context, id string) error {
if err := svc.DeleteKnowledge(ctx, id); err != nil {
return cmdutil.WrapHTTP(err, "delete document %s", id)
}
return nil
},
)
require.NoError(t, err)
require.Len(t, outcomes, 3)
for _, oc := range outcomes {
assert.Nil(t, oc.Err, "expected no error for id %s", oc.ID)
}
assert.Equal(t, "a", outcomes[0].ID)
assert.Equal(t, "b", outcomes[1].ID)
assert.Equal(t, "c", outcomes[2].ID)
assert.Equal(t, 3, svc.calls)
}
func TestRunMultiDelete_KeepGoingOnError(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeDeleteSvc{errFor: map[string]error{"doc_b": errors.New("not found")}}
outcomes, err := cmdutil.RunBatch(
context.Background(),
[]string{"doc_a", "doc_b", "doc_c"},
func(ctx context.Context, id string) error {
if err := svc.DeleteKnowledge(ctx, id); err != nil {
return cmdutil.WrapHTTP(err, "delete document %s", id)
}
return nil
},
)
require.Error(t, err, "partial failure must return non-nil error (exit 1)")
assert.Equal(t, 3, svc.calls, "all ids must be attempted (keep-going)")
require.Len(t, outcomes, 3)
// order preserved: doc_a ok, doc_b fail, doc_c ok
assert.Equal(t, "doc_a", outcomes[0].ID)
assert.Nil(t, outcomes[0].Err)
assert.Equal(t, "doc_b", outcomes[1].ID)
assert.NotNil(t, outcomes[1].Err)
assert.Equal(t, "doc_c", outcomes[2].ID)
assert.Nil(t, outcomes[2].Err)
}
func TestRunMultiDelete_AllFail(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeDeleteSvc{errFor: map[string]error{
"x": errors.New("HTTP error 404: not found"),
"y": errors.New("HTTP error 403: forbidden"),
}}
outcomes, err := cmdutil.RunBatch(
context.Background(),
[]string{"x", "y"},
func(ctx context.Context, id string) error {
if err := svc.DeleteKnowledge(ctx, id); err != nil {
return cmdutil.WrapHTTP(err, "delete document %s", id)
}
return nil
},
)
require.Error(t, err)
require.Len(t, outcomes, 2)
assert.NotNil(t, outcomes[0].Err)
assert.NotNil(t, outcomes[1].Err)
}
func TestRunMultiDelete_ConfirmBatch_NonTTY_RequiresConfirmation(t *testing.T) {
_, _ = iostreams.SetForTest(t) // non-TTY
svc := &fakeDeleteSvc{}
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}
err := cmdutil.ConfirmDestructiveBatch(&testutil.ConfirmPrompter{Answer: false}, false, fopts.WantsJSON(), "delete", "document", 2, "doc.delete", nil)
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, "must not call DeleteKnowledge without confirmation")
}
func TestRunMultiDelete_ConfirmBatch_TTY_UserAborts(t *testing.T) {
_, errBuf := iostreams.SetForTestWithTTY(t)
svc := &fakeDeleteSvc{}
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatText}
err := cmdutil.ConfirmDestructiveBatch(&testutil.ConfirmPrompter{Answer: false}, false, fopts.WantsJSON(), "delete", "document", 3, "doc.delete", nil)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeUserAborted, typed.Code)
assert.Contains(t, errBuf.String(), "Aborted.")
assert.Equal(t, 0, svc.calls, "user aborted ⇒ SDK must NOT be called")
}
// ---------------------------------------------------------------------------
// Emit tests — JSON path now emits the batch envelope
// ---------------------------------------------------------------------------
// batchEnvelope is a minimal struct for parsing the batch envelope shape.
type batchEnvelope struct {
OK bool `json:"ok"`
Data []batchItem `json:"data"`
Meta batchMeta `json:"meta"`
}
type batchItem struct {
ID string `json:"id"`
OK bool `json:"ok"`
Result json.RawMessage `json:"result,omitempty"`
Error *batchItemError `json:"error,omitempty"`
}
type batchItemError struct {
Type string `json:"type"`
Message string `json:"message"`
}
type batchMeta struct {
Count int `json:"count"`
Successes int `json:"successes"`
Failures int `json:"failures"`
}
func TestEmitMultiDelete_JSON(t *testing.T) {
var buf bytes.Buffer
outcomes := []cmdutil.BatchOutcome{
{ID: "a", Err: nil},
{ID: "b", Err: nil},
{ID: "c", Err: errors.New("x")},
}
err := cmdutil.EmitBatch(outcomes, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, &buf, cmdutil.DeletedAtNow)
require.NoError(t, err)
var got batchEnvelope
require.NoError(t, json.Unmarshal(buf.Bytes(), &got))
// top-level: partial failure → ok:false
assert.False(t, got.OK)
assert.Equal(t, 3, got.Meta.Count)
assert.Equal(t, 2, got.Meta.Successes)
assert.Equal(t, 1, got.Meta.Failures)
require.Len(t, got.Data, 3)
assert.Equal(t, "a", got.Data[0].ID)
assert.True(t, got.Data[0].OK)
assert.Equal(t, "b", got.Data[1].ID)
assert.True(t, got.Data[1].OK)
assert.Equal(t, "c", got.Data[2].ID)
assert.False(t, got.Data[2].OK)
assert.NotNil(t, got.Data[2].Error)
}
func TestEmitMultiDelete_Text(t *testing.T) {
var buf bytes.Buffer
outcomes := []cmdutil.BatchOutcome{
{ID: "a", Err: nil},
{ID: "b", Err: errors.New("boom")},
}
err := cmdutil.EmitBatch(outcomes, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &buf, nil)
require.NoError(t, err)
out := buf.String()
assert.Contains(t, out, "OK a")
assert.Contains(t, out, "FAIL b:")
assert.Contains(t, out, "boom")
}
func TestEmitMultiDelete_TextEmpty(t *testing.T) {
var buf bytes.Buffer
outcomes := []cmdutil.BatchOutcome{
{ID: "x", Err: nil},
{ID: "y", Err: nil},
}
err := cmdutil.EmitBatch(outcomes, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &buf, nil)
require.NoError(t, err)
out := buf.String()
assert.Contains(t, out, "OK x")
assert.Contains(t, out, "OK y")
assert.NotContains(t, out, "FAIL")
}
func TestEmitMultiDelete_UnsupportedFormat(t *testing.T) {
// EmitBatch defers unsupported format handling to WriteBatchEnvelope; for
// non-JSON formats it falls through to text. Verify it does not error.
var buf bytes.Buffer
outcomes := []cmdutil.BatchOutcome{}
err := cmdutil.EmitBatch(outcomes, &cmdutil.FormatOptions{Mode: "yaml"}, &buf, nil)
// EmitBatch itself does not error on unknown mode (it uses WantsJSON gate)
require.NoError(t, err)
}
// ---------------------------------------------------------------------------
// Batch envelope shape test — Task 2.6
// ---------------------------------------------------------------------------
// TestDocDelete_MultiID_PartialFailure_BatchEnvelope verifies that when a
// multi-id delete has a partial failure, stdout carries the batch
// envelope shape: ok:false, data:[BatchItem...], meta:{count, successes,
// failures}. Order follows original argv order.
func TestDocDelete_MultiID_PartialFailure_BatchEnvelope(t *testing.T) {
// id1 succeeds, id2 fails, id3 succeeds.
svc := &fakeDeleteSvc{errFor: map[string]error{
"id2": errors.New("HTTP error 404: not found"),
}}
outcomes, runErr := cmdutil.RunBatch(
context.Background(),
[]string{"id1", "id2", "id3"},
func(ctx context.Context, id string) error {
if err := svc.DeleteKnowledge(ctx, id); err != nil {
return cmdutil.WrapHTTP(err, "delete document %s", id)
}
return nil
},
)
require.Error(t, runErr, "partial failure must return non-nil error")
require.Len(t, outcomes, 3)
var buf bytes.Buffer
require.NoError(t, cmdutil.EmitBatch(outcomes, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON, TTY: false}, &buf, cmdutil.DeletedAtNow))
var env batchEnvelope
require.NoError(t, json.Unmarshal(buf.Bytes(), &env))
// top-level ok:false (partial failure)
assert.False(t, env.OK)
// meta counts
assert.Equal(t, 3, env.Meta.Count)
assert.Equal(t, 2, env.Meta.Successes)
assert.Equal(t, 1, env.Meta.Failures)
// items in argv order
require.Len(t, env.Data, 3)
assert.Equal(t, "id1", env.Data[0].ID)
assert.True(t, env.Data[0].OK)
assert.Nil(t, env.Data[0].Error)
assert.Equal(t, "id2", env.Data[1].ID)
assert.False(t, env.Data[1].OK)
require.NotNil(t, env.Data[1].Error)
assert.Equal(t, "resource.not_found", env.Data[1].Error.Type)
assert.Equal(t, "id3", env.Data[2].ID)
assert.True(t, env.Data[2].OK)
assert.Nil(t, env.Data[2].Error)
}
// ---------------------------------------------------------------------------
// --all mode tests (runDeleteAll)
// ---------------------------------------------------------------------------
// TestDocDelete_All_MissingKB_ReturnsFlagError: --all without --kb must exit 2.
func TestDocDelete_All_MissingKB_ReturnsFlagError(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeAllSvc{}
// Simulate the RunE guard: --all without --kb returns FlagError before
// runDeleteAll is ever called.
err := cmdutil.NewFlagError(fmt.Errorf("--all requires --kb=<id>"))
require.Error(t, err)
var flagErr *cmdutil.FlagError
require.ErrorAs(t, err, &flagErr)
assert.Equal(t, 2, cmdutil.ExitCode(err))
assert.False(t, svc.called)
}
// TestDocDelete_All_WithoutYes_JSONMode_ReturnsExit10 verifies the
// CodeInputConfirmationRequired (exit 10) path with risk metadata in JSON/non-TTY.
func TestDocDelete_All_WithoutYes_JSONMode_ReturnsExit10(t *testing.T) {
_, _ = iostreams.SetForTest(t) // non-TTY
svc := &fakeAllSvc{}
opts := &DeleteOptions{All: true, KB: "kb_x", Yes: false}
err := runDeleteAll(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, &testutil.ConfirmPrompter{})
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeInputConfirmationRequired, typed.Code)
assert.Equal(t, 10, cmdutil.ExitCode(err))
require.NotNil(t, typed.Risk)
assert.Equal(t, "destructive", typed.Risk.Level)
assert.Equal(t, "doc.delete_all", typed.Risk.Action)
assert.False(t, svc.called)
}
// TestDocDelete_All_WithYes_CallsClearKB verifies that with -y the call is made
// and the JSON envelope contains kb_id + deleted_count.
func TestDocDelete_All_WithYes_CallsClearKB(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeAllSvc{resp: &sdk.ClearKnowledgeBaseContentsResponse{DeletedCount: 17}}
opts := &DeleteOptions{All: true, KB: "kb_x", Yes: true}
err := runDeleteAll(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, &testutil.ConfirmPrompter{})
require.NoError(t, err)
assert.True(t, svc.called)
assert.Equal(t, "kb_x", svc.gotID)
var env struct {
OK bool `json:"ok"`
Data map[string]any `json:"data"`
}
require.NoError(t, json.Unmarshal(out.Bytes(), &env), "expected valid JSON envelope, got %q", out.String())
assert.True(t, env.OK)
assert.Equal(t, "kb_x", env.Data["kb_id"])
assert.Equal(t, float64(17), env.Data["deleted_count"])
}
// TestDocDelete_All_WithYes_TextMode verifies the text output path.
func TestDocDelete_All_WithYes_TextMode(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeAllSvc{resp: &sdk.ClearKnowledgeBaseContentsResponse{DeletedCount: 5}}
opts := &DeleteOptions{All: true, KB: "kb_y", Yes: true}
err := runDeleteAll(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{})
require.NoError(t, err)
assert.True(t, svc.called)
body := out.String()
assert.Contains(t, body, "5")
assert.Contains(t, body, "kb_y")
}
// TestDocDelete_All_TTY_UserAborts: interactive TTY + user says no → CodeUserAborted.
func TestDocDelete_All_TTY_UserAborts(t *testing.T) {
_, errBuf := iostreams.SetForTestWithTTY(t)
svc := &fakeAllSvc{}
opts := &DeleteOptions{All: true, KB: "kb_z", Yes: false}
err := runDeleteAll(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{Answer: false})
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeUserAborted, typed.Code)
assert.False(t, svc.called)
assert.Contains(t, errBuf.String(), "Aborted")
}
// TestDocDelete_All_ServiceError propagates SDK errors via WrapHTTP.
func TestDocDelete_All_ServiceError(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeAllSvc{err: errors.New("HTTP error 404: not found")}
opts := &DeleteOptions{All: true, KB: "kb_missing", Yes: true}
err := runDeleteAll(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{})
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeResourceNotFound, typed.Code)
}
// ---------------------------------------------------------------------------
// KB name resolution tests (--all --kb resolves name → id)
// ---------------------------------------------------------------------------
// TestDocDelete_All_ResolvesKBNameToID verifies that `doc delete --all --kb eng -y`
// resolves the KB name "eng" to its canonical id "kb_eng" before calling
// ClearKnowledgeBaseContents. Uses an httptest.Server to fake both the
// ListKnowledgeBases and ClearKnowledgeBaseContents endpoints so the real
// *sdk.Client can be injected through the factory (mirrors link_test.go pattern).
func TestDocDelete_All_ResolvesKBNameToID(t *testing.T) {
_, _ = iostreams.SetForTest(t)
var clearCalledWith string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/knowledge-bases":
_ = json.NewEncoder(w).Encode(sdk.KnowledgeBaseListResponse{
Success: true,
Data: []sdk.KnowledgeBase{{ID: "kb_eng", Name: "eng"}},
})
case r.Method == http.MethodDelete && r.URL.Path == "/api/v1/knowledge-bases/kb_eng/knowledge":
clearCalledWith = "kb_eng"
_ = json.NewEncoder(w).Encode(map[string]any{
"success": true,
"data": map[string]any{"deleted_count": 3},
})
default:
http.NotFound(w, r)
}
}))
t.Cleanup(srv.Close)
cli := sdk.NewClient(srv.URL)
f := &cmdutil.Factory{
Config: func() (*config.Config, error) {
return &config.Config{
CurrentProfile: "default",
Profiles: map[string]config.Profile{"default": {Host: srv.URL}},
}, nil
},
Client: func() (*sdk.Client, error) { return cli, nil },
Prompter: func() prompt.Prompter { return &testutil.ConfirmPrompter{Answer: true} },
}
root := withRootHarnessDoc(NewCmdDelete(f), "--all", "--kb", "eng", "-y")
err := root.Execute()
require.NoError(t, err, "doc delete --all --kb eng -y should succeed")
assert.Equal(t, "kb_eng", clearCalledWith,
"ClearKnowledgeBaseContents must be called with resolved id 'kb_eng', not raw name 'eng'")
}
+35
View File
@@ -0,0 +1,35 @@
// Package doc implements the `weknora doc` subtree (list / view / upload /
// fetch / create / download / delete / wait). Upload supports --recursive /
// --glob for bulk ingestion from local files. Fetch ingests a remote URL.
// Create adds a knowledge entry from inline text content.
//
// "Doc" is the CLI noun; the underlying SDK type is `Knowledge`. The renaming
// is deliberate: end-users think of a knowledge entry as the document they
// uploaded, not as an abstract knowledge unit. Mapping happens in this package
// only - the SDK surface and server API keep the original spelling.
package doc
import (
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
)
// NewCmd builds the `weknora doc` parent command.
func NewCmd(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "doc",
Short: "Manage documents in a knowledge base",
}
cmd.AddCommand(NewCmdCreate(f))
cmd.AddCommand(NewCmdDelete(f))
cmd.AddCommand(NewCmdDownload(f))
cmd.AddCommand(NewCmdFetch(f))
cmd.AddCommand(NewCmdList(f))
cmd.AddCommand(NewCmdReparse(f))
cmd.AddCommand(NewCmdUpdate(f))
cmd.AddCommand(NewCmdUpload(f))
cmd.AddCommand(NewCmdView(f))
cmd.AddCommand(NewCmdWait(f))
return cmd
}
+200
View File
@@ -0,0 +1,200 @@
package doc
import (
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
)
// downloadFields enumerates the fields surfaced for `--format json` discovery on
// `doc download`. Matches the downloadResult struct.
var downloadFields = []string{"path", "bytes", "filename"}
// downloadResult is the typed payload emitted as the success envelope when
// --format json is requested and output is going to a file (not stdout).
type downloadResult struct {
Path string `json:"path"`
Bytes int64 `json:"bytes"`
Filename string `json:"filename"`
}
type DownloadOptions struct {
Output string // --output / -O: target path, "-" for stdout, "" for server-suggested filename
Clobber bool // --clobber: allow overwrite of an existing file
}
// DownloadService is the narrow SDK surface this command depends on. The
// CLI calls OpenKnowledgeFile so it can inspect the server-suggested
// filename and refuse-to-overwrite *before* streaming any bytes.
type DownloadService interface {
OpenKnowledgeFile(ctx context.Context, knowledgeID string) (string, io.ReadCloser, error)
}
// NewCmdDownload builds `weknora doc download <id>`. Positional id, output
// flag, `-` sentinel for stdout. Flags: `-O, --output <file>` for
// destination, `--clobber` for overwrite control.
func NewCmdDownload(f *cmdutil.Factory) *cobra.Command {
opts := &DownloadOptions{}
cmd := &cobra.Command{
Use: "download <doc-id>",
Short: "Download a document by ID",
Long: `Streams the document bytes to disk (or stdout with --output -).
Default behavior (no --output): writes to the cwd under the filename the
server suggests via Content-Disposition. If the server doesn't suggest
one, the command errors and asks for --output FILE explicitly.
Existing files are NOT overwritten unless --clobber is passed.
With --format json, on success emits a JSON envelope whose data has
path, bytes, and filename fields. When output is stdout (--output -),
the JSON envelope is suppressed because the raw bytes already occupy
stdout.`,
Example: ` weknora doc download doc_abc # writes ./<server-name>
weknora doc download doc_abc -O report.pdf
weknora doc download doc_abc --output - # stream to stdout (binary safe)
weknora doc download doc_abc -O report.pdf --clobber
weknora doc download doc_abc -O report.pdf --format json # JSON envelope`,
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
cli, err := f.Client()
if err != nil {
return err
}
return runDownload(c.Context(), opts, fopts, cli, args[0])
},
}
cmd.Flags().StringVarP(&opts.Output, "output", "O", "", `Output path; "-" for stdout. Defaults to the server-suggested filename.`)
cmd.Flags().BoolVar(&opts.Clobber, "clobber", false, "Overwrite the output file if it already exists")
cmdutil.AddIgnoredKBFlag(cmd)
cmdutil.AddFormatFlag(cmd, downloadFields...)
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "Download a document's bytes by id. Writes a file (or stdout with --output -).",
RequiredFlags: []string{"<doc-id> (positional)"},
Output: "with --format json (file output): envelope.data has path, bytes, filename; suppressed with --output - (raw bytes to stdout)",
Examples: []string{
"weknora doc download doc_abc --output ./manual.pdf",
"weknora doc download doc_abc --output - > manual.pdf",
},
})
return cmd
}
func runDownload(ctx context.Context, opts *DownloadOptions, fopts *cmdutil.FormatOptions, svc DownloadService, id string) error {
suggested, body, err := svc.OpenKnowledgeFile(ctx, id)
if err != nil {
return cmdutil.WrapHTTP(err, "download %s", id)
}
defer body.Close()
dest, err := resolveDownloadDest(opts, suggested)
if err != nil {
return err
}
if dest == "-" {
// Raw bytes go to stdout — suppress any JSON envelope regardless of
// --format json because both can't occupy stdout simultaneously.
_, err := io.Copy(iostreams.IO.Out, body)
return err
}
if err := refuseIfExists(dest, opts.Clobber); err != nil {
return err
}
return streamToFile(body, dest, fopts)
}
// resolveDownloadDest returns the final destination ("-" for stdout, an
// absolute or relative path otherwise) after applying the --output flag
// and sanitizing the server-suggested name. A server that returns a path-
// like filename (..\, /etc/foo) is rejected - only the basename is
// accepted.
func resolveDownloadDest(opts *DownloadOptions, suggested string) (string, error) {
if opts.Output == "-" {
return "-", nil
}
if opts.Output != "" {
return opts.Output, nil
}
if suggested == "" {
return "", &cmdutil.Error{
Code: cmdutil.CodeInputMissingFlag,
Message: "server did not supply a filename and --output is unset",
Hint: "pass --output FILE (or --output - for stdout)",
}
}
base := filepath.Base(suggested)
if base == "" || base == "." || base == ".." || base == string(filepath.Separator) {
return "", &cmdutil.Error{
Code: cmdutil.CodeInputInvalidArgument,
Message: fmt.Sprintf("server returned an unusable filename %q", suggested),
Hint: "pass --output FILE explicitly",
}
}
return base, nil
}
// refuseIfExists returns CodeInputInvalidArgument when path is present on
// disk and clobber is false. Missing-file is success.
func refuseIfExists(path string, clobber bool) error {
if clobber {
return nil
}
_, err := os.Stat(path)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "stat %s", path)
}
return &cmdutil.Error{
Code: cmdutil.CodeInputInvalidArgument,
Message: fmt.Sprintf("%s already exists", path),
Hint: "pass --clobber to overwrite",
}
}
// streamToFile copies body into a newly-created file at path. On any
// streaming error the partial file is removed so callers don't see a
// truncated artifact at the user-visible path.
//
// On success: if fopts.WantsJSON(), emits a downloadResult envelope to
// stdout instead of the "✓ Saved" text.
func streamToFile(body io.Reader, path string, fopts *cmdutil.FormatOptions) error {
f, err := os.Create(path)
if err != nil {
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "create %s", path)
}
n, copyErr := io.Copy(f, body)
if copyErr != nil {
_ = f.Close()
_ = os.Remove(path)
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, copyErr, "write %s", path)
}
if err := f.Close(); err != nil {
_ = os.Remove(path)
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "close %s", path)
}
if fopts.WantsJSON() {
return fopts.Emit(iostreams.IO.Out, downloadResult{
Path: path,
Bytes: n,
Filename: filepath.Base(path),
}, nil)
}
fmt.Fprintf(iostreams.IO.Err, "✓ Saved %s\n", path)
return nil
}
+201
View File
@@ -0,0 +1,201 @@
package doc
import (
"context"
"encoding/json"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
)
// fakeDownloadSvc scripts an OpenKnowledgeFile response. The fake returns
// a ReadCloser over `content` and reports `filename` as the server-
// suggested name.
type fakeDownloadSvc struct {
content string
filename string
err error
gotID string
}
func (f *fakeDownloadSvc) OpenKnowledgeFile(_ context.Context, id string) (string, io.ReadCloser, error) {
f.gotID = id
if f.err != nil {
return "", nil, f.err
}
return f.filename, io.NopCloser(strings.NewReader(f.content)), nil
}
// textFopts returns a text-mode FormatOptions for tests that don't care
// about JSON output.
func textFopts() *cmdutil.FormatOptions { return &cmdutil.FormatOptions{Mode: cmdutil.FormatText} }
func TestDownload_DefaultUsesServerFilename(t *testing.T) {
_, _ = iostreams.SetForTest(t)
tmp := t.TempDir()
prevWD, _ := os.Getwd()
require.NoError(t, os.Chdir(tmp))
defer os.Chdir(prevWD)
svc := &fakeDownloadSvc{content: "PDF-1.4 bytes", filename: "report.pdf"}
require.NoError(t, runDownload(context.Background(), &DownloadOptions{}, textFopts(), svc, "doc_abc"))
got, err := os.ReadFile(filepath.Join(tmp, "report.pdf"))
require.NoError(t, err)
assert.Equal(t, "PDF-1.4 bytes", string(got))
}
func TestDownload_OutFile(t *testing.T) {
_, _ = iostreams.SetForTest(t)
dest := filepath.Join(t.TempDir(), "out.bin")
svc := &fakeDownloadSvc{content: "hello", filename: "ignored.txt"}
require.NoError(t, runDownload(context.Background(), &DownloadOptions{Output: dest}, textFopts(), svc, "doc_abc"))
got, err := os.ReadFile(dest)
require.NoError(t, err)
assert.Equal(t, "hello", string(got))
}
func TestDownload_OutDash_Stdout(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeDownloadSvc{content: "binary payload", filename: "report.pdf"}
require.NoError(t, runDownload(context.Background(), &DownloadOptions{Output: "-"}, textFopts(), svc, "doc_abc"))
assert.Equal(t, "binary payload", out.String())
}
func TestDownload_NoFilenameFromServer_DefaultPath_Errors(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeDownloadSvc{content: "x", filename: ""}
err := runDownload(context.Background(), &DownloadOptions{}, textFopts(), svc, "doc_abc")
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeInputMissingFlag, typed.Code)
assert.Contains(t, typed.Hint, "--output")
}
func TestDownload_NotFound(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeDownloadSvc{err: errors.New("HTTP error 404: not found")}
err := runDownload(context.Background(), &DownloadOptions{Output: filepath.Join(t.TempDir(), "x")}, textFopts(), svc, "doc_missing")
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeResourceNotFound, typed.Code)
}
func TestDownload_RefusesOverwrite(t *testing.T) {
_, _ = iostreams.SetForTest(t)
dest := filepath.Join(t.TempDir(), "exists.bin")
require.NoError(t, os.WriteFile(dest, []byte("OLD"), 0o644))
svc := &fakeDownloadSvc{content: "NEW", filename: ""}
err := runDownload(context.Background(), &DownloadOptions{Output: dest}, textFopts(), svc, "doc_abc")
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
got, _ := os.ReadFile(dest)
assert.Equal(t, "OLD", string(got), "must not overwrite without --clobber")
}
// TestDownload_RejectsServerPathTraversal proves that a malicious or buggy
// server cannot escape the cwd via Content-Disposition: only the basename
// of the suggested filename is accepted.
func TestDownload_RejectsServerPathTraversal(t *testing.T) {
_, _ = iostreams.SetForTest(t)
tmp := t.TempDir()
prevWD, _ := os.Getwd()
require.NoError(t, os.Chdir(tmp))
defer os.Chdir(prevWD)
// The server sends "../../etc/shadow" - we accept only "shadow" and
// write to cwd.
svc := &fakeDownloadSvc{content: "exfil", filename: "../../etc/shadow"}
require.NoError(t, runDownload(context.Background(), &DownloadOptions{}, textFopts(), svc, "doc_abc"))
// File must land inside cwd; parent dirs untouched.
got, err := os.ReadFile(filepath.Join(tmp, "shadow"))
require.NoError(t, err)
assert.Equal(t, "exfil", string(got))
}
// TestDownload_RejectsBareDotDot covers the literal-".." case: a server
// returning Content-Disposition: attachment; filename=".." would, before
// the rejection list was extended, pass `filepath.Base("..") == ".."`
// through to os.Create and produce a confusing local.file_io wrap.
func TestDownload_RejectsBareDotDot(t *testing.T) {
_, _ = iostreams.SetForTest(t)
for _, name := range []string{"..", "../"} {
_, err := resolveDownloadDest(&DownloadOptions{}, name)
require.Error(t, err, "filename=%q must be rejected", name)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
}
}
func TestDownload_ForceOverwrites(t *testing.T) {
_, _ = iostreams.SetForTest(t)
dest := filepath.Join(t.TempDir(), "exists.bin")
require.NoError(t, os.WriteFile(dest, []byte("OLD"), 0o644))
svc := &fakeDownloadSvc{content: "NEW", filename: ""}
require.NoError(t, runDownload(context.Background(), &DownloadOptions{Output: dest, Clobber: true}, textFopts(), svc, "doc_abc"))
got, _ := os.ReadFile(dest)
assert.Equal(t, "NEW", string(got))
}
// TestDownload_JSONEnvelope verifies that --format json emits a success
// envelope with path/bytes/filename when downloading to a temp file.
func TestDownload_JSONEnvelope(t *testing.T) {
out, _ := iostreams.SetForTest(t)
dest := filepath.Join(t.TempDir(), "report.pdf")
svc := &fakeDownloadSvc{content: "PDF bytes here", filename: "report.pdf"}
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}
require.NoError(t, runDownload(context.Background(), &DownloadOptions{Output: dest}, fopts, svc, "doc_abc"))
// File content must still be written correctly.
got, err := os.ReadFile(dest)
require.NoError(t, err)
assert.Equal(t, "PDF bytes here", string(got))
// stdout must contain the JSON envelope.
var env struct {
OK bool `json:"ok"`
Data struct {
Path string `json:"path"`
Bytes int64 `json:"bytes"`
Filename string `json:"filename"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal([]byte(out.String()), &env), "expected valid JSON envelope, got %q", out.String())
assert.True(t, env.OK)
assert.Equal(t, dest, env.Data.Path)
assert.Equal(t, int64(len("PDF bytes here")), env.Data.Bytes)
assert.Equal(t, "report.pdf", env.Data.Filename)
}
// TestDownload_JSONEnvelope_SuppressedOnStdout verifies that when output is
// stdout (--output -), the JSON envelope is NOT emitted even with --format json
// because raw bytes already occupy stdout.
func TestDownload_JSONEnvelope_SuppressedOnStdout(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeDownloadSvc{content: "binary payload", filename: "report.pdf"}
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}
require.NoError(t, runDownload(context.Background(), &DownloadOptions{Output: "-"}, fopts, svc, "doc_abc"))
// stdout must contain only the raw bytes, not a JSON envelope.
assert.Equal(t, "binary payload", out.String())
}
+142
View File
@@ -0,0 +1,142 @@
// Package doc — dryrun_validation_test.go asserts that --dry-run on
// doc subcommands rejects identically to the live path (validation must run
// before previewing). Before the surrounding fix, validation lived in runX()
// and was reached only after HandleDryRun
// short-circuited, so --dry-run silently emitted plan envelopes for inputs
// the live path would reject — agents got false-positive previews.
package doc
import (
"context"
"errors"
"testing"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/prompt"
sdk "github.com/Tencent/WeKnora/client"
)
// docDryRunFactory builds a Factory whose Client closure panics if invoked —
// dry-run must early-exit before any SDK call. Factory.ResolveKB is a method
// that short-circuits when --kb is a uuid (IsKBID), so the tests below pass
// a literal uuid to avoid the Client()-based name lookup path.
func docDryRunFactory(t *testing.T) *cmdutil.Factory {
t.Helper()
return &cmdutil.Factory{
Client: func() (*sdk.Client, error) {
t.Fatal("dry-run path must not call Factory.Client(); SDK side effect leaked")
return nil, nil
},
Prompter: func() prompt.Prompter {
t.Fatal("dry-run path must not call Factory.Prompter(); confirm-prompt side effect leaked")
return nil
},
}
}
// withRootHarnessDoc wraps a doc subcommand under a synthetic root cmd that
// registers the global persistent flags (mirrors addGlobalFlags in
// cmd/root.go).
func withRootHarnessDoc(sub *cobra.Command, args ...string) *cobra.Command {
root := &cobra.Command{Use: "weknora"}
pf := root.PersistentFlags()
pf.BoolP("yes", "y", false, "")
pf.String("format", "", "")
pf.StringP("jq", "q", "", "")
root.AddCommand(sub)
root.SetArgs(append([]string{sub.Name()}, args...))
root.SetContext(context.Background())
root.SilenceErrors = true
root.SilenceUsage = true
return root
}
// TestDocDelete_DryRun_RejectsAllWithoutKB: --all without --kb is rejected
// on the live path; --dry-run must do the same.
func TestDocDelete_DryRun_RejectsAllWithoutKB(t *testing.T) {
iostreams.SetForTest(t)
root := withRootHarnessDoc(NewCmdDelete(docDryRunFactory(t)),
"--all", "--dry-run", "--format", "json")
err := root.Execute()
require.Error(t, err)
var typed *cmdutil.Error
require.True(t, errors.As(err, &typed), "expected *cmdutil.Error, got %T %v", err, err)
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
}
// TestDocDelete_DryRun_RejectsAllWithIDs: --all + positional ids is mutually
// exclusive; --dry-run must reject the same.
func TestDocDelete_DryRun_RejectsAllWithIDs(t *testing.T) {
iostreams.SetForTest(t)
root := withRootHarnessDoc(NewCmdDelete(docDryRunFactory(t)),
"doc_a", "--all", "--kb", "00000000-0000-0000-0000-000000000001", "--dry-run", "--format", "json")
err := root.Execute()
require.Error(t, err)
// FlagError ⇒ exit 2.
assert.Equal(t, 2, cmdutil.ExitCode(err))
}
// TestDocDelete_DryRun_RejectsNoIDsNoAll: no positional ids and no --all is
// rejected on the live path; --dry-run must do the same.
func TestDocDelete_DryRun_RejectsNoIDsNoAll(t *testing.T) {
iostreams.SetForTest(t)
root := withRootHarnessDoc(NewCmdDelete(docDryRunFactory(t)),
"--dry-run", "--format", "json")
err := root.Execute()
require.Error(t, err)
assert.Equal(t, 2, cmdutil.ExitCode(err))
}
// TestDocFetch_DryRun_RejectsInvalidURL: malformed URL is rejected on the
// live path via cmdutil.ValidateHTTPURL; --dry-run must do the same.
func TestDocFetch_DryRun_RejectsInvalidURL(t *testing.T) {
iostreams.SetForTest(t)
root := withRootHarnessDoc(NewCmdFetch(docDryRunFactory(t)),
"not-a-url", "--kb", "00000000-0000-0000-0000-000000000001", "--dry-run", "--format", "json")
err := root.Execute()
require.Error(t, err, "dry-run must reject malformed URL")
}
// TestDocUpload_DryRun_RejectsMissingPositional: positional file path is
// required (or --recursive); --dry-run must reject identically.
func TestDocUpload_DryRun_RejectsMissingPositional(t *testing.T) {
iostreams.SetForTest(t)
root := withRootHarnessDoc(NewCmdUpload(docDryRunFactory(t)),
"--kb", "00000000-0000-0000-0000-000000000001", "--dry-run", "--format", "json")
err := root.Execute()
require.Error(t, err, "dry-run must reject missing positional file path")
}
// TestDocUpload_DryRun_RejectsBadMetadata: --metadata key=value enforces
// the `key=value` shape; --dry-run must reject malformed values too.
func TestDocUpload_DryRun_RejectsBadMetadata(t *testing.T) {
iostreams.SetForTest(t)
root := withRootHarnessDoc(NewCmdUpload(docDryRunFactory(t)),
"./somefile", "--kb", "00000000-0000-0000-0000-000000000001", "--metadata", "no-equals-sign",
"--dry-run", "--format", "json")
err := root.Execute()
require.Error(t, err, "dry-run must reject malformed --metadata")
var typed *cmdutil.Error
require.True(t, errors.As(err, &typed))
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
}
// TestDocUpload_DryRun_RejectsBadEnableMultimodel: --enable-multimodel
// expects a parseable tri-bool; --dry-run must reject the same garbage
// values the live path rejects.
func TestDocUpload_DryRun_RejectsBadEnableMultimodel(t *testing.T) {
iostreams.SetForTest(t)
root := withRootHarnessDoc(NewCmdUpload(docDryRunFactory(t)),
"./somefile", "--kb", "00000000-0000-0000-0000-000000000001", "--enable-multimodel=garbage",
"--dry-run", "--format", "json")
err := root.Execute()
require.Error(t, err)
var typed *cmdutil.Error
require.True(t, errors.As(err, &typed))
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
}
+168
View File
@@ -0,0 +1,168 @@
// Package doc — fetch.go implements `weknora doc fetch <url>`.
package doc
import (
"cmp"
"context"
"errors"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
// docFetchFields enumerates the fields surfaced for `--format json` discovery
// on `doc fetch`.
var docFetchFields = []string{
"id", "knowledge_base_id", "tag_id", "type", "title", "description",
"source", "channel", "parse_status", "summary_status", "enable_status",
"embedding_model_id", "file_name", "file_type", "file_size", "file_hash",
"file_path", "storage_size",
"created_at", "updated_at", "processed_at", "error_message",
}
// FetchOptions holds CLI flag values for `doc fetch`.
type FetchOptions struct {
URL string
Name string // --name: FileName hint for file-vs-crawl mode detection
Title string // --title: display title for the new entry
FileType string // --file-type: extension hint for extension-less URLs
TagID string // --tag-id: associate the new entry with a tag
Channel string // --channel: ingestion-channel tag (default "api")
EnableMultimodel *bool // tri-state: nil = server default
DryRun bool
}
// FetchService is the narrow SDK surface for `doc fetch`.
// *sdk.Client satisfies it.
type FetchService interface {
CreateKnowledgeFromURL(
ctx context.Context,
kbID string,
req sdk.CreateKnowledgeFromURLRequest,
) (*sdk.Knowledge, error)
}
// NewCmdFetch builds `weknora doc fetch <url>`.
func NewCmdFetch(f *cmdutil.Factory) *cobra.Command {
opts := &FetchOptions{}
cmd := &cobra.Command{
Use: "fetch <url>",
Short: "Fetch a remote document into a knowledge base",
Long: `Server fetches the document at the given URL and ingests it into the resolved
knowledge base. KB resolution follows the standard 4-level chain:
--kb flag > WEKNORA_KB_ID env > .weknora/project.yaml > error.
When the URL has a known file extension (.pdf, .docx, .md, .txt) the server
automatically switches from web-page-crawl mode to file-download mode. Pass
--file-type or --name with a recognisable extension to force file-download mode
for extension-less URLs.
Server-side ingestion knobs:
--name <name> Override the recorded file name; also used as the
file-type hint when the extension is recognisable.
--title <title> Set the display title stored with the entry.
--file-type <ext> Explicit file-type hint (e.g. "pdf") for URLs
without an extension.
--tag-id <id> Associate the new entry with a tag.
--enable-multimodel Toggle multimodal extraction (image-in-PDF → text).
Unset ⇒ server default; pass true or false to override.
--channel <name> Override the ingestion-channel tag (default "api").`,
Example: ` weknora doc fetch https://example.com/whitepaper.pdf
weknora doc fetch https://example.com/no-ext --file-type pdf --title "Whitepaper"
weknora doc fetch https://example.com/article.html --name "Q3 Article" --tag-id tag_abc
weknora doc fetch https://example.com/report.pdf --kb my-kb --enable-multimodel`,
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
opts.URL = args[0]
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
// Pure-local validation runs before the dry-run gate so --dry-run
// rejects identically to the live path.
if c.Flags().Changed("enable-multimodel") {
raw, _ := c.Flags().GetString("enable-multimodel")
v, perr := parseTriBool(raw)
if perr != nil {
return perr
}
opts.EnableMultimodel = &v
}
if err := cmdutil.ValidateHTTPURL("<url>", opts.URL); err != nil {
return err
}
if opts.DryRun {
// Local-only KB resolution: plan reports the raw --kb value
// (UUID or name) without an SDK lookup.
kbID, err := f.ResolveKBLocal(c)
if err != nil {
return err
}
if handled, err := cmdutil.HandleDryRun(c, true, cmdutil.DryRunPlan{
Action: "doc.fetch",
Args: map[string]any{
"url": opts.URL,
"kb": kbID,
},
}); handled {
return err
}
}
cli, err := f.Client()
if err != nil {
return err
}
kbID, err := f.ResolveKB(c)
if err != nil {
return err
}
return runFetch(c.Context(), opts, fopts, cli, kbID)
},
}
cmdutil.AddKBFlag(cmd)
cmd.Flags().StringVar(&opts.Name, "name", "", "File name hint (also used as file-type hint when extension is recognisable)")
cmd.Flags().StringVar(&opts.Title, "title", "", "Display title for the new entry")
cmd.Flags().StringVar(&opts.FileType, "file-type", "", "File-type hint such as \"pdf\" when the URL has no extension")
cmd.Flags().StringVar(&opts.TagID, "tag-id", "", "Tag id to associate with the new entry")
cmd.Flags().StringVar(&opts.Channel, "channel", "", "Ingestion-channel tag recorded server-side (default \"api\")")
cmd.Flags().String("enable-multimodel", "", "Toggle multimodal extraction (true|false); unset ⇒ server default")
cmd.Flags().Lookup("enable-multimodel").NoOptDefVal = "true"
cmdutil.AddFormatFlag(cmd, docFetchFields...)
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "Ingest a remote URL into the resolved knowledge base. KB resolved via --kb flag, WEKNORA_KB_ID env, or project link. Emits the created Knowledge object with its id.",
RequiredFlags: []string{"<url> (positional)", "--kb (or WEKNORA_KB_ID / project link)"},
Examples: []string{
`weknora doc fetch https://example.com/whitepaper.pdf --kb kb_eng`,
},
Output: "envelope.data is the created Knowledge object with id, knowledge_base_id, source, parse_status",
})
return cmd
}
// runFetch ingests a remote URL via SDK CreateKnowledgeFromURL.
func runFetch(ctx context.Context, opts *FetchOptions, fopts *cmdutil.FormatOptions, svc FetchService, kbID string) error {
req := sdk.CreateKnowledgeFromURLRequest{
URL: opts.URL,
FileName: opts.Name,
FileType: opts.FileType,
EnableMultimodel: opts.EnableMultimodel,
Title: opts.Title,
TagID: opts.TagID,
Channel: cmp.Or(opts.Channel, uploadChannel),
}
k, err := svc.CreateKnowledgeFromURL(ctx, kbID, req)
if err != nil {
if errors.Is(err, sdk.ErrDuplicateURL) {
return cmdutil.Wrapf(cmdutil.CodeResourceAlreadyExists, err,
"URL already ingested into this knowledge base")
}
return cmdutil.WrapHTTP(err, "fetch document from %s", opts.URL)
}
return renderUploadSuccess(k, fopts, "Ingested", opts.Name, opts.URL)
}
+161
View File
@@ -0,0 +1,161 @@
package doc
import (
"context"
"encoding/json"
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
// fakeFetchSvc captures call arguments and returns canned responses.
type fakeFetchSvc struct {
resp *sdk.Knowledge
err error
got struct {
kbID string
req sdk.CreateKnowledgeFromURLRequest
}
}
func (f *fakeFetchSvc) CreateKnowledgeFromURL(
_ context.Context,
kbID string,
req sdk.CreateKnowledgeFromURLRequest,
) (*sdk.Knowledge, error) {
f.got.kbID = kbID
f.got.req = req
return f.resp, f.err
}
func TestFetch_Success_Text(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeFetchSvc{resp: &sdk.Knowledge{ID: "doc_url_1", FileName: "whitepaper.pdf"}}
opts := &FetchOptions{URL: "https://example.com/whitepaper.pdf"}
require.NoError(t, runFetch(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
assert.Equal(t, "kb_xxx", svc.got.kbID)
assert.Equal(t, "https://example.com/whitepaper.pdf", svc.got.req.URL)
assert.Equal(t, "api", svc.got.req.Channel)
assert.Contains(t, out.String(), "Ingested")
assert.Contains(t, out.String(), "doc_url_1")
}
func TestFetch_WithName_PassesAsFileName(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeFetchSvc{resp: &sdk.Knowledge{ID: "doc_url_2"}}
opts := &FetchOptions{URL: "https://example.com/article.html", Name: "Q3 Article"}
require.NoError(t, runFetch(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
assert.Equal(t, "Q3 Article", svc.got.req.FileName,
"--name must be forwarded as FileName (server uses it for file-vs-crawl mode hint)")
}
func TestFetch_Title(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeFetchSvc{resp: &sdk.Knowledge{ID: "doc_u"}}
opts := &FetchOptions{URL: "https://example.com/a.pdf", Title: "My Title"}
require.NoError(t, runFetch(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
assert.Equal(t, "My Title", svc.got.req.Title)
}
func TestFetch_FileType(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeFetchSvc{resp: &sdk.Knowledge{ID: "doc_u"}}
opts := &FetchOptions{URL: "https://example.com/no-ext", FileType: "pdf"}
require.NoError(t, runFetch(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
assert.Equal(t, "pdf", svc.got.req.FileType)
}
func TestFetch_TagID(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeFetchSvc{resp: &sdk.Knowledge{ID: "doc_u"}}
opts := &FetchOptions{URL: "https://example.com/a.pdf", TagID: "tag_99"}
require.NoError(t, runFetch(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
assert.Equal(t, "tag_99", svc.got.req.TagID)
}
func TestFetch_EnableMultimodel_Forwarded(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeFetchSvc{resp: &sdk.Knowledge{ID: "doc_u"}}
mm := true
opts := &FetchOptions{URL: "https://example.com/a.pdf", EnableMultimodel: &mm}
require.NoError(t, runFetch(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
require.NotNil(t, svc.got.req.EnableMultimodel)
assert.True(t, *svc.got.req.EnableMultimodel)
}
func TestFetch_Channel_Override(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeFetchSvc{resp: &sdk.Knowledge{ID: "doc_u"}}
opts := &FetchOptions{URL: "https://example.com/a.pdf", Channel: "web"}
require.NoError(t, runFetch(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
assert.Equal(t, "web", svc.got.req.Channel)
}
func TestFetch_Channel_DefaultIsAPI(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeFetchSvc{resp: &sdk.Knowledge{ID: "doc_u"}}
opts := &FetchOptions{URL: "https://example.com/a.pdf"}
require.NoError(t, runFetch(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
assert.Equal(t, uploadChannel, svc.got.req.Channel)
}
func TestFetch_JSON_Envelope(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeFetchSvc{resp: &sdk.Knowledge{ID: "doc_url_3", FileName: "ok.pdf"}}
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}
require.NoError(t, runFetch(context.Background(),
&FetchOptions{URL: "https://example.com/ok.pdf"}, fopts, svc, "kb_xxx"))
got := out.String()
var env struct {
OK bool `json:"ok"`
Data sdk.Knowledge `json:"data"`
}
require.NoError(t, json.Unmarshal([]byte(got), &env), "expected valid JSON envelope, got %q", got)
assert.True(t, env.OK, "envelope.ok must be true")
assert.Equal(t, "doc_url_3", env.Data.ID, "envelope.data.id must be doc_url_3")
assert.NotContains(t, got, `"risk":`)
}
func TestFetch_DuplicateURL_Maps_resource_already_exists(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeFetchSvc{
resp: &sdk.Knowledge{ID: "doc_existing"},
err: sdk.ErrDuplicateURL,
}
err := runFetch(context.Background(),
&FetchOptions{URL: "https://example.com/dup.pdf"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx")
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeResourceAlreadyExists, typed.Code)
}
func TestFetch_ServerError_Wraps(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeFetchSvc{err: errors.New("HTTP error 500: internal server error")}
err := runFetch(context.Background(),
&FetchOptions{URL: "https://example.com/doc.pdf"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx")
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeServerError, typed.Code)
}
func TestFetch_KBResolutionFailure_Propagates(t *testing.T) {
// runFetch itself receives a pre-resolved kbID; KB resolution failure
// happens in RunE before runFetch is called. This test verifies that
// runFetch does NOT swallow an error returned from the service when
// kbID is empty (which a broken resolution chain might produce).
_, _ = iostreams.SetForTest(t)
svc := &fakeFetchSvc{err: errors.New("HTTP error 404: knowledge base not found")}
err := runFetch(context.Background(),
&FetchOptions{URL: "https://example.com/doc.pdf"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "")
require.Error(t, err)
}
+265
View File
@@ -0,0 +1,265 @@
package doc
import (
"context"
"fmt"
"sort"
"text/tabwriter"
"time"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/output"
"github.com/Tencent/WeKnora/cli/internal/text"
sdk "github.com/Tencent/WeKnora/client"
)
// docListFields enumerates the fields surfaced for `--format json` discovery on
// `doc list`. Filter applies to each Knowledge object in the bare array.
var docListFields = []string{
"id", "knowledge_base_id", "tag_id", "type", "title", "description",
"source", "channel", "parse_status", "summary_status", "enable_status",
"embedding_model_id", "file_name", "file_type", "file_size", "file_hash",
"file_path", "storage_size",
"created_at", "updated_at", "processed_at", "error_message",
}
type ListOptions struct {
PageSize int // Items per server batch. With --all-pages, controls
// per-request load. Without, controls the single page size.
Status string // --status: filter by parse_status (server-side query param)
// Limit caps the returned items client-side (default 30; 0 = no cap).
// Applied after pagination / --all-pages accumulation and sort.
Limit int
// AllPages walks server pages internally, accumulating items until
// total exhausted or --limit hit.
AllPages bool
// Additional server-side filters (each maps 1:1 to a sdk.KnowledgeListFilter
// field). Empty / zero values are omitted from the request.
Keyword string
FileType string
Source string
TagID string
StartTime string // raw RFC3339; parsed into filter.StartTime
EndTime string // raw RFC3339; parsed into filter.EndTime
}
// docListStatusValues mirrors internal/types/knowledge.go ParseStatus*
// constants - these are the values the server accepts on the
// ?parse_status= query. Kept in sync manually since the SDK doesn't
// re-export the enum.
var docListStatusValues = []string{"pending", "processing", "completed", "failed"}
// ListService is the narrow SDK surface this command depends on.
// *sdk.Client satisfies it.
type ListService interface {
ListKnowledgeWithFilter(ctx context.Context, kbID string, page, pageSize int, filter sdk.KnowledgeListFilter) ([]sdk.Knowledge, int64, error)
}
// NewCmdList builds `weknora doc list`.
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
opts := &ListOptions{}
cmd := &cobra.Command{
Use: "list",
Short: "List documents in a knowledge base",
Long: `Lists documents (uploaded files / web pages / inline text) in the
resolved knowledge base. KB resolution follows the standard 4-level chain:
--kb flag > WEKNORA_KB_ID env > .weknora/project.yaml > error. The --kb
flag accepts either a KB UUID (passed through) or a name (resolved via list).
Default sort is updated_at desc so the most recent uploads surface first;
backend storage order is not guaranteed and varies between deployments.`,
Example: ` weknora doc list # uses project link / env
weknora doc list --kb a32a63ff-fb36-4874-bcaa-30f48570a694 # explicit UUID
weknora doc list --kb my-kb # resolved by name
weknora doc list --all-pages --format json # walk every page`,
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, _ []string) error {
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
// Validate static input (--page-size / --limit / --status) before
// resolving the KB or building the client so a bad value returns
// input.invalid_argument (exit 5) instead of an auth error (exit 3)
// when the profile is unconfigured — an agent must see the real,
// fixable problem.
if err := validateListOpts(opts); err != nil {
return err
}
kbID, err := f.ResolveKB(c)
if err != nil {
return err
}
cli, err := f.Client()
if err != nil {
return err
}
return runList(c.Context(), opts, fopts, cli, kbID)
},
}
cmdutil.AddKBFlag(cmd)
cmd.Flags().IntVar(&opts.PageSize, "page-size", 50, "Items per server batch (1..1000)")
cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum results to return — client-side cap; meta.has_more/total_count report the full size (1..10000)")
cmd.Flags().BoolVar(&opts.AllPages, "all-pages", false, "Walk all server pages until exhausted (or --limit hit)")
cmd.Flags().StringVar(&opts.Status, "status", "", "Filter by parse status: pending | processing | completed | failed")
cmd.Flags().StringVar(&opts.Keyword, "keyword", "", "Server-side substring match against title / file_name (case-insensitive)")
cmd.Flags().StringVar(&opts.FileType, "file-type", "", `Filter by file extension (e.g. "pdf", "md")`)
cmd.Flags().StringVar(&opts.Source, "source", "", `Filter by ingestion source (e.g. "api", "web")`)
cmd.Flags().StringVar(&opts.TagID, "tag-id", "", "Filter by tag association")
cmd.Flags().StringVar(&opts.StartTime, "start-time", "", "Include docs with updated_at >= this RFC3339 timestamp (e.g. 2006-01-02T15:04:05Z)")
cmd.Flags().StringVar(&opts.EndTime, "end-time", "", "Include docs with updated_at <= this RFC3339 timestamp (e.g. 2006-01-02T15:04:05Z)")
cmdutil.AddFormatFlag(cmd, docListFields...)
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "List documents in the resolved knowledge base. Results come with meta.count; use --limit to cap, --all-pages to walk every server page, --status/--keyword to filter server-side.",
Examples: []string{"weknora doc list --format json", "weknora doc list --all-pages --limit 200 --format json"},
Output: "envelope.data is an array of Knowledge objects with id, title, file_name, parse_status; meta.count is the returned count; meta.total_count is the server-side total before client-side --limit truncation; meta.has_more=true when --limit truncated",
})
return cmd
}
// validateListOpts checks --page-size / --limit / --status. Called from RunE
// before the KB/client are resolved (so bad input surfaces as exit 5, not an
// auth error) and again at the top of runList for direct callers; idempotent.
func validateListOpts(opts *ListOptions) error {
if opts.PageSize < 1 || opts.PageSize > 1000 {
return &cmdutil.Error{
Code: cmdutil.CodeInputInvalidArgument,
Message: fmt.Sprintf("--page-size must be in 1..1000, got %d", opts.PageSize),
}
}
if opts.Limit < 1 || opts.Limit > 10000 {
return &cmdutil.Error{
Code: cmdutil.CodeInputInvalidArgument,
Message: fmt.Sprintf("--limit must be in 1..10000, got %d", opts.Limit),
}
}
// Validate + normalize --status via the shared enum helper (same exit-5
// path and case-insensitive normalization as every other enum flag).
status, err := cmdutil.ValidateEnum("status", opts.Status, docListStatusValues)
if err != nil {
return err
}
opts.Status = status
return nil
}
func runList(ctx context.Context, opts *ListOptions, fopts *cmdutil.FormatOptions, svc ListService, kbID string) error {
if err := validateListOpts(opts); err != nil {
return err
}
filter := sdk.KnowledgeListFilter{
ParseStatus: opts.Status,
Keyword: opts.Keyword,
FileType: opts.FileType,
Source: opts.Source,
TagID: opts.TagID,
}
start, err := cmdutil.ParseTimeFlag("--start-time", opts.StartTime)
if err != nil {
return err
}
if start != nil {
filter.StartTime = *start
}
end, err := cmdutil.ParseTimeFlag("--end-time", opts.EndTime)
if err != nil {
return err
}
if end != nil {
filter.EndTime = *end
}
// Pagination is always 1-indexed internally. --all-pages walks; the
// non-walking path returns the first page only.
var items []sdk.Knowledge
var serverTotal int64
if opts.AllPages {
accum := make([]sdk.Knowledge, 0)
for page := 1; ; page++ {
chunk, total, err := svc.ListKnowledgeWithFilter(ctx, kbID, page, opts.PageSize, filter)
if err != nil {
return cmdutil.WrapHTTP(err, "list documents")
}
serverTotal = total
accum = append(accum, chunk...)
if opts.Limit > 0 && len(accum) >= opts.Limit {
accum = accum[:opts.Limit]
break
}
if int64(len(accum)) >= total || len(chunk) == 0 {
break
}
}
items = accum
} else {
chunk, total, err := svc.ListKnowledgeWithFilter(ctx, kbID, 1, opts.PageSize, filter)
if err != nil {
return cmdutil.WrapHTTP(err, "list documents")
}
serverTotal = total
items = chunk
}
if items == nil {
items = []sdk.Knowledge{} // ensure JSON [] not null
}
// Default sort: updated_at desc. Server return order is not guaranteed,
// so client-side sort makes output deterministic regardless of backend
// storage choices. Mirrors `weknora kb list`.
sort.Slice(items, func(i, j int) bool {
return items[i].UpdatedAt.After(items[j].UpdatedAt)
})
// --limit applies after sort so users get the top-N most-recent items
// when combined with a single-page fetch where page_size > limit.
truncated := false
if opts.Limit > 0 && len(items) > opts.Limit {
items = items[:opts.Limit]
truncated = true
}
if fopts.WantsJSON() {
meta := &output.Meta{Count: output.IntPtr(len(items)), HasMore: truncated, TotalCount: output.IntPtr(int(serverTotal))}
return fopts.Emit(iostreams.IO.Out, items, meta)
}
if len(items) == 0 {
fmt.Fprintln(iostreams.IO.Out, "(no documents)")
return nil
}
tw := tabwriter.NewWriter(iostreams.IO.Out, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "ID\tNAME\tSTATUS\tSIZE\tUPDATED")
now := time.Now()
for _, k := range items {
name := text.Truncate(40, text.KnowledgeDisplayName(k.FileName, k.Title, k.ID))
updated := text.FuzzyAgo(now, k.UpdatedAt)
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", k.ID, name, k.ParseStatus, formatSize(k.FileSize), updated)
}
return tw.Flush()
}
// formatSize renders a byte count as a short human string (KB / MB).
// Kept tiny on purpose - go-humanize would pull a transitive dep just for one
// column. A "-" placeholder hides zero-size entries (URL / text).
func formatSize(bytes int64) string {
if bytes <= 0 {
return "-"
}
const (
kb = 1 << 10
mb = 1 << 20
gb = 1 << 30
)
switch {
case bytes >= gb:
return fmt.Sprintf("%.1fGB", float64(bytes)/float64(gb))
case bytes >= mb:
return fmt.Sprintf("%.1fMB", float64(bytes)/float64(mb))
case bytes >= kb:
return fmt.Sprintf("%.1fKB", float64(bytes)/float64(kb))
}
return fmt.Sprintf("%dB", bytes)
}
+498
View File
@@ -0,0 +1,498 @@
package doc
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/config"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
// fakeListSvc captures the request args and returns canned responses.
type fakeListSvc struct {
items []sdk.Knowledge
total int64
err error
got struct {
kbID string
page int
pageSize int
filter sdk.KnowledgeListFilter
}
}
func (f *fakeListSvc) ListKnowledgeWithFilter(_ context.Context, kbID string, page, pageSize int, filter sdk.KnowledgeListFilter) ([]sdk.Knowledge, int64, error) {
f.got.kbID, f.got.page, f.got.pageSize, f.got.filter = kbID, page, pageSize, filter
return f.items, f.total, f.err
}
// chdirIsolated parks cwd in a fresh tempdir so Factory.ResolveKB doesn't pick
// up a stray .weknora/project.yaml from the repo. Also clears WEKNORA_KB_ID
// for the duration of the test.
func chdirIsolated(t *testing.T) {
t.Helper()
prev, err := os.Getwd()
require.NoError(t, err)
require.NoError(t, os.Chdir(t.TempDir()))
t.Cleanup(func() { _ = os.Chdir(prev) })
t.Setenv("WEKNORA_KB_ID", "")
}
func TestList_Success_Text(t *testing.T) {
out, _ := iostreams.SetForTest(t)
now := time.Now()
items := []sdk.Knowledge{
{ID: "doc1", FileName: "alpha.pdf", FileSize: 2048, ParseStatus: "completed", UpdatedAt: now.Add(-1 * time.Hour)},
{ID: "doc2", FileName: "beta.md", FileSize: 0, ParseStatus: "pending", UpdatedAt: now.Add(-2 * 24 * time.Hour)},
}
svc := &fakeListSvc{items: items, total: 2}
opts := &ListOptions{PageSize: 20, Limit: 30}
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
assert.Equal(t, "kb_xxx", svc.got.kbID)
assert.Equal(t, 1, svc.got.page)
assert.Equal(t, 20, svc.got.pageSize)
assert.Equal(t, sdk.KnowledgeListFilter{}, svc.got.filter, "no flags ⇒ empty filter")
got := out.String()
for _, want := range []string{"ID", "NAME", "STATUS", "SIZE", "UPDATED", "doc1", "alpha.pdf", "completed", "2.0KB", "doc2", "beta.md", "pending"} {
if !strings.Contains(got, want) {
t.Errorf("output missing %q in:\n%s", want, got)
}
}
}
func TestList_Success_JSON(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeListSvc{items: []sdk.Knowledge{{ID: "doc1", FileName: "a.pdf"}}, total: 1}
opts := &ListOptions{PageSize: 20, Limit: 30}
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb_xxx"))
got := out.String()
var env struct {
OK bool `json:"ok"`
Data []sdk.Knowledge `json:"data"`
}
require.NoError(t, json.Unmarshal([]byte(got), &env), "expected valid JSON envelope, got %q", got)
assert.True(t, env.OK, "envelope.ok must be true")
assert.Contains(t, got, `"id":"doc1"`)
assert.NotContains(t, got, `"_meta":`)
}
func TestList_Empty_Text(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeListSvc{items: nil, total: 0}
opts := &ListOptions{PageSize: 20, Limit: 30}
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
assert.Contains(t, out.String(), "(no documents)")
}
func TestList_Empty_JSON(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeListSvc{items: nil, total: 0}
opts := &ListOptions{PageSize: 20, Limit: 30}
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb_xxx"))
var env struct {
OK bool `json:"ok"`
Data []sdk.Knowledge `json:"data"`
}
require.NoError(t, json.Unmarshal(out.Bytes(), &env), "expected valid JSON envelope, got %q", out.String())
assert.True(t, env.OK, "envelope.ok must be true")
assert.Len(t, env.Data, 0, "empty list must produce empty data array, not null")
}
func TestList_HTTPError_500(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeListSvc{err: errors.New("HTTP error 500: internal")}
opts := &ListOptions{PageSize: 20, Limit: 30}
err := runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx")
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeServerError, typed.Code)
}
// TestList_KBIDRequired drives the cobra layer to verify Factory.ResolveKB's
// "no source supplied" path bubbles up as CodeKBIDRequired. Isolates cwd so
// no project.yaml sneaks in, and clears WEKNORA_KB_ID.
func TestList_KBIDRequired(t *testing.T) {
chdirIsolated(t)
_, _ = iostreams.SetForTest(t)
cfg := &config.Config{
CurrentProfile: "default",
Profiles: map[string]config.Profile{"default": {Host: "https://example"}},
}
f := &cmdutil.Factory{
Config: func() (*config.Config, error) { return cfg, nil },
Client: func() (*sdk.Client, error) {
return nil, errors.New("client should not be called when kb id is missing")
},
}
cmd := NewCmdList(f)
cmd.SetContext(context.Background())
cmd.SetArgs([]string{}) // no --kb
cmd.SilenceErrors = true
cmd.SilenceUsage = true
err := cmd.Execute()
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeKBIDRequired, typed.Code)
}
// TestList_KBFlagWiredToResolveKB confirms that --kb=kb_<id> passed at the
// cobra layer reaches Factory.ResolveKB and short-circuits without listing.
func TestList_KBFlagWiredToResolveKB(t *testing.T) {
chdirIsolated(t)
_, _ = iostreams.SetForTest(t)
cfg := &config.Config{
CurrentProfile: "default",
Profiles: map[string]config.Profile{"default": {Host: "https://example"}},
}
f := &cmdutil.Factory{
Config: func() (*config.Config, error) { return cfg, nil },
Client: func() (*sdk.Client, error) {
return nil, errors.New("forced-after-resolvekb")
},
}
// With --kb=kb_<id> supplied, ResolveKB short-circuits on the prefix
// match without consulting the client. The RunE then asks for the client
// to run the actual list - that call triggers the forced error.
// Surfacing "forced-after-resolvekb" (rather than CodeKBIDRequired) is
// the proof point that --kb was honored.
cmd := NewCmdList(f)
cmd.SetContext(context.Background())
cmd.SetArgs([]string{"--kb", "kb_explicit"})
cmd.SilenceErrors = true
cmd.SilenceUsage = true
err := cmd.Execute()
require.Error(t, err, "expected client construction error")
assert.Contains(t, err.Error(), "forced-after-resolvekb",
"should surface the Client closure error, not a kb-required error")
}
// pinning the sort order: most-recent-first regardless of input order.
func TestList_SortByUpdatedDesc(t *testing.T) {
out, _ := iostreams.SetForTest(t)
now := time.Now()
// Server returns oldest first; CLI must reorder.
items := []sdk.Knowledge{
{ID: "old", FileName: "old.pdf", UpdatedAt: now.Add(-10 * 24 * time.Hour)},
{ID: "new", FileName: "new.pdf", UpdatedAt: now.Add(-1 * time.Hour)},
}
svc := &fakeListSvc{items: items, total: 2}
require.NoError(t, runList(context.Background(), &ListOptions{PageSize: 20, Limit: 30}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
got := out.String()
newIdx := strings.Index(got, "new.pdf")
oldIdx := strings.Index(got, "old.pdf")
require.GreaterOrEqual(t, newIdx, 0)
require.GreaterOrEqual(t, oldIdx, 0)
assert.Less(t, newIdx, oldIdx, "most-recent should render first")
}
// TestFormatSize sanity-checks the byte-count formatter without exporting it.
func TestFormatSize(t *testing.T) {
cases := []struct {
in int64
want string
}{
{0, "-"},
{-1, "-"},
{900, "900B"},
{2048, "2.0KB"},
{5 * 1024 * 1024, "5.0MB"},
}
for _, c := range cases {
if got := formatSize(c.in); got != c.want {
t.Errorf("formatSize(%d) = %q, want %q", c.in, got, c.want)
}
}
}
func TestList_StatusFilter_ForwardedToSDK(t *testing.T) {
chdirIsolated(t)
_, _ = iostreams.SetForTest(t)
svc := &fakeListSvc{}
opts := &ListOptions{PageSize: 20, Limit: 30, Status: "failed"}
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
assert.Equal(t, "failed", svc.got.filter.ParseStatus,
"--status must be forwarded as filter.ParseStatus for server-side filtering")
}
func TestList_StatusFilter_RejectsUnknownValue(t *testing.T) {
chdirIsolated(t)
_, _ = iostreams.SetForTest(t)
svc := &fakeListSvc{}
opts := &ListOptions{PageSize: 20, Limit: 30, Status: "bogus"}
err := runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx")
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, "pending")
assert.Contains(t, typed.Message, "failed")
}
func TestList_StatusFilter_AcceptsAllEnumValues(t *testing.T) {
chdirIsolated(t)
for _, v := range docListStatusValues {
_, _ = iostreams.SetForTest(t)
svc := &fakeListSvc{}
opts := &ListOptions{PageSize: 20, Limit: 30, Status: v}
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"),
"status=%q should be accepted", v)
}
}
// makeDocs returns N Knowledge records with distinct IDs and descending
// UpdatedAt timestamps, useful for limit / pagination tests.
func makeDocs(n int) []sdk.Knowledge {
base := time.Now()
out := make([]sdk.Knowledge, n)
for i := 0; i < n; i++ {
out[i] = sdk.Knowledge{
ID: fmt.Sprintf("doc_%02d", i),
FileName: fmt.Sprintf("f_%02d.pdf", i),
UpdatedAt: base.Add(-time.Duration(i) * time.Hour),
}
}
return out
}
// pagedDocSvc returns server-paginated Knowledge results from a flat slice.
// Records the page numbers requested for assertion.
type pagedDocSvc struct {
all []sdk.Knowledge
calls []int // 1-based page numbers received
pageSize int
}
func (p *pagedDocSvc) ListKnowledgeWithFilter(_ context.Context, _ string, page, pageSize int, _ sdk.KnowledgeListFilter) ([]sdk.Knowledge, int64, error) {
p.calls = append(p.calls, page)
p.pageSize = pageSize
start := (page - 1) * pageSize
if start >= len(p.all) {
return []sdk.Knowledge{}, int64(len(p.all)), nil
}
end := start + pageSize
if end > len(p.all) {
end = len(p.all)
}
return p.all[start:end], int64(len(p.all)), nil
}
func TestList_Limit_LessThanPageSize_SlicesToLimit(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeListSvc{items: makeDocs(20), total: 20}
opts := &ListOptions{PageSize: 20, Limit: 5}
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb_xxx"))
body := out.String()
// Count occurrences of "id":"doc_" - should be exactly 5.
got := strings.Count(body, `"id":"doc_`)
assert.Equal(t, 5, got, "--limit 5 must slice 20 returned items to 5; body=\n%s", body)
}
func TestList_Limit_GreaterThanPageSize_NoCap(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeListSvc{items: makeDocs(10), total: 10}
opts := &ListOptions{PageSize: 10, Limit: 50}
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb_xxx"))
got := strings.Count(out.String(), `"id":"doc_`)
assert.Equal(t, 10, got, "--limit 50 with page-size 10 + 10 items returns all 10")
}
func TestList_Limit_Negative_Rejected(t *testing.T) {
_, _ = iostreams.SetForTest(t)
opts := &ListOptions{PageSize: 20, Limit: -1}
err := runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &fakeListSvc{}, "kb_xxx")
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
}
func TestList_AllPages_WalksAllServerPages(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &pagedDocSvc{all: makeDocs(45)}
opts := &ListOptions{PageSize: 20, Limit: 10000, AllPages: true}
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb_xxx"))
// 45 items / page_size 20 = 3 pages: 20 + 20 + 5.
assert.Equal(t, []int{1, 2, 3}, svc.calls)
got := strings.Count(out.String(), `"id":"doc_`)
assert.Equal(t, 45, got)
}
func TestList_AllPages_WithLimit_StopsAtLimit(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &pagedDocSvc{all: makeDocs(200)}
opts := &ListOptions{PageSize: 20, AllPages: true, Limit: 50}
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb_xxx"))
got := strings.Count(out.String(), `"id":"doc_`)
assert.Equal(t, 50, got, "--limit 50 with --all-pages should stop after 50 items")
// Should have called pages 1..3 (60 items) then capped at 50.
assert.LessOrEqual(t, len(svc.calls), 3, "should not walk past the page that fills --limit")
}
// ----- C11: richer filter flags -----
func TestList_Keyword_PassedToFilter(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeListSvc{}
opts := &ListOptions{PageSize: 20, Limit: 30, Keyword: "spec"}
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
assert.Equal(t, "spec", svc.got.filter.Keyword)
}
func TestList_FileType_PassedToFilter(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeListSvc{}
opts := &ListOptions{PageSize: 20, Limit: 30, FileType: "pdf"}
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
assert.Equal(t, "pdf", svc.got.filter.FileType)
}
func TestList_Source_PassedToFilter(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeListSvc{}
opts := &ListOptions{PageSize: 20, Limit: 30, Source: "api"}
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
assert.Equal(t, "api", svc.got.filter.Source)
}
func TestList_TagID_PassedToFilter(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeListSvc{}
opts := &ListOptions{PageSize: 20, Limit: 30, TagID: "tag_42"}
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
assert.Equal(t, "tag_42", svc.got.filter.TagID)
}
func TestList_StartTime_RFC3339Parses(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeListSvc{}
want := "2026-05-01T00:00:00Z"
opts := &ListOptions{PageSize: 20, Limit: 30, StartTime: want}
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
parsed, err := time.Parse(time.RFC3339, want)
require.NoError(t, err)
assert.True(t, svc.got.filter.StartTime.Equal(parsed),
"--start-time must be parsed into filter.StartTime; got %v want %v",
svc.got.filter.StartTime, parsed)
}
func TestList_EndTime_RFC3339Parses(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeListSvc{}
want := "2026-06-30T23:59:59Z"
opts := &ListOptions{PageSize: 20, Limit: 30, EndTime: want}
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
parsed, err := time.Parse(time.RFC3339, want)
require.NoError(t, err)
assert.True(t, svc.got.filter.EndTime.Equal(parsed))
}
func TestList_StartTime_InvalidFormat_Rejected(t *testing.T) {
_, _ = iostreams.SetForTest(t)
opts := &ListOptions{PageSize: 20, Limit: 30, StartTime: "tomorrow"}
err := runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &fakeListSvc{}, "kb_xxx")
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, "--start-time")
assert.Contains(t, typed.Message, "RFC3339")
}
func TestList_EndTime_InvalidFormat_Rejected(t *testing.T) {
_, _ = iostreams.SetForTest(t)
opts := &ListOptions{PageSize: 20, Limit: 30, EndTime: "2026-05-01"} // date-only, not RFC3339
err := runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &fakeListSvc{}, "kb_xxx")
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, "--end-time")
}
// TestList_JSON_TotalCount_SinglePage asserts that meta.total_count is populated
// from the server total when doing a single-page fetch.
func TestList_JSON_TotalCount_SinglePage(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeListSvc{
items: []sdk.Knowledge{{ID: "doc1", FileName: "a.pdf"}},
total: 99,
}
opts := &ListOptions{PageSize: 20, Limit: 30}
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb_xxx"))
body := out.String()
assert.Contains(t, body, `"total_count":99`, "single-page fetch must surface server total in meta.total_count")
}
// TestList_JSON_TotalCount_AllPages asserts meta.total_count is populated
// from the server total when walking all pages.
func TestList_JSON_TotalCount_AllPages(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &pagedDocSvc{all: makeDocs(45)}
opts := &ListOptions{PageSize: 20, Limit: 10000, AllPages: true}
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb_xxx"))
body := out.String()
assert.Contains(t, body, `"total_count":45`, "--all-pages fetch must surface server total in meta.total_count")
}
// TestList_JSON_TotalCount_Zero_Present asserts that when server returns total=0
// on an empty list, meta.total_count (and meta.count) still serialize as 0. The
// *int + omitempty pattern omits only nil, so the agent contract stays stable:
// an empty result reports 0, not a missing key.
func TestList_JSON_TotalCount_Zero_Present(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeListSvc{items: nil, total: 0}
opts := &ListOptions{PageSize: 20, Limit: 30}
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb_xxx"))
body := out.String()
assert.Contains(t, body, `"total_count":0`, "zero server total must serialize as 0 on empty list")
assert.Contains(t, body, `"count":0`, "empty list must report count:0, not omit it")
}
// TestList_AllFiltersCombined drives every new filter flag at once to confirm
// they all land on the same filter struct (AND combine on the server).
func TestList_AllFiltersCombined(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeListSvc{}
opts := &ListOptions{
PageSize: 20,
Limit: 30,
Status: "completed",
Keyword: "spec",
FileType: "pdf",
Source: "api",
TagID: "tag_42",
StartTime: "2026-01-01T00:00:00Z",
EndTime: "2026-12-31T23:59:59Z",
}
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
f := svc.got.filter
assert.Equal(t, "completed", f.ParseStatus)
assert.Equal(t, "spec", f.Keyword)
assert.Equal(t, "pdf", f.FileType)
assert.Equal(t, "api", f.Source)
assert.Equal(t, "tag_42", f.TagID)
assert.False(t, f.StartTime.IsZero())
assert.False(t, f.EndTime.IsZero())
}
+96
View File
@@ -0,0 +1,96 @@
package doc
import (
"context"
"fmt"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/text"
sdk "github.com/Tencent/WeKnora/client"
)
// docReparseFields enumerates the fields surfaced for `--format json` discovery
// on `doc reparse`. The result is the Knowledge object; the caller mainly wants
// the id and the reset parse_status.
var docReparseFields = []string{"id", "parse_status", "file_name", "title"}
// ReparseOptions captures `doc reparse` flag state.
type ReparseOptions struct {
DryRun bool
}
// ReparseService is the narrow SDK surface this command depends on.
type ReparseService interface {
ReparseKnowledge(ctx context.Context, id string) (*sdk.Knowledge, error)
}
// NewCmdReparse builds `weknora doc reparse <doc-id>`.
func NewCmdReparse(f *cmdutil.Factory) *cobra.Command {
opts := &ReparseOptions{}
cmd := &cobra.Command{
Use: "reparse <doc-id>",
Short: "Re-run parsing on a document",
Long: `Re-trigger server-side parsing of a document — the recovery path when a
document's parse_status is failed, or after its source changed. The document
keeps its id; parsing restarts asynchronously, so follow with
'weknora doc wait <doc-id>' to block until it reaches a terminal status.`,
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
if handled, err := cmdutil.HandleDryRun(c, opts.DryRun, cmdutil.DryRunPlan{
Action: "doc.reparse",
Args: map[string]any{"doc": args[0]},
}); handled {
return err
}
cli, err := f.Client()
if err != nil {
return err
}
return runReparse(c.Context(), opts, fopts, cli, args[0])
},
}
cmdutil.AddIgnoredKBFlag(cmd)
cmdutil.AddFormatFlag(cmd, docReparseFields...)
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "re-run parsing on a document (recovery after parse_status=failed, or a changed source)",
RequiredFlags: []string{"<doc-id> (positional)"},
Examples: []string{
"weknora doc reparse doc_abc",
"weknora doc reparse doc_abc --format json",
},
Output: "envelope.data is the Knowledge object with parse_status reset to the re-processing state; poll `weknora doc wait <doc-id>` for completion",
})
return cmd
}
func runReparse(ctx context.Context, opts *ReparseOptions, fopts *cmdutil.FormatOptions, svc ReparseService, id string) error {
k, err := svc.ReparseKnowledge(ctx, id)
if err != nil {
return cmdutil.WrapHTTP(err, "reparse document %s", id)
}
if fopts.WantsJSON() {
return fopts.Emit(iostreams.IO.Out, k, nil)
}
// fileName → title, with no id fallback (empty means "skip the suffix").
name := text.KnowledgeDisplayName(k.FileName, k.Title, "")
w := iostreams.IO.Out
fmt.Fprintf(w, "✓ reparse triggered for %s", id)
if name != "" {
fmt.Fprintf(w, " (%s)", name)
}
fmt.Fprintln(w)
if k.ParseStatus != "" {
fmt.Fprintf(w, " parse_status: %s\n", k.ParseStatus)
}
fmt.Fprintf(w, " next: weknora doc wait %s\n", id)
return nil
}
+84
View File
@@ -0,0 +1,84 @@
package doc
import (
"context"
"encoding/json"
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
type fakeReparseSvc struct {
k *sdk.Knowledge
err error
called bool
}
func (f *fakeReparseSvc) ReparseKnowledge(_ context.Context, id string) (*sdk.Knowledge, error) {
f.called = true
if f.err != nil {
return nil, f.err
}
k := *f.k
k.ID = id
return &k, nil
}
func TestDocReparse_Text(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeReparseSvc{k: &sdk.Knowledge{FileName: "runbook.md", ParseStatus: "processing"}}
require.NoError(t, runReparse(context.Background(), &ReparseOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "doc_abc"))
assert.True(t, svc.called)
got := out.String()
assert.Contains(t, got, "doc_abc")
assert.Contains(t, got, "runbook.md")
assert.Contains(t, got, "processing")
assert.Contains(t, got, "doc wait doc_abc")
}
func TestDocReparse_JSON(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeReparseSvc{k: &sdk.Knowledge{ParseStatus: "processing"}}
require.NoError(t, runReparse(context.Background(), &ReparseOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "doc_abc"))
var env struct {
OK bool `json:"ok"`
Data sdk.Knowledge `json:"data"`
}
require.NoError(t, json.Unmarshal(out.Bytes(), &env), "got %q", out.String())
assert.True(t, env.OK)
assert.Equal(t, "doc_abc", env.Data.ID)
assert.Equal(t, "processing", env.Data.ParseStatus)
}
func TestDocReparse_NotFound(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeReparseSvc{err: errors.New("HTTP error 404: not found")}
err := runReparse(context.Background(), &ReparseOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "missing")
require.Error(t, err)
assert.True(t, cmdutil.IsNotFound(err))
}
// TestDocReparse_DryRun_NoServerCall: --dry-run must emit a doc.reparse plan
// (exit 0) without reaching the server, honoring the mutation-preview contract.
func TestDocReparse_DryRun_NoServerCall(t *testing.T) {
out, _ := iostreams.SetForTest(t)
root := withRootHarnessDoc(NewCmdReparse(docDryRunFactory(t)), "doc_x", "--dry-run", "--format", "json")
require.NoError(t, root.Execute(), "dry-run must succeed without a client")
var env struct {
OK bool `json:"ok"`
Meta struct {
DryRun bool `json:"dry_run"`
Plan map[string]any `json:"plan"`
} `json:"meta"`
}
require.NoError(t, json.Unmarshal(out.Bytes(), &env), "got %q", out.String())
assert.True(t, env.OK)
assert.True(t, env.Meta.DryRun)
assert.Equal(t, "doc.reparse", env.Meta.Plan["action"])
}
+153
View File
@@ -0,0 +1,153 @@
package doc
import (
"context"
"fmt"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
// docUpdateFields enumerates the fields surfaced for `--format json` discovery
// on `doc update`. The result is the updated Knowledge object.
var docUpdateFields = []string{"id", "title", "description", "file_name", "parse_status"}
// UpdateOptions captures `doc update` flag state. Title/Description are *string
// so an unset flag is distinguishable from "set to empty" — only fields the
// user passed are changed; the rest are preserved via fetch-then-update.
type UpdateOptions struct {
Title *string
Description *string
Yes bool // sourced from global -y/--yes persistent flag
DryRun bool
}
// UpdateService is the narrow SDK surface this command depends on. The server's
// UpdateKnowledge endpoint persists only title and description (other fields on
// the PUT body are ignored), so this command edits just those two; GetKnowledge
// supplies the fetch half of the fetch-then-update flow.
type UpdateService interface {
GetKnowledge(ctx context.Context, id string) (*sdk.Knowledge, error)
UpdateKnowledge(ctx context.Context, k *sdk.Knowledge) error
}
// NewCmdUpdate builds `weknora doc update <doc-id>`. At least one of
// --title / --description must be provided.
func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
opts := &UpdateOptions{}
var title, desc string
cmd := &cobra.Command{
Use: "update <doc-id>",
Short: "Update a document's title or description",
Long: `Update a document's title and/or description. At least one of --title /
--description must be supplied; the document's content, file, and parse state are
untouched (only the title and description are editable — re-upload to change the
content).
AI agents: this is a high-risk write. Without -y/--yes the CLI exits 10
with input.confirmation_required. Never auto-pass -y; surface the prompt
to the user first.`,
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
opts.Yes, _ = c.Flags().GetBool("yes")
if c.Flag("title").Changed {
opts.Title = &title
}
if c.Flag("description").Changed {
opts.Description = &desc
}
id := args[0]
// Validate "at least one mutation flag" before the dry-run gate so
// --dry-run rejects identically to the live path.
if opts.Title == nil && opts.Description == nil {
return errNoUpdateFlag()
}
planArgs := map[string]any{"doc": id}
if opts.Title != nil {
planArgs["title"] = *opts.Title
}
if opts.Description != nil {
planArgs["description"] = *opts.Description
}
if handled, err := cmdutil.HandleDryRun(c, opts.DryRun, cmdutil.DryRunPlan{
Action: "doc.update",
Args: planArgs,
}); handled {
return err
}
cli, err := f.Client()
if err != nil {
return err
}
// Build a retry command from the flags the user actually passed so
// agents can re-invoke with -y after explicit human approval.
retryCmd := cmdutil.BuildRetryArgv(c, []string{"weknora", "doc", "update", id}, "title", "description", "format")
if err := cmdutil.ConfirmWrite(f.Prompter(), opts.Yes, fopts.WantsJSON(), "update", "document", id, "doc.update", retryCmd); err != nil {
return err
}
return runUpdate(c.Context(), opts, fopts, cli, id)
},
}
cmd.Flags().StringVar(&title, "title", "", "New title (omit to leave unchanged)")
cmd.Flags().StringVar(&desc, "description", "", "New description (omit to leave unchanged)")
cmdutil.AddIgnoredKBFlag(cmd)
cmdutil.AddFormatFlag(cmd, docUpdateFields...)
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
cmdutil.SetWriteRisk(cmd, "doc.update")
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "update a document's title or description (content is not editable — re-upload for that)",
RequiredFlags: []string{"<doc-id> (positional)", "--title or --description (at least one)"},
Examples: []string{
`weknora doc update doc_abc --title "Q3 Runbook" -y`,
`weknora doc update doc_abc --description "archived" --format json -y`,
},
Output: "envelope.data is the Knowledge object with the new title/description",
Warnings: []string{
"Requires explicit user approval (exit 10 / input.confirmation_required); never auto-add -y.",
},
})
return cmd
}
func errNoUpdateFlag() error {
return &cmdutil.Error{
Code: cmdutil.CodeInputMissingFlag,
Message: "doc update requires at least one of --title or --description",
Hint: "pass --title <title> and/or --description <desc>",
}
}
func runUpdate(ctx context.Context, opts *UpdateOptions, fopts *cmdutil.FormatOptions, svc UpdateService, id string) error {
if opts.Title == nil && opts.Description == nil {
return errNoUpdateFlag()
}
// Fetch-then-update: load the current record, overlay only the fields the
// user set, and PUT the whole object. TOCTOU note: a concurrent writer could
// change the record between the Get and Put (same window as kb update).
current, err := svc.GetKnowledge(ctx, id)
if err != nil {
return cmdutil.WrapHTTP(err, "fetch document %s", id)
}
if opts.Title != nil {
current.Title = *opts.Title
}
if opts.Description != nil {
current.Description = *opts.Description
}
if err := svc.UpdateKnowledge(ctx, current); err != nil {
return cmdutil.WrapHTTP(err, "update document %s", id)
}
if fopts.WantsJSON() {
return fopts.Emit(iostreams.IO.Out, current, nil)
}
fmt.Fprintf(iostreams.IO.Out, "✓ Updated document %s\n", id)
return nil
}
+137
View File
@@ -0,0 +1,137 @@
package doc
import (
"context"
"encoding/json"
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/prompt"
sdk "github.com/Tencent/WeKnora/client"
)
type fakeUpdateSvc struct {
current *sdk.Knowledge
getErr error
updateErr error
got *sdk.Knowledge // captured object passed to UpdateKnowledge
}
func (f *fakeUpdateSvc) GetKnowledge(_ context.Context, id string) (*sdk.Knowledge, error) {
if f.getErr != nil {
return nil, f.getErr
}
c := *f.current
c.ID = id
return &c, nil
}
func (f *fakeUpdateSvc) UpdateKnowledge(_ context.Context, k *sdk.Knowledge) error {
f.got = k
return f.updateErr
}
func strptr(s string) *string { return &s }
func TestDocUpdate_Title_FetchThenUpdate(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeUpdateSvc{current: &sdk.Knowledge{Title: "Old", Description: "keep", FileName: "f.md"}}
require.NoError(t, runUpdate(context.Background(),
&UpdateOptions{Title: strptr("New")}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "doc_abc"))
// Only title changed; description preserved from the fetched record.
require.NotNil(t, svc.got)
assert.Equal(t, "New", svc.got.Title)
assert.Equal(t, "keep", svc.got.Description)
var env struct {
OK bool `json:"ok"`
Data sdk.Knowledge `json:"data"`
}
require.NoError(t, json.Unmarshal(out.Bytes(), &env), "got %q", out.String())
assert.True(t, env.OK)
assert.Equal(t, "doc_abc", env.Data.ID)
assert.Equal(t, "New", env.Data.Title)
}
func TestDocUpdate_DescriptionOnly_PreservesTitle(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeUpdateSvc{current: &sdk.Knowledge{Title: "Keep", Description: "old"}}
require.NoError(t, runUpdate(context.Background(),
&UpdateOptions{Description: strptr("fresh")}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "doc_abc"))
assert.Equal(t, "Keep", svc.got.Title)
assert.Equal(t, "fresh", svc.got.Description)
}
func TestDocUpdate_NoFlags_MissingFlag(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeUpdateSvc{current: &sdk.Knowledge{}}
err := runUpdate(context.Background(), &UpdateOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "doc_abc")
var typed *cmdutil.Error
require.True(t, errors.As(err, &typed))
assert.Equal(t, cmdutil.CodeInputMissingFlag, typed.Code)
assert.Nil(t, svc.got, "must not call UpdateKnowledge when no flag is set")
}
func TestDocUpdate_NotFound(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeUpdateSvc{getErr: errors.New("HTTP error 404: not found")}
err := runUpdate(context.Background(),
&UpdateOptions{Title: strptr("x")}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "missing")
require.Error(t, err)
assert.True(t, cmdutil.IsNotFound(err))
}
// TestDocUpdate_DryRun_NoServerCall: --dry-run emits a doc.update plan (exit 0)
// without reaching the server.
func TestDocUpdate_DryRun_NoServerCall(t *testing.T) {
out, _ := iostreams.SetForTest(t)
root := withRootHarnessDoc(NewCmdUpdate(docDryRunFactory(t)), "doc_x", "--title", "T", "--dry-run", "--format", "json")
require.NoError(t, root.Execute(), "dry-run must succeed without a client")
var env struct {
OK bool `json:"ok"`
Meta struct {
DryRun bool `json:"dry_run"`
Plan map[string]any `json:"plan"`
} `json:"meta"`
}
require.NoError(t, json.Unmarshal(out.Bytes(), &env), "got %q", out.String())
assert.True(t, env.Meta.DryRun)
assert.Equal(t, "doc.update", env.Meta.Plan["action"])
}
// TestDocUpdate_RequiresConfirmation asserts that without -y (non-TTY / JSON
// mode), doc update returns input.confirmation_required (exit 10) — parity with
// kb/agent update gating (AGENTS.md §3.1: all three updates are confirmation-gated).
func TestDocUpdate_RequiresConfirmation(t *testing.T) {
iostreams.SetForTest(t) // non-TTY
f := &cmdutil.Factory{
Client: func() (*sdk.Client, error) { return nil, nil },
Prompter: func() prompt.Prompter { return prompt.AgentPrompter{} },
}
root := withRootHarnessDoc(NewCmdUpdate(f), "doc_abc", "--title", "New", "--format", "json")
err := root.Execute()
require.Error(t, err)
var ce *cmdutil.Error
require.ErrorAs(t, err, &ce)
assert.Equal(t, cmdutil.CodeInputConfirmationRequired, ce.Code)
assert.Equal(t, 10, cmdutil.ExitCode(err))
// retry argv must include -y and the target id
assert.Contains(t, ce.RetryArgv, "-y")
assert.Contains(t, ce.RetryArgv, "doc_abc")
}
// TestDocUpdate_DryRun_RejectsNoFlag: --dry-run rejects the no-mutation-flag
// invocation identically to the live path (validation before the dry-run gate).
func TestDocUpdate_DryRun_RejectsNoFlag(t *testing.T) {
_, _ = iostreams.SetForTest(t)
root := withRootHarnessDoc(NewCmdUpdate(docDryRunFactory(t)), "doc_x", "--dry-run", "--format", "json")
err := root.Execute()
require.Error(t, err, "dry-run must reject identically to live path")
var typed *cmdutil.Error
require.True(t, errors.As(err, &typed))
assert.Equal(t, cmdutil.CodeInputMissingFlag, typed.Code)
}
+314
View File
@@ -0,0 +1,314 @@
package doc
import (
"cmp"
"context"
"errors"
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
// uploadChannel is the default ingestion-channel tag the server records for
// CLI uploads. Distinct from "web" (browser UI), "browser_extension"
// (one-click capture), and "wechat" (mini-program). The server uses this only
// for analytics. Users can override via --channel for cross-tool replay.
const uploadChannel = "api"
// docUploadFields enumerates the fields surfaced for `--format json` discovery on
// `doc upload`. The single-file upload result is the full Knowledge struct;
// these are its top-level json tags.
var docUploadFields = []string{
"id", "knowledge_base_id", "tag_id", "type", "title", "description",
"source", "channel", "parse_status", "summary_status", "enable_status",
"embedding_model_id", "file_name", "file_type", "file_size", "file_hash",
"file_path", "storage_size",
"created_at", "updated_at", "processed_at", "error_message",
}
type UploadOptions struct {
Name string
Recursive bool // --recursive: positional arg is a directory; walk + upload each match
Glob string // --glob: filename pattern under --recursive (default "*")
// EnableMultimodel toggles server-side multimodal extraction
// (e.g. images-in-PDF → OCR'd text). nil means "server default" -
// the flag was not set. true/false explicitly override.
EnableMultimodel *bool
// Metadata is the raw --metadata key=value list. Parsed into a map
// at run-time; empty values allowed, duplicate keys last-wins.
Metadata []string
// Channel overrides the ingestion-channel tag recorded server-side.
// Empty ⇒ uploadChannel ("api"). Free-form: server validates.
Channel string
DryRun bool
}
// UploadService is the narrow SDK surface this command depends on.
// *sdk.Client satisfies it.
type UploadService interface {
CreateKnowledgeFromFile(
ctx context.Context,
kbID, filePath string,
metadata map[string]string,
enableMultimodel *bool,
customFileName, channel string,
processConfig *sdk.KnowledgeProcessOverrides,
) (*sdk.Knowledge, error)
}
// NewCmdUpload builds `weknora doc upload <file>`.
func NewCmdUpload(f *cmdutil.Factory) *cobra.Command {
opts := &UploadOptions{}
cmd := &cobra.Command{
Use: "upload <file>",
Short: "Upload a local file to the knowledge base",
Long: `Uploads a file (PDF / DOCX / Markdown / TXT / etc.) to the resolved
knowledge base. KB resolution follows the standard 4-level chain:
--kb flag > WEKNORA_KB_ID env > .weknora/project.yaml > error. The --kb
flag accepts either a KB UUID (passed through) or a name (resolved via list).
Pass --name to override the recorded file name (useful when the local file
has a generic name like "report.pdf" but you want to surface it as e.g.
"Q3 Marketing Report.pdf" in the UI).
The two input modes (positional file / --recursive directory walk) are
mutually exclusive - pass exactly one. Use --recursive --glob to upload a
directory tree (see Examples). To ingest a remote URL use "weknora doc fetch";
to create an entry from inline text use "weknora doc create".
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.
--metadata key=value Attach arbitrary key/value metadata. Repeatable.
Empty value allowed; duplicate keys ⇒ last-wins.
Malformed values (no '=', empty key) ⇒
input.invalid_argument.
--channel <name> Override the ingestion-channel tag (default "api").
Applies to file / --recursive.`,
Example: ` weknora doc upload report.pdf
weknora doc upload notes.md --kb a32a63ff-fb36-4874-bcaa-30f48570a694
weknora doc upload notes.md --kb my-kb
weknora doc upload q3.pdf --name "Q3 Marketing Report.pdf"
weknora doc upload report.pdf --enable-multimodel --metadata team=alpha --metadata sprint=Q4
weknora doc upload ./docs --recursive --glob '*.pdf' --metadata team=alpha`,
Args: cobra.MaximumNArgs(1),
RunE: func(c *cobra.Command, args []string) error {
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
// Pure-local validation runs before the dry-run gate so --dry-run
// rejects identically to the live path. Filesystem stat
// (validateUploadPath) and recursive directory enumeration are
// intentionally skipped under --dry-run — those are side-effect-ish
// reads the preview is allowed to defer.
if c.Flags().Changed("enable-multimodel") {
raw, _ := c.Flags().GetString("enable-multimodel")
v, perr := parseTriBool(raw)
if perr != nil {
return perr
}
opts.EnableMultimodel = &v
}
if err := validateUploadFlags(opts, args); err != nil {
return err
}
// Validate --metadata key=value shape upfront; same typed Error as
// runUpload (kept there for direct-call callers / batch paths).
if _, err := parseMetadataKV(opts.Metadata); err != nil {
return err
}
if opts.DryRun {
// Local-only KB resolution: plan reports the raw --kb value
// without an SDK lookup.
kbID, err := f.ResolveKBLocal(c)
if err != nil {
return err
}
filePath := args[0]
planArgs := map[string]any{
"file": filePath,
"kb": kbID,
}
// recursive vs single-file is the blast-radius switch
// (N docs vs 1); surface it on the plan so agents can
// decide whether to gate on user approval.
if opts.Recursive {
planArgs["recursive"] = true
}
if opts.Glob != "" {
planArgs["glob"] = opts.Glob
}
if handled, err := cmdutil.HandleDryRun(c, true, cmdutil.DryRunPlan{
Action: "doc.upload",
Args: planArgs,
}); handled {
return err
}
}
cli, err := f.Client()
if err != nil {
return err
}
kbID, err := f.ResolveKB(c)
if err != nil {
return err
}
if opts.Recursive {
return runUploadRecursive(c.Context(), opts, fopts, cli, kbID, args[0])
}
if err := validateUploadPath(args[0]); err != nil {
return err
}
return runUpload(c.Context(), opts, fopts, cli, kbID, args[0])
},
}
cmdutil.AddKBFlag(cmd)
cmd.Flags().StringVar(&opts.Name, "name", "", "Custom file name to record (defaults to base name)")
cmd.Flags().BoolVar(&opts.Recursive, "recursive", false, "Treat the positional argument as a directory to walk")
cmd.Flags().StringVar(&opts.Glob, "glob", "*", "Filename pattern to filter when --recursive (e.g. '*.pdf')")
// Tri-state flag: unset ⇒ server default, "true"/"false" override. The
// raw string is decoded into opts.EnableMultimodel in RunE.
cmd.Flags().String("enable-multimodel", "", "Toggle multimodal extraction (true|false); unset ⇒ server default")
cmd.Flags().Lookup("enable-multimodel").NoOptDefVal = "true"
cmd.Flags().StringSliceVar(&opts.Metadata, "metadata", nil, "Attach metadata `key=value` (repeatable; empty value allowed, last-wins on duplicate keys)")
cmd.Flags().StringVar(&opts.Channel, "channel", "", "Ingestion-channel tag recorded server-side (default \"api\")")
cmdutil.AddFormatFlag(cmd, docUploadFields...)
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "Upload a local file to the resolved knowledge base. KB resolved via --kb flag, WEKNORA_KB_ID env, or project link. Emits the created Knowledge object with its id.",
RequiredFlags: []string{"<file> (positional)", "--kb (or WEKNORA_KB_ID / project link)"},
Examples: []string{
`weknora doc upload report.pdf --kb kb_eng`,
`weknora doc upload report.pdf --kb kb_eng --jq .data.id # then: doc wait <id>`,
},
Output: "envelope.data is the created Knowledge object with id, knowledge_base_id, file_name, parse_status",
})
return cmd
}
// parseTriBool parses the raw --enable-multimodel string into a bool. Bare
// --enable-multimodel (no value) is treated as "true" via NoOptDefVal at
// registration time; callers gate on Changed() so an unset flag never gets
// here. An explicit empty string (e.g. --enable-multimodel="" from an
// uninterpolated shell variable) is rejected as input.invalid_argument
// rather than silently coerced.
func parseTriBool(raw string) (bool, error) {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "true", "1", "yes":
return true, nil
case "false", "0", "no":
return false, nil
default:
return false, cmdutil.NewError(cmdutil.CodeInputInvalidArgument,
fmt.Sprintf("--enable-multimodel expects true|false, got %q", raw))
}
}
// parseMetadataKV converts the raw --metadata key=value slice into a map.
// Empty values are allowed. Duplicate keys ⇒ last-wins. Returns nil when
// the slice is empty so callers pass nil through to the SDK unchanged.
func parseMetadataKV(raw []string) (map[string]string, error) {
if len(raw) == 0 {
return nil, nil
}
out := make(map[string]string, len(raw))
for _, kv := range raw {
eq := strings.IndexByte(kv, '=')
if eq <= 0 {
return nil, cmdutil.NewError(cmdutil.CodeInputInvalidArgument,
fmt.Sprintf("--metadata expects key=value (got %q)", kv))
}
out[kv[:eq]] = kv[eq+1:]
}
return out, nil
}
// validateUploadFlags enforces mutual exclusion between the two input modes
// (positional file path / --recursive directory walk) and requires that at
// least one input mode is supplied.
func validateUploadFlags(opts *UploadOptions, args []string) error {
hasPath := len(args) == 1
if !hasPath {
// Wrap as FlagError so the exit code (2) matches what cobra's own
// MinimumNArgs(1) would emit — consistent with every other command
// that requires a positional argument.
return cmdutil.NewFlagError(errors.New(
"a file path is required (or use `weknora doc fetch` for URLs, `weknora doc create` for inline text)"))
}
return nil
}
// renderUploadSuccess emits the post-upload result. JSON path is the bare
// Knowledge object; text path prints a checkmark line. Shared by single-
// file upload and URL ingest; verb varies (uploaded/ingested) and
// fallbackDisplay covers the case when the server-recorded file_name is
// blank (URL ingest pre-redirect).
func renderUploadSuccess(k *sdk.Knowledge, fopts *cmdutil.FormatOptions, verb, customName, fallbackDisplay string) error {
if fopts.WantsJSON() {
return fopts.Emit(iostreams.IO.Out, k, nil)
}
displayed := customName
if displayed == "" {
displayed = k.FileName
}
if displayed == "" {
displayed = fallbackDisplay
}
fmt.Fprintf(iostreams.IO.Out, "✓ %s %q (id: %s)\n", verb, displayed, k.ID)
return nil
}
// validateUploadPath checks that path exists and refers to a regular file.
// Symlinks and directories are rejected up-front so users get a typed error
// instead of an opaque SDK failure mid-upload. os.Stat (not Lstat) is used
// here so a symlink to a regular file is accepted - that matches what
// `cp` / `git add` do, and the SDK opens the file via os.Open which follows
// symlinks anyway.
func validateUploadPath(path string) error {
info, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return cmdutil.Wrapf(cmdutil.CodeUploadFileNotFound, err, "file not found: %s", path)
}
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "stat %s", path)
}
if !info.Mode().IsRegular() {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument,
fmt.Sprintf("not a regular file: %s (directories and devices are not supported)", path))
}
return nil
}
func runUpload(ctx context.Context, opts *UploadOptions, fopts *cmdutil.FormatOptions, svc UploadService, kbID, path string) error {
meta, err := parseMetadataKV(opts.Metadata)
if err != nil {
return err
}
k, err := svc.CreateKnowledgeFromFile(ctx, kbID, path, meta, opts.EnableMultimodel, opts.Name, cmp.Or(opts.Channel, uploadChannel), nil)
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, fopts, "Uploaded", opts.Name, path)
}
+184
View File
@@ -0,0 +1,184 @@
package doc
import (
"cmp"
"context"
"fmt"
"io/fs"
"os"
"path/filepath"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/output"
)
// uploadedFile holds the server-side result for a successful per-file upload.
// Keyed by file path in the uploadResults map populated by the RunBatch closure.
type uploadedFile struct {
ID string
Name string
}
// runUploadRecursive walks dir, filters by Glob, and uploads each match
// sequentially. Per-file errors do NOT abort the walk - they accumulate
// and the final return aggregates them so the user sees the full picture
// in one run. Exit semantics: nil error on full success, a typed *cmdutil.Error
// when ≥1 file failed (the typed code mirrors the first failure's
// classification so callers can still branch).
func runUploadRecursive(ctx context.Context, opts *UploadOptions, fopts *cmdutil.FormatOptions, svc UploadService, kbID, dir string) error {
if opts.Name != "" {
return &cmdutil.Error{
Code: cmdutil.CodeInputInvalidArgument,
Message: "--name cannot be combined with --recursive (one name can't apply to N files)",
Hint: "drop --name or upload files one at a time",
}
}
// Parse --metadata up front so a malformed value aborts before the
// first SDK call - otherwise a typo in `key=value` would only surface
// per-file as repeated identical errors.
meta, err := parseMetadataKV(opts.Metadata)
if err != nil {
return err
}
info, err := os.Stat(dir)
if err != nil {
if os.IsNotExist(err) {
return cmdutil.Wrapf(cmdutil.CodeUploadFileNotFound, err, "directory not found: %s", dir)
}
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "stat %s", dir)
}
if !info.IsDir() {
return &cmdutil.Error{
Code: cmdutil.CodeInputInvalidArgument,
Message: fmt.Sprintf("not a directory: %s (drop --recursive to upload a single file)", dir),
}
}
// Sanity-check the pattern up front so a typo doesn't show up as "no
// files matched" per-file. Cobra populates --glob; tests pass it
// explicitly - no in-function default needed.
if _, err := filepath.Match(opts.Glob, ""); err != nil {
return &cmdutil.Error{
Code: cmdutil.CodeInputInvalidArgument,
Message: fmt.Sprintf("invalid --glob %q: %v", opts.Glob, err),
}
}
matches, err := walkMatches(dir, opts.Glob)
if err != nil {
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "walk %s", dir)
}
if len(matches) == 0 {
if fopts.WantsJSON() {
// Empty batch: emit an all-success envelope with zero items.
return output.WriteBatchEnvelope(iostreams.IO.Out, nil, fopts.TTY, cmdutil.GetProfile())
}
fmt.Fprintf(iostreams.IO.Out, "(no files matched %q under %s)\n", opts.Glob, dir)
return nil
}
// uploaded captures per-path server results for successful uploads.
// Populated by the RunBatch closure; read by the resultFn below.
uploaded := make(map[string]uploadedFile, len(matches))
channel := cmp.Or(opts.Channel, uploadChannel)
outcomes, runErr := cmdutil.RunBatch(ctx, matches, func(ctx context.Context, p string) error {
k, err := svc.CreateKnowledgeFromFile(ctx, kbID, p, meta, opts.EnableMultimodel, "", channel, nil)
if err != nil {
// Per-file progress lines are human progress signal; suppress
// under --format json so they don't precede the JSON object on stdout.
if !fopts.WantsJSON() {
fmt.Fprintf(iostreams.IO.Out, "FAIL %s: %v\n", filepath.Base(p), err)
}
return err
}
id, name := "", ""
if k != nil {
id = k.ID
name = k.FileName
}
uploaded[p] = uploadedFile{ID: id, Name: name}
if !fopts.WantsJSON() {
fmt.Fprintf(iostreams.IO.Out, "OK %s (id: %s)\n", filepath.Base(p), id)
}
return nil
})
failures := 0
for _, oc := range outcomes {
if oc.Err != nil {
failures++
}
}
if fopts.WantsJSON() {
if err := cmdutil.EmitBatch(outcomes, fopts, iostreams.IO.Out, func(p string) any {
f := uploaded[p]
return map[string]any{"id": f.ID, "name": f.Name}
}); err != nil {
return err
}
} else {
fmt.Fprintf(iostreams.IO.Out, "Uploaded %d, Failed %d\n", len(outcomes)-failures, failures)
}
if runErr != nil {
// Silent on the --format json path: the success object above already
// carries per-file detail; without Silent the root error handler would
// print to stderr in addition. ExitCode still walks Code so the typed
// exit-code-by-class contract holds.
// Any per-file failure collapses to operation.failed (exit 1) — the
// per-file batch envelope carries each file's typed error, which is the
// authoritative signal (matches doc/session/chunk batch delete). A
// cancelled / timed-out batch keeps its context class (124 / 130) so a
// permanent partial failure (e.g. a duplicate) is never misreported as a
// retryable 5xx that an agent would loop on.
code := cmdutil.CodeOperationFailed
if ctxErr := ctx.Err(); ctxErr != nil {
code = cmdutil.ClassifyContextErr(ctxErr)
}
return &cmdutil.Error{
Code: code,
Message: fmt.Sprintf("%d of %d uploads failed", failures, len(matches)),
Silent: fopts.WantsJSON(),
}
}
return nil
}
// walkMatches returns every regular file under root whose base name matches
// pattern. Order is filepath.WalkDir's lexical order (stdlib guarantee on
// every supported FS), which is deterministic for test assertions.
func walkMatches(root, pattern string) ([]string, error) {
var out []string
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
// Skip non-regular files (sockets, devices); the SDK can't upload
// them and they'd show as opaque server errors.
info, ierr := d.Info()
if ierr != nil {
return ierr
}
if !info.Mode().IsRegular() {
return nil
}
ok, merr := filepath.Match(pattern, d.Name())
if merr != nil {
return merr
}
if ok {
out = append(out, p)
}
return nil
})
if err != nil {
return nil, err
}
return out, nil
}
+274
View File
@@ -0,0 +1,274 @@
package doc
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
// scriptedUploadSvc records every CreateKnowledgeFromFile call and returns
// per-path scripted results.
type scriptedUploadSvc struct {
results map[string]struct {
k *sdk.Knowledge
err error
}
called []string
// Captures from the most-recent call (every recursive iteration writes
// these; tests that want all-rows can extend to slices later).
lastMetadata map[string]string
lastEnableMultimodel *bool
lastChannel string
}
func (s *scriptedUploadSvc) CreateKnowledgeFromFile(
_ context.Context,
_, filePath string,
metadata map[string]string,
enableMultimodel *bool,
_, channel string,
_ *sdk.KnowledgeProcessOverrides,
) (*sdk.Knowledge, error) {
s.called = append(s.called, filepath.Base(filePath))
s.lastMetadata = metadata
s.lastEnableMultimodel = enableMultimodel
s.lastChannel = channel
r, ok := s.results[filepath.Base(filePath)]
if !ok {
return &sdk.Knowledge{ID: "doc_" + filepath.Base(filePath), FileName: filepath.Base(filePath)}, nil
}
return r.k, r.err
}
func mkTree(t *testing.T, base string, names ...string) {
t.Helper()
for _, n := range names {
full := filepath.Join(base, n)
require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755))
require.NoError(t, os.WriteFile(full, []byte("x"), 0o644))
}
}
func TestUploadRecursive_WalksAllFiles(t *testing.T) {
out, _ := iostreams.SetForTest(t)
dir := t.TempDir()
mkTree(t, dir, "a.pdf", "b.pdf", "sub/c.pdf")
svc := &scriptedUploadSvc{}
opts := &UploadOptions{Recursive: true, Glob: "*"}
require.NoError(t, runUploadRecursive(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", dir))
sort.Strings(svc.called)
assert.Equal(t, []string{"a.pdf", "b.pdf", "c.pdf"}, svc.called)
got := out.String()
for _, w := range []string{"a.pdf", "b.pdf", "c.pdf", "Uploaded 3"} {
assert.Contains(t, got, w)
}
}
func TestUploadRecursive_GlobFilter(t *testing.T) {
_, _ = iostreams.SetForTest(t)
dir := t.TempDir()
mkTree(t, dir, "doc.pdf", "ignore.txt", "sub/keep.pdf", "sub/also-ignore.md")
svc := &scriptedUploadSvc{}
opts := &UploadOptions{Recursive: true, Glob: "*.pdf"}
require.NoError(t, runUploadRecursive(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", dir))
sort.Strings(svc.called)
assert.Equal(t, []string{"doc.pdf", "keep.pdf"}, svc.called)
}
func TestUploadRecursive_PartialFailure_Exits1(t *testing.T) {
out, _ := iostreams.SetForTest(t)
dir := t.TempDir()
mkTree(t, dir, "ok.pdf", "bad.pdf")
svc := &scriptedUploadSvc{results: map[string]struct {
k *sdk.Knowledge
err error
}{
"bad.pdf": {err: errors.New("HTTP error 500: internal")},
}}
opts := &UploadOptions{Recursive: true, Glob: "*"}
err := runUploadRecursive(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", dir)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
// Any per-file failure aggregates to operation.failed (exit 1), matching the
// batch-delete contract — a partial failure (even a permanent one like a
// duplicate, which classifies as server.error per-file) must not surface as
// a retryable exit 7 an agent would loop on. Per-file codes live in the
// batch envelope.
assert.Equal(t, cmdutil.CodeOperationFailed, typed.Code)
got := out.String()
assert.Contains(t, got, "OK") // ok.pdf still succeeded
assert.Contains(t, got, "FAIL")
assert.Contains(t, got, "Uploaded 1")
assert.Contains(t, got, "Failed 1")
}
func TestUploadRecursive_NoMatches(t *testing.T) {
out, _ := iostreams.SetForTest(t)
dir := t.TempDir()
mkTree(t, dir, "only.txt")
svc := &scriptedUploadSvc{}
opts := &UploadOptions{Recursive: true, Glob: "*.pdf"}
require.NoError(t, runUploadRecursive(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", dir))
assert.Len(t, svc.called, 0)
assert.Contains(t, strings.ToLower(out.String()), "no files matched")
}
func TestUploadRecursive_NotADirectory(t *testing.T) {
_, _ = iostreams.SetForTest(t)
path := writeTempFile(t, "single.pdf")
svc := &scriptedUploadSvc{}
err := runUploadRecursive(context.Background(), &UploadOptions{Recursive: true, Glob: "*"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", path)
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, "directory")
}
func TestUploadRecursive_RejectsNameFlag(t *testing.T) {
_, _ = iostreams.SetForTest(t)
dir := t.TempDir()
mkTree(t, dir, "a.pdf")
svc := &scriptedUploadSvc{}
opts := &UploadOptions{Recursive: true, Glob: "*", Name: "single-name.pdf"}
err := runUploadRecursive(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", dir)
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, "--name")
}
func TestUploadRecursive_PropagatesMultimodelAndMetadata(t *testing.T) {
_, _ = iostreams.SetForTest(t)
dir := t.TempDir()
mkTree(t, dir, "a.pdf")
svc := &scriptedUploadSvc{}
mm := true
opts := &UploadOptions{
Recursive: true,
Glob: "*",
EnableMultimodel: &mm,
Metadata: []string{"team=alpha"},
Channel: "browser_extension",
}
require.NoError(t, runUploadRecursive(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", dir))
require.NotNil(t, svc.lastEnableMultimodel)
assert.True(t, *svc.lastEnableMultimodel)
assert.Equal(t, map[string]string{"team": "alpha"}, svc.lastMetadata)
assert.Equal(t, "browser_extension", svc.lastChannel)
}
func TestUploadRecursive_MetadataInvalid_NoCalls(t *testing.T) {
_, _ = iostreams.SetForTest(t)
dir := t.TempDir()
mkTree(t, dir, "a.pdf")
svc := &scriptedUploadSvc{}
opts := &UploadOptions{Recursive: true, Glob: "*", Metadata: []string{"badformat"}}
err := runUploadRecursive(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", dir)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
assert.Empty(t, svc.called, "must fail before any per-file call")
}
// TestUploadRecursive_JSON_BatchEnvelope verifies that --format json emits the
// batch envelope shape: {ok, data:[{id,ok,result?|error?}...], meta:{count,successes,failures}}.
// The per-item id is the file path; result carries {id, name} from the server.
func TestUploadRecursive_JSON_BatchEnvelope(t *testing.T) {
out, _ := iostreams.SetForTest(t)
dir := t.TempDir()
mkTree(t, dir, "ok.pdf", "bad.pdf")
svc := &scriptedUploadSvc{results: map[string]struct {
k *sdk.Knowledge
err error
}{
"bad.pdf": {err: errors.New("HTTP error 500: internal")},
}}
opts := &UploadOptions{Recursive: true, Glob: "*"}
err := runUploadRecursive(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb_xxx", dir)
require.Error(t, err) // partial failure → typed error
var env struct {
OK bool `json:"ok"`
Data []struct {
ID string `json:"id"`
OK bool `json:"ok"`
Result *struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"result,omitempty"`
Error *struct {
Type string `json:"type"`
Message string `json:"message"`
} `json:"error,omitempty"`
} `json:"data"`
Meta struct {
Count int `json:"count"`
Successes int `json:"successes"`
Failures int `json:"failures"`
} `json:"meta"`
}
require.NoError(t, json.Unmarshal(out.Bytes(), &env), "must be valid JSON: %s", out.String())
assert.False(t, env.OK, "top-level ok must be false when any item failed")
assert.Equal(t, 2, env.Meta.Count)
assert.Equal(t, 1, env.Meta.Successes)
assert.Equal(t, 1, env.Meta.Failures)
require.Len(t, env.Data, 2)
// File paths are used as batch item ids; verify both files appear.
ids := []string{env.Data[0].ID, env.Data[1].ID}
assert.True(t, strings.Contains(ids[0], "ok.pdf") || strings.Contains(ids[1], "ok.pdf"), "ok.pdf must appear in batch data")
assert.True(t, strings.Contains(ids[0], "bad.pdf") || strings.Contains(ids[1], "bad.pdf"), "bad.pdf must appear in batch data")
// The success item must have a result with server id/name.
for _, item := range env.Data {
if strings.Contains(item.ID, "ok.pdf") {
assert.True(t, item.OK)
assert.NotNil(t, item.Result)
} else {
assert.False(t, item.OK)
assert.NotNil(t, item.Error)
}
}
// --format json must emit exactly ONE JSON document. Per-file "FAIL"/"OK"
// progress lines belong on the human path; the typed error is Silent so
// the root handler doesn't write anything additional to stdout.
body := out.String()
assert.NotContains(t, body, "FAIL ", "per-file plain lines must not appear under --format json")
assert.NotContains(t, body, "OK ", "per-file plain lines must not appear under --format json")
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.True(t, typed.Silent, "JSON-path partial failure must be Silent")
assert.Equal(t, cmdutil.CodeOperationFailed, typed.Code)
}
+321
View File
@@ -0,0 +1,321 @@
package doc
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
// fakeUploadSvc captures call arguments and returns canned responses.
type fakeUploadSvc struct {
resp *sdk.Knowledge
err error
got struct {
kbID, filePath, customName, channel string
metadata map[string]string
enableMultimodel *bool
}
}
func (f *fakeUploadSvc) CreateKnowledgeFromFile(
_ context.Context,
kbID, filePath string,
metadata map[string]string,
enableMultimodel *bool,
customFileName, channel string,
_ *sdk.KnowledgeProcessOverrides,
) (*sdk.Knowledge, error) {
f.got.kbID = kbID
f.got.filePath = filePath
f.got.metadata = metadata
f.got.enableMultimodel = enableMultimodel
f.got.customName = customFileName
f.got.channel = channel
return f.resp, f.err
}
// writeTempFile creates a regular file under t.TempDir() with sample content.
func writeTempFile(t *testing.T, name string) string {
t.Helper()
path := filepath.Join(t.TempDir(), name)
require.NoError(t, os.WriteFile(path, []byte("hello world"), 0o644))
return path
}
func TestUpload_Success_Text(t *testing.T) {
out, _ := iostreams.SetForTest(t)
path := writeTempFile(t, "report.pdf")
svc := &fakeUploadSvc{resp: &sdk.Knowledge{ID: "doc_99", FileName: "report.pdf"}}
opts := &UploadOptions{}
require.NoError(t, runUpload(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", path))
assert.Equal(t, "kb_xxx", svc.got.kbID)
assert.Equal(t, path, svc.got.filePath)
assert.Equal(t, "", svc.got.customName, "no --name ⇒ empty (server uses base name)")
assert.Equal(t, uploadChannel, svc.got.channel)
assert.Nil(t, svc.got.metadata)
assert.Nil(t, svc.got.enableMultimodel)
got := out.String()
for _, want := range []string{"✓", "Uploaded", "report.pdf", "doc_99"} {
if !strings.Contains(got, want) {
t.Errorf("output missing %q in:\n%s", want, got)
}
}
}
func TestUpload_Success_CustomName(t *testing.T) {
_, _ = iostreams.SetForTest(t)
path := writeTempFile(t, "q3.pdf")
svc := &fakeUploadSvc{resp: &sdk.Knowledge{ID: "doc_88", FileName: "q3.pdf"}}
opts := &UploadOptions{Name: "Q3 Marketing Report.pdf"}
require.NoError(t, runUpload(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", path))
assert.Equal(t, "Q3 Marketing Report.pdf", svc.got.customName)
}
func TestUpload_Success_JSON(t *testing.T) {
out, _ := iostreams.SetForTest(t)
path := writeTempFile(t, "a.md")
svc := &fakeUploadSvc{resp: &sdk.Knowledge{ID: "doc_77", FileName: "a.md"}}
opts := &UploadOptions{}
require.NoError(t, runUpload(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb_xxx", path))
got := out.String()
var env struct {
OK bool `json:"ok"`
Data sdk.Knowledge `json:"data"`
}
require.NoError(t, json.Unmarshal([]byte(got), &env), "expected valid JSON envelope, got %q", got)
assert.True(t, env.OK, "envelope.ok must be true")
assert.Equal(t, "doc_77", env.Data.ID, "envelope.data.id must be doc_77")
assert.Contains(t, got, `"file_name":"a.md"`)
}
func TestUpload_HTTPError_500(t *testing.T) {
_, _ = iostreams.SetForTest(t)
path := writeTempFile(t, "x.txt")
svc := &fakeUploadSvc{err: errors.New("HTTP error 500: internal")}
err := runUpload(context.Background(), &UploadOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", path)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeServerError, typed.Code)
}
func TestUpload_HTTPError_409Conflict(t *testing.T) {
_, _ = iostreams.SetForTest(t)
path := writeTempFile(t, "dup.pdf")
svc := &fakeUploadSvc{err: errors.New("HTTP error 409: file exists")}
err := runUpload(context.Background(), &UploadOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", path)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
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.
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{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, 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)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeUploadFileNotFound, typed.Code)
}
func TestValidateUploadPath_DirectoryRejected(t *testing.T) {
dir := t.TempDir() // already exists, is a dir
err := validateUploadPath(dir)
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, "not a regular file")
}
func TestValidateUploadPath_RegularFileAccepted(t *testing.T) {
path := writeTempFile(t, "ok.txt")
require.NoError(t, validateUploadPath(path))
}
func TestValidateUploadPath_SymlinkToFileAccepted(t *testing.T) {
target := writeTempFile(t, "target.txt")
link := filepath.Join(t.TempDir(), "link.txt")
if err := os.Symlink(target, link); err != nil {
t.Skipf("symlink not supported on this platform: %v", err)
}
// os.Stat (not Lstat) should follow the symlink and report regular file.
require.NoError(t, validateUploadPath(link))
}
func TestValidateUploadFlags_NoPath_Rejected(t *testing.T) {
err := validateUploadFlags(&UploadOptions{}, nil)
require.Error(t, err)
// Missing required input wraps as FlagError so the exit code (2)
// matches cobra's MinimumNArgs(1) for commands taking a positional.
var fe *cmdutil.FlagError
require.ErrorAs(t, err, &fe, "expected FlagError so exit code maps to 2")
assert.Equal(t, 2, cmdutil.ExitCode(err))
}
func TestValidateUploadFlags_WithPath_OK(t *testing.T) {
require.NoError(t, validateUploadFlags(&UploadOptions{}, []string{"/tmp/x.pdf"}))
}
// --- C10 expanded flags: multimodel / metadata / channel ---
func TestUpload_EnableMultimodel_Set_True(t *testing.T) {
_, _ = iostreams.SetForTest(t)
path := writeTempFile(t, "mm.pdf")
svc := &fakeUploadSvc{resp: &sdk.Knowledge{ID: "doc_mm", FileName: "mm.pdf"}}
mm := true
opts := &UploadOptions{EnableMultimodel: &mm}
require.NoError(t, runUpload(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", path))
require.NotNil(t, svc.got.enableMultimodel, "expected non-nil *bool when flag set")
assert.True(t, *svc.got.enableMultimodel)
}
func TestUpload_EnableMultimodel_Set_False(t *testing.T) {
_, _ = iostreams.SetForTest(t)
path := writeTempFile(t, "mm.pdf")
svc := &fakeUploadSvc{resp: &sdk.Knowledge{ID: "doc_mm", FileName: "mm.pdf"}}
mm := false
opts := &UploadOptions{EnableMultimodel: &mm}
require.NoError(t, runUpload(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", path))
require.NotNil(t, svc.got.enableMultimodel, "explicit false must still surface as non-nil *bool")
assert.False(t, *svc.got.enableMultimodel)
}
// TestParseTriBool pins the empty-string-rejects behavior. Bare
// --enable-multimodel maps to "true" via NoOptDefVal before the flag reaches
// parseTriBool, so an empty value here always indicates an explicit
// --enable-multimodel="" (e.g. uninterpolated $VAR). Silently coercing
// empty to true used to surprise users.
func TestParseTriBool(t *testing.T) {
for _, c := range []struct {
in string
want bool
wantErr bool
}{
{"true", true, false},
{"1", true, false},
{"yes", true, false},
{"false", false, false},
{"0", false, false},
{"no", false, false},
{"", false, true}, // explicit empty rejected
{" ", false, true}, // whitespace rejected
{"maybe", false, true},
} {
t.Run(c.in, func(t *testing.T) {
got, err := parseTriBool(c.in)
if c.wantErr {
require.Error(t, err)
assert.Contains(t, err.Error(), "input.invalid_argument")
return
}
require.NoError(t, err)
assert.Equal(t, c.want, got)
})
}
}
func TestUpload_Metadata_ParseKV(t *testing.T) {
_, _ = iostreams.SetForTest(t)
path := writeTempFile(t, "m.pdf")
svc := &fakeUploadSvc{resp: &sdk.Knowledge{ID: "doc_m", FileName: "m.pdf"}}
opts := &UploadOptions{Metadata: []string{"foo=bar", "baz=qux"}}
require.NoError(t, runUpload(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", path))
assert.Equal(t, map[string]string{"foo": "bar", "baz": "qux"}, svc.got.metadata)
}
func TestUpload_Metadata_EmptyValueAllowed(t *testing.T) {
_, _ = iostreams.SetForTest(t)
path := writeTempFile(t, "m.pdf")
svc := &fakeUploadSvc{resp: &sdk.Knowledge{ID: "doc_m", FileName: "m.pdf"}}
opts := &UploadOptions{Metadata: []string{"foo="}}
require.NoError(t, runUpload(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", path))
assert.Equal(t, map[string]string{"foo": ""}, svc.got.metadata)
}
func TestUpload_Metadata_LastWins(t *testing.T) {
_, _ = iostreams.SetForTest(t)
path := writeTempFile(t, "m.pdf")
svc := &fakeUploadSvc{resp: &sdk.Knowledge{ID: "doc_m", FileName: "m.pdf"}}
opts := &UploadOptions{Metadata: []string{"k=v1", "k=v2"}}
require.NoError(t, runUpload(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", path))
assert.Equal(t, map[string]string{"k": "v2"}, svc.got.metadata)
}
func TestUpload_Metadata_InvalidFormat_NoEquals(t *testing.T) {
_, _ = iostreams.SetForTest(t)
path := writeTempFile(t, "m.pdf")
svc := &fakeUploadSvc{resp: &sdk.Knowledge{ID: "doc_m", FileName: "m.pdf"}}
opts := &UploadOptions{Metadata: []string{"foo"}}
err := runUpload(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", path)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
}
func TestUpload_Metadata_InvalidFormat_EmptyKey(t *testing.T) {
_, _ = iostreams.SetForTest(t)
path := writeTempFile(t, "m.pdf")
svc := &fakeUploadSvc{resp: &sdk.Knowledge{ID: "doc_m", FileName: "m.pdf"}}
opts := &UploadOptions{Metadata: []string{"=bar"}}
err := runUpload(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", path)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
}
func TestUpload_Channel_Override(t *testing.T) {
_, _ = iostreams.SetForTest(t)
path := writeTempFile(t, "c.pdf")
svc := &fakeUploadSvc{resp: &sdk.Knowledge{ID: "doc_c", FileName: "c.pdf"}}
opts := &UploadOptions{Channel: "browser_extension"}
require.NoError(t, runUpload(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", path))
assert.Equal(t, "browser_extension", svc.got.channel)
}
func TestUpload_Channel_DefaultStillAPI(t *testing.T) {
_, _ = iostreams.SetForTest(t)
path := writeTempFile(t, "c.pdf")
svc := &fakeUploadSvc{resp: &sdk.Knowledge{ID: "doc_c", FileName: "c.pdf"}}
// Empty Channel is the runUpload contract for "use default".
opts := &UploadOptions{}
require.NoError(t, runUpload(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx", path))
assert.Equal(t, uploadChannel, svc.got.channel)
}
+141
View File
@@ -0,0 +1,141 @@
package doc
import (
"context"
"fmt"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/text"
sdk "github.com/Tencent/WeKnora/client"
)
// docViewFields enumerates the fields surfaced for `--format json` discovery on
// `doc view`. Lists the Knowledge struct top-level json tags.
var docViewFields = []string{
"id", "knowledge_base_id", "tag_id", "type", "title", "description",
"source", "channel", "parse_status", "summary_status", "enable_status",
"embedding_model_id", "file_name", "file_type", "file_size", "file_hash",
"file_path", "storage_size",
"created_at", "updated_at", "processed_at", "error_message",
}
type ViewOptions struct{}
// ViewService is the narrow SDK surface this command depends on.
type ViewService interface {
GetKnowledge(ctx context.Context, id string) (*sdk.Knowledge, error)
}
// NewCmdView builds `weknora doc view <id>`.
func NewCmdView(f *cmdutil.Factory) *cobra.Command {
opts := &ViewOptions{}
cmd := &cobra.Command{
Use: "view <doc-id>",
Short: "Show a document by ID",
Example: ` weknora doc view doc_abc
weknora doc view doc_abc --format json`,
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
cli, err := f.Client()
if err != nil {
return err
}
return runView(c.Context(), opts, fopts, cli, args[0])
},
}
cmdutil.AddFormatFlag(cmd, docViewFields...)
cmdutil.AddIgnoredKBFlag(cmd)
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "fetch one document's metadata by id",
RequiredFlags: []string{"<doc-id> (positional)"},
Examples: []string{"weknora doc view doc_abc", "weknora doc view doc_abc --jq .data.parse_status"},
Output: "envelope.data is the document object (id, file_name, parse_status, ...)",
})
return cmd
}
func runView(ctx context.Context, opts *ViewOptions, fopts *cmdutil.FormatOptions, svc ViewService, id string) error {
doc, err := svc.GetKnowledge(ctx, id)
if err != nil {
return cmdutil.WrapHTTP(err, "get document %q", id)
}
if fopts.WantsJSON() {
return fopts.Emit(iostreams.IO.Out, doc, nil)
}
w := iostreams.IO.Out
fmt.Fprintf(w, "ID: %s\n", doc.ID)
fmt.Fprintf(w, "NAME: %s\n", text.KnowledgeDisplayName(doc.FileName, doc.Title, doc.ID))
// Title is rendered as a separate line only when it adds info over
// NAME (i.e. FileName is set AND differs from Title). When FileName
// is empty, KnowledgeDisplayName already used Title for NAME so a
// duplicate TITLE line would be noise.
if doc.Title != "" && doc.FileName != "" && doc.Title != doc.FileName {
fmt.Fprintf(w, "TITLE: %s\n", doc.Title)
}
if doc.KnowledgeBaseID != "" {
fmt.Fprintf(w, "KB: %s\n", doc.KnowledgeBaseID)
}
if doc.TagID != "" {
fmt.Fprintf(w, "TAG: %s\n", doc.TagID)
}
if doc.Description != "" {
fmt.Fprintf(w, "DESC: %s\n", doc.Description)
}
if doc.FileType != "" {
fmt.Fprintf(w, "TYPE: %s\n", doc.FileType)
}
if doc.Source != "" {
fmt.Fprintf(w, "SOURCE: %s\n", doc.Source)
}
if doc.Channel != "" {
fmt.Fprintf(w, "CHANNEL: %s\n", doc.Channel)
}
if doc.FileSize > 0 {
fmt.Fprintf(w, "SIZE: %s\n", formatSize(doc.FileSize))
}
if doc.StorageSize > 0 {
fmt.Fprintf(w, "STORAGE: %s\n", formatSize(doc.StorageSize))
}
if doc.ParseStatus != "" {
fmt.Fprintf(w, "STATUS: %s\n", doc.ParseStatus)
}
if doc.SummaryStatus != "" {
fmt.Fprintf(w, "SUMMARY: %s\n", doc.SummaryStatus)
}
if doc.EnableStatus != "" {
fmt.Fprintf(w, "ENABLED: %s\n", doc.EnableStatus)
}
if doc.EmbeddingModelID != "" {
fmt.Fprintf(w, "EMBEDDING: %s\n", doc.EmbeddingModelID)
}
if doc.FileHash != "" {
// Git-SHA-style 12-char prefix is enough for de-duplication
// while keeping the line short.
h := doc.FileHash
if len(h) > 12 {
h = h[:12]
}
fmt.Fprintf(w, "HASH: %s\n", h)
}
if !doc.CreatedAt.IsZero() {
fmt.Fprintf(w, "CREATED: %s\n", doc.CreatedAt.Format("2006-01-02 15:04:05"))
}
if !doc.UpdatedAt.IsZero() {
fmt.Fprintf(w, "UPDATED: %s\n", doc.UpdatedAt.Format("2006-01-02 15:04:05"))
}
if doc.ProcessedAt != nil && !doc.ProcessedAt.IsZero() {
fmt.Fprintf(w, "PROCESSED: %s\n", doc.ProcessedAt.Format("2006-01-02 15:04:05"))
}
if doc.ErrorMessage != "" {
fmt.Fprintf(w, "ERROR: %s\n", doc.ErrorMessage)
}
return nil
}
+272
View File
@@ -0,0 +1,272 @@
package doc
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
// fakeViewSvc scripts a GetKnowledge response. Tests bypass cobra and call
// runView directly with this fake injected.
type fakeViewSvc struct {
doc *sdk.Knowledge
err error
}
func (f *fakeViewSvc) GetKnowledge(_ context.Context, _ string) (*sdk.Knowledge, error) {
return f.doc, f.err
}
func TestView_Text_RendersExpectedFields(t *testing.T) {
out, _ := iostreams.SetForTest(t)
processed := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
svc := &fakeViewSvc{doc: &sdk.Knowledge{
ID: "doc_abc",
KnowledgeBaseID: "kb1",
FileName: "policy.pdf",
Title: "Policy",
FileType: "pdf",
FileSize: 2048,
ParseStatus: "completed",
EmbeddingModelID: "text-embedding-3",
CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
UpdatedAt: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC),
ProcessedAt: &processed,
}}
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "doc_abc"); err != nil {
t.Fatalf("runView: %v", err)
}
got := out.String()
for _, want := range []string{
"ID:", "doc_abc", "NAME:", "policy.pdf", "KB:", "kb1",
"TYPE:", "pdf", "SIZE:", "STATUS:", "completed",
"EMBEDDING:", "text-embedding-3", "PROCESSED:",
} {
if !strings.Contains(got, want) {
t.Errorf("missing %q in:\n%s", want, got)
}
}
}
// A doc with no FileName falls back to Title for the NAME line - same
// ordering as `doc list` (KnowledgeDisplayName precedence).
func TestView_Text_TitleFallback(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeViewSvc{doc: &sdk.Knowledge{ID: "doc_url", Title: "Pasted article", FileName: ""}}
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "doc_url"); err != nil {
t.Fatalf("runView: %v", err)
}
got := out.String()
if !strings.Contains(got, "Pasted article") {
t.Errorf("expected Title fallback in NAME line; got:\n%s", got)
}
}
// Optional fields (ProcessedAt nil, ErrorMessage empty, EmbeddingModelID
// empty) must not produce empty KEY: lines - the formatter omits them
// rather than printing "PROCESSED: -".
func TestView_Text_OmitsEmptyFields(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeViewSvc{doc: &sdk.Knowledge{
ID: "doc_abc",
FileName: "x.txt",
}}
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "doc_abc"); err != nil {
t.Fatalf("runView: %v", err)
}
// Line-prefix match (not substring): "ERROR:" as a substring could
// also appear inside `STATUS: error` if a future test seeds
// ParseStatus="error". Splitting by newline pins the assertion to the
// label column.
lines := strings.Split(out.String(), "\n")
for _, prefix := range []string{"PROCESSED:", "EMBEDDING:", "ERROR:"} {
for _, l := range lines {
if strings.HasPrefix(l, prefix) {
t.Errorf("expected no line beginning with %q (empty field), got: %q", prefix, l)
}
}
}
}
func TestView_JSON_BareObject(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeViewSvc{doc: &sdk.Knowledge{ID: "doc_abc", FileName: "x.txt", KnowledgeBaseID: "kb1"}}
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "doc_abc"); err != nil {
t.Fatalf("runView: %v", err)
}
got := out.String()
var env struct {
OK bool `json:"ok"`
Data sdk.Knowledge `json:"data"`
}
if err := json.Unmarshal([]byte(got), &env); err != nil {
t.Fatalf("parse: %v\n%s", err, got)
}
if !env.OK {
t.Errorf("envelope.ok must be true, got %q", got)
}
for _, want := range []string{`"id":"doc_abc"`, `"file_name":"x.txt"`, `"knowledge_base_id":"kb1"`} {
if !strings.Contains(got, want) {
t.Errorf("missing %q in:\n%s", want, got)
}
}
}
func TestView_NotFound_ClassifiedAs404(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeViewSvc{err: errors.New("HTTP error 404: not found")}
err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "missing")
if err == nil {
t.Fatal("expected error")
}
if !cmdutil.IsNotFound(err) {
t.Errorf("expected resource.not_found, got %v", err)
}
}
// --- expanded text render: title/desc/source/channel/etc. ---
func TestView_Title_RendersWhenDifferentFromFileName(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeViewSvc{doc: &sdk.Knowledge{
ID: "doc_t", FileName: "raw.pdf", Title: "Quarterly Plan",
}}
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "doc_t"); err != nil {
t.Fatalf("runView: %v", err)
}
got := out.String()
if !strings.Contains(got, "TITLE:") || !strings.Contains(got, "Quarterly Plan") {
t.Errorf("expected TITLE line:\n%s", got)
}
}
// When Title and FileName are equal, the TITLE line is redundant with NAME
// and should be omitted.
func TestView_Title_OmittedWhenSameAsFileName(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeViewSvc{doc: &sdk.Knowledge{
ID: "doc_t", FileName: "policy.pdf", Title: "policy.pdf",
}}
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "doc_t"); err != nil {
t.Fatalf("runView: %v", err)
}
for _, l := range strings.Split(out.String(), "\n") {
if strings.HasPrefix(l, "TITLE:") {
t.Errorf("TITLE line should be omitted when same as filename: %q", l)
}
}
}
func TestView_Description(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeViewSvc{doc: &sdk.Knowledge{ID: "doc_d", FileName: "x.pdf", Description: "Annual review"}}
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "doc_d"); err != nil {
t.Fatalf("runView: %v", err)
}
if !strings.Contains(out.String(), "Annual review") {
t.Errorf("expected description text:\n%s", out.String())
}
}
func TestView_SourceAndChannel(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeViewSvc{doc: &sdk.Knowledge{
ID: "doc_s", FileName: "x.pdf", Source: "https://example.com/x", Channel: "web",
}}
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "doc_s"); err != nil {
t.Fatalf("runView: %v", err)
}
got := out.String()
for _, want := range []string{"SOURCE:", "https://example.com/x", "CHANNEL:", "web"} {
if !strings.Contains(got, want) {
t.Errorf("missing %q in:\n%s", want, got)
}
}
}
func TestView_SummaryAndEnableStatus(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeViewSvc{doc: &sdk.Knowledge{
ID: "doc_st", FileName: "x.pdf",
SummaryStatus: "completed",
EnableStatus: "disabled",
}}
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "doc_st"); err != nil {
t.Fatalf("runView: %v", err)
}
got := out.String()
for _, want := range []string{"SUMMARY:", "completed", "ENABLED:", "disabled"} {
if !strings.Contains(got, want) {
t.Errorf("missing %q in:\n%s", want, got)
}
}
}
func TestView_TagID(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeViewSvc{doc: &sdk.Knowledge{ID: "doc_t", FileName: "x.pdf", TagID: "tag_abc"}}
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "doc_t"); err != nil {
t.Fatalf("runView: %v", err)
}
got := out.String()
if !strings.Contains(got, "TAG:") || !strings.Contains(got, "tag_abc") {
t.Errorf("expected TAG line:\n%s", got)
}
}
func TestView_StorageSize_Text(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeViewSvc{doc: &sdk.Knowledge{ID: "doc_sz", FileName: "x.pdf", StorageSize: 2 * 1024 * 1024}}
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "doc_sz"); err != nil {
t.Fatalf("runView: %v", err)
}
got := out.String()
if !strings.Contains(got, "STORAGE:") || !strings.Contains(got, "MB") {
t.Errorf("expected STORAGE line with human-readable bytes:\n%s", got)
}
}
func TestView_FileHash_Prefix12(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeViewSvc{doc: &sdk.Knowledge{
ID: "doc_h",
FileName: "x.pdf",
FileHash: "abcdef1234567890fedcba0987654321",
}}
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "doc_h"); err != nil {
t.Fatalf("runView: %v", err)
}
got := out.String()
if !strings.Contains(got, "HASH:") || !strings.Contains(got, "abcdef123456") {
t.Errorf("expected HASH line with 12-char prefix:\n%s", got)
}
if strings.Contains(got, "abcdef1234567890fedcba0987654321") {
t.Errorf("full hash should be truncated, got:\n%s", got)
}
}
func TestView_ErrorMessage_WarnPrefix(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeViewSvc{doc: &sdk.Knowledge{
ID: "doc_e", FileName: "x.pdf", ErrorMessage: "parser failed at offset 4096",
}}
if err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "doc_e"); err != nil {
t.Fatalf("runView: %v", err)
}
got := out.String()
if !strings.Contains(got, "parser failed at offset 4096") {
t.Errorf("expected error message rendered:\n%s", got)
}
// Either ERROR: or a WARN-prefixed label is acceptable as a "warn"
// signal — assert at least one.
if !strings.Contains(got, "ERROR:") && !strings.Contains(got, "WARN") {
t.Errorf("expected ERROR or WARN prefix on error line:\n%s", got)
}
}
+331
View File
@@ -0,0 +1,331 @@
package doc
import (
"context"
"errors"
"fmt"
"io"
"math/rand"
"sync"
"time"
sdk "github.com/Tencent/WeKnora/client"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
)
// WaitOptions captures `doc wait` flag state.
type WaitOptions struct {
IDs []string
Timeout time.Duration
Interval time.Duration
}
// NewCmdWait builds `weknora doc wait <id> [<id>...]`.
//
// Multi-id behaviour is always wait-all: blocks until every id reaches a
// terminal state. Use shell composition
// (`weknora doc wait id1 && weknora doc wait id2`) when fail-fast is
// desired.
func NewCmdWait(f *cmdutil.Factory) *cobra.Command {
opts := &WaitOptions{}
cmd := &cobra.Command{
Use: "wait <doc-id> [<doc-id>...]",
Short: "Wait for one or more documents to finish parsing",
Long: `Block until every given document reaches a terminal parse_status
(completed or failed), the timeout expires, or the user interrupts (Ctrl-C).
Always wait-all: every id must reach a terminal state before returning.
Exit codes:
0 all completed
1 any failed
124 --timeout reached (matches GNU 'timeout' command)
130 Ctrl-C / SIGINT
Multi-id is polled concurrently (max 5 parallel; use 'xargs -P' for more).
For fail-fast semantics, use shell composition:
weknora doc wait id1 && weknora doc wait id2 && weknora doc wait id3`,
Example: ` weknora doc wait doc_abc
weknora doc wait id1 id2 id3 --timeout 20m
weknora doc wait id1 id2 --format ndjson`,
Args: cobra.MinimumNArgs(1),
RunE: func(c *cobra.Command, args []string) error {
// Validate flags FIRST so an invalid --format doesn't cost the
// user a multi-minute poll before erroring out.
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
opts.IDs = args
cli, err := f.Client()
if err != nil {
return err
}
res, err := waitForDocs(c.Context(), opts.IDs, cli, *opts)
if err != nil {
return err
}
if err := emitWaitResult(res, fopts, iostreams.IO.Out); err != nil {
return err
}
// The partition (completed/failed/timeout) is already on stdout via
// emitWaitResult. On aggregate failure we still need a non-zero exit,
// but the error is Silent so PrintError does NOT write a second,
// contradictory {ok:false} envelope to stderr — the agent reads the
// failure detail from the stdout partition + the exit code (1/124).
// Same contract as cmdutil.RunBatch.
switch res.ExitCode() {
case 0:
return nil
case 1:
return cmdutil.NewError(cmdutil.CodeOperationFailed, fmt.Sprintf("%d doc(s) failed", len(res.Failed))).WithSilent()
case 124:
return cmdutil.NewError(cmdutil.CodeOperationTimeout, fmt.Sprintf("wait timed out (%d doc(s) still pending)", len(res.Timeout))).WithSilent()
}
return nil
},
}
cmd.Flags().DurationVar(&opts.Timeout, "timeout", 10*time.Minute, "Max wait time before exiting 124")
cmd.Flags().DurationVar(&opts.Interval, "interval", 2*time.Second, "Initial poll interval; exponential backoff capped at 15s + jitter")
cmdutil.AddFormatFlag(cmd)
cmdutil.AddIgnoredKBFlag(cmd)
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "block until one or more documents reach a terminal parse state (completed or failed), or --timeout elapses",
RequiredFlags: []string{"<doc-id>... (one or more positionals)"},
Examples: []string{
"weknora doc wait doc_abc",
"weknora doc wait doc_a doc_b --timeout 5m",
},
Output: "envelope.data is {completed:[], failed:[{id,message}], timeout:[]}",
Warnings: []string{
"exit code carries the aggregate result: 0 all completed, 1 any failed, 124 timeout",
"ok:true means the wait ran to a terminal state — NOT that every doc succeeded; branch on the exit code or inspect data.failed",
},
})
return cmd
}
// ---------------------------------------------------------------------------
// Core poll loop (B2)
// ---------------------------------------------------------------------------
// WaitService is the narrow SDK surface needed for polling.
type WaitService interface {
GetKnowledge(ctx context.Context, id string) (*sdk.Knowledge, error)
}
// WaitResult is the terminal-state partition returned by waitForDocs.
type WaitResult struct {
Completed []string `json:"completed"`
Failed []FailedDoc `json:"failed,omitempty"`
Timeout []string `json:"timeout,omitempty"`
}
// FailedDoc carries the id + reason for a doc that reached parse_status=failed
// or that GetKnowledge returned an error for.
type FailedDoc struct {
ID string `json:"id"`
Code string `json:"code,omitempty"`
Message string `json:"message,omitempty"`
}
const (
maxConcurrentPolls = 5
maxBackoffInterval = 15 * time.Second
jitterMax = 500 * time.Millisecond
)
// waitForDocs polls each id until terminal state or timeout. Concurrency is
// bounded by maxConcurrentPolls. Returns the partitioned terminal state.
// Always waits for every id (wait-all semantics).
//
// Duplicate ids are deduplicated at entry — polling the same id twice would
// produce duplicate result entries and waste poll quota.
//
// Exponential backoff starts at opts.Interval, doubles each tick, caps at
// maxBackoffInterval, with up to jitterMax random jitter added per sleep.
func waitForDocs(ctx context.Context, ids []string, svc WaitService, opts WaitOptions) (*WaitResult, error) {
// Dedup ids while preserving first-seen order.
seen := make(map[string]struct{}, len(ids))
deduped := make([]string, 0, len(ids))
for _, id := range ids {
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
deduped = append(deduped, id)
}
ids = deduped
ctx, cancel := context.WithTimeout(ctx, opts.Timeout)
defer cancel()
// Completed is non-omitempty (always present in the partition), so start it
// as an empty slice rather than nil — agents see `"completed":[]`, not null.
result := &WaitResult{Completed: []string{}}
var mu sync.Mutex
addCompleted := func(id string) {
mu.Lock()
defer mu.Unlock()
result.Completed = append(result.Completed, id)
}
addFailed := func(fd FailedDoc) {
mu.Lock()
defer mu.Unlock()
result.Failed = append(result.Failed, fd)
}
addTimeout := func(id string) {
mu.Lock()
defer mu.Unlock()
result.Timeout = append(result.Timeout, id)
}
sem := make(chan struct{}, maxConcurrentPolls)
var wg sync.WaitGroup
for _, id := range ids {
wg.Add(1)
go func(id string) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
interval := opts.Interval
for {
select {
case <-ctx.Done():
// Distinguish SIGINT/SIGTERM (Canceled) from --timeout
// (DeadlineExceeded) so a user interrupt does not
// pollute the timeout list with the in-flight ids.
if errors.Is(ctx.Err(), context.Canceled) {
// Signal-driven cancel: root's signal handler
// exits 130; don't classify these ids as timed out.
return
}
addTimeout(id)
return
default:
}
doc, err := svc.GetKnowledge(ctx, id)
if err != nil {
// Check Canceled first — SIGINT during an in-flight
// request surfaces as a context-canceled error; it is
// not a real GetKnowledge failure.
if errors.Is(ctx.Err(), context.Canceled) {
return
}
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
addTimeout(id)
return
}
addFailed(FailedDoc{ID: id, Message: err.Error()})
return
}
switch doc.ParseStatus {
case "completed":
addCompleted(id)
return
case "failed":
addFailed(FailedDoc{ID: id, Message: doc.ErrorMessage})
return
case "draft":
// draft = created but NOT queued for parsing (inline
// `doc create` leaves docs here; file `doc upload`
// auto-enqueues). It never progresses on its own, so
// waiting would hang to the --timeout (124). Fail fast with
// the exact unblock command instead of silently polling.
addFailed(FailedDoc{ID: id, Message: "parse_status=draft: not queued for parsing — run `weknora doc reparse " + id + "` to index it"})
return
}
// Not yet terminal — sleep with jitter, then exp-backoff.
jitter := time.Duration(rand.Int63n(int64(jitterMax)))
timer := time.NewTimer(interval + jitter)
select {
case <-ctx.Done():
timer.Stop()
if errors.Is(ctx.Err(), context.Canceled) {
return
}
addTimeout(id)
return
case <-timer.C:
}
interval *= 2
if interval > maxBackoffInterval {
interval = maxBackoffInterval
}
}
}(id)
}
wg.Wait()
return result, nil
}
// ExitCode resolves the compound terminal state to a Unix exit code.
// Priority: 1 > 124 > 0 (failed > timeout > completed). SIGINT (exit 130)
// is handled by the Go runtime / context cancellation, not here.
func (r *WaitResult) ExitCode() int {
if len(r.Failed) > 0 {
return 1
}
if len(r.Timeout) > 0 {
return 124
}
return 0
}
// Compile-time assertion: *sdk.Client satisfies WaitService.
var _ WaitService = (*sdk.Client)(nil)
// ---------------------------------------------------------------------------
// B5: output rendering
// ---------------------------------------------------------------------------
// emitWaitResult renders r according to --format. Output writer is
// parametrized for tests; production callers pass iostreams.IO.Out.
func emitWaitResult(r *WaitResult, fopts *cmdutil.FormatOptions, w io.Writer) error {
switch fopts.Mode {
case cmdutil.FormatJSON, cmdutil.FormatNDJSON:
return fopts.Emit(w, r, nil)
case cmdutil.FormatText, "":
return writeWaitText(w, r)
default:
return fmt.Errorf("unsupported --format %q for doc wait", fopts.Mode)
}
}
// writeWaitText renders r as human-readable lines:
//
// ✓ <id> completed
// ✗ <id> failed: <message>
// ⏱ <id> timeout
func writeWaitText(w io.Writer, r *WaitResult) error {
for _, id := range r.Completed {
if _, err := fmt.Fprintf(w, "✓ %s completed\n", id); err != nil {
return err
}
}
for _, fd := range r.Failed {
msg := fd.Message
if msg == "" {
msg = "(no message)"
}
if _, err := fmt.Fprintf(w, "✗ %s failed: %s\n", fd.ID, msg); err != nil {
return err
}
}
for _, id := range r.Timeout {
if _, err := fmt.Fprintf(w, "⏱ %s timeout\n", id); err != nil {
return err
}
}
return nil
}
+340
View File
@@ -0,0 +1,340 @@
package doc
import (
"bytes"
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"testing"
"time"
sdk "github.com/Tencent/WeKnora/client"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
)
func TestWaitCmd_Shape(t *testing.T) {
cmd := NewCmdWait(&cmdutil.Factory{})
if !strings.Contains(cmd.Use, "<doc-id>") {
t.Errorf("Use=%q missing <doc-id>", cmd.Use)
}
if f := cmd.Flags().Lookup("timeout"); f == nil {
t.Error("missing --timeout flag")
}
if f := cmd.Flags().Lookup("interval"); f == nil {
t.Error("missing --interval flag")
}
// wait is always wait-all: no fail-fast option. --keep-going is
// intentionally absent; users who want fail-fast compose with shell
// short-circuiting (`wait id1 && wait id2`).
if f := cmd.Flags().Lookup("keep-going"); f != nil {
t.Error("--keep-going should not be registered; wait is always wait-all")
}
// --format is a persistent root flag (v0.7), not per-command — skip its lookup here.
}
func TestWaitCmd_DefaultFlagValues(t *testing.T) {
cmd := NewCmdWait(&cmdutil.Factory{})
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags: %v", err)
}
tf, _ := cmd.Flags().GetDuration("timeout")
if tf != 10*time.Minute {
t.Errorf("--timeout default = %v, want 10m", tf)
}
in, _ := cmd.Flags().GetDuration("interval")
if in != 2*time.Second {
t.Errorf("--interval default = %v, want 2s", in)
}
}
// ---------------------------------------------------------------------------
// B2: waitForDocs tests
// ---------------------------------------------------------------------------
type fakeKnowledgeSvc struct {
mu sync.Mutex
calls map[string]int // id → call count
sequence map[string][]string // id → parse_status sequence to return
}
func newFakeKBSvc(seq map[string][]string) *fakeKnowledgeSvc {
return &fakeKnowledgeSvc{
calls: make(map[string]int),
sequence: seq,
}
}
func (f *fakeKnowledgeSvc) GetKnowledge(_ context.Context, id string) (*sdk.Knowledge, error) {
f.mu.Lock()
defer f.mu.Unlock()
idx := f.calls[id]
f.calls[id]++
seq, ok := f.sequence[id]
if !ok {
return nil, fmt.Errorf("unexpected id %q", id)
}
if idx >= len(seq) {
idx = len(seq) - 1
}
status := seq[idx]
k := &sdk.Knowledge{ID: id, ParseStatus: status}
if status == "failed" {
k.ErrorMessage = "parse error"
}
return k, nil
}
func TestWaitForDocs_SingleIDCompletes(t *testing.T) {
svc := newFakeKBSvc(map[string][]string{
"doc_x": {"processing", "processing", "completed"},
})
res, err := waitForDocs(context.Background(), []string{"doc_x"}, svc, WaitOptions{
Timeout: 5 * time.Second,
Interval: 1 * time.Millisecond, // fast for tests
})
if err != nil {
t.Fatalf("waitForDocs: %v", err)
}
if len(res.Completed) != 1 || res.Completed[0] != "doc_x" {
t.Errorf("Completed=%v, want [doc_x]", res.Completed)
}
if svc.calls["doc_x"] < 3 {
t.Errorf("expected at least 3 polls, got %d", svc.calls["doc_x"])
}
}
func TestWaitForDocs_SingleIDFails(t *testing.T) {
svc := newFakeKBSvc(map[string][]string{
"doc_x": {"failed"},
})
res, _ := waitForDocs(context.Background(), []string{"doc_x"}, svc, WaitOptions{
Timeout: 5 * time.Second,
Interval: 1 * time.Millisecond,
})
if len(res.Failed) != 1 || res.Failed[0].ID != "doc_x" {
t.Errorf("Failed=%v, want [doc_x]", res.Failed)
}
if res.Failed[0].Message != "parse error" {
t.Errorf("Failed[0].Message=%q, want %q", res.Failed[0].Message, "parse error")
}
}
func TestWaitForDocs_Timeout(t *testing.T) {
svc := newFakeKBSvc(map[string][]string{
"doc_x": {"processing"}, // never completes
})
res, _ := waitForDocs(context.Background(), []string{"doc_x"}, svc, WaitOptions{
Timeout: 20 * time.Millisecond, // tight
Interval: 5 * time.Millisecond,
})
if len(res.Timeout) != 1 || res.Timeout[0] != "doc_x" {
t.Errorf("Timeout=%v, want [doc_x]", res.Timeout)
}
}
// ---------------------------------------------------------------------------
// B3: multi-id wait-all behavior
// ---------------------------------------------------------------------------
func TestWaitForDocs_MultiID_AllSucceed(t *testing.T) {
svc := newFakeKBSvc(map[string][]string{
"a": {"processing", "completed"},
"b": {"completed"},
"c": {"processing", "processing", "completed"},
})
res, err := waitForDocs(context.Background(), []string{"a", "b", "c"}, svc, WaitOptions{
Timeout: 5 * time.Second,
Interval: 1 * time.Millisecond,
})
if err != nil {
t.Fatalf("waitForDocs: %v", err)
}
if len(res.Completed) != 3 {
t.Errorf("Completed=%v (len %d), want 3", res.Completed, len(res.Completed))
}
if len(res.Failed) != 0 || len(res.Timeout) != 0 {
t.Errorf("Failed=%v Timeout=%v, want both empty", res.Failed, res.Timeout)
}
}
// TestWaitForDocs_MultiID_WaitAllOnPartialFailure verifies wait-all
// semantics: even when one id fails terminally, the remaining ids are
// still waited on until they reach their own terminal state.
func TestWaitForDocs_MultiID_WaitAllOnPartialFailure(t *testing.T) {
svc := newFakeKBSvc(map[string][]string{
"a": {"failed"},
"b": {"processing", "completed"},
"c": {"completed"},
})
res, _ := waitForDocs(context.Background(), []string{"a", "b", "c"}, svc, WaitOptions{
Timeout: 5 * time.Second,
Interval: 1 * time.Millisecond,
})
if len(res.Failed) != 1 || res.Failed[0].ID != "a" {
t.Errorf("Failed=%v, want [{a, parse error}]", res.Failed)
}
completedSet := make(map[string]bool)
for _, id := range res.Completed {
completedSet[id] = true
}
if !completedSet["b"] || !completedSet["c"] {
t.Errorf("Completed=%v, want both b and c present", res.Completed)
}
}
// ---------------------------------------------------------------------------
// B5: emitWaitResult rendering tests
// ---------------------------------------------------------------------------
func TestEmitWaitResult_TextSummary(t *testing.T) {
var buf bytes.Buffer
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatText}
res := &WaitResult{
Completed: []string{"a", "b"},
Failed: []FailedDoc{{ID: "c", Message: "parse boom"}},
Timeout: []string{"d"},
}
if err := emitWaitResult(res, fopts, &buf); err != nil {
t.Fatalf("emitWaitResult: %v", err)
}
out := buf.String()
for _, want := range []string{"a", "b", "c", "d", "parse boom"} {
if !strings.Contains(out, want) {
t.Errorf("text output missing %q:\n%s", want, out)
}
}
}
func TestEmitWaitResult_JSONSingleRecord(t *testing.T) {
var buf bytes.Buffer
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}
res := &WaitResult{
Completed: []string{"a"},
Failed: []FailedDoc{{ID: "b", Message: "x"}},
}
if err := emitWaitResult(res, fopts, &buf); err != nil {
t.Fatalf("emitWaitResult: %v", err)
}
var env struct {
OK bool `json:"ok"`
Data WaitResult `json:"data"`
}
if err := json.Unmarshal(buf.Bytes(), &env); err != nil {
t.Fatalf("not JSON: %v\n%s", err, buf.String())
}
got := env.Data
if len(got.Completed) != 1 || got.Completed[0] != "a" {
t.Errorf("Completed=%v, want [a]", got.Completed)
}
if len(got.Failed) != 1 || got.Failed[0].ID != "b" {
t.Errorf("Failed=%v, want [{b, x}]", got.Failed)
}
}
func TestEmitWaitResult_NDJSONSingleLine(t *testing.T) {
var buf bytes.Buffer
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatNDJSON}
res := &WaitResult{Completed: []string{"a"}}
if err := emitWaitResult(res, fopts, &buf); err != nil {
t.Fatalf("emitWaitResult: %v", err)
}
out := buf.String()
// NDJSON: single record = single line ending with \n
if strings.Count(out, "\n") != 1 {
t.Errorf("expected exactly 1 newline, got %q", out)
}
var got WaitResult
if err := json.Unmarshal([]byte(strings.TrimRight(out, "\n")), &got); err != nil {
t.Fatalf("line not JSON: %v\n%s", err, out)
}
if len(got.Completed) != 1 {
t.Error("expected Completed populated")
}
}
// ---------------------------------------------------------------------------
// B4: WaitResult.ExitCode tests
// ---------------------------------------------------------------------------
func TestWaitResult_ExitCode(t *testing.T) {
cases := []struct {
name string
res *WaitResult
want int
}{
{"all completed", &WaitResult{Completed: []string{"a"}}, 0},
{"empty result", &WaitResult{}, 0},
{"any failed", &WaitResult{Failed: []FailedDoc{{ID: "a"}}}, 1},
{"timeout only", &WaitResult{Timeout: []string{"a"}}, 124},
{"failed wins over timeout", &WaitResult{
Failed: []FailedDoc{{ID: "a"}},
Timeout: []string{"b"},
}, 1},
{"failed wins over completed+timeout", &WaitResult{
Completed: []string{"a"},
Failed: []FailedDoc{{ID: "b"}},
Timeout: []string{"c"},
}, 1},
{"timeout wins over completed", &WaitResult{
Completed: []string{"a"},
Timeout: []string{"b"},
}, 124},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := tc.res.ExitCode(); got != tc.want {
t.Errorf("ExitCode = %d, want %d", got, tc.want)
}
})
}
}
// On aggregate failure, doc wait emits the partition envelope to stdout; the
// returned error must be Silent so PrintError does NOT write a second,
// contradictory {ok:false} envelope to stderr. Regression for the dual-envelope
// contract violation (stdout ok:true + stderr ok:false for one invocation).
func TestDocWait_FailureError_IsSilent(t *testing.T) {
// Mirror RunBatch's contract: the failure carries CodeOperationFailed for
// the exit code, but Silent=true suppresses the stderr envelope.
err := cmdutil.NewError(cmdutil.CodeOperationFailed, "1 doc(s) failed").WithSilent()
typed := cmdutil.AsError(err)
if typed == nil {
t.Fatal("expected typed *Error")
}
if !typed.Silent {
t.Error("doc wait failure error must be Silent to avoid a contradictory stderr envelope")
}
if cmdutil.ExitCode(err) != 1 {
t.Errorf("exit code = %d, want 1", cmdutil.ExitCode(err))
}
}
// TestWaitForDocs_DraftFailsFast pins that a document stuck in parse_status
// "draft" (inline `doc create` leaves docs here; it never auto-progresses) is
// failed fast with a reparse hint instead of polling until the --timeout.
// Regression: `doc create` + `doc wait` used to hang to a 124 timeout.
func TestWaitForDocs_DraftFailsFast(t *testing.T) {
svc := newFakeKBSvc(map[string][]string{
"doc_draft": {"draft", "draft", "draft"},
})
start := time.Now()
res, _ := waitForDocs(context.Background(), []string{"doc_draft"}, svc, WaitOptions{
Timeout: 5 * time.Second,
Interval: 1 * time.Millisecond,
})
if len(res.Timeout) != 0 {
t.Errorf("draft must NOT time out; got timeout=%v", res.Timeout)
}
if len(res.Failed) != 1 || res.Failed[0].ID != "doc_draft" {
t.Fatalf("draft doc must be failed-fast; got failed=%v", res.Failed)
}
if !strings.Contains(res.Failed[0].Message, "reparse") {
t.Errorf("draft failure message must point to reparse; got %q", res.Failed[0].Message)
}
if elapsed := time.Since(start); elapsed > time.Second {
t.Errorf("draft must fail fast (well under timeout); took %v", elapsed)
}
}
+487
View File
@@ -0,0 +1,487 @@
// Package doctor implements `weknora doctor` - 4-item self-check.
//
// Status semantics (4-tier):
//
// ok - passed
// warn - soft problem; non-blocking (e.g. server minor older than CLI,
// keychain unavailable so falling back to file store)
// fail - failed; "hint" actionable
// skip - cascade-skipped (prereq failed) or --offline mode
//
// JSON output emits the Result object directly (bare data). Exit-code
// signal:
// - any check is fail → exit 1 (RunE returns SilentError so the data
// object is still emitted)
// - warn only / all ok → exit 0
//
// summary.all_passed gives the agent a one-line short-circuit; it is true
// ONLY when no warn / fail / skip checks are present. Agents SHOULD also
// inspect checks[].status to distinguish warn from ok.
package doctor
import (
"context"
"fmt"
"net/http"
"os"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/build"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/compat"
"github.com/Tencent/WeKnora/cli/internal/config"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/secrets"
sdk "github.com/Tencent/WeKnora/client"
)
// doctorFields enumerates the fields surfaced for `--format json` discovery on
// `doctor`. Items here refer to data.checks[*] entries (Check struct).
var doctorFields = []string{"name", "status", "details", "hint"}
type Options struct {
NoCache bool
Offline bool
}
// Status is the per-check outcome on the wire (JSON Marshal still emits the
// underlying string). Typed so cascade comparisons can't typo against bare
// "ok"/"fail"/"skip" string literals.
type Status string
const (
StatusOK Status = "ok"
StatusWarn Status = "warn"
StatusFail Status = "fail"
StatusSkip Status = "skip"
)
// Check is one row in the report.
type Check struct {
Name string `json:"name"`
Status Status `json:"status"`
Details string `json:"details,omitempty"`
Hint string `json:"hint,omitempty"`
}
// Summary is the agent-friendly short-circuit payload.
//
// AllPassed is true only when there are zero warn/fail/skip rows; warn does
// not block exit-0 but it does flip AllPassed so agents reading just the
// boolean still notice the soft issue.
type Summary struct {
AllPassed bool `json:"all_passed"`
Passed int `json:"passed"`
Warned int `json:"warned,omitempty"`
Failed int `json:"failed"`
Skipped int `json:"skipped"`
}
// Result is the bare JSON payload.
type Result struct {
Summary Summary `json:"summary"`
Checks []Check `json:"checks"`
}
// Services groups the narrow interfaces doctor needs. Implemented by
// realServices (production) and fakeServices (tests).
type Services interface {
PingBaseURL(ctx context.Context) error
GetCurrentUser(ctx context.Context) (*sdk.CurrentUserResponse, error)
GetSystemInfo(ctx context.Context) (*sdk.SystemInfo, error)
}
// NewCmd builds `weknora doctor`.
func NewCmd(f *cmdutil.Factory) *cobra.Command {
opts := &Options{}
cmd := &cobra.Command{
Use: "doctor",
Short: "Run 4 self-checks: base URL, auth, server version, credential storage",
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, _ []string) error {
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
svc, err := buildServices(f)
if err != nil {
return err
}
cliVer, _, _ := build.Info()
r := runChecks(c.Context(), opts, svc, cliVer)
emit(fopts, r)
// Exit-code policy: fail → exit 1; warn / ok / skip → exit 0.
// SilentError suppresses both the human "error: ..." line and
// the stderr error formatter, so the JSON already written by
// emit() is the only stdout content.
if r.Summary.Failed > 0 {
return cmdutil.SilentError
}
return nil
},
}
cmd.Flags().BoolVar(&opts.NoCache, "no-cache", false, "Bypass server-info cache (located at $XDG_CACHE_HOME/weknora/server-info.yaml); force re-probe")
cmd.Flags().BoolVar(&opts.Offline, "offline", false, "Skip network checks; only verify local keyring/file storage (credential_storage check still runs)")
cmdutil.AddFormatFlag(cmd, doctorFields...)
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "run self-checks: base-url reachability, auth credential, server version, credential storage",
Examples: []string{
"weknora doctor",
"weknora doctor --offline",
"weknora doctor --no-cache --format json",
},
Output: "envelope.data is {summary:{all_passed,passed,warned,failed,skipped}, checks:[{name,status,details,hint?}]}",
})
return cmd
}
// cascade implements the two short-circuits every gated check shares:
// offline-mode skip and prereq-failed skip. Returns true when the check has
// been completed (Status set on c) and the caller should NOT run its body.
//
// A prereq is considered "passing" if its Status is OK or Warn - warn signals
// a soft problem that does not block downstream functionality. Only fail/skip
// cascades into a downstream skip.
func cascade(c *Check, offline bool, prereqs ...*Check) bool {
if offline {
c.Status, c.Details = StatusSkip, "offline mode"
return true
}
for _, p := range prereqs {
if p.Status != StatusOK && p.Status != StatusWarn {
c.Status, c.Details = StatusSkip, "prereq failed: "+p.Name
return true
}
}
return false
}
// runChecks executes the 4-item check matrix with cascade-skip semantics.
// Pure function over Services so tests can drive it directly.
func runChecks(ctx context.Context, opts *Options, svc Services, cliVer string) Result {
checks := []Check{
{Name: "base_url_reachable"},
{Name: "auth_credential"},
{Name: "server_version"},
{Name: "credential_storage"},
}
// 1. base_url_reachable - gated by offline only.
if !cascade(&checks[0], opts.Offline) {
t0 := time.Now()
if err := svc.PingBaseURL(ctx); err != nil {
checks[0].Status = StatusFail
checks[0].Hint = "verify the host configured for the active profile (run `weknora profile list` / `weknora profile add <n> --host=...`) and network reachability"
checks[0].Details = err.Error()
} else {
checks[0].Status = StatusOK
checks[0].Details = fmt.Sprintf("reachable in %s", time.Since(t0).Round(time.Millisecond))
}
}
// 2. auth_credential - needs base_url.
if !cascade(&checks[1], opts.Offline, &checks[0]) {
if _, err := svc.GetCurrentUser(ctx); err != nil {
checks[1].Status = StatusFail
checks[1].Hint = "run `weknora auth login`"
checks[1].Details = err.Error()
} else {
checks[1].Status = StatusOK
}
}
// 3. server_version - needs auth_credential.
if !cascade(&checks[2], opts.Offline, &checks[1]) {
info, fromCache, err := loadOrProbeServerInfo(ctx, opts, svc)
if err != nil {
checks[2].Status = StatusFail
checks[2].Details = err.Error()
} else {
fillVersionCheck(&checks[2], info, cliVer, fromCache)
}
}
// 4. credential_storage - independent of network; never gated by offline.
fillCredentialStorageCheck(&checks[3])
return Result{Summary: summarize(checks), Checks: checks}
}
// fillVersionCheck applies compat.Compat to (server, cli) version pair and
// sets Status/Details/Hint on c. fromCache toggles the "cached" suffix -
// the loader knows authoritatively which branch it took, time-based
// derivation from ProbedAt is unreliable since SaveCache uses time.Now().
//
// Mapping:
//
// compat.OK → StatusOK
// compat.SoftWarn → StatusWarn (server older but in-range; soft skew)
// compat.HardError → StatusFail (incompatible major; upgrade required)
func fillVersionCheck(c *Check, info *compat.Info, cliVer string, fromCache bool) {
level, hint := compat.Compat(info.ServerVersion, cliVer)
suffix := ""
if fromCache {
suffix = " (cached, pass --no-cache to refresh)"
}
switch level {
case compat.HardError:
c.Status = StatusFail
c.Hint = hint
c.Details = "server " + info.ServerVersion + suffix
case compat.SoftWarn:
c.Status = StatusWarn
// Lowercase, no trailing punctuation: matches existing details style
// ("reachable in N", "keyring or file storage available"). The hint
// from compat.Compat already says "server is older (server X, client Y)…"
c.Details = hint + suffix
c.Hint = "some new commands may degrade gracefully; upgrade server when convenient"
default:
c.Status = StatusOK
if hint != "" {
c.Details = hint + suffix
} else {
c.Details = fmt.Sprintf("server %s%s", info.ServerVersion, suffix)
}
}
}
// credStoreFactory is the seam tests use to inject a fake-store outcome -
// keyring success, file-store fallback, or hard failure - without touching
// the lazy-resolve buildServices contract (round-4 fix). Production stays
// at secrets.NewBestEffortStore.
var credStoreFactory = func() (secrets.Store, error) { return secrets.NewBestEffortStore() }
// SetCredStoreFactoryForTest overrides the credential-storage factory used by
// runChecks and returns a cleanup function that restores the previous value.
// Exported so out-of-package tests (notably cli/acceptance/contract) can
// pin the credential_storage outcome - otherwise the check probes the real
// OS keyring, which is present on macOS dev machines but not on Linux CI
// runners without libsecret, producing host-dependent test flakes.
//
// Usage:
//
// restore := doctor.SetCredStoreFactoryForTest(func() (secrets.Store, error) {
// return secrets.NewMemStore(), nil // not *FileStore → StatusOK
// })
// defer restore()
func SetCredStoreFactoryForTest(fn func() (secrets.Store, error)) (restore func()) {
saved := credStoreFactory
credStoreFactory = fn
return func() { credStoreFactory = saved }
}
// fillCredentialStorageCheck distinguishes the three terminal states the
// secrets layer can produce:
//
// keyring usable → StatusOK (preferred path)
// file fallback → StatusWarn (keyring unavailable, secrets still persist
// with 0600 file perms - agent containers / WSL hit this)
// construction fails → StatusFail
//
// Detection of "fallback to file store" relies on the type returned by
// secrets.NewBestEffortStore: a *FileStore concrete value means keyring was
// unavailable and the layer degraded. The Ref() URI scheme would also work
// but type-assertion is structurally more robust to scheme renames.
func fillCredentialStorageCheck(c *Check) {
store, err := credStoreFactory()
if err != nil {
c.Status = StatusFail
c.Details = err.Error()
c.Hint = "verify keyring access; falls back to file store"
return
}
if _, isFile := store.(*secrets.FileStore); isFile {
c.Status = StatusWarn
c.Details = "falling back to file store: keychain unavailable"
c.Hint = "secrets persist at 0600 under $XDG_CONFIG_HOME/weknora/secrets/; install / unlock keyring for OS-backed storage"
return
}
c.Status = StatusOK
c.Details = "keyring or file storage available"
}
// loadOrProbeServerInfo respects --no-cache: load fresh cache when allowed,
// else call compat.Probe (which wraps svc.GetSystemInfo) and persist. Cache
// write is best-effort. Returns fromCache so the caller can render the
// "cached" presentation hint without a brittle ProbedAt heuristic.
func loadOrProbeServerInfo(ctx context.Context, opts *Options, svc Services) (info *compat.Info, fromCache bool, err error) {
if !opts.NoCache {
if cached, fresh, _ := compat.LoadCache(); fresh && cached != nil {
return cached, true, nil
}
}
probed, err := compat.Probe(ctx, svc)
if err != nil {
return nil, false, err
}
_ = compat.SaveCache(probed)
return probed, false, nil
}
func summarize(cs []Check) Summary {
s := Summary{}
for _, c := range cs {
switch c.Status {
case StatusOK:
s.Passed++
case StatusWarn:
s.Warned++
case StatusFail:
s.Failed++
case StatusSkip:
s.Skipped++
}
}
s.AllPassed = s.Failed == 0 && s.Skipped == 0 && s.Warned == 0
return s
}
// emit renders the doctor result. The JSON path emits the Result directly;
// pass/fail signaling is conveyed by summary.failed (and the process exit
// code, set by the caller).
func emit(fopts *cmdutil.FormatOptions, r Result) {
if fopts.WantsJSON() {
_ = fopts.Emit(iostreams.IO.Out, r, nil)
return
}
for _, c := range r.Checks {
// %-2s for the glyph: most are 1 column, leaves room for one trailing
// space. Status word follows so screen-readers / non-glyph terminals
// still get the textual classification.
line := fmt.Sprintf("%-2s %-20s %s", marker(c.Status), c.Name, c.Status)
if c.Details != "" {
line += " (" + c.Details + ")"
}
fmt.Fprintln(iostreams.IO.Out, line)
if c.Hint != "" {
fmt.Fprintf(iostreams.IO.Out, " hint: %s\n", c.Hint)
}
}
fmt.Fprintf(iostreams.IO.Out, "\nsummary: %d passed, %d warned, %d failed, %d skipped\n",
r.Summary.Passed, r.Summary.Warned, r.Summary.Failed, r.Summary.Skipped)
}
// marker returns the text-mode prefix glyph for a check status.
//
// Agent / JSON consumers read the stable status string from
// data.checks[].status; the glyphs are presentation-only and never appear
// in --format json output.
func marker(s Status) string {
switch s {
case StatusFail:
return "✗"
case StatusWarn:
return "⚠"
case StatusSkip:
return "⊘"
default:
return "✓"
}
}
// buildServices wires the Factory closures into the doctor.Services interface.
// Reads the active profile's host so PingBaseURL targets the user's actual
// server, not localhost.
//
// Critically: this does NOT pre-resolve f.Client(). doctor's package promise
// (top comment) is that credential_storage runs even when no auth is set up -
// e.g. first-time `weknora doctor` to diagnose setup. Pre-resolving Client
// here would early-exit with auth.unauthenticated before any check runs,
// contradicting the docs. Instead, GetCurrentUser / GetSystemInfo lazily
// resolve and surface their own failure as a per-check StatusFail.
func buildServices(f *cmdutil.Factory) (Services, error) {
cfg, err := f.Config()
if err != nil {
return nil, err
}
return &realServices{f: f, host: resolveDoctorHost(cfg)}, nil
}
// resolveDoctorHost picks the host base_url_reachable probes. Tiers 2 and 3
// mirror the client builder (buildClientFromEnv) so doctor probes the host the
// real commands actually connect to; tier 1 is a doctor-local test/dev knob:
//
// 1. WEKNORA_BASE_URL — doctor-only probe override (used by tests); NOT read
// by the client builder, so setting it points doctor at a host no real
// command uses. Kept for test/dev harnesses; leave unset in normal use.
// 2. WEKNORA_HOST — when stateless env credentials (WEKNORA_TOKEN /
// WEKNORA_API_KEY) are in effect, i.e. the headless agent path. Without
// this, `WEKNORA_API_KEY=… WEKNORA_HOST=… weknora doctor` falsely reported
// "no host configured" and exited 1 while every other command worked.
// 3. active profile host — the configured default.
func resolveDoctorHost(cfg *config.Config) string {
host := ""
if ctx, ok := cfg.Profiles[cfg.CurrentProfile]; ok {
host = ctx.Host
}
// Env credentials authenticate via WEKNORA_HOST, bypassing the profile.
// Honor it only when such creds are actually set, matching the client
// builder (a bare WEKNORA_HOST without creds is ignored there too).
if envActive, _ := cmdutil.EnvCredential(); envActive {
if v := strings.TrimSpace(os.Getenv("WEKNORA_HOST")); v != "" {
host = v
}
}
if v := os.Getenv("WEKNORA_BASE_URL"); v != "" {
host = v
}
return host
}
type realServices struct {
f *cmdutil.Factory
host string
}
// pingTimeout caps the HEAD /health probe so a wedged TCP connection
// can't hang doctor indefinitely.
const pingTimeout = 5 * time.Second
func (s *realServices) PingBaseURL(ctx context.Context) error {
if s.host == "" {
return fmt.Errorf("no host configured for active profile")
}
url := s.host + "/health"
ctx, cancel := context.WithTimeout(ctx, pingTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 500 {
return fmt.Errorf("server returned %d", resp.StatusCode)
}
return nil
}
// GetCurrentUser lazily resolves the SDK client. When no profile is configured
// or credentials missing, f.Client() returns auth.unauthenticated; we surface
// that as the auth_credential check's failure rather than aborting doctor.
func (s *realServices) GetCurrentUser(ctx context.Context) (*sdk.CurrentUserResponse, error) {
cli, err := s.f.Client()
if err != nil {
return nil, err
}
return cli.GetCurrentUser(ctx)
}
// GetSystemInfo lazily resolves the SDK client (same rationale as GetCurrentUser).
// In the cascade ordering, auth_credential gates server_version, so this only
// runs when auth_credential succeeded - but the lazy resolution keeps doctor
// useful when only credential_storage is checkable (e.g., user not yet logged in).
func (s *realServices) GetSystemInfo(ctx context.Context) (*sdk.SystemInfo, error) {
cli, err := s.f.Client()
if err != nil {
return nil, err
}
return cli.GetSystemInfo(ctx)
}
+561
View File
@@ -0,0 +1,561 @@
package doctor
import (
"context"
"errors"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/compat"
"github.com/Tencent/WeKnora/cli/internal/config"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/secrets"
sdk "github.com/Tencent/WeKnora/client"
)
// withCredStoreFactory swaps the package-level credStoreFactory hook for the
// duration of t. Tests that assert the credential_storage outcome use this
// rather than touching the real OS keyring (which is environment-dependent
// and would make CI flake on macOS vs Linux vs WSL). Restored in t.Cleanup.
//
// The hook intentionally lives at package scope (rather than as a runChecks
// parameter) so the production call site stays a zero-arg function - keeping
// the lazy-resolve buildServices contract unchanged (round-4 fix).
func withCredStoreFactory(t *testing.T, fn func() (secrets.Store, error)) {
t.Helper()
saved := credStoreFactory
credStoreFactory = fn
t.Cleanup(func() { credStoreFactory = saved })
}
type fakeServices struct {
systemInfo *sdk.SystemInfo
systemErr error
userResp *sdk.CurrentUserResponse
userErr error
pingErr error
systemInfoHits atomic.Int32 // count GetSystemInfo invocations
}
func (f *fakeServices) GetSystemInfo(ctx context.Context) (*sdk.SystemInfo, error) {
f.systemInfoHits.Add(1)
return f.systemInfo, f.systemErr
}
func (f *fakeServices) GetCurrentUser(ctx context.Context) (*sdk.CurrentUserResponse, error) {
return f.userResp, f.userErr
}
func (f *fakeServices) PingBaseURL(ctx context.Context) error { return f.pingErr }
func goodUserResp() *sdk.CurrentUserResponse {
r := &sdk.CurrentUserResponse{}
r.Data.User = &sdk.AuthUser{ID: "u1"}
return r
}
func TestDoctor_AllOK(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
out, _ := iostreams.SetForTest(t)
// Pin credential_storage to ok so this test doesn't probe the host OS
// keyring - macOS dev machines have Keychain, Linux CI without libsecret
// would otherwise warn and break the AllPassed assertion.
withCredStoreFactory(t, func() (secrets.Store, error) {
return secrets.NewMemStore(), nil
})
svc := &fakeServices{
systemInfo: &sdk.SystemInfo{Version: "1.0.0"},
userResp: goodUserResp(),
}
r := runChecks(context.Background(), &Options{}, svc, "1.0.0")
if !r.Summary.AllPassed {
t.Errorf("expected all_passed, got summary %+v", r.Summary)
}
if r.Summary.Passed != 4 {
t.Errorf("expected Passed=4 (one per check), got %+v", r.Summary)
}
if r.Summary.Failed != 0 || r.Summary.Skipped != 0 {
t.Errorf("expected 0 fail / 0 skip, got %+v", r.Summary)
}
emit(&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, r)
if !strings.Contains(out.String(), `"all_passed":true`) {
t.Errorf("bare output should embed all_passed=true, got %q", out.String())
}
}
func TestDoctor_BaseURLFails_DownstreamSkip(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
_, _ = iostreams.SetForTest(t)
svc := &fakeServices{
pingErr: errors.New("connect refused"),
userResp: goodUserResp(),
systemInfo: &sdk.SystemInfo{Version: "1.0.0"},
}
r := runChecks(context.Background(), &Options{}, svc, "1.0.0")
if r.Summary.Skipped != 2 {
t.Errorf("expected 2 skipped (auth_credential + server_version), got %d", r.Summary.Skipped)
}
if r.Checks[0].Status != StatusFail {
t.Errorf("base_url_reachable status = %q, want fail", r.Checks[0].Status)
}
if !strings.Contains(r.Checks[0].Hint, "profile") {
t.Errorf("base_url fail hint should reference the active profile's host config, got %q", r.Checks[0].Hint)
}
if r.Checks[1].Status != StatusSkip {
t.Errorf("auth_credential status = %q, want skip", r.Checks[1].Status)
}
if r.Checks[2].Status != StatusSkip {
t.Errorf("server_version status = %q, want skip", r.Checks[2].Status)
}
// credential_storage is network-independent and runs regardless of base_url failures.
if r.Checks[3].Name != "credential_storage" {
t.Errorf("Checks[3] = %q, want credential_storage", r.Checks[3].Name)
}
}
func TestDoctor_Offline_OnlyKeyringChecked(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
_, _ = iostreams.SetForTest(t)
svc := &fakeServices{}
r := runChecks(context.Background(), &Options{Offline: true}, svc, "1.0.0")
if r.Summary.Skipped < 3 {
t.Errorf("expected >=3 skip in offline mode, got %d", r.Summary.Skipped)
}
last := r.Checks[3]
if last.Name != "credential_storage" {
t.Errorf("last check = %q, want credential_storage", last.Name)
}
if last.Status == StatusSkip {
t.Error("credential_storage should NOT be skipped even in offline mode")
}
}
func TestDoctor_AuthFails_VersionSkipped(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
_, _ = iostreams.SetForTest(t)
svc := &fakeServices{
userErr: errors.New("HTTP error 401: unauthenticated"),
systemInfo: &sdk.SystemInfo{Version: "1.0.0"},
}
r := runChecks(context.Background(), &Options{}, svc, "1.0.0")
if r.Checks[0].Status != StatusOK {
t.Errorf("base_url should pass, got %q", r.Checks[0].Status)
}
if r.Checks[1].Status != StatusFail {
t.Errorf("auth_credential should fail, got %q", r.Checks[1].Status)
}
if r.Checks[2].Status != StatusSkip {
t.Errorf("server_version should skip due to auth fail, got %q", r.Checks[2].Status)
}
}
func TestDoctor_CacheHit_SkipsProbe(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
_, _ = iostreams.SetForTest(t)
// Pre-populate fresh cache
if err := compat.SaveCache(&compat.Info{ServerVersion: "1.0.0", ProbedAt: time.Now()}); err != nil {
t.Fatalf("seed cache: %v", err)
}
svc := &fakeServices{userResp: goodUserResp()}
r := runChecks(context.Background(), &Options{}, svc, "1.0.0")
if r.Checks[2].Status != StatusOK {
t.Errorf("server_version should be ok via cache, got %q (%s)", r.Checks[2].Status, r.Checks[2].Details)
}
if svc.systemInfoHits.Load() != 0 {
t.Errorf("expected 0 GetSystemInfo calls (cache hit), got %d", svc.systemInfoHits.Load())
}
if !strings.Contains(r.Checks[2].Details, "cached") {
t.Errorf("details should mention cache, got %q", r.Checks[2].Details)
}
}
// TestDoctor_NoConfig_StillRunsCredentialStorage guards the package-doc
// promise that credential_storage runs even with zero configuration. Round-4
// reviewer surfaced that buildServices used to abort on f.Client() failure,
// silently violating the doc for any first-time user running `weknora doctor`
// to diagnose setup. The lazy-resolve fix means missing auth surfaces as
// auth_credential=fail, not a top-level command exit.
func TestDoctor_NoConfig_StillRunsCredentialStorage(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("XDG_CACHE_HOME", t.TempDir())
_, _ = iostreams.SetForTest(t)
// Pin credential_storage to ok regardless of host keyring presence
// (Linux CI without libsecret would otherwise warn).
withCredStoreFactory(t, func() (secrets.Store, error) {
return secrets.NewMemStore(), nil
})
f := cmdutil.New()
svc, err := buildServices(f)
if err != nil {
t.Fatalf("buildServices must succeed even with no config; got: %v", err)
}
r := runChecks(context.Background(), &Options{}, svc, "1.0.0")
// All 4 checks must run (no early-exit). Last one is credential_storage and
// has no network/auth dependency, so it must report ok.
if got := len(r.Checks); got != 4 {
t.Fatalf("expected 4 checks executed, got %d", got)
}
if r.Checks[3].Name != "credential_storage" {
t.Errorf("Checks[3] = %q, want credential_storage", r.Checks[3].Name)
}
if r.Checks[3].Status != StatusOK {
t.Errorf("credential_storage must run / pass even without auth, got %q (%s)",
r.Checks[3].Status, r.Checks[3].Details)
}
// base_url_reachable fails (no host); cascade then skips auth_credential
// and server_version. Whether auth/version are skip vs fail is an internal
// cascade detail; the user-visible promise is that 4 checks executed and
// the independent credential_storage one passed.
if r.Checks[0].Status != StatusFail {
t.Errorf("base_url must fail without host, got %q", r.Checks[0].Status)
}
if r.Checks[1].Status == StatusOK {
t.Errorf("auth_credential must NOT report ok without config, got %q", r.Checks[1].Status)
}
}
func TestDoctor_NoCache_BypassesCache(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
_, _ = iostreams.SetForTest(t)
// Pre-populate fresh cache; --no-cache should ignore it
if err := compat.SaveCache(&compat.Info{ServerVersion: "9.9.9", ProbedAt: time.Now()}); err != nil {
t.Fatalf("seed cache: %v", err)
}
svc := &fakeServices{
userResp: goodUserResp(),
systemInfo: &sdk.SystemInfo{Version: "1.0.0"},
}
r := runChecks(context.Background(), &Options{NoCache: true}, svc, "1.0.0")
if svc.systemInfoHits.Load() != 1 {
t.Errorf("expected 1 GetSystemInfo call (--no-cache), got %d", svc.systemInfoHits.Load())
}
if !strings.Contains(r.Checks[2].Details, "1.0.0") {
t.Errorf("details should reflect probed version 1.0.0 not cached 9.9.9, got %q", r.Checks[2].Details)
}
}
// TestDoctor_VersionSkewWarns covers the soft-skew path: server is older
// than CLI by ≥ 1 minor (same major, in compat range) → server_version=warn,
// summary.failed=0 (exit 0), summary.all_passed=false (so agents reading
// just the boolean still notice). The compat decision lives in
// cli/internal/compat; this test pins the doctor-side mapping
// (compat.SoftWarn → StatusWarn).
func TestDoctor_VersionSkewWarns(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
_, _ = iostreams.SetForTest(t)
// Force keyring path to ok so credential_storage doesn't itself warn -
// we want this test to assert ONLY on server_version.
withCredStoreFactory(t, func() (secrets.Store, error) { return secrets.NewMemStore(), nil })
svc := &fakeServices{
systemInfo: &sdk.SystemInfo{Version: "1.0.0"}, // server 1.0
userResp: goodUserResp(),
}
r := runChecks(context.Background(), &Options{}, svc, "1.5.0") // CLI 1.5
v := r.Checks[2]
if v.Name != "server_version" {
t.Fatalf("Checks[2] = %q, want server_version", v.Name)
}
if v.Status != StatusWarn {
t.Errorf("server_version status = %q, want warn (server older than CLI)", v.Status)
}
// compat.Compat hint contains "server is older" - that's the load-bearing
// substring agents may pattern-match. Not asserting full message text
// since compat.Compat owns the wording.
if !strings.Contains(v.Details, "older") {
t.Errorf("server_version details should mention older server, got %q", v.Details)
}
// Summary: warned counted, failed=0, all_passed flipped.
if r.Summary.Warned != 1 {
t.Errorf("expected Warned=1, got summary %+v", r.Summary)
}
if r.Summary.Failed != 0 {
t.Errorf("expected Failed=0, got summary %+v", r.Summary)
}
if r.Summary.AllPassed {
t.Error("AllPassed must be false when any check is warn")
}
// Wire shape: warn-only run has envelope wrapper with ok:true.
out, _ := iostreams.SetForTest(t)
emit(&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, r)
body := out.String()
if !strings.Contains(body, `"ok":true`) {
t.Errorf("doctor output must carry envelope ok:true, got %q", body)
}
if !strings.Contains(body, `"failed":0`) {
t.Errorf("bare output must carry failed:0 on warn-only run, got %q", body)
}
if !strings.Contains(body, `"status":"warn"`) {
t.Errorf("bare output must surface status=warn, got %q", body)
}
}
// TestDoctor_HardErrorStillFails guards that a different-major skew remains
// fail (not warn). Without this, a later refactor could silently downgrade
// HardError to warn and we'd lose exit-1 on incompatible servers.
func TestDoctor_HardErrorStillFails(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
_, _ = iostreams.SetForTest(t)
withCredStoreFactory(t, func() (secrets.Store, error) { return secrets.NewMemStore(), nil })
svc := &fakeServices{
systemInfo: &sdk.SystemInfo{Version: "2.0.0"},
userResp: goodUserResp(),
}
r := runChecks(context.Background(), &Options{}, svc, "1.0.0")
if r.Checks[2].Status != StatusFail {
t.Errorf("server_version status = %q, want fail (different major)", r.Checks[2].Status)
}
if r.Summary.Failed != 1 {
t.Errorf("expected Failed=1, got %+v", r.Summary)
}
}
// TestDoctor_KeychainFallbackWarns covers credential_storage's third
// state: keyring unavailable, fell back to FileStore (agent containers,
// headless CI, WSL without DBus). The check should warn - secrets still
// persist (0600 file perms) but the OS-backed path was unreachable.
func TestDoctor_KeychainFallbackWarns(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
_, _ = iostreams.SetForTest(t)
withCredStoreFactory(t, func() (secrets.Store, error) {
// FileStore concrete type → fillCredentialStorageCheck reads warn.
return secrets.NewFileStore()
})
svc := &fakeServices{
userResp: goodUserResp(),
systemInfo: &sdk.SystemInfo{Version: "1.0.0"},
}
r := runChecks(context.Background(), &Options{}, svc, "1.0.0")
cs := r.Checks[3]
if cs.Name != "credential_storage" {
t.Fatalf("Checks[3] = %q, want credential_storage", cs.Name)
}
if cs.Status != StatusWarn {
t.Errorf("credential_storage status = %q, want warn (file fallback)", cs.Status)
}
if !strings.Contains(strings.ToLower(cs.Details), "file store") {
t.Errorf("details should mention file store, got %q", cs.Details)
}
if r.Summary.Warned != 1 {
t.Errorf("expected Warned=1, got %+v", r.Summary)
}
if r.Summary.Failed != 0 {
t.Errorf("expected Failed=0 (warn doesn't fail), got %+v", r.Summary)
}
}
// TestDoctor_CredStoreFactoryError surfaces the constructor failure path as
// fail (not warn) - distinguishes "cannot persist credentials at all" from
// "downgraded to file store".
func TestDoctor_CredStoreFactoryError(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
_, _ = iostreams.SetForTest(t)
withCredStoreFactory(t, func() (secrets.Store, error) {
return nil, errors.New("disk full")
})
r := runChecks(context.Background(), &Options{Offline: true}, &fakeServices{}, "1.0.0")
if r.Checks[3].Status != StatusFail {
t.Errorf("credential_storage status = %q, want fail on constructor error", r.Checks[3].Status)
}
if !strings.Contains(r.Checks[3].Details, "disk full") {
t.Errorf("details should propagate underlying error, got %q", r.Checks[3].Details)
}
}
// TestDoctor_BareJSON_WarnDoesNotSignalFail pins the wire contract: warn
// keeps summary.failed=0 (so exit code stays 0). emit() is the seam between
// Result and what agents observe.
func TestDoctor_BareJSON_WarnDoesNotSignalFail(t *testing.T) {
out, _ := iostreams.SetForTest(t)
r := Result{
Summary: Summary{AllPassed: false, Passed: 3, Warned: 1},
Checks: []Check{
{Name: "base_url_reachable", Status: StatusOK},
{Name: "auth_credential", Status: StatusOK},
{Name: "server_version", Status: StatusWarn, Details: "server is older"},
{Name: "credential_storage", Status: StatusOK},
},
}
emit(&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, r)
got := out.String()
if !strings.Contains(got, `"failed":0`) {
t.Errorf("warn-only result must have summary.failed=0 (exit-0 signal), got %q", got)
}
// v0.7 envelope: ok:true is expected
if !strings.Contains(got, `"ok":true`) {
t.Errorf("output must carry envelope ok:true, got %q", got)
}
}
// TestDoctor_BareJSON_FailRaisesSummary pins the dual: any fail surfaces in
// summary.failed (caller maps that to exit 1 via SilentError).
func TestDoctor_BareJSON_FailRaisesSummary(t *testing.T) {
out, _ := iostreams.SetForTest(t)
r := Result{
Summary: Summary{AllPassed: false, Passed: 2, Failed: 1, Skipped: 1},
Checks: []Check{
{Name: "base_url_reachable", Status: StatusFail, Details: "refused"},
{Name: "auth_credential", Status: StatusSkip},
{Name: "server_version", Status: StatusOK},
{Name: "credential_storage", Status: StatusOK},
},
}
emit(&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, r)
got := out.String()
if !strings.Contains(got, `"failed":1`) {
t.Errorf("fail must surface in summary.failed, got %q", got)
}
}
// TestDoctor_TextMarker_Warn confirms the human-mode glyph appears for warn
// rows. Glyph choice is presentation-only; we pin via substring (no width
// alignment assertion since terminal-width handling is environmental).
func TestDoctor_TextMarker_Warn(t *testing.T) {
out, _ := iostreams.SetForTest(t)
r := Result{
Summary: Summary{Warned: 1},
Checks: []Check{
{Name: "server_version", Status: StatusWarn, Details: "older"},
},
}
emit(&cmdutil.FormatOptions{Mode: cmdutil.FormatText}, r)
got := out.String()
if !strings.Contains(got, "⚠") {
t.Errorf("text output should contain ⚠ glyph for warn, got %q", got)
}
if !strings.Contains(got, "warn") {
t.Errorf("text output should still contain status word `warn`, got %q", got)
}
}
// TestDoctor_WarnedField_OmittedAtZero protects the JSON wire compactness:
// `warned` carries omitempty, so a clean run has no warned key. Existing
// agents inspecting older outputs shouldn't see a sudden new field unless
// it actually fired.
func TestDoctor_WarnedField_OmittedAtZero(t *testing.T) {
out, _ := iostreams.SetForTest(t)
r := Result{
Summary: Summary{AllPassed: true, Passed: 4},
Checks: []Check{
{Name: "base_url_reachable", Status: StatusOK},
{Name: "auth_credential", Status: StatusOK},
{Name: "server_version", Status: StatusOK},
{Name: "credential_storage", Status: StatusOK},
},
}
emit(&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, r)
got := out.String()
if strings.Contains(got, `"warned"`) {
t.Errorf("output should omit `warned` field when zero, got %q", got)
}
}
// TestDoctor_RunE_FailReturnsSilentError is a behavior test on NewCmd:
// when any check is fail, RunE must return cmdutil.SilentError so the
// framework exit-1 path runs without writing a second error line on top
// of the data object emit() already wrote.
//
// SilenceErrors/SilenceUsage on the leaf cobra.Command suppress cobra's own
// "Error: ..." + usage dump that would otherwise leak to stderr when running
// the leaf in isolation (root.go sets these on the root, not on every leaf).
func TestDoctor_RunE_FailReturnsSilentError(t *testing.T) {
// Force base_url to fail by leaving host empty.
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("XDG_CACHE_HOME", t.TempDir())
_, _ = iostreams.SetForTest(t)
withCredStoreFactory(t, func() (secrets.Store, error) { return secrets.NewMemStore(), nil })
f := cmdutil.New()
cmd := NewCmd(f)
cmd.SilenceErrors = true
cmd.SilenceUsage = true
// --format is persistent at root in v0.7; register locally for the
// isolated leaf-execution test path.
cmd.PersistentFlags().String("format", "", "")
cmd.PersistentFlags().String("jq", "", "")
cmd.SetArgs([]string{"--format", "json"})
cmd.SetContext(context.Background())
err := cmd.Execute()
if !errors.Is(err, cmdutil.SilentError) {
t.Errorf("RunE on fail must return SilentError, got %v", err)
}
}
// TestDoctor_RunE_WarnReturnsNil - soft skew path stays exit-0. Pairs with
// TestDoctor_RunE_FailReturnsSilentError: warn does NOT trigger SilentError.
func TestDoctor_RunE_WarnReturnsNil(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
_, _ = iostreams.SetForTest(t)
withCredStoreFactory(t, func() (secrets.Store, error) { return secrets.NewFileStore() })
r := runChecks(context.Background(), &Options{Offline: true}, &fakeServices{}, "1.0.0")
// Smoke-check: warn-only result, no fails.
if r.Summary.Failed != 0 {
t.Fatalf("setup error: expected Failed=0, got %+v", r.Summary)
}
if r.Summary.Warned == 0 {
t.Fatalf("setup error: expected Warned>=1, got %+v", r.Summary)
}
}
// TestResolveDoctorHost_EnvCredentials - doctor's base_url probe must honor
// WEKNORA_HOST when stateless env credentials are in effect (the headless
// agent path), mirroring buildClientFromEnv. Regression: doctor previously
// read only the active profile host, so `WEKNORA_API_KEY=... WEKNORA_HOST=...
// weknora doctor` reported "no host configured" and exited 1 while every
// other command worked.
func TestResolveDoctorHost_EnvCredentials(t *testing.T) {
cfg := &config.Config{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{"prod": {Host: "https://profile-host"}},
}
t.Run("env creds + WEKNORA_HOST wins over profile", func(t *testing.T) {
t.Setenv("WEKNORA_TOKEN", "")
t.Setenv("WEKNORA_API_KEY", "sk-test")
t.Setenv("WEKNORA_HOST", "https://env-host:8080")
t.Setenv("WEKNORA_BASE_URL", "")
if got := resolveDoctorHost(cfg); got != "https://env-host:8080" {
t.Errorf("want env host, got %q", got)
}
})
t.Run("env creds without WEKNORA_HOST falls back to profile", func(t *testing.T) {
t.Setenv("WEKNORA_API_KEY", "sk-test")
t.Setenv("WEKNORA_HOST", "")
t.Setenv("WEKNORA_BASE_URL", "")
if got := resolveDoctorHost(cfg); got != "https://profile-host" {
t.Errorf("want profile host, got %q", got)
}
})
t.Run("no env creds ignores WEKNORA_HOST (matches client builder)", func(t *testing.T) {
t.Setenv("WEKNORA_TOKEN", "")
t.Setenv("WEKNORA_API_KEY", "")
t.Setenv("WEKNORA_HOST", "https://should-be-ignored")
t.Setenv("WEKNORA_BASE_URL", "")
if got := resolveDoctorHost(cfg); got != "https://profile-host" {
t.Errorf("want profile host, got %q", got)
}
})
t.Run("WEKNORA_BASE_URL test override always wins", func(t *testing.T) {
t.Setenv("WEKNORA_API_KEY", "sk-test")
t.Setenv("WEKNORA_HOST", "https://env-host")
t.Setenv("WEKNORA_BASE_URL", "https://base-url-override")
if got := resolveDoctorHost(cfg); got != "https://base-url-override" {
t.Errorf("want base-url override, got %q", got)
}
})
}
+116
View File
@@ -0,0 +1,116 @@
package cmd
import (
"sort"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
)
// dryRunExpectation declares, for every leaf command, whether it must register
// --dry-run. The rule (per internal/cmdutil/dryrun.go): commands that change
// server/local state get a preview path. Reads, generate/stream ops, the
// interactive credential flow, and the long-running MCP server are exempt.
//
// This is a drift guard (sibling of TestEveryLeafCommandHasAgentHelp): every
// leaf must appear here, so a newly-added command forces an explicit dry-run
// decision rather than silently inheriting "no preview".
var dryRunExpectation = map[string]bool{
// --- mutations: MUST have --dry-run ---
"kb create": true, "kb update": true, "kb delete": true, "kb pin": true, "kb unpin": true,
"kb config set": true, // binds models to a KB (state change)
"model create": true, "model update": true, "model delete": true,
"doc create": true, "doc upload": true, "doc fetch": true, "doc delete": true,
"doc reparse": true, // re-triggers server-side parsing (a state change)
"doc update": true, // edits title/description server-side
"chunk delete": true, "message delete": true,
"session delete": true, "session stop": true, "session tool-approval resolve": true,
"agent create": true, "agent update": true, "agent delete": true,
"profile add": true, "profile use": true, "profile remove": true,
"skills install": true, // writes skill files to a local dir (state change)
"auth logout": true, "auth refresh": true,
"link": true, "unlink": true,
"api": true, // passthrough: dry-run previews write methods, rejected on GET
// --- exempt: no state change to preview ---
// reads
"kb list": false, "kb view": false, "kb status": false, "kb check": false,
"kb config": false, // read-only inspection of a KB's model config
"doc list": false, "doc view": false, "doc download": false,
"doc wait": false, // polling read, no mutation
"chunk list": false, "chunk view": false,
"message list": false, "message search": false,
"session list": false, "session view": false,
"agent list": false, "agent view": false, "agent status": false, "agent check": false,
"model list": false, "model view": false,
"search chunks": false, "search docs": false, "search kb": false, "search sessions": false,
"auth list": false, "auth status": false, "auth token": false,
"profile list": false,
"skills list": false, // read-only: lists the embedded skills
// read-only inspection of the resolved config; no mutation, no network.
"config view": false,
"doctor": false, "version": false,
// generate / stream ops — the session-creation side effect is incidental,
// not a CRUD write; a no-SDK-call preview would be meaningless.
"chat": false, "session ask": false, "session resume": false,
// auth login VALIDATES credentials against the server and stores them; its
// whole purpose is the server round-trip, which a side-effect-free dry-run
// cannot exercise — so previewing it would be misleading. Exempt by design.
"auth login": false,
// long-running stdio server, not a one-shot command.
"mcp serve": false,
// offline help topic: prints the static exit-code matrix.
"exit-codes": false,
// offline introspection: prints command contracts from the in-binary tree.
"schema": false,
}
// TestIdAddressedCommandsTolerateKB pins that the id-addressed read/wait
// commands accept a (redundant, ignored) --kb flag, so an agent flowing from
// `doc upload --kb X` into `doc wait <id> --kb X` doesn't hit exit 2.
func TestIdAddressedCommandsTolerateKB(t *testing.T) {
root := NewRootCmd(cmdutil.New())
for _, path := range [][]string{
{"doc", "view"}, {"doc", "wait"}, {"doc", "download"},
{"doc", "reparse"}, {"doc", "update"},
{"chunk", "list"}, {"chunk", "view"}, {"chunk", "delete"},
} {
c, _, err := root.Find(path)
if err != nil {
t.Fatalf("find %v: %v", path, err)
}
if c.Flags().Lookup("kb") == nil {
t.Errorf("`%s` must accept a --kb flag (ignored) so a carried-over --kb doesn't error", strings.Join(path, " "))
}
}
}
func TestDryRunCoverageMatchesExpectation(t *testing.T) {
root := NewRootCmd(cmdutil.New())
var unlisted, wrong []string
eachLeafCommand(root, func(c *cobra.Command) {
key := strings.TrimPrefix(c.CommandPath(), "weknora ")
want, declared := dryRunExpectation[key]
if !declared {
unlisted = append(unlisted, key)
return
}
has := c.Flags().Lookup("dry-run") != nil
if has != want {
wrong = append(wrong, key)
}
})
sort.Strings(unlisted)
sort.Strings(wrong)
if len(unlisted) > 0 {
t.Errorf("leaf commands missing from dryRunExpectation (declare must-have-dry-run true/false):\n %v", unlisted)
}
if len(wrong) > 0 {
t.Errorf("dry-run flag presence disagrees with dryRunExpectation:\n %v", wrong)
}
}

Some files were not shown because too many files have changed in this diff Show More