refactor(cli): delete envelope infrastructure, errors to stderr

Removes the entire envelope machinery now that every success path
emits bare JSON:

- cli/internal/format/envelope.go (Envelope, Success, Failure,
  SuccessWithRisk, WriteEnvelope, Meta, Notice, UpdateNotice,
  VersionSkewNotice, Risk, RiskLevel, ErrorBody) + tests.
- cli/internal/format/filter.go envelope-specific helpers
  (WriteEnvelopeFiltered, marshalEnvelope, applyFieldFilter,
  filterDataPayload, filterObjectData); the reusable
  filterArrayItems / filterObjectKeys / writeJQ stay for bare.go.
- cli/internal/cmdutil/exporter.go + tests (envelope-only).
- cli/internal/cmdutil/PrintErrorEnvelope + ToErrorBody +
  operationRiskOf + Error.OperationRisk field + OperationRisk struct.

Error path: all errors now go to stderr via cmdutil.PrintError in
`code: message\nhint: ...` form, regardless of --json. Stdout stays
empty (or holds the partial-success the command already wrote) so
downstream `--json | jq` pipelines never have to filter error shapes
out of the success stream. Typed exit codes (3 auth.* / 4
resource.not_found / 5 input.* / 6 server.rate_limited / 7 server.*
+ network.* / 10 input.confirmation_required) carry the failure
class for agents that branch on it.

Acceptance contract:
- envelope_test.go → wire_test.go (TestEnvelopeGolden → TestWireGolden).
- testdata/envelopes/ → testdata/wire/.
- Error-path cases assert the typed code substring on stderr.
- Orphan whoami.*.json goldens deleted.

AGENTS.md + README.md rewritten for the bare-data contract:
- Drop envelope schema section + dry-run rule.
- Document bare JSON on stdout + `code: msg\nhint: …` on stderr.
- ADR-3 reframed around bare data and why error separation matters
  for `--json | jq` pipelines.

WriteJSONFiltered short-circuits to WriteJSON when both filters are
empty (skip the marshal-buffer round-trip for the common case).

Final review pass:
- Fix wire-contract bug: `--json id,name` (space form) is broken by
  pflag's NoOptDefVal; AGENTS.md / README.md / SetAgentHelp + the
  field-discovery help text all switched to `--json=id,name`.
- Fix `weknora api --jq` silently ignored: api.go now routes through
  WriteJSONFiltered with jopts.JQ.
- AGENTS.md: drop the false claim that `auth logout` honors `-y`
  (logout is local-only with no ConfirmDestructive guard); list the
  actual destructive commands instead.
- Rewrite cli/acceptance/e2e/e2e_test.go for the bare-data wire shape
  (was still parsing `out["data"]` / `env["ok"]`).
