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