refactor(cli): finish context→profile cascade + post-review hardening (BREAKING)

Post-review polish on the v0.7 wire / surface contract. Bundles five
follow-ups that landed after the main BREAKING feat commit:

1. Complete context→profile cascade (internal API + YAML schema)

The prior commit renamed only the user-visible surface (commands /
flags / env / project link / envelope field). The internal Go API
and on-disk config schema were still half-renamed — an L-25
self-consistency violation flagged by post-merge review. Closed here:

Internal Go API:
- config.Context           → config.Profile
- config.Config.CurrentContext → CurrentProfile
- config.Config.Contexts       → Profiles
- LoginOptions.Context     → LoginOptions.Profile
- clearContextSecrets()    → clearProfileSecrets()
- saveContextRef()         → saveProfileRef()
- secrets.Store: param name `context` → `profile` (interface +
  FileStore + KeyringStore + MemStore)
- cmdutil.LoadSecret(store, context, key) → LoadSecret(store, profile, key)
- cmdutil.RefreshAndPersist's ctxName → profileName
- Local var `ctx := &config.Profile{...}` → `prof := &config.Profile{...}`
  in auth/login.go to eliminate the visual collision with Go stdlib
  context.Context that motivated the whole rename in the first place.

On-disk config.yaml schema:
- current_context: → current_profile:
- contexts:       → profiles:
- Pre-1.0 break, no compat alias. Users on v0.6 dogfooded configs
  must delete ~/.config/weknora/config.yaml or hand-rename the two
  keys (CHANGELOG migration note added).

Tests / fixtures / golden files:
- factory_test.go YAML fixture + assertion updated.
- acceptance/e2e/e2e_test.go writeContextYAML → writeProfileYAML,
  fixture YAML keys updated.
- acceptance/testdata/wire/doctor.error_network.json golden updated
  ("active context" → "active profile" in hint string).

User-visible prose sweep:
- cmd/mcp/serve.go --help Long: "active context (or --context)" →
  "active profile (or --profile)" — most-visible miss.
- cmd/{kb/list, search/kb, session/list, api/api} Short/Long help.
- cmd/auth/login.go stdout: `(context=%s)` → `(profile=%s)`.
- cmd/auth/logout.go error: `"no current context"` → `"no current profile"`.
- cmd/doctor/doctor.go hint string (also the wire golden above).
- cmd/auth/refresh.go error: `"refresh token missing for context"` →
  `"refresh token missing for profile"`.
- README.md: `## Multi-context` H2 → `## Multi-profile`; code-block
  comment `# current context` → `# current profile`.

Code-comment / docstring sweep across cli/cmd/auth/ and
cli/internal/cmdutil/. Comments referencing Go stdlib context.Context,
the RAG / LLM "context window" concept, and historical CHANGELOG
entries for v0.4 / v0.5 were left alone.

CHANGELOG v0.7 BREAKING entry gains the on-disk-schema bullet under
the existing "context → profile" item.

2. Profile name validation (shell-injection guard)

`envelope.error.retry_command` is a single shell-string field. An
AI agent that exec()s it via `sh -c <retry_command>` was injectable
through a maliciously-named profile:

  weknora auth logout --name 'x; rm -rf ~'
  # would produce: retry_command = "weknora auth logout --name x; rm -rf ~ -y"

`cmd/profile/add.go` already enforced an alphanumeric + `-_.`
allowlist via `validateName`. The `auth login` and `auth logout`
paths bypassed it.