- Add `JSONOptions.Emit(w, v)` helper; collapse ~33 repeated
  `format.WriteJSONFiltered(iostreams.IO.Out, X, jopts.Fields,
  jopts.JQ)` sites to `jopts.Emit(iostreams.IO.Out, X)` — drops the
  format import from 22 cmd/* files.
- Delete single-caller `cmdutil.MustRequireFlag`; inline as
  `_ = cmd.MarkFlagRequired(...)` everywhere.
- Add `_ = cmd.MarkFlagRequired("name")` to `kb create`; it was the
  only write command relying on runtime --name validation while
  `context add` already used the cobra-level mark.
- `context use`: register `--json` / `--jq` (was always emitting JSON
  unconditionally with no human path and no flag — diverged from
  every other write command); human mode now prints
  `✓ Switched context to X (was Y)`.
- Replace per-package `confirmPrompter` / `scriptedConfirm` /
  `errPrompter` test doubles with `testutil.ConfirmPrompter`.
- Rename `chatService` → `ChatService` (export to match siblings
  `ListService` / `ViewService`); rename `printUploadSuccess` →
  `renderUploadSuccess` (siblings use `render*`).
- `defaultHint(CodeResourceNotFound)`: drop the hardcoded
  "list available with `weknora kb list`" — misleading on agent /
  doc / session 404. Replaced with "verify the resource ID and try
  again".
- Strip stale `v0.2/v0.3` / "envelope" / "v0.0/v0.1 supports only"
  historical tags from production comments and a few test
  descriptions.
This commit is contained in:
nullkey
2026-05-15 01:51:11 +08:00
committed by lyingbug
parent cc8254f862
commit e623e8208f
94 changed files with 599 additions and 1421 deletions

View File

@@ -11,9 +11,9 @@
> >
> **Naming note.** "Agent" appears in two distinct WeKnora contexts: > **Naming note.** "Agent" appears in two distinct WeKnora contexts:
> >
> - **This file (`AGENTS.md`) + the `agent` annotation on each command's > - **This file (`AGENTS.md`) + the `agent_help` annotation on each
> `--help`**: documents the contract for AI coding agents (you, the > command's `--help`**: documents the contract for AI coding agents
> LLM-driven CLI consumer). > (you, the LLM-driven CLI consumer).
> - **The `weknora agent` subtree** (`agent list / view / invoke`): > - **The `weknora agent` subtree** (`agent list / view / invoke`):
> manages WeKnora's first-class *Custom Agent* resources — server-side > manages WeKnora's first-class *Custom Agent* resources — server-side
> records (system prompt + model + allowed tools + KB scope) that the > records (system prompt + model + allowed tools + KB scope) that the
@@ -22,11 +22,11 @@
> coding agent, drive WeKnora — that's `kb` / `doc` / `search` / `chat` > coding agent, drive WeKnora — that's `kb` / `doc` / `search` / `chat`
> / `mcp serve`. > / `mcp serve`.
`weknora` is designed to be agent-friendly: error messages, output format, `weknora` is designed to be agent-friendly: error messages, output
and flag design follow conventions agents can rely on. Wire-contract format, and flag design follow conventions agents can rely on.
breaking changes are flagged in their PR description and the corresponding Wire-contract breaking changes are flagged in their PR description and
`weknora --version` bump — agents should pin a known-good version and the corresponding `weknora --version` bump — agents should pin a
re-validate against `--help` output on upgrade. known-good version and re-validate against `--help` output on upgrade.
The "Output contract" and "Behavioral rules" sections below are the The "Output contract" and "Behavioral rules" sections below are the
self-contained specification of the wire format; everything an integrator self-contained specification of the wire format; everything an integrator
@@ -38,52 +38,102 @@ needs is in this document.
### Streams ### Streams
- **stdout** is the data channel: JSON envelope (with `--json`) or - **stdout** is the data channel: bare JSON (with `--json`) or
human-formatted output. human-formatted output (without `--json`). Never carries error text.
- **stderr** is logs / progress / warnings / agent guidance footnotes. - **stderr** is logs / progress / warnings / errors / agent guidance
Never parse stderr for data. footnotes. On failure, the error message + actionable hint go here.
A non-empty stderr does **not** mean failure — read the exit code instead. A non-empty stderr does **not** mean failure — read the exit code instead.
### JSON envelope ### JSON output (bare data)
When `--json` is set, stdout contains exactly one envelope: When `--json` is set on a successful command, stdout contains exactly
one JSON value matching the resource the command produces:
```jsonc | Command shape | stdout JSON |
{ |---|---|
"ok": true, // false on failure; check this first | `list` / `search` | `[ { …resource… }, … ]` (bare array; `[]` when empty) |
"data": { /* command-specific payload */ }, | `view` / `create` / `edit` | `{ …resource… }` (bare object) |
"error": { "code": "...", "message": "...", "hint": "..." }, // iff ok=false | `delete` / `pin` / write ops | `{ id, …action-result fields… }` (bare object) |
"_meta": { "request_id": "...", "kb_id": "..." }, // optional | `doctor` | `{ summary: { all_passed, passed, warned, failed, skipped }, checks: [ … ] }` |
"risk": { "level": "high-risk-write", "action": "..." }, // write commands
"dry_run": false // true on --dry-run There is no `ok` / `data` / `error` wrapper. Agents read the resource
} shape directly. Successful runs always return exit 0; failures never
emit data JSON on stdout.
#### Field selection: `--json=field,field,…`
`--json` accepts a comma-separated field list (passed with `=`) to restrict
each top-level object (or each element of a top-level array) to the named
keys:
```bash
weknora kb list --json=id,name # [{ "id": "kb_x", "name": "Eng" }, …]
weknora kb view kb_x --json=id # { "id": "kb_x" }
``` ```
This snippet is illustrative. Fields are added (never renamed or repurposed) Note the `=` form: pflag's optional-value parser treats space-separated
within a minor version, and agents must not error on unknown keys. The arguments after a bare `--json` as positionals, so `--json id,name` would
authoritative envelope shape lives in `cli/internal/format/envelope.go`. be interpreted as bare `--json` plus the positional `id,name`. Always use
`--json=field,...` for projection.
### Error codes (closed registry) Unknown field names are silently dropped so you can pass an aspirational
field set across heterogenous outputs.
`error.code` is a `namespace.snake_case` string from a closed registry in #### jq pipeline: `--jq <expr>`
`cli/internal/cmdutil/errors.go` `AllCodes()`. An acceptance test enforces
that every code referenced in `cli/cmd/` is registered.
Categories: `auth.*` / `resource.*` / `input.*` / `server.*` / `network.*` / `--jq` applies a jq expression to the JSON before printing. The
`local.*` / `mcp.*`. expression sees the same bare shape the command produces (no envelope
indirection). String results render without quotes for shell-friendly
substitution; non-string results render as JSON.
`error.hint` provides a deterministic next-step hint agents can follow ```bash
without natural-language parsing. weknora kb list --jq '.[].id' # one id per line
weknora kb view kb_x --jq .name # bare name
weknora search chunks "x" --kb e --jq '.[].score' # scores per line
```
`--jq` requires `--json`. Combining with `--json=id,name` is fine — the
filter runs after the field projection.
### Errors (stderr, exit code carries the class)
On failure, stdout is empty (or holds the partial-success output the
command already wrote before the failure). The error message goes to
stderr in this format:
```
<error.code>: <message>[: <wrapped cause>]
hint: <actionable next-step>
```
- `<error.code>` is a `namespace.snake_case` string from a closed
registry in `cli/internal/cmdutil/errors.go` (`AllCodes()`).
An acceptance test enforces that every code referenced in `cli/cmd/`
is registered.
- `<message>` is the human-readable description.
- `<hint>` is the deterministic next-step hint (omitted when no hint
applies).
Code namespaces: `auth.*` / `resource.*` / `input.*` / `server.*` /
`network.*` / `local.*` / `mcp.*`.
To pattern-match programmatically: split on the first `:` to extract
the code, or branch on the exit code (see below).
### Exit codes ### Exit codes
| Code | Meaning | Agent action | | Code | Meaning | Agent action |
|---|---|---| |---|---|---|
| `0` | Success | Continue | | `0` | Success | Continue |
| `1` | Typed error (see envelope.error.code) | Read code, decide retry/abort | | `1` | Typed `local.*` error or unclassified | Read stderr, decide retry/abort |
| `2` | Flag/argument validation error | Re-check `weknora <command> --help` | | `2` | Flag/argument validation error | Re-check `weknora <command> --help` |
| `10` | **Confirmation required** for high-risk write | Ask the human, retry with `-y` only after explicit approval | | `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 and retry |
| `6` | `server.rate_limited` | Back off, then retry |
| `7` | `server.*` / `network.*` | Transient; retry with backoff |
| `10` | **`input.confirmation_required`** — high-risk write needs `-y` | Ask the human, retry with `-y` only after explicit approval |
| `130` | Cancelled (SIGINT / Ctrl-C) | Stop, do not retry | | `130` | Cancelled (SIGINT / Ctrl-C) | Stop, do not retry |
Exit 10 is the wire-level signal for "high-risk write needs explicit Exit 10 is the wire-level signal for "high-risk write needs explicit
@@ -123,11 +173,11 @@ The command tree follows `<noun> <verb>`. Verbs are:
bundle host + tenant + credentials, so they need a richer abstraction bundle host + tenant + credentials, so they need a richer abstraction
than a single per-host token slot). `auth refresh` exchanges the stored than a single per-host token slot). `auth refresh` exchanges the stored
refresh token for a new access + refresh pair (OAuth refresh-token refresh token for a new access + refresh pair (OAuth refresh-token
grant); it grant); it errors with `input.invalid_argument` on API-key contexts
errors with `input.invalid_argument` on API-key contexts which have no which have no refresh semantic. Transparent 401 → refresh → retry is
refresh semantic. Transparent 401 → refresh → retry is wired into the wired into the SDK transport (`cli/internal/cmdutil/authretry.go`)
SDK transport (`cli/internal/cmdutil/authretry.go`) with singleflight with singleflight de-dup, so most callers never need to invoke `auth
de-dup, so most callers never need to invoke `auth refresh` explicitly. refresh` explicitly.
`search` subtree: `search chunks "<q>" --kb X` for hybrid retrieval; `search` subtree: `search chunks "<q>" --kb X` for hybrid retrieval;
`search kb "<q>"` / `search docs "<q>" --kb X` / `search sessions "<q>"` `search kb "<q>"` / `search docs "<q>" --kb X` / `search sessions "<q>"`
@@ -141,29 +191,31 @@ Top-level RAG / connectivity verbs: `chat`, `search`, `api`, `link`,
`doctor` is a deliberate WeKnora addition: RAG deployments routinely `doctor` is a deliberate WeKnora addition: RAG deployments routinely
break on misconfigured embeddings, storage backends, and credentials, break on misconfigured embeddings, storage backends, and credentials,
and a structured 4-status envelope (ok/warn/fail/skip) is the cleanest and the structured `{summary: {all_passed, passed, warned, failed,
agent-readable surface for that. skipped}, checks: [...]}` JSON shape is the cleanest agent-readable
surface for that.
--- ---
## Behavioral rules ## Behavioral rules
Per-command guidance also appears in each command's `--help` output Per-command guidance also appears in each command's `--help` output
(under "AI Agent guidance:"). (under "AI Agents:").
1. **Pass `-y/--yes`** on `kb delete` / `doc delete` / `auth logout` when 1. **Pass `-y/--yes`** on destructive writes (`kb delete` / `kb empty` /
running headless. Without it, you will get exit 10. **Never auto-add `doc delete` / `session delete` / `context remove` when targeting
`-y`** without the user's explicit go-ahead — the exit-10 protocol is the current context) when running headless. Without it, you will
the one explicit guard against unintended writes. get exit 10. **Never auto-add `-y`** without the user's explicit
go-ahead — the exit-10 protocol is the one explicit guard against
unintended writes.
2. **Prefer typed commands over `weknora api`** for known endpoints. 2. **Prefer typed commands over `weknora api`** for known endpoints.
Fallback to `weknora api` only when no typed command covers the call. Fallback to `weknora api` only when no typed command covers the
3. **For chat, prefer `--no-stream --json`** in agent contexts. Streaming call.
tokens to stdout makes JSON envelope parsing impossible. 3. **For chat, prefer `--no-stream --json`** in agent contexts.
4. **Honor `--dry-run`** — when the user passes it, don't follow up with Streaming tokens to stdout makes JSON parsing impossible.
the real command unless explicitly asked. The dry-run envelope is the 4. **`link` writes to the user's working directory** — only run it
answer. when the user invoked it, not as a side effect of unrelated
5. **`link` writes to the user's working directory** — only run it when automation.
the user invoked it, not as a side effect of unrelated automation.
(Additional safety guidance — e.g. "do not switch context unless the (Additional safety guidance — e.g. "do not switch context unless the
user asked" — is documented in the affected command's own `--help`.) user asked" — is documented in the affected command's own `--help`.)
@@ -185,11 +237,9 @@ annotation. **No behavior change** — this is help-text rendering only.
To suppress detection (e.g. running `weknora` interactively from inside To suppress detection (e.g. running `weknora` interactively from inside
Claude Code without the agent footer): `WEKNORA_NO_AGENT_AUTODETECT=1`. Claude Code without the agent footer): `WEKNORA_NO_AGENT_AUTODETECT=1`.
The omnibus `--agent` mode-switch flag that briefly existed in early Agent detection (`CLAUDECODE` / `CURSOR_AGENT` env) also tags the
v0.2 was removed in favor of per-command `--json` + TTY auto-detect, User-Agent header for server-side telemetry — it never changes CLI
which covers the same ground without an extra global switch. Agent behavior.
detection (`CLAUDECODE` / `CURSOR_AGENT` env) only tags the User-Agent
header for server-side telemetry — it never changes CLI behavior.
--- ---
@@ -198,11 +248,17 @@ header for server-side telemetry — it never changes CLI behavior.
A handful of decisions are referenced inline in the source as `ADR-N`. They A handful of decisions are referenced inline in the source as `ADR-N`. They
live here, alongside the contract they shape. live here, alongside the contract they shape.
**ADR-3 — opinionated noun-verb tree with stable JSON envelope.** The **ADR-3 — bare-data JSON on stdout, errors on stderr.** Successful
v0.0/v0.1 surface was audited against several mainstream CLIs; the commands emit the raw resource shape (`[]Item` for lists, `T` for views,
"opinionated noun-verb tool with stable JSON envelope + agent-aware `{id, deleted: true}` for deletes) — no `ok` / `data` / `error`
error model" shape was the closest fit for the agent-friendly contract wrapper. Errors are not data; they go to stderr in `code: message\nhint:
this document promises. WeKnora-specific shape choices: …` form, and the typed exit code carries the failure class for
programmatic branching. This separates "what the command produces" from
"how the run went", so `--json | jq` pipelines never have to filter
error shapes out of the success stream, and matches the contract of
gh / aws / stripe.
WeKnora-specific shape choices:
- `link` (project-binding) — `<cwd>/.weknora/project.yaml` walk-up - `link` (project-binding) — `<cwd>/.weknora/project.yaml` walk-up
matches how RAG users scope work to a specific knowledge base. There matches how RAG users scope work to a specific knowledge base. There
@@ -213,10 +269,12 @@ this document promises. WeKnora-specific shape choices:
- `context use` switches the active credential set; contexts bundle - `context use` switches the active credential set; contexts bundle
host + tenant + credential, so a richer abstraction than a single host + tenant + credential, so a richer abstraction than a single
per-host token slot is required. per-host token slot is required.
- `doctor` (4-status: ok / warn / fail / skip) is the agent-readable - `doctor` (4-status: ok / warn / fail / skip per check, plus a
surface for RAG-deployment misconfiguration (embeddings, storage, summary object) is the agent-readable surface for RAG-deployment
credentials) — failure modes that the underlying SDK can't classify misconfiguration (embeddings, storage, credentials) — failure modes
on its own. that the underlying SDK can't classify on its own. Agents short-
circuit on `summary.all_passed`; exit code is non-zero iff any
check is `fail`.
Verb canon: `list / view / create / edit / delete / upload / download Verb canon: `list / view / create / edit / delete / upload / download
/ pin / unpin / use`. WeKnora-specific verbs for resource semantics / pin / unpin / use`. WeKnora-specific verbs for resource semantics
@@ -238,19 +296,20 @@ SDK, and the dependency graph of any one command is visible in one file.
## Known limitations ## Known limitations
The following classes of failure currently surface as `error.code = "network.error"` The following classes of failure currently surface as `error.code =
with `context deadline exceeded` rather than a precise typed code. A future "network.error"` with `context deadline exceeded` rather than a precise
release will introduce a `precondition.*` namespace (server returns HTTP 412 typed code. A future release will introduce a `precondition.*`
with a typed remediation body before opening the SSE / streaming response): namespace (server returns HTTP 412 with a typed remediation body before
opening the SSE / streaming response):
- `weknora chat` when no chat model is configured for the active tenant - `weknora chat` when no chat model is configured for the active tenant
- `weknora search chunks` when no retriever / vector store is configured - `weknora search chunks` when no retriever / vector store is configured
- `weknora doc upload` when no storage engine is selected for the KB - `weknora doc upload` when no storage engine is selected for the KB
Workaround until then: if a chat / search / upload call times out without Workaround until then: if a chat / search / upload call times out
producing a first-byte response, check the server's tenant configuration without producing a first-byte response, check the server's tenant
(LLM / vector store / storage engine) before retrying. A planned configuration (LLM / vector store / storage engine) before retrying. A
`weknora doctor --server-config` will probe these directly. planned `weknora doctor --server-config` will probe these directly.
--- ---
@@ -261,4 +320,5 @@ https://github.com/Tencent/WeKnora/issues with:
- The exact command line - The exact command line
- `weknora --version` output - `weknora --version` output
- The envelope you got vs the envelope this document promises - The output (stdout + stderr) you got vs the output this document
promises

View File

@@ -25,8 +25,9 @@ Available Commands:
The command surface mirrors `gh` CLI's `<noun> <verb>` convention. See The command surface mirrors `gh` CLI's `<noun> <verb>` convention. See
[AGENTS.md](AGENTS.md) for the operational contract that AI agents [AGENTS.md](AGENTS.md) for the operational contract that AI agents
(Claude Code, Cursor, Aider, …) can rely on: envelope schema, exit-code (Claude Code, Cursor, Aider, …) can rely on: bare-JSON output shape,
protocol, error-code registry, and per-command guidance. stderr error format, exit-code protocol, error-code registry, and
per-command guidance.
--- ---
@@ -105,33 +106,30 @@ weknora auth logout --all
--- ---
## JSON envelope output ## JSON output
Every command supports `--json`, returning a stable envelope shape: Every command supports `--json`, emitting bare JSON for the resource it
produces — an array for `list` / `search`, a single object for `view`
and write outcomes:
```json ```bash
{ weknora kb list --json # [{ "id": "kb_x", "name": "Eng" }, …]
"ok": true, weknora kb view kb_x --json # { "id": "kb_x", "name": "Eng", … }
"data": { /* command-specific payload */ }, weknora kb list --json=id,name # project to listed fields
"_meta": { "context": "prod", "kb_id": "a32a63ff-fb36-4874-bcaa-30f48570a694" } weknora kb list --json --jq '.[].id' # jq over the bare data
}
``` ```
On error: On failure, stdout stays empty and the typed error goes to stderr in
`code: message\nhint: ...` form:
```json ```
{ auth.unauthenticated: fetch current user: HTTP error 401: ...
"ok": false, hint: run `weknora auth login`
"error": {
"code": "auth.unauthenticated",
"message": "...",
"hint": "run `weknora auth login`"
}
}
``` ```
The full schema, error-code registry, and exit-code protocol (0 / 1 / 2 / 10 The typed exit code (3 / 4 / 5 / 6 / 7 / 10) carries the failure
/ 130) are documented in [AGENTS.md](AGENTS.md). class for agents that branch on it. The full error-code registry and
exit-code protocol are documented in [AGENTS.md](AGENTS.md).
--- ---

View File

@@ -21,7 +21,7 @@ import (
// TestMain pins the doctor credential-storage outcome for the whole suite. // TestMain pins the doctor credential-storage outcome for the whole suite.
// Otherwise the check probes the real OS keyring, which differs between // Otherwise the check probes the real OS keyring, which differs between
// macOS dev machines (Keychain present → ok) and Linux CI runners without // macOS dev machines (Keychain present → ok) and Linux CI runners without
// libsecret (file fallback → warn), making golden envelopes host-dependent. // libsecret (file fallback → warn), making golden outputs host-dependent.
// MemStore is neither *FileStore nor a real keyring, so the doctor's // MemStore is neither *FileStore nor a real keyring, so the doctor's
// type-switch hits the StatusOK branch. // type-switch hits the StatusOK branch.
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
@@ -62,13 +62,9 @@ func newTestFactory(t *testing.T, mockServer *httptest.Server, mockClient *sdk.C
// runCmd executes the root command in-process and returns captured stdout/stderr. // runCmd executes the root command in-process and returns captured stdout/stderr.
// Replaces iostreams.IO singleton via SetForTest (auto-restored in t.Cleanup). // Replaces iostreams.IO singleton via SetForTest (auto-restored in t.Cleanup).
// //
// Mirrors cmd.Execute() carefully: callers expect the same envelope-printing // Mirrors cmd.Execute(): wires the cobra Out / Err sinks to the same buffers
// behavior the real entrypoint provides. The helper (a) wires the cobra Out / // it returns, and re-runs cmdutil.PrintError on stderr for failure cases so
// Err sinks to the same buffers it returns (the `version` leaf and any future // the contract assertion sees the typed `code: message\nhint: ...` line.
// command using c.OutOrStdout would otherwise leak to os.Stdout), and (b)
// re-runs the error-envelope path so failure cases produce the JSON envelope
// the contract test compares against. Without (b), every error scenario's
// golden would be empty.
func runCmd(t *testing.T, f *cmdutil.Factory, args ...string) (stdout, stderr string, exitCode int) { func runCmd(t *testing.T, f *cmdutil.Factory, args ...string) (stdout, stderr string, exitCode int) {
t.Helper() t.Helper()
out, errBuf := iostreams.SetForTest(t) out, errBuf := iostreams.SetForTest(t)
@@ -77,31 +73,25 @@ func runCmd(t *testing.T, f *cmdutil.Factory, args ...string) (stdout, stderr st
root.SetContext(context.Background()) root.SetContext(context.Background())
root.SetOut(out) root.SetOut(out)
root.SetErr(errBuf) root.SetErr(errBuf)
leaf, err := root.ExecuteC() _, err := root.ExecuteC()
if err != nil { if err != nil {
err = cmd.MapCobraError(err) err = cmd.MapCobraError(err)
if cmd.WantsJSONOutput(leaf) { cmdutil.PrintError(iostreams.IO.Err, err)
cmdutil.PrintErrorEnvelope(iostreams.IO.Out, err)
} else {
cmdutil.PrintError(iostreams.IO.Err, err)
}
} }
return out.String(), errBuf.String(), cmdutil.ExitCode(err) return out.String(), errBuf.String(), cmdutil.ExitCode(err)
} }
// assertGolden compares got against the JSON golden file at path. // assertGolden compares got against the JSON golden file at path.
// With -update, writes got to path. Normalizes _meta.request_id to "<id>" // With -update, writes got to path.
// before compare (only field known unstable in v0.0).
// //
// CRLF normalization: Windows checkouts with the default core.autocrlf=true // CRLF normalization: Windows checkouts with the default core.autocrlf=true
// turn LF in tracked text files into CRLF on disk. The command output is // 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 // always LF, so byte-equal would fail despite identical content.
// is the primary defense (forcing LF on testdata/**/*.json), but we also // .gitattributes is the primary defense (forcing LF on testdata/**/*.json),
// strip CR here so a misconfigured contributor checkout doesn't break the // but we also strip CR here so a misconfigured contributor checkout doesn't
// suite locally before they push. // break the suite locally before they push.
func assertGolden(t *testing.T, got []byte, path string) { func assertGolden(t *testing.T, got []byte, path string) {
t.Helper() t.Helper()
got = normalizeEnvelope(got)
if *update { if *update {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
t.Fatalf("mkdir testdata: %v", err) t.Fatalf("mkdir testdata: %v", err)
@@ -118,7 +108,7 @@ func assertGolden(t *testing.T, got []byte, path string) {
want = stripCR(want) want = stripCR(want)
got = stripCR(got) got = stripCR(got)
if !bytes.Equal(want, got) { if !bytes.Equal(want, got) {
t.Errorf("envelope mismatch for %s\nwant:\n%s\ngot:\n%s", path, want, got) t.Errorf("stdout mismatch for %s\nwant:\n%s\ngot:\n%s", path, want, got)
} }
} }
@@ -127,10 +117,3 @@ func assertGolden(t *testing.T, got []byte, path string) {
func stripCR(b []byte) []byte { func stripCR(b []byte) []byte {
return bytes.ReplaceAll(b, []byte{'\r'}, nil) return bytes.ReplaceAll(b, []byte{'\r'}, nil)
} }
// normalizeEnvelope replaces unstable fields with placeholders for stable diff.
// Currently no-op (v0.0 commands don't set _meta.request_id, so output is stable).
// Hook for future fields.
func normalizeEnvelope(b []byte) []byte {
return b
}

View File

@@ -1,39 +1,34 @@
// cli/acceptance/contract/envelope_test.go // cli/acceptance/contract/wire_test.go
// //
// Envelope contract test (PR-8 Task 18). Drives root cobra in-process for // Wire contract test. Drives root cobra in-process for each scenario,
// each scenario, captures stdout, and compares against a JSON golden file // captures stdout + stderr, and asserts:
// in cli/acceptance/testdata/envelopes/.
// //
// Spec §4.1 lists 19 envelope scenarios. Implemented count: 16. // - stdout matches a JSON golden in cli/acceptance/testdata/wire/
// - on wantErr cases, stderr contains the expected typed error code
// //
// Cases dropped (with reason): // Successful cases produce bare JSON on stdout (no envelope wrapper);
// - doctor.success — non-offline path emits unstable // failure cases produce empty stdout (or, for `doctor`, the data object
// timing ("reachable in 2ms"). // the command writes before returning SilentError) and a `code: msg`
// Unit tests in cli/cmd/doctor // line on stderr.
// 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; helpers_test
// (PR-6) does not yet expose a
// stdin hook. Deferred to the e2e harness.
// - auth_login.error_auth_unauthenticated — same setup as above; deferred
// together.
// - context_use.error_local_context_not_found — `context use` has no --json
// flag in v0.1, so error path
// renders plain stderr. Pinning
// its envelope shape needs either
// a --json flag added to the leaf
// or a global --json. Deferred
// until that lands; the success
// case is golden-pinned (writes
// envelope unconditionally).
// //
// All cases use leaf-positioned --json (e.g. `version --json`) instead of the // Cases intentionally omitted (with reason):
// `--json version` form sketched in the spec. v0.0v0.1 implements --json as a // - doctor.success — non-offline path emits
// per-leaf flag, not a global persistent flag — root-level --json is detected // unstable timing
// only as an error-envelope fallback (see argsRequestJSON in cmd/root.go). // ("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 --json (e.g. `version --json`). --json is a
// per-leaf flag, not a global persistent flag.
package contract_test package contract_test
import ( import (
@@ -49,23 +44,28 @@ import (
sdk "github.com/Tencent/WeKnora/client" sdk "github.com/Tencent/WeKnora/client"
) )
// envelopeCase declares one row in the contract matrix. Optional fields: // wireCase declares one row in the contract matrix. Optional fields:
// server — mock /api/v1/* endpoints; nil means no network needed. // server — mock /api/v1/* endpoints; nil means no network.
// preConfig — seed config.yaml under the per-test XDG_CONFIG_HOME (set by // preConfig — seed config.yaml under the per-test XDG_CONFIG_HOME
// newTestFactory); use for cases like context use that read // (set by newTestFactory); use for cases like
// local state without an SDK round-trip. // `context use` that read local state without an
// wantErr — true means the run is expected to exit non-zero. // SDK round-trip.
type envelopeCase struct { // wantErr — non-zero exit expected.
name string // wantStderrSubstring — stderr must contain this substring (typically the
args []string // typed error code, e.g. "auth.unauthenticated").
server http.HandlerFunc // Only meaningful when wantErr=true.
preConfig func(t *testing.T) type wireCase struct {
wantErr bool name string
args []string
server http.HandlerFunc
preConfig func(t *testing.T)
wantErr bool
wantStderrSubstring string
} }
// envelopeCases enumerates every contract scenario whose envelope is golden- // wireCases enumerates every contract scenario whose stdout is golden-pinned.
// pinned. Order is illustrative (matches spec §4.1 mostly), not load-bearing. // Order is illustrative, not load-bearing.
var envelopeCases = []envelopeCase{ var wireCases = []wireCase{
// 1. version.success — pure local; no client touched. // 1. version.success — pure local; no client touched.
{ {
name: "version.success", name: "version.success",
@@ -82,9 +82,9 @@ var envelopeCases = []envelopeCase{
// 3. doctor.error_network — base_url returns 500 → ping fail → cascade // 3. doctor.error_network — base_url returns 500 → ping fail → cascade
// skip on auth_credential + server_version. credential_storage still // skip on auth_credential + server_version. credential_storage still
// runs (independent). v0.2 contract: any check=fail flips envelope.ok // runs (independent). Contract: any check=fail bumps summary.failed
// to false and exits 1 (RunE returns SilentError so the data envelope // and RunE returns SilentError → exit 1 with the data object
// written by emit() is preserved as the only stdout content). // written by emit() as the only stdout content.
{ {
name: "doctor.error_network", name: "doctor.error_network",
args: []string{"doctor", "--json"}, args: []string{"doctor", "--json"},
@@ -104,10 +104,11 @@ var envelopeCases = []envelopeCase{
server: kbListEmpty, server: kbListEmpty,
}, },
{ {
name: "kb_list.error_auth_forbidden", name: "kb_list.error_auth_forbidden",
args: []string{"kb", "list", "--json"}, args: []string{"kb", "list", "--json"},
server: always403, server: always403,
wantErr: true, wantErr: true,
wantStderrSubstring: "auth.forbidden",
}, },
{ {
name: "kb_view.success", name: "kb_view.success",
@@ -115,16 +116,17 @@ var envelopeCases = []envelopeCase{
server: kbGetOne, server: kbGetOne,
}, },
{ {
name: "kb_view.error_resource_not_found", name: "kb_view.error_resource_not_found",
args: []string{"kb", "view", "missing", "--json"}, args: []string{"kb", "view", "missing", "--json"},
server: always404, server: always404,
wantErr: true, wantErr: true,
wantStderrSubstring: "resource.not_found",
}, },
// 8. context use — pure local I/O against config.yaml. // 8. context use — pure local I/O against config.yaml.
{ {
name: "context_use.success", name: "context_use.success",
args: []string{"context", "use", "production"}, args: []string{"context", "use", "production", "--json"},
preConfig: func(t *testing.T) { preConfig: func(t *testing.T) {
cfg := &config.Config{ cfg := &config.Config{
CurrentContext: "staging", CurrentContext: "staging",
@@ -147,10 +149,11 @@ var envelopeCases = []envelopeCase{
server: whoamiOK, server: whoamiOK,
}, },
{ {
name: "auth_status.error_auth_unauthenticated", name: "auth_status.error_auth_unauthenticated",
args: []string{"auth", "status", "--json"}, args: []string{"auth", "status", "--json"},
server: always401, server: always401,
wantErr: true, wantErr: true,
wantStderrSubstring: "auth.unauthenticated",
}, },
// 11-13. search chunks — verb-noun shape (gh search parity), positional query, --kb required. // 11-13. search chunks — verb-noun shape (gh search parity), positional query, --kb required.
@@ -163,26 +166,28 @@ var envelopeCases = []envelopeCase{
server: searchTwoResults, server: searchTwoResults,
}, },
{ {
name: "search.error_resource_not_found", name: "search.error_resource_not_found",
args: []string{"search", "chunks", "query", "--kb=eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", "--json"}, args: []string{"search", "chunks", "query", "--kb=eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", "--json"},
server: always404, server: always404,
wantErr: true, wantErr: true,
wantStderrSubstring: "resource.not_found",
}, },
{ {
// --no-vector + --no-keyword is the input.invalid case; the KB UUID // --no-vector + --no-keyword is the input.invalid case; the KB UUID
// is just there to satisfy MarkFlagRequired so validation runs deep // is just there to satisfy MarkFlagRequired so validation runs deep
// enough to hit the mutex-channel check. // enough to hit the mutex-channel check.
name: "search.error_input_invalid", name: "search.error_input_invalid",
args: []string{"search", "chunks", "query", "--kb=11111111-1111-4111-8111-111111111111", "--no-vector", "--no-keyword", "--json"}, args: []string{"search", "chunks", "query", "--kb=11111111-1111-4111-8111-111111111111", "--no-vector", "--no-keyword", "--json"},
wantErr: true, wantErr: true,
wantStderrSubstring: "input.invalid_argument",
}, },
} }
// TestEnvelopeGolden is the matrix-runner. Cases are sequential (the // TestWireGolden is the matrix-runner. Cases are sequential (the
// iostreams singleton swap inside helpers.runCmd is package-global; t.Parallel // iostreams singleton swap inside helpers.runCmd is package-global;
// is contractually forbidden — see helpers_test.go SetForTest comment). // t.Parallel is contractually forbidden — see helpers_test.go).
func TestEnvelopeGolden(t *testing.T) { func TestWireGolden(t *testing.T) {
for _, tc := range envelopeCases { for _, tc := range wireCases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
var ts *httptest.Server var ts *httptest.Server
var mockClient *sdk.Client var mockClient *sdk.Client
@@ -202,7 +207,10 @@ func TestEnvelopeGolden(t *testing.T) {
if !tc.wantErr && exit != 0 { if !tc.wantErr && exit != 0 {
t.Errorf("unexpected non-zero exit %d; stdout=%q stderr=%q", exit, stdout, stderr) t.Errorf("unexpected non-zero exit %d; stdout=%q stderr=%q", exit, stdout, stderr)
} }
path := filepath.Join("..", "testdata", "envelopes", tc.name+".json") 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) assertGolden(t, []byte(stdout), path)
}) })
} }

View File

@@ -31,7 +31,7 @@ import (
// TestRAGFullLoop walks the demo MVP path: link a context, create a KB, // TestRAGFullLoop walks the demo MVP path: link a context, create a KB,
// upload a doc, wait for indexing, search it, then chat against it. Each // upload a doc, wait for indexing, search it, then chat against it. Each
// step parses the CLI's JSON envelope to extract IDs for the next step — // step parses the CLI's bare JSON to extract IDs for the next step —
// validating both functional behavior and wire-contract stability. // validating both functional behavior and wire-contract stability.
func TestRAGFullLoop(t *testing.T) { func TestRAGFullLoop(t *testing.T) {
host := mustEnv(t, "WEKNORA_E2E_HOST") host := mustEnv(t, "WEKNORA_E2E_HOST")
@@ -45,75 +45,69 @@ func TestRAGFullLoop(t *testing.T) {
env := append(os.Environ(), env := append(os.Environ(),
"XDG_CONFIG_HOME="+xdg, "XDG_CONFIG_HOME="+xdg,
"XDG_CACHE_HOME="+filepath.Join(xdg, "cache"), "XDG_CACHE_HOME="+filepath.Join(xdg, "cache"),
// SDK debug off — explicit so the CI run isn't noisy. C1 SDK silence // SDK debug off — explicit so the CI run isn't noisy.
// makes this redundant in practice but the explicit flag documents
// intent.
"WEKNORA_SDK_DEBUG=", "WEKNORA_SDK_DEBUG=",
) )
// 1. kb create // 1. kb create → bare KnowledgeBase object
kbName := prefix + fmt.Sprintf("%d", time.Now().UnixNano()) kbName := prefix + fmt.Sprintf("%d", time.Now().UnixNano())
createOut := runJSON(t, bin, env, "kb", "create", "--name", kbName, "--json") var created struct {
kbData, ok := createOut["data"].(map[string]any) ID string `json:"id"`
if !ok { Name string `json:"name"`
t.Fatalf("kb create envelope: data not an object: %v", createOut)
} }
kbID, _ := kbData["id"].(string) runJSONInto(t, bin, env, &created, "kb", "create", "--name", kbName, "--json")
if kbID == "" { if created.ID == "" {
t.Fatalf("kb create returned no id: %v", createOut) t.Fatalf("kb create returned no id")
} }
t.Logf("created KB: %s (%s)", kbID, kbName) t.Logf("created KB: %s (%s)", created.ID, kbName)
t.Cleanup(func() { t.Cleanup(func() {
// Best-effort cleanup; a 404 means the KB was already gone. // Best-effort cleanup; a 404 means the KB was already gone.
out, err := run(bin, env, "kb", "delete", kbID, "-y", "--json") out, err := run(bin, env, "kb", "delete", created.ID, "-y", "--json")
if err != nil { if err != nil {
t.Logf("cleanup kb delete: %v\n%s", err, out) t.Logf("cleanup kb delete: %v\n%s", err, out)
} }
}) })
// 2. doc upload // 2. doc upload → bare Knowledge object
docPath := writeSampleDoc(t) docPath := writeSampleDoc(t)
uploadOut := runJSON(t, bin, env, "doc", "upload", docPath, "--kb", kbID, "--json") var uploaded struct {
docData, _ := uploadOut["data"].(map[string]any) ID string `json:"id"`
docID, _ := docData["id"].(string)
if docID == "" {
t.Fatalf("doc upload returned no id: %v", uploadOut)
} }
t.Logf("uploaded doc: %s", docID) runJSONInto(t, bin, env, &uploaded, "doc", "upload", docPath, "--kb", created.ID, "--json")
if uploaded.ID == "" {
t.Fatalf("doc upload returned no id")
}
t.Logf("uploaded doc: %s", uploaded.ID)
// 3. poll until indexing finishes (status changes from "pending" / "processing" to "ready" / similar) // 3. poll until indexing finishes (status changes from "pending" / "processing" to "ready" / similar)
waitDocReady(t, bin, env, kbID, docID, 90*time.Second) waitDocReady(t, bin, env, created.ID, uploaded.ID, 90*time.Second)
// 4. search — verify retrieval returns chunks // 4. search chunks → bare []SearchResult
searchOut := runJSON(t, bin, env, "search", "chunks", "sample", "--kb", kbID, "--limit", "5", "--json") var results []map[string]any
searchData, _ := searchOut["data"].(map[string]any) runJSONInto(t, bin, env, &results, "search", "chunks", "sample", "--kb", created.ID, "--limit", "5", "--json")
results, _ := searchData["results"].([]any)
if len(results) == 0 { if len(results) == 0 {
t.Fatalf("search returned no results: %v", searchOut) t.Fatalf("search returned no results")
} }
t.Logf("search returned %d results", len(results)) t.Logf("search returned %d results", len(results))
// 5. chat — verify LLM answer + references in --json + --no-stream mode // 5. chat --no-stream --json → bare {answer, references, ...} object
// (--no-stream forces accumulator path; --json gates envelope output) var chat struct {
chatOut := runJSON(t, bin, env, "chat", "summarize the document briefly", "--kb", kbID, "--no-stream", "--json") Answer string `json:"answer"`
chatData, _ := chatOut["data"].(map[string]any) References []map[string]any `json:"references"`
answer, _ := chatData["answer"].(string)
if strings.TrimSpace(answer) == "" {
t.Fatalf("chat returned empty answer: %v", chatOut)
} }
refs, _ := chatData["references"].([]any) runJSONInto(t, bin, env, &chat, "chat", "summarize the document briefly", "--kb", created.ID, "--no-stream", "--json")
t.Logf("chat answer (%d chars), %d references", len(answer), len(refs)) if strings.TrimSpace(chat.Answer) == "" {
if len(refs) == 0 { t.Fatalf("chat returned empty answer")
}
t.Logf("chat answer (%d chars), %d references", len(chat.Answer), len(chat.References))
if len(chat.References) == 0 {
// Soft warning — some servers may not surface references for every // Soft warning — some servers may not surface references for every
// question, but the demo flow is supposed to. // question, but the demo flow is supposed to.
t.Logf("warning: chat returned 0 references (server may have a different config)") t.Logf("warning: chat returned 0 references (server may have a different config)")
} }
} }
// mustEnv reads an env var and skips the test if missing — keeps the
// suite friendly to community contributors who clone the repo without
// access to the maintainer's E2E secrets.
func mustEnv(t *testing.T, key string) string { func mustEnv(t *testing.T, key string) string {
t.Helper() t.Helper()
v := os.Getenv(key) v := os.Getenv(key)
@@ -203,27 +197,23 @@ func waitDocReady(t *testing.T, bin string, env []string, kbID, docID string, ti
deadline := time.Now().Add(timeout) deadline := time.Now().Add(timeout)
tick := 2 * time.Second tick := 2 * time.Second
for time.Now().Before(deadline) { for time.Now().Before(deadline) {
out := runJSON(t, bin, env, "doc", "list", "--kb", kbID, "--page-size", "100", "--json") var docs []struct {
data, _ := out["data"].(map[string]any) ID string `json:"id"`
items, _ := data["items"].([]any) ParseStatus string `json:"parse_status"`
for _, it := range items { }
m, ok := it.(map[string]any) runJSONInto(t, bin, env, &docs, "doc", "list", "--kb", kbID, "--page-size", "100", "--json")
if !ok { for _, d := range docs {
if d.ID != docID {
continue continue
} }
id, _ := m["id"].(string) low := strings.ToLower(d.ParseStatus)
if id != docID {
continue
}
status, _ := m["status"].(string)
low := strings.ToLower(status)
switch { switch {
case low == "failed", low == "error": case low == "failed", low == "error":
t.Fatalf("doc %s indexing failed: status=%q", docID, status) t.Fatalf("doc %s indexing failed: status=%q", docID, d.ParseStatus)
case low == "pending", low == "processing", low == "": case low == "pending", low == "processing", low == "":
// keep waiting // keep waiting
default: default:
t.Logf("doc %s ready (status=%q)", docID, status) t.Logf("doc %s ready (status=%q)", docID, d.ParseStatus)
return return
} }
} }
@@ -246,20 +236,16 @@ func run(bin string, env []string, args ...string) ([]byte, error) {
return stdout.Bytes(), nil return stdout.Bytes(), nil
} }
// runJSON runs the CLI expecting --json output and parses the envelope. // runJSONInto runs the CLI expecting bare JSON output and decodes stdout
// Test fails immediately on non-zero exit or unparseable JSON. // into out (a struct, slice, or map pointer). Test fails on non-zero exit
func runJSON(t *testing.T, bin string, env []string, args ...string) map[string]any { // or unparseable JSON.
func runJSONInto(t *testing.T, bin string, env []string, out any, args ...string) {
t.Helper() t.Helper()
out, err := run(bin, env, args...) stdout, err := run(bin, env, args...)
if err != nil { if err != nil {
t.Fatalf("%v", err) t.Fatalf("%v", err)
} }
var env_ map[string]any if err := json.Unmarshal(stdout, out); err != nil {
if err := json.Unmarshal(out, &env_); err != nil { t.Fatalf("parse JSON from %v: %v\nstdout:\n%s", args, err, string(stdout))
t.Fatalf("parse envelope from %v: %v\nstdout:\n%s", args, err, string(out))
} }
if ok, _ := env_["ok"].(bool); !ok {
t.Fatalf("envelope ok=false from %v: %s", args, string(out))
}
return env_
} }

View File

@@ -1 +0,0 @@
{"ok":false,"error":{"code":"auth.unauthenticated","message":"fetch current user: HTTP error 401: {\"error\":\"unauthenticated\"}","hint":"run `weknora auth login`"}}

View File

@@ -1 +0,0 @@
{"ok":false,"error":{"code":"auth.forbidden","message":"list knowledge bases: HTTP error 403: {\"error\":\"forbidden\"}","hint":"active context lacks permission for this resource"}}

View File

@@ -1 +0,0 @@
{"ok":false,"error":{"code":"resource.not_found","message":"get knowledge base \"missing\": HTTP error 404: {\"error\":\"not found\"}","hint":"verify the resource ID; list available with `weknora kb list`"}}

View File

@@ -1 +0,0 @@
{"ok":false,"error":{"code":"input.invalid_argument","message":"--no-vector and --no-keyword cannot both be set","hint":"see `weknora <command> --help` for valid usage"}}

View File

@@ -1 +0,0 @@
{"ok":false,"error":{"code":"resource.not_found","message":"hybrid search: HTTP error 404: {\"error\":\"not found\"}","hint":"verify the resource ID; list available with `weknora kb list`"}}

View File

@@ -1 +0,0 @@
{"ok":false,"error":{"code":"auth.unauthenticated","message":"fetch current user: HTTP error 401: {\"error\":\"unauthenticated\"}","hint":"run `weknora auth login`"}}

View File

@@ -1 +0,0 @@
{"ok":true,"data":{"user_id":"usr_abc","tenant_id":42}}

View File

@@ -18,8 +18,9 @@ import (
) )
// agentInvokeFields enumerates fields surfaced for `--json` discovery on // agentInvokeFields enumerates fields surfaced for `--json` discovery on
// `agent invoke`. Matches invokeData below — single-shot result envelope // `agent invoke`. Matches invokeData below — the single-shot result
// with the agent's final answer plus the trace (references, tool events). // object with the agent's final answer plus the trace (references,
// tool events).
var agentInvokeFields = []string{ var agentInvokeFields = []string{
"answer", "references", "tool_events", "thinking", "answer", "references", "tool_events", "thinking",
"session_id", "agent_id", "query", "session_id", "agent_id", "query",
@@ -46,7 +47,7 @@ type InvokeService interface {
AgentQAStreamWithRequest(ctx context.Context, sessionID string, req *sdk.AgentQARequest, cb sdk.AgentEventCallback) error AgentQAStreamWithRequest(ctx context.Context, sessionID string, req *sdk.AgentQARequest, cb sdk.AgentEventCallback) error
} }
// invokeData is the JSON envelope payload. // invokeData is the JSON payload emitted on the --json path.
type invokeData struct { type invokeData struct {
Answer string `json:"answer"` Answer string `json:"answer"`
References []*sdk.SearchResult `json:"references"` References []*sdk.SearchResult `json:"references"`
@@ -71,7 +72,7 @@ config — agent invoke is the thin shim that streams the result.
Modes: Modes:
TTY (default): live answer streaming + tool-trace footer TTY (default): live answer streaming + tool-trace footer
Pipe / --no-stream / --json: buffered, single envelope at completion`, Pipe / --no-stream / --json: buffered, single JSON object at completion`,
Example: ` weknora agent invoke ag_abc "Summarise the Q3 plan" Example: ` weknora agent invoke ag_abc "Summarise the Q3 plan"
weknora agent invoke ag_abc "Continue?" --session sess_xyz weknora agent invoke ag_abc "Continue?" --session sess_xyz
weknora agent invoke ag_abc "What did we ship?" --json`, weknora agent invoke ag_abc "What did we ship?" --json`,
@@ -93,7 +94,7 @@ Modes:
cmd.Flags().StringVar(&opts.SessionID, "session", "", "Continue an existing chat session (skip auto-create)") cmd.Flags().StringVar(&opts.SessionID, "session", "", "Continue an existing chat session (skip auto-create)")
cmd.Flags().BoolVar(&opts.NoStream, "no-stream", false, "Buffer the full answer before printing (forces accumulate mode)") cmd.Flags().BoolVar(&opts.NoStream, "no-stream", false, "Buffer the full answer before printing (forces accumulate mode)")
cmdutil.AddJSONFlags(cmd, agentInvokeFields) cmdutil.AddJSONFlags(cmd, agentInvokeFields)
aiclient.SetAgentHelp(cmd, "Streams an agent's answer over SSE. Pass --json (or run non-TTY) to receive a single completed envelope with answer + references + tool_events instead of partial chunks. Errors: resource.not_found (unknown agent_id) / server.session_create_failed (auto-create) / local.sse_stream_aborted (mid-stream).") aiclient.SetAgentHelp(cmd, "Streams an agent's answer over SSE. Pass --json (or run non-TTY) to receive a single completed {answer, references, tool_events, …} JSON object instead of partial chunks. Errors: resource.not_found (unknown agent_id) / server.session_create_failed (auto-create) / local.sse_stream_aborted (mid-stream).")
return cmd return cmd
} }
@@ -183,7 +184,7 @@ func runInvoke(ctx context.Context, opts *InvokeOptions, jopts *cmdutil.JSONOpti
AgentID: opts.AgentID, AgentID: opts.AgentID,
Query: opts.Query, Query: opts.Query,
} }
return format.WriteJSONFiltered(iostreams.IO.Out, data, jopts.Fields, jopts.JQ) return jopts.Emit(iostreams.IO.Out, data)
} }
out := iostreams.IO.Out out := iostreams.IO.Out

View File

@@ -62,7 +62,7 @@ func referencesEvent(refs []*sdk.SearchResult) *sdk.AgentStreamResponse {
} }
} }
func TestInvoke_AccumulateMode_EmitsJSONEnvelope(t *testing.T) { func TestInvoke_AccumulateMode_EmitsBareJSON(t *testing.T) {
out, _ := iostreams.SetForTest(t) out, _ := iostreams.SetForTest(t)
svc := &scriptedInvokeSvc{ svc := &scriptedInvokeSvc{
events: []*sdk.AgentStreamResponse{ events: []*sdk.AgentStreamResponse{

View File

@@ -11,7 +11,6 @@ import (
"github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/aiclient"
"github.com/Tencent/WeKnora/cli/internal/cmdutil" "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/iostreams"
"github.com/Tencent/WeKnora/cli/internal/text" "github.com/Tencent/WeKnora/cli/internal/text"
sdk "github.com/Tencent/WeKnora/client" sdk "github.com/Tencent/WeKnora/client"
@@ -60,7 +59,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
} }
cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum results to return (0 = no cap, 1..10000 = explicit)") cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum results to return (0 = no cap, 1..10000 = explicit)")
cmdutil.AddJSONFlags(cmd, agentListFields) cmdutil.AddJSONFlags(cmd, agentListFields)
aiclient.SetAgentHelp(cmd, "Lists tenant-visible agents (built-in + custom) as a bare JSON array of Agent objects (empty `[]` when none). --limit caps the returned slice. Use `--json id,name` to project fields, `--jq` for arbitrary reshape.") aiclient.SetAgentHelp(cmd, "Lists tenant-visible agents (built-in + custom) as a bare JSON array of Agent objects (empty `[]` when none). --limit caps the returned slice. Use `--json=id,name` to project fields, `--jq` for arbitrary reshape.")
return cmd return cmd
} }
@@ -92,7 +91,7 @@ func runList(ctx context.Context, opts *ListOptions, jopts *cmdutil.JSONOptions,
} }
if jopts.Enabled() { if jopts.Enabled() {
return format.WriteJSONFiltered(iostreams.IO.Out, items, jopts.Fields, jopts.JQ) return jopts.Emit(iostreams.IO.Out, items)
} }
if len(items) == 0 { if len(items) == 0 {

View File

@@ -9,7 +9,6 @@ import (
"github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/aiclient"
"github.com/Tencent/WeKnora/cli/internal/cmdutil" "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/iostreams"
sdk "github.com/Tencent/WeKnora/client" sdk "github.com/Tencent/WeKnora/client"
) )
@@ -64,7 +63,7 @@ func runView(ctx context.Context, jopts *cmdutil.JSONOptions, svc ViewService, a
return cmdutil.WrapHTTP(err, "fetch agent %s", agentID) return cmdutil.WrapHTTP(err, "fetch agent %s", agentID)
} }
if jopts.Enabled() { if jopts.Enabled() {
return format.WriteJSONFiltered(iostreams.IO.Out, a, jopts.Fields, jopts.JQ) return jopts.Emit(iostreams.IO.Out, a)
} }
renderAgent(iostreams.IO.Out, a) renderAgent(iostreams.IO.Out, a)
return nil return nil

View File

@@ -3,7 +3,7 @@
// Shape: one positional (path) + `-X/--method` flag, default GET (auto- // Shape: one positional (path) + `-X/--method` flag, default GET (auto-
// promoted to POST when a body is supplied via --data or --input). The two // promoted to POST when a body is supplied via --data or --input). The two
// body-source flags are mutually exclusive. Default raw response body to // body-source flags are mutually exclusive. Default raw response body to
// stdout; --json wraps in CLI envelope. Reuses sdk.Client.Raw which already // stdout; --json emits a {status, headers, body} object. Reuses sdk.Client.Raw which already
// applies tenant + auth headers. // applies tenant + auth headers.
package api package api
@@ -26,7 +26,7 @@ import (
) )
// apiFields is intentionally a marker — api wraps arbitrary HTTP responses // apiFields is intentionally a marker — api wraps arbitrary HTTP responses
// whose schema the CLI doesn't know, so the `--json id,name` field-filter // whose schema the CLI doesn't know, so the `--json=id,name` field-filter
// is a no-op here. The marker shows up in --help so users can tell. // is a no-op here. The marker shows up in --help so users can tell.
var apiFields = []string{"<response-shape-varies>"} var apiFields = []string{"<response-shape-varies>"}
@@ -59,7 +59,7 @@ POST. Use -X/--method to override (DELETE / PUT / PATCH / HEAD).
Auth, tenant, and request-id headers are applied automatically from the Auth, tenant, and request-id headers are applied automatically from the
active context. The response body is written to stdout by default; use active context. The response body is written to stdout by default; use
--json to wrap it in the CLI envelope (status / headers / body). --json to emit a {status, headers, body} JSON object.
Examples: Examples:
weknora api /api/v1/knowledge-bases # GET weknora api /api/v1/knowledge-bases # GET
@@ -131,7 +131,7 @@ func resolveMethod(opts *Options) string {
} }
// runAPI is the testable core: validate inputs, dispatch via Service.Raw, // runAPI is the testable core: validate inputs, dispatch via Service.Raw,
// classify status, and emit either the raw body or a JSON envelope. The // classify status, and emit either the raw body or a JSON object. The
// caller is responsible for resolving the method (defaults / auto-POST) // caller is responsible for resolving the method (defaults / auto-POST)
// and uppercasing it; runAPI guards against unsupported values like // and uppercasing it; runAPI guards against unsupported values like
// `-X PATCH-INVALID` reaching the wire. // `-X PATCH-INVALID` reaching the wire.
@@ -181,7 +181,7 @@ func runAPI(ctx context.Context, opts *Options, jopts *cmdutil.JSONOptions, svc
out := iostreams.IO.Out out := iostreams.IO.Out
if jopts.Enabled() { if jopts.Enabled() {
// Best-effort decode: if response body is valid JSON, surface the // Best-effort decode: if response body is valid JSON, surface the
// parsed structure under .data.body so envelope consumers can drill // parsed structure under .body so JSON consumers can drill
// in; otherwise fall back to the raw string. // in; otherwise fall back to the raw string.
var bodyAny any var bodyAny any
if len(respBody) > 0 { if len(respBody) > 0 {
@@ -195,11 +195,13 @@ func runAPI(ctx context.Context, opts *Options, jopts *cmdutil.JSONOptions, svc
hdrs[k] = v[0] hdrs[k] = v[0]
} }
} }
return format.WriteJSON(out, map[string]any{ // --json field-filter is ignored (response shape unknown to the
// CLI); --jq runs over the full {status, headers, body} object.
return format.WriteJSONFiltered(out, map[string]any{
"status": resp.StatusCode, "status": resp.StatusCode,
"headers": hdrs, "headers": hdrs,
"body": bodyAny, "body": bodyAny,
}) }, nil, jopts.JQ)
} }
if _, err := out.Write(respBody); err != nil { if _, err := out.Write(respBody); err != nil {

View File

@@ -8,7 +8,7 @@ import (
"github.com/Tencent/WeKnora/cli/internal/cmdutil" "github.com/Tencent/WeKnora/cli/internal/cmdutil"
) )
// Credential-mode tokens used in the JSON envelope of auth list / login / // Credential-mode tokens used in the JSON output of auth list / login /
// status / token. The string names describe the HTTP credential type rather // status / token. The string names describe the HTTP credential type rather
// than the login flow (e.g. JWT → bearer regardless of whether it was // 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 // obtained via password or refresh) so an agent can branch directly on the

View File

@@ -68,7 +68,7 @@ func runList(jopts *cmdutil.JSONOptions, f *cmdutil.Factory) error {
sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name }) sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name })
if jopts.Enabled() { if jopts.Enabled() {
return format.WriteJSONFiltered(iostreams.IO.Out, entries, jopts.Fields, jopts.JQ) return jopts.Emit(iostreams.IO.Out, entries)
} }
if len(entries) == 0 { if len(entries) == 0 {
fmt.Fprintln(iostreams.IO.Out, "No contexts configured. Run `weknora auth login` to create one.") fmt.Fprintln(iostreams.IO.Out, "No contexts configured. Run `weknora auth login` to create one.")

View File

@@ -11,7 +11,6 @@ import (
"github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/aiclient"
"github.com/Tencent/WeKnora/cli/internal/cmdutil" "github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/config" "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/iostreams"
sdk "github.com/Tencent/WeKnora/client" sdk "github.com/Tencent/WeKnora/client"
) )
@@ -75,7 +74,7 @@ the current_context in ~/.config/weknora/config.yaml.`,
cmd.Flags().StringVar(&opts.Context, "name", "default", "Context name to register in config.yaml") cmd.Flags().StringVar(&opts.Context, "name", "default", "Context name to register in config.yaml")
cmd.Flags().BoolVar(&opts.WithToken, "with-token", false, "Read an API key from stdin instead of prompting for password") cmd.Flags().BoolVar(&opts.WithToken, "with-token", false, "Read an API key from stdin instead of prompting for password")
cmdutil.AddJSONFlags(cmd, authLoginFields) cmdutil.AddJSONFlags(cmd, authLoginFields)
cmdutil.MustRequireFlag(cmd, "host") _ = cmd.MarkFlagRequired("host")
aiclient.SetAgentHelp(cmd, "Authenticates and stores credentials. --with-token reads an API key from stdin (no password prompt, agent-safe). Otherwise email/password prompts fire — non-TTY callers must pipe `--with-token` or pre-set --name. Errors: auth.bad_credential on wrong password; input.invalid_argument on bad --host; input.missing_flag when --with-token has empty stdin.") aiclient.SetAgentHelp(cmd, "Authenticates and stores credentials. --with-token reads an API key from stdin (no password prompt, agent-safe). Otherwise email/password prompts fire — non-TTY callers must pipe `--with-token` or pre-set --name. Errors: auth.bad_credential on wrong password; input.invalid_argument on bad --host; input.missing_flag when --with-token has empty stdin.")
return cmd return cmd
} }
@@ -211,7 +210,7 @@ func saveContextRef(opts *LoginOptions, jopts *cmdutil.JSONOptions, f *cmdutil.F
result.User = user.Email result.User = user.Email
result.TenantID = user.TenantID result.TenantID = user.TenantID
} }
return format.WriteJSONFiltered(iostreams.IO.Out, result, jopts.Fields, jopts.JQ) return jopts.Emit(iostreams.IO.Out, result)
} }
who := opts.Context who := opts.Context
if user != nil { if user != nil {

View File

@@ -9,7 +9,6 @@ import (
"github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/aiclient"
"github.com/Tencent/WeKnora/cli/internal/cmdutil" "github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/config" "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/iostreams"
"github.com/Tencent/WeKnora/cli/internal/secrets" "github.com/Tencent/WeKnora/cli/internal/secrets"
) )
@@ -97,7 +96,7 @@ func runLogout(opts *LogoutOptions, jopts *cmdutil.JSONOptions, f *cmdutil.Facto
} }
if jopts.Enabled() { if jopts.Enabled() {
return format.WriteJSONFiltered(iostreams.IO.Out, logoutResult{Removed: targets}, jopts.Fields, jopts.JQ) return jopts.Emit(iostreams.IO.Out, logoutResult{Removed: targets})
} }
fmt.Fprintf(iostreams.IO.Out, "✓ Logged out of %d context(s): %s\n", len(targets), strings.Join(targets, ", ")) fmt.Fprintf(iostreams.IO.Out, "✓ Logged out of %d context(s): %s\n", len(targets), strings.Join(targets, ", "))
return nil return nil

