diff --git a/cli/AGENTS.md b/cli/AGENTS.md index b5edee03..da353273 100644 --- a/cli/AGENTS.md +++ b/cli/AGENTS.md @@ -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": "", - "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 --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 -### 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 diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 03dcefbb..dff4a00c 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -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 `.** - 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 ` 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: " --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 diff --git a/cli/Makefile b/cli/Makefile index 848d2a63..6c0f154e 100644 --- a/cli/Makefile +++ b/cli/Makefile @@ -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 diff --git a/cli/README.md b/cli/README.md index 685b5675..70e87f2e 100644 --- a/cli/README.md +++ b/cli/README.md @@ -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 ` for single-shot profile overrides without disk writes. diff --git a/cli/acceptance/contract/wire_test.go b/cli/acceptance/contract/wire_test.go index 7fbedfc7..aa01db31 100644 --- a/cli/acceptance/contract/wire_test.go +++ b/cli/acceptance/contract/wire_test.go @@ -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"}, }, diff --git a/cli/acceptance/e2e/e2e_test.go b/cli/acceptance/e2e/e2e_test.go index 44e49ef4..5fe0dbbf 100644 --- a/cli/acceptance/e2e/e2e_test.go +++ b/cli/acceptance/e2e/e2e_test.go @@ -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 diff --git a/cli/acceptance/testdata/wire/doctor.error_network.json b/cli/acceptance/testdata/wire/doctor.error_network.json index 49fbc739..57d7b920 100644 --- a/cli/acceptance/testdata/wire/doctor.error_network.json +++ b/cli/acceptance/testdata/wire/doctor.error_network.json @@ -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"}]}} diff --git a/cli/cmd/api/api.go b/cli/cmd/api/api.go index d60dcaf1..8fc669f4 100644 --- a/cli/cmd/api/api.go +++ b/cli/cmd/api/api.go @@ -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: diff --git a/cli/cmd/auth/auth.go b/cli/cmd/auth/auth.go index 2f2f2707..4980761e 100644 --- a/cli/cmd/auth/auth.go +++ b/cli/cmd/auth/auth.go @@ -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 { diff --git a/cli/cmd/auth/auth_test.go b/cli/cmd/auth/auth_test.go index cfffd479..f19fbd64 100644 --- a/cli/cmd/auth/auth_test.go +++ b/cli/cmd/auth/auth_test.go @@ -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", diff --git a/cli/cmd/auth/list.go b/cli/cmd/auth/list.go index 6ccc279c..7680f572 100644 --- a/cli/cmd/auth/list.go +++ b/cli/cmd/auth/list.go @@ -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 }) diff --git a/cli/cmd/auth/list_test.go b/cli/cmd/auth/list_test.go index fe03b28a..36ddc305 100644 --- a/cli/cmd/auth/list_test.go +++ b/cli/cmd/auth/list_test.go @@ -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", "")) diff --git a/cli/cmd/auth/login.go b/cli/cmd/auth/login.go index 9e1afa4d..46a552a4 100644 --- a/cli/cmd/auth/login.go +++ b/cli/cmd/auth/login.go @@ -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 } diff --git a/cli/cmd/auth/login_test.go b/cli/cmd/auth/login_test.go index 19a32337..9336ab28 100644 --- a/cli/cmd/auth/login_test.go +++ b/cli/cmd/auth/login_test.go @@ -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") } diff --git a/cli/cmd/auth/logout.go b/cli/cmd/auth/logout.go index 7bb92595..a61dbc2d 100644 --- a/cli/cmd/auth/logout.go +++ b/cli/cmd/auth/logout.go @@ -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 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") } diff --git a/cli/cmd/auth/logout_test.go b/cli/cmd/auth/logout_test.go index 1b22aab4..49061f13 100644 --- a/cli/cmd/auth/logout_test.go +++ b/cli/cmd/auth/logout_test.go @@ -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"}) diff --git a/cli/cmd/auth/refresh.go b/cli/cmd/auth/refresh.go index 62dc71f0..cbbc6adb 100644 --- a/cli/cmd/auth/refresh.go +++ b/cli/cmd/auth/refresh.go @@ -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)) diff --git a/cli/cmd/auth/refresh_test.go b/cli/cmd/auth/refresh_test.go index aea56e2e..2771c7a5 100644 --- a/cli/cmd/auth/refresh_test.go +++ b/cli/cmd/auth/refresh_test.go @@ -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"` diff --git a/cli/cmd/auth/status.go b/cli/cmd/auth/status.go index cf6177ed..e16bdb58 100644 --- a/cli/cmd/auth/status.go +++ b/cli/cmd/auth/status.go @@ -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) diff --git a/cli/cmd/auth/status_test.go b/cli/cmd/auth/status_test.go index eeb53ecb..f1fe7b00 100644 --- a/cli/cmd/auth/status_test.go +++ b/cli/cmd/auth/status_test.go @@ -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) diff --git a/cli/cmd/auth/token.go b/cli/cmd/auth/token.go index 2bda9774..758cc503 100644 --- a/cli/cmd/auth/token.go +++ b/cli/cmd/auth/token.go @@ -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. diff --git a/cli/cmd/auth/token_test.go b/cli/cmd/auth/token_test.go index 6537d984..5c7d3f71 100644 --- a/cli/cmd/auth/token_test.go +++ b/cli/cmd/auth/token_test.go @@ -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"}, }, } diff --git a/cli/cmd/chat/chat.go b/cli/cmd/chat/chat.go index a43bab15..c18e9d83 100644 --- a/cli/cmd/chat/chat.go +++ b/cli/cmd/chat/chat.go @@ -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{ diff --git a/cli/cmd/chunk/delete_test.go b/cli/cmd/chunk/delete_test.go index aeb77f69..cce4fb76 100644 --- a/cli/cmd/chunk/delete_test.go +++ b/cli/cmd/chunk/delete_test.go @@ -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) diff --git a/cli/cmd/doc/create.go b/cli/cmd/doc/create.go index b3d1f17a..92ce564f 100644 --- a/cli/cmd/doc/create.go +++ b/cli/cmd/doc/create.go @@ -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") diff --git a/cli/cmd/doc/delete_test.go b/cli/cmd/doc/delete_test.go index ffa91f6a..e9ac72d7 100644 --- a/cli/cmd/doc/delete_test.go +++ b/cli/cmd/doc/delete_test.go @@ -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) { diff --git a/cli/cmd/doc/fetch.go b/cli/cmd/doc/fetch.go index e66b6bd8..7d944c2f 100644 --- a/cli/cmd/doc/fetch.go +++ b/cli/cmd/doc/fetch.go @@ -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") diff --git a/cli/cmd/doc/list.go b/cli/cmd/doc/list.go index 2fdcc620..167bec83 100644 --- a/cli/cmd/doc/list.go +++ b/cli/cmd/doc/list.go @@ -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)") diff --git a/cli/cmd/doc/list_test.go b/cli/cmd/doc/list_test.go index 8abb9403..8ec832bf 100644 --- a/cli/cmd/doc/list_test.go +++ b/cli/cmd/doc/list_test.go @@ -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 }, diff --git a/cli/cmd/doc/upload.go b/cli/cmd/doc/upload.go index 71a726dc..e5b9aefe 100644 --- a/cli/cmd/doc/upload.go +++ b/cli/cmd/doc/upload.go @@ -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')") diff --git a/cli/cmd/doc/upload_recursive_test.go b/cli/cmd/doc/upload_recursive_test.go index e49f99f9..6a7dc502 100644 --- a/cli/cmd/doc/upload_recursive_test.go +++ b/cli/cmd/doc/upload_recursive_test.go @@ -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) diff --git a/cli/cmd/doc/wait.go b/cli/cmd/doc/wait.go index a3110571..92460da4 100644 --- a/cli/cmd/doc/wait.go +++ b/cli/cmd/doc/wait.go @@ -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 diff --git a/cli/cmd/doctor/doctor.go b/cli/cmd/doctor/doctor.go index 0de6d216..4f0d0d94 100644 --- a/cli/cmd/doctor/doctor.go +++ b/cli/cmd/doctor/doctor.go @@ -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) { diff --git a/cli/cmd/kb/list.go b/cli/cmd/kb/list.go index 3a59044a..d02b722d 100644 --- a/cli/cmd/kb/list.go +++ b/cli/cmd/kb/list.go @@ -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) diff --git a/cli/cmd/link/link.go b/cli/cmd/link/link.go index 8b13136c..e76b6483 100644 --- a/cli/cmd/link/link.go +++ b/cli/cmd/link/link.go @@ -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 diff --git a/cli/cmd/link/link_test.go b/cli/cmd/link/link_test.go index 22635f3d..381bd874 100644 --- a/cli/cmd/link/link_test.go +++ b/cli/cmd/link/link_test.go @@ -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"}, }, } diff --git a/cli/cmd/mcp/mcp.go b/cli/cmd/mcp/mcp.go index 2e072aed..a2f3992b 100644 --- a/cli/cmd/mcp/mcp.go +++ b/cli/cmd/mcp/mcp.go @@ -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 diff --git a/cli/cmd/mcp/serve.go b/cli/cmd/mcp/serve.go index f5b8ca89..e507806d 100644 --- a/cli/cmd/mcp/serve.go +++ b/cli/cmd/mcp/serve.go @@ -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. diff --git a/cli/cmd/profile/add.go b/cli/cmd/profile/add.go index b89ab7f3..ea48bc0a 100644 --- a/cli/cmd/profile/add.go +++ b/cli/cmd/profile/add.go @@ -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 -} diff --git a/cli/cmd/profile/add_test.go b/cli/cmd/profile/add_test.go index a6708627..77aebd24 100644 --- a/cli/cmd/profile/add_test.go +++ b/cli/cmd/profile/add_test.go @@ -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) } } diff --git a/cli/cmd/profile/list.go b/cli/cmd/profile/list.go index 99b7c472..628318ee 100644 --- a/cli/cmd/profile/list.go +++ b/cli/cmd/profile/list.go @@ -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 }) diff --git a/cli/cmd/profile/list_test.go b/cli/cmd/profile/list_test.go index d48c86ca..28c71eb9 100644 --- a/cli/cmd/profile/list_test.go +++ b/cli/cmd/profile/list_test.go @@ -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"}, }, diff --git a/cli/cmd/profile/remove.go b/cli/cmd/profile/remove.go index 3612e3ba..454d9a72 100644 --- a/cli/cmd/profile/remove.go +++ b/cli/cmd/profile/remove.go @@ -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 `" + ` 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") } diff --git a/cli/cmd/profile/remove_test.go b/cli/cmd/profile/remove_test.go index 261f0fae..aacf24c3 100644 --- a/cli/cmd/profile/remove_test.go +++ b/cli/cmd/profile/remove_test.go @@ -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") } } diff --git a/cli/cmd/profile/use.go b/cli/cmd/profile/use.go index b1d49e03..de06f440 100644 --- a/cli/cmd/profile/use.go +++ b/cli/cmd/profile/use.go @@ -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) diff --git a/cli/cmd/profile/use_test.go b/cli/cmd/profile/use_test.go index c9efd1e6..7fcf5268 100644 --- a/cli/cmd/profile/use_test.go +++ b/cli/cmd/profile/use_test.go @@ -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) diff --git a/cli/cmd/search/kb.go b/cli/cmd/search/kb.go index 56208667..7deb10cc 100644 --- a/cli/cmd/search/kb.go +++ b/cli/cmd/search/kb.go @@ -40,7 +40,7 @@ type KBSearchService interface { } // NewCmdKB builds `weknora search kb ""` - 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 ""`, 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 diff --git a/cli/cmd/session/ask.go b/cli/cmd/session/ask.go index f68b3e71..7696f886 100644 --- a/cli/cmd/session/ask.go +++ b/cli/cmd/session/ask.go @@ -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, diff --git a/cli/cmd/session/ask_test.go b/cli/cmd/session/ask_test.go index 16f0e658..160a222a 100644 --- a/cli/cmd/session/ask_test.go +++ b/cli/cmd/session/ask_test.go @@ -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"), diff --git a/cli/cmd/session/delete.go b/cli/cmd/session/delete.go index f826c8c2..db2bbe9a 100644 --- a/cli/cmd/session/delete.go +++ b/cli/cmd/session/delete.go @@ -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 { diff --git a/cli/cmd/session/delete_test.go b/cli/cmd/session/delete_test.go index e2d59080..0070146a 100644 --- a/cli/cmd/session/delete_test.go +++ b/cli/cmd/session/delete_test.go @@ -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) diff --git a/cli/cmd/session/list.go b/cli/cmd/session/list.go index ed077223..ce2f8936 100644 --- a/cli/cmd/session/list.go +++ b/cli/cmd/session/list.go @@ -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) diff --git a/cli/internal/cmdutil/authrefresh.go b/cli/internal/cmdutil/authrefresh.go index 361c3a99..b8cc51ea 100644 --- a/cli/internal/cmdutil/authrefresh.go +++ b/cli/internal/cmdutil/authrefresh.go @@ -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 diff --git a/cli/internal/cmdutil/authretry.go b/cli/internal/cmdutil/authretry.go index 9952cbb4..7989874e 100644 --- a/cli/internal/cmdutil/authretry.go +++ b/cli/internal/cmdutil/authretry.go @@ -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 { diff --git a/cli/internal/cmdutil/batch.go b/cli/internal/cmdutil/batch.go index c09c563b..e97ecf49 100644 --- a/cli/internal/cmdutil/batch.go +++ b/cli/internal/cmdutil/batch.go @@ -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 " / +// the batch envelope; text mode emits per-line "OK " / // "FAIL : ". // // resultFn builds the per-item Result map for successes; nil ⇒ omit. diff --git a/cli/internal/cmdutil/batch_test.go b/cli/internal/cmdutil/batch_test.go index d61195f9..d87b226a 100644 --- a/cli/internal/cmdutil/batch_test.go +++ b/cli/internal/cmdutil/batch_test.go @@ -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{ diff --git a/cli/internal/cmdutil/errors.go b/cli/internal/cmdutil/errors.go index 145da574..ce06bb78 100644 --- a/cli/internal/cmdutil/errors.go +++ b/cli/internal/cmdutil/errors.go @@ -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 diff --git a/cli/internal/cmdutil/exit.go b/cli/internal/cmdutil/exit.go index 606ecef0..832e39bf 100644 --- a/cli/internal/cmdutil/exit.go +++ b/cli/internal/cmdutil/exit.go @@ -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 "" diff --git a/cli/internal/cmdutil/factory.go b/cli/internal/cmdutil/factory.go index c62c79f5..d294e111 100644 --- a/cli/internal/cmdutil/factory.go +++ b/cli/internal/cmdutil/factory.go @@ -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 } diff --git a/cli/internal/cmdutil/factory_test.go b/cli/internal/cmdutil/factory_test.go index 0b162c47..c17d2230 100644 --- a/cli/internal/cmdutil/factory_test.go +++ b/cli/internal/cmdutil/factory_test.go @@ -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 diff --git a/cli/internal/cmdutil/format.go b/cli/internal/cmdutil/format.go index cedcf885..5129e0c2 100644 --- a/cli/internal/cmdutil/format.go +++ b/cli/internal/cmdutil/format.go @@ -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: diff --git a/cli/internal/cmdutil/format_envelope_test.go b/cli/internal/cmdutil/format_envelope_test.go index c0634fc0..2886ed75 100644 --- a/cli/internal/cmdutil/format_envelope_test.go +++ b/cli/internal/cmdutil/format_envelope_test.go @@ -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"}} diff --git a/cli/internal/cmdutil/profilename.go b/cli/internal/cmdutil/profilename.go new file mode 100644 index 00000000..4cc21259 --- /dev/null +++ b/cli/internal/cmdutil/profilename.go @@ -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 ` 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 +} diff --git a/cli/internal/cmdutil/profilename_test.go b/cli/internal/cmdutil/profilename_test.go new file mode 100644 index 00000000..eb702890 --- /dev/null +++ b/cli/internal/cmdutil/profilename_test.go @@ -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:" where key is "access", +// Namespace convention: "weknora::" 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:/// or -// keychain://weknora//) describing where a saved secret lives. +// Ref returns a stable URI (e.g. file:/// or +// keychain://weknora//) 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/. +// FileStore writes 0600 plain-text files under $XDG_CONFIG_HOME/weknora/secrets/. // 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:/...").