diff --git a/CLAUDE.md b/CLAUDE.md index 928454d0..20a78d89 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -961,6 +961,12 @@ kbagent kai ask --message "question" [--project NAME] kbagent kai chat --message "msg" [--chat-id ID] [--project NAME] kbagent kai chat-detail --chat-id ID [--project NAME] kbagent kai history [--project NAME] [--limit N] +# Default-project resolution (since vNEXT, #684): the `kai` group, `docs query`, `component detail`, +# `component list --query`, `config examples`, and `config new` (scaffold mode) resolve an omitted +# --project through the shared cascade (--project > KBAGENT_PROJECT env > `project use` pin > sole +# project). Before vNEXT these paths used the FIRST REGISTERED project and ignored the pin. +# Several projects with no pin now exit 5 (CONFIG_ERROR) instead of silently picking the first. +# Same fix over `kbagent serve`: /kai/*, POST /documentation/query, GET /components?query=... kbagent transformation create --project NAME --name NAME (--sql 'SELECT ...' | --sql-file PATH) [--created-table NAME ...] [--component-id ID] [--description D] [--branch ID] [--dry-run] kbagent transformation show --project NAME --config-id ID [--component-id ID] [--branch ID] diff --git a/plugins/kbagent/agents/keboola-expert.md b/plugins/kbagent/agents/keboola-expert.md index fd9380bc..232ae0ac 100644 --- a/plugins/kbagent/agents/keboola-expert.md +++ b/plugins/kbagent/agents/keboola-expert.md @@ -188,6 +188,15 @@ its absence is NOT a promise the entry is version-independent (see ยง1 Rule 6). because the component ships none. Reach for `config examples` or an existing config in the project instead. +**A command without `--project` refused with CONFIG_ERROR (or picked a project)** +- On **vNEXT+** the `kai` group, `docs query`, `component detail`, `component + list --query`, `config examples`, and `config new` (scaffold mode) resolve an + omitted `--project` through the pin cascade: `--project` > `KBAGENT_PROJECT` + env > `project use` pin > sole project. Several projects and no pin -> exit 5 + (`CONFIG_ERROR`). That exit is the fix working, not a bug -- pass `--project` + or run `project use`. On <= 0.90.1 the same commands silently used the FIRST + registered project and ignored the pin (issue #684). gotchas.md. + **Reading job logs / table usage / narrow config search (0.88.0+)** - `job detail --log-tail-lines N` -- the ONLY route to an already-finished job's logs (`job run` tails only the run it started). `job list --offset/--sort-by/ diff --git a/plugins/kbagent/skills/kbagent/references/commands-reference.md b/plugins/kbagent/skills/kbagent/references/commands-reference.md index 71784fe2..0ad6ed64 100644 --- a/plugins/kbagent/skills/kbagent/references/commands-reference.md +++ b/plugins/kbagent/skills/kbagent/references/commands-reference.md @@ -117,9 +117,9 @@ Requires a **super-admin** Manage API token (same kind as `org setup`). Same def ## Component Discovery - `component sync-action ACTION_NAME --component-id ID --project ALIAS (--config-id ID [--row-id ID] | --config-data JSON|@file|-) [--branch ID] [--timeout N]` -- run a synchronous component action (`testConnection`, `getTables`, ...) on the `sync-actions.{stack}` service. `ACTION_NAME` is freeform (component-defined; discover via `component detail` `synchronous_actions`). `--row-id` shallow-merges the row over the root config at TOP level only (row `parameters`/`storage` replace root wholesale -- NOT deep merge; MCP `run_sync_action` parity). `--config-data` sends explicit `configData` verbatim. Response is action-specific pass-through. Ports the `run_sync_action` MCP tool. **Since 0.89.0 (#620)** the ROOT configuration's `authorization` and `runtime` blocks are forwarded into `configData` too -- root only (a `--row-id` never overrides them), and only when non-empty. `authorization.oauth_api.id` is the OAuth broker reference the sync-actions service resolves and decrypts, so on 0.88.0 and earlier every sync action on an OAuth / Service-Account component (`keboola.ex-linkedin-ads`, ...) failed with an opaque empty-body 400. -- `config examples --component-id ID [--project NAME] [--row]` -- sample root/row configurations from the AI-service component detail. `--json` emits `{component_id, root_examples, row_examples}`; `--row` limits to row examples. Ports the `get_config_examples` MCP tool. -- `component list [--project NAME] [--type TYPE] [--query "text"]` -- list/search components (AI-powered with `--query`) -- `component detail --component-id ID [--project NAME]` -- show component schema, docs URL, examples. **Since 0.90.0** a component the AI Service does not index (private/deprecated: `keboola.mcp-server-tool`, `keboola.data-apps`) no longer errors -- it falls back to the project's Storage component catalog. `documentation_source` (`"ai_service"` vs `"storage_catalog"`) is on BOTH paths and tells them apart; the fallback has NO configuration examples (`examples_count`/`row_examples_count` always 0), so read `documentation_source` before treating 0 as "this component ships none". `NOT_FOUND` is still raised when both sources miss. **Since 0.90.0** omitting `--project` really does use the first configured project -- on <= 0.89.x it failed with `CONFIG_ERROR: Project 'None' not found` despite the help text, so pass `--project` explicitly there. See `gotchas.md`. +- `config examples --component-id ID [--project NAME] [--row]` -- sample root/row configurations from the AI-service component detail. `--json` emits `{component_id, root_examples, row_examples}`; `--row` limits to row examples. Ports the `get_config_examples` MCP tool. **Since vNEXT (#684)** an omitted `--project` resolves the `project use` pin, not the first registered project. See `gotchas.md`. +- `component list [--project NAME] [--type TYPE] [--query "text"]` -- list/search components (AI-powered with `--query`). **Since vNEXT (#684)** the `--query` path resolves an omitted `--project` through the `project use` pin, not the first registered project. The no-`--query` path still fans out to all projects. See `gotchas.md`. +- `component detail --component-id ID [--project NAME]` -- show component schema, docs URL, examples. **Since 0.90.0** a component the AI Service does not index (private/deprecated: `keboola.mcp-server-tool`, `keboola.data-apps`) no longer errors -- it falls back to the project's Storage component catalog. `documentation_source` (`"ai_service"` vs `"storage_catalog"`) is on BOTH paths and tells them apart; the fallback has NO configuration examples (`examples_count`/`row_examples_count` always 0), so read `documentation_source` before treating 0 as "this component ships none". `NOT_FOUND` is still raised when both sources miss. **Since 0.90.0** omitting `--project` really does fall back to a default project -- on <= 0.89.x it failed with `CONFIG_ERROR: Project 'None' not found` despite the help text, so pass `--project` explicitly there. **Since vNEXT (#684)** that fallback is the `project use` pin (explicit > `KBAGENT_PROJECT` env > pin > sole project), not the first registered project. Several projects with no pin now fail with `CONFIG_ERROR` (exit 5). On 0.90.0-0.90.1 the fallback was the first registered project. See `gotchas.md`. ## Configuration Browsing - `config list [--project NAME] [--component-type TYPE] [--component-id ID] [--branch ID] [--include-rows]` -- list configs across projects (branch-aware). With `--include-rows` each row extends to include the full `configuration` and `rows` body (noticeably larger payload -- use only when the bodies are needed; the summary default covers name/description/component/last_modified/folder) @@ -131,7 +131,7 @@ Requires a **super-admin** Manage API token (same kind as `org setup`). Same def - `config delete --project NAME --component-id ID --config-id ID [--branch ID] [--dry-run]` -- SOFT-delete a configuration into the Storage trash (restorable). Since 0.89.0 it locates the config first and a config already in the trash is NOT deleted again -- the raw API purges permanently on a second DELETE (the retry-after-timeout trap); kbagent reports `already_in_trash` and exits 0 instead. `--dry-run` reports the located state without writing. Undo with `config restore`. Permission class `destructive` - `config restore --project NAME --component-id ID --config-id ID [--branch ID]` *(since v0.89.0)* -- restore a trashed configuration (versions, rows and metadata come back). Only works on a config currently in the trash. Permission class `write` - `config trash-list --project NAME [--component-id ID] [--branch ID]` *(since v0.89.0)* -- list configurations in the trash; each row carries `component_id`, `config_id`, `name`, `version` and `deleted_at`, which is exactly what `config restore` needs. Permission class `read` -- `config new --component-id ID [--project NAME] [--name NAME] [--output-dir DIR] [--push --no-files --description D --configuration JSON|@file|- --configuration-file PATH --no-validate --branch ID --dry-run --allow-plaintext-on-encrypt-failure]` -- **two modes**. **Default (no `--push`)**: scaffold new config from component schema; writes files to `--output-dir` or prints to stdout. **No Storage write calls** -- one AI Service read fetches the component schema/examples (that is what `--project` authenticates even without `--push`). **With `--push`** (0.33.0+, requires `--project` + non-empty `--name`): also POSTs to `/v2/storage/components/{cid}/configs` for a one-shot remote create. `#`-prefixed secrets in the pushed body auto-encrypt via the Encryption API first (fail-closed; since 0.54.0, #378; `--allow-plaintext-on-encrypt-failure` overrides). `--no-files` skips the filesystem step entirely (FIIA-style empty-shell pattern). `--configuration` / `--configuration-file` override the POSTed body (default is `{}`, with validation auto-skipped for the default empty shell). `--dry-run` previews the planned POST + validation result without creating. Since 0.89.0, `--push` + `--output-dir` writes the scaffold WITH `_keboola.config_id` into the subtree of the branch the config was created in (registering the branch in the manifest when missing), so the next `sync push` adopts it instead of duplicating (issue #644); with an explicit body the local file mirrors the pushed encrypted configuration. Schema validation runs by default when an explicit body is given (fail-closed: `ConfigError` exit 5 on mismatch) but skips silently if the AI Service has no schema for the component or returns an error; `--no-validate` opts out. Works for ALL component types including `keboola.snowflake-transformation`. **Since 0.90.0** the scaffold mode's optional `--project` really does fall back to the first configured project when omitted -- on <= 0.89.x it failed with `CONFIG_ERROR: Project 'None' not found` (see `gotchas.md`). +- `config new --component-id ID [--project NAME] [--name NAME] [--output-dir DIR] [--push --no-files --description D --configuration JSON|@file|- --configuration-file PATH --no-validate --branch ID --dry-run --allow-plaintext-on-encrypt-failure]` -- **two modes**. **Default (no `--push`)**: scaffold new config from component schema; writes files to `--output-dir` or prints to stdout. **No Storage write calls** -- one AI Service read fetches the component schema/examples (that is what `--project` authenticates even without `--push`). **With `--push`** (0.33.0+, requires `--project` + non-empty `--name`): also POSTs to `/v2/storage/components/{cid}/configs` for a one-shot remote create. `#`-prefixed secrets in the pushed body auto-encrypt via the Encryption API first (fail-closed; since 0.54.0, #378; `--allow-plaintext-on-encrypt-failure` overrides). `--no-files` skips the filesystem step entirely (FIIA-style empty-shell pattern). `--configuration` / `--configuration-file` override the POSTed body (default is `{}`, with validation auto-skipped for the default empty shell). `--dry-run` previews the planned POST + validation result without creating. Since 0.89.0, `--push` + `--output-dir` writes the scaffold WITH `_keboola.config_id` into the subtree of the branch the config was created in (registering the branch in the manifest when missing), so the next `sync push` adopts it instead of duplicating (issue #644); with an explicit body the local file mirrors the pushed encrypted configuration. Schema validation runs by default when an explicit body is given (fail-closed: `ConfigError` exit 5 on mismatch) but skips silently if the AI Service has no schema for the component or returns an error; `--no-validate` opts out. Works for ALL component types including `keboola.snowflake-transformation`. **Since 0.90.0** the scaffold mode's optional `--project` really does fall back to a default project when omitted -- on <= 0.89.x it failed with `CONFIG_ERROR: Project 'None' not found` (see `gotchas.md`). **Since vNEXT (#684)** that fallback is the `project use` pin, not the first registered project. - `config clone --project P --component-id ID --config-id ID --name NAME [--target-project P2] [--description D] [--set PATH=VALUE ...] [--secret PATH=VALUE ...] [--branch ID] [--target-branch ID] [--dry-run] [--allow-plaintext-on-encrypt-failure]` (0.84.2+, #587) -- duplicate a configuration **whole**. Reach for this instead of reading `config detail` and rebuilding a body: copying only `configuration["parameters"]` silently drops its siblings (`runtime`, `storage`, `authorization`), and a lost `runtime.parallelism` makes Keboola fall back to `parallelism: 1` -- the reporter's 65-row writer went sequential, 140 min instead of ~60-90, with nothing in any output pointing at it. **Same project** (default): server-side copy via `POST .../configs/{id}/versions/{v}/create`; rows and `KBC::` encrypted values travel with it (verified live). `--set PATH=VALUE` is applied as a follow-up update on the copy, so an override can never be the reason a key went missing. **Cross project** (`--target-project`): reassembled client-side and rows recreated one by one, because encrypted values **cannot** travel -- a Keboola ciphertext is scoped to the project it was encrypted in. Any `KBC::` value makes the clone **fail with exit 5**, listing every path, until re-supplied via `--secret PATH=VALUE` (encrypted in the TARGET project on write). `--dry-run` reports those paths instead of refusing -- run it first to learn what to gather. Storage bucket/table IDs are copied **verbatim, never remapped**; `sync clone` is the command that remaps. - `config variables-set --project NAME --component-id ID --config-id ID --var KEY=VALUE [--var ...] [--replace] [--variables-id ID] [--values-id ID] [--branch ID] [--dry-run] [--allow-plaintext-on-encrypt-failure] [--yes]` -- attach variable values to a config. Auto-creates a sibling `keboola.variables` config + default row on first use and links it via the parent's `runtime.variables_id` / `variables_values_id`. Defaults to merge; `--replace` drops keys not in `--var`. `#`-prefixed values encrypt via the Encryption API (fail-closed; exit non-zero on `ENCRYPTION_FAILED`). See `variables-workflow.md` - `config variables-get --project NAME --component-id ID --config-id ID [--branch ID]` -- resolve `variables_id` + `values_id` from the parent config and fetch the current KEY=VALUE map. Returns `{linked: bool, variables_id, values_id, values}`; `linked=false` means the parent has no variables attached @@ -279,10 +279,10 @@ Ports the `create_sql_transformation` / `update_sql_transformation` tools (#396) - `transformation edit --project NAME --config-id ID --change-description TEXT (--op JSON ... | --op-file ops.json) [--storage JSON|@file|-] [--component-id ID] [--branch ID] [--dry-run]` -- 9 ops (`add_block`, `remove_block`, `rename_block`, `add_code`, `remove_code`, `rename_code`, `set_code`, `add_script`, `str_replace`) applied sequentially against batch-start ids. `--storage` REPLACES `configuration.storage` wholesale (include ALL mappings you want to keep). Unknown ids error with the list of valid ids. ## Documentation Q&A -- `docs query "QUESTION" [--project NAME]` -- natural-language answer from the Keboola documentation via the AI Service (server-side RAG, no local corpus). Returns answer text + source URLs; `--json` emits `{query, text, source_urls}`. Unlike `kai ask` it does NOT see project data, works with any token (no master-token / feature-flag requirement), and is the right tool for "how do I ..." questions. Ports the `docs_query` MCP tool. +- `docs query "QUESTION" [--project NAME]` -- natural-language answer from the Keboola documentation via the AI Service (server-side RAG, no local corpus). Returns answer text + source URLs; `--json` emits `{query, text, source_urls}`. Unlike `kai ask` it does NOT see project data, works with any token (no master-token / feature-flag requirement), and is the right tool for "how do I ..." questions. Ports the `docs_query` MCP tool. **Since vNEXT (#684)** an omitted `--project` resolves the `project use` pin, not the first registered project. See `gotchas.md`. ## Kai (Keboola AI Assistant) -Requires the project to be added with its **master ('owner') Storage API token** -- the auto-generated owner token, not a custom one. Custom tokens cannot access Kai. Also requires the `agent-chat` feature flag on the project. Use `kai preflight` to verify both conditions without raising. +Requires the project to be added with its **master ('owner') Storage API token** -- the auto-generated owner token, not a custom one. Custom tokens cannot access Kai. Also requires the `agent-chat` feature flag on the project. Use `kai preflight` to verify both conditions without raising. **Since vNEXT (#684)** every `kai` subcommand resolves an omitted `--project` through the `project use` pin (explicit > `KBAGENT_PROJECT` env > pin > sole project). On 0.90.1 and older the fallback was the first registered project and the pin was ignored -- pass `--project` explicitly there. See `gotchas.md`. - `kai ping [--project NAME]` -- check Kai server health and MCP connection status. Fails with KAI_NOT_ENABLED if the agent-chat feature is missing or the token is not a master token - `kai preflight [--project NAME]` -- inspect token readiness WITHOUT raising. Returns `{ok, is_master_token, has_agent_chat_feature, token_description, error}`. Use this in UIs and automation pre-flight checks instead of `ping` - `kai ask --message "question" [--project NAME]` -- one-shot question to Kai, collects full response diff --git a/plugins/kbagent/skills/kbagent/references/gotchas.md b/plugins/kbagent/skills/kbagent/references/gotchas.md index 48891fa6..b23e102b 100644 --- a/plugins/kbagent/skills/kbagent/references/gotchas.md +++ b/plugins/kbagent/skills/kbagent/references/gotchas.md @@ -4559,6 +4559,8 @@ fallback (`config examples` already resolved it correctly). `component detail`'s `project_alias` reports the alias actually used (never `None`). With NO projects configured at all, the failure is an actionable `CONFIG_ERROR: No projects configured. Use 'kbagent project add' ...`. + Since vNEXT the fallback is the `project use` pin, not the first project -- + see the vNEXT gotcha on the `project use` pin (issue #684). - **<= 0.89.x**: pass `--project` explicitly to these two commands -- the help text's "first available" promise does not work there. - `component sync-action` is unaffected: its `--project` is genuinely required @@ -4811,3 +4813,28 @@ volatile components without waiting for an upstream kbagent release. and pushing DELETED the config in production. If you are stuck on an older version, do not delete-dir-then-push a `keboola.mcp-server-tool` (or any MCP-workspace) directory -- upgrade instead. + +## `kai` / `docs query` / `component` / `config new` now honor the `project use` pin + +*(since vNEXT, closes #684)* These commands take an optional `--project`: the whole `kai` group, +`docs query`, `component detail`, `component list --query`, `config examples`, +and `config new` (scaffold mode). Before vNEXT, they resolved an omitted +`--project` to the **first registered project**. They ignored the pin from +`kbagent project use`. With two or more projects registered, the command acted +on the wrong project and reported no warning. + +An omitted `--project` now resolves through the same cascade as every other +single-project command: + +1. explicit `--project` +2. `KBAGENT_PROJECT` env var +3. the `project use` pin +4. the sole registered project + +- **New failure mode**: several projects and no pin now fail with + `CONFIG_ERROR` (exit 5). The message names the three fixes. Before, the + command silently used the first registered project. +- **On 0.90.1 and older**: pass `--project` explicitly whenever the pinned + project is not the first row of `project list`. +- The fix also covers `kbagent serve`: the `/kai/*` routes, `POST /documentation/query`, + and `GET /components?query=...` resolved the project the same wrong way. diff --git a/scripts/check_version_gates.py b/scripts/check_version_gates.py index df80f0fa..8501d4e8 100644 --- a/scripts/check_version_gates.py +++ b/scripts/check_version_gates.py @@ -161,6 +161,17 @@ def find_vnext_residue(paths: list[Path]) -> list[VnextResidue]: # which has no anchor slug to break. HEADING_RE = re.compile(r"^ {0,3}#{1,6} ") +# A code-fence delimiter: 3+ backticks or 3+ tildes, at most 3 spaces indented +# (CommonMark). The heading check needs it because a ``#`` line INSIDE a fence +# is content, not a heading -- it renders no anchor slug, so the slug-breakage +# rationale does not apply there. CLAUDE.md's ``## All CLI Commands`` section +# is one giant fence full of ``#`` comment lines, and feature PRs are REQUIRED +# to tag new notes there with ``vNEXT`` (CONTRIBUTING.md, coding convention +# #17) -- so flagging them would fail every PR that follows the process. This +# is the same argument that already exempts ``#`` comments in ``.py`` files. +# The residue scan is untouched: a fenced gate is still a live gate. +FENCE_RE = re.compile(r"^ {0,3}(`{3,}|~{3,})") + def find_heading_placeholders(paths: list[Path]) -> list[VnextResidue]: """Return every markdown heading carrying a live ``vNEXT`` placeholder. @@ -176,6 +187,11 @@ def find_heading_placeholders(paths: list[Path]) -> list[VnextResidue]: Numeric versions in headings are deliberately NOT flagged: an already resolved tag never changes again, so its slug is stable, and flagging the dozen historical ones would be noise with no inbound link at risk. + + Lines inside a code fence are skipped -- they render as content, never as + headings, so no anchor slug is at risk (see ``FENCE_RE``). A closing fence + must repeat the opening character (CommonMark): a ``~~~`` inside a backtick + fence is content and does not close it. """ flagged: list[VnextResidue] = [] for path in paths: @@ -185,9 +201,20 @@ def find_heading_placeholders(paths: list[Path]) -> list[VnextResidue]: rel = path.relative_to(REPO_ROOT).as_posix() except ValueError: rel = path.as_posix() + fence_open: str | None = None for lineno, line in enumerate( path.read_text(encoding="utf-8", errors="replace").splitlines(), start=1 ): + fence = FENCE_RE.match(line) + if fence is not None: + marker = fence.group(1) + if fence_open is None: + fence_open = marker + elif marker[0] == fence_open[0] and len(marker) >= len(fence_open): + fence_open = None + continue + if fence_open is not None: + continue if VNEXT_TOKEN not in line or not HEADING_RE.match(line): continue # Same quotation rule as the residue scan: a heading that merely diff --git a/src/keboola_agent_cli/commands/component.py b/src/keboola_agent_cli/commands/component.py index 20582482..da4e54e5 100644 --- a/src/keboola_agent_cli/commands/component.py +++ b/src/keboola_agent_cli/commands/component.py @@ -206,7 +206,7 @@ def component_detail( project: str | None = typer.Option( None, "--project", - help="Project alias (uses first available if not set)", + help="Project alias (defaults to the pinned default project)", ), ) -> None: """Show detailed information about a specific component.""" diff --git a/src/keboola_agent_cli/commands/config.py b/src/keboola_agent_cli/commands/config.py index cd36e7a2..b6b4da8b 100644 --- a/src/keboola_agent_cli/commands/config.py +++ b/src/keboola_agent_cli/commands/config.py @@ -415,7 +415,7 @@ def config_examples( project: str | None = typer.Option( None, "--project", - help="Project alias (uses first available if not set)", + help="Project alias (defaults to the pinned default project)", ), row: bool = typer.Option( False, diff --git a/src/keboola_agent_cli/commands/docs.py b/src/keboola_agent_cli/commands/docs.py index 18a6a675..32c48967 100644 --- a/src/keboola_agent_cli/commands/docs.py +++ b/src/keboola_agent_cli/commands/docs.py @@ -57,7 +57,7 @@ def docs_query( project: str | None = typer.Option( None, "--project", - help="Project alias (uses first available if not set)", + help="Project alias (defaults to the pinned default project)", ), ) -> None: """Ask the Keboola documentation a natural language question.""" diff --git a/src/keboola_agent_cli/server/routers/docs.py b/src/keboola_agent_cli/server/routers/docs.py index 838cf1c4..b864ceb2 100644 --- a/src/keboola_agent_cli/server/routers/docs.py +++ b/src/keboola_agent_cli/server/routers/docs.py @@ -28,7 +28,9 @@ def query(body: DocsQuery, registry: ServiceRegistry = Depends(get_registry)) -> """Natural-language question answered from the official Keboola docs. Mirrors `kbagent docs query`. ``project`` selects which project's stack - URL + token reach the AI Service; omitted means the first configured - project (the answer itself is project-independent). + URL + token reach the AI Service. Omitted resolves through the + default-project cascade (``--project`` > ``KBAGENT_PROJECT`` env > + ``project use`` pin > sole project); the answer itself is + project-independent. """ return registry.docs.ask_docs(alias=body.project, query=body.query) diff --git a/src/keboola_agent_cli/services/base.py b/src/keboola_agent_cli/services/base.py index b8e4e5af..eb70c1a6 100644 --- a/src/keboola_agent_cli/services/base.py +++ b/src/keboola_agent_cli/services/base.py @@ -14,7 +14,11 @@ from ..auth.sentinel import is_session_token, parse_session_project_id, require_static_token from ..client import KeboolaClient from ..config_store import ConfigError, ConfigStore, project_not_found_error -from ..constants import ENV_MAX_PARALLEL_WORKERS, UNEXPECTED_ERROR_MAX_MESSAGE_LEN +from ..constants import ( + ENV_KBAGENT_PROJECT, + ENV_MAX_PARALLEL_WORKERS, + UNEXPECTED_ERROR_MAX_MESSAGE_LEN, +) from ..errors import ErrorCode from ..models import ProjectConfig @@ -217,6 +221,72 @@ def resolve_projects(self, aliases: list[str] | None = None) -> dict[str, Projec return resolved + def resolve_pinned_alias(self, explicit: str | None = None) -> tuple[str, str]: + """Resolve the effective project alias for a single-project operation. + + Precedence (first match wins): + 1. ``explicit`` argument (typically the CLI ``--project`` flag) + 2. ``KBAGENT_PROJECT`` env var + 3. Persisted ``default_project`` pin + 4. If exactly one project is registered, fall back to it (source=sole) + 5. Fail hard with ConfigError + + This is the single-project analog of ``resolve_projects()`` (which + fans out to all projects). Every service that needs one implicit + project must go through this cascade -- never a "first registered + project" shortcut, which ignores the ``project use`` pin (issue #684). + + Args: + explicit: Explicit alias from a CLI flag, or None. + + Returns: + Tuple of (alias, source). + + Raises: + ConfigError: If the resolved alias is not registered, or if none + can be resolved. + """ + config = self._config_store.load() + + if explicit: + if explicit not in config.projects: + raise project_not_found_error( + explicit, self._config_store.config_path, self._config_store.source + ) + return explicit, "explicit" + + env_value = os.environ.get(ENV_KBAGENT_PROJECT) + if env_value: + if env_value not in config.projects: + raise ConfigError( + f"{ENV_KBAGENT_PROJECT}='{env_value}' points to a project " + "that is not registered. Use 'kbagent project add' or " + "unset the env var." + ) + return env_value, "env" + + pinned = config.default_project + if pinned: + if pinned not in config.projects: + raise ConfigError( + f"Pinned default project '{pinned}' is not registered. " + "Run 'kbagent project use ' to repair." + ) + return pinned, "pin" + + if len(config.projects) == 1: + (sole,) = config.projects.keys() + return sole, "sole" + + if not config.projects: + raise ConfigError("No projects configured. Run 'kbagent project add' first.") + + raise ConfigError( + "Multiple projects configured and no default pinned. " + "Pass --project , set KBAGENT_PROJECT, or run " + "'kbagent project use '." + ) + def _resolve_max_workers(self) -> int: """Resolve max parallel workers: env var > config.json > default (10). diff --git a/src/keboola_agent_cli/services/component_service.py b/src/keboola_agent_cli/services/component_service.py index c7387c85..5baaabc2 100644 --- a/src/keboola_agent_cli/services/component_service.py +++ b/src/keboola_agent_cli/services/component_service.py @@ -32,9 +32,9 @@ class ResolvedProject: """A project resolved together with the alias it was resolved under. - ``alias`` is the caller's alias when one was given, otherwise the first - configured project's -- the value response payloads report as - ``project_alias``. A dataclass rather than a bare 2-tuple per + ``alias`` is the caller's alias when one was given, otherwise the one + the default-project cascade resolved -- the value response payloads + report as ``project_alias``. A dataclass rather than a bare 2-tuple per CONTRIBUTING.md's multi-value-return rule. """ @@ -518,7 +518,7 @@ def list_components( Two modes of operation: - With ``query``: Uses AI Service to suggest components matching a natural language description. Enriches each suggestion with detail - from get_component_detail(). Runs against first/default project. + from get_component_detail(). Runs against the default project. - Without ``query``: Uses Storage API list_components() across all resolved projects in parallel, returning unique components. @@ -537,26 +537,22 @@ def list_components( return self._list_via_ai(aliases, component_type, query) return self._list_via_storage(aliases, component_type) - def _resolve_alias_or_first(self, alias: str | None) -> ResolvedProject: - """Resolve *alias* to a project, defaulting to the first configured one. + def _resolve_alias_or_default(self, alias: str | None) -> ResolvedProject: + """Resolve *alias* to a project, defaulting via the shared cascade. - :meth:`BaseService.resolve_projects` falls back to "all projects" only - when the alias LIST itself is empty/None -- a ``[None]`` element goes - down its strict lookup and raises "Project 'None' not found". Commands - that document ``--project`` as optional (``component detail``, - ``config examples``, ``config new`` without ``--push``) must therefore - normalise an omitted alias here instead of passing it through. + Commands that document ``--project`` as optional (``component + detail``, ``config examples``, ``config new`` without ``--push``) + normalise an omitted alias here. The fallback goes through + :meth:`BaseService.resolve_pinned_alias` (KBAGENT_PROJECT env > + ``project use`` pin > sole project), never "first registered + project" -- that shortcut ignored the pin (issue #684). Raises: - ConfigError: If *alias* is given but unknown, or when no projects - are configured at all. + ConfigError: If *alias* is given but unknown, or when no default + project can be resolved. """ - projects = self.resolve_projects([alias] if alias else None) - if not projects: - raise ConfigError( - "No projects configured. Use 'kbagent project add' to connect a project first." - ) - resolved_alias = alias or next(iter(projects)) + resolved_alias, _source = self.resolve_pinned_alias(explicit=alias) + projects = self.resolve_projects([resolved_alias]) return ResolvedProject(alias=resolved_alias, project=projects[resolved_alias]) def get_component_detail(self, alias: str | None, component_id: str) -> dict[str, Any]: @@ -584,7 +580,7 @@ def get_component_detail(self, alias: str | None, component_id: str) -> dict[str Args: alias: Project alias (used to derive stack URL and token). When - None, the first available project is used. + None, the default-project cascade resolves it. component_id: The component identifier (e.g. 'keboola.ex-aws-s3'). Returns: @@ -598,7 +594,7 @@ def get_component_detail(self, alias: str | None, component_id: str) -> dict[str re-raised unchanged only when the Storage catalog does not know the component either (i.e. the id really is wrong). """ - resolved = self._resolve_alias_or_first(alias) + resolved = self._resolve_alias_or_default(alias) project = resolved.project ai_client = self._ai_client_factory(project.stack_url, project.token) @@ -710,8 +706,8 @@ def get_config_examples(self, alias: str | None, component_id: str) -> dict[str, contract is a summary and stays unchanged). Args: - alias: Project alias. When None, the first available project is - used (only the stack URL and token are needed). + alias: Project alias. When None, the default-project cascade + resolves it (only the stack URL and token are needed). component_id: The component identifier (e.g. 'keboola.ex-google-drive'). Returns: @@ -722,7 +718,7 @@ def get_config_examples(self, alias: str | None, component_id: str) -> dict[str, ConfigError: If the alias is not found or no projects are configured. KeboolaApiError: If the AI Service call fails. """ - project = self._resolve_alias_or_first(alias).project + project = self._resolve_alias_or_default(alias).project ai_client = self._ai_client_factory(project.stack_url, project.token) try: @@ -856,7 +852,7 @@ def generate_scaffold( Args: alias: Project alias (used to derive stack URL and token). When - None, the first available project is used (`config new` + None, the default-project cascade resolves it (`config new` without --push documents --project as optional). component_id: The component identifier. name: Configuration name. If None, defaults to @@ -870,7 +866,7 @@ def generate_scaffold( configured. KeboolaApiError: If the AI Service call fails. """ - project = self._resolve_alias_or_first(alias).project + project = self._resolve_alias_or_default(alias).project ai_client = self._ai_client_factory(project.stack_url, project.token) try: @@ -910,13 +906,18 @@ def _list_via_ai( ) -> dict[str, Any]: """Search components via AI Service suggestions. - Uses first/default project for AI queries, then enriches each - suggestion with component detail. + Runs the AI query against one project's stack: the first explicitly + requested alias, or the default-project cascade when none was given + (never "first registered" -- issue #684). Enriches each suggestion + with component detail. """ - projects = self.resolve_projects(aliases) - # Use first project for AI queries - first_alias = next(iter(projects)) - project = projects[first_alias] + if aliases: + projects = self.resolve_projects(aliases) + query_alias = next(iter(projects)) + project = projects[query_alias] + else: + resolved = self._resolve_alias_or_default(None) + query_alias, project = resolved.alias, resolved.project ai_client = self._ai_client_factory(project.stack_url, project.token) components: list[dict[str, Any]] = [] @@ -974,7 +975,7 @@ def _list_via_ai( except KeboolaApiError as exc: errors.append( { - "project_alias": first_alias, + "project_alias": query_alias, "error_code": exc.error_code, "message": exc.message, } @@ -982,7 +983,7 @@ def _list_via_ai( except Exception as exc: errors.append( { - "project_alias": first_alias, + "project_alias": query_alias, "error_code": "UNEXPECTED_ERROR", "message": str(exc), } diff --git a/src/keboola_agent_cli/services/docs_service.py b/src/keboola_agent_cli/services/docs_service.py index f5b127ce..5a8c2483 100644 --- a/src/keboola_agent_cli/services/docs_service.py +++ b/src/keboola_agent_cli/services/docs_service.py @@ -10,7 +10,6 @@ from typing import Any from ..config_store import ConfigStore -from ..errors import ConfigError from ..models import DocsAnswer from .base import BaseService, ClientFactory from .component_service import AiClientFactory, default_ai_client_factory @@ -39,7 +38,8 @@ def ask_docs(self, alias: str | None, query: str) -> dict[str, Any]: Args: alias: Project alias used to derive the stack URL and token. - None means the first configured project. + None resolves via the shared default-project cascade + (KBAGENT_PROJECT env > ``project use`` pin > sole project). query: Natural language question about the Keboola platform. Returns: @@ -50,14 +50,12 @@ def ask_docs(self, alias: str | None, query: str) -> dict[str, Any]: is grounded in Raises: - ConfigError: If no projects are configured or the alias is unknown. + ConfigError: If the alias is unknown, or no default project can + be resolved. KeboolaApiError: If the AI Service call fails. """ - projects = self.resolve_projects([alias] if alias else None) - if not projects: - raise ConfigError("No projects configured. Run 'kbagent project add' first.") - first_alias = next(iter(projects)) - project = projects[first_alias] + resolved_alias, _source = self.resolve_pinned_alias(explicit=alias) + project = self.resolve_projects([resolved_alias])[resolved_alias] ai_client = self._ai_client_factory(project.stack_url, project.token) try: diff --git a/src/keboola_agent_cli/services/kai_service.py b/src/keboola_agent_cli/services/kai_service.py index 567d4bee..66e63360 100644 --- a/src/keboola_agent_cli/services/kai_service.py +++ b/src/keboola_agent_cli/services/kai_service.py @@ -12,7 +12,7 @@ from ..auth.sentinel import require_static_token from ..constants import KAI_FEATURE_FLAG, KAI_REQUEST_TIMEOUT, KAI_STREAM_TIMEOUT -from ..errors import ConfigError, ErrorCode, KeboolaApiError +from ..errors import ErrorCode, KeboolaApiError from .base import BaseService logger = logging.getLogger(__name__) @@ -32,6 +32,10 @@ class KaiService(BaseService): def resolve_alias(self, alias: str | None) -> str: """Resolve a project alias, falling back to the default project. + Delegates to the shared cascade (explicit > KBAGENT_PROJECT env > + ``project use`` pin > sole project), so an omitted alias honors the + pinned default instead of the first registered project (issue #684). + Args: alias: Explicit alias, or None for default. @@ -39,17 +43,10 @@ def resolve_alias(self, alias: str | None) -> str: Resolved alias string. Raises: - ConfigError: If no projects configured or alias not found. + ConfigError: If the alias is unknown, or none can be resolved. """ - if alias: - # Validate it exists - self.resolve_projects([alias]) - return alias - # Fall back to default (first project) - projects = self.resolve_projects() - if not projects: - raise ConfigError("No projects configured. Run 'kbagent project add' first.") - return next(iter(projects)) + resolved, _source = self.resolve_pinned_alias(explicit=alias) + return resolved # ------------------------------------------------------------------ # Feature detection diff --git a/src/keboola_agent_cli/services/project_service.py b/src/keboola_agent_cli/services/project_service.py index c57638a5..1939979b 100644 --- a/src/keboola_agent_cli/services/project_service.py +++ b/src/keboola_agent_cli/services/project_service.py @@ -980,68 +980,3 @@ def get_info(self, alias: str) -> dict[str, Any]: "is_master_token": raw.get("isMasterToken", False), "token_expires": raw.get("expires"), } - - def resolve_pinned_alias(self, explicit: str | None = None) -> tuple[str, str]: - """Resolve the effective project alias for a single-project operation. - - Precedence (first match wins): - 1. ``explicit`` argument (typically the CLI ``--project`` flag) - 2. ``KBAGENT_PROJECT`` env var - 3. Persisted ``default_project`` pin - 4. If exactly one project is registered, fall back to it (source=sole) - 5. Fail hard with ConfigError - - This is the single-project analog of ``resolve_projects()`` (which - fans out to all projects). Use this from write/destructive command - paths where fan-out would be surprising or unsafe. - - Args: - explicit: Explicit alias from a CLI flag, or None. - - Returns: - Tuple of (alias, source). - - Raises: - ConfigError: If the resolved alias is not registered, or if none - can be resolved. - """ - config = self._config_store.load() - - if explicit: - if explicit not in config.projects: - raise project_not_found_error( - explicit, self._config_store.config_path, self._config_store.source - ) - return explicit, "explicit" - - env_value = os.environ.get(ENV_KBAGENT_PROJECT) - if env_value: - if env_value not in config.projects: - raise ConfigError( - f"{ENV_KBAGENT_PROJECT}='{env_value}' points to a project " - "that is not registered. Use 'kbagent project add' or " - "unset the env var." - ) - return env_value, "env" - - pinned = config.default_project - if pinned: - if pinned not in config.projects: - raise ConfigError( - f"Pinned default project '{pinned}' is not registered. " - "Run 'kbagent project use ' to repair." - ) - return pinned, "pin" - - if len(config.projects) == 1: - (sole,) = config.projects.keys() - return sole, "sole" - - if not config.projects: - raise ConfigError("No projects configured. Run 'kbagent project add' first.") - - raise ConfigError( - "Multiple projects configured and no default pinned. " - "Pass --project , set KBAGENT_PROJECT, or run " - "'kbagent project use '." - ) diff --git a/tests/test_check_version_gates.py b/tests/test_check_version_gates.py index 412601e4..342d5c25 100644 --- a/tests/test_check_version_gates.py +++ b/tests/test_check_version_gates.py @@ -414,6 +414,42 @@ def test_hash_without_a_space_is_not_a_heading(self, tmp_path: Path) -> None: f = _write(tmp_path, "g.md", "#vNEXT (since vNEXT)\n") assert headings([f]) == [] + def test_fenced_comment_line_is_not_a_heading(self, tmp_path: Path) -> None: + """The CLAUDE.md shape: a ``#`` gate comment inside the command fence. + + A fenced ``#`` line renders as content and has no anchor slug, so the + slug-breakage rationale does not apply -- and feature PRs are REQUIRED + to write exactly this shape (coding convention #17). The residue scan + must still see it: a fenced gate is a live gate. + """ + f = _write( + tmp_path, + "commands.md", + "```\n# component detail (since vNEXT, #684): resolves the pin\n```\n", + ) + assert headings([f]) == [] + assert len(residue([f])) == 1 + + def test_heading_after_a_closed_fence_is_flagged(self, tmp_path: Path) -> None: + """Closing the fence re-arms the check -- the toggle must not stick.""" + f = _write( + tmp_path, + "g.md", + "```\n# fenced (since vNEXT)\n```\n\n## Real heading (since vNEXT)\n", + ) + found = headings([f]) + assert len(found) == 1 + assert found[0].line == 5 + + def test_tilde_line_does_not_close_a_backtick_fence(self, tmp_path: Path) -> None: + """CommonMark: a closing fence must repeat the opening character.""" + f = _write( + tmp_path, + "g.md", + "```\n~~~\n# still fenced (since vNEXT)\n```\n", + ) + assert headings([f]) == [] + def test_live_repository_has_no_placeholder_headings(self) -> None: """The real tree must stay clean -- this is the check's whole point.""" assert headings(check_version_gates.resolve_paths()) == [] diff --git a/tests/test_component_service.py b/tests/test_component_service.py index e955f5d9..60ea3060 100644 --- a/tests/test_component_service.py +++ b/tests/test_component_service.py @@ -7,7 +7,7 @@ import pytest import yaml -from helpers import setup_single_project +from helpers import setup_single_project, setup_two_projects from keboola_agent_cli.config_store import ConfigStore from keboola_agent_cli.constants import SECRET_PLACEHOLDER from keboola_agent_cli.errors import ConfigError, ErrorCode, KeboolaApiError @@ -334,6 +334,33 @@ def test_list_components_via_ai_query(self, tmp_config_dir: Path) -> None: mock_ai.suggest_components.assert_called_once_with("download data from http") mock_ai.close.assert_called_once() + def test_list_via_ai_no_alias_honors_pin(self, tmp_config_dir: Path, monkeypatch) -> None: + """A query without aliases resolves the pin, not the first project. + + Regression test for issue #684: `component list --query` without + --project ran the AI query against the first registered project. + """ + monkeypatch.delenv("KBAGENT_PROJECT", raising=False) + store = setup_two_projects(tmp_config_dir) + # add_project() pinned "prod" (first added); repoint like `project use dev`. + cfg = store.load() + cfg.default_project = "dev" + store.save(cfg) + dev_token = store.load().projects["dev"].token + + mock_ai = _make_ai_client(suggest_response=[]) + seen_tokens: list[str] = [] + + def factory(url: str, token: str) -> MagicMock: + seen_tokens.append(token) + return mock_ai + + service = ComponentService(config_store=store, ai_client_factory=factory) + service.list_components(query="anything") + + assert seen_tokens == [dev_token] + mock_ai.close.assert_called_once() + # =========================================================================== # get_component_detail @@ -343,6 +370,35 @@ def test_list_components_via_ai_query(self, tmp_config_dir: Path) -> None: class TestGetComponentDetail: """Tests for get_component_detail.""" + def test_get_component_detail_no_alias_honors_pin( + self, tmp_config_dir: Path, monkeypatch + ) -> None: + """alias=None resolves the pin, not the first project. + + Regression test for issue #684: `component detail` without --project + used the first registered project. + """ + monkeypatch.delenv("KBAGENT_PROJECT", raising=False) + store = setup_two_projects(tmp_config_dir) + # add_project() pinned "prod" (first added); repoint like `project use dev`. + cfg = store.load() + cfg.default_project = "dev" + store.save(cfg) + dev_token = store.load().projects["dev"].token + + mock_ai = _make_ai_client(detail_response=EXTRACTOR_RESPONSE) + seen_tokens: list[str] = [] + + def factory(url: str, token: str) -> MagicMock: + seen_tokens.append(token) + return mock_ai + + service = ComponentService(config_store=store, ai_client_factory=factory) + service.get_component_detail(alias=None, component_id="keboola.ex-http") + + assert seen_tokens == [dev_token] + mock_ai.close.assert_called_once() + def test_get_component_detail_success(self, tmp_config_dir: Path) -> None: """Returns parsed detail with schema_summary for a valid component.""" mock_ai = _make_ai_client(detail_response=EXTRACTOR_RESPONSE) diff --git a/tests/test_docs_cli.py b/tests/test_docs_cli.py index bfffb0fc..91a20bf0 100644 --- a/tests/test_docs_cli.py +++ b/tests/test_docs_cli.py @@ -214,16 +214,26 @@ def test_ask_docs_explicit_alias(self, tmp_path: Path) -> None: "source_urls": ["https://help.keboola.com/x"], } - def test_ask_docs_default_alias_uses_first_project(self, tmp_path: Path) -> None: - """alias=None falls back to the first configured project.""" + def test_ask_docs_default_alias_uses_pin(self, tmp_path: Path, monkeypatch) -> None: + """alias=None resolves the ``project use`` pin, not the first project. + + Regression test for issue #684: with two projects and the pin moved + to the second one, `docs query` without --project used the first + registered project. + """ + monkeypatch.delenv("KBAGENT_PROJECT", raising=False) store = self._make_store(tmp_path, ["prod", "dev"]) + # add_project() pinned "prod" (first added); repoint like `project use dev`. + cfg = store.load() + cfg.default_project = "dev" + store.save(cfg) raw = {"text": "Answer.", "sourceUrls": []} service, _mock_client, mock_factory = self._make_service(store, raw) result = service.ask_docs(alias=None, query="q") mock_factory.assert_called_once_with( - "https://connection.prod.keboola.com", + "https://connection.dev.keboola.com", TEST_TOKEN, ) assert result["source_urls"] == [] diff --git a/tests/test_kai_service.py b/tests/test_kai_service.py index c8f16b6f..9b98cc7b 100644 --- a/tests/test_kai_service.py +++ b/tests/test_kai_service.py @@ -10,7 +10,7 @@ import pytest -from helpers import setup_single_project +from helpers import setup_single_project, setup_two_projects from keboola_agent_cli.errors import ConfigError, KeboolaApiError from keboola_agent_cli.models import TokenVerifyResponse from keboola_agent_cli.services.kai_service import KaiService @@ -524,10 +524,27 @@ def test_resolve_explicit_alias(self, tmp_config_dir: Path) -> None: assert service.resolve_alias("prod") == "prod" def test_resolve_default_alias(self, tmp_config_dir: Path) -> None: - """resolve_alias with None returns the first (default) project.""" + """resolve_alias with None returns the sole configured project.""" service, _ = _make_kai_service(tmp_config_dir) assert service.resolve_alias(None) == "prod" + def test_resolve_default_honors_pin(self, tmp_config_dir: Path, monkeypatch) -> None: + """resolve_alias(None) returns the pin, not the first project. + + Regression test for issue #684: with two projects and the pin moved + to the second one, `kai ping/preflight/ask/chat` without --project + acted on the first registered project. + """ + monkeypatch.delenv("KBAGENT_PROJECT", raising=False) + store = setup_two_projects(tmp_config_dir) + # add_project() pinned "prod" (first added); repoint like `project use dev`. + cfg = store.load() + cfg.default_project = "dev" + store.save(cfg) + + service = KaiService(config_store=store, client_factory=lambda url, token: MagicMock()) + assert service.resolve_alias(None) == "dev" + def test_resolve_unknown_alias(self, tmp_config_dir: Path) -> None: """resolve_alias raises ConfigError for unknown alias.""" service, _ = _make_kai_service(tmp_config_dir)