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
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:
@@ -0,0 +1,4 @@
|
||||
bin/
|
||||
coverage.out
|
||||
ROADMAP.md
|
||||
ROADMAP.html
|
||||
+622
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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
@@ -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).
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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"}]}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -0,0 +1 @@
|
||||
{"ok":true,"data":{"current_profile":"production","previous_profile":"staging"},"profile":"staging"}
|
||||
@@ -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}}
|
||||
@@ -0,0 +1 @@
|
||||
{"ok":true,"data":{"commit":"none","date":"unknown","version":"dev"}}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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`)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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())])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)"
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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'")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user