View File

@@ -8,7 +8,6 @@ import (
"github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/aiclient"
"github.com/Tencent/WeKnora/cli/internal/cmdutil" "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/iostreams"
sdk "github.com/Tencent/WeKnora/client" sdk "github.com/Tencent/WeKnora/client"
) )
@@ -113,7 +112,7 @@ func runRefresh(ctx context.Context, opts *RefreshOptions, jopts *cmdutil.JSONOp
} }
if jopts.Enabled() { if jopts.Enabled() {
return format.WriteJSONFiltered(iostreams.IO.Out, refreshResult{Context: name}, jopts.Fields, jopts.JQ) return jopts.Emit(iostreams.IO.Out, refreshResult{Context: name})
} }
fmt.Fprintf(iostreams.IO.Out, "✓ Refreshed access token for context %s\n", name) fmt.Fprintf(iostreams.IO.Out, "✓ Refreshed access token for context %s\n", name)
return nil return nil

View File

@@ -8,7 +8,6 @@ import (
"github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/aiclient"
"github.com/Tencent/WeKnora/cli/internal/cmdutil" "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/iostreams"
sdk "github.com/Tencent/WeKnora/client" sdk "github.com/Tencent/WeKnora/client"
) )
@@ -82,7 +81,7 @@ func runStatus(ctx context.Context, jopts *cmdutil.JSONOptions, f *cmdutil.Facto
if tenant != nil { if tenant != nil {
result.TenantName = tenant.Name result.TenantName = tenant.Name
} }
return format.WriteJSONFiltered(iostreams.IO.Out, result, jopts.Fields, jopts.JQ) return jopts.Emit(iostreams.IO.Out, result)
} }
host := "" host := ""

View File

@@ -7,7 +7,6 @@ import (
"github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/aiclient"
"github.com/Tencent/WeKnora/cli/internal/cmdutil" "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/iostreams"
) )
@@ -32,7 +31,7 @@ type tokenResult struct {
// `auth list` shows which mode each context uses. // `auth list` shows which mode each context uses.
// //
// Default output: raw token on stdout, no trailing newline (clean $(...)). // Default output: raw token on stdout, no trailing newline (clean $(...)).
// `--json[=fields]` wraps in envelope {token, mode, context}. // `--json[=fields]` emits a bare {token, mode, context} object.
func NewCmdToken(f *cmdutil.Factory) *cobra.Command { func NewCmdToken(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "token", Use: "token",
@@ -61,7 +60,7 @@ to see which mode each context uses, and construct the matching HTTP header:
}, },
} }
cmdutil.AddJSONFlags(cmd, authTokenFields) cmdutil.AddJSONFlags(cmd, authTokenFields)
aiclient.SetAgentHelp(cmd, "Prints the active context's credential to stdout for scripting. Default: raw secret, no trailing newline. With --json: envelope {token, mode, context}. Errors: auth.unauthenticated when no active context or no stored credential (run `auth login`); local.keychain_denied when the keyring rejects the read.") aiclient.SetAgentHelp(cmd, "Prints the active context's credential to stdout for scripting. Default: raw secret, no trailing newline. With --json: bare {token, mode, context} object. Errors: auth.unauthenticated when no active context or no stored credential (run `auth login`); local.keychain_denied when the keyring rejects the read.")
return cmd return cmd
} }
@@ -117,9 +116,7 @@ func runToken(f *cmdutil.Factory, jopts *cmdutil.JSONOptions) error {
} }
if jopts.Enabled() { if jopts.Enabled() {
return format.WriteJSONFiltered(iostreams.IO.Out, return jopts.Emit(iostreams.IO.Out, tokenResult{Token: token, Mode: mode, Context: ctxName})
tokenResult{Token: token, Mode: mode, Context: ctxName},
jopts.Fields, jopts.JQ)
} }
// No trailing newline — clean $(weknora auth token) substitution. // No trailing newline — clean $(weknora auth token) substitution.

View File

@@ -9,13 +9,13 @@
// "feels alive" UX a human typing in a terminal expects. // "feels alive" UX a human typing in a terminal expects.
// //
// - Accumulate mode (non-TTY, --no-stream, or --json): buffer every // - Accumulate mode (non-TTY, --no-stream, or --json): buffer every
// fragment via sse.Accumulator and emit a single envelope (or a single // fragment via sse.Accumulator and emit a single JSON object (or a single
// plain-text answer + references block) once Done. Agents and pipes // plain-text answer + references block) once Done. Agents and pipes
// get a deterministic single record to parse. // get a deterministic single record to parse.
// //
// The SDK's KnowledgeQAStream callback contract is invoked sequentially on // The SDK's KnowledgeQAStream callback contract is invoked sequentially on
// one goroutine, so neither mode needs locking. The runChat core takes a // one goroutine, so neither mode needs locking. The runChat core takes a
// chatService interface so tests inject a fake without standing up a real // ChatService interface so tests inject a fake without standing up a real
// SSE server. // SSE server.
package chat package chat
@@ -49,15 +49,15 @@ type Options struct {
NoStream bool NoStream bool
} }
// chatService is the narrow SDK surface this command depends on. *sdk.Client // 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 // satisfies it; tests substitute a fake. Compile-time check is at the bottom
// of this file. // of this file.
type chatService interface { type ChatService interface {
CreateSession(ctx context.Context, req *sdk.CreateSessionRequest) (*sdk.Session, error) CreateSession(ctx context.Context, req *sdk.CreateSessionRequest) (*sdk.Session, error)
KnowledgeQAStream(ctx context.Context, sessionID string, req *sdk.KnowledgeQARequest, cb func(*sdk.StreamResponse) error) error KnowledgeQAStream(ctx context.Context, sessionID string, req *sdk.KnowledgeQARequest, cb func(*sdk.StreamResponse) error) error
} }
// chatData is the success-envelope payload. Mirrors what an agent needs to // chatData is the JSON payload emitted on the --json path. Mirrors what an agent needs to
// continue a conversation: the answer text, retrieval references, and the // continue a conversation: the answer text, retrieval references, and the
// session pointer to thread follow-ups. // session pointer to thread follow-ups.
type chatData struct { type chatData struct {
@@ -116,13 +116,13 @@ Modes:
cmd.Flags().StringVar(&opts.SessionID, "session", "", "Continue an existing chat session (skip auto-create)") cmd.Flags().StringVar(&opts.SessionID, "session", "", "Continue an existing chat session (skip auto-create)")
cmd.Flags().BoolVar(&opts.NoStream, "no-stream", false, "Buffer the full answer before printing (forces accumulate mode)") cmd.Flags().BoolVar(&opts.NoStream, "no-stream", false, "Buffer the full answer before printing (forces accumulate mode)")
cmdutil.AddJSONFlags(cmd, chatFields) cmdutil.AddJSONFlags(cmd, chatFields)
aiclient.SetAgentHelp(cmd, "Streams an LLM answer over SSE. Agent / non-TTY callers should pass --json so the full {answer, references, session_id, assistant_message_id} envelope is emitted at completion (no partial chunks). Pass --session to thread follow-ups. Errors: server.session_create_failed when auto-create fails; local.sse_stream_aborted on mid-stream disconnect.") aiclient.SetAgentHelp(cmd, "Streams an LLM answer over SSE. Agent / non-TTY callers should pass --json so the full {answer, references, session_id, assistant_message_id} object is emitted once at completion (no partial chunks). Pass --session to thread follow-ups. Errors: server.session_create_failed when auto-create fails; local.sse_stream_aborted on mid-stream disconnect.")
return cmd return cmd
} }
// runChat is the testable core: validate, ensure a session, dispatch the // runChat is the testable core: validate, ensure a session, dispatch the
// stream, and route output. Returns a typed error suitable for the envelope. // stream, and route output. Returns a typed error.
func runChat(ctx context.Context, opts *Options, jopts *cmdutil.JSONOptions, svc chatService) error { func runChat(ctx context.Context, opts *Options, jopts *cmdutil.JSONOptions, svc ChatService) error {
if opts.Query == "" { if opts.Query == "" {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "query argument cannot be empty") return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "query argument cannot be empty")
} }
@@ -157,12 +157,12 @@ func runChat(ctx context.Context, opts *Options, jopts *cmdutil.JSONOptions, svc
// Decide output mode. Stream mode requires: // Decide output mode. Stream mode requires:
// 1. an interactive stdout (tty) // 1. an interactive stdout (tty)
// 2. no --no-stream // 2. no --no-stream
// 3. no --json (envelope is single-record by definition) // 3. no --json (JSON output is single-record by definition)
streamMode := iostreams.IO.IsStdoutTTY() && !opts.NoStream && !jsonOut streamMode := iostreams.IO.IsStdoutTTY() && !opts.NoStream && !jsonOut
// Surface the auto-created session ID up-front so a user who hits ^C // 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 // mid-stream still has the pointer to resume — no need to scroll back
// past tokens. Skipped in JSON mode (it ends up in the envelope) and // past tokens. Skipped in JSON mode (it ends up in the data object) and
// when the caller already supplied --session. // when the caller already supplied --session.
if autoCreated && !jsonOut { if autoCreated && !jsonOut {
fmt.Fprintf(iostreams.IO.Err, "session: %s (use --session to continue)\n", sessionID) fmt.Fprintf(iostreams.IO.Err, "session: %s (use --session to continue)\n", sessionID)
@@ -193,7 +193,7 @@ func runChat(ctx context.Context, opts *Options, jopts *cmdutil.JSONOptions, svc
// Re-surface the auto-created session id on failure so a user who // Re-surface the auto-created session id on failure so a user who
// missed the start-of-stream notice (it scrolls past mid-stream // missed the start-of-stream notice (it scrolls past mid-stream
// tokens, especially on ^C) can still recover with --session. // tokens, especially on ^C) can still recover with --session.
// Skipped in JSON mode — the envelope carries it in .data.session_id. // Skipped in JSON mode — the data object carries it in .session_id.
if autoCreated && !jsonOut { if autoCreated && !jsonOut {
fmt.Fprintf(iostreams.IO.Err, "session: %s (resume with --session %s)\n", sessionID, sessionID) fmt.Fprintf(iostreams.IO.Err, "session: %s (resume with --session %s)\n", sessionID, sessionID)
} }
@@ -241,7 +241,7 @@ func runChat(ctx context.Context, opts *Options, jopts *cmdutil.JSONOptions, svc
KBID: opts.KBID, KBID: opts.KBID,
Query: opts.Query, Query: opts.Query,
} }
return format.WriteJSONFiltered(iostreams.IO.Out, data, jopts.Fields, jopts.JQ) return jopts.Emit(iostreams.IO.Out, data)
} }
// Human / non-JSON paths: streaming mode already wrote the answer body // Human / non-JSON paths: streaming mode already wrote the answer body
@@ -264,5 +264,5 @@ func runChat(ctx context.Context, opts *Options, jopts *cmdutil.JSONOptions, svc
return nil return nil
} }
// compile-time check: the production SDK client implements chatService. // compile-time check: the production SDK client implements ChatService.
var _ chatService = (*sdk.Client)(nil) var _ ChatService = (*sdk.Client)(nil)

View File