- Moved validation from `cmd/profile/add.go` to
  `cli/internal/cmdutil/profilename.go` as exported
  `ValidateProfileName` (cmdutil is the import-cycle-safe home;
  internal/config can't depend on cmdutil).
- `auth login` runs the validator before any persist call.
- `auth logout` runs the validator on `opts.Name` before
  constructing `retry_command`.
- Unit tests (`profilename_test.go`) cover the allowlist, empty
  rejection, path-traversal, shell metacharacters (`;`, `&`, `|`,
  `$()`, backticks, quotes, whitespace, glob, redirects), and the
  user-facing hint text. The shell-metachar test exists as a
  regression guard.

Wire shape (`retry_command` string → `retry_command_argv []string`)
remains a v0.8 additive change per ROADMAP — this fix removes the
practical exploit path without touching the wire contract.

3. AI-agent terminology disambiguation

"agent" has three referents in this codebase: (a) WeKnora's
server-side Custom Agent resource, (b) the removed `agent invoke`
verb, (c) external LLM/automation consumers. Per project memory
feedback_no_meta_disambiguation_in_docs, the fix is full-term
naming, not "X has N meanings" prose. Surgical changes at section
headers + ambiguous prose:

- AGENTS.md: "Agent decision shortcuts" → "AI agent decision
  shortcuts"; "agent-callable surface" → "AI-agent-callable
  surface".
- README.md: "Designed to be agent-first" → "AI-agent-first";
  "Other agent ergonomics" → "Other AI-agent ergonomics"; "in
  agent contexts" → "in AI-agent contexts"; "for CI / agents" →
  "for CI / AI agents".

Anaphoric "agents" inside paragraphs that already established
"AI agents" was left alone — full substitution everywhere would
have been prose noise without clarity gain.

4. Wire-contract review follow-ups

Real findings from a second-pass review of the v0.7 envelope /
streaming / surface design. Per project memory
feedback_check_in_domain_anchor_first, candidate findings were
first verified against the in-domain peer CLI explicitly cited as
the envelope anchor; two earlier-flagged issues turned out to be
in-pattern and were withdrawn.

Surviving fixes:

- AGENTS.md success-envelope example rewritten. The prior example
  showed `has_more: false` / `_notice: {}` as if they were always
  present, but both fields are `omitempty` and never serialize
  when zero / nil. Replaced with three realistic shapes (list /
  single resource / mutation with no payload) and added a note
  that optional fields are omitted when empty.

- cmd/chat/chat.go Args: MinimumNArgs(1) → ExactArgs(1).
  v0.6 silently joined `weknora chat hello world` into
  `"hello world"`. v0.7 now rejects multi-arg with exit 2,
  matching `weknora session ask`. BREAKING; CHANGELOG entry
  added under v0.7 BREAKING.

- internal/output/envelope.go extracts NewEnvelope(data, meta,
  profile) constructor. The jq-filter path in
  cmdutil.FormatOptions.Emit was manually rebuilding the
  envelope literal alongside the canonical WriteEnvelope path —
  drift risk when fields are added. Single construction point now.

- internal/cmdutil/factory.go adds AddKBFlag(cmd) helper.
  Five files (chat, doc/list, doc/upload, doc/create, doc/fetch)
  had verbatim-identical `cmd.Flags().String("kb", ...)`
  declarations. Centralised so flag name + help text stay
  in sync with Factory.ResolveKB. Docstring reordering + gofmt
  fixup landed in the same edit to keep ResolveKB's own godoc
  attached to its function.

5. OSS-readiness comment / doc sweep

Pre-publication scrub of code, comments, and shipped Markdown to
remove references that only make sense in the development repo:

- AGENTS.md "Deliberate deviations + mainstream alignments"
  section: removed peer-project name-drops from the comparison
  table; rewrote as five flagged design decisions with rationale
  but no specific competitor named. The four rows that previously
  contrasted against a named peer CLI now state WeKnora's choice
  + rationale directly. Section header renamed to "Design
  decisions worth flagging" since it is no longer a
  deviation/alignment matrix.

- CHANGELOG v0.7 BREAKING rationales: three references to a
  named peer CLI removed; the context→profile rationale now
  cites only mainstream multi-credential CLIs by category (AWS /
  Stripe / OpenAI / Anthropic), and the `api -d/--data` removal
  rationale cites only `gh api` / `curl`. `chat` BREAKING entry
  rationale similarly simplified.

- 35 cross-references to design-spec section numbers (§4.1 /
  §4.5 / §5.3 etc.) removed from Go doc comments and test
  comments across 13 files. The referenced spec lives outside
  the shipped tree; readers of the public repo cannot resolve
  them. Each reference replaced with a self-contained semantic
  description (e.g. "the batch envelope" / "AGENTS.md section
  on the success path").

- Mixed-language strings translated to English:
  - Four Go comments: internal/cmdutil/exit.go:213,215,
    internal/cmdutil/errors.go:156,
    internal/output/batch_test.go:90,
    internal/output/envelope_test.go:27.
  - One CHANGELOG section title:
    `v0.7 — Agent-first wire contract + 命令面集中清理` →
    `... + command-surface cleanup`.
  - CJK test fixtures (internal/text/truncate_test.go CJK
    truncation cases, cmd/session/list_test.go Chinese session
    title, acceptance/e2e/e2e_test.go Chinese RAG corpus)
    retained — they are intentional test inputs, not stray prose.

- Makefile help comment: `golangci-lint added in PR-9` →
  `golangci-lint planned`. Internal PR numbering should not
  surface in shipped Makefile prose.

Build green, 28/28 packages, +5 new ValidateProfileName tests.
go vet / gofmt / go mod verify / go mod tidy all clean.

Rationale for the cascade: pre-1.0 is the cheapest moment to close
L-25 self-consistency (L-26). The half-finished internal rename
would have perpetuated the very `context` vs `context.Context`
ambiguity that motivated v0.7's user-visible rename in the first
place.
This commit is contained in:
nullkey
2026-05-21 23:44:16 +08:00
committed by lyingbug
parent 2ce348d020
commit 2ee9741fa1
74 changed files with 657 additions and 491 deletions

View File

@@ -12,25 +12,37 @@ JSON field you write becomes part of an agent's decision-making input.**
### Stdout (success path)
All `--format json` (default) commands emit a symmetric envelope:
All `--format json` (default) commands emit a symmetric envelope. Optional
fields are `omitempty` — they only appear when populated:
```json
// list (kb list, doc list, ...) — data is an array, meta carries count
{
"ok": true,
"data": "<T>",
"meta": {"count": 0, "has_more": false},
"_notice": {},
"profile": "default"
"data": [ {"id": "kb_abc", "name": "prod"} ],
"meta": {"count": 1},
"profile": "prod"
}
// single resource (kb view, doc view, ...) — data is an object
{
"ok": true,
"data": {"id": "kb_abc", "name": "prod", "description": "..."},
"profile": "prod"
}
// mutation success with no payload (some delete / edit paths)
{"ok": true, "profile": "prod"}
```
`data` is omitted on mutation-only success (no payload). `meta` carries list
counters (`count`, `has_more`) and batch successes/failures. `meta.next_cursor`,
`meta.total_count`, and `meta.request_id` are reserved — not currently
populated; planned for v0.8 when the SDK exposes pagination cursors and response
headers. `_notice` is reserved — open-map infrastructure is in place for
deprecation / version_skew / security notices; producer wiring planned for v0.8.
`profile` echoes the resolved profile name.
counters (`count`, `has_more`) and batch successes/failures, and is omitted
when empty. `meta.next_cursor`, `meta.total_count`, and `meta.request_id` are
reserved — not currently populated; planned for v0.8 when the SDK exposes
pagination cursors and response headers. `_notice` is reserved — open-map
infrastructure is in place for deprecation / version_skew / security notices;
the field is omitted until a producer is wired in v0.8. `profile` echoes the
resolved profile name and is omitted when no profile is configured.
### Stderr (error path)
@@ -126,42 +138,39 @@ Make errors structured, actionable, and specific.
> subcommand (`weknora version --format json`) or `WEKNORA_AGENT_HELP=1
> weknora <cmd> --help`. Planned fix for v0.8.
## Deliberate deviations + mainstream alignments
## Design decisions worth flagging
Five design decisions: four deliberate deviations from mainstream CLI
conventions, one mainstream alignment.
Five design decisions readers may want context on: where WeKnora picks an
opinionated default, what the trade-off is, and what mainstream practice it
is or isn't aligned with.
### 1. Channel split: success → stdout, error → stderr
| | |
|---|---|
| **Mainstream** | lark CLI puts both success and error envelopes on stdout |
| **WeKnora** | success envelope → stdout; error envelope → stderr |
| **Rationale** | `weknora ... --format json \| jq '.data[]'` must not mix error objects into the data stream. Channel split lets pipeline consumers suppress errors with `2>/dev/null` and still get clean JSON on stdout. (kubectl follows this; gh does not.) |
| **Rationale** | `weknora ... --format json \| jq '.data[]'` must not mix error objects into the data stream. Channel split lets pipeline consumers suppress errors with `2>/dev/null` and still get clean JSON on stdout. (kubectl follows this convention.) |
### 2. `weknora api DELETE` triggers exit-10 confirmation
| | |
|---|---|
| **Mainstream** | gh `gh api` / lark / Stripe CLI raw-API commands don't prompt confirm; they rely on restricted API keys |
| **WeKnora** | DELETE triggers exit-10 (`input.confirmation_required`); user bypasses with `-y/--yes` |
| **Rationale** | DELETE is irreversible; self-hosted deployments may not have restricted credential infrastructure. Defensive default because agents are common consumers. |
| **Rationale** | DELETE is irreversible. Most raw-API CLI commands rely on restricted credentials for safety, but self-hosted deployments may not have restricted-credential infrastructure available. Defensive default because agents are common consumers. |
### 3. `retry_command` distinct from `hint`
| | |
|---|---|
| **Mainstream** | lark embeds fix commands inside prose hint strings |
| **WeKnora** | two separate fields: `retry_command` (suggested next argv, directly-executable for non-destructive errors; informational only on exit-10) + `hint` (prose) |
| **Rationale** | Agents don't regex-extract argv from prose — known fragility. Trade-off: one extra envelope field. On exit-10, the user must approve the destructive write; agents surface `retry_command` for human review, not auto-execution. |
### 4. NDJSON event stream has no envelope wrapping — mainstream alignment
### 4. NDJSON event stream has no envelope wrapping
| | |
|---|---|
| **Mainstream** | 4/4 CLIs with NDJSON event streams (Claude Code / Codex / Gemini / lark webhook) use bare `{type:...}` — zero use envelope wrapping |
| **WeKnora** | same as mainstream |
| **Rationale** | Streaming envelope requires unwrap before dispatch — net burden with no benefit. This is not a deviation; it is explicit alignment with the surveyed field. |
| **WeKnora** | streaming commands (`chat`, `session ask`) emit bare `{type:...}` per line; no envelope |
| **Rationale** | This matches established practice across NDJSON-emitting CLIs and webhook protocols. A streaming envelope requires unwrap before dispatch — net burden with no benefit. |
### 5. No `schema_version` field in payload
@@ -409,9 +418,9 @@ Agents parse the first colon to extract the typed code. The exit code class (see
<!-- ERROR_REFERENCE_END -->
### Agent decision shortcuts
### AI agent decision shortcuts
For common retry patterns, agents can hardcode:
For common retry patterns, AI agents can hardcode:
- `network.*` → retry with exponential backoff
- `auth.token_expired` → run `weknora auth refresh`, then retry once
@@ -468,7 +477,7 @@ The curated 10 tools (`cli/internal/mcp/tools.go`):
| `agent_list` | list custom agents |
| `agent_invoke` | run a query through a custom agent |
Adding a tool is a deliberate API expansion — the agent-callable surface is the reason this CLI ships an MCP server, not its CLI command list, so the registration list in `registerTools` is maintained by hand.
Adding a tool is a deliberate API expansion — the AI-agent-callable surface is the reason this CLI ships an MCP server, not its CLI command list, so the registration list in `registerTools` is maintained by hand.
## Command surface design SOP

View File

@@ -12,7 +12,7 @@ CLI history before v0.3 is recorded in the project root
## [Unreleased]
### v0.7 — Agent-first wire contract + 命令面集中清理
### v0.7 — Agent-first wire contract + command-surface cleanup
#### BREAKING (v0.6 → v0.7)
- **All JSON output now wrapped in symmetric envelope.**
@@ -44,14 +44,17 @@ CLI history before v0.3 is recorded in the project root
- **`weknora context` command group renamed to `weknora profile`.**
- Subcommands `context list/add/remove/use` → `profile list/add/remove/use`.
- Global flag `--context` → `--profile`.
- On-disk config `~/.config/weknora/config.yaml` keys `current_context:` /
`contexts:` → `current_profile:` / `profiles:` (no backwards-compat
alias; delete the file or rename the keys by hand to migrate).
- Binding file `.weknora/project.yaml` field `context:` → `profile:`
(no backwards-compat alias; re-run `weknora link` to regenerate).
(re-run `weknora link` to regenerate).
- `profile use` JSON fields `current_context` / `previous_context` →
`current_profile` / `previous_profile`.
- `weknora link` JSON field `context` → `profile`.
- Rationale: `context` collided with LLM "context window" / RAG "context" /
Go `context.Context`; mainstream multi-credential CLIs (AWS / Stripe /
OpenAI / Anthropic / lark) all use `profile`.
Go `context.Context`. Mainstream multi-credential CLIs (AWS, Stripe,
OpenAI, Anthropic) settle on `profile` as the term of art.
- **`weknora agent invoke` removed; use `weknora session ask --agent <id>`.**
- Server route is `POST /sessions/{session_id}/agent-qa` — session-anchored.
- `weknora agent` keeps CRUD only (list / view / create / edit / delete /
@@ -73,7 +76,7 @@ CLI history before v0.3 is recorded in the project root
- **`weknora api -d/--data` flag removed; use `--input <file>` or `--input -`
(stdin).**
- `weknora api` now accepts any non-empty HTTP method (whitelist removed;
aligns with gh / lark / curl).
matches `gh api` / `curl` behaviour).
- Migration: `weknora api -d '{"foo":1}' /endpoint` →
`echo '{"foo":1}' | weknora api --input - /endpoint`.
- **Batch operations envelope shape — per-item `ok` pattern.**
@@ -91,6 +94,11 @@ CLI history before v0.3 is recorded in the project root
- `input.unknown_subcommand` with `detail.{unknown, command_path, available[]}`
+ `retry_command: "<parent> --help"`. Replaces v0.6's free-form
`"unknown command \"x\" for \"weknora\""` prose.
- **`weknora chat` requires the query as a single quoted argument.**
- v0.6: `MinimumNArgs(1)` silently joined `weknora chat hello world` into
`"hello world"`.
- v0.7: `ExactArgs(1)` rejects multi-arg with exit 2; matches
`weknora session ask`. Quote the query: `weknora chat "hello world"`.
#### Added
- **`WEKNORA_PROFILE` env var** selects the active profile for a single

View File

@@ -4,7 +4,7 @@
# make build compile to ./bin/weknora with version metadata
# make test go test ./...
# make test-coverage same with coverage report
# make lint go vet (golangci-lint added in PR-9)
# make lint go vet (golangci-lint planned)
# make tidy go mod tidy
# make clean remove ./bin

View File

@@ -61,7 +61,7 @@ release. Grab the latest from the [Releases page](https://github.com/Tencent/WeK
# 1. Log in to your WeKnora server (interactive password prompt)
weknora auth login --host https://kb.example.com
# 2. Or pipe an API key from stdin (for CI / agents)
# 2. Or pipe an API key from stdin (for CI / AI agents)
echo "sk-..." | weknora auth login --host https://kb.example.com --with-token
# 3. List knowledge bases
@@ -116,7 +116,7 @@ For AI agents (Claude Code, Cursor, Gemini CLI, etc.) integrating WeKnora:
---
## Multi-context
## Multi-profile
Switch between several WeKnora servers (or several tenants on the same server)
without re-logging in:
@@ -136,7 +136,7 @@ under `$XDG_CONFIG_HOME/weknora/secrets/`. The active profile lives in
To remove a profile's stored credentials:
```bash
weknora auth logout # current context
weknora auth logout # current profile
weknora auth logout --name staging # specific
weknora auth logout --all
```
@@ -145,7 +145,7 @@ weknora auth logout --all
## Wire contract
Designed to be agent-first. Stable across minor releases; breaking
Designed to be AI-agent-first. Stable across minor releases; breaking
changes announced in the changelog and the corresponding
`weknora --version` bump.
@@ -220,9 +220,9 @@ explicit confirmation". Pass `-y/--yes` on `kb delete` /
**Never auto-add `-y` without the user's explicit go-ahead** — exit 10
is the guard against unintended writes.
### Other agent ergonomics
### Other AI-agent ergonomics
- For chat / session ask in agent contexts, pass `--format json` —
- For chat / session ask in AI-agent contexts, pass `--format json` —
streaming tokens to stdout makes JSON parsing impossible.
- `--format json` composes with the global `--profile <name>` for
single-shot profile overrides without disk writes.

View File

@@ -49,7 +49,7 @@ import (
// server - mock /api/v1/* endpoints; nil means no network.
// preConfig - seed config.yaml under the per-test XDG_CONFIG_HOME
// (set by newTestFactory); use for cases like
// `context use` that read local state without an
// `profile use` that read local state without an
// SDK round-trip.
// wantErr - non-zero exit expected.
// wantStderrSubstring - stderr must contain this substring (typically the
@@ -130,8 +130,8 @@ var wireCases = []wireCase{
args: []string{"profile", "use", "production", "--format", "json"},
preConfig: func(t *testing.T) {
cfg := &config.Config{
CurrentContext: "staging",
Contexts: map[string]config.Context{
CurrentProfile: "staging",
Profiles: map[string]config.Profile{
"staging": {Host: "https://staging.example.com"},
"production": {Host: "https://prod.example.com"},
},

View File

@@ -29,7 +29,7 @@ import (
"time"
)
// TestRAGFullLoop walks the demo MVP path: link a context, create a KB,
// TestRAGFullLoop walks the demo MVP path: link a profile, create a KB,
// upload a doc, wait for indexing, search it, then chat against it. Each
// step parses the CLI's bare JSON to extract IDs for the next step -
// validating both functional behavior and wire-contract stability.
@@ -40,7 +40,7 @@ func TestRAGFullLoop(t *testing.T) {
bin := buildBinary(t)
xdg := t.TempDir()
writeContextYAML(t, xdg, host, token)
writeProfileYAML(t, xdg, host, token)
env := append(os.Environ(),
"XDG_CONFIG_HOME="+xdg,
@@ -142,18 +142,18 @@ func buildBinary(t *testing.T) string {
return out
}
// writeContextYAML drops a minimal config.yaml into XDG_CONFIG_HOME so the
// CLI finds a context without needing `weknora context add` (which prompts
// writeProfileYAML drops a minimal config.yaml into XDG_CONFIG_HOME so the
// CLI finds a profile without needing `weknora profile add` (which prompts
// in interactive scenarios). Tests using `auth login` belong to a different
// suite; here we go straight to authenticated calls.
func writeContextYAML(t *testing.T, xdg, host, token string) {
func writeProfileYAML(t *testing.T, xdg, host, token string) {
t.Helper()
dir := filepath.Join(xdg, "weknora")
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatalf("mkdir xdg: %v", err)
}
yaml := fmt.Sprintf(`current_context: e2e
contexts:
yaml := fmt.Sprintf(`current_profile: e2e
profiles:
- name: e2e
host: %s
token: %s

View File

@@ -1 +1 @@
{"ok":true,"data":{"summary":{"all_passed":false,"passed":1,"failed":1,"skipped":2},"checks":[{"name":"base_url_reachable","status":"fail","details":"server returned 500","hint":"verify the host configured for the active context (run `weknora auth login --host=...`) and network reachability"},{"name":"auth_credential","status":"skip","details":"prereq failed: base_url_reachable"},{"name":"server_version","status":"skip","details":"prereq failed: auth_credential"},{"name":"credential_storage","status":"ok","details":"keyring or file storage available"}]}}
{"ok":true,"data":{"summary":{"all_passed":false,"passed":1,"failed":1,"skipped":2},"checks":[{"name":"base_url_reachable","status":"fail","details":"server returned 500","hint":"verify the host configured for the active profile (run `weknora auth login --host=...`) and network reachability"},{"name":"auth_credential","status":"skip","details":"prereq failed: base_url_reachable"},{"name":"server_version","status":"skip","details":"prereq failed: auth_credential"},{"name":"credential_storage","status":"ok","details":"keyring or file storage available"}]}}

View File

@@ -1,10 +1,9 @@
// Package api implements the `weknora api` raw HTTP passthrough command.
//
// Shape: one positional (path) + `-X/--method` flag, default GET (auto-
// promoted to POST when a body is supplied via --data or --input). The two
// body-source flags are mutually exclusive. Default raw response body to
// stdout; --format json emits a {status, headers, body} object. Reuses sdk.Client.Raw which already
// applies tenant + auth headers.
// promoted to POST when a body is supplied via --input). Default raw
// response body to stdout; --format json emits a {status, headers, body}
// object. Reuses sdk.Client.Raw which already applies tenant + auth headers.
package api
import (
@@ -58,7 +57,7 @@ The default method is GET; passing --input auto-promotes it to POST. Use
PATCH / HEAD / OPTIONS / TRACE / custom).
Auth, tenant, and request-id headers are applied automatically from the
active context. The response body is written to stdout by default; use
active profile. The response body is written to stdout by default; use
--format json to emit a {status, headers, body} envelope.
Examples:

View File

@@ -20,7 +20,7 @@ const (
ModeUnknown = "unknown"
)
// modeFromRefs maps the per-context TokenRef / APIKeyRef presence to a
// modeFromRefs maps the per-profile TokenRef / APIKeyRef presence to a
// canonical credential-mode token. Bearer wins when both are present -
// matches the precedence in cmdutil.buildClient.
func modeFromRefs(apiKeyRef, tokenRef string) string {

View File

@@ -74,17 +74,17 @@ func TestPersistAPIKey_WritesContext(t *testing.T) {
}
opts := &LoginOptions{
Host: "https://kb.example.com",
Context: "ci",
Profile: "ci",
APIKey: "sk-zzz",
}
require.NoError(t, persistAPIKey(opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, nil))
v, _ := store.Get("ci", "api_key")
assert.Equal(t, "sk-zzz", v)
cfg, _ := f.Config()
assert.Equal(t, "ci", cfg.CurrentContext)
assert.Equal(t, "https://kb.example.com", cfg.Contexts["ci"].Host)
assert.Equal(t, "ci", cfg.CurrentProfile)
assert.Equal(t, "https://kb.example.com", cfg.Profiles["ci"].Host)
// APIKeyRef should be the mem:// URI from the store's Ref method.
assert.Equal(t, "mem://ci/api_key", cfg.Contexts["ci"].APIKeyRef)
assert.Equal(t, "mem://ci/api_key", cfg.Profiles["ci"].APIKeyRef)
}
func TestPersistJWT_StoresBothTokens(t *testing.T) {
@@ -98,7 +98,7 @@ func TestPersistJWT_StoresBothTokens(t *testing.T) {
}
opts := &LoginOptions{
Host: "https://x",
Context: "p",
Profile: "p",
}
resp := &sdk.LoginResponse{
Token: "jwt-acc",

View File

@@ -16,7 +16,7 @@ import (
type ListOptions struct{}
// authListFields enumerates the fields surfaced for `--format json` discovery on
// `auth list`. Each entry is a per-context summary row.
// `auth list`. Each entry is a per-profile summary row.
var authListFields = []string{
"name", "host", "user", "mode", "current",
}
@@ -56,14 +56,14 @@ func runList(fopts *cmdutil.FormatOptions, f *cmdutil.Factory) error {
if err != nil {
return err
}
entries := make([]listEntry, 0, len(cfg.Contexts))
for name, c := range cfg.Contexts {
entries := make([]listEntry, 0, len(cfg.Profiles))
for name, c := range cfg.Profiles {
entries = append(entries, listEntry{
Name: name,
Host: c.Host,
User: c.User,
Mode: modeFromRefs(c.APIKeyRef, c.TokenRef),
Current: name == cfg.CurrentContext,
Current: name == cfg.CurrentProfile,
})
}
sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name })

View File

@@ -22,8 +22,8 @@ func newListFactory(cfg *config.Config) *cmdutil.Factory {
func TestList_TextRender(t *testing.T) {
out, _ := iostreams.SetForTest(t)
cfg := &config.Config{
CurrentContext: "prod",
Contexts: map[string]config.Context{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{
"prod": {Host: "https://prod", User: "alice@example.com", TokenRef: "keychain://prod/access"},
"staging": {Host: "https://staging", APIKeyRef: "keychain://staging/api_key"},
},
@@ -31,7 +31,7 @@ func TestList_TextRender(t *testing.T) {
require.NoError(t, runList(&cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newListFactory(cfg)))
got := out.String()
// One row per context, current marked with `*`.
// One row per profile, current marked with `*`.
assert.Contains(t, got, "* prod")
assert.Contains(t, got, " staging")
// Mode column.
@@ -39,7 +39,7 @@ func TestList_TextRender(t *testing.T) {
assert.Contains(t, got, ModeAPIKey)
// Sorted alphabetically - prod after staging? No: "prod" < "staging".
assert.Less(t, strings.Index(got, "prod"), strings.Index(got, "staging"),
"contexts should render sorted by name")
"profiles should render sorted by name")
}
func TestList_Empty(t *testing.T) {
@@ -51,8 +51,8 @@ func TestList_Empty(t *testing.T) {
func TestList_JSON_BareArray(t *testing.T) {
out, _ := iostreams.SetForTest(t)
cfg := &config.Config{
CurrentContext: "prod",
Contexts: map[string]config.Context{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{
"prod": {Host: "https://prod", User: "alice", TokenRef: "tok"},
"staging": {Host: "https://staging", APIKeyRef: "key"},
},
@@ -78,7 +78,7 @@ func TestList_JSON_BareArray(t *testing.T) {
func TestModeFromRefs(t *testing.T) {
// Hand-edited config with neither ref set - surface "unknown" rather
// than pretending the context is a valid login.
// than pretending the profile is a valid login.
assert.Equal(t, ModeUnknown, modeFromRefs("", ""))
assert.Equal(t, ModeBearer, modeFromRefs("", "tok"))
assert.Equal(t, ModeAPIKey, modeFromRefs("key", ""))

View File

@@ -26,7 +26,7 @@ var authLoginFields = []string{
// LoginOptions is the configuration captured from flags + prompts.
type LoginOptions struct {
Host string // --host
Context string // --name: context name to write into config.yaml
Profile string // --name: profile name to write into config.yaml
WithToken bool // --with-token: read api key from stdin instead of prompting password
APIKey string // populated by --with-token from stdin
Email string
@@ -45,7 +45,7 @@ type LoginService interface {
// fails fast at `auth login --with-token` time rather than on the next
// authenticated call.
//
// Returns the resolved user (used to populate context.User / TenantID at
// Returns the resolved user (used to populate Profile.User / TenantID at
// rest, so later `auth list` reflects who owns the key).
type apiKeyValidator func(ctx context.Context, host, apiKey string) (*sdk.AuthUser, error)
@@ -72,8 +72,8 @@ func NewCmdLogin(f *cmdutil.Factory, runF func(context.Context, *LoginOptions, *
Long: `Log in by email + password (interactive prompt) or pipe an API key with --with-token.
Credentials are persisted to the OS keyring when available; otherwise to a
0600 file under $XDG_CONFIG_HOME/weknora/secrets. The named context becomes
the current_context in ~/.config/weknora/config.yaml.`,
0600 file under $XDG_CONFIG_HOME/weknora/secrets. The named profile becomes
the current_profile in ~/.config/weknora/config.yaml.`,
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, args []string) error {
fopts, err := cmdutil.CheckFormatFlag(c)
@@ -93,7 +93,7 @@ the current_context in ~/.config/weknora/config.yaml.`,
},
}
cmd.Flags().StringVar(&opts.Host, "host", "", "WeKnora server URL, e.g. https://kb.example.com")
cmd.Flags().StringVar(&opts.Context, "name", "default", "Profile name to register in config.yaml")
cmd.Flags().StringVar(&opts.Profile, "name", "default", "Profile 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")
cmdutil.AddFormatFlag(cmd, authLoginFields...)
_ = cmd.MarkFlagRequired("host")
@@ -101,7 +101,7 @@ the current_context in ~/.config/weknora/config.yaml.`,
}
// loginServiceFor returns a fresh SDK client targeting host. login.go cannot
// reuse Factory.Client because that closure requires an existing context.
// reuse Factory.Client because that closure requires an existing profile.
func loginServiceFor(host string) LoginService {
if host == "" {
return nil
@@ -113,6 +113,12 @@ func runLogin(ctx context.Context, opts *LoginOptions, fopts *cmdutil.FormatOpti
if err := validateHost(opts.Host); err != nil {
return err
}
// Reject shell-metacharacter / path-like names up-front so opts.Profile
// stays safe to interpolate into the keyring namespace, config.yaml
// keys, and (later) envelope.error.retry_command. Matches `profile add`.
if err := cmdutil.ValidateProfileName(opts.Profile); err != nil {
return err
}
if opts.WithToken {
key, err := readStdinTrimmed(opts.StdinReader)
@@ -179,9 +185,9 @@ func runLogin(ctx context.Context, opts *LoginOptions, fopts *cmdutil.FormatOpti
return persistJWT(opts, fopts, f, resp)
}
// persistAPIKey saves the --with-token API key and writes the context.
// persistAPIKey saves the --with-token API key and writes the profile.
// user is the principal returned by /auth/me during pre-persist validation,
// used to populate context.User / TenantID so `auth list` reflects who
// used to populate Profile.User / TenantID so `auth list` reflects who
// owns the key.
func persistAPIKey(opts *LoginOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Factory, user *sdk.AuthUser) error {
store, err := f.Secrets()
@@ -189,45 +195,45 @@ func persistAPIKey(opts *LoginOptions, fopts *cmdutil.FormatOptions, f *cmdutil.
return err
}
warnOnFileFallback(store)
if err := store.Set(opts.Context, "api_key", opts.APIKey); err != nil {
if err := store.Set(opts.Profile, "api_key", opts.APIKey); err != nil {
return cmdutil.Wrapf(cmdutil.CodeLocalKeychainDenied, err, "save api key")
}
ctx := &config.Context{
prof := &config.Profile{
Host: opts.Host,
APIKeyRef: store.Ref(opts.Context, "api_key"),
APIKeyRef: store.Ref(opts.Profile, "api_key"),
}
if user != nil {
ctx.User = user.Email
ctx.TenantID = user.TenantID
prof.User = user.Email
prof.TenantID = user.TenantID
}
return saveContextRef(opts, fopts, f, ctx, user)
return saveProfileRef(opts, fopts, f, prof, user)
}
// persistJWT saves access + refresh tokens and writes the context.
// persistJWT saves access + refresh tokens and writes the profile.
func persistJWT(opts *LoginOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Factory, resp *sdk.LoginResponse) error {
store, err := f.Secrets()
if err != nil {
return err
}
warnOnFileFallback(store)
if err := store.Set(opts.Context, "access", resp.Token); err != nil {
if err := store.Set(opts.Profile, "access", resp.Token); err != nil {
return cmdutil.Wrapf(cmdutil.CodeLocalKeychainDenied, err, "save access token")
}
if resp.RefreshToken != "" {
if err := store.Set(opts.Context, "refresh", resp.RefreshToken); err != nil {
if err := store.Set(opts.Profile, "refresh", resp.RefreshToken); err != nil {
return cmdutil.Wrapf(cmdutil.CodeLocalKeychainDenied, err, "save refresh token")
}
}
c := &config.Context{
prof := &config.Profile{
Host: opts.Host,
TokenRef: store.Ref(opts.Context, "access"),
RefreshRef: store.Ref(opts.Context, "refresh"),
TokenRef: store.Ref(opts.Profile, "access"),
RefreshRef: store.Ref(opts.Profile, "refresh"),
}
if resp.User != nil {
c.User = resp.User.Email
c.TenantID = resp.User.TenantID
prof.User = resp.User.Email
prof.TenantID = resp.User.TenantID
}
return saveContextRef(opts, fopts, f, c, resp.User)
return saveProfileRef(opts, fopts, f, prof, resp.User)
}
// loginResult is the typed payload emitted by `--format json`. mode is derived from
@@ -240,22 +246,22 @@ type loginResult struct {
TenantID uint64 `json:"tenant_id,omitempty"`
}
// saveContextRef writes the context to config.yaml and prints success.
func saveContextRef(opts *LoginOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Factory, ctx *config.Context, user *sdk.AuthUser) error {
// saveProfileRef writes the profile to config.yaml and prints success.
func saveProfileRef(opts *LoginOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Factory, prof *config.Profile, user *sdk.AuthUser) error {
cfg, err := f.Config()
if err != nil {
return err
}
if cfg.Contexts == nil {
cfg.Contexts = map[string]config.Context{}
if cfg.Profiles == nil {
cfg.Profiles = map[string]config.Profile{}
}
cfg.Contexts[opts.Context] = *ctx
cfg.CurrentContext = opts.Context
cfg.Profiles[opts.Profile] = *prof
cfg.CurrentProfile = opts.Profile
if err := config.Save(cfg); err != nil {
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "save config")
}
if fopts.WantsJSON() {
result := loginResult{Profile: opts.Context, Host: opts.Host, Mode: ModeAPIKey}
result := loginResult{Profile: opts.Profile, Host: opts.Host, Mode: ModeAPIKey}
if user != nil {
result.Mode = ModeBearer
result.User = user.Email
@@ -263,11 +269,11 @@ func saveContextRef(opts *LoginOptions, fopts *cmdutil.FormatOptions, f *cmdutil
}
return fopts.Emit(iostreams.IO.Out, result, nil)
}
who := opts.Context
who := opts.Profile
if user != nil {
who = user.Email
}
fmt.Fprintf(iostreams.IO.Out, "✓ Logged in to %s as %s (context=%s)\n", opts.Host, who, opts.Context)
fmt.Fprintf(iostreams.IO.Out, "✓ Logged in to %s as %s (profile=%s)\n", opts.Host, who, opts.Profile)
return nil
}

View File

@@ -60,7 +60,7 @@ func TestRunLogin_PasswordMode(t *testing.T) {
}}
opts := &LoginOptions{
Host: "https://kb.example.com",
Context: "prod",
Profile: "prod",
}
require.NoError(t, runLogin(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, svc))
@@ -80,7 +80,7 @@ func TestRunLogin_WithToken(t *testing.T) {
defer restore()
opts := &LoginOptions{
Host: "https://kb.example.com",
Context: "ci",
Profile: "ci",
WithToken: true,
StdinReader: strings.NewReader(" sk-1234 \n"),
}
@@ -88,8 +88,8 @@ func TestRunLogin_WithToken(t *testing.T) {
got, _ := store.Get("ci", "api_key")
assert.Equal(t, "sk-1234", got)
cfg, _ := f.Config()
assert.Equal(t, "ci@example.com", cfg.Contexts["ci"].User, "validator-returned user should be persisted")
assert.Equal(t, uint64(7), cfg.Contexts["ci"].TenantID)
assert.Equal(t, "ci@example.com", cfg.Profiles["ci"].User, "validator-returned user should be persisted")
assert.Equal(t, uint64(7), cfg.Profiles["ci"].TenantID)
}
func TestRunLogin_WithToken_ServerRejects(t *testing.T) {
@@ -103,7 +103,7 @@ func TestRunLogin_WithToken_ServerRejects(t *testing.T) {
defer restore()
opts := &LoginOptions{
Host: "https://kb.example.com",
Context: "ci",
Profile: "ci",
WithToken: true,
StdinReader: strings.NewReader("sk-bad"),
}
@@ -135,7 +135,7 @@ func TestRunLogin_WithToken_Empty(t *testing.T) {
defer restore()
opts := &LoginOptions{
Host: "https://kb.example.com",
Context: "ci",
Profile: "ci",
WithToken: true,
StdinReader: strings.NewReader(""),
}
@@ -156,7 +156,7 @@ func TestRunLogin_LoginRefused(t *testing.T) {
iostreams.SetForTest(t)
f, _ := newTestFactoryWithConfig(t, scriptedPrompter{email: "a@b.c", password: "x"})
svc := &fakeLoginService{resp: &sdk.LoginResponse{Success: false, Message: "bad password"}}
err := runLogin(context.Background(), &LoginOptions{Host: "https://x", Context: "p"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, svc)
err := runLogin(context.Background(), &LoginOptions{Host: "https://x", Profile: "p"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, svc)
require.Error(t, err)
assert.Contains(t, err.Error(), "auth.bad_credential")
}

View File

@@ -79,9 +79,18 @@ func runLogout(opts *LogoutOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Fac
if err != nil {
return err
}
if len(cfg.Contexts) == 0 {
if len(cfg.Profiles) == 0 {
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated, "no profiles configured; nothing to log out")
}
// Reject shell-metacharacter names so opts.Name is safe to interpolate
// into envelope.error.retry_command. Validation is name-only — the
// profile may or may not exist in cfg.Profiles; the existence check
// happens in pickLogoutTargets.
if opts.Name != "" {
if err := cmdutil.ValidateProfileName(opts.Name); err != nil {
return err
}
}
targets, err := pickLogoutTargets(opts, cfg)
if err != nil {
@@ -105,15 +114,15 @@ func runLogout(opts *LogoutOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Fac
return err
}
for _, name := range targets {
clearContextSecrets(store, cfg.Contexts[name], name)
delete(cfg.Contexts, name)
clearProfileSecrets(store, cfg.Profiles[name], name)
delete(cfg.Profiles, name)
}
// If we removed the active context, pick a remaining one (deterministic by
// map order would be flaky - leave CurrentContext empty so the next
// invocation surfaces a clear "no current context" error rather than
// If we removed the active profile, pick a remaining one (deterministic by
// map order would be flaky - leave CurrentProfile empty so the next
// invocation surfaces a clear "no current profile" error rather than
// silently switching).
if _, stillExists := cfg.Contexts[cfg.CurrentContext]; !stillExists {
cfg.CurrentContext = ""
if _, stillExists := cfg.Profiles[cfg.CurrentProfile]; !stillExists {
cfg.CurrentProfile = ""
}
if err := config.Save(cfg); err != nil {
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "save config")
@@ -129,31 +138,31 @@ func runLogout(opts *LogoutOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Fac
// pickLogoutTargets resolves the set of profiles to clear from flags + config.
func pickLogoutTargets(opts *LogoutOptions, cfg *config.Config) ([]string, error) {
if opts.All {
names := make([]string, 0, len(cfg.Contexts))
for n := range cfg.Contexts {
names := make([]string, 0, len(cfg.Profiles))
for n := range cfg.Profiles {
names = append(names, n)
}
return names, nil
}
name := opts.Name
if name == "" {
name = cfg.CurrentContext
name = cfg.CurrentProfile
}
if name == "" {
return nil, cmdutil.NewError(cmdutil.CodeInputMissingFlag,
"no current profile set; pass --name <profile> or --all")
}
if _, ok := cfg.Contexts[name]; !ok {
if _, ok := cfg.Profiles[name]; !ok {
return nil, cmdutil.NewError(cmdutil.CodeLocalProfileNotFound,
fmt.Sprintf("profile %q not found in config", name))
}
return []string{name}, nil
}
// clearContextSecrets best-effort deletes every secret slot the profile
// clearProfileSecrets best-effort deletes every secret slot the profile
// references. Errors are swallowed because a missing secret is a no-op
// (tested in keyring_test.go) - we don't want a stale ref to block logout.
func clearContextSecrets(store secrets.Store, c config.Context, name string) {
func clearProfileSecrets(store secrets.Store, c config.Profile, name string) {
if c.TokenRef != "" {
_ = store.Delete(name, "access")
}

View File

@@ -32,7 +32,7 @@ func isolateConfig(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
}
func TestLogout_CurrentContext(t *testing.T) {
func TestLogout_CurrentProfile(t *testing.T) {
isolateConfig(t)
_, _ = iostreams.SetForTest(t)
store := secrets.NewMemStore()
@@ -41,19 +41,19 @@ func TestLogout_CurrentContext(t *testing.T) {
require.NoError(t, store.Set("staging", "api_key", "sk-staging"))
cfg := &config.Config{
CurrentContext: "prod",
Contexts: map[string]config.Context{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{
"prod": {Host: "https://prod", TokenRef: store.Ref("prod", "access"), RefreshRef: store.Ref("prod", "refresh")},
"staging": {Host: "https://staging", APIKeyRef: store.Ref("staging", "api_key")},
},
}
require.NoError(t, runLogout(&LogoutOptions{Yes: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newLogoutFactory(t, cfg, store)))
assert.Empty(t, cfg.CurrentContext, "current_context should clear when removed")
assert.NotContains(t, cfg.Contexts, "prod")
assert.Contains(t, cfg.Contexts, "staging", "non-target context untouched")
assert.Empty(t, cfg.CurrentProfile, "current_profile should clear when removed")
assert.NotContains(t, cfg.Profiles, "prod")
assert.Contains(t, cfg.Profiles, "staging", "non-target profile untouched")
// Secrets gone for the removed context, kept for the survivor.
// Secrets gone for the removed profile, kept for the survivor.
if _, err := store.Get("prod", "access"); err == nil {
t.Error("prod access secret should be deleted")
}
@@ -69,17 +69,17 @@ func TestLogout_NamedContext(t *testing.T) {
require.NoError(t, store.Set("staging", "api_key", "sk-staging"))
cfg := &config.Config{
CurrentContext: "prod",
Contexts: map[string]config.Context{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{
"prod": {Host: "https://prod", TokenRef: "tok"},
"staging": {Host: "https://staging", APIKeyRef: store.Ref("staging", "api_key")},
},
}
require.NoError(t, runLogout(&LogoutOptions{Name: "staging", Yes: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newLogoutFactory(t, cfg, store)))
assert.Equal(t, "prod", cfg.CurrentContext, "current_context untouched when removing other")
assert.NotContains(t, cfg.Contexts, "staging")
assert.Contains(t, cfg.Contexts, "prod")
assert.Equal(t, "prod", cfg.CurrentProfile, "current_profile untouched when removing other")
assert.NotContains(t, cfg.Profiles, "staging")
assert.Contains(t, cfg.Profiles, "prod")
}
func TestLogout_All(t *testing.T) {
@@ -87,19 +87,19 @@ func TestLogout_All(t *testing.T) {
_, _ = iostreams.SetForTest(t)
store := secrets.NewMemStore()
cfg := &config.Config{
CurrentContext: "prod",
Contexts: map[string]config.Context{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{
"prod": {Host: "https://prod"},
"staging": {Host: "https://staging"},
},
}
require.NoError(t, runLogout(&LogoutOptions{All: true, Yes: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newLogoutFactory(t, cfg, store)))
assert.Empty(t, cfg.Contexts)
assert.Empty(t, cfg.CurrentContext)
assert.Empty(t, cfg.Profiles)
assert.Empty(t, cfg.CurrentProfile)
}
func TestLogout_NoContexts(t *testing.T) {
func TestLogout_NoProfiles(t *testing.T) {
isolateConfig(t)
_, _ = iostreams.SetForTest(t)
cfg := &config.Config{}
@@ -114,8 +114,8 @@ func TestLogout_UnknownName(t *testing.T) {
isolateConfig(t)
_, _ = iostreams.SetForTest(t)
cfg := &config.Config{
CurrentContext: "prod",
Contexts: map[string]config.Context{"prod": {Host: "https://prod"}},
CurrentProfile: "prod",
Profiles: map[string]config.Profile{"prod": {Host: "https://prod"}},
}
err := runLogout(&LogoutOptions{Name: "ghost"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newLogoutFactory(t, cfg, secrets.NewMemStore()))
require.Error(t, err)
@@ -128,7 +128,7 @@ func TestLogout_NoCurrentNoFlag(t *testing.T) {
isolateConfig(t)
_, _ = iostreams.SetForTest(t)
cfg := &config.Config{
Contexts: map[string]config.Context{"prod": {Host: "https://prod"}},
Profiles: map[string]config.Profile{"prod": {Host: "https://prod"}},
}
err := runLogout(&LogoutOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newLogoutFactory(t, cfg, secrets.NewMemStore()))
require.Error(t, err)
@@ -142,7 +142,7 @@ func TestLogout_NoCurrentNoFlag(t *testing.T) {
func TestLogout_Cobra_FlagsMutuallyExclusive(t *testing.T) {
isolateConfig(t)
_, _ = iostreams.SetForTest(t)
cfg := &config.Config{Contexts: map[string]config.Context{"a": {}}}
cfg := &config.Config{Profiles: map[string]config.Profile{"a": {}}}
cmd := NewCmdLogout(newLogoutFactory(t, cfg, secrets.NewMemStore()))
cmd.SetContext(context.Background())
cmd.SetArgs([]string{"--name", "a", "--all"})

View File

@@ -75,13 +75,13 @@ func runRefresh(ctx context.Context, opts *RefreshOptions, fopts *cmdutil.Format
}
name := opts.Name
if name == "" {
name = cfg.CurrentContext
name = cfg.CurrentProfile
}
if name == "" {
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated,
"no current profile configured; run `weknora auth login` to set one up")
}
c, ok := cfg.Contexts[name]
c, ok := cfg.Profiles[name]
if !ok {
return cmdutil.NewError(cmdutil.CodeLocalProfileNotFound,
fmt.Sprintf("profile not found: %s", name))

View File

@@ -56,8 +56,8 @@ func TestRefresh_Happy(t *testing.T) {
require.NoError(t, store.Set("prod", "refresh", "old-refresh"))
cfg := &config.Config{
CurrentContext: "prod",
Contexts: map[string]config.Context{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{
"prod": {
Host: "https://kb.example.com",
TokenRef: "mem://prod/access",
@@ -87,8 +87,8 @@ func TestRefresh_NamedContext(t *testing.T) {
require.NoError(t, store.Set("staging", "refresh", "stg-refresh"))
cfg := &config.Config{
CurrentContext: "prod",
Contexts: map[string]config.Context{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{
"prod": {Host: "https://prod", TokenRef: "mem://prod/access", RefreshRef: "mem://prod/refresh"},
"staging": {Host: "https://stg", TokenRef: "mem://staging/access", RefreshRef: "mem://staging/refresh"},
},
@@ -99,14 +99,14 @@ func TestRefresh_NamedContext(t *testing.T) {
}}
require.NoError(t, runRefresh(context.Background(), &RefreshOptions{Name: "staging"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, stubSvc(svc)))
assert.Equal(t, "stg-refresh", svc.gotTok, "--name=staging must refresh the staging context, not current")
assert.Equal(t, "stg-refresh", svc.gotTok, "--name=staging must refresh the staging profile, not current")
// current is untouched
if v, _ := store.Get("prod", "access"); v != "" {
t.Errorf("prod must not have been touched, got %q", v)
}
}
func TestRefresh_NoCurrentContext(t *testing.T) {
func TestRefresh_NoCurrentProfile(t *testing.T) {
iostreams.SetForTest(t)
f := newRefreshFactory(t, &config.Config{}, secrets.NewMemStore())
err := runRefresh(context.Background(), &RefreshOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, stubSvc(&fakeRefreshService{}))
@@ -121,8 +121,8 @@ func TestRefresh_APIKeyContext(t *testing.T) {
store := secrets.NewMemStore()
require.NoError(t, store.Set("ci", "api_key", "sk-123"))
cfg := &config.Config{
CurrentContext: "ci",
Contexts: map[string]config.Context{"ci": {Host: "https://kb", APIKeyRef: "mem://ci/api_key"}},
CurrentProfile: "ci",
Profiles: map[string]config.Profile{"ci": {Host: "https://kb", APIKeyRef: "mem://ci/api_key"}},
}
f := newRefreshFactory(t, cfg, store)
err := runRefresh(context.Background(), &RefreshOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, stubSvc(&fakeRefreshService{}))
@@ -130,14 +130,14 @@ func TestRefresh_APIKeyContext(t *testing.T) {
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
assert.Contains(t, typed.Hint, "api-key", "hint should explain api-key contexts cannot be refreshed")
assert.Contains(t, typed.Hint, "api-key", "hint should explain api-key profiles cannot be refreshed")
}
func TestRefresh_NoRefreshTokenStored(t *testing.T) {
iostreams.SetForTest(t)
cfg := &config.Config{
CurrentContext: "prod",
Contexts: map[string]config.Context{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{
"prod": {Host: "https://kb", TokenRef: "mem://prod/access", RefreshRef: "mem://prod/refresh"},
},
}
@@ -156,8 +156,8 @@ func TestRefresh_ServerRefused(t *testing.T) {
store := secrets.NewMemStore()
require.NoError(t, store.Set("prod", "refresh", "stale-refresh"))
cfg := &config.Config{
CurrentContext: "prod",
Contexts: map[string]config.Context{"prod": {Host: "https://kb", TokenRef: "mem://prod/access", RefreshRef: "mem://prod/refresh"}},
CurrentProfile: "prod",
Profiles: map[string]config.Profile{"prod": {Host: "https://kb", TokenRef: "mem://prod/access", RefreshRef: "mem://prod/refresh"}},
}
f := newRefreshFactory(t, cfg, store)
svc := &fakeRefreshService{resp: &sdk.RefreshTokenResponse{Success: false, Message: "refresh token expired"}}
@@ -179,8 +179,8 @@ func TestRefresh_TransportError(t *testing.T) {
store := secrets.NewMemStore()
require.NoError(t, store.Set("prod", "refresh", "ok-refresh"))
cfg := &config.Config{
CurrentContext: "prod",
Contexts: map[string]config.Context{"prod": {Host: "https://kb", TokenRef: "mem://prod/access", RefreshRef: "mem://prod/refresh"}},
CurrentProfile: "prod",
Profiles: map[string]config.Profile{"prod": {Host: "https://kb", TokenRef: "mem://prod/access", RefreshRef: "mem://prod/refresh"}},
}
f := newRefreshFactory(t, cfg, store)
svc := &fakeRefreshService{err: errors.New("connection reset")}
@@ -199,8 +199,8 @@ func TestRefresh_JSONOutput(t *testing.T) {
store := secrets.NewMemStore()
require.NoError(t, store.Set("prod", "refresh", "ok-refresh"))
cfg := &config.Config{
CurrentContext: "prod",
Contexts: map[string]config.Context{"prod": {Host: "https://kb", TokenRef: "mem://prod/access", RefreshRef: "mem://prod/refresh"}},
CurrentProfile: "prod",
Profiles: map[string]config.Profile{"prod": {Host: "https://kb", TokenRef: "mem://prod/access", RefreshRef: "mem://prod/refresh"}},
}
f := newRefreshFactory(t, cfg, store)
svc := &fakeRefreshService{resp: &sdk.RefreshTokenResponse{Success: true, AccessToken: "a", RefreshToken: "r"}}
@@ -211,8 +211,8 @@ func TestRefresh_JSONOutput(t *testing.T) {
assert.NotContains(t, body, "ok-refresh", "output must not leak refresh token")
assert.NotContains(t, body, "\"a\"", "output must not leak the new access token")
assert.NotContains(t, body, "\"r\"", "output must not leak the new refresh token")
// must mention the context name so agents can confirm what was refreshed
assert.True(t, strings.Contains(body, "prod"), "output should reference the refreshed context")
// must mention the profile name so agents can confirm what was refreshed
assert.True(t, strings.Contains(body, "prod"), "output should reference the refreshed profile")
// v0.7 envelope: ok:true is expected
var env struct {
OK bool `json:"ok"`

View File

@@ -85,7 +85,7 @@ func runStatus(ctx context.Context, fopts *cmdutil.FormatOptions, f *cmdutil.Fac
}
if fopts.WantsJSON() {
result := statusResult{Profile: cfg.CurrentContext}
result := statusResult{Profile: cfg.CurrentProfile}
if user != nil {
result.UserID = user.ID
result.Username = user.Username
@@ -101,10 +101,10 @@ func runStatus(ctx context.Context, fopts *cmdutil.FormatOptions, f *cmdutil.Fac
}
host := ""
if c, ok := cfg.Contexts[cfg.CurrentContext]; ok {
if c, ok := cfg.Profiles[cfg.CurrentProfile]; ok {
host = c.Host
}
fmt.Fprintf(iostreams.IO.Out, "profile: %s\n", cfg.CurrentContext)
fmt.Fprintf(iostreams.IO.Out, "profile: %s\n", cfg.CurrentProfile)
fmt.Fprintf(iostreams.IO.Out, "host: %s\n", host)
if user != nil {
fmt.Fprintf(iostreams.IO.Out, "user: %s (%s)\n", user.Email, user.ID)

View File

@@ -37,8 +37,8 @@ func TestRunStatus_TextOutput(t *testing.T) {
out, _ := iostreams.SetForTest(t)
testutil.XDGTempDir(t)
require.NoError(t, config.Save(&config.Config{
CurrentContext: "prod",
Contexts: map[string]config.Context{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{
"prod": {Host: "https://kb.example.com", TenantID: 7},
},
}))
@@ -64,8 +64,8 @@ func TestRunStatus_JSONOutput(t *testing.T) {
out, _ := iostreams.SetForTest(t)
testutil.XDGTempDir(t)
require.NoError(t, config.Save(&config.Config{
CurrentContext: "prod",
Contexts: map[string]config.Context{"prod": {Host: "https://x"}},
CurrentProfile: "prod",
Profiles: map[string]config.Profile{"prod": {Host: "https://x"}},
}))
f := &cmdutil.Factory{Config: func() (*config.Config, error) { return config.Load() }}
svc := &fakeStatusService{resp: newCurrentUserResponse(&sdk.AuthUser{ID: "u1", Email: "a@b.c", TenantID: 7}, nil)}
@@ -91,7 +91,7 @@ func TestRunStatus_NoSDKClient(t *testing.T) {
func TestRunStatus_SDKError_Transport(t *testing.T) {
iostreams.SetForTest(t)
testutil.XDGTempDir(t)
require.NoError(t, config.Save(&config.Config{CurrentContext: "p", Contexts: map[string]config.Context{"p": {Host: "https://x"}}}))
require.NoError(t, config.Save(&config.Config{CurrentProfile: "p", Profiles: map[string]config.Profile{"p": {Host: "https://x"}}}))
f := &cmdutil.Factory{Config: func() (*config.Config, error) { return config.Load() }}
err := runStatus(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, &fakeStatusService{err: assert.AnError})
require.Error(t, err)
@@ -103,7 +103,7 @@ func TestRunStatus_SDKError_Transport(t *testing.T) {
func TestRunStatus_SDKError_HTTP401(t *testing.T) {
iostreams.SetForTest(t)
testutil.XDGTempDir(t)
require.NoError(t, config.Save(&config.Config{CurrentContext: "p", Contexts: map[string]config.Context{"p": {Host: "https://x"}}}))
require.NoError(t, config.Save(&config.Config{CurrentProfile: "p", Profiles: map[string]config.Profile{"p": {Host: "https://x"}}}))
f := &cmdutil.Factory{Config: func() (*config.Config, error) { return config.Load() }}
err := runStatus(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, f, &fakeStatusService{err: errors.New("HTTP error 401: invalid token")})
require.Error(t, err)

View File

@@ -68,18 +68,18 @@ func runToken(f *cmdutil.Factory, fopts *cmdutil.FormatOptions) error {
if err != nil {
return err
}
ctxName := cfg.CurrentContext
profileName := cfg.CurrentProfile
if f.ProfileOverride != "" {
ctxName = f.ProfileOverride
profileName = f.ProfileOverride
}
if ctxName == "" {
if profileName == "" {
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated,
"no current profile configured")
}
ctx, ok := cfg.Contexts[ctxName]
ctx, ok := cfg.Profiles[profileName]
if !ok {
return cmdutil.NewError(cmdutil.CodeLocalProfileNotFound,
fmt.Sprintf("profile %q not found", ctxName))
fmt.Sprintf("profile %q not found", profileName))
}
store, err := f.Secrets()
@@ -93,29 +93,29 @@ func runToken(f *cmdutil.Factory, fopts *cmdutil.FormatOptions) error {
var token, mode string
switch {
case ctx.TokenRef != "":
v, ferr := cmdutil.LoadSecret(store, ctxName, "access")
v, ferr := cmdutil.LoadSecret(store, profileName, "access")
if ferr != nil {
return ferr
}
token, mode = v, ModeBearer
case ctx.APIKeyRef != "":
v, ferr := cmdutil.LoadSecret(store, ctxName, "api_key")
v, ferr := cmdutil.LoadSecret(store, profileName, "api_key")
if ferr != nil {
return ferr
}
token, mode = v, ModeAPIKey
default:
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated,
fmt.Sprintf("profile %q has no stored credential; run `weknora auth login`", ctxName))
fmt.Sprintf("profile %q has no stored credential; run `weknora auth login`", profileName))
}
if token == "" {
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated,
fmt.Sprintf("profile %q credential is empty in keyring; run `weknora auth login`", ctxName))
fmt.Sprintf("profile %q credential is empty in keyring; run `weknora auth login`", profileName))
}
if fopts.WantsJSON() {
return fopts.Emit(iostreams.IO.Out, tokenResult{Token: token, Mode: mode, Profile: ctxName}, nil)
return fopts.Emit(iostreams.IO.Out, tokenResult{Token: token, Mode: mode, Profile: profileName}, nil)
}
// No trailing newline - clean $(weknora auth token) substitution.

View File

@@ -25,8 +25,8 @@ func tokenTestFactory(t *testing.T, cfg *config.Config, store *secrets.MemStore)
func TestAuthToken_BearerMode_PlainOutput(t *testing.T) {
cfg := &config.Config{
CurrentContext: "prod",
Contexts: map[string]config.Context{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{
"prod": {Host: "https://kb.example.com", TokenRef: "prod:access", RefreshRef: "prod:refresh"},
},
}
@@ -49,8 +49,8 @@ func TestAuthToken_BearerMode_PlainOutput(t *testing.T) {
func TestAuthToken_APIKeyMode_PlainOutput(t *testing.T) {
cfg := &config.Config{
CurrentContext: "ci",
Contexts: map[string]config.Context{
CurrentProfile: "ci",
Profiles: map[string]config.Profile{
"ci": {Host: "https://kb.example.com", APIKeyRef: "ci:api_key"},
},
}
@@ -68,8 +68,8 @@ func TestAuthToken_APIKeyMode_PlainOutput(t *testing.T) {
func TestAuthToken_JSON(t *testing.T) {
cfg := &config.Config{
CurrentContext: "prod",
Contexts: map[string]config.Context{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{
"prod": {Host: "https://kb.example.com", TokenRef: "prod:access"},
},
}
@@ -99,8 +99,8 @@ func TestAuthToken_JSON(t *testing.T) {
func TestAuthToken_JSON_JQProjection(t *testing.T) {
cfg := &config.Config{
CurrentContext: "ci",
Contexts: map[string]config.Context{
CurrentProfile: "ci",
Profiles: map[string]config.Profile{
"ci": {Host: "https://kb.example.com", APIKeyRef: "ci:api_key"},
},
}
@@ -125,7 +125,7 @@ func TestAuthToken_JSON_JQProjection(t *testing.T) {
}
}
func TestAuthToken_NoCurrentContext(t *testing.T) {
func TestAuthToken_NoCurrentProfile(t *testing.T) {
cfg := &config.Config{}
store := secrets.NewMemStore()
iostreams.SetForTest(t)
@@ -140,8 +140,8 @@ func TestAuthToken_NoCurrentContext(t *testing.T) {
func TestAuthToken_ProfileOverride(t *testing.T) {
cfg := &config.Config{
CurrentContext: "prod",
Contexts: map[string]config.Context{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{
"prod": {Host: "https://prod.example.com", TokenRef: "prod:access"},
"staging": {Host: "https://staging.example.com", APIKeyRef: "staging:api_key"},
},
@@ -164,8 +164,8 @@ func TestAuthToken_ProfileOverride(t *testing.T) {
func TestAuthToken_NoStoredCredential(t *testing.T) {
cfg := &config.Config{
CurrentContext: "prod",
Contexts: map[string]config.Context{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{
"prod": {Host: "https://kb.example.com", TokenRef: "prod:access"},
},
}
@@ -183,8 +183,8 @@ func TestAuthToken_NoStoredCredential(t *testing.T) {
func TestAuthToken_ContextWithNoCredentialRefs(t *testing.T) {
cfg := &config.Config{
CurrentContext: "empty",
Contexts: map[string]config.Context{
CurrentProfile: "empty",
Profiles: map[string]config.Profile{
"empty": {Host: "https://kb.example.com"}, // no TokenRef or APIKeyRef
},
}
@@ -209,8 +209,8 @@ func TestAuthToken_ContextWithNoCredentialRefs(t *testing.T) {
func makeBearerCfg() (*config.Config, *secrets.MemStore) {
cfg := &config.Config{
CurrentContext: "prod",
Contexts: map[string]config.Context{
CurrentProfile: "prod",
Profiles: map[string]config.Profile{
"prod": {Host: "https://kb.example.com", TokenRef: "prod:access"},
},
}
@@ -221,8 +221,8 @@ func makeBearerCfg() (*config.Config, *secrets.MemStore) {
func makeAPIKeyCfg() (*config.Config, *secrets.MemStore) {
cfg := &config.Config{
CurrentContext: "ci",
Contexts: map[string]config.Context{
CurrentProfile: "ci",
Profiles: map[string]config.Profile{
"ci": {Host: "https://kb.example.com", APIKeyRef: "ci:api_key"},
},
}

View File

@@ -12,7 +12,7 @@
// "init" event at stream head, then pass through every SDK event verbatim
// as NDJSON lines. Agents and pipes get a live event stream they can
// parse incrementally. --format json routes here too — buffered JSON
// envelope makes no sense for a streaming command (§5).
// envelope makes no sense for a streaming command.
//
// The SDK's KnowledgeQAStream callback contract is invoked sequentially on
// one goroutine, so neither mode needs locking. The runChat core takes a
@@ -37,7 +37,7 @@ import (
// chatFields enumerates the NDJSON init-event fields surfaced for
// `--format json` / `--format ndjson` discovery on `chat`. Reflects the
// InitEvent head line + the raw SDK event vocabulary (§5).
// InitEvent head line + the raw SDK event vocabulary.
var chatFields = []string{
"session_id", "kb_id",
// SDK event fields (pass-through): response_type, content, done,
@@ -74,13 +74,13 @@ Modes:
one init line at head (session_id, kb_id),
then raw SDK events verbatim. Both json
and ndjson flags produce the same NDJSON
stream (§5).`,
stream.`,
Example: ` weknora chat "What is RRF?" --kb a32a63ff-fb36-4874-bcaa-30f48570a694
weknora chat "Summarise this design doc" --kb my-kb --format json
weknora chat "Continue?" --session sess_abc`,
Args: cobra.MinimumNArgs(1),
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
opts.Query = strings.TrimSpace(strings.Join(args, " "))
opts.Query = strings.TrimSpace(args[0])
if opts.Query == "" {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "query argument cannot be empty")
}
@@ -101,7 +101,7 @@ Modes:
return runChat(c.Context(), opts, fopts, cli)
},
}
cmd.Flags().String("kb", "", "Knowledge base UUID or name (overrides project link / env)")
cmdutil.AddKBFlag(cmd)
cmd.Flags().StringVar(&opts.SessionID, "session", "", "Continue an existing chat session (skip auto-create)")
cmdutil.AddFormatFlag(cmd, chatFields...)
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
@@ -130,7 +130,7 @@ func runChat(ctx context.Context, opts *Options, fopts *cmdutil.FormatOptions, s
// Streaming commands route --format json AND --format ndjson to the
// NDJSON event-stream path. A buffered envelope makes no sense for a
// streaming command (§5). Only --format text uses the live renderer.
// streaming command. Only --format text uses the live renderer.
ndjsonMode := fopts != nil && (fopts.Mode == cmdutil.FormatJSON || fopts.Mode == cmdutil.FormatNDJSON)
sessionID := opts.SessionID
@@ -173,11 +173,11 @@ func runChat(ctx context.Context, opts *Options, fopts *cmdutil.FormatOptions, s
// runChatNDJSON handles --format json and --format ndjson paths.
// Emits a CLI init event at stream head, then passes every SDK event through
// verbatim as NDJSON lines. No buffering — callers parse the stream
// incrementally (§5).
// incrementally.
func runChatNDJSON(ctx context.Context, opts *Options, sessionID string, svc ChatService) error {
w := iostreams.IO.Out
// 1. Inject the CLI-managed init event at the head of the stream (§5.3).
// 1. Inject the CLI-managed init event at the head of the stream.
// Carries the session pointer + retrieval context callers need for
// follow-up threading.
initEv := output.InitEvent{

View File

@@ -188,7 +188,7 @@ func TestMultiDelete_NonTTY_NoYes_RequiresConfirmation(t *testing.T) {
}
// TestChunkDelete_MultiID_PartialFailure_BatchEnvelope verifies that the JSON
// output for a multi-id partial failure uses the §4.5 batch envelope shape:
// output for a multi-id partial failure uses the batch envelope shape:
// {ok:false, data:[{id,ok,result?|error?}...], meta:{count,successes,failures}}.
func TestChunkDelete_MultiID_PartialFailure_BatchEnvelope(t *testing.T) {
_, _ = iostreams.SetForTest(t)

View File

@@ -77,7 +77,7 @@ don't require a file upload or remote URL. KB resolution follows the standard
return runCreate(c.Context(), opts, fopts, cli, kbID)
},
}
cmd.Flags().String("kb", "", "Knowledge base UUID or name (overrides env / project link)")
cmdutil.AddKBFlag(cmd)
cmd.Flags().StringVar(&opts.Text, "text", "", "Document text content in Markdown format (required)")
cmd.Flags().StringVar(&opts.Name, "name", "", "Document title")
cmd.Flags().StringVar(&opts.TagID, "tag-id", "", "Tag id to associate with the new entry")

View File

@@ -280,7 +280,7 @@ func TestRunMultiDelete_ConfirmBatch_TTY_UserAborts(t *testing.T) {
}
// ---------------------------------------------------------------------------
// Emit tests — JSON path now emits batch envelope (§4.5)
// Emit tests — JSON path now emits the batch envelope
// ---------------------------------------------------------------------------
// batchEnvelope is a minimal struct for parsing the batch envelope shape.
@@ -380,7 +380,7 @@ func TestEmitMultiDelete_UnsupportedFormat(t *testing.T) {
// ---------------------------------------------------------------------------
// TestDocDelete_MultiID_PartialFailure_BatchEnvelope verifies that when a
// multi-id delete has a partial failure, stdout carries the §4.5 batch
// multi-id delete has a partial failure, stdout carries the batch
// envelope shape: ok:false, data:[BatchItem...], meta:{count, successes,
// failures}. Order follows original argv order.
func TestDocDelete_MultiID_PartialFailure_BatchEnvelope(t *testing.T) {

View File

@@ -105,7 +105,7 @@ Server-side ingestion knobs:
return runFetch(c.Context(), opts, fopts, cli, kbID)
},
}
cmd.Flags().String("kb", "", "Knowledge base UUID or name (overrides env / project link)")
cmdutil.AddKBFlag(cmd)
cmd.Flags().StringVar(&opts.Name, "name", "", "File name hint (also used as file-type hint when extension is recognisable)")
cmd.Flags().StringVar(&opts.Title, "title", "", "Display title for the new entry")
cmd.Flags().StringVar(&opts.FileType, "file-type", "", "File-type hint such as \"pdf\" when the URL has no extension")

View File

@@ -99,9 +99,7 @@ backend storage order is not guaranteed and varies between deployments.`,
return runList(c.Context(), opts, fopts, cli, kbID)
},
}
// --kb is read by Factory.ResolveKB; declare it here so cobra parses the
// value into the command's flag set.
cmd.Flags().String("kb", "", "Knowledge base UUID or name (overrides env / project link)")
cmdutil.AddKBFlag(cmd)
cmd.Flags().IntVar(&opts.PageSize, "page-size", 50, "Items per server batch (1..1000)")
cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum results to return (1..10000)")
cmd.Flags().BoolVar(&opts.AllPages, "all-pages", false, "Walk all server pages until exhausted (or --limit hit)")

View File

@@ -133,8 +133,8 @@ func TestList_KBIDRequired(t *testing.T) {
_, _ = iostreams.SetForTest(t)
cfg := &config.Config{
CurrentContext: "default",
Contexts: map[string]config.Context{"default": {Host: "https://example"}},
CurrentProfile: "default",
Profiles: map[string]config.Profile{"default": {Host: "https://example"}},
}
f := &cmdutil.Factory{
Config: func() (*config.Config, error) { return cfg, nil },
@@ -162,8 +162,8 @@ func TestList_KBFlagWiredToResolveKB(t *testing.T) {
_, _ = iostreams.SetForTest(t)
cfg := &config.Config{
CurrentContext: "default",
Contexts: map[string]config.Context{"default": {Host: "https://example"}},
CurrentProfile: "default",
Profiles: map[string]config.Profile{"default": {Host: "https://example"}},
}
f := &cmdutil.Factory{
Config: func() (*config.Config, error) { return cfg, nil },

View File

@@ -140,7 +140,7 @@ Server-side ingestion knobs:
return runUpload(c.Context(), opts, fopts, cli, kbID, args[0])
},
}
cmd.Flags().String("kb", "", "Knowledge base UUID or name (overrides env / project link)")
cmdutil.AddKBFlag(cmd)
cmd.Flags().StringVar(&opts.Name, "name", "", "Custom file name to record (defaults to base name)")
cmd.Flags().BoolVar(&opts.Recursive, "recursive", false, "Treat the positional argument as a directory to walk")
cmd.Flags().StringVar(&opts.Glob, "glob", "*", "Filename pattern to filter when --recursive (e.g. '*.pdf')")

View File

@@ -195,7 +195,7 @@ func TestUploadRecursive_MetadataInvalid_NoCalls(t *testing.T) {
}
// TestUploadRecursive_JSON_BatchEnvelope verifies that --format json emits the
// §4.5 batch envelope shape: {ok, data:[{id,ok,result?|error?}...], meta:{count,successes,failures}}.
// batch envelope shape: {ok, data:[{id,ok,result?|error?}...], meta:{count,successes,failures}}.
// The per-item id is the file path; result carries {id, name} from the server.
func TestUploadRecursive_JSON_BatchEnvelope(t *testing.T) {
out, _ := iostreams.SetForTest(t)

View File

@@ -239,9 +239,9 @@ func waitForDocs(ctx context.Context, ids []string, svc WaitService, opts WaitOp
return result, nil
}
// ExitCode resolves the compound terminal state to a Unix exit code per
// spec §3.2: priority 1 > 124 > 0 (failed > timeout > completed). SIGINT
// (exit 130) is handled by the Go runtime / context cancellation, not here.
// ExitCode resolves the compound terminal state to a Unix exit code.
// Priority: 1 > 124 > 0 (failed > timeout > completed). SIGINT (exit 130)
// is handled by the Go runtime / context cancellation, not here.
func (r *WaitResult) ExitCode() int {
if len(r.Failed) > 0 {
return 1

View File

@@ -164,7 +164,7 @@ func runChecks(ctx context.Context, opts *Options, svc Services, cliVer string)
t0 := time.Now()
if err := svc.PingBaseURL(ctx); err != nil {
checks[0].Status = StatusFail
checks[0].Hint = "verify the host configured for the active context (run `weknora auth login --host=...`) and network reachability"
checks[0].Hint = "verify the host configured for the active profile (run `weknora auth login --host=...`) and network reachability"
checks[0].Details = err.Error()
} else {
checks[0].Status = StatusOK
@@ -373,7 +373,7 @@ func marker(s Status) string {
}
// buildServices wires the Factory closures into the doctor.Services interface.
// Reads the active context's host so PingBaseURL targets the user's actual
// Reads the active profile's host so PingBaseURL targets the user's actual
// server, not localhost.
//
// Critically: this does NOT pre-resolve f.Client(). doctor's package promise
@@ -388,7 +388,7 @@ func buildServices(f *cmdutil.Factory) (Services, error) {
return nil, err
}
host := ""
if ctx, ok := cfg.Contexts[cfg.CurrentContext]; ok {
if ctx, ok := cfg.Profiles[cfg.CurrentProfile]; ok {
host = ctx.Host
}
// WEKNORA_BASE_URL still wins as a test/dev override; production reads host.
@@ -409,7 +409,7 @@ const pingTimeout = 5 * time.Second
func (s *realServices) PingBaseURL(ctx context.Context) error {
if s.host == "" {
return fmt.Errorf("no host configured for active context")
return fmt.Errorf("no host configured for active profile")
}
url := s.host + "/health"
ctx, cancel := context.WithTimeout(ctx, pingTimeout)
@@ -429,7 +429,7 @@ func (s *realServices) PingBaseURL(ctx context.Context) error {
return nil
}
// GetCurrentUser lazily resolves the SDK client. When no context is configured
// GetCurrentUser lazily resolves the SDK client. When no profile is configured
// or credentials missing, f.Client() returns auth.unauthenticated; we surface
// that as the auth_credential check's failure rather than aborting doctor.
func (s *realServices) GetCurrentUser(ctx context.Context) (*sdk.CurrentUserResponse, error) {

View File

@@ -48,8 +48,8 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
opts := &ListOptions{}
cmd := &cobra.Command{
Use: "list",
Short: "List knowledge bases visible to the active context",
Long: `List knowledge bases visible to the active context, sorted by most recently updated. Pass --pinned to restrict to pinned KBs.`,
Short: "List knowledge bases visible to the active profile",
Long: `List knowledge bases visible to the active profile, sorted by most recently updated. Pass --pinned to restrict to pinned KBs.`,
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, _ []string) error {
fopts, err := cmdutil.CheckFormatFlag(c)

View File

@@ -123,10 +123,10 @@ func resolveProfile(f *cmdutil.Factory) (string, error) {
if err != nil {
return "", err
}
if cfg.CurrentContext == "" {
if cfg.CurrentProfile == "" {
return "", cmdutil.NewError(cmdutil.CodeAuthUnauthenticated, "no active profile; run `weknora auth login` first")
}
return cfg.CurrentContext, nil
return cfg.CurrentProfile, nil
}
// resolveKB resolves --kb to (kbID, kbName). Name is empty when the user

View File

@@ -42,8 +42,8 @@ func fakeKBServer(t *testing.T, kbs []sdk.KnowledgeBase) *httptest.Server {
func newFactory(currentCtx string, client *sdk.Client) *cmdutil.Factory {
cfg := &config.Config{
CurrentContext: currentCtx,
Contexts: map[string]config.Context{
CurrentProfile: currentCtx,
Profiles: map[string]config.Profile{
currentCtx: {Host: "https://example"},
},
}

View File

@@ -4,7 +4,7 @@
// the JSON-RPC 2.0 wire protocol agentic IDEs use to call external tools.
// `weknora mcp serve` exposes a curated subset of the CLI's read surface
// as MCP tools so an IDE-side agent can list / view / search / chat against
// the user's active WeKnora context without shelling out to the CLI per
// the user's active WeKnora profile without shelling out to the CLI per
// call.
//
// Package name is `mcpcmd` to avoid shadowing `cli/internal/mcp` (the

View File

@@ -16,8 +16,8 @@ func NewCmdServe(f *cmdutil.Factory) *cobra.Command {
Long: `Speaks JSON-RPC 2.0 on stdin/stdout to an MCP client. Logs go to
stderr; the data channel is reserved for protocol traffic.
Authentication is inherited from the active context (or --context). The
server eagerly resolves the SDK client at startup - if no context is
Authentication is inherited from the active profile (or --profile). The
server eagerly resolves the SDK client at startup - if no profile is
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.

View File

@@ -2,7 +2,6 @@ package profilecmd
import (
"fmt"
"strings"
"github.com/spf13/cobra"
@@ -68,7 +67,7 @@ adds leave the current profile untouched.`,
}
func runAdd(opts *AddOptions, fopts *cmdutil.FormatOptions, name string) error {
if err := validateName(name); err != nil {
if err := cmdutil.ValidateProfileName(name); err != nil {
return err
}
host, err := cmdutil.NormalizeHost(opts.Host)
@@ -80,20 +79,20 @@ func runAdd(opts *AddOptions, fopts *cmdutil.FormatOptions, name string) error {
if err != nil {
return err
}
if _, exists := cfg.Contexts[name]; exists {
if _, exists := cfg.Profiles[name]; exists {
return &cmdutil.Error{
Code: cmdutil.CodeResourceAlreadyExists,
Message: fmt.Sprintf("profile %q already exists", name),
Hint: fmt.Sprintf("use a different name, or run `weknora profile remove %s` first", name),
}
}
if cfg.Contexts == nil {
cfg.Contexts = map[string]config.Context{}
if cfg.Profiles == nil {
cfg.Profiles = map[string]config.Profile{}
}
cfg.Contexts[name] = config.Context{Host: host, User: opts.User}
wasFirst := cfg.CurrentContext == ""
cfg.Profiles[name] = config.Profile{Host: host, User: opts.User}
wasFirst := cfg.CurrentProfile == ""
if wasFirst {
cfg.CurrentContext = name
cfg.CurrentProfile = name
}
if err := config.Save(cfg); err != nil {
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "save config")
@@ -109,40 +108,3 @@ func runAdd(opts *AddOptions, fopts *cmdutil.FormatOptions, name string) error {
}
return nil
}
// validateName enforces the allowlist advertised in the --help hint: letters,
// digits, dash, underscore, dot. The `.` exception lets emails / DNS-like
// names through; the path-traversal `..` is structurally rejected by a
// separate guard because it would let a hand-edited config.yaml claim a
// profile whose name walks out of the keyring namespace.
func validateName(name string) error {
if name == "" {
return &cmdutil.Error{
Code: cmdutil.CodeInputInvalidArgument,
Message: "profile name must not be empty",
}
}
if name == "." || name == ".." || strings.Contains(name, "/") || strings.Contains(name, "\\") {
return &cmdutil.Error{
Code: cmdutil.CodeInputInvalidArgument,
Message: fmt.Sprintf("profile name %q is reserved or path-like", name),
Hint: "use letters, digits, dashes, underscores, or dots",
}
}
for _, r := range name {
switch {
case r >= 'a' && r <= 'z',
r >= 'A' && r <= 'Z',
r >= '0' && r <= '9',
r == '-' || r == '_' || r == '.':
continue
default:
return &cmdutil.Error{
Code: cmdutil.CodeInputInvalidArgument,
Message: fmt.Sprintf("profile name %q contains invalid character %q", name, r),
Hint: "use letters, digits, dashes, underscores, or dots",
}
}
}
return nil
}

View File

@@ -22,9 +22,9 @@ func TestAdd_HappyPath(t *testing.T) {
if err != nil {
t.Fatalf("Load: %v", err)
}
c, ok := cfg.Contexts["staging"]
c, ok := cfg.Profiles["staging"]
if !ok {
t.Fatalf("staging not in Contexts; got keys=%v", profileKeys(cfg.Contexts))
t.Fatalf("staging not in Profiles; got keys=%v", profileKeys(cfg.Profiles))
}
if c.Host != "https://my.example.com" {
t.Errorf("Host=%q, want https://my.example.com", c.Host)
@@ -33,8 +33,8 @@ func TestAdd_HappyPath(t *testing.T) {
t.Errorf("User=%q, want alice@example.com", c.User)
}
// First profile auto-becomes current.
if cfg.CurrentContext != "staging" {
t.Errorf("first profile should auto-become current, got CurrentContext=%q", cfg.CurrentContext)
if cfg.CurrentProfile != "staging" {
t.Errorf("first profile should auto-become current, got CurrentProfile=%q", cfg.CurrentProfile)
}
if !strings.Contains(out.String(), "staging") {
t.Errorf("output should mention added name, got %q", out.String())
@@ -46,8 +46,8 @@ func TestAdd_DuplicateName(t *testing.T) {
_, _ = iostreams.SetForTest(t)
cfg := &config.Config{
CurrentContext: "staging",
Contexts: map[string]config.Context{"staging": {Host: "https://old.example.com"}},
CurrentProfile: "staging",
Profiles: map[string]config.Profile{"staging": {Host: "https://old.example.com"}},
}
if err := config.Save(cfg); err != nil {
t.Fatalf("Save: %v", err)
@@ -66,8 +66,8 @@ func TestAdd_DuplicateName(t *testing.T) {
}
// Existing entry must NOT be overwritten.
got, _ := config.Load()
if got.Contexts["staging"].Host != "https://old.example.com" {
t.Errorf("existing profile overwritten; Host=%q", got.Contexts["staging"].Host)
if got.Profiles["staging"].Host != "https://old.example.com" {
t.Errorf("existing profile overwritten; Host=%q", got.Profiles["staging"].Host)
}
}
@@ -103,8 +103,8 @@ func TestAdd_SecondProfileDoesNotChangeCurrent(t *testing.T) {
_, _ = iostreams.SetForTest(t)
cfg := &config.Config{
CurrentContext: "production",
Contexts: map[string]config.Context{"production": {Host: "https://prod.example.com"}},
CurrentProfile: "production",
Profiles: map[string]config.Profile{"production": {Host: "https://prod.example.com"}},
}
if err := config.Save(cfg); err != nil {
t.Fatalf("Save: %v", err)
@@ -114,8 +114,8 @@ func TestAdd_SecondProfileDoesNotChangeCurrent(t *testing.T) {
t.Fatalf("runAdd: %v", err)
}
got, _ := config.Load()
if got.CurrentContext != "production" {
t.Errorf("adding a second profile must not switch current; got %q", got.CurrentContext)
if got.CurrentProfile != "production" {
t.Errorf("adding a second profile must not switch current; got %q", got.CurrentProfile)
}
}

View File

@@ -61,13 +61,13 @@ func runList(fopts *cmdutil.FormatOptions) error {
if err != nil {
return err
}
entries := make([]listEntry, 0, len(cfg.Contexts))
for name, c := range cfg.Contexts {
entries := make([]listEntry, 0, len(cfg.Profiles))
for name, c := range cfg.Profiles {
entries = append(entries, listEntry{
Name: name,
Host: c.Host,
User: c.User,
Current: name == cfg.CurrentContext,
Current: name == cfg.CurrentProfile,
})
}
sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name })

View File

@@ -27,8 +27,8 @@ func TestList_MultipleSorted(t *testing.T) {
out, _ := iostreams.SetForTest(t)
cfg := &config.Config{
CurrentContext: "staging",
Contexts: map[string]config.Context{
CurrentProfile: "staging",
Profiles: map[string]config.Profile{
"production": {Host: "https://prod.example.com", User: "alice@example.com"},
"staging": {Host: "https://staging.example.com"},
"alpha": {Host: "https://alpha.example.com"},
@@ -65,8 +65,8 @@ func TestList_JSON(t *testing.T) {
out, _ := iostreams.SetForTest(t)
cfg := &config.Config{
CurrentContext: "staging",
Contexts: map[string]config.Context{
CurrentProfile: "staging",
Profiles: map[string]config.Profile{
"staging": {Host: "https://staging.example.com", User: "bob@example.com"},
"production": {Host: "https://prod.example.com"},
},

View File

@@ -42,7 +42,7 @@ func NewCmdRemove(f *cmdutil.Factory) *cobra.Command {
Long: `Deletes the named profile from config.yaml and best-effort clears any
keyring references it owned (matches ` + "`weknora auth logout`" + `).
Removing the current profile also clears CurrentContext - subsequent commands
Removing the current profile also clears CurrentProfile - subsequent commands
will error until you select another with ` + "`weknora profile use <name>`" + ` or pick
one up via the global ` + "`--profile`" + ` flag. Because that change is observable in
every later command, removing the current profile requires explicit -y/--yes
@@ -84,11 +84,11 @@ func runRemove(opts *RemoveOptions, fopts *cmdutil.FormatOptions, name string, s
if err != nil {
return err
}
ctx, exists := cfg.Contexts[name]
ctx, exists := cfg.Profiles[name]
if !exists {
return notFoundError(name, cfg)
}
wasCurrent := name == cfg.CurrentContext
wasCurrent := name == cfg.CurrentProfile
jsonOut := fopts.WantsJSON()
// Confirmation only fires for removing the current profile - non-current
@@ -101,9 +101,9 @@ func runRemove(opts *RemoveOptions, fopts *cmdutil.FormatOptions, name string, s
// Config first, secrets after: a crash in between leaves an orphan
// keyring entry but no dangling config ref (same ordering as auth logout).
delete(cfg.Contexts, name)
delete(cfg.Profiles, name)
if wasCurrent {
cfg.CurrentContext = ""
cfg.CurrentProfile = ""
}
if err := config.Save(cfg); err != nil {
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "save config")
@@ -125,7 +125,7 @@ func runRemove(opts *RemoveOptions, fopts *cmdutil.FormatOptions, name string, s
// clearProfileSecrets mirrors auth/logout.go: best-effort delete every secret
// slot the profile references. Errors are swallowed so a missing keyring
// entry doesn't block remove (same policy as `auth logout`).
func clearProfileSecrets(store secrets.Store, c config.Context, name string) {
func clearProfileSecrets(store secrets.Store, c config.Profile, name string) {
if c.TokenRef != "" {
_ = store.Delete(name, "access")
}

View File

@@ -38,8 +38,8 @@ func TestRemove_NonCurrent_NoPromptNeeded(t *testing.T) {
out, _ := iostreams.SetForTest(t)
cfg := &config.Config{
CurrentContext: "production",
Contexts: map[string]config.Context{
CurrentProfile: "production",
Profiles: map[string]config.Profile{
"production": {Host: "https://prod.example.com", TokenRef: "mem://production/access"},
"staging": {Host: "https://staging.example.com", APIKeyRef: "mem://staging/api_key"},
},
@@ -58,11 +58,11 @@ func TestRemove_NonCurrent_NoPromptNeeded(t *testing.T) {
}
got, _ := config.Load()
if _, exists := got.Contexts["staging"]; exists {
t.Errorf("staging should have been removed; Contexts=%v", got.Contexts)
if _, exists := got.Profiles["staging"]; exists {
t.Errorf("staging should have been removed; Profiles=%v", got.Profiles)
}
if got.CurrentContext != "production" {
t.Errorf("CurrentContext must be unchanged, got %q", got.CurrentContext)
if got.CurrentProfile != "production" {
t.Errorf("CurrentProfile must be unchanged, got %q", got.CurrentProfile)
}
assertDeleted(t, store, "staging", "api_key")
if !strings.Contains(out.String(), "staging") {
@@ -74,7 +74,7 @@ func TestRemove_NotFound_WithDidYouMean(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
_, _ = iostreams.SetForTest(t)
cfg := &config.Config{Contexts: map[string]config.Context{
cfg := &config.Config{Profiles: map[string]config.Profile{
"production": {Host: "https://prod"},
"staging": {Host: "https://staging"},
}}
@@ -103,8 +103,8 @@ func TestRemove_Current_NonTTY_NoYes_RequiresConfirmation(t *testing.T) {
_, _ = iostreams.SetForTest(t)
cfg := &config.Config{
CurrentContext: "production",
Contexts: map[string]config.Context{
CurrentProfile: "production",
Profiles: map[string]config.Profile{
"production": {Host: "https://prod", TokenRef: "mem://production/access"},
"staging": {Host: "https://staging"},
},
@@ -129,8 +129,8 @@ func TestRemove_Current_NonTTY_NoYes_RequiresConfirmation(t *testing.T) {
t.Errorf("expected exit-10, got %d", cmdutil.ExitCode(err))
}
// Must not have mutated config or keyring.
if got, _ := config.Load(); got.CurrentContext != "production" {
t.Errorf("config mutated despite confirmation gate: CurrentContext=%q", got.CurrentContext)
if got, _ := config.Load(); got.CurrentProfile != "production" {
t.Errorf("config mutated despite confirmation gate: CurrentProfile=%q", got.CurrentProfile)
}
if v, err := store.Get("production", "access"); err != nil || v == "" {
t.Errorf("keyring touched before confirmation, get=%q err=%v", v, err)
@@ -142,8 +142,8 @@ func TestRemove_Current_WithYes_ClearsCurrent(t *testing.T) {
out, _ := iostreams.SetForTest(t)
cfg := &config.Config{
CurrentContext: "production",
Contexts: map[string]config.Context{
CurrentProfile: "production",
Profiles: map[string]config.Profile{
"production": {Host: "https://prod", TokenRef: "mem://production/access"},
"staging": {Host: "https://staging"},
},
@@ -157,11 +157,11 @@ func TestRemove_Current_WithYes_ClearsCurrent(t *testing.T) {
t.Fatalf("runRemove: %v", err)
}
got, _ := config.Load()
if _, exists := got.Contexts["production"]; exists {
if _, exists := got.Profiles["production"]; exists {
t.Errorf("production should be removed")
}
if got.CurrentContext != "" {
t.Errorf("removing current must clear CurrentContext, got %q", got.CurrentContext)
if got.CurrentProfile != "" {
t.Errorf("removing current must clear CurrentProfile, got %q", got.CurrentProfile)
}
assertDeleted(t, store, "production", "access")
if !strings.Contains(out.String(), "current profile cleared") {
@@ -174,8 +174,8 @@ func TestRemove_Current_TTY_PromptNo(t *testing.T) {
_, errBuf := iostreams.SetForTestWithTTY(t)
cfg := &config.Config{
CurrentContext: "production",
Contexts: map[string]config.Context{"production": {Host: "https://prod"}},
CurrentProfile: "production",
Profiles: map[string]config.Profile{"production": {Host: "https://prod"}},
}
if err := config.Save(cfg); err != nil {
t.Fatalf("Save: %v", err)
@@ -199,7 +199,7 @@ func TestRemove_Current_TTY_PromptNo(t *testing.T) {
if !strings.Contains(errBuf.String(), "Aborted") {
t.Errorf("stderr should contain Aborted, got %q", errBuf.String())
}
if got, _ := config.Load(); got.CurrentContext != "production" {
if got, _ := config.Load(); got.CurrentProfile != "production" {
t.Errorf("aborted remove must not mutate config")
}
}

View File

@@ -56,11 +56,11 @@ func runUse(name string, fopts *cmdutil.FormatOptions) error {
if err != nil {
return err
}
if _, ok := cfg.Contexts[name]; !ok {
if _, ok := cfg.Profiles[name]; !ok {
return notFoundError(name, cfg)
}
prev := cfg.CurrentContext
cfg.CurrentContext = name
prev := cfg.CurrentProfile
cfg.CurrentProfile = name
if err := config.Save(cfg); err != nil {
return err
}
@@ -77,14 +77,14 @@ func runUse(name string, fopts *cmdutil.FormatOptions) error {
}
func notFoundError(name string, cfg *config.Config) error {
if len(cfg.Contexts) == 0 {
if len(cfg.Profiles) == 0 {
return &cmdutil.Error{
Code: cmdutil.CodeLocalProfileNotFound,
Message: fmt.Sprintf("profile not found: %s", name),
Hint: "no profiles registered - run `weknora auth login` first",
}
}
keys := profileKeys(cfg.Contexts)
keys := profileKeys(cfg.Profiles)
candidate := closestMatch(name, keys)
var hint string
if candidate != "" && candidate != name {
@@ -99,7 +99,7 @@ func notFoundError(name string, cfg *config.Config) error {
}
}
func profileKeys(m map[string]config.Context) []string {
func profileKeys(m map[string]config.Profile) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)

View File

@@ -14,8 +14,8 @@ func TestUse_OK(t *testing.T) {
out, _ := iostreams.SetForTest(t)
cfg := &config.Config{
CurrentContext: "staging",
Contexts: map[string]config.Context{
CurrentProfile: "staging",
Profiles: map[string]config.Profile{
"staging": {Host: "https://staging.example.com"},
"production": {Host: "https://prod.example.com"},
},
@@ -32,8 +32,8 @@ func TestUse_OK(t *testing.T) {
if err != nil {
t.Fatalf("reload: %v", err)
}
if got.CurrentContext != "production" {
t.Errorf("CurrentContext = %q, want production", got.CurrentContext)
if got.CurrentProfile != "production" {
t.Errorf("CurrentProfile = %q, want production", got.CurrentProfile)
}
if !strings.Contains(out.String(), "production") {
t.Errorf("output should mention switched-to profile, got %q", out.String())
@@ -44,7 +44,7 @@ func TestUse_NotFound_WithDidYouMean(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
_, _ = iostreams.SetForTest(t)
cfg := &config.Config{Contexts: map[string]config.Context{
cfg := &config.Config{Profiles: map[string]config.Profile{
"production": {Host: "https://prod"},
"staging": {Host: "https://staging"},
}}
@@ -74,7 +74,7 @@ func TestUse_NotFound_WithDidYouMean(t *testing.T) {
func TestUse_NotFound_DeterministicTieBreak(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
_, _ = iostreams.SetForTest(t)
cfg := &config.Config{Contexts: map[string]config.Context{
cfg := &config.Config{Profiles: map[string]config.Profile{
"prod": {Host: "https://a"},
"prom": {Host: "https://b"},
"prog": {Host: "https://c"},
@@ -113,7 +113,7 @@ func TestUse_CaseSensitive(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
_, _ = iostreams.SetForTest(t)
cfg := &config.Config{Contexts: map[string]config.Context{
cfg := &config.Config{Profiles: map[string]config.Profile{
"Production": {Host: "https://prod"},
}}
_ = config.Save(cfg)

View File

@@ -40,7 +40,7 @@ type KBSearchService interface {
}
// NewCmdKB builds `weknora search kb "<query>"` - substring + case-insensitive
// match across KB names and descriptions visible to the active context.
// match across KB names and descriptions visible to the active profile.
// Results are sorted by name length (shortest first; usually the closest
// hit) for deterministic output.
func NewCmdKB(f *cmdutil.Factory) *cobra.Command {
@@ -49,7 +49,7 @@ func NewCmdKB(f *cmdutil.Factory) *cobra.Command {
Use: `kb "<query>"`,
Short: "Find knowledge bases by name or description (client-side substring match)",
Long: `Substring + case-insensitive match across KB names and descriptions visible
to the active context. Results are sorted by name length (shortest first;
to the active profile. Results are sorted by name length (shortest first;
usually the closest hit) for deterministic output.
This is name-discovery only - for searching *inside* a knowledge base's

View File

@@ -18,7 +18,7 @@ import (
// sessionAskFields enumerates the NDJSON init-event fields surfaced for
// `--format json` / `--format ndjson` discovery on `session ask`. Reflects
// the InitEvent head line + the raw SDK agent event vocabulary (§5).
// the InitEvent head line + the raw SDK agent event vocabulary.
var sessionAskFields = []string{
"session_id", "agent_id",
// SDK event fields (pass-through): response_type, content, done,
@@ -65,7 +65,7 @@ Modes:
one init line at head (session_id, agent_id),
then raw SDK agent events verbatim. Both
json and ndjson flags produce the same
NDJSON stream (§5).`,
NDJSON stream.`,
Example: ` weknora session ask --agent ag_x "Summarize Q3 sales"
weknora session ask --session sess_x --agent ag_x "Follow-up question"
weknora session ask --agent ag_x "Multi-step task" --format ndjson`,
@@ -113,7 +113,7 @@ func runAsk(ctx context.Context, opts *AskOptions, fopts *cmdutil.FormatOptions,
// Streaming commands route --format json AND --format ndjson to the
// NDJSON event-stream path. A buffered envelope makes no sense for a
// streaming command (§5). Only --format text uses the live renderer.
// streaming command. Only --format text uses the live renderer.
ndjsonMode := fopts != nil && (fopts.Mode == cmdutil.FormatJSON || fopts.Mode == cmdutil.FormatNDJSON)
sessionID := opts.SessionID
@@ -150,11 +150,11 @@ func runAsk(ctx context.Context, opts *AskOptions, fopts *cmdutil.FormatOptions,
// runAskNDJSON handles --format json and --format ndjson paths.
// Emits a CLI init event at stream head, then passes every SDK agent event
// through verbatim as NDJSON lines. No buffering (§5).
// through verbatim as NDJSON lines. No buffering.
func runAskNDJSON(ctx context.Context, opts *AskOptions, sessionID string, svc AskService) error {
w := iostreams.IO.Out
// 1. Inject the CLI-managed init event at the head of the stream (§5.3).
// 1. Inject the CLI-managed init event at the head of the stream.
// Carries session pointer + agent id callers need for follow-up threading.
initEv := output.InitEvent{
SessionID: sessionID,

View File

@@ -70,7 +70,7 @@ func textOpts() *cmdutil.FormatOptions {
}
// ndjsonOpts returns a FormatOptions for the NDJSON event-stream path.
// --format json routes here too (§5).
// --format json routes here too for streaming commands.
func ndjsonOpts() *cmdutil.FormatOptions {
return &cmdutil.FormatOptions{Mode: cmdutil.FormatNDJSON}
}
@@ -153,7 +153,7 @@ func TestSessionAsk_NDJSON_PassthroughEvents(t *testing.T) {
}
// TestSessionAsk_NDJSON_JSONEqualsNDJSON verifies that --format json and --format ndjson
// produce identical NDJSON streams for session ask (§5).
// produce identical NDJSON streams for session ask.
func TestSessionAsk_NDJSON_JSONEqualsNDJSON(t *testing.T) {
events := []*sdk.AgentStreamResponse{
answerEvent("hello"),

View File

@@ -14,7 +14,7 @@ import (
// sessionDeleteFields enumerates the fields surfaced for `--format json`
// discovery on `session delete`. Tracks the single-id result struct;
// multi-id mode emits batch envelope (§4.5).
// multi-id mode emits the batch envelope.
var sessionDeleteFields = []string{"id", "deleted"}
type DeleteOptions struct {

View File

@@ -156,7 +156,7 @@ func TestMultiDelete_NonTTY_NoYes_RequiresConfirmation(t *testing.T) {
}
// TestSessionDelete_MultiID_PartialFailure_BatchEnvelope verifies that the JSON
// output for a multi-id partial failure uses the §4.5 batch envelope shape:
// output for a multi-id partial failure uses the batch envelope shape:
// {ok:false, data:[{id,ok,result?|error?}...], meta:{count,successes,failures}}.
func TestSessionDelete_MultiID_PartialFailure_BatchEnvelope(t *testing.T) {
_, _ = iostreams.SetForTest(t)

View File

@@ -49,7 +49,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
opts := &ListOptions{PageSize: defaultPageSize}
cmd := &cobra.Command{
Use: "list",
Short: "List chat sessions for the active context",
Short: "List chat sessions for the active profile",
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, _ []string) error {
fopts, err := cmdutil.CheckFormatFlag(c)

View File

@@ -14,7 +14,7 @@ type Refresher interface {
RefreshToken(ctx context.Context, refreshToken string) (*sdk.RefreshTokenResponse, error)
}
// RefreshAndPersist reads the stored refresh token for ctxName, exchanges it
// RefreshAndPersist reads the stored refresh token for profileName, exchanges it
// for a new access + refresh pair via refresher, and writes both back to the
// secrets store. Returns the new access token (the refresh is already
// persisted as a side-effect, so callers only need the access value to
@@ -23,12 +23,12 @@ type Refresher interface {
// Single canonical implementation shared by `weknora auth refresh` and the
// AuthRetryTransport's refresh closure - both used to inline the same
// six-step sequence with subtly diverging error wording.
func RefreshAndPersist(ctx context.Context, store secrets.Store, refresher Refresher, ctxName string) (string, error) {
refresh, err := store.Get(ctxName, "refresh")
func RefreshAndPersist(ctx context.Context, store secrets.Store, refresher Refresher, profileName string) (string, error) {
refresh, err := store.Get(profileName, "refresh")
if errors.Is(err, secrets.ErrNotFound) || refresh == "" {
return "", &Error{
Code: CodeAuthTokenExpired,
Message: "refresh token missing for context " + ctxName,
Message: "refresh token missing for profile " + profileName,
Hint: "run `weknora auth login` to re-authenticate",
}
}
@@ -55,10 +55,10 @@ func RefreshAndPersist(ctx context.Context, store secrets.Store, refresher Refre
}
}
if err := store.Set(ctxName, "access", resp.AccessToken); err != nil {
if err := store.Set(profileName, "access", resp.AccessToken); err != nil {
return "", Wrapf(CodeLocalKeychainDenied, err, "save access token")
}
if err := store.Set(ctxName, "refresh", resp.RefreshToken); err != nil {
if err := store.Set(profileName, "refresh", resp.RefreshToken); err != nil {
return "", Wrapf(CodeLocalKeychainDenied, err, "save refresh token")
}
return resp.AccessToken, nil

View File

@@ -20,7 +20,7 @@ import (
// - 2xx / 4xx / 5xx other than 401: pass through unchanged.
// - 401 on /api/v1/auth/login or /api/v1/auth/refresh: pass through
// (otherwise a stale refresh token causes infinite recursion).
// - 401 with no initial token configured (api-key contexts): pass through
// - 401 with no initial token configured (api-key profiles): pass through
// - api-key credentials have no refresh semantic.
// - 401 with non-replayable request body (req.GetBody == nil): pass
// through. The SDK always uses bytes.Buffer bodies; this is a safety
@@ -48,7 +48,7 @@ type AuthRetryTransport struct {
// agrees with whatever the SDK was constructed with.
//
// Pass an empty initialToken to indicate "no bearer credential configured"
// (e.g. an api-key context). In that mode the transport never invokes
// (e.g. an api-key profile). In that mode the transport never invokes
// refreshFn - a 401 is propagated as-is.
func NewAuthRetryTransport(base http.RoundTripper, initialToken string, refreshFn func(context.Context) (string, error)) *AuthRetryTransport {
if base == nil {

View File

@@ -1,5 +1,5 @@
// Package cmdutil — batch.go provides reusable plumbing for multi-target
// mutations that emit the §4.5 batch envelope.
// mutations that emit the batch envelope.
//
// Three pieces:
// - BatchOutcome: per-target structured outcome (preserves argv order).
@@ -56,7 +56,7 @@ func RunBatch(ctx context.Context, ids []string, op func(context.Context, string
Code: CodeOperationFailed,
Message: fmt.Sprintf("%d/%d operation(s) failed", failed, len(ids)),
// Silent suppresses the stderr error envelope because the caller
// already emitted the batch envelope to stdout (§4.5). The exit code
// already emitted the batch envelope to stdout. The exit code
// still propagates via Error.Code → ExitCode (falls through to 1).
Silent: true,
}
@@ -65,7 +65,7 @@ func RunBatch(ctx context.Context, ids []string, op func(context.Context, string
}
// EmitBatch writes the per-item outcomes per --format. JSON/NDJSON emit
// the §4.5 batch envelope; text mode emits per-line "OK <id>" /
// the batch envelope; text mode emits per-line "OK <id>" /
// "FAIL <id>: <msg>".
//
// resultFn builds the per-item Result map for successes; nil ⇒ omit.

View File

@@ -112,7 +112,7 @@ func TestRunBatch_ContextCancellation(t *testing.T) {
}
}
// TestEmitBatch_JSON_Envelope verifies that the JSON path emits a valid §4.5
// TestEmitBatch_JSON_Envelope verifies that the JSON path emits a valid
// batch envelope with correct ok/error/result fields.
func TestEmitBatch_JSON_Envelope(t *testing.T) {
outcomes := []BatchOutcome{

View File

@@ -152,8 +152,8 @@ func (e *Error) WithHint(hint string) *Error {
return e
}
// WithRetryCommand sets the directly-executable retry argv string.
// Agent 端不用 regex 从 prose hint 提 argv。
// WithRetryCommand sets the directly-executable retry argv string so agents
// don't have to regex-extract argv from the prose Hint.
// Empty string for codes without a canonical retry command.
func (e *Error) WithRetryCommand(cmd string) *Error {
e.RetryCommand = cmd

View File

@@ -210,9 +210,10 @@ func defaultRetryCommand(code ErrorCode) string {
case CodeNetworkError, CodeServerTimeout:
return "weknora doctor"
case CodeProjectLinkCorrupt:
return "weknora link" // 重新绑定
return "weknora link" // re-bind the project to a KB
case CodeLocalConfigCorrupt:
// config + login 是两步;prose hint 已说明retry argv 留空
// Recovery is two steps (delete config + re-login); the prose hint
// already spells it out, so the retry argv stays empty.
return ""
}
return ""

View File

@@ -47,7 +47,7 @@ type Factory struct {
Prompter func() prompt.Prompter
Secrets func() (secrets.Store, error)
// ProfileOverride, if non-empty, replaces config.CurrentContext for this
// ProfileOverride, if non-empty, replaces config.CurrentProfile for this
// invocation only - set by the global --profile flag in PersistentPreRun.
// Buildable Config() / Client() honor it without writing to disk.
ProfileOverride string
@@ -85,7 +85,7 @@ func New() *Factory {
return nil, Wrapf(CodeLocalFileIO, err, "load config")
}
if f.ProfileOverride != "" {
cfg.CurrentContext = f.ProfileOverride
cfg.CurrentProfile = f.ProfileOverride
}
return cfg, nil
}
@@ -108,7 +108,7 @@ func New() *Factory {
return f
}
// buildClient resolves the active context, loads the credentials from secrets,
// buildClient resolves the active profile, loads the credentials from secrets,
// and constructs a *sdk.Client. Returns CodeAuthUnauthenticated when no
// credentials are available so the user gets the right hint to run
// `weknora auth login`.
@@ -117,11 +117,11 @@ func buildClient(f *Factory) (*sdk.Client, error) {
if err != nil {
return nil, err
}
profileName := cfg.CurrentContext
profileName := cfg.CurrentProfile
if profileName == "" {
return nil, NewError(CodeAuthUnauthenticated, "no current profile configured; run `weknora auth login` to set one up")
}
ctx, ok := cfg.Contexts[profileName]
prof, ok := cfg.Profiles[profileName]
if !ok {
// If the user explicitly overrode the profile (via --profile flag or
// WEKNORA_PROFILE env), it's a bad argument - not a corrupt config file.
@@ -132,11 +132,11 @@ func buildClient(f *Factory) (*sdk.Client, error) {
WithHint("list available profiles with `weknora profile list`").
WithRetryCommand("weknora profile list")
}
// ProfileOverride is empty: config.CurrentContext points at a missing entry.
// ProfileOverride is empty: config.CurrentProfile points at a missing entry.
// That's a genuinely corrupt config file.
return nil, NewError(CodeLocalConfigCorrupt, fmt.Sprintf("config references unknown profile %q", profileName))
}
if ctx.Host == "" {
if prof.Host == "" {
return nil, NewError(CodeLocalConfigCorrupt, fmt.Sprintf("profile %q has no host", profileName))
}
@@ -145,11 +145,11 @@ func buildClient(f *Factory) (*sdk.Client, error) {
if err != nil {
return nil, Wrapf(CodeLocalKeychainDenied, err, "init secrets store")
}
// Only fetch the secrets the context actually references. Skipping the
// Only fetch the secrets the profile actually references. Skipping the
// unused fetch avoids a `security` exec (macOS) / DBus call (Linux) per
// authenticated invocation.
var accessToken string
if ctx.TokenRef != "" {
if prof.TokenRef != "" {
if access, err := LoadSecret(store, profileName, "access"); err != nil {
return nil, err
} else if access != "" {
@@ -157,34 +157,41 @@ func buildClient(f *Factory) (*sdk.Client, error) {
opts = append(opts, sdk.WithBearerToken(access))
}
}
if ctx.APIKeyRef != "" {
if prof.APIKeyRef != "" {
if apiKey, err := LoadSecret(store, profileName, "api_key"); err != nil {
return nil, err
} else if apiKey != "" {
opts = append(opts, sdk.WithAPIKey(apiKey))
}
}
// JWT contexts (have both access + refresh refs) get the transparent
// JWT profiles (have both access + refresh refs) get the transparent
// 401-retry transport: on the first 401 from a non-/auth/* endpoint, the
// transport reads the stored refresh token, calls /api/v1/auth/refresh,
// persists the new pair, and replays the original request with the new
// bearer. API-key contexts skip this (no refresh semantic) - a 401 from
// bearer. API-key profiles skip this (no refresh semantic) - a 401 from
// them propagates as auth.unauthenticated for the caller to handle.
if ctx.TokenRef != "" && ctx.RefreshRef != "" {
if prof.TokenRef != "" && prof.RefreshRef != "" {
refreshFn := func(rctx context.Context) (string, error) {
return refreshAccessToken(rctx, store, ctx.Host, profileName)
return refreshAccessToken(rctx, store, prof.Host, profileName)
}
opts = append(opts, sdk.WithTransport(
NewAuthRetryTransport(http.DefaultTransport, accessToken, refreshFn),
))
}
// ctx.TenantID is intentionally NOT injected as X-Tenant-ID. Servers derive
// prof.TenantID is intentionally NOT injected as X-Tenant-ID. Servers derive
// tenant from the credential itself (JWT claim or API key prefix); the
// header is only meaningful for explicit cross-tenant switching by users
// with CanAccessAllTenants. Auto-mirroring the persisted tenant from config
// breaks that contract - explicit cross-tenant flags would be required
// before sending it. `tenant_id` stays in config for `auth status` display only.
return sdk.NewClient(ctx.Host, opts...), nil
return sdk.NewClient(prof.Host, opts...), nil
}
// AddKBFlag registers the standard `--kb` flag that ResolveKB reads. Use this
// instead of duplicating the flag declaration in every command that scopes to
// a knowledge base — one source of truth for flag name and help text.
func AddKBFlag(cmd *cobra.Command) {
cmd.Flags().String("kb", "", "Knowledge base UUID or name (overrides env / project link)")
}
// ResolveKB returns the active KB id for the running command, applying the
@@ -245,13 +252,13 @@ func (f *Factory) ApplyLogLevel(cmd *cobra.Command, stderr io.Writer) error {
return nil
}
// LoadSecret fetches a named secret for the given context from the keyring.
// LoadSecret fetches a named secret for the given profile from the keyring.
// Returns ("", nil) when the secret is absent (ErrNotFound); a real keyring
// access failure surfaces as CodeLocalKeychainDenied. Used by buildClient
// to assemble SDK auth options and by `auth token` to expose the raw
// credential for shell scripting.
func LoadSecret(store secrets.Store, context, key string) (string, error) {
v, err := store.Get(context, key)
func LoadSecret(store secrets.Store, profile, key string) (string, error) {
v, err := store.Get(profile, key)
if errors.Is(err, secrets.ErrNotFound) {
return "", nil
}
@@ -273,7 +280,7 @@ func refreshAccessToken(ctx context.Context, store secrets.Store, host, profileN
// ActiveProfile returns the resolved profile name for this invocation:
// 1. ProfileOverride (set by --profile flag in root PersistentPreRunE)
// 2. WEKNORA_PROFILE env var
// 3. Config's CurrentContext (the active context / profile name)
// 3. Config's CurrentProfile (the persisted active profile name)
// 4. Empty string when nothing is configured (envelope omits the field).
func (f *Factory) ActiveProfile() string {
if f.ProfileOverride != "" {
@@ -289,5 +296,5 @@ func (f *Factory) ActiveProfile() string {
if err != nil || cfg == nil {
return ""
}
return cfg.CurrentContext
return cfg.CurrentProfile
}

View File

@@ -55,9 +55,9 @@ func TestFactory_Lazy(t *testing.T) {
// TestNew_FoundationDefaults verifies the production New() returns a usable
// Factory and that Client surfaces auth.unauthenticated when no current
// context is configured (the precondition for `weknora auth login`).
// profile is configured (the precondition for `weknora auth login`).
func TestNew_FoundationDefaults(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir()) // empty config → no current context
t.Setenv("XDG_CONFIG_HOME", t.TempDir()) // empty config → no current profile
f := New()
require.NotNil(t, f)
require.NotNil(t, f.Config)
@@ -73,18 +73,18 @@ func TestNew_FoundationDefaults(t *testing.T) {
}
// TestFactory_ProfileOverride verifies the global --profile flag mechanism:
// f.ProfileOverride replaces config.CurrentContext for this invocation only,
// f.ProfileOverride replaces config.CurrentProfile for this invocation only,
// without writing to disk.
func TestFactory_ProfileOverride(t *testing.T) {
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
// Seed config with two contexts; CurrentContext = "default"
// Seed config with two profiles; CurrentProfile = "default"
cfgPath := dir + "/weknora/config.yaml"
require.NoError(t, os.MkdirAll(dir+"/weknora", 0o700))
require.NoError(t, os.WriteFile(cfgPath, []byte(`
current_context: default
contexts:
current_profile: default
profiles:
default:
host: https://default.example
other:
@@ -93,25 +93,25 @@ contexts:
f := New()
t.Run("no override: returns CurrentContext from disk", func(t *testing.T) {
t.Run("no override: returns CurrentProfile from disk", func(t *testing.T) {
f.ProfileOverride = ""
cfg, err := f.Config()
require.NoError(t, err)
assert.Equal(t, "default", cfg.CurrentContext)
assert.Equal(t, "default", cfg.CurrentProfile)
})
t.Run("override applied: ProfileOverride wins over disk", func(t *testing.T) {
f.ProfileOverride = "other"
cfg, err := f.Config()
require.NoError(t, err)
assert.Equal(t, "other", cfg.CurrentContext)
assert.Equal(t, "other", cfg.CurrentProfile)
})
t.Run("override does not persist to disk", func(t *testing.T) {
// Reload from disk: should still be "default" (the original).
raw, err := os.ReadFile(cfgPath)
require.NoError(t, err)
assert.Contains(t, string(raw), "current_context: default")
assert.Contains(t, string(raw), "current_profile: default")
})
}
@@ -164,7 +164,7 @@ func memSecretsFn(s *secrets.MemStore) func() (secrets.Store, error) {
return func() (secrets.Store, error) { return s, nil }
}
func TestBuildClient_NoCurrentContext(t *testing.T) {
func TestBuildClient_NoCurrentProfile(t *testing.T) {
f := &Factory{
Config: func() (*config.Config, error) { return &config.Config{}, nil },
Secrets: memSecretsFn(secrets.NewMemStore()),
@@ -179,7 +179,7 @@ func TestBuildClient_NoCurrentContext(t *testing.T) {
func TestBuildClient_UnknownContext(t *testing.T) {
f := &Factory{
Config: func() (*config.Config, error) {
return &config.Config{CurrentContext: "ghost"}, nil
return &config.Config{CurrentProfile: "ghost"}, nil
},
Secrets: memSecretsFn(secrets.NewMemStore()),
}
@@ -194,8 +194,8 @@ func TestBuildClient_MissingHost(t *testing.T) {
f := &Factory{
Config: func() (*config.Config, error) {
return &config.Config{
CurrentContext: "p",
Contexts: map[string]config.Context{"p": {Host: ""}},
CurrentProfile: "p",
Profiles: map[string]config.Profile{"p": {Host: ""}},
}, nil
},
Secrets: memSecretsFn(secrets.NewMemStore()),
@@ -214,8 +214,8 @@ func TestBuildClient_HappyPath(t *testing.T) {
f := &Factory{
Config: func() (*config.Config, error) {
return &config.Config{
CurrentContext: "p",
Contexts: map[string]config.Context{
CurrentProfile: "p",
Profiles: map[string]config.Profile{
"p": {
Host: "https://kb.example.com",
TenantID: 7,
@@ -233,15 +233,15 @@ func TestBuildClient_HappyPath(t *testing.T) {
}
func TestBuildClient_SkipsUnreferencedSecrets(t *testing.T) {
// If the context doesn't list APIKeyRef, buildClient must not call
// If the profile doesn't list APIKeyRef, buildClient must not call
// Get(api_key) - a perf invariant: avoid keychain trips for unused creds.
store := &countingSecrets{MemStore: secrets.NewMemStore()}
require.NoError(t, store.Set("p", "access", "jwt"))
f := &Factory{
Config: func() (*config.Config, error) {
return &config.Config{
CurrentContext: "p",
Contexts: map[string]config.Context{
CurrentProfile: "p",
Profiles: map[string]config.Profile{
"p": {Host: "https://x", TokenRef: "mem://p/access"},
},
}, nil

View File

@@ -99,21 +99,14 @@ func (o *FormatOptions) WantsJSON() bool {
// project with ".data[]", ".meta.count", etc.
//
// FormatNDJSON path: emits one bare JSON object per line (no envelope).
// Matches spec §5 NDJSON event-passthrough semantics.
// Matches the NDJSON event-passthrough contract used by streaming commands.
//
// FormatText path returns an error so a missed dispatch surfaces loudly.
func (o *FormatOptions) Emit(w io.Writer, data any, meta *output.Meta) error {
switch o.Mode {
case FormatJSON:
if o.JQ != "" {
env := output.Envelope{
OK: true,
Data: data,
Meta: meta,
Notice: output.GetNotice(),
Profile: globalProfile,
}
return format.WriteJSONFiltered(w, env, nil, o.JQ)
return format.WriteJSONFiltered(w, output.NewEnvelope(data, meta, globalProfile), nil, o.JQ)
}
return output.WriteEnvelope(w, data, meta, o.TTY, globalProfile)
case FormatNDJSON:

View File

@@ -42,7 +42,7 @@ func TestEmit_WithMeta(t *testing.T) {
}
func TestEmit_NDJSON_NoEnvelope(t *testing.T) {
// NDJSON path is event-passthrough; no envelope wrapping per spec §5.
// NDJSON path is event-passthrough; no envelope wrapping.
var buf bytes.Buffer
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatNDJSON}
data := []map[string]string{{"id": "a"}, {"id": "b"}}

View File

@@ -0,0 +1,48 @@
package cmdutil
import (
"fmt"
"strings"
)
// ValidateProfileName enforces the profile-name allowlist: letters, digits,
// dash, underscore, dot. The `.` exception lets email / DNS-like names
// through; `.` / `..` and path separators are structurally rejected so a
// hand-edited config.yaml can't claim a profile whose name walks out of the
// keyring namespace.
//
// Crucially this also rejects shell metacharacters (space, `;`, `&`, `|`,
// `$`, quotes, backticks, etc.), so the profile name remains safe to
// interpolate into envelope.error.retry_command — an agent that exec's
// `sh -c <retry_command>` cannot be tricked via a maliciously-named profile.
func ValidateProfileName(name string) error {
if name == "" {
return &Error{
Code: CodeInputInvalidArgument,
Message: "profile name must not be empty",
}
}
if name == "." || name == ".." || strings.Contains(name, "/") || strings.Contains(name, "\\") {
return &Error{
Code: CodeInputInvalidArgument,
Message: fmt.Sprintf("profile name %q is reserved or path-like", name),
Hint: "use letters, digits, dashes, underscores, or dots",
}
}
for _, r := range name {
switch {
case r >= 'a' && r <= 'z',
r >= 'A' && r <= 'Z',
r >= '0' && r <= '9',
r == '-' || r == '_' || r == '.':
continue
default:
return &Error{
Code: CodeInputInvalidArgument,
Message: fmt.Sprintf("profile name %q contains invalid character %q", name, r),
Hint: "use letters, digits, dashes, underscores, or dots",
}
}
}
return nil
}

View File

@@ -0,0 +1,113 @@
package cmdutil
import (
"errors"
"strings"
"testing"
)
// TestValidateProfileName_AcceptsAllowlist verifies the documented charset
// is accepted as-is.
func TestValidateProfileName_AcceptsAllowlist(t *testing.T) {
for _, name := range []string{
"default",
"prod",
"staging-2026",
"ci_runner",
"alice.example",
"a", // single char
"A-Z_0-9",
} {
if err := ValidateProfileName(name); err != nil {
t.Errorf("ValidateProfileName(%q) unexpected error: %v", name, err)
}
}
}
// TestValidateProfileName_RejectsEmpty guards the empty-string base case.
func TestValidateProfileName_RejectsEmpty(t *testing.T) {
err := ValidateProfileName("")
if err == nil {
t.Fatal("expected error for empty name")
}
var ce *Error
if !errors.As(err, &ce) || ce.Code != CodeInputInvalidArgument {
t.Errorf("expected input.invalid_argument, got %v", err)
}
}
// TestValidateProfileName_RejectsShellMetachars is the security-critical
// case: anything that could break retry_command shell interpolation must be
// rejected at the entry point. If this test ever loosens, an agent that
// exec()s retry_command becomes injectable via a malicious profile name.
func TestValidateProfileName_RejectsShellMetachars(t *testing.T) {
cases := []string{
"evil; rm -rf /",
"foo && bar",
"foo || bar",
"foo|bar",
"foo`whoami`",
"foo$(whoami)",
"foo$bar",
"foo>out",
"foo<in",
"foo bar", // space alone
"foo'bar",
`foo"bar`,
"foo\nbar", // newline
"foo\tbar", // tab
"foo\\bar", // backslash (also path-like)
"foo/bar", // slash (also path-like)
"#foo", // comment marker
"foo*bar", // glob
"foo?bar", // glob
"foo~bar", // home expansion
"foo!bar", // history expansion
"foo,bar", // brace-expansion-ish
"foo[bar",
}
for _, name := range cases {
err := ValidateProfileName(name)
if err == nil {
t.Errorf("ValidateProfileName(%q) should have rejected the name; an agent exec'ing retry_command would be injectable", name)
continue
}
var ce *Error
if !errors.As(err, &ce) || ce.Code != CodeInputInvalidArgument {
t.Errorf("ValidateProfileName(%q) returned wrong code %v; want input.invalid_argument", name, err)
}
}
}
// TestValidateProfileName_RejectsPathTraversal covers the keyring-namespace
// escape vector. `.` and `..` are reserved, and any slash is rejected.
func TestValidateProfileName_RejectsPathTraversal(t *testing.T) {
for _, name := range []string{".", "..", "../foo", "foo/..", "a/b", `a\b`} {
err := ValidateProfileName(name)
if err == nil {
t.Errorf("ValidateProfileName(%q) should have rejected the path-like name", name)
continue
}
// Path-shaped names hit the dedicated "reserved or path-like" branch
// first (clearer hint than "invalid character %q") for `..` and the
// slashed forms.
if name == "." || name == ".." || strings.ContainsAny(name, "/\\") {
if !strings.Contains(err.Error(), "reserved or path-like") {
t.Errorf("ValidateProfileName(%q): expected path-like hint, got %v", name, err)
}
}
}
}
// TestValidateProfileName_HintMentionsAllowlist verifies the user-facing
// hint actually tells them what's allowed (not just "invalid").
func TestValidateProfileName_HintMentionsAllowlist(t *testing.T) {
err := ValidateProfileName("foo bar")
var ce *Error
if !errors.As(err, &ce) {
t.Fatalf("expected typed Error, got %v", err)
}
if !strings.Contains(ce.Hint, "letters") || !strings.Contains(ce.Hint, "dots") {
t.Errorf("hint should describe allowlist; got %q", ce.Hint)
}
}

View File

@@ -1,6 +1,6 @@
// Package config reads and writes the user-level config at
// $XDG_CONFIG_HOME/weknora/config.yaml. yaml.v3 directly; viper is
// intentionally not used. Multi-host context map lives here;
// intentionally not used. Multi-host profile map lives here;
// the per-project link (.weknora/project.yaml) is handled by the
// projectlink package.
package config
@@ -18,8 +18,8 @@ import (
// Config is the on-disk schema. Empty zero-value is valid (returned when the
// file does not exist) so commands like --help / version don't fail.
type Config struct {
CurrentContext string `yaml:"current_context,omitempty"`
Contexts map[string]Context `yaml:"contexts,omitempty"`
CurrentProfile string `yaml:"current_profile,omitempty"`
Profiles map[string]Profile `yaml:"profiles,omitempty"`
// Defaults holds CLI-wide defaults; fields opt-in.
Defaults struct {
@@ -29,8 +29,8 @@ type Config struct {
} `yaml:"defaults,omitempty"`
}
// Context is one named connection target (host + tenant + credential reference).
type Context struct {
// Profile is one named connection target (host + tenant + credential reference).
type Profile struct {
Host string `yaml:"host"`
TenantID uint64 `yaml:"tenant_id,omitempty"`
User string `yaml:"user,omitempty"`

View File

@@ -18,14 +18,14 @@ func TestLoad_FileMissing(t *testing.T) {
c, err := Load()
require.NoError(t, err, "missing file must not error (commands like --help must work)")
assert.NotNil(t, c)
assert.Empty(t, c.Contexts)
assert.Empty(t, c.Profiles)
}
func TestSaveLoad_RoundTrip(t *testing.T) {
testutil.XDGTempDir(t)
in := &Config{
CurrentContext: "prod",
Contexts: map[string]Context{
CurrentProfile: "prod",
Profiles: map[string]Profile{
"prod": {Host: "https://kb.example.com", TenantID: 7, APIKeyRef: "keychain://weknora/prod/access"},
},
}
@@ -33,9 +33,9 @@ func TestSaveLoad_RoundTrip(t *testing.T) {
out, err := Load()
require.NoError(t, err)
assert.Equal(t, "prod", out.CurrentContext)
assert.Equal(t, "https://kb.example.com", out.Contexts["prod"].Host)
assert.Equal(t, uint64(7), out.Contexts["prod"].TenantID)
assert.Equal(t, "prod", out.CurrentProfile)
assert.Equal(t, "https://kb.example.com", out.Profiles["prod"].Host)
assert.Equal(t, uint64(7), out.Profiles["prod"].TenantID)
p, err := Path()
require.NoError(t, err)
@@ -80,11 +80,11 @@ func TestPath_FallsBackToHome(t *testing.T) {
func TestSave_AtomicReplace(t *testing.T) {
dir := testutil.XDGTempDir(t)
require.NoError(t, Save(&Config{CurrentContext: "a"}))
require.NoError(t, Save(&Config{CurrentContext: "b"}))
require.NoError(t, Save(&Config{CurrentProfile: "a"}))
require.NoError(t, Save(&Config{CurrentProfile: "b"}))
out, err := Load()
require.NoError(t, err)
assert.Equal(t, "b", out.CurrentContext)
assert.Equal(t, "b", out.CurrentProfile)
matches, _ := filepath.Glob(filepath.Join(dir, "weknora", "*.tmp"))
assert.Empty(t, matches)
}

View File

@@ -2,7 +2,7 @@ package format
// DashIfEmpty returns "-" for empty strings, otherwise s itself. Standard
// rendering convention for human-readable table cells whose value is
// optional (e.g. user email in `auth list` / `context list`).
// optional (e.g. user email in `auth list` / `profile list`).
func DashIfEmpty(s string) string {
if s == "" {
return "-"

View File

@@ -2,7 +2,7 @@ package output
import "io"
// BatchItem is one per-id outcome in a batch operation envelope (§4.5).
// BatchItem is one per-id outcome in a batch operation envelope.
type BatchItem struct {
ID string `json:"id"`
OK bool `json:"ok"`
@@ -10,7 +10,7 @@ type BatchItem struct {
Error *ErrDetail `json:"error,omitempty"`
}
// WriteBatchEnvelope writes a batch operation envelope per §4.5.
// WriteBatchEnvelope writes a batch operation envelope.
//
// Wire shape: {ok, data:[BatchItem...], meta:{count, successes, failures}, profile?}.
// Top-level ok = (failures == 0). Per-id ok reflects each item's outcome.

View File

@@ -87,7 +87,8 @@ func TestWriteBatchEnvelope_AllFail(t *testing.T) {
if !strings.Contains(got, `"ok":false`) {
t.Errorf("expected ok:false; got %q", got)
}
// 全失败仍走 batch envelope shape不退化到 ErrorEnvelope.
// All-fail still uses the batch envelope shape; it does not degrade
// to ErrorEnvelope.
// An ErrorEnvelope has a top-level "error" key alongside "ok"; a batch
// envelope has a top-level "data" array. Check that "data":[ is present.
if !strings.Contains(got, `"data":[`) {

View File

@@ -8,7 +8,8 @@ import (
"io"
)
// Envelope is the success-path stdout envelope (§4.1).
// Envelope is the success-path stdout envelope. See AGENTS.md
// "Stdout (success path)" for the full wire contract.
type Envelope struct {
OK bool `json:"ok"`
Data any `json:"data,omitempty"`
@@ -17,14 +18,15 @@ type Envelope struct {
Profile string `json:"profile,omitempty"`
}
// ErrorEnvelope is the error-path stderr envelope (§4.2).
// ErrorEnvelope is the error-path stderr envelope. See AGENTS.md
// "Stderr (error path)" for the full wire contract.
type ErrorEnvelope struct {
OK bool `json:"ok"`
Error *ErrDetail `json:"error"`
Notice map[string]any `json:"_notice,omitempty"`
}
// Meta carries optional metadata in success envelopes (§4.3).
// Meta carries optional metadata in success envelopes.
type Meta struct {
Count int `json:"count,omitempty"`
HasMore bool `json:"has_more,omitempty"`
@@ -38,7 +40,8 @@ type Meta struct {
Failures *int `json:"failures,omitempty"` // batch ops
}
// ErrDetail describes a structured error (§4.2).
// ErrDetail describes a structured error. Embedded in ErrorEnvelope.Error
// and also surfaced in batch envelope per-item failures.
type ErrDetail struct {
Type string `json:"type"`
Message string `json:"message"`
@@ -49,7 +52,8 @@ type ErrDetail struct {
Detail any `json:"detail,omitempty"`
}
// RiskDetail tags high-risk writes for agent protocol (§4.2 error.risk).
// RiskDetail tags high-risk writes for the agent protocol. Surfaces in
// error.risk on confirmation_required errors.
// Level: only "destructive" is emitted; "read" / "write" slots reserved.
type RiskDetail struct {
Level string `json:"level"`
@@ -69,20 +73,27 @@ func GetNotice() map[string]any {
return PendingNotice()
}
// WriteEnvelope writes a success envelope to w. Caller sets data + optional meta;
// notice is injected from GetNotice() automatically.
//
// When profile is non-empty, the envelope includes a "profile" field.
// indent: if true, output is multi-line (TTY mode); else compact (pipe mode).
func WriteEnvelope(w io.Writer, data any, meta *Meta, indent bool, profile string) error {
env := Envelope{
// NewEnvelope assembles a success Envelope with the given data + optional
// meta + profile, injecting the pending _notice automatically. Single source
// of construction so callers that need the envelope value (e.g. jq filtering)
// stay in sync with WriteEnvelope when fields are added.
func NewEnvelope(data any, meta *Meta, profile string) Envelope {
return Envelope{
OK: true,
Data: data,
Meta: meta,
Notice: GetNotice(),
Profile: profile,
}
return writeJSON(w, env, indent)
}
// WriteEnvelope writes a success envelope to w. Caller sets data + optional meta;
// notice is injected from GetNotice() automatically.
//
// When profile is non-empty, the envelope includes a "profile" field.
// indent: if true, output is multi-line (TTY mode); else compact (pipe mode).
func WriteEnvelope(w io.Writer, data any, meta *Meta, indent bool, profile string) error {
return writeJSON(w, NewEnvelope(data, meta, profile), indent)
}
// WriteErrorEnvelope writes an error envelope to w (typically stderr).

View File

@@ -24,7 +24,7 @@ func TestWriteEnvelope_SuccessWithData(t *testing.T) {
}
func TestWriteEnvelope_OmitDataWhenNil(t *testing.T) {
// mutation payload data 字段应被省略(omitempty
// Mutation with no payload: the data field should be omitted (omitempty).
var buf bytes.Buffer
if err := output.WriteEnvelope(&buf, nil, nil, false, ""); err != nil {
t.Fatalf("unexpected error: %v", err)

View File

@@ -6,8 +6,8 @@ import (
)
// InitEvent is the CLI-injected lifecycle event written at the head of a
// streaming command's NDJSON output (§5.3). Carries enough context for an
// agent to thread follow-ups (session id, kb id, model, profile).
// streaming command's NDJSON output. Carries enough context for an agent to
// thread follow-ups (session id, kb id, model, profile).
//
// Type field is always "init"; the JSON tag fixes the wire shape.
type InitEvent struct {
@@ -37,7 +37,8 @@ func EmitInit(w io.Writer, ev InitEvent) error {
}
// EmitSDKEvent passes through the raw SDK event as one NDJSON line.
// SDK is source of truth for event vocab (§5.1).
// The SDK is the source of truth for event vocabulary; the CLI does not
// rename or reshape events.
func EmitSDKEvent(w io.Writer, ev any) error {
return WriteNDJSONLine(w, ev)
}

View File

@@ -8,7 +8,7 @@ import (
)
// keyringService is the namespace prefix passed to OS keyring backends.
// Final keyring entries look like service="weknora:<context>", account="<key>".
// Final keyring entries look like service="weknora:<profile>", account="<key>".
const keyringService = "weknora"
// KeyringStore is the OS-backed credential store: macOS Keychain, GNOME
@@ -20,15 +20,15 @@ type KeyringStore struct{}
// real Get/Set surfaces ErrUnsupported on systems with no keyring backend.
func NewKeyringStore() *KeyringStore { return &KeyringStore{} }
// service returns the per-context service identifier. Splitting by context
// service returns the per-profile service identifier. Splitting by profile
// (rather than by host) follows the Supabase pattern: a user with
// two tenants on the same host gets two distinct keyring namespaces.
func (k *KeyringStore) service(context string) string {
return keyringService + ":" + context
func (k *KeyringStore) service(profile string) string {
return keyringService + ":" + profile
}
func (k *KeyringStore) Get(context, key string) (string, error) {
v, err := keyring.Get(k.service(context), key)
func (k *KeyringStore) Get(profile, key string) (string, error) {
v, err := keyring.Get(k.service(profile), key)
if errors.Is(err, keyring.ErrNotFound) {
return "", ErrNotFound
}
@@ -38,15 +38,15 @@ func (k *KeyringStore) Get(context, key string) (string, error) {
return v, nil
}
func (k *KeyringStore) Set(context, key, value string) error {
if err := keyring.Set(k.service(context), key, value); err != nil {
func (k *KeyringStore) Set(profile, key, value string) error {
if err := keyring.Set(k.service(profile), key, value); err != nil {
return fmt.Errorf("keyring set: %w", err)
}
return nil
}
func (k *KeyringStore) Delete(context, key string) error {
err := keyring.Delete(k.service(context), key)
func (k *KeyringStore) Delete(profile, key string) error {
err := keyring.Delete(k.service(profile), key)
if errors.Is(err, keyring.ErrNotFound) {
return nil
}
@@ -57,8 +57,8 @@ func (k *KeyringStore) Delete(context, key string) error {
}
// Ref returns the keychain:// URI under which a secret is stored.
func (k *KeyringStore) Ref(context, key string) string {
return "keychain://" + keyringService + "/" + context + "/" + key
func (k *KeyringStore) Ref(profile, key string) string {
return "keychain://" + keyringService + "/" + profile + "/" + key
}
// NewBestEffortStore returns a Store that prefers keyring (when available)
@@ -69,7 +69,7 @@ func (k *KeyringStore) Ref(context, key string) string {
// it is unsupported we return the file store directly.
func NewBestEffortStore() (Store, error) {
k := NewKeyringStore()
// Use a sentinel context/key that should not exist; we expect either
// Use a sentinel profile/key that should not exist; we expect either
// ErrNotFound (backend works, just empty) or ErrUnsupported (no backend).
_, err := keyring.Get(k.service("__probe__"), "__probe__")
if err == nil || errors.Is(err, keyring.ErrNotFound) {

View File

@@ -3,7 +3,7 @@
// $XDG_CONFIG_HOME/weknora/secrets/, used as a fallback when no keyring
// backend is available).
//
// Namespace convention: "weknora:<context>:<key>" where key is "access",
// Namespace convention: "weknora:<profile>:<key>" where key is "access",
// "refresh", or "api_key".
package secrets
@@ -22,18 +22,18 @@ var ErrNotFound = errors.New("secret: not found")
// Store is the abstraction CLI commands depend on; tests inject in-memory impls.
//
// Ref returns a stable URI (e.g. file://<context>/<key> or
// keychain://weknora/<context>/<key>) describing where a saved secret lives.
// Ref returns a stable URI (e.g. file://<profile>/<key> or
// keychain://weknora/<profile>/<key>) describing where a saved secret lives.
// Backends own their scheme so commands never need to type-assert the
// concrete implementation.
type Store interface {
Get(context, key string) (string, error)
Set(context, key, value string) error
Delete(context, key string) error
Ref(context, key string) string
Get(profile, key string) (string, error)
Set(profile, key, value string) error
Delete(profile, key string) error
Ref(profile, key string) string
}
// 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/<profile>.
// It is the headless / CI default and the keychain fallback.
type FileStore struct {
root string
@@ -54,12 +54,12 @@ func defaultRoot() (string, error) {
return xdg.Path("XDG_CONFIG_HOME", ".config", "secrets")
}
func (f *FileStore) path(context, key string) string {
return filepath.Join(f.root, context, key)
func (f *FileStore) path(profile, key string) string {
return filepath.Join(f.root, profile, key)
}
func (f *FileStore) Get(context, key string) (string, error) {
data, err := os.ReadFile(f.path(context, key))
func (f *FileStore) Get(profile, key string) (string, error) {
data, err := os.ReadFile(f.path(profile, key))
if errors.Is(err, os.ErrNotExist) {
return "", ErrNotFound
}
@@ -69,19 +69,19 @@ func (f *FileStore) Get(context, key string) (string, error) {
return string(data), nil
}
func (f *FileStore) Set(context, key, value string) error {
dir := filepath.Join(f.root, context)
func (f *FileStore) Set(profile, key, value string) error {
dir := filepath.Join(f.root, profile)
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("mkdir secrets dir: %w", err)
}
if err := os.WriteFile(f.path(context, key), []byte(value), 0o600); err != nil {
if err := os.WriteFile(f.path(profile, key), []byte(value), 0o600); err != nil {
return fmt.Errorf("write secret: %w", err)
}
return nil
}
func (f *FileStore) Delete(context, key string) error {
err := os.Remove(f.path(context, key))
func (f *FileStore) Delete(profile, key string) error {
err := os.Remove(f.path(profile, key))
if errors.Is(err, os.ErrNotExist) {
return nil
}
@@ -93,8 +93,8 @@ func (f *FileStore) Delete(context, key string) error {
// file:///C:/... on Windows) - the wire format must not depend on platform
// path separator since this string is persisted to config.yaml and may be
// consumed by tooling on a different OS.
func (f *FileStore) Ref(context, key string) string {
p := filepath.ToSlash(f.path(context, key))
func (f *FileStore) Ref(profile, key string) string {
p := filepath.ToSlash(f.path(profile, key))
if !strings.HasPrefix(p, "/") {
// Windows absolute path "C:/..." needs a leading slash to form a
// proper file:/// URI ("file:///C:/...").