@@ -12,7 +12,7 @@ import (
sdk "github.com/Tencent/WeKnora/client" sdk "github.com/Tencent/WeKnora/client"
) )
// fakeChatService implements chatService for unit tests. Tests configure the // fakeChatService implements ChatService for unit tests. Tests configure the
// callback driver via streamEvents (delivered in order) and observe captured // callback driver via streamEvents (delivered in order) and observe captured
// inputs through the exported fields. // inputs through the exported fields.
type fakeChatService struct { type fakeChatService struct {
@@ -37,7 +37,7 @@ func (f *fakeChatService) CreateSession(_ context.Context, req *sdk.CreateSessio
return f.createSessionResp, nil return f.createSessionResp, nil
} }
// Default: return a deterministic session id derived from the title so // Default: return a deterministic session id derived from the title so
// envelope assertions don't depend on uuid generation. // JSON assertions don't depend on uuid generation.
return &sdk.Session{ID: "sess_auto", Title: req.Title}, nil return &sdk.Session{ID: "sess_auto", Title: req.Title}, nil
} }
@@ -56,9 +56,9 @@ func (f *fakeChatService) KnowledgeQAStream(ctx context.Context, sessionID strin
return f.streamErr return f.streamErr
} }
// Sanity: fakeChatService must satisfy chatService. Mirrors the production // Sanity: fakeChatService must satisfy ChatService. Mirrors the production
// var _ chatService = (*sdk.Client)(nil) check at the bottom of chat.go. // var _ ChatService = (*sdk.Client)(nil) check at the bottom of chat.go.
var _ chatService = (*fakeChatService)(nil) var _ ChatService = (*fakeChatService)(nil)
func TestChat_StreamMode(t *testing.T) { func TestChat_StreamMode(t *testing.T) {
out, errBuf := iostreams.SetForTestWithTTY(t) out, errBuf := iostreams.SetForTestWithTTY(t)
@@ -117,7 +117,7 @@ func TestChat_JSONMode(t *testing.T) {
} }
// JSON mode must NOT print the human session-hint on stderr; the session // JSON mode must NOT print the human session-hint on stderr; the session
// id is carried inside the envelope instead. // id is carried inside the JSON object instead.
if errBuf.Len() != 0 { if errBuf.Len() != 0 {
t.Errorf("expected empty stderr in JSON mode, got %q", errBuf.String()) t.Errorf("expected empty stderr in JSON mode, got %q", errBuf.String())
} }

View File

@@ -9,7 +9,6 @@ import (
"github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/aiclient"
"github.com/Tencent/WeKnora/cli/internal/cmdutil" "github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/config" "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/iostreams"
) )
@@ -102,9 +101,7 @@ func runAdd(opts *AddOptions, jopts *cmdutil.JSONOptions, name string) error {
} }
if jopts.Enabled() { if jopts.Enabled() {
return format.WriteJSONFiltered(iostreams.IO.Out, return jopts.Emit(iostreams.IO.Out, addResult{Name: name, Host: host, User: opts.User, Current: wasFirst})
addResult{Name: name, Host: host, User: opts.User, Current: wasFirst},
jopts.Fields, jopts.JQ)
} }
if wasFirst { if wasFirst {
fmt.Fprintf(iostreams.IO.Out, "✓ Added context %s (now current). Run `weknora auth login --name %s` to attach credentials.\n", name, name) fmt.Fprintf(iostreams.IO.Out, "✓ Added context %s (now current). Run `weknora auth login --name %s` to attach credentials.\n", name, name)

View File

@@ -73,7 +73,7 @@ func runList(jopts *cmdutil.JSONOptions) error {
sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name }) sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name })
if jopts.Enabled() { if jopts.Enabled() {
return format.WriteJSONFiltered(iostreams.IO.Out, entries, jopts.Fields, jopts.JQ) return jopts.Emit(iostreams.IO.Out, entries)
} }
if len(entries) == 0 { if len(entries) == 0 {
fmt.Fprintln(iostreams.IO.Out, "No contexts configured. Run `weknora auth login` (or `weknora context add`) to create one.") fmt.Fprintln(iostreams.IO.Out, "No contexts configured. Run `weknora auth login` (or `weknora context add`) to create one.")

View File

@@ -8,7 +8,6 @@ import (
"github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/aiclient"
"github.com/Tencent/WeKnora/cli/internal/cmdutil" "github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/config" "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/iostreams"
"github.com/Tencent/WeKnora/cli/internal/prompt" "github.com/Tencent/WeKnora/cli/internal/prompt"
"github.com/Tencent/WeKnora/cli/internal/secrets" "github.com/Tencent/WeKnora/cli/internal/secrets"
@@ -80,7 +79,6 @@ func runRemove(opts *RemoveOptions, jopts *cmdutil.JSONOptions, name string, sto
return notFoundError(name, cfg) return notFoundError(name, cfg)
} }
wasCurrent := name == cfg.CurrentContext wasCurrent := name == cfg.CurrentContext
risk := riskForRemove(name, wasCurrent)
jsonOut := jopts.Enabled() jsonOut := jopts.Enabled()
// Confirmation only fires for removing the current context — non-current // Confirmation only fires for removing the current context — non-current
@@ -102,10 +100,9 @@ func runRemove(opts *RemoveOptions, jopts *cmdutil.JSONOptions, name string, sto
} }
clearContextSecrets(store, ctx, name) clearContextSecrets(store, ctx, name)
_ = risk // risk classification dropped in v0.4; exit code already signals
result := removeResult{Name: name, Removed: true, WasCurrent: wasCurrent} result := removeResult{Name: name, Removed: true, WasCurrent: wasCurrent}
if jsonOut { if jsonOut {
return format.WriteJSONFiltered(iostreams.IO.Out, result, jopts.Fields, jopts.JQ) return jopts.Emit(iostreams.IO.Out, result)
} }
if wasCurrent { if wasCurrent {
fmt.Fprintf(iostreams.IO.Out, "✓ Removed context %s (current context cleared — run `weknora context use <name>` to pick another)\n", name) fmt.Fprintf(iostreams.IO.Out, "✓ Removed context %s (current context cleared — run `weknora context use <name>` to pick another)\n", name)
@@ -115,19 +112,6 @@ func runRemove(opts *RemoveOptions, jopts *cmdutil.JSONOptions, name string, sto
return nil return nil
} }
// riskForRemove returns the operation risk: high-risk-write only when the
// target is the currently-active context (subsequent commands will have no
// default --context until the user picks one).
func riskForRemove(name string, wasCurrent bool) *format.Risk {
if wasCurrent {
return &format.Risk{
Level: format.RiskHighRiskWrite,
Action: fmt.Sprintf("remove context %s (the current context — subsequent commands will need a new --context)", name),
}
}
return &format.Risk{Level: format.RiskWrite, Action: fmt.Sprintf("remove context %s", name)}
}
// clearContextSecrets mirrors auth/logout.go: best-effort delete every secret // clearContextSecrets mirrors auth/logout.go: best-effort delete every secret
// slot the context references. Errors are swallowed so a missing keyring // slot the context references. Errors are swallowed so a missing keyring
// entry doesn't block remove (logout has had the same policy since v0.2). // entry doesn't block remove (logout has had the same policy since v0.2).

View File

@@ -9,10 +9,13 @@ import (
"github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/aiclient"
"github.com/Tencent/WeKnora/cli/internal/cmdutil" "github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/config" "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/iostreams"
) )
// contextUseFields enumerates fields surfaced for `--json` discovery on
// `context use`.
var contextUseFields = []string{"current_context", "previous_context"}
// NewCmdUse builds the `weknora context use <name>` command. // NewCmdUse builds the `weknora context use <name>` command.
func NewCmdUse(f *cmdutil.Factory) *cobra.Command { func NewCmdUse(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
@@ -29,13 +32,18 @@ you to. Context selection is a user preference; one-shot overrides should use
the global --context flag instead, which writes nothing to disk.`, the global --context flag instead, which writes nothing to disk.`,
Example: ` weknora context use staging # persist switch Example: ` weknora context use staging # persist switch
weknora --context staging kb list # one-shot override (no disk write) weknora --context staging kb list # one-shot override (no disk write)
weknora context use --help # this help`, weknora context use staging --json # {current_context, previous_context}`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error { RunE: func(c *cobra.Command, args []string) error {
return runUse(args[0]) jopts, err := cmdutil.CheckJSONFlags(c)
if err != nil {
return err
}
return runUse(args[0], jopts)
}, },
} }
aiclient.SetAgentHelp(cmd, "Switches default CLI context. Returns previous_context + current_context. Errors with hint when name unknown.") cmdutil.AddJSONFlags(cmd, contextUseFields)
aiclient.SetAgentHelp(cmd, "Switches default CLI context. With --json: returns {current_context, previous_context}. Errors with local.context_not_found and a did-you-mean hint when name unknown.")
return cmd return cmd
} }
@@ -44,7 +52,7 @@ type useResult struct {
PreviousContext string `json:"previous_context,omitempty"` PreviousContext string `json:"previous_context,omitempty"`
} }
func runUse(name string) error { func runUse(name string, jopts *cmdutil.JSONOptions) error {
cfg, err := config.Load() cfg, err := config.Load()
if err != nil { if err != nil {
return err return err
@@ -57,10 +65,16 @@ func runUse(name string) error {
if err := config.Save(cfg); err != nil { if err := config.Save(cfg); err != nil {
return err return err
} }
return format.WriteJSON(iostreams.IO.Out, useResult{ result := useResult{CurrentContext: name, PreviousContext: prev}
CurrentContext: name, if jopts.Enabled() {
PreviousContext: prev, return jopts.Emit(iostreams.IO.Out, result)
}) }
if prev != "" && prev != name {
fmt.Fprintf(iostreams.IO.Out, "✓ Switched context to %s (was %s)\n", name, prev)
} else {
fmt.Fprintf(iostreams.IO.Out, "✓ Active context: %s\n", name)
}
return nil
} }
func notFoundError(name string, cfg *config.Config) error { func notFoundError(name string, cfg *config.Config) error {

View File

@@ -24,7 +24,7 @@ func TestUse_OK(t *testing.T) {
t.Fatalf("Save initial config: %v", err) t.Fatalf("Save initial config: %v", err)
} }
if err := runUse("production"); err != nil { if err := runUse("production", nil); err != nil {
t.Fatalf("runUse: %v", err) t.Fatalf("runUse: %v", err)
} }
@@ -52,7 +52,7 @@ func TestUse_NotFound_WithDidYouMean(t *testing.T) {
t.Fatalf("Save: %v", err) t.Fatalf("Save: %v", err)
} }
err := runUse("prodution") // typo: missing 'c' err := runUse("prodution", nil) // typo: missing 'c'
if err == nil { if err == nil {
t.Fatal("expected error") t.Fatal("expected error")
} }
@@ -84,7 +84,7 @@ func TestUse_NotFound_DeterministicTieBreak(t *testing.T) {
} }
// "prox" is distance 1 from prod / prom (both win); lex tie-break → prod. // "prox" is distance 1 from prod / prom (both win); lex tie-break → prod.
for i := 0; i < 5; i++ { for i := 0; i < 5; i++ {
err := runUse("prox") err := runUse("prox", nil)
if err == nil { if err == nil {
t.Fatalf("iter %d: expected error", i) t.Fatalf("iter %d: expected error", i)
} }
@@ -99,7 +99,7 @@ func TestUse_NotFound_EmptyContexts(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir()) t.Setenv("XDG_CONFIG_HOME", t.TempDir())
_, _ = iostreams.SetForTest(t) _, _ = iostreams.SetForTest(t)
err := runUse("anything") err := runUse("anything", nil)
if err == nil { if err == nil {
t.Fatal("expected error") t.Fatal("expected error")
} }
@@ -118,7 +118,7 @@ func TestUse_CaseSensitive(t *testing.T) {
}} }}
_ = config.Save(cfg) _ = config.Save(cfg)
err := runUse("production") // lowercase — must NOT match "Production" err := runUse("production", nil) // lowercase — must NOT match "Production"
if err == nil { if err == nil {
t.Fatal("expected case-sensitive miss") t.Fatal("expected case-sensitive miss")
} }

View File

@@ -8,7 +8,6 @@ import (
"github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/aiclient"
"github.com/Tencent/WeKnora/cli/internal/cmdutil" "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/iostreams"
"github.com/Tencent/WeKnora/cli/internal/prompt" "github.com/Tencent/WeKnora/cli/internal/prompt"
) )
@@ -44,12 +43,12 @@ func NewCmdDelete(f *cmdutil.Factory) *cobra.Command {
when stdout is a TTY and --json is not set; pass -y/--yes (global flag) to skip when stdout is a TTY and --json is not set; pass -y/--yes (global flag) to skip
the prompt (required in agent / CI / piped contexts). the prompt (required in agent / CI / piped contexts).
AI agents: This is a high-risk write. Without -y/--yes the CLI exits 10 and AI agents: This is a high-risk write. Without -y/--yes the CLI exits 10
returns an envelope describing the missing confirmation. NEVER auto-pass -y and writes input.confirmation_required to stderr. NEVER auto-pass -y
without the user's explicit go-ahead.`, without the user's explicit go-ahead.`,
Example: ` weknora doc delete doc_abc # interactive confirm Example: ` weknora doc delete doc_abc # interactive confirm
weknora doc delete doc_abc -y # no prompt weknora doc delete doc_abc -y # no prompt
weknora doc delete doc_abc -y --json # envelope output`, weknora doc delete doc_abc -y --json # bare {id, deleted:true} JSON`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error { RunE: func(c *cobra.Command, args []string) error {
jopts, err := cmdutil.CheckJSONFlags(c) jopts, err := cmdutil.CheckJSONFlags(c)
@@ -79,7 +78,7 @@ func runDelete(ctx context.Context, opts *DeleteOptions, jopts *cmdutil.JSONOpti
} }
if jopts.Enabled() { if jopts.Enabled() {
return format.WriteJSONFiltered(iostreams.IO.Out, deleteResult{ID: id, Deleted: true}, jopts.Fields, jopts.JQ) return jopts.Emit(iostreams.IO.Out, deleteResult{ID: id, Deleted: true})
} }
fmt.Fprintf(iostreams.IO.Out, "✓ Deleted document %s\n", id) fmt.Fprintf(iostreams.IO.Out, "✓ Deleted document %s\n", id)
return nil return nil

View File

@@ -11,6 +11,7 @@ import (
"github.com/Tencent/WeKnora/cli/internal/cmdutil" "github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams" "github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/testutil"
) )
// fakeDeleteSvc captures the id passed and returns a canned error. // fakeDeleteSvc captures the id passed and returns a canned error.
@@ -26,30 +27,13 @@ func (f *fakeDeleteSvc) DeleteKnowledge(_ context.Context, id string) error {
return f.err return f.err
} }
// scriptedConfirm satisfies prompt.Prompter and returns predetermined answers.
type scriptedConfirm struct{ confirmReturn bool }
func (s scriptedConfirm) Input(string, string) (string, error) { return "", nil }
func (s scriptedConfirm) Password(string) (string, error) { return "", nil }
func (s scriptedConfirm) Confirm(string, bool) (bool, error) { return s.confirmReturn, nil }
// errPrompter returns an error from Confirm — simulates a non-TTY agent
// prompter.
type errPrompter struct{}
func (errPrompter) Input(string, string) (string, error) { return "", nil }
func (errPrompter) Password(string) (string, error) { return "", nil }
func (errPrompter) Confirm(string, bool) (bool, error) {
return false, errors.New("no tty")
}
func TestDelete_Success_WithForce(t *testing.T) { func TestDelete_Success_WithForce(t *testing.T) {
out, _ := iostreams.SetForTest(t) out, _ := iostreams.SetForTest(t)
svc := &fakeDeleteSvc{} svc := &fakeDeleteSvc{}
opts := &DeleteOptions{Yes: true} opts := &DeleteOptions{Yes: true}
// Force=true short-circuits the confirm path; the prompter must not be // Force=true short-circuits the confirm path; the prompter must not be
// consulted, so any value works. // consulted, so any value works.
require.NoError(t, runDelete(context.Background(), opts, nil, svc, scriptedConfirm{confirmReturn: false}, "doc_abc")) require.NoError(t, runDelete(context.Background(), opts, nil, svc, &testutil.ConfirmPrompter{Answer: false}, "doc_abc"))
assert.Equal(t, "doc_abc", svc.got) assert.Equal(t, "doc_abc", svc.got)
assert.Equal(t, 1, svc.calls) assert.Equal(t, 1, svc.calls)
@@ -61,7 +45,7 @@ func TestDelete_Success_JSON(t *testing.T) {
out, _ := iostreams.SetForTest(t) out, _ := iostreams.SetForTest(t)
svc := &fakeDeleteSvc{} svc := &fakeDeleteSvc{}
opts := &DeleteOptions{Yes: true} opts := &DeleteOptions{Yes: true}
require.NoError(t, runDelete(context.Background(), opts, &cmdutil.JSONOptions{}, svc, scriptedConfirm{confirmReturn: true}, "doc_abc")) require.NoError(t, runDelete(context.Background(), opts, &cmdutil.JSONOptions{}, svc, &testutil.ConfirmPrompter{Answer: true}, "doc_abc"))
got := out.String() got := out.String()
assert.True(t, strings.HasPrefix(strings.TrimSpace(got), `{"id":"doc_abc"`), "expected bare object; got %q", got) assert.True(t, strings.HasPrefix(strings.TrimSpace(got), `{"id":"doc_abc"`), "expected bare object; got %q", got)
@@ -72,7 +56,7 @@ func TestDelete_Success_JSON(t *testing.T) {
func TestDelete_NotFound_404(t *testing.T) { func TestDelete_NotFound_404(t *testing.T) {
_, _ = iostreams.SetForTest(t) _, _ = iostreams.SetForTest(t)
svc := &fakeDeleteSvc{err: errors.New("HTTP error 404: not found")} svc := &fakeDeleteSvc{err: errors.New("HTTP error 404: not found")}
err := runDelete(context.Background(), &DeleteOptions{Yes: true}, nil, svc, scriptedConfirm{}, "doc_missing") err := runDelete(context.Background(), &DeleteOptions{Yes: true}, nil, svc, &testutil.ConfirmPrompter{}, "doc_missing")
require.Error(t, err) require.Error(t, err)
var typed *cmdutil.Error var typed *cmdutil.Error
@@ -83,7 +67,7 @@ func TestDelete_NotFound_404(t *testing.T) {
func TestDelete_HTTPError_500(t *testing.T) { func TestDelete_HTTPError_500(t *testing.T) {
_, _ = iostreams.SetForTest(t) _, _ = iostreams.SetForTest(t)
svc := &fakeDeleteSvc{err: errors.New("HTTP error 500: internal")} svc := &fakeDeleteSvc{err: errors.New("HTTP error 500: internal")}
err := runDelete(context.Background(), &DeleteOptions{Yes: true}, nil, svc, scriptedConfirm{}, "doc_x") err := runDelete(context.Background(), &DeleteOptions{Yes: true}, nil, svc, &testutil.ConfirmPrompter{}, "doc_x")
require.Error(t, err) require.Error(t, err)
var typed *cmdutil.Error var typed *cmdutil.Error
@@ -94,7 +78,7 @@ func TestDelete_HTTPError_500(t *testing.T) {
func TestDelete_ConfirmYes(t *testing.T) { func TestDelete_ConfirmYes(t *testing.T) {
out, _ := iostreams.SetForTestWithTTY(t) out, _ := iostreams.SetForTestWithTTY(t)
svc := &fakeDeleteSvc{} svc := &fakeDeleteSvc{}
err := runDelete(context.Background(), &DeleteOptions{Yes: false}, nil, svc, scriptedConfirm{confirmReturn: true}, "doc_abc") err := runDelete(context.Background(), &DeleteOptions{Yes: false}, nil, svc, &testutil.ConfirmPrompter{Answer: true}, "doc_abc")
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, 1, svc.calls, "user said yes ⇒ delete proceeds") assert.Equal(t, 1, svc.calls, "user said yes ⇒ delete proceeds")
assert.Contains(t, out.String(), "✓") assert.Contains(t, out.String(), "✓")
@@ -103,7 +87,7 @@ func TestDelete_ConfirmYes(t *testing.T) {
func TestDelete_ConfirmNo(t *testing.T) { func TestDelete_ConfirmNo(t *testing.T) {
_, errBuf := iostreams.SetForTestWithTTY(t) _, errBuf := iostreams.SetForTestWithTTY(t)
svc := &fakeDeleteSvc{} svc := &fakeDeleteSvc{}
err := runDelete(context.Background(), &DeleteOptions{Yes: false}, nil, svc, scriptedConfirm{confirmReturn: false}, "doc_abc") err := runDelete(context.Background(), &DeleteOptions{Yes: false}, nil, svc, &testutil.ConfirmPrompter{Answer: false}, "doc_abc")
require.Error(t, err) require.Error(t, err)
assert.Equal(t, 0, svc.calls, "user said no ⇒ SDK must NOT be called") assert.Equal(t, 0, svc.calls, "user said no ⇒ SDK must NOT be called")
@@ -119,7 +103,7 @@ func TestDelete_ConfirmNo(t *testing.T) {
func TestDelete_AgentPrompterErrors(t *testing.T) { func TestDelete_AgentPrompterErrors(t *testing.T) {
_, _ = iostreams.SetForTestWithTTY(t) _, _ = iostreams.SetForTestWithTTY(t)
svc := &fakeDeleteSvc{} svc := &fakeDeleteSvc{}
err := runDelete(context.Background(), &DeleteOptions{Yes: false}, nil, svc, errPrompter{}, "doc_abc") err := runDelete(context.Background(), &DeleteOptions{Yes: false}, nil, svc, &testutil.ConfirmPrompter{Err: errors.New("no tty")}, "doc_abc")
require.Error(t, err) require.Error(t, err)
assert.Equal(t, 0, svc.calls) assert.Equal(t, 0, svc.calls)
@@ -135,7 +119,7 @@ func TestDelete_AgentPrompterErrors(t *testing.T) {
func TestDelete_NoYes_NonTTY_RequiresConfirmation(t *testing.T) { func TestDelete_NoYes_NonTTY_RequiresConfirmation(t *testing.T) {
_, _ = iostreams.SetForTest(t) _, _ = iostreams.SetForTest(t)
svc := &fakeDeleteSvc{} svc := &fakeDeleteSvc{}
err := runDelete(context.Background(), &DeleteOptions{Yes: false}, nil, svc, errPrompter{}, "doc_abc") err := runDelete(context.Background(), &DeleteOptions{Yes: false}, nil, svc, &testutil.ConfirmPrompter{Err: errors.New("no tty")}, "doc_abc")
require.Error(t, err) require.Error(t, err)
var typed *cmdutil.Error var typed *cmdutil.Error
require.ErrorAs(t, err, &typed) require.ErrorAs(t, err, &typed)

View File

@@ -13,7 +13,6 @@ import (
"github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/aiclient"
"github.com/Tencent/WeKnora/cli/internal/cmdutil" "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/iostreams"
"github.com/Tencent/WeKnora/cli/internal/text" "github.com/Tencent/WeKnora/cli/internal/text"
sdk "github.com/Tencent/WeKnora/client" sdk "github.com/Tencent/WeKnora/client"
@@ -123,19 +122,14 @@ func runList(ctx context.Context, opts *ListOptions, jopts *cmdutil.JSONOptions,
// Pagination is always 1-indexed internally. --all-pages walks; the // Pagination is always 1-indexed internally. --all-pages walks; the
// non-walking path returns the first page only. // non-walking path returns the first page only.
var ( var items []sdk.Knowledge
items []sdk.Knowledge
total int64
)
if opts.AllPages { if opts.AllPages {
accum := make([]sdk.Knowledge, 0) accum := make([]sdk.Knowledge, 0)
page := 1 for page := 1; ; page++ {
for { chunk, total, err := svc.ListKnowledgeWithFilter(ctx, kbID, page, opts.PageSize, filter)
chunk, t, err := svc.ListKnowledgeWithFilter(ctx, kbID, page, opts.PageSize, filter)
if err != nil { if err != nil {
return cmdutil.WrapHTTP(err, "list documents") return cmdutil.WrapHTTP(err, "list documents")
} }
total = t
accum = append(accum, chunk...) accum = append(accum, chunk...)
if opts.Limit > 0 && len(accum) >= opts.Limit { if opts.Limit > 0 && len(accum) >= opts.Limit {
accum = accum[:opts.Limit] accum = accum[:opts.Limit]
@@ -144,16 +138,14 @@ func runList(ctx context.Context, opts *ListOptions, jopts *cmdutil.JSONOptions,
if int64(page*opts.PageSize) >= total || len(chunk) == 0 { if int64(page*opts.PageSize) >= total || len(chunk) == 0 {
break break
} }
page++
} }
items = accum items = accum
} else { } else {
chunk, t, err := svc.ListKnowledgeWithFilter(ctx, kbID, 1, opts.PageSize, filter) chunk, _, err := svc.ListKnowledgeWithFilter(ctx, kbID, 1, opts.PageSize, filter)
if err != nil { if err != nil {
return cmdutil.WrapHTTP(err, "list documents") return cmdutil.WrapHTTP(err, "list documents")
} }
items = chunk items = chunk
total = t
} }
if items == nil { if items == nil {
items = []sdk.Knowledge{} // ensure JSON [] not null items = []sdk.Knowledge{} // ensure JSON [] not null
@@ -169,10 +161,9 @@ func runList(ctx context.Context, opts *ListOptions, jopts *cmdutil.JSONOptions,
if opts.Limit > 0 && len(items) > opts.Limit { if opts.Limit > 0 && len(items) > opts.Limit {
items = items[:opts.Limit] items = items[:opts.Limit]
} }
_ = total // pagination metadata is no longer surfaced; --all-pages drains for callers who need everything
if jopts.Enabled() { if jopts.Enabled() {
return format.WriteJSONFiltered(iostreams.IO.Out, items, jopts.Fields, jopts.JQ) return jopts.Emit(iostreams.IO.Out, items)
} }
if len(items) == 0 { if len(items) == 0 {

View File

@@ -10,7 +10,6 @@ import (
"github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/aiclient"
"github.com/Tencent/WeKnora/cli/internal/cmdutil" "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/iostreams"
sdk "github.com/Tencent/WeKnora/client" sdk "github.com/Tencent/WeKnora/client"
) )
@@ -165,17 +164,17 @@ func runUploadFromURL(ctx context.Context, opts *UploadOptions, jopts *cmdutil.J
return cmdutil.WrapHTTP(err, "ingest URL %s", opts.FromURL) return cmdutil.WrapHTTP(err, "ingest URL %s", opts.FromURL)
} }
return printUploadSuccess(k, jopts, "Ingested", opts.Name, opts.FromURL) return renderUploadSuccess(k, jopts, "Ingested", opts.Name, opts.FromURL)
} }
// printUploadSuccess emits the post-upload result. JSON path is the bare // renderUploadSuccess emits the post-upload result. JSON path is the bare
// Knowledge object; human path prints a checkmark line. Shared by single- // Knowledge object; human path prints a checkmark line. Shared by single-
// file upload and URL ingest; humanVerb varies (uploaded/ingested) and // file upload and URL ingest; humanVerb varies (uploaded/ingested) and
// fallbackDisplay covers the case when the server-recorded file_name is // fallbackDisplay covers the case when the server-recorded file_name is
// blank (URL ingest pre-redirect). // blank (URL ingest pre-redirect).
func printUploadSuccess(k *sdk.Knowledge, jopts *cmdutil.JSONOptions, humanVerb, customName, fallbackDisplay string) error { func renderUploadSuccess(k *sdk.Knowledge, jopts *cmdutil.JSONOptions, humanVerb, customName, fallbackDisplay string) error {
if jopts.Enabled() { if jopts.Enabled() {
return format.WriteJSONFiltered(iostreams.IO.Out, k, jopts.Fields, jopts.JQ) return jopts.Emit(iostreams.IO.Out, k)
} }
displayed := customName displayed := customName
if displayed == "" { if displayed == "" {
@@ -214,5 +213,5 @@ func runUpload(ctx context.Context, opts *UploadOptions, jopts *cmdutil.JSONOpti
if err != nil { if err != nil {
return cmdutil.WrapHTTP(err, "upload %s", path) return cmdutil.WrapHTTP(err, "upload %s", path)
} }
return printUploadSuccess(k, jopts, "Uploaded", opts.Name, path) return renderUploadSuccess(k, jopts, "Uploaded", opts.Name, path)
} }

View File

@@ -8,7 +8,6 @@ import (
"path/filepath" "path/filepath"
"github.com/Tencent/WeKnora/cli/internal/cmdutil" "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/iostreams"
) )
@@ -63,7 +62,7 @@ func runUploadRecursive(ctx context.Context, opts *UploadOptions, jopts *cmdutil
} }
if len(matches) == 0 { if len(matches) == 0 {
if jopts.Enabled() { if jopts.Enabled() {
return format.WriteJSONFiltered(iostreams.IO.Out, recursiveResult{KBID: kbID}, jopts.Fields, jopts.JQ) return jopts.Emit(iostreams.IO.Out, recursiveResult{KBID: kbID})
} }
fmt.Fprintf(iostreams.IO.Out, "(no files matched %q under %s)\n", opts.Glob, dir) fmt.Fprintf(iostreams.IO.Out, "(no files matched %q under %s)\n", opts.Glob, dir)
return nil return nil
@@ -80,7 +79,7 @@ func runUploadRecursive(ctx context.Context, opts *UploadOptions, jopts *cmdutil
} }
failed = append(failed, uploadOutcome{Path: p, Error: err.Error()}) failed = append(failed, uploadOutcome{Path: p, Error: err.Error()})
// Per-file progress lines are human progress signal; suppress // Per-file progress lines are human progress signal; suppress
// under --json so they don't precede the envelope on stdout. // under --json so they don't precede the JSON object on stdout.
if !jopts.Enabled() { if !jopts.Enabled() {
fmt.Fprintf(iostreams.IO.Out, "FAIL %s: %v\n", filepath.Base(p), err) fmt.Fprintf(iostreams.IO.Out, "FAIL %s: %v\n", filepath.Base(p), err)
} }
@@ -98,7 +97,7 @@ func runUploadRecursive(ctx context.Context, opts *UploadOptions, jopts *cmdutil
if jopts.Enabled() { if jopts.Enabled() {
result := recursiveResult{KBID: kbID, Uploaded: uploaded, Failed: failed} result := recursiveResult{KBID: kbID, Uploaded: uploaded, Failed: failed}
if err := format.WriteJSONFiltered(iostreams.IO.Out, result, jopts.Fields, jopts.JQ); err != nil { if err := jopts.Emit(iostreams.IO.Out, result); err != nil {
return err return err
} }
} else { } else {

View File

@@ -8,7 +8,6 @@ import (
"github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/aiclient"
"github.com/Tencent/WeKnora/cli/internal/cmdutil" "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/iostreams"
"github.com/Tencent/WeKnora/cli/internal/text" "github.com/Tencent/WeKnora/cli/internal/text"
sdk "github.com/Tencent/WeKnora/client" sdk "github.com/Tencent/WeKnora/client"
@@ -63,7 +62,7 @@ func runView(ctx context.Context, opts *ViewOptions, jopts *cmdutil.JSONOptions,
return cmdutil.WrapHTTP(err, "get document %q", id) return cmdutil.WrapHTTP(err, "get document %q", id)
} }
if jopts.Enabled() { if jopts.Enabled() {
return format.WriteJSONFiltered(iostreams.IO.Out, doc, jopts.Fields, jopts.JQ) return jopts.Emit(iostreams.IO.Out, doc)
} }
w := iostreams.IO.Out w := iostreams.IO.Out
fmt.Fprintf(w, "ID: %s\n", doc.ID) fmt.Fprintf(w, "ID: %s\n", doc.ID)

View File

@@ -32,7 +32,6 @@ import (
"github.com/Tencent/WeKnora/cli/internal/build" "github.com/Tencent/WeKnora/cli/internal/build"
"github.com/Tencent/WeKnora/cli/internal/cmdutil" "github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/compat" "github.com/Tencent/WeKnora/cli/internal/compat"
"github.com/Tencent/WeKnora/cli/internal/format"
"github.com/Tencent/WeKnora/cli/internal/iostreams" "github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/secrets" "github.com/Tencent/WeKnora/cli/internal/secrets"
sdk "github.com/Tencent/WeKnora/client" sdk "github.com/Tencent/WeKnora/client"
@@ -207,7 +206,7 @@ func runChecks(ctx context.Context, opts *Options, svc Services, cliVer string)
// the loader knows authoritatively which branch it took, time-based // the loader knows authoritatively which branch it took, time-based
// derivation from ProbedAt is unreliable since SaveCache uses time.Now(). // derivation from ProbedAt is unreliable since SaveCache uses time.Now().
// //
// v0.2 mapping: // Mapping:
// //
// compat.OK → StatusOK // compat.OK → StatusOK
// compat.SoftWarn → StatusWarn (server older but in-range; soft skew) // compat.SoftWarn → StatusWarn (server older but in-range; soft skew)
@@ -336,7 +335,7 @@ func summarize(cs []Check) Summary {
// code, set by the caller). // code, set by the caller).
func emit(jopts *cmdutil.JSONOptions, r Result) { func emit(jopts *cmdutil.JSONOptions, r Result) {
if jopts.Enabled() { if jopts.Enabled() {
_ = format.WriteJSONFiltered(iostreams.IO.Out, r, jopts.Fields, jopts.JQ) _ = jopts.Emit(iostreams.IO.Out, r)
return return
} }
for _, c := range r.Checks { for _, c := range r.Checks {

View File

@@ -236,11 +236,12 @@ func TestDoctor_NoCache_BypassesCache(t *testing.T) {
} }
} }
// TestDoctor_VersionSkewWarns covers the v0.2 soft-skew path: server is older // TestDoctor_VersionSkewWarns covers the soft-skew path: server is older
// than CLI by ≥ 1 minor (same major, in compat range) → server_version=warn, // than CLI by ≥ 1 minor (same major, in compat range) → server_version=warn,
// envelope.ok stays true, all_passed=false (so agents reading just the // summary.failed=0 (exit 0), summary.all_passed=false (so agents reading
// boolean still notice). The compat decision lives in cli/internal/compat; // just the boolean still notice). The compat decision lives in
// this test pins the doctor-side mapping (compat.SoftWarn → StatusWarn). // cli/internal/compat; this test pins the doctor-side mapping
// (compat.SoftWarn → StatusWarn).
func TestDoctor_VersionSkewWarns(t *testing.T) { func TestDoctor_VersionSkewWarns(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir()) t.Setenv("XDG_CACHE_HOME", t.TempDir())
_, _ = iostreams.SetForTest(t) _, _ = iostreams.SetForTest(t)
@@ -315,7 +316,7 @@ func TestDoctor_HardErrorStillFails(t *testing.T) {
} }
} }
// TestDoctor_KeychainFallbackWarns covers credential_storage's v0.2 third // TestDoctor_KeychainFallbackWarns covers credential_storage's third
// state: keyring unavailable, fell back to FileStore (agent containers, // state: keyring unavailable, fell back to FileStore (agent containers,
// headless CI, WSL without DBus). The check should warn — secrets still // headless CI, WSL without DBus). The check should warn — secrets still
// persist (0600 file perms) but the OS-backed path was unreachable. // persist (0600 file perms) but the OS-backed path was unreachable.
@@ -439,7 +440,7 @@ func TestDoctor_HumanMarker_Warn(t *testing.T) {
// TestDoctor_WarnedField_OmittedAtZero protects the JSON wire compactness: // TestDoctor_WarnedField_OmittedAtZero protects the JSON wire compactness:
// `warned` carries omitempty, so a clean run has no warned key. Existing // `warned` carries omitempty, so a clean run has no warned key. Existing
// agents inspecting older envelopes shouldn't see a sudden new field unless // agents inspecting older outputs shouldn't see a sudden new field unless
// it actually fired. // it actually fired.
func TestDoctor_WarnedField_OmittedAtZero(t *testing.T) { func TestDoctor_WarnedField_OmittedAtZero(t *testing.T) {
out, _ := iostreams.SetForTest(t) out, _ := iostreams.SetForTest(t)
@@ -455,14 +456,14 @@ func TestDoctor_WarnedField_OmittedAtZero(t *testing.T) {
emit(&cmdutil.JSONOptions{}, r) emit(&cmdutil.JSONOptions{}, r)
got := out.String() got := out.String()
if strings.Contains(got, `"warned"`) { if strings.Contains(got, `"warned"`) {
t.Errorf("envelope should omit `warned` field when zero, got %q", got) t.Errorf("output should omit `warned` field when zero, got %q", got)
} }
} }
// TestDoctor_RunE_FailReturnsSilentError is a behavior test on NewCmd: when // TestDoctor_RunE_FailReturnsSilentError is a behavior test on NewCmd:
// any check is fail, RunE must return cmdutil.SilentError so the framework // when any check is fail, RunE must return cmdutil.SilentError so the
// exit-1 path runs WITHOUT overwriting the data envelope emit() already // framework exit-1 path runs without writing a second error line on top
// wrote. This is the v0.2 contract change from v0.1's "always nil". // of the data object emit() already wrote.
// //
// SilenceErrors/SilenceUsage on the leaf cobra.Command suppress cobra's own // SilenceErrors/SilenceUsage on the leaf cobra.Command suppress cobra's own
// "Error: ..." + usage dump that would otherwise leak to stderr when running // "Error: ..." + usage dump that would otherwise leak to stderr when running

View File

@@ -9,7 +9,6 @@ import (
"github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/aiclient"
"github.com/Tencent/WeKnora/cli/internal/cmdutil" "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/iostreams"
sdk "github.com/Tencent/WeKnora/client" sdk "github.com/Tencent/WeKnora/client"
) )
@@ -62,13 +61,15 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command {
cmd.Flags().StringVar(&opts.Description, "description", "", "Knowledge base description (optional)") cmd.Flags().StringVar(&opts.Description, "description", "", "Knowledge base description (optional)")
cmd.Flags().StringVar(&opts.EmbeddingModel, "embedding-model", "", "Embedding model ID (optional; server picks default when unset)") cmd.Flags().StringVar(&opts.EmbeddingModel, "embedding-model", "", "Embedding model ID (optional; server picks default when unset)")
cmdutil.AddJSONFlags(cmd, kbCreateFields) cmdutil.AddJSONFlags(cmd, kbCreateFields)
aiclient.SetAgentHelp(cmd, "Creates a knowledge base under the active context. --name is required; --description and --embedding-model are optional. Returns data: full KnowledgeBase object including the new id.") _ = cmd.MarkFlagRequired("name")
aiclient.SetAgentHelp(cmd, "Creates a knowledge base under the active context. --name is required; --description and --embedding-model are optional. Returns the full KnowledgeBase object including the new id.")
return cmd return cmd
} }
func runCreate(ctx context.Context, opts *CreateOptions, jopts *cmdutil.JSONOptions, svc CreateService) error { func runCreate(ctx context.Context, opts *CreateOptions, jopts *cmdutil.JSONOptions, svc CreateService) error {
// Validate locally before any HTTP — keeps `input.invalid_argument` // Trim defensively in case a caller invokes runCreate directly with
// distinct from a server-side 400. // whitespace; the cobra layer marks --name required so the empty-string
// case is unreachable from the CLI.
if strings.TrimSpace(opts.Name) == "" { if strings.TrimSpace(opts.Name) == "" {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "--name is required") return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "--name is required")
} }
@@ -87,7 +88,7 @@ func runCreate(ctx context.Context, opts *CreateOptions, jopts *cmdutil.JSONOpti
} }
if jopts.Enabled() { if jopts.Enabled() {
return format.WriteJSONFiltered(iostreams.IO.Out, created, jopts.Fields, jopts.JQ) return jopts.Emit(iostreams.IO.Out, created)
} }
fmt.Fprintf(iostreams.IO.Out, "✓ Created knowledge base %q (id: %s)\n", created.Name, created.ID) fmt.Fprintf(iostreams.IO.Out, "✓ Created knowledge base %q (id: %s)\n", created.Name, created.ID)
return nil return nil

View File

@@ -8,7 +8,6 @@ import (
"github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/aiclient"
"github.com/Tencent/WeKnora/cli/internal/cmdutil" "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/iostreams"
"github.com/Tencent/WeKnora/cli/internal/prompt" "github.com/Tencent/WeKnora/cli/internal/prompt"
) )
@@ -46,13 +45,13 @@ func NewCmdDelete(f *cmdutil.Factory) *cobra.Command {
Prompts for confirmation by default when stdout is a TTY and --json is not set. Prompts for confirmation by default when stdout is a TTY and --json is not set.
Pass -y/--yes (global flag) to skip the prompt (required in agent / CI / piped contexts). Pass -y/--yes (global flag) to skip the prompt (required in agent / CI / piped contexts).
AI agents: This is a high-risk write. Without -y/--yes the CLI exits 10 and AI agents: This is a high-risk write. Without -y/--yes the CLI exits 10
returns an envelope describing the missing confirmation. NEVER auto-pass -y 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 without the user's explicit go-ahead — the exit-10 protocol exists
guard against unintended deletes.`, exactly to guard against unintended deletes.`,
Example: ` weknora kb delete kb_abc # interactive confirm Example: ` weknora kb delete kb_abc # interactive confirm
weknora kb delete kb_abc -y # no prompt weknora kb delete kb_abc -y # no prompt
weknora kb delete kb_abc -y --json # envelope output`, weknora kb delete kb_abc -y --json # bare {id, deleted:true} JSON`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error { RunE: func(c *cobra.Command, args []string) error {
jopts, err := cmdutil.CheckJSONFlags(c) jopts, err := cmdutil.CheckJSONFlags(c)
@@ -82,7 +81,7 @@ func runDelete(ctx context.Context, opts *DeleteOptions, jopts *cmdutil.JSONOpti
} }
if jopts.Enabled() { if jopts.Enabled() {
return format.WriteJSONFiltered(iostreams.IO.Out, deleteResult{ID: id, Deleted: true}, jopts.Fields, jopts.JQ) return jopts.Emit(iostreams.IO.Out, deleteResult{ID: id, Deleted: true})
} }
fmt.Fprintf(iostreams.IO.Out, "✓ Deleted knowledge base %s\n", id) fmt.Fprintf(iostreams.IO.Out, "✓ Deleted knowledge base %s\n", id)
return nil return nil

View File

@@ -12,13 +12,14 @@ import (
"github.com/Tencent/WeKnora/cli/internal/cmdutil" "github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams" "github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/prompt" "github.com/Tencent/WeKnora/cli/internal/prompt"
"github.com/Tencent/WeKnora/cli/internal/testutil"
) )
// fakeDeleteSvc records what id was deleted. // fakeDeleteSvc records what id was deleted.
type fakeDeleteSvc struct { type fakeDeleteSvc struct {
err error err error
gotID string gotID string
called bool called bool
} }
func (f *fakeDeleteSvc) DeleteKnowledgeBase(_ context.Context, id string) error { func (f *fakeDeleteSvc) DeleteKnowledgeBase(_ context.Context, id string) error {
@@ -27,30 +28,16 @@ func (f *fakeDeleteSvc) DeleteKnowledgeBase(_ context.Context, id string) error
return f.err return f.err
} }
// confirmPrompter scripts a Confirm answer; Input/Password are unused here.
type confirmPrompter struct {
answer bool
err error
asked bool
}
func (c *confirmPrompter) Input(string, string) (string, error) { return "", prompt.ErrAgentNoPrompt }
func (c *confirmPrompter) Password(string) (string, error) { return "", prompt.ErrAgentNoPrompt }
func (c *confirmPrompter) Confirm(string, bool) (bool, error) {
c.asked = true
return c.answer, c.err
}
func TestDelete_Success_WithForce(t *testing.T) { func TestDelete_Success_WithForce(t *testing.T) {
out, _ := iostreams.SetForTest(t) out, _ := iostreams.SetForTest(t)
svc := &fakeDeleteSvc{} svc := &fakeDeleteSvc{}
p := &confirmPrompter{} p := &testutil.ConfirmPrompter{}
opts := &DeleteOptions{Yes: true} opts := &DeleteOptions{Yes: true}
require.NoError(t, runDelete(context.Background(), opts, nil, svc, p, "kb_force")) require.NoError(t, runDelete(context.Background(), opts, nil, svc, p, "kb_force"))
assert.True(t, svc.called) assert.True(t, svc.called)
assert.Equal(t, "kb_force", svc.gotID) assert.Equal(t, "kb_force", svc.gotID)
assert.False(t, p.asked, "--force must skip the confirm prompt") assert.False(t, p.Asked, "--force must skip the confirm prompt")
assert.Contains(t, out.String(), "✓ Deleted") assert.Contains(t, out.String(), "✓ Deleted")
assert.Contains(t, out.String(), "kb_force") assert.Contains(t, out.String(), "kb_force")
} }
@@ -58,7 +45,7 @@ func TestDelete_Success_WithForce(t *testing.T) {
func TestDelete_NotFound(t *testing.T) { func TestDelete_NotFound(t *testing.T) {
_, _ = iostreams.SetForTest(t) _, _ = iostreams.SetForTest(t)
svc := &fakeDeleteSvc{err: errors.New("HTTP error 404: not found")} svc := &fakeDeleteSvc{err: errors.New("HTTP error 404: not found")}
p := &confirmPrompter{} p := &testutil.ConfirmPrompter{}
err := runDelete(context.Background(), &DeleteOptions{Yes: true}, nil, svc, p, "kb_missing") err := runDelete(context.Background(), &DeleteOptions{Yes: true}, nil, svc, p, "kb_missing")
require.Error(t, err) require.Error(t, err)
@@ -73,7 +60,7 @@ func TestDelete_NonTTY_NoYes_RequiresConfirmation(t *testing.T) {
// silently proceed in scripted contexts. // silently proceed in scripted contexts.
iostreams.SetForTest(t) iostreams.SetForTest(t)
svc := &fakeDeleteSvc{} svc := &fakeDeleteSvc{}
p := &confirmPrompter{} p := &testutil.ConfirmPrompter{}
err := runDelete(context.Background(), &DeleteOptions{}, nil, svc, p, "kb_nontty") err := runDelete(context.Background(), &DeleteOptions{}, nil, svc, p, "kb_nontty")
require.Error(t, err) require.Error(t, err)
@@ -81,14 +68,14 @@ func TestDelete_NonTTY_NoYes_RequiresConfirmation(t *testing.T) {
require.ErrorAs(t, err, &typed) require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeInputConfirmationRequired, typed.Code) assert.Equal(t, cmdutil.CodeInputConfirmationRequired, typed.Code)
assert.False(t, svc.called, "non-TTY without -y must not call DeleteKnowledgeBase") assert.False(t, svc.called, "non-TTY without -y must not call DeleteKnowledgeBase")
assert.False(t, p.asked, "non-TTY ⇒ Confirm is never invoked") assert.False(t, p.Asked, "non-TTY ⇒ Confirm is never invoked")
assert.Equal(t, 10, cmdutil.ExitCode(err), "exit code 10 per destructive-write protocol") assert.Equal(t, 10, cmdutil.ExitCode(err), "exit code 10 per destructive-write protocol")
} }
func TestDelete_JSONOutput(t *testing.T) { func TestDelete_JSONOutput(t *testing.T) {
out, _ := iostreams.SetForTest(t) out, _ := iostreams.SetForTest(t)
svc := &fakeDeleteSvc{} svc := &fakeDeleteSvc{}
p := &confirmPrompter{} p := &testutil.ConfirmPrompter{}
opts := &DeleteOptions{Yes: true} opts := &DeleteOptions{Yes: true}
require.NoError(t, runDelete(context.Background(), opts, &cmdutil.JSONOptions{}, svc, p, "kb_json")) require.NoError(t, runDelete(context.Background(), opts, &cmdutil.JSONOptions{}, svc, p, "kb_json"))
@@ -104,10 +91,10 @@ func TestDelete_JSONOutput(t *testing.T) {
func TestDelete_ConfirmYes(t *testing.T) { func TestDelete_ConfirmYes(t *testing.T) {
_, _ = iostreams.SetForTestWithTTY(t) _, _ = iostreams.SetForTestWithTTY(t)
svc := &fakeDeleteSvc{} svc := &fakeDeleteSvc{}
p := &confirmPrompter{answer: true} p := &testutil.ConfirmPrompter{Answer: true}
require.NoError(t, runDelete(context.Background(), &DeleteOptions{}, nil, svc, p, "kb_yes")) require.NoError(t, runDelete(context.Background(), &DeleteOptions{}, nil, svc, p, "kb_yes"))
assert.True(t, p.asked, "confirm prompt should fire on TTY without --force") assert.True(t, p.Asked, "confirm prompt should fire on TTY without --force")
assert.True(t, svc.called, "answer=yes ⇒ delete proceeds") assert.True(t, svc.called, "answer=yes ⇒ delete proceeds")
assert.Equal(t, "kb_yes", svc.gotID) assert.Equal(t, "kb_yes", svc.gotID)
} }
@@ -115,14 +102,14 @@ func TestDelete_ConfirmYes(t *testing.T) {
func TestDelete_ConfirmNo(t *testing.T) { func TestDelete_ConfirmNo(t *testing.T) {
_, errBuf := iostreams.SetForTestWithTTY(t) _, errBuf := iostreams.SetForTestWithTTY(t)
svc := &fakeDeleteSvc{} svc := &fakeDeleteSvc{}
p := &confirmPrompter{answer: false} p := &testutil.ConfirmPrompter{Answer: false}
err := runDelete(context.Background(), &DeleteOptions{}, nil, svc, p, "kb_no") err := runDelete(context.Background(), &DeleteOptions{}, nil, svc, p, "kb_no")
require.Error(t, err) require.Error(t, err)
var typed *cmdutil.Error var typed *cmdutil.Error
require.ErrorAs(t, err, &typed) require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeUserAborted, typed.Code) assert.Equal(t, cmdutil.CodeUserAborted, typed.Code)
assert.True(t, p.asked) assert.True(t, p.Asked)
assert.False(t, svc.called, "answer=no ⇒ delete must NOT run") assert.False(t, svc.called, "answer=no ⇒ delete must NOT run")
assert.Contains(t, errBuf.String(), "Aborted") assert.Contains(t, errBuf.String(), "Aborted")
} }
@@ -130,7 +117,7 @@ func TestDelete_ConfirmNo(t *testing.T) {
func TestDelete_ConfirmPrompterError(t *testing.T) { func TestDelete_ConfirmPrompterError(t *testing.T) {
_, _ = iostreams.SetForTestWithTTY(t) _, _ = iostreams.SetForTestWithTTY(t)
svc := &fakeDeleteSvc{} svc := &fakeDeleteSvc{}
p := &confirmPrompter{err: prompt.ErrAgentNoPrompt} p := &testutil.ConfirmPrompter{Err: prompt.ErrAgentNoPrompt}
err := runDelete(context.Background(), &DeleteOptions{}, nil, svc, p, "kb_err") err := runDelete(context.Background(), &DeleteOptions{}, nil, svc, p, "kb_err")
require.Error(t, err) require.Error(t, err)
@@ -146,7 +133,7 @@ func TestDelete_JSONOut_NoYes_RequiresConfirmation(t *testing.T) {
// Exit-10 protocol must fire when -y is absent. // Exit-10 protocol must fire when -y is absent.
iostreams.SetForTestWithTTY(t) iostreams.SetForTestWithTTY(t)
svc := &fakeDeleteSvc{} svc := &fakeDeleteSvc{}
p := &confirmPrompter{} p := &testutil.ConfirmPrompter{}
opts := &DeleteOptions{} opts := &DeleteOptions{}
err := runDelete(context.Background(), opts, &cmdutil.JSONOptions{}, svc, p, "kb_jtty") err := runDelete(context.Background(), opts, &cmdutil.JSONOptions{}, svc, p, "kb_jtty")
@@ -154,21 +141,21 @@ func TestDelete_JSONOut_NoYes_RequiresConfirmation(t *testing.T) {
var typed *cmdutil.Error var typed *cmdutil.Error
require.ErrorAs(t, err, &typed) require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeInputConfirmationRequired, typed.Code) assert.Equal(t, cmdutil.CodeInputConfirmationRequired, typed.Code)
assert.False(t, p.asked, "--json must skip the prompt even on TTY") assert.False(t, p.Asked, "--json must skip the prompt even on TTY")
assert.False(t, svc.called, "--json without -y must not call DeleteKnowledgeBase") assert.False(t, svc.called, "--json without -y must not call DeleteKnowledgeBase")
assert.Equal(t, 10, cmdutil.ExitCode(err)) assert.Equal(t, 10, cmdutil.ExitCode(err))
} }
func TestDelete_JSONOut_WithYes_Proceeds(t *testing.T) { func TestDelete_JSONOut_WithYes_Proceeds(t *testing.T) {
// --json + -y is the agent happy-path: scripted caller with explicit // --json + -y is the agent happy-path: scripted caller with explicit
// approval. Must call SDK and emit envelope. // approval. Must call SDK and emit the bare result object.
out, _ := iostreams.SetForTestWithTTY(t) out, _ := iostreams.SetForTestWithTTY(t)
svc := &fakeDeleteSvc{} svc := &fakeDeleteSvc{}
p := &confirmPrompter{} p := &testutil.ConfirmPrompter{}
opts := &DeleteOptions{Yes: true} opts := &DeleteOptions{Yes: true}
require.NoError(t, runDelete(context.Background(), opts, &cmdutil.JSONOptions{}, svc, p, "kb_jtty")) require.NoError(t, runDelete(context.Background(), opts, &cmdutil.JSONOptions{}, svc, p, "kb_jtty"))
assert.False(t, p.asked, "-y must skip the prompt") assert.False(t, p.Asked, "-y must skip the prompt")
assert.True(t, svc.called) assert.True(t, svc.called)
assert.Contains(t, out.String(), `"deleted":true`) assert.Contains(t, out.String(), `"deleted":true`)
} }

View File

@@ -8,7 +8,6 @@ import (
"github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/aiclient"
"github.com/Tencent/WeKnora/cli/internal/cmdutil" "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/iostreams"
sdk "github.com/Tencent/WeKnora/client" sdk "github.com/Tencent/WeKnora/client"
) )
@@ -111,7 +110,7 @@ func runEdit(ctx context.Context, opts *EditOptions, jopts *cmdutil.JSONOptions,
return cmdutil.WrapHTTP(err, "edit knowledge base %s", id) return cmdutil.WrapHTTP(err, "edit knowledge base %s", id)
} }
if jopts.Enabled() { if jopts.Enabled() {
return format.WriteJSONFiltered(iostreams.IO.Out, updated, jopts.Fields, jopts.JQ) return jopts.Emit(iostreams.IO.Out, updated)
} }
fmt.Fprintf(iostreams.IO.Out, "✓ Updated knowledge base %s\n", id) fmt.Fprintf(iostreams.IO.Out, "✓ Updated knowledge base %s\n", id)
return nil return nil

View File

@@ -8,7 +8,6 @@ import (
"github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/aiclient"
"github.com/Tencent/WeKnora/cli/internal/cmdutil" "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/iostreams"
"github.com/Tencent/WeKnora/cli/internal/prompt" "github.com/Tencent/WeKnora/cli/internal/prompt"
sdk "github.com/Tencent/WeKnora/client" sdk "github.com/Tencent/WeKnora/client"
@@ -83,8 +82,7 @@ func runEmpty(ctx context.Context, opts *EmptyOptions, jopts *cmdutil.JSONOption
} }
if jopts.Enabled() { if jopts.Enabled() {
return format.WriteJSONFiltered(iostreams.IO.Out, return jopts.Emit(iostreams.IO.Out, emptyResult{ID: id, DeletedCount: deleted})
emptyResult{ID: id, DeletedCount: deleted}, jopts.Fields, jopts.JQ)
} }
fmt.Fprintf(iostreams.IO.Out, "✓ Emptied knowledge base %s (%d document(s) cleared)\n", id, deleted) fmt.Fprintf(iostreams.IO.Out, "✓ Emptied knowledge base %s (%d document(s) cleared)\n", id, deleted)
return nil return nil

View File

@@ -11,7 +11,6 @@ import (
"github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/aiclient"
"github.com/Tencent/WeKnora/cli/internal/cmdutil" "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/iostreams"
"github.com/Tencent/WeKnora/cli/internal/text" "github.com/Tencent/WeKnora/cli/internal/text"
sdk "github.com/Tencent/WeKnora/client" sdk "github.com/Tencent/WeKnora/client"
@@ -20,7 +19,7 @@ import (
// kbListFields enumerates the fields surfaced for `--json` discovery on // kbListFields enumerates the fields surfaced for `--json` discovery on
// `kb list`. Nested config structs (chunking / image / FAQ / VLM / storage // `kb list`. Nested config structs (chunking / image / FAQ / VLM / storage
// / extract) are intentionally omitted — users wanting those can use `--jq` // / extract) are intentionally omitted — users wanting those can use `--jq`
// against the full envelope. // against the full object.
var kbListFields = []string{ var kbListFields = []string{
"id", "name", "type", "description", "id", "name", "type", "description",
"is_temporary", "is_pinned", "is_temporary", "is_pinned",
@@ -66,7 +65,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
cmd.Flags().BoolVar(&opts.Pinned, "pinned", false, "Only show pinned knowledge bases") cmd.Flags().BoolVar(&opts.Pinned, "pinned", false, "Only show pinned knowledge bases")
cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum results to return (0 = no cap, 1..10000 = explicit)") cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum results to return (0 = no cap, 1..10000 = explicit)")
cmdutil.AddJSONFlags(cmd, kbListFields) cmdutil.AddJSONFlags(cmd, kbListFields)
aiclient.SetAgentHelp(cmd, "Lists all knowledge bases as a bare JSON array of {id, name, ...} objects (empty `[]` when none). --pinned restricts to pinned KBs (client-side filter). --limit caps the returned slice. Use `--json` (bare) for full objects, `--json id,name` to project fields, or `--jq` for arbitrary reshape.") aiclient.SetAgentHelp(cmd, "Lists all knowledge bases as a bare JSON array of {id, name, ...} objects (empty `[]` when none). --pinned restricts to pinned KBs (client-side filter). --limit caps the returned slice. Use `--json` (bare) for full objects, `--json=id,name` to project fields, or `--jq` for arbitrary reshape.")
return cmd return cmd
} }
@@ -105,7 +104,7 @@ func runList(ctx context.Context, opts *ListOptions, jopts *cmdutil.JSONOptions,
} }
if jopts.Enabled() { if jopts.Enabled() {
return format.WriteJSONFiltered(iostreams.IO.Out, items, jopts.Fields, jopts.JQ) return jopts.Emit(iostreams.IO.Out, items)
} }
if len(items) == 0 { if len(items) == 0 {

View File

@@ -8,7 +8,6 @@ import (
"github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/aiclient"
"github.com/Tencent/WeKnora/cli/internal/cmdutil" "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/iostreams"
sdk "github.com/Tencent/WeKnora/client" sdk "github.com/Tencent/WeKnora/client"
) )
@@ -80,7 +79,7 @@ func runPin(ctx context.Context, opts *PinOptions, jopts *cmdutil.JSONOptions, s
// both fresh-toggle and no-op paths. Human path prints a confirming // both fresh-toggle and no-op paths. Human path prints a confirming
// line; agents observe via the unchanged is_pinned field. // line; agents observe via the unchanged is_pinned field.
if jopts.Enabled() { if jopts.Enabled() {
return format.WriteJSONFiltered(iostreams.IO.Out, current, jopts.Fields, jopts.JQ) return jopts.Emit(iostreams.IO.Out, current)
} }
fmt.Fprintf(iostreams.IO.Out, "✓ %s is already %s\n", id, state) fmt.Fprintf(iostreams.IO.Out, "✓ %s is already %s\n", id, state)
return nil return nil
@@ -91,7 +90,7 @@ func runPin(ctx context.Context, opts *PinOptions, jopts *cmdutil.JSONOptions, s
return cmdutil.WrapHTTP(err, "%s knowledge base %s", verb, id) return cmdutil.WrapHTTP(err, "%s knowledge base %s", verb, id)
} }
if jopts.Enabled() { if jopts.Enabled() {
return format.WriteJSONFiltered(iostreams.IO.Out, updated, jopts.Fields, jopts.JQ) return jopts.Emit(iostreams.IO.Out, updated)
} }
state := "pinned" state := "pinned"
if !updated.IsPinned { if !updated.IsPinned {

View File

@@ -8,7 +8,6 @@ import (
"github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/aiclient"
"github.com/Tencent/WeKnora/cli/internal/cmdutil" "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/iostreams"
"github.com/Tencent/WeKnora/cli/internal/text" "github.com/Tencent/WeKnora/cli/internal/text"
sdk "github.com/Tencent/WeKnora/client" sdk "github.com/Tencent/WeKnora/client"
@@ -63,7 +62,7 @@ func runView(ctx context.Context, opts *ViewOptions, jopts *cmdutil.JSONOptions,
return cmdutil.WrapHTTP(err, "get knowledge base %q", id) return cmdutil.WrapHTTP(err, "get knowledge base %q", id)
} }
if jopts.Enabled() { if jopts.Enabled() {
return format.WriteJSONFiltered(iostreams.IO.Out, kb, jopts.Fields, jopts.JQ) return jopts.Emit(iostreams.IO.Out, kb)
} }
// Human: KEY: VALUE // Human: KEY: VALUE
w := iostreams.IO.Out w := iostreams.IO.Out

View File

@@ -16,7 +16,6 @@ import (
"github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/aiclient"
"github.com/Tencent/WeKnora/cli/internal/cmdutil" "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/iostreams"
"github.com/Tencent/WeKnora/cli/internal/projectlink" "github.com/Tencent/WeKnora/cli/internal/projectlink"
) )
@@ -105,7 +104,7 @@ func runLink(ctx context.Context, opts *Options, jopts *cmdutil.JSONOptions, f *
ProjectLinkPath: linkPath, ProjectLinkPath: linkPath,
} }
if jopts.Enabled() { if jopts.Enabled() {
return format.WriteJSONFiltered(iostreams.IO.Out, r, jopts.Fields, jopts.JQ) return jopts.Emit(iostreams.IO.Out, r)
} }
if kbName != "" { if kbName != "" {
fmt.Fprintf(iostreams.IO.Out, "✓ Linked %s to %s (kb=%s, id=%s)\n", linkPath, ctxName, kbName, kbID) fmt.Fprintf(iostreams.IO.Out, "✓ Linked %s to %s (kb=%s, id=%s)\n", linkPath, ctxName, kbName, kbID)

View File

@@ -8,7 +8,6 @@ import (
"github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/aiclient"
"github.com/Tencent/WeKnora/cli/internal/cmdutil" "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/iostreams"
"github.com/Tencent/WeKnora/cli/internal/projectlink" "github.com/Tencent/WeKnora/cli/internal/projectlink"
) )
@@ -39,7 +38,7 @@ discovery that ` + "`--kb`" + ` resolution uses; you do not need to cd to the
project root to unlink. Errors with input.invalid_argument when no link project root to unlink. Errors with input.invalid_argument when no link
is present anywhere in the parent chain.`, is present anywhere in the parent chain.`,
Example: ` weknora unlink # remove the binding for this project Example: ` weknora unlink # remove the binding for this project
weknora unlink --json # envelope output (CI / agents)`, weknora unlink --json # bare JSON (CI / agents)`,
Args: cobra.NoArgs, Args: cobra.NoArgs,
RunE: func(c *cobra.Command, _ []string) error { RunE: func(c *cobra.Command, _ []string) error {
jopts, err := cmdutil.CheckJSONFlags(c) jopts, err := cmdutil.CheckJSONFlags(c)
@@ -74,8 +73,7 @@ func runUnlink(opts *UnlinkOptions, jopts *cmdutil.JSONOptions) error {
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "remove %s", linkPath) return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "remove %s", linkPath)
} }
if jopts.Enabled() { if jopts.Enabled() {
return format.WriteJSONFiltered(iostreams.IO.Out, return jopts.Emit(iostreams.IO.Out, unlinkResult{ProjectLinkPath: linkPath})
unlinkResult{ProjectLinkPath: linkPath}, jopts.Fields, jopts.JQ)
} }
fmt.Fprintf(iostreams.IO.Out, "✓ Unlinked %s\n", linkPath) fmt.Fprintf(iostreams.IO.Out, "✓ Unlinked %s\n", linkPath)
return nil return nil

View File

@@ -3,7 +3,6 @@ package cmd
import ( import (
"fmt" "fmt"
"os"
"strings" "strings"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -30,62 +29,16 @@ import (
// Execute is the entry point invoked by main(). Returns the process exit code. // Execute is the entry point invoked by main(). Returns the process exit code.
func Execute() int { func Execute() int {
root := NewRootCmd(cmdutil.New()) root := NewRootCmd(cmdutil.New())
// ExecuteC returns the actually-invoked leaf (or root when invocation if err := root.Execute(); err != nil {
// failed before dispatch); we use it to honor the leaf's --json and // Errors go to stderr (matches gh/aws/stripe). Stdout stays
// inherited --format without walking the tree ourselves. // empty (or holds partial success the command produced) so
cmd, err := root.ExecuteC() // downstream `--json | jq` pipelines never filter error shapes
if err == nil { // out of the success stream. The typed exit code (3/4/5/6/7/10)
return 0 // carries the error class.
cmdutil.PrintError(iostreams.IO.Err, MapCobraError(err))
return cmdutil.ExitCode(MapCobraError(err))
} }
err = MapCobraError(err) return 0
if WantsJSONOutput(cmd) {
cmdutil.PrintErrorEnvelope(iostreams.IO.Out, err)
} else {
cmdutil.PrintError(iostreams.IO.Err, err)
}
return cmdutil.ExitCode(err)
}
// WantsJSONOutput reports whether cmd was invoked with --json, so error
// output matches the success format. Persistent flags inherit automatically
// via cmd.Flags().
//
// Falls back to scanning os.Args when cobra never reached the leaf — e.g.
// unknown subcommand or unknown flag at root level. Without this, `weknora
// bogus --json` would emit a human stderr line instead of the envelope the
// agent asked for.
//
// Exported so the acceptance/contract test helper can replicate Execute()'s
// envelope-printing path without having to call os.Exit-bound Execute() itself.
func WantsJSONOutput(cmd *cobra.Command) bool {
// --json is a StringSlice in v0.4 (optional field filter). A
// Changed=true flag indicates the user requested JSON output.
if f := cmd.Flags().Lookup("json"); f != nil && f.Changed {
return true
}
return argsRequestJSON(os.Args[1:])
}
// argsRequestJSON scans a flag-only slice for --json in the forms pflag
// accepts. Used as a fallback when cobra short-circuits before flag parsing
// (unknown command / unknown flag at root). Recognizes `--json` bare,
// `--json id,name`, and `--json=id,name` forms.
func argsRequestJSON(args []string) bool {
for i, a := range args {
switch {
case a == "--json":
return true
case strings.HasPrefix(a, "--json="):
return true
default:
// `--json id,name` — split into two args; we don't try to
// distinguish "next arg is a value" vs "next arg is a flag"
// here, since false positives just mean we emit JSON for an
// error that would otherwise be human (still parseable).
_ = i
}
}
return false
} }
// MapCobraError tags the textually-emitted cobra errors as cmdutil.FlagError // MapCobraError tags the textually-emitted cobra errors as cmdutil.FlagError
@@ -97,8 +50,8 @@ func argsRequestJSON(args []string) bool {
// cobra/command.go: required-flag / unknown-command). TestMapCobraError_PinnedPrefixes // cobra/command.go: required-flag / unknown-command). TestMapCobraError_PinnedPrefixes
// guards against a silent break on cobra bumps. // guards against a silent break on cobra bumps.
// //
// Exported so the acceptance/contract test helper can reuse the mapping when // Exported so the acceptance/contract test helper can reuse the mapping
// replicating Execute()'s error-envelope path in-process. // when replicating Execute()'s stderr error-path in-process.
func MapCobraError(err error) error { func MapCobraError(err error) error {
if err == nil { if err == nil {
return nil return nil
@@ -143,8 +96,8 @@ hybrid searches against a WeKnora server from your shell or an AI agent.`,
SilenceErrors: true, SilenceErrors: true,
// Version makes cobra auto-register a `--version` global flag that // Version makes cobra auto-register a `--version` global flag that
// prints this string. We accept both `--version` and a `version` // prints this string. We accept both `--version` and a `version`
// subcommand; the subcommand still owns the richer `--json` envelope // subcommand; the subcommand still owns the richer `--json` output
// output. // (build commit + date).
Version: fmt.Sprintf("%s (commit %s, built %s)", v, commit, date), Version: fmt.Sprintf("%s (commit %s, built %s)", v, commit, date),
PersistentPreRun: func(c *cobra.Command, args []string) { PersistentPreRun: func(c *cobra.Command, args []string) {
// Propagate the global --context flag into the Factory for this // Propagate the global --context flag into the Factory for this
@@ -191,10 +144,10 @@ func addGlobalFlags(cmd *cobra.Command) {
pf.String("context", "", "Override the active context for this invocation (no disk write)") pf.String("context", "", "Override the active context for this invocation (no disk write)")
} }
// agentAwareHelpFunc wraps cobra's default help to append the AI agent guidance // agentAwareHelpFunc wraps cobra's default help to append the AI agent
// (Annotations[aiclient.AIAgentHelpKey]) only when an AI coding agent env var is // guidance (Annotations[aiclient.AIAgentHelpKey]) only when an AI coding
// detected (CLAUDECODE / CURSOR_AGENT). Help-only render — no behavior switch // agent env var is detected (CLAUDECODE / CURSOR_AGENT). Help-only
// (v0.2 ADR-3). // render — no behavior switch.
func agentAwareHelpFunc(orig func(*cobra.Command, []string)) func(*cobra.Command, []string) { func agentAwareHelpFunc(orig func(*cobra.Command, []string)) func(*cobra.Command, []string) {
return func(c *cobra.Command, args []string) { return func(c *cobra.Command, args []string) {
orig(c, args) orig(c, args)
@@ -213,7 +166,7 @@ func agentAwareHelpFunc(orig func(*cobra.Command, []string)) func(*cobra.Command
} }
// versionFields enumerates the fields surfaced for `--json` discovery on // versionFields enumerates the fields surfaced for `--json` discovery on
// `version`. Mirrors the version envelope payload. // `version`. Mirrors the version object payload.
var versionFields = []string{"version", "commit", "date"} var versionFields = []string{"version", "commit", "date"}
// newVersionCmd is the only leaf command shipped in the foundation PR. It // newVersionCmd is the only leaf command shipped in the foundation PR. It

View File

@@ -140,36 +140,3 @@ func TestRoot_ContextFlagPropagation(t *testing.T) {
} }
} }
func TestArgsRequestJSON(t *testing.T) {
// v0.4 R-1: --json is now StringSlice (gh-style field filter). Any
// `--json` token in args means the user wants JSON output, regardless
// of value (boolean string parsing dropped).
cases := []struct {
name string
args []string
want bool
}{
{"empty", nil, false},
{"--json bare", []string{"version", "--json"}, true},
{"--json=id,name", []string{"kb", "list", "--json=id,name"}, true},
{"--json=anything", []string{"version", "--json=foo"}, true},
{"unrelated", []string{"bogus", "--kb", "x"}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, argsRequestJSON(tc.args))
})
}
}
func TestWantsJSONOutput(t *testing.T) {
// Build a minimal *cobra.Command with the json flag directly so we test
// the helper without going through cobra's parse pipeline. WantsJSONOutput
// reads cmd.Flags() which on a fresh command equals LocalFlags().
c := &cobra.Command{Use: "x"}
c.Flags().Bool("json", false, "")
assert.False(t, WantsJSONOutput(c), "default: --json unset")
require.NoError(t, c.Flags().Set("json", "true"))
assert.True(t, WantsJSONOutput(c), "--json=true honored")
}

View File

@@ -9,7 +9,6 @@ import (
"github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/aiclient"
"github.com/Tencent/WeKnora/cli/internal/cmdutil" "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/iostreams"
sdk "github.com/Tencent/WeKnora/client" sdk "github.com/Tencent/WeKnora/client"
) )
@@ -144,7 +143,7 @@ func runChunks(ctx context.Context, opts *ChunksOptions, jopts *cmdutil.JSONOpti
if results == nil { if results == nil {
results = []*sdk.SearchResult{} results = []*sdk.SearchResult{}
} }
return format.WriteJSONFiltered(iostreams.IO.Out, results, jopts.Fields, jopts.JQ) return jopts.Emit(iostreams.IO.Out, results)
} }
return renderChunkResults(results, opts.KBID) return renderChunkResults(results, opts.KBID)
} }

View File

@@ -44,7 +44,7 @@ func TestRunSearch_HumanOutput(t *testing.T) {
assert.Contains(t, got, "doc-1") assert.Contains(t, got, "doc-1")
} }
// JSON envelope must surface match_type so machine consumers / agents can // JSON output must surface match_type so machine consumers / agents can
// reason about retrieval channels without re-implementing the wire format. // reason about retrieval channels without re-implementing the wire format.
// (Human renderer keeps default minimal — diagnostic info opt-in via --json.) // (Human renderer keeps default minimal — diagnostic info opt-in via --json.)
func TestRunSearch_JSONIncludesMatchType(t *testing.T) { func TestRunSearch_JSONIncludesMatchType(t *testing.T) {

View File

@@ -11,7 +11,6 @@ import (
"github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/aiclient"
"github.com/Tencent/WeKnora/cli/internal/cmdutil" "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/iostreams"
"github.com/Tencent/WeKnora/cli/internal/text" "github.com/Tencent/WeKnora/cli/internal/text"
sdk "github.com/Tencent/WeKnora/client" sdk "github.com/Tencent/WeKnora/client"
@@ -120,7 +119,7 @@ done:
if matches == nil { if matches == nil {
matches = []sdk.Knowledge{} matches = []sdk.Knowledge{}
} }
return format.WriteJSONFiltered(iostreams.IO.Out, matches, jopts.Fields, jopts.JQ) return jopts.Emit(iostreams.IO.Out, matches)
} }
if len(matches) == 0 { if len(matches) == 0 {
fmt.Fprintln(iostreams.IO.Out, "(no matches)") fmt.Fprintln(iostreams.IO.Out, "(no matches)")

View File

@@ -11,7 +11,6 @@ import (
"github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/aiclient"
"github.com/Tencent/WeKnora/cli/internal/cmdutil" "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/iostreams"
"github.com/Tencent/WeKnora/cli/internal/text" "github.com/Tencent/WeKnora/cli/internal/text"
sdk "github.com/Tencent/WeKnora/client" sdk "github.com/Tencent/WeKnora/client"
@@ -91,7 +90,7 @@ func runKBSearch(ctx context.Context, opts *KBSearchOptions, jopts *cmdutil.JSON
if matches == nil { if matches == nil {
matches = []sdk.KnowledgeBase{} matches = []sdk.KnowledgeBase{}
} }
return format.WriteJSONFiltered(iostreams.IO.Out, matches, jopts.Fields, jopts.JQ) return jopts.Emit(iostreams.IO.Out, matches)
} }
if len(matches) == 0 { if len(matches) == 0 {
fmt.Fprintln(iostreams.IO.Out, "(no matches)") fmt.Fprintln(iostreams.IO.Out, "(no matches)")

View File

@@ -11,7 +11,6 @@ import (
"github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/aiclient"
"github.com/Tencent/WeKnora/cli/internal/cmdutil" "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/iostreams"
"github.com/Tencent/WeKnora/cli/internal/text" "github.com/Tencent/WeKnora/cli/internal/text"
sdk "github.com/Tencent/WeKnora/client" sdk "github.com/Tencent/WeKnora/client"
@@ -100,7 +99,7 @@ done:
if matches == nil { if matches == nil {
matches = []sdk.Session{} matches = []sdk.Session{}
} }
return format.WriteJSONFiltered(iostreams.IO.Out, matches, jopts.Fields, jopts.JQ) return jopts.Emit(iostreams.IO.Out, matches)
} }
if len(matches) == 0 { if len(matches) == 0 {
fmt.Fprintln(iostreams.IO.Out, "(no matches)") fmt.Fprintln(iostreams.IO.Out, "(no matches)")

View File

@@ -8,7 +8,6 @@ import (
"github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/aiclient"
"github.com/Tencent/WeKnora/cli/internal/cmdutil" "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/iostreams"
"github.com/Tencent/WeKnora/cli/internal/prompt" "github.com/Tencent/WeKnora/cli/internal/prompt"
) )
@@ -44,8 +43,8 @@ func NewCmdDelete(f *cmdutil.Factory) *cobra.Command {
Prompts for confirmation by default when stdout is a TTY and --json is not set. Prompts for confirmation by default when stdout is a TTY and --json is not set.
Pass -y/--yes (global flag) to skip the prompt (required in agent / CI / piped contexts). Pass -y/--yes (global flag) to skip the prompt (required in agent / CI / piped contexts).
AI agents: This is a high-risk write. Without -y/--yes the CLI exits 10 and AI agents: This is a high-risk write. Without -y/--yes the CLI exits 10
returns an envelope describing the missing confirmation. NEVER auto-pass -y and writes input.confirmation_required to stderr. NEVER auto-pass -y
without the user's explicit go-ahead.`, without the user's explicit go-ahead.`,
Example: ` weknora session delete s_abc # interactive confirm Example: ` weknora session delete s_abc # interactive confirm
weknora session delete s_abc -y # no prompt weknora session delete s_abc -y # no prompt
@@ -79,7 +78,7 @@ func runDelete(ctx context.Context, opts *DeleteOptions, jopts *cmdutil.JSONOpti
} }
if jopts.Enabled() { if jopts.Enabled() {
return format.WriteJSONFiltered(iostreams.IO.Out, deleteResult{ID: id, Deleted: true}, jopts.Fields, jopts.JQ) return jopts.Emit(iostreams.IO.Out, deleteResult{ID: id, Deleted: true})
} }
fmt.Fprintf(iostreams.IO.Out, "✓ Deleted session %s\n", id) fmt.Fprintf(iostreams.IO.Out, "✓ Deleted session %s\n", id)
return nil return nil

View File

@@ -12,14 +12,12 @@ import (
"github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/aiclient"
"github.com/Tencent/WeKnora/cli/internal/cmdutil" "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/iostreams"
"github.com/Tencent/WeKnora/cli/internal/text" "github.com/Tencent/WeKnora/cli/internal/text"
sdk "github.com/Tencent/WeKnora/client" sdk "github.com/Tencent/WeKnora/client"
) )
const ( const (
defaultPage = 1
defaultPageSize = 30 defaultPageSize = 30
maxPageSize = 1000 maxPageSize = 1000
) )
@@ -96,19 +94,14 @@ func runList(ctx context.Context, opts *ListOptions, jopts *cmdutil.JSONOptions,
since = d since = d
} }
var ( var items []sdk.Session
items []sdk.Session
total int
)
if opts.AllPages { if opts.AllPages {
accum := make([]sdk.Session, 0) accum := make([]sdk.Session, 0)
page := 1 for page := 1; ; page++ {
for { chunk, total, err := svc.GetSessionsByTenant(ctx, page, opts.PageSize)
chunk, t, err := svc.GetSessionsByTenant(ctx, page, opts.PageSize)
if err != nil { if err != nil {
return cmdutil.WrapHTTP(err, "list sessions") return cmdutil.WrapHTTP(err, "list sessions")
} }
total = t
accum = append(accum, chunk...) accum = append(accum, chunk...)
if opts.Limit > 0 && len(accum) >= opts.Limit { if opts.Limit > 0 && len(accum) >= opts.Limit {
accum = accum[:opts.Limit] accum = accum[:opts.Limit]
@@ -117,16 +110,14 @@ func runList(ctx context.Context, opts *ListOptions, jopts *cmdutil.JSONOptions,
if page*opts.PageSize >= total || len(chunk) == 0 { if page*opts.PageSize >= total || len(chunk) == 0 {
break break
} }
page++
} }
items = accum items = accum
} else { } else {
chunk, t, err := svc.GetSessionsByTenant(ctx, 1, opts.PageSize) chunk, _, err := svc.GetSessionsByTenant(ctx, 1, opts.PageSize)
if err != nil { if err != nil {
return cmdutil.WrapHTTP(err, "list sessions") return cmdutil.WrapHTTP(err, "list sessions")
} }
items = chunk items = chunk
total = t
} }
if items == nil { if items == nil {
items = []sdk.Session{} // JSON [] not null items = []sdk.Session{} // JSON [] not null
@@ -149,10 +140,9 @@ func runList(ctx context.Context, opts *ListOptions, jopts *cmdutil.JSONOptions,
if opts.Limit > 0 && len(items) > opts.Limit { if opts.Limit > 0 && len(items) > opts.Limit {
items = items[:opts.Limit] items = items[:opts.Limit]
} }
_ = total // pagination metadata no longer surfaced; --all-pages drains for callers who need everything
if jopts.Enabled() { if jopts.Enabled() {
return format.WriteJSONFiltered(iostreams.IO.Out, items, jopts.Fields, jopts.JQ) return jopts.Emit(iostreams.IO.Out, items)
} }
if len(items) == 0 { if len(items) == 0 {

View File

@@ -9,7 +9,6 @@ import (
"github.com/Tencent/WeKnora/cli/internal/aiclient" "github.com/Tencent/WeKnora/cli/internal/aiclient"
"github.com/Tencent/WeKnora/cli/internal/cmdutil" "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/iostreams"
sdk "github.com/Tencent/WeKnora/client" sdk "github.com/Tencent/WeKnora/client"
) )
@@ -60,7 +59,7 @@ func runView(ctx context.Context, opts *ViewOptions, jopts *cmdutil.JSONOptions,
return cmdutil.WrapHTTP(err, "get session %q", id) return cmdutil.WrapHTTP(err, "get session %q", id)
} }
if jopts.Enabled() { if jopts.Enabled() {
return format.WriteJSONFiltered(iostreams.IO.Out, s, jopts.Fields, jopts.JQ) return jopts.Emit(iostreams.IO.Out, s)
} }
w := iostreams.IO.Out w := iostreams.IO.Out
fmt.Fprintf(w, "ID: %s\n", s.ID) fmt.Fprintf(w, "ID: %s\n", s.ID)

View File

@@ -1,12 +1,6 @@
// Package agent handles AI agent integration: env-based detection (used to // Package aiclient handles AI agent integration: env-based detection
// trigger AGENT-targeted help text) and per-command help annotations. // (used to trigger AGENT-targeted help text) and per-command help
// // annotations. See cli/AGENTS.md for the full agent contract.
// v0.2 ADR-3: removed the omnibus `--agent` flag + ApplyAgentSugar
// mode-switch as over-design. Per-command --json + TTY auto-detect cover
// 95% of cases. See cli/AGENTS.md for the full agent contract.
//
// What remains here: a small env-detect for known coding agents, used purely
// to render the AGENT-targeted help section (no behavior change).
package aiclient package aiclient
import "os" import "os"

View File

@@ -29,7 +29,7 @@ func TestDetectAIAgent(t *testing.T) {
{name: "claude code", set: map[string]string{"CLAUDECODE": "1"}, want: "claude-code"}, {name: "claude code", set: map[string]string{"CLAUDECODE": "1"}, want: "claude-code"},
{name: "cursor", set: map[string]string{"CURSOR_AGENT": "yes"}, want: "cursor"}, {name: "cursor", set: map[string]string{"CURSOR_AGENT": "yes"}, want: "cursor"},
// Other entries (codex / aider / continue / opencode / gemini-coder) // Other entries (codex / aider / continue / opencode / gemini-coder)
// were dropped in v0.2 ADR-3 — env names had no official agent docs. // were dropped — those env names had no official agent docs.
// New entries should arrive with a documented source URL. // New entries should arrive with a documented source URL.
{ {
name: "first-match precedence", name: "first-match precedence",

View File

@@ -1,7 +1,6 @@
package cmdutil package cmdutil
import ( import (
"errors"
"fmt" "fmt"
"github.com/Tencent/WeKnora/cli/internal/iostreams" "github.com/Tencent/WeKnora/cli/internal/iostreams"
@@ -25,24 +24,15 @@ import (
// proceed. See cli/AGENTS.md "Exit codes". // proceed. See cli/AGENTS.md "Exit codes".
// //
// `yes` should be sourced from the persistent global -y/--yes flag. // `yes` should be sourced from the persistent global -y/--yes flag.
//
// On exit-10 path, the returned *Error carries OperationRisk so the envelope
// printer attaches `risk: {level: "high-risk-write", action: ...}`.
func ConfirmDestructive(p prompt.Prompter, yes, jsonOut bool, what, id string) error { func ConfirmDestructive(p prompt.Prompter, yes, jsonOut bool, what, id string) error {
if yes { if yes {
return nil return nil
} }
risk := &OperationRisk{Level: "high-risk-write", Action: fmt.Sprintf("delete %s %s", what, id)}
if !iostreams.IO.IsStdoutTTY() || jsonOut { if !iostreams.IO.IsStdoutTTY() || jsonOut {
e := NewError( return NewError(
CodeInputConfirmationRequired, CodeInputConfirmationRequired,
fmt.Sprintf("delete %s %s requires explicit confirmation: re-run with -y/--yes", what, id), fmt.Sprintf("delete %s %s requires explicit confirmation: re-run with -y/--yes", what, id),
) )
var typed *Error
if errors.As(e, &typed) {
typed.OperationRisk = risk
}
return e
} }
ok, err := p.Confirm(fmt.Sprintf("Delete %s %s? This cannot be undone.", what, id), false) ok, err := p.Confirm(fmt.Sprintf("Delete %s %s? This cannot be undone.", what, id), false)
if err != nil { if err != nil {

View File

@@ -9,9 +9,9 @@ import (
"strings" "strings"
) )
// ErrorCode is a namespaced stable identifier carried in the failure envelope. // ErrorCode is a namespaced stable identifier emitted on stderr in the
// SemVer governance: v0.x maintains the registry below; new codes are noted // `code: message` failure line. SemVer governance: v0.x maintains the
// in release notes. v0.9 introduces a CI compat test (see ADR-6b). // registry below; new codes are noted in release notes.
type ErrorCode string type ErrorCode string
const ( const (
@@ -33,9 +33,9 @@ const (
CodeInputMissingFlag ErrorCode = "input.missing_flag" CodeInputMissingFlag ErrorCode = "input.missing_flag"
// CodeInputConfirmationRequired marks a high-risk write that has no // CodeInputConfirmationRequired marks a high-risk write that has no
// interactive UI (non-TTY or --json) and was invoked without -y/--yes. // interactive UI (non-TTY or --json) and was invoked without -y/--yes.
// Mapped to exit code 10 (see cli/AGENTS.md). // Mapped to exit code 10 (see cli/AGENTS.md). Agents must surface the
// Agents must surface the envelope to the user and only retry with -y // error to the user and only retry with -y after explicit human
// after explicit human approval; never auto-retry. // approval; never auto-retry.
CodeInputConfirmationRequired ErrorCode = "input.confirmation_required" CodeInputConfirmationRequired ErrorCode = "input.confirmation_required"
// server.* / network.* // server.* / network.*
@@ -60,9 +60,9 @@ const (
CodeKBNotFound ErrorCode = "local.kb_not_found" CodeKBNotFound ErrorCode = "local.kb_not_found"
CodeProjectLinkCorrupt ErrorCode = "local.project_link_corrupt" CodeProjectLinkCorrupt ErrorCode = "local.project_link_corrupt"
// CodeUserAborted marks a user-cancelled destructive operation (declined a // CodeUserAborted marks a user-cancelled destructive operation (declined a
// confirm prompt). Distinct from SilentError so envelopes still carry a // confirm prompt). Distinct from SilentError so the stderr line still
// stable code; distinct from input.* because the user supplied valid args // carries a stable code; distinct from input.* because the user supplied
// and simply chose not to proceed. // valid args and simply chose not to proceed.
CodeUserAborted ErrorCode = "local.user_aborted" CodeUserAborted ErrorCode = "local.user_aborted"
// CodeUploadFileNotFound marks a `weknora doc upload` invocation pointing at // CodeUploadFileNotFound marks a `weknora doc upload` invocation pointing at
// a path that does not exist. Distinct from CodeLocalFileIO (permission / // a path that does not exist. Distinct from CodeLocalFileIO (permission /
@@ -81,7 +81,9 @@ const (
) )
// Error is the typed error implementations carry through the call stack. // Error is the typed error implementations carry through the call stack.
// RunE returns a *Error and the root command formats it into the envelope. // RunE returns a *Error and the root command renders it on stderr in
// `code: message[: cause]\nhint: ...` form. Exit code is derived by
// ExitCode().
type Error struct { type Error struct {
Code ErrorCode Code ErrorCode
Message string Message string
@@ -89,27 +91,13 @@ type Error struct {
Cause error Cause error
Retryable bool Retryable bool
HTTPStatus int HTTPStatus int
// Risk classifies the operation that produced this error. Set by callers // Silent suppresses PrintError's stderr output while preserving the
// invoking destructive write paths so envelope.risk surfaces to agents. // typed Code for ExitCode. Set by commands that already wrote their
// Stored as the format.Risk JSON shape via OperationRisk to avoid an // own output (e.g. bulk operations reporting partial-success data on
// import cycle with internal/format. // stdout) but still need to surface a non-zero exit code.
OperationRisk *OperationRisk
// Silent suppresses the default Failure envelope written by
// PrintErrorEnvelope while preserving the typed Code for ExitCode.
// Set by commands that already wrote their own envelope (e.g. bulk
// operations reporting partial-success data) but still need to surface
// a non-zero exit code matched to the failure class.
Silent bool Silent bool
} }
// OperationRisk mirrors format.Risk in the cmdutil layer (avoiding a circular
// import). cmdutil → format is OK; the inverse is not, so cmdutil owns its
// own type and ToErrorBody / PrintErrorEnvelope translate.
type OperationRisk struct {
Level string // "read" | "write" | "high-risk-write"
Action string
}
func (e *Error) Error() string { func (e *Error) Error() string {
if e == nil { if e == nil {
return "" return ""
@@ -202,7 +190,7 @@ func matchPrefix(err error, prefix string) bool {
} }
// ClassifyHTTPStatus maps an HTTP status code to the canonical ErrorCode. // ClassifyHTTPStatus maps an HTTP status code to the canonical ErrorCode.
// Single source of truth so envelope codes stay aligned whether the failure // Single source of truth so error codes stay aligned whether the failure
// was detected by the SDK (string-formatted error) or by the CLI directly // was detected by the SDK (string-formatted error) or by the CLI directly
// (e.g. raw passthrough reading resp.StatusCode). // (e.g. raw passthrough reading resp.StatusCode).
func ClassifyHTTPStatus(status int) ErrorCode { func ClassifyHTTPStatus(status int) ErrorCode {
@@ -229,7 +217,7 @@ func ClassifyHTTPStatus(status int) ErrorCode {
// parsing the "HTTP error <status>: ..." message format the SDK currently // parsing the "HTTP error <status>: ..." message format the SDK currently
// emits (client.parseResponse). Until the SDK exposes a typed APIError this // emits (client.parseResponse). Until the SDK exposes a typed APIError this
// is the lowest-friction way to surface 401/404/429/etc. as the right // is the lowest-friction way to surface 401/404/429/etc. as the right
// envelope code instead of every server-side problem collapsing to // typed code instead of every server-side problem collapsing to
// server.error. // server.error.
// //
// Returns CodeNetworkError when err is not an HTTP error (transport / DNS), // Returns CodeNetworkError when err is not an HTTP error (transport / DNS),

View File

@@ -4,8 +4,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"github.com/Tencent/WeKnora/cli/internal/format"
) )
// ExitCode maps an error to the documented CLI exit code (spec §2.4 + ADR-3). // ExitCode maps an error to the documented CLI exit code (spec §2.4 + ADR-3).
@@ -56,13 +54,10 @@ func ExitCode(err error) int {
return 1 return 1
} }
// PrintError writes err to w in human-readable form. The envelope-aware // PrintError writes err to w (typically stderr) as `code: message\nhint:
// JSON formatter is in `internal/format`; this helper is the human path used // ...`. Typed *Error values surface their Hint as a second line so users
// when no command produced its own output. // see the actionable next-step. Falls through to defaultHint when the
// // caller didn't set one.
// Typed *Error values surface their Hint as a second line so users see the
// actionable next-step (matches envelope.error.hint visibility in --json).
// Falls through to defaultHint when caller didn't set one.
func PrintError(w io.Writer, err error) { func PrintError(w io.Writer, err error) {
if err == nil || errors.Is(err, SilentError) { if err == nil || errors.Is(err, SilentError) {
return return
@@ -80,76 +75,10 @@ func PrintError(w io.Writer, err error) {
} }
} }
// PrintErrorEnvelope writes err as a JSON envelope on w. Used in agent mode / // defaultHint returns a canonical actionable hint for known error codes
// --json / --format=json output so failures stay machine-parseable. When the // when the call site didn't set one. `auth.unauthenticated` always points
// error carries an OperationRisk (destructive write paths), it's surfaced as // at `weknora auth login` — covers the broad surface (auth status / kb
// the envelope-level Risk field so agents can decide whether to surface the // list / kb view / search) without per-command hint plumbing.
// failure differently to the user.
func PrintErrorEnvelope(w io.Writer, err error) {
if err == nil || errors.Is(err, SilentError) {
return
}
var typed *Error
if errors.As(err, &typed) && typed.Silent {
return
}
env := format.Failure(ToErrorBody(err))
if r := operationRiskOf(err); r != nil {
env.Risk = &format.Risk{Level: format.RiskLevel(r.Level), Action: r.Action}
}
_ = format.WriteEnvelope(w, env)
}
// operationRiskOf extracts an OperationRisk from a typed *Error chain, or nil.
func operationRiskOf(err error) *OperationRisk {
var typed *Error
if errors.As(err, &typed) {
return typed.OperationRisk
}
return nil
}
// ToErrorBody projects err into the canonical envelope ErrorBody. Exposed so
// other emit paths (planned: MCP) reuse the same projection rather than
// reimplementing the typed-error → wire-shape mapping.
func ToErrorBody(err error) *format.ErrorBody {
if err == nil {
return nil
}
body := &format.ErrorBody{Message: err.Error()}
var typed *Error
if errors.As(err, &typed) {
body.Code = string(typed.Code)
body.Message = typed.Message
body.Hint = typed.Hint
if body.Hint == "" {
body.Hint = defaultHint(typed.Code)
}
body.Retryable = typed.Retryable
// Surface the wrapped cause so agents see the actual server / SDK
// error string, not just the wrap message ("hybrid search"). The
// human's printed line and the JSON envelope both end with the
// underlying problem.
if typed.Cause != nil {
body.Message = typed.Message + ": " + typed.Cause.Error()
}
return body
}
var fe *FlagError
if errors.As(err, &fe) {
body.Code = string(CodeInputInvalidArgument)
return body
}
// Unclassified error; consumers see the message but no stable code.
body.Code = string(CodeServerError)
return body
}
// defaultHint returns a canonical actionable hint for known error codes when
// the call site didn't set one. Spec §1.4 zero-config matrix mandates
// `auth.unauthenticated` envelopes carry "run weknora auth login" — this
// fallback covers the broad surface (auth status / kb list / kb get / search)
// without per-command hint plumbing.
// //
// Empty string for codes without a stable canonical hint. // Empty string for codes without a stable canonical hint.
func defaultHint(code ErrorCode) string { func defaultHint(code ErrorCode) string {
@@ -171,7 +100,7 @@ func defaultHint(code ErrorCode) string {
case CodeServerTimeout: case CodeServerTimeout:
return "request timed out; retry, or run `weknora doctor` to check connectivity" return "request timed out; retry, or run `weknora doctor` to check connectivity"
case CodeResourceNotFound: case CodeResourceNotFound:
return "verify the resource ID; list available with `weknora kb list`" return "verify the resource ID and try again"
case CodeInputInvalidArgument, CodeInputMissingFlag: case CodeInputInvalidArgument, CodeInputMissingFlag:
return "see `weknora <command> --help` for valid usage" return "see `weknora <command> --help` for valid usage"
case CodeInputConfirmationRequired: case CodeInputConfirmationRequired:

View File

@@ -36,34 +36,6 @@ func TestExitCode(t *testing.T) {
} }
} }
func TestToErrorBody(t *testing.T) {
t.Run("nil returns nil", func(t *testing.T) {
assert.Nil(t, ToErrorBody(nil))
})
t.Run("typed without cause", func(t *testing.T) {
body := ToErrorBody(NewError(CodeResourceNotFound, "kb missing"))
assert.Equal(t, "resource.not_found", body.Code)
assert.Equal(t, "kb missing", body.Message)
})
t.Run("typed with cause surfaces inner", func(t *testing.T) {
inner := errors.New("HTTP error 500: server exploded")
body := ToErrorBody(Wrapf(CodeServerError, inner, "hybrid search"))
assert.Equal(t, "server.error", body.Code)
// Both wrap context AND server cause must appear so agents see what
// actually broke, not just our wrap label.
assert.Equal(t, "hybrid search: HTTP error 500: server exploded", body.Message)
})
t.Run("flag error", func(t *testing.T) {
body := ToErrorBody(NewFlagError(errors.New("bad flag")))
assert.Equal(t, "input.invalid_argument", body.Code)
})
t.Run("unclassified", func(t *testing.T) {
body := ToErrorBody(errors.New("anything"))
assert.Equal(t, "server.error", body.Code)
assert.Equal(t, "anything", body.Message)
})
}
func TestPrintError(t *testing.T) { func TestPrintError(t *testing.T) {
t.Run("nil is silent", func(t *testing.T) { t.Run("nil is silent", func(t *testing.T) {
var buf bytes.Buffer var buf bytes.Buffer

View File

@@ -1,25 +0,0 @@
package cmdutil
import (
"io"
"github.com/Tencent/WeKnora/cli/internal/format"
)
// Exporter renders an envelope to a writer. Currently the only
// implementation is the JSON exporter; the interface stays in case a future
// renderer (templated text, table) needs to plug in without changing call
// sites that already write through Exporter.Write.
type Exporter interface {
Write(w io.Writer, env format.Envelope) error
}
// NewJSONExporter returns an Exporter that emits envelope JSON via
// format.WriteEnvelope (single-source encoder config: no HTML escape).
func NewJSONExporter() Exporter { return &jsonExporter{} }
type jsonExporter struct{}
func (jsonExporter) Write(w io.Writer, env format.Envelope) error {
return format.WriteEnvelope(w, env)
}

View File

@@ -1,27 +0,0 @@
package cmdutil
import (
"bytes"
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/format"
)
func TestJSONExporter_WritesEnvelope(t *testing.T) {
var buf bytes.Buffer
exp := NewJSONExporter()
require.NoError(t, exp.Write(&buf, format.Success(map[string]string{"k": "v"}, nil)))
var got map[string]any
require.NoError(t, json.Unmarshal(buf.Bytes(), &got))
assert.Equal(t, true, got["ok"])
}
func TestFlagError_IsSentinel(t *testing.T) {
err := NewFlagError(assert.AnError)
_, ok := err.(*FlagError)
assert.True(t, ok)
}

View File

@@ -76,9 +76,9 @@ func New() *Factory {
f.Config = func() (*config.Config, error) { f.Config = func() (*config.Config, error) {
cfg, err := config.Load() cfg, err := config.Load()
if err != nil { if err != nil {
// Map raw fs / parse errors to typed codes so envelopes don't // Map raw fs / parse errors to typed codes so the stderr line
// surface bare `server.error` for what's actually a local IO / // doesn't surface bare `server.error` for what's actually a
// corrupt-config problem. // local IO / corrupt-config problem.
if errors.Is(err, config.ErrCorrupt) { if errors.Is(err, config.ErrCorrupt) {
return nil, Wrapf(CodeLocalConfigCorrupt, err, "config malformed") return nil, Wrapf(CodeLocalConfigCorrupt, err, "config malformed")
} }

View File

@@ -2,17 +2,20 @@ package cmdutil
import ( import (
"errors" "errors"
"io"
"sort" "sort"
"strings" "strings"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/pflag" "github.com/spf13/pflag"
"github.com/Tencent/WeKnora/cli/internal/format"
) )
// JSONOptions captures the resolved --json + --jq state after CheckJSONFlags. // JSONOptions captures the resolved --json + --jq state after CheckJSONFlags.
// A non-nil value means the user requested JSON output; Fields restricts // A non-nil value means the user requested JSON output; Fields restricts
// data.items[*] when len(Fields) > 0; JQ is a jq expression applied to the // each top-level object (or each element of a top-level array) to the
// final envelope JSON. // listed keys; JQ is a jq expression applied to the final JSON.
type JSONOptions struct { type JSONOptions struct {
Fields []string Fields []string
JQ string JQ string
@@ -22,24 +25,32 @@ type JSONOptions struct {
// shorthand for `opts != nil`. // shorthand for `opts != nil`.
func (o *JSONOptions) Enabled() bool { return o != nil } func (o *JSONOptions) Enabled() bool { return o != nil }
// jsonNoOptSentinel marks a bare `--json` (no comma-separated values // Emit serializes v as bare JSON to w, honoring the resolved field-filter
// after it). pflag's NoOptDefVal mechanism stores this sentinel into the // and jq expression. Equivalent to calling format.WriteJSONFiltered with
// slice; CheckJSONFlags then maps it to "no field filter" (full envelope). // o.Fields / o.JQ, but lets call sites stay free of the format import.
// Safe to call on a nil receiver in case the caller composes it with
// Enabled().
func (o *JSONOptions) Emit(w io.Writer, v any) error {
if o == nil {
return format.WriteJSON(w, v)
}
return format.WriteJSONFiltered(w, v, o.Fields, o.JQ)
}
// jsonNoOptSentinel marks a bare `--json` (no comma-separated values after
// it). pflag's NoOptDefVal mechanism stores this sentinel into the slice;
// CheckJSONFlags then maps it to "no field filter" (full payload).
// //
// Rationale: WeKnora's envelope is itself the machine-readable contract // Field discovery moves to per-command `--help` "JSON fields available"
// (carries typed error.code / _meta / risk), so a bare `--json` always // sections rendered by AddJSONFlags.
// producing the full envelope keeps the standard `weknora kb list --json |
// jq` pattern working. Field discovery moves to per-command `--help`
// "JSON fields available" sections rendered by AddJSONFlags.
const jsonNoOptSentinel = "\x00json-no-value" const jsonNoOptSentinel = "\x00json-no-value"
// AddJSONFlags registers --json and --jq on cmd. // AddJSONFlags registers --json and --jq on cmd.
// //
// - `--json` → full envelope (no field filter) // - `--json` → bare JSON payload, no field filter
// - `--json id,name` → envelope with data.items[*] / data restricted // - `--json=id,name` → each object restricted to the listed fields
// to listed fields // - `--jq <expr>` → apply a jq expression to the JSON; requires
// - `--jq <expr>` → applies a jq expression after marshaling; // --json to be set explicitly
// requires --json to be set explicitly
// //
// `fields` is the set of available fields the user may pass; rendered in // `fields` is the set of available fields the user may pass; rendered in
// the command's help. Pass nil to skip the help annotation (uncommon). // the command's help. Pass nil to skip the help annotation (uncommon).
@@ -48,7 +59,7 @@ func AddJSONFlags(cmd *cobra.Command, fields []string) {
// Backticks reserved for pflag's UnquoteUsage to extract the varname; // Backticks reserved for pflag's UnquoteUsage to extract the varname;
// avoid them in the description so the help doesn't render the flag // avoid them in the description so the help doesn't render the flag
// name twice. // name twice.
f.StringSlice("json", nil, "Output JSON envelope (bare for full; --json=id,name for `fields`)") f.StringSlice("json", nil, "Output bare JSON (--json=id,name to project `fields`)")
f.Lookup("json").NoOptDefVal = jsonNoOptSentinel f.Lookup("json").NoOptDefVal = jsonNoOptSentinel
f.StringP("jq", "q", "", "Filter JSON output using a jq `expression` (requires --json)") f.StringP("jq", "q", "", "Filter JSON output using a jq `expression` (requires --json)")
@@ -56,7 +67,7 @@ func AddJSONFlags(cmd *cobra.Command, fields []string) {
sorted := append([]string(nil), fields...) sorted := append([]string(nil), fields...)
sort.Strings(sorted) sort.Strings(sorted)
// Append to Long without overwriting per-command prose. // Append to Long without overwriting per-command prose.
hdr := "\n\nJSON fields available via `--json id,name,...`:\n " + hdr := "\n\nJSON fields available via `--json=id,name,...`:\n " +
strings.Join(sorted, "\n ") strings.Join(sorted, "\n ")
if cmd.Long != "" { if cmd.Long != "" {
cmd.Long += hdr cmd.Long += hdr
@@ -71,8 +82,8 @@ func AddJSONFlags(cmd *cobra.Command, fields []string) {
// - (*JSONOptions, nil) --json set (possibly with --jq) // - (*JSONOptions, nil) --json set (possibly with --jq)
// - (nil, error) --jq without --json (plain error, exit 1) // - (nil, error) --jq without --json (plain error, exit 1)
// //
// Bare `--json` yields Fields == nil (full envelope). Explicit field list // Bare `--json` yields Fields == nil (no field filter). Explicit field
// yields Fields == []string{"id", "name", ...} (filter applied). // list yields Fields == []string{"id", "name", ...} (filter applied).
func CheckJSONFlags(cmd *cobra.Command) (*JSONOptions, error) { func CheckJSONFlags(cmd *cobra.Command) (*JSONOptions, error) {
f := cmd.Flags() f := cmd.Flags()
jsonFlag := f.Lookup("json") jsonFlag := f.Lookup("json")

View File

@@ -32,8 +32,8 @@ func newTestCmd(t *testing.T, captured **cmdutil.JSONOptions) *cobra.Command {
return cmd return cmd
} }
func TestAddJSONFlags_BareYieldsFullEnvelopeOpts(t *testing.T) { func TestAddJSONFlags_BareYieldsEnabledOptsWithNoFields(t *testing.T) {
// `--json` bare → Enabled() with empty Fields → caller emits full envelope. // `--json` bare → Enabled() with empty Fields → caller emits full payload.
var captured *cmdutil.JSONOptions var captured *cmdutil.JSONOptions
cmd := newTestCmd(t, &captured) cmd := newTestCmd(t, &captured)
cmd.SetArgs([]string{"--json"}) cmd.SetArgs([]string{"--json"})
@@ -53,8 +53,9 @@ func TestAddJSONFlags_BareYieldsFullEnvelopeOpts(t *testing.T) {
func TestAddJSONFlags_FieldsFlagParsing(t *testing.T) { func TestAddJSONFlags_FieldsFlagParsing(t *testing.T) {
// NoOptDefVal sentinel means the `=` form is required for value passing. // NoOptDefVal sentinel means the `=` form is required for value passing.
// Space form `--json id,name` parses as bare + positional, which is // Space form `--json id,name` parses as bare + positional, which is a
// documented divergence from gh CLI to keep bare-envelope semantics. // documented divergence from gh CLI: weknora keeps bare `--json` as a
// shortcut for "full payload".
cases := []struct { cases := []struct {
args []string args []string
want []string want []string

View File

@@ -1,17 +0,0 @@
package cmdutil
import (
"fmt"
"github.com/spf13/cobra"
)
// MustRequireFlag panics on programmer error (typo in flag name). cobra's
// MarkFlagRequired only returns an error when the named flag does not exist
// on the command, which means the caller has a typo at registration time —
// non-recoverable. Wrap so command builders stay one-line.
func MustRequireFlag(cmd *cobra.Command, name string) {
if err := cmd.MarkFlagRequired(name); err != nil {
panic(fmt.Sprintf("MarkFlagRequired %q: %v", name, err))
}
}

View File

@@ -1,9 +1,8 @@
// Package config reads and writes the user-level config at // Package config reads and writes the user-level config at
// $XDG_CONFIG_HOME/weknora/config.yaml. yaml.v3 directly; viper is intentionally // $XDG_CONFIG_HOME/weknora/config.yaml. yaml.v3 directly; viper is
// not used (see ADR-2). // intentionally not used (see ADR-2). Multi-host context map lives here;
// // the per-project link (.weknora/project.yaml) is handled by the
// v0.0 supports only Load/Save with multi-host context map; project link // projectlink package (see ADR-16).
// (.weknora/project.toml) is wired in v0.2 (ADR-16).
package config package config
import ( import (

View File

@@ -6,10 +6,9 @@ import (
"io" "io"
) )
// WriteJSON serializes v as one-line JSON to w. Bare-data contract: no // WriteJSON serializes v as one-line JSON to w. Bare-data contract: list
// envelope wrapper. Each list command emits its array directly, each // commands emit their array directly, single-resource commands emit their
// single-resource command emits its object directly. The shape is whatever // object directly. The shape is whatever the producing command marshals.
// the producing command marshals.
func WriteJSON(w io.Writer, v any) error { func WriteJSON(w io.Writer, v any) error {
enc := json.NewEncoder(w) enc := json.NewEncoder(w)
enc.SetEscapeHTML(false) enc.SetEscapeHTML(false)
@@ -31,9 +30,11 @@ func WriteJSON(w io.Writer, v any) error {
// - v marshals to a scalar → unchanged // - v marshals to a scalar → unchanged
// //
// Unknown field names are silently dropped so users can pass an aspirational // Unknown field names are silently dropped so users can pass an aspirational
// field set across heterogenous list outputs without per-command tailoring // field set across heterogenous list outputs.
// (same policy as the old envelope filter).
func WriteJSONFiltered(w io.Writer, v any, fields []string, jqExpr string) error { func WriteJSONFiltered(w io.Writer, v any, fields []string, jqExpr string) error {
if len(fields) == 0 && jqExpr == "" {
return WriteJSON(w, v)
}
raw, err := marshalJSON(v) raw, err := marshalJSON(v)
if err != nil { if err != nil {
return err return err

View File

@@ -1,128 +0,0 @@
// Package format renders command output: JSON envelope, jq, template, table.
//
// Non-TTY default = JSON envelope (ADR-5). Envelope schema is the contract
// shared with `weknora mcp serve` tools and external agents.
package format
import (
"encoding/json"
"io"
)
// Envelope is the canonical success/failure shape returned by every command.
//
// v0.2 ADR-3 added Notice / Risk. Both are absent on read-only commands;
// write commands populate Risk even on success so agents can record what
// action ran.
type Envelope struct {
OK bool `json:"ok"`
Data any `json:"data,omitempty"`
Error *ErrorBody `json:"error,omitempty"`
Meta *Meta `json:"_meta,omitempty"`
Notice *Notice `json:"_notice,omitempty"`
Risk *Risk `json:"risk,omitempty"`
}
// Notice carries system-level advisories independent of the command outcome:
// CLI update available, server-CLI version skew, etc. Agents read these to
// surface upgrade prompts to users without polluting `data`.
type Notice struct {
Update *UpdateNotice `json:"update,omitempty"`
VersionSkew *VersionSkewNotice `json:"version_skew,omitempty"`
}
// UpdateNotice indicates a newer CLI version is available.
type UpdateNotice struct {
Available bool `json:"available"`
Current string `json:"current"`
Latest string `json:"latest,omitempty"`
}
// VersionSkewNotice indicates the server is behind the CLI within the compat
// window. `Level` mirrors doctor's status semantics: "warn" (degraded but
// functional) or "error" (out of compat).
type VersionSkewNotice struct {
Client string `json:"client"`
Server string `json:"server"`
Level string `json:"level"`
}
// Risk classifies the operation the user is performing — not the error.
// Agents inspect this on every envelope. When Level == RiskHighRiskWrite and
// the operation requires confirmation (no -y), the CLI exits 10. See
// cli/AGENTS.md "Exit codes".
type Risk struct {
Level RiskLevel `json:"level"`
Action string `json:"action,omitempty"`
}
// Meta carries non-payload context fields useful to agents and observability.
//
// Pagination metadata (Page / PageSize / Total) lives here rather than in
// data.{...} so every list command's `data` field has the same shape —
// always `{items: [...]}` — and agents can branch on the resource type
// without per-list parser variants.
type Meta struct {
Context string `json:"context,omitempty"`
TenantID uint64 `json:"tenant_id,omitempty"`
KBID string `json:"kb_id,omitempty"`
RequestID string `json:"request_id,omitempty"`
NextCursor string `json:"next_cursor,omitempty"`
HasMore bool `json:"has_more,omitempty"`
Page int `json:"page,omitempty"`
PageSize int `json:"page_size,omitempty"`
Total int64 `json:"total,omitempty"`
Warnings []string `json:"warnings,omitempty"`
AppliedFilters []string `json:"applied_filters,omitempty"`
}
// RiskLevel classifies an operation. Agents use this to decide whether to
// retry, require explicit user approval, or stop.
type RiskLevel string
const (
RiskRead RiskLevel = "read"
RiskWrite RiskLevel = "write"
RiskHighRiskWrite RiskLevel = "high-risk-write"
)
// ErrorBody is the failure shape. `code` is a stable namespaced ID (e.g.
// "auth.unauthenticated"); `hint` is an actionable next step. Operation-level
// risk lives at the envelope level (Envelope.Risk), not here.
type ErrorBody struct {
Code string `json:"code"`
Message string `json:"message"`
Hint string `json:"hint,omitempty"`
RequestID string `json:"request_id,omitempty"`
Context string `json:"context,omitempty"`
Retryable bool `json:"retryable,omitempty"`
ConsoleURL string `json:"console_url,omitempty"`
Details map[string]any `json:"details,omitempty"`
}
// WriteEnvelope serializes env as one-line JSON to w. Used for non-TTY output
// and for `--json` per-command mode (the omnibus `--agent` mode-switch was
// removed in v0.2 ADR-3; see cli/AGENTS.md for the agent contract).
func WriteEnvelope(w io.Writer, env Envelope) error {
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
return enc.Encode(env)
}
// Success constructs a success envelope.
func Success(data any, meta *Meta) Envelope {
return Envelope{OK: true, Data: data, Meta: meta}
}
// SuccessWithRisk is Success + a per-operation Risk classification. Used by
// every write command (kb create/delete, doc upload/delete, api POST/PUT/
// DELETE, ...) so an agent reading any envelope can tell what kind of
// operation produced it without parsing the data shape.
func SuccessWithRisk(data any, meta *Meta, risk *Risk) Envelope {
return Envelope{OK: true, Data: data, Meta: meta, Risk: risk}
}
// Failure constructs a failure envelope.
func Failure(err *ErrorBody) Envelope {
return Envelope{OK: false, Error: err}
}

View File

@@ -1,65 +0,0 @@
package format
import (
"bytes"
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSuccessEnvelope(t *testing.T) {
env := Success(map[string]string{"id": "kb_abc"}, &Meta{Context: "prod", RequestID: "cli-01"})
var buf bytes.Buffer
require.NoError(t, WriteEnvelope(&buf, env))
var got map[string]any
require.NoError(t, json.Unmarshal(buf.Bytes(), &got))
assert.Equal(t, true, got["ok"])
assert.NotNil(t, got["data"])
assert.NotNil(t, got["_meta"])
assert.Nil(t, got["error"])
}
func TestFailureEnvelope(t *testing.T) {
env := Failure(&ErrorBody{
Code: "auth.unauthenticated",
Message: "no creds",
Hint: "run weknora auth login",
Retryable: false,
})
var buf bytes.Buffer
require.NoError(t, WriteEnvelope(&buf, env))
var got map[string]any
require.NoError(t, json.Unmarshal(buf.Bytes(), &got))
assert.Equal(t, false, got["ok"])
errBody := got["error"].(map[string]any)
assert.Equal(t, "auth.unauthenticated", errBody["code"])
assert.Equal(t, "run weknora auth login", errBody["hint"])
}
func TestEnvelope_RiskAndNotice(t *testing.T) {
env := Success(map[string]string{"id": "kb_x"}, nil)
env.Risk = &Risk{Level: RiskHighRiskWrite, Action: "delete kb_x"}
env.Notice = &Notice{Update: &UpdateNotice{Available: true, Current: "0.2.0", Latest: "0.3.0"}}
var buf bytes.Buffer
require.NoError(t, WriteEnvelope(&buf, env))
var got map[string]any
require.NoError(t, json.Unmarshal(buf.Bytes(), &got))
risk := got["risk"].(map[string]any)
assert.Equal(t, "high-risk-write", risk["level"])
assert.Equal(t, "delete kb_x", risk["action"])
notice := got["_notice"].(map[string]any)
upd := notice["update"].(map[string]any)
assert.Equal(t, true, upd["available"])
assert.Equal(t, "0.3.0", upd["latest"])
}
func TestEnvelope_NoEscapeHTML(t *testing.T) {
// Hints / messages may include & or <; ensure we don't HTML-escape them
// (would break agent jq pipelines).
env := Failure(&ErrorBody{Code: "x", Message: "a & b < c"})
var buf bytes.Buffer
require.NoError(t, WriteEnvelope(&buf, env))
assert.Contains(t, buf.String(), "a & b < c")
}

View File

@@ -9,127 +9,12 @@ import (
"github.com/itchyny/gojq" "github.com/itchyny/gojq"
) )
// WriteEnvelopeFiltered serializes env to w, optionally restricting
// data.items[*] (for list envelopes) or data (for single-resource envelopes)
// to the named fields, then applying a jq expression on the result.
//
// - len(fields) == 0 → no field filter (full envelope)
// - jqExpr == "" → no jq filter (just write the envelope)
//
// The envelope structure (ok / data / error / _meta / risk / _notice)
// is preserved across field filtering — only Data is rewritten. jq operates
// on the entire envelope JSON so users can `--jq '.data.items[].id'`.
func WriteEnvelopeFiltered(w io.Writer, env Envelope, fields []string, jqExpr string) error {
raw, err := marshalEnvelope(env)
if err != nil {
return err
}
if len(fields) > 0 {
raw, err = applyFieldFilter(raw, fields)
if err != nil {
return err
}
}
if jqExpr != "" {
return writeJQ(w, raw, jqExpr)
}
_, err = w.Write(raw)
return err
}
// marshalEnvelope returns the canonical newline-terminated envelope JSON used
// by the non-filtered path. Identical to WriteEnvelope but as bytes.
func marshalEnvelope(env Envelope) ([]byte, error) {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(env); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// applyFieldFilter rewrites envelope.data so that nested objects keep only
// the named keys. The shape rules:
//
// - data is an object with `items` (the standard list envelope shape):
// filter each items[*] to the named fields.
// - data is an object without `items` (single-resource envelope):
// filter data itself.
// - data is an array (uncommon — currently no command produces this, but
// defensive): filter each [*].
// - data is nil / scalar: unchanged.
//
// Unknown field names are silently ignored so a user may pass an
// aspirational field set across heterogenous list outputs without per-
// command tailoring.
func applyFieldFilter(envelopeJSON []byte, fields []string) ([]byte, error) {
var raw map[string]json.RawMessage
if err := json.Unmarshal(envelopeJSON, &raw); err != nil {
return nil, fmt.Errorf("field filter: parse envelope: %w", err)
}
dataRaw, ok := raw["data"]
if !ok || len(dataRaw) == 0 || string(dataRaw) == "null" {
return envelopeJSON, nil
}
filtered, err := filterDataPayload(dataRaw, fields)
if err != nil {
return nil, err
}
raw["data"] = filtered
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(raw); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// filterDataPayload dispatches on the shape of the data JSON value.
func filterDataPayload(dataRaw json.RawMessage, fields []string) (json.RawMessage, error) {
trimmed := bytes.TrimSpace(dataRaw)
if len(trimmed) == 0 {
return dataRaw, nil
}
switch trimmed[0] {
case '{':
return filterObjectData(dataRaw, fields)
case '[':
return filterArrayItems(dataRaw, fields)
default:
// scalar (number / string / bool / null) — nothing to filter
return dataRaw, nil
}
}
// filterObjectData filters either data.items[*] (list shape) or data (single
// resource).
func filterObjectData(dataRaw json.RawMessage, fields []string) (json.RawMessage, error) {
var obj map[string]json.RawMessage
if err := json.Unmarshal(dataRaw, &obj); err != nil {
return nil, fmt.Errorf("field filter: parse data object: %w", err)
}
if items, ok := obj["items"]; ok {
filtered, err := filterArrayItems(items, fields)
if err != nil {
return nil, err
}
obj["items"] = filtered
return json.Marshal(obj)
}
// Single-resource envelope: filter the data object itself.
return filterObjectKeys(dataRaw, fields)
}
// filterArrayItems applies filterObjectKeys to each element of an array. // filterArrayItems applies filterObjectKeys to each element of an array.
// Non-object elements (e.g. an array of strings) are passed through. // Non-object elements (e.g. an array of strings) are passed through.
func filterArrayItems(arrayRaw json.RawMessage, fields []string) (json.RawMessage, error) { func filterArrayItems(arrayRaw json.RawMessage, fields []string) (json.RawMessage, error) {
var items []json.RawMessage var items []json.RawMessage
if err := json.Unmarshal(arrayRaw, &items); err != nil { if err := json.Unmarshal(arrayRaw, &items); err != nil {
return nil, fmt.Errorf("field filter: parse data items: %w", err) return nil, fmt.Errorf("field filter: parse array: %w", err)
} }
for i, item := range items { for i, item := range items {
trimmed := bytes.TrimSpace(item) trimmed := bytes.TrimSpace(item)
@@ -161,21 +46,20 @@ func filterObjectKeys(objRaw json.RawMessage, fields []string) (json.RawMessage,
return json.Marshal(dst) return json.Marshal(dst)
} }
// writeJQ evaluates expr against envelopeJSON and writes each result line // writeJQ evaluates expr against raw and writes each result line by line to w.
// by line to w. String results render without quotes (so `--jq '.x.name'` // String results render without quotes (so `--jq '.name'` yields shell-friendly
// yields shell-friendly bare strings); non-string results use // bare strings); non-string results use encoding/json.
// encoding/json.
// //
// Returns input.invalid_argument-shaped errors via plain errors.New + fmt; // Returns input.invalid_argument-shaped errors via plain errors.New + fmt;
// the caller is responsible for wrapping with cmdutil.NewError if it wants // the caller is responsible for wrapping with cmdutil.NewError if it wants
// the typed envelope code. // the typed code.
func writeJQ(w io.Writer, envelopeJSON []byte, expr string) error { func writeJQ(w io.Writer, raw []byte, expr string) error {
query, err := gojq.Parse(expr) query, err := gojq.Parse(expr)
if err != nil { if err != nil {
return fmt.Errorf("jq parse: %w", err) return fmt.Errorf("jq parse: %w", err)
} }
var input any var input any
if err := json.Unmarshal(envelopeJSON, &input); err != nil { if err := json.Unmarshal(raw, &input); err != nil {
return fmt.Errorf("jq input parse: %w", err) return fmt.Errorf("jq input parse: %w", err)
} }
iter := query.Run(input) iter := query.Run(input)

View File

@@ -1,195 +0,0 @@
package format_test
import (
"bytes"
"encoding/json"
"strings"
"testing"
"github.com/Tencent/WeKnora/cli/internal/format"
)
func TestWriteEnvelopeFiltered_NoFilter(t *testing.T) {
env := format.Success(map[string]any{"items": []any{
map[string]any{"id": "1", "name": "alpha"},
}}, &format.Meta{KBID: "kb_x"})
buf := &bytes.Buffer{}
if err := format.WriteEnvelopeFiltered(buf, env, nil, ""); err != nil {
t.Fatalf("err = %v", err)
}
var got map[string]any
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
t.Fatalf("parse: %v", err)
}
if got["ok"] != true {
t.Errorf("ok = %v, want true", got["ok"])
}
}
func TestWriteEnvelopeFiltered_FieldsOnListItems(t *testing.T) {
env := format.Success(map[string]any{"items": []any{
map[string]any{"id": "1", "name": "alpha", "kb_id": "kb_x", "updated_at": "2026-01-01"},
map[string]any{"id": "2", "name": "beta", "kb_id": "kb_x", "updated_at": "2026-01-02"},
}}, nil)
buf := &bytes.Buffer{}
if err := format.WriteEnvelopeFiltered(buf, env, []string{"id", "name"}, ""); err != nil {
t.Fatalf("err = %v", err)
}
var got struct {
OK bool `json:"ok"`
Data struct {
Items []map[string]string `json:"items"`
} `json:"data"`
}
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
t.Fatalf("parse: %v\n%s", err, buf.String())
}
if len(got.Data.Items) != 2 {
t.Fatalf("items len = %d, want 2", len(got.Data.Items))
}
for i, item := range got.Data.Items {
if _, has := item["kb_id"]; has {
t.Errorf("item[%d] should not have kb_id: %v", i, item)
}
if _, has := item["updated_at"]; has {
t.Errorf("item[%d] should not have updated_at: %v", i, item)
}
if item["id"] == "" || item["name"] == "" {
t.Errorf("item[%d] missing required keys: %v", i, item)
}
}
}
func TestWriteEnvelopeFiltered_FieldsOnSingleObject(t *testing.T) {
env := format.Success(map[string]any{
"id": "kb_x",
"name": "Engineering",
"owner": "alice",
}, nil)
buf := &bytes.Buffer{}
if err := format.WriteEnvelopeFiltered(buf, env, []string{"id", "name"}, ""); err != nil {
t.Fatalf("err = %v", err)
}
var got struct {
Data map[string]string `json:"data"`
}
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
t.Fatalf("parse: %v\n%s", err, buf.String())
}
if _, has := got.Data["owner"]; has {
t.Errorf("should not have owner: %v", got.Data)
}
if got.Data["id"] != "kb_x" || got.Data["name"] != "Engineering" {
t.Errorf("missing kept fields: %v", got.Data)
}
}
func TestWriteEnvelopeFiltered_UnknownFieldSilent(t *testing.T) {
env := format.Success(map[string]any{"id": "1"}, nil)
buf := &bytes.Buffer{}
if err := format.WriteEnvelopeFiltered(buf, env, []string{"id", "nonexistent"}, ""); err != nil {
t.Fatalf("err = %v", err)
}
var got struct {
Data map[string]string `json:"data"`
}
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
t.Fatalf("parse: %v", err)
}
if got.Data["id"] != "1" {
t.Errorf("id missing: %v", got.Data)
}
if _, has := got.Data["nonexistent"]; has {
t.Errorf("nonexistent should be silently dropped: %v", got.Data)
}
}
func TestWriteEnvelopeFiltered_PreservesEnvelopeFields(t *testing.T) {
// Even with field filter, meta/risk/error must be preserved.
env := format.Success(map[string]any{"items": []any{
map[string]any{"id": "1", "name": "x", "kb_id": "kb"},
}}, &format.Meta{KBID: "kb_x", RequestID: "req_123"})
buf := &bytes.Buffer{}
if err := format.WriteEnvelopeFiltered(buf, env, []string{"id"}, ""); err != nil {
t.Fatalf("err = %v", err)
}
var got struct {
OK bool `json:"ok"`
Meta *format.Meta `json:"_meta"`
Data struct {
Items []map[string]any `json:"items"`
} `json:"data"`
}
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
t.Fatalf("parse: %v\n%s", err, buf.String())
}
if got.Meta == nil || got.Meta.RequestID != "req_123" {
t.Errorf("_meta lost or mangled: %+v", got.Meta)
}
}
func TestWriteEnvelopeFiltered_JQOnly(t *testing.T) {
env := format.Success(map[string]any{"items": []any{
map[string]any{"id": "1", "name": "alpha"},
map[string]any{"id": "2", "name": "beta"},
}}, nil)
buf := &bytes.Buffer{}
if err := format.WriteEnvelopeFiltered(buf, env, nil, ".data.items[].id"); err != nil {
t.Fatalf("err = %v", err)
}
// gh CLI parity: string results render without JSON quotes
// (see https://github.com/cli/cli/blob/trunk/pkg/export/filter.go).
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
if len(lines) != 2 {
t.Fatalf("expected 2 lines, got %d: %q", len(lines), buf.String())
}
if lines[0] != "1" || lines[1] != "2" {
t.Errorf("wrong output: %q", buf.String())
}
}
func TestWriteEnvelopeFiltered_FieldsAndJQ(t *testing.T) {
env := format.Success(map[string]any{"items": []any{
map[string]any{"id": "1", "name": "alpha", "kb_id": "kb"},
map[string]any{"id": "2", "name": "beta", "kb_id": "kb"},
}}, nil)
buf := &bytes.Buffer{}
if err := format.WriteEnvelopeFiltered(buf, env, []string{"id", "name"}, ".data.items | length"); err != nil {
t.Fatalf("err = %v", err)
}
out := strings.TrimSpace(buf.String())
if out != "2" {
t.Errorf("expected '2', got %q", out)
}
}
func TestWriteEnvelopeFiltered_JQParseError(t *testing.T) {
env := format.Success(map[string]any{"x": 1}, nil)
buf := &bytes.Buffer{}
err := format.WriteEnvelopeFiltered(buf, env, nil, ".[invalid jq")
if err == nil {
t.Fatal("expected parse error, got nil")
}
if !strings.Contains(err.Error(), "jq parse") {
t.Errorf("expected 'jq parse' in error, got %q", err.Error())
}
}
func TestWriteEnvelopeFiltered_ScalarDataPassThrough(t *testing.T) {
// Defensive: scalar data should not crash field filter.
env := format.Success("just a string", nil)
buf := &bytes.Buffer{}
if err := format.WriteEnvelopeFiltered(buf, env, []string{"id"}, ""); err != nil {
t.Fatalf("err = %v", err)
}
if !strings.Contains(buf.String(), `"just a string"`) {
t.Errorf("scalar data lost: %s", buf.String())
}
}

View File

@@ -34,8 +34,8 @@ type Store interface {
} }
// FileStore writes 0600 plain-text files under $XDG_CONFIG_HOME/weknora/secrets/<context>. // FileStore writes 0600 plain-text files under $XDG_CONFIG_HOME/weknora/secrets/<context>.
// It is the headless / CI default and the keychain fallback. Real keychain support // It is the headless / CI default and the keychain fallback (see ADR-17 for
// is wired in v0.1 (see ADR-17 for namespace strategy). // namespace strategy).
type FileStore struct { type FileStore struct {
root string root string
} }

View File

@@ -3,9 +3,9 @@
// //
// Accumulator is the canonical sink: every callback event appends to a // Accumulator is the canonical sink: every callback event appends to a
// buffered Content string and updates terminal-state fields like References // buffered Content string and updates terminal-state fields like References
// and SessionID. The non-streaming JSON envelope mode reads .Result() once // and SessionID. The non-streaming JSON mode reads .Result() once .Done()
// .Done() is true; streaming mode writes Content tokens directly to stdout // is true; streaming mode writes Content tokens directly to stdout and
// and only consults the accumulator for the final References footer. // only consults the accumulator for the final References footer.
package sse package sse
import ( import (

View File

@@ -1,11 +1,7 @@
// Package xdg consolidates the XDG-rooted file path lookup and atomic-write // Package xdg consolidates the XDG-rooted file path lookup and atomic-write
// idioms used by config / compat / secrets / future stores. // idioms used by config / compat / secrets / projectlink. Centralizing means
// // a single place to fix behavior (mode bits, fallback dirs, mkdir order,
// Before extraction these patterns were copy-pasted across cli/internal/{config, // error wrapping) instead of copy-pasting across stores.
// compat, secrets}; reuse review surfaced 3× duplication of Path and 2× of the
// tmp+rename atomic-write recipe. Centralizing here means a single place to fix
// behavior (mode bits, fallback dirs, mkdir order, error wrapping) and one less
// thing to copy when v0.2 adds project-link / state stores.
package xdg package xdg
import ( import (