diff --git a/.serena/memories/project_overview.md b/.serena/memories/project_overview.md index 853d599..e4b67bd 100644 --- a/.serena/memories/project_overview.md +++ b/.serena/memories/project_overview.md @@ -1,9 +1,11 @@ # Project Overview ## Purpose + `ks-cli` (kscli) is a CLI tool for the Knowledge Stack platform. It wraps the auto-generated `ksapi` Python SDK with a Click-based command interface using a verb-first routing pattern (e.g. `kscli get folders`, `kscli describe document `). ## Tech Stack + - **Language**: Python 3.14+ - **Runtime/Package Manager**: uv - **CLI Framework**: Click @@ -16,6 +18,7 @@ - **Releases**: semantic-release with conventional commits ## Codebase Structure + ``` src/kscli/ ├── cli.py # Root CLI group, verb-first routing, command registration @@ -50,15 +53,19 @@ tests/ ## Key Architecture Patterns ### Verb-first CLI routing + Commands are organized as `kscli `. Verb groups (get, describe, create, update, delete, search, etc.) are defined in `cli.py`. Each resource module exposes `register_(group)` functions that add Click commands to those groups. ### Command implementation pattern + Every command follows this pattern: + 1. Get authenticated client: `api_client = get_api_client(ctx)` 2. Wrap in error handling: `with handle_client_errors():` 3. Instantiate SDK API: `api = ksapi.Api(api_client)` 4. Call SDK method and format: `print_result(ctx, to_dict(result), columns=COLUMNS)` ### Config layering + Environment variables → `~/.config/kscli/config.json` → defaults. Key env vars: `KSCLI_BASE_URL`, `ADMIN_API_KEY`, `KSCLI_FORMAT`, `KSCLI_VERIFY_SSL`, `KSCLI_CA_BUNDLE`, `KSCLI_CONFIG`, `KSCLI_CREDENTIALS_PATH`. diff --git a/.serena/memories/style_and_conventions.md b/.serena/memories/style_and_conventions.md index bf934bf..59d061f 100644 --- a/.serena/memories/style_and_conventions.md +++ b/.serena/memories/style_and_conventions.md @@ -1,6 +1,7 @@ # Style and Conventions ## Code Style + - **Line length**: 88 characters - **Quote style**: Double quotes - **Indent style**: Spaces @@ -9,19 +10,23 @@ - **Imports**: isort-managed, first-party packages: `shared_utils`, `database_schema`, `private_api_client` ## Ruff Rules + Extensive rule set enabled (E, W, F, I, UP, B, C4, DTZ, T10, G, PIE, PT, RET, SIM, TCH, ARG, PTH, ERA, PD, PGH, PL, RUF, D). Notable ignores: + - All missing docstring rules (D100-D107) are ignored - E501 (line length) ignored — handled by formatter - B008 (function call in default argument) ignored — common in Click/FastAPI - Tests allow assert, unused args (fixtures), magic values ## Naming Conventions + - Command modules: `src/kscli/commands/.py` - Test files: `tests/test_cli_.py` - Registration functions: `register_(group: click.Group)` - Column definitions: `COLUMNS = [...]` at module level ## Design Patterns + - **Verb-first routing**: Commands registered as ` `, not ` ` - **Registration pattern**: Each module exposes `register_()` functions, wired in `cli.py` - **Error handling**: Always use `with handle_client_errors():` context manager around API calls @@ -29,4 +34,5 @@ Extensive rule set enabled (E, W, F, I, UP, B, C4, DTZ, T10, G, PIE, PT, RET, SI - **Output**: Always pass through `to_dict()` → `print_result()` pipeline ## Commit Convention + Conventional commits (semantic-release): `feat:`, `fix:`, `perf:`, `chore:`, `ci:`, `docs:`, `style:`, `refactor:`, `test:` diff --git a/.serena/memories/suggested_commands.md b/.serena/memories/suggested_commands.md index 634eed5..8274187 100644 --- a/.serena/memories/suggested_commands.md +++ b/.serena/memories/suggested_commands.md @@ -1,12 +1,14 @@ # Suggested Commands ## Development Setup + ```bash uv sync --all-extras --group dev # Install all dependencies uv run pre-commit install # Install pre-commit hooks ``` ## Linting & Formatting + ```bash uv run ruff check # Lint uv run ruff check --fix # Lint + autofix @@ -14,11 +16,13 @@ uv run ruff format # Format code ``` ## Type Checking + ```bash uv run basedpyright --stats # Type check ``` ## Testing + ```bash uv run pytest # Run all tests uv run pytest tests/test_cli_folders.py # Single test file @@ -27,17 +31,20 @@ uv run pytest -x # Stop on first failure ``` ## Pre-commit (all checks) + ```bash make pre-commit # Runs lint + typecheck + test ``` ## Running the CLI + ```bash uv run kscli --help # Show CLI help uv run python -m kscli # Alternative entry point ``` ## System utilities (macOS/Darwin) + ```bash git status / git diff / git log # Git operations ls / find / grep # File operations (standard unix) diff --git a/.serena/memories/task_completion_checklist.md b/.serena/memories/task_completion_checklist.md index d63d13d..f38be85 100644 --- a/.serena/memories/task_completion_checklist.md +++ b/.serena/memories/task_completion_checklist.md @@ -10,6 +10,7 @@ After completing a coding task, run these checks: Or run all at once: `make pre-commit` ## When adding a new resource command + - Create `src/kscli/commands/.py` with `register_()` functions - Register the commands in `src/kscli/cli.py` on the appropriate verb groups - Add tests in `tests/test_cli_.py` using `cli_helpers.run_kscli_ok`/`run_kscli_fail` diff --git a/.serena/project.yml b/.serena/project.yml index ec2831e..6df4f5a 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -115,3 +115,14 @@ initial_prompt: "" # override of the corresponding setting in serena_config.yml, see the documentation there. # If null or missing, the value from the global config is used. symbol_info_budget: + +# The language backend to use for this project. +# If not set, the global setting from serena_config.yml is used. +# Valid values: LSP, JetBrains +# Note: the backend is fixed at startup. If a project with a different backend +# is activated post-init, an error will be returned. +language_backend: + +# list of regex patterns which, when matched, mark a memory entry as read‑only. +# Extends the list from the global configuration, merging the two lists. +read_only_memory_patterns: [] diff --git a/CLAUDE.md b/CLAUDE.md index 7163dfd..918c13f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,7 +38,7 @@ make pre-commit The CLI uses resource groups as top-level subcommands (e.g. `folders`, `documents`, `chunks`, `tags`). Each resource module defines a `@click.group()` with verb subcommands — e.g. `kscli folders list`, `kscli folders describe `, `kscli folders create`. The groups are registered in `cli.py` via `main.add_command(resource_group)`. -Top-level commands outside resource groups: `assume-user`, `whoami`, `settings`. +Top-level commands outside resource groups: `login`, `logout`, `whoami`, `settings`. Resource groups: `folders`, `documents`, `document-versions`, `sections`, `chunks`, `tags`, `workflows`, `tenants`, `users`, `permissions`, `invites`, `threads`, `thread-messages`, `chunk-lineages`, `path-parts`. @@ -59,11 +59,11 @@ Each resource (folders, documents, chunks, etc.) follows the same pattern: ### Auth (`src/kscli/auth.py`) -JWT-based auth via admin impersonation. `assume_user()` calls `/v1/auth/assume_user`, caches the token to a credentials file (default: `/tmp/kscli/.credentials`). `load_credentials()` auto-refreshes expired tokens. +API key auth via `kscli login --api-key `. `save_api_key()` stores the key to a credentials file (default: `/tmp/kscli/.credentials`). `load_credentials()` reads the stored API key. `kscli logout` removes credentials. ### Config (`src/kscli/config.py`) -Layered config resolution: environment variables → config file (`~/.config/kscli/config.json`) → defaults. Key env vars: `KSCLI_BASE_URL`, `ADMIN_API_KEY`, `KSCLI_FORMAT`, `KSCLI_VERIFY_SSL`, `KSCLI_CA_BUNDLE`, `KSCLI_CONFIG`, `KSCLI_CREDENTIALS_PATH`. +Layered config resolution: environment variables → config file (`~/.config/kscli/config.json`) → defaults. Key env vars: `KSCLI_BASE_URL`, `KSCLI_FORMAT`, `KSCLI_VERIFY_SSL`, `KSCLI_CA_BUNDLE`, `KSCLI_CONFIG`, `KSCLI_CREDENTIALS_PATH`. Environment presets: `local` (localhost:8000), `prod` (api.knowledgestack.ai) — set via `kscli settings environment `. diff --git a/Makefile b/Makefile index 7d432f6..69a0f1b 100644 --- a/Makefile +++ b/Makefile @@ -45,5 +45,24 @@ wait-for-api: ## Wait for the e2e API to be ready e2e-test: wait-for-api ## Run e2e tests (requires running backend) @uv run pytest tests/e2e/ -v -m e2e -n 2 +.PHONY: local-login +local-login: ## Login to local dev API (assumes dev-stack + dev-api running) + @API_URL=https://localhost:18000; \ + USER_ID=00000000-0000-0000-0001-000000000001; \ + TENANT_ID=00000000-0000-0000-0002-000000000001; \ + TOKEN=$$(curl -sk -X POST $$API_URL/system/auth/assume_user \ + -H "Authorization: Bearer dev-admin-api-key" \ + -H "Content-Type: application/json" \ + -d "{\"user_id\": \"$$USER_ID\", \"tenant_id\": \"$$TENANT_ID\"}" \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])") && \ + API_KEY=$$(curl -sk -X POST $$API_URL/v1/api-keys \ + -H "Cookie: ks_uat=$$TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"name": "cli-dev"}' \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['key'])") && \ + uv run kscli settings environment local --url $$API_URL && \ + uv run kscli login --api-key $$API_KEY && \ + uv run kscli whoami + .PHONY: pre-commit pre-commit: lint typecheck test ## Run linting, typechecking, and tests \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 0fe941e..043c801 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,9 +11,7 @@ urls = { Repository = "https://github.com/knowledgestack/ks-cli" } dependencies = [ "certifi>=2026.1.4", "click>=8.3.1", - "httpx>=0.28.1", "ksapi>=1.25.0", - "pyjwt>=2.11.0", "rich>=14.3.3", ] diff --git a/src/kscli/auth.py b/src/kscli/auth.py index ab907fe..93a9d45 100644 --- a/src/kscli/auth.py +++ b/src/kscli/auth.py @@ -1,85 +1,41 @@ -"""Credential caching and token management.""" +"""Credential caching for API key authentication.""" import json import os -from datetime import UTC, datetime from pathlib import Path -import certifi -import httpx -import jwt +from kscli.config import get_current_environment -from kscli.config import get_tls_config - -CREDENTIALS_PATH = Path( - os.environ.get("KSCLI_CREDENTIALS_PATH", "/tmp/kscli/.credentials") +_CREDENTIALS_DIR = Path( + os.environ.get("KSCLI_CREDENTIALS_PATH", "/tmp/kscli") ) -def assume_user( - base_url: str, admin_api_key: str, tenant_id: str, user_id: str -) -> dict[str, str]: - """Call assume_user endpoint and cache the resulting token.""" - verify_ssl, ca_bundle = get_tls_config() - verify = ca_bundle or certifi.where() if verify_ssl else False - - resp = httpx.post( - f"{base_url}/v1/auth/assume_user", - json={"tenant_id": tenant_id, "user_id": user_id}, - headers={"X-Admin-Api-Key": admin_api_key}, - timeout=30.0, - verify=verify, - ) - resp.raise_for_status() - token = resp.json()["token"] - save_credentials(token, user_id, tenant_id, admin_api_key) - return load_credentials() +def _credentials_path() -> Path: + """Resolve per-environment credentials file path.""" + env = get_current_environment() + return _CREDENTIALS_DIR / f".credentials_{env}" -def save_credentials( - token: str, user_id: str, tenant_id: str, admin_api_key: str -) -> None: - """Decode JWT exp claim and write credentials file.""" - payload = jwt.decode(token, options={"verify_signature": False}) - expires_at = datetime.fromtimestamp(payload["exp"], tz=UTC).isoformat() - CREDENTIALS_PATH.parent.mkdir(parents=True, exist_ok=True) - CREDENTIALS_PATH.write_text( - json.dumps( - { - "token": token, - "user_id": user_id, - "tenant_id": tenant_id, - "expires_at": expires_at, - "admin_api_key": admin_api_key, - } - ) - ) - CREDENTIALS_PATH.chmod(0o600) +def save_api_key(api_key: str) -> None: + """Store API key to per-environment credentials file with restricted permissions.""" + path = _credentials_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps({"api_key": api_key})) + path.chmod(0o600) def load_credentials() -> dict[str, str]: - """Load and validate credentials; re-assume if expired.""" - if not CREDENTIALS_PATH.exists(): + """Load credentials for the current environment.""" + path = _credentials_path() + if not path.exists(): + env = get_current_environment() raise SystemExit( - "Not authenticated. Run: kscli assume-user --tenant-id --user-id " - ) - creds = json.loads(CREDENTIALS_PATH.read_text()) - expires_at = datetime.fromisoformat(creds["expires_at"]) - if datetime.now(tz=UTC) >= expires_at: - from kscli.config import get_admin_api_key, get_base_url # noqa: PLC0415 - - # Prefer credential-cached admin key; fallback keeps compatibility - # with credentials created before this field existed. - admin_api_key = creds.get("admin_api_key") or get_admin_api_key() - return assume_user( - get_base_url(), - admin_api_key, - creds["tenant_id"], - creds["user_id"], + f"Not authenticated for '{env}' environment. Run: kscli login --api-key " ) - return creds + return json.loads(path.read_text()) def clear_credentials() -> None: - """Remove credentials file.""" - CREDENTIALS_PATH.unlink(missing_ok=True) + """Remove credentials file for the current environment.""" + _credentials_path().unlink(missing_ok=True) diff --git a/src/kscli/cli.py b/src/kscli/cli.py index 0061dc0..558a927 100644 --- a/src/kscli/cli.py +++ b/src/kscli/cli.py @@ -117,7 +117,8 @@ def main(ctx, format_, no_header, base_url): # noqa: ARG001 — params required # ── Top-level commands ────────────────────────────────────────────────────── -main.add_command(auth.assume_user) +main.add_command(auth.login) +main.add_command(auth.logout) main.add_command(auth.whoami) main.add_command(settings.settings) diff --git a/src/kscli/client.py b/src/kscli/client.py index 2930741..e18fade 100644 --- a/src/kscli/client.py +++ b/src/kscli/client.py @@ -1,6 +1,5 @@ """SDK client helpers for kscli.""" -import json import sys from contextlib import contextmanager from typing import Any, Protocol, runtime_checkable @@ -12,9 +11,10 @@ from kscli.auth import load_credentials from kscli.config import get_base_url, get_tls_config +from kscli.utils.error import format_api_error _STATUS_MESSAGES = { - 401: "Session expired. Run: kscli assume-user --tenant-id --user-id ", + 401: "Session expired. Run: kscli login --api-key ", 403: "Permission denied", 404: "Not found", 409: "Conflict", @@ -47,19 +47,20 @@ def get_api_client(ctx: click.Context) -> ksapi.ApiClient: if verify_ssl: config.ssl_ca_cert = ca_bundle or certifi.where() - return ksapi.ApiClient(config, cookie=f"ks_uat={creds['token']}") + client = ksapi.ApiClient(config) + client.default_headers["authorization"] = f"Bearer {creds['api_key']}" + return client + + +def get_current_identity(api_client: ksapi.ApiClient) -> ksapi.UserResponse: + """Fetch the current user's identity via /me. Returns the user dict.""" + return ksapi.UsersApi(api_client).get_me() def handle_api_error(e: ksapi.ApiException) -> None: """Map SDK ApiException to error messages and exit codes matching original behavior.""" status = e.status or 500 - detail = "" - if e.body: - try: - body = json.loads(e.body) - detail = body.get("detail", body) - except Exception: - detail = e.body + detail = format_api_error(e) prefix = _STATUS_MESSAGES.get(status, f"Server error: {status}") click.echo(f"Error: {prefix}: {detail}", err=True) sys.exit(_EXIT_CODES.get(status, 1)) @@ -96,14 +97,3 @@ def _handle_ssl_error() -> None: err=True, ) sys.exit(1) - - -def to_dict(obj: object) -> dict[str, Any] | list[Any]: - """Convert an SDK response model to a plain dict/list for print_result().""" - if obj is None: - return {} - if isinstance(obj, _SupportsToDict): - return obj.to_dict() - if isinstance(obj, (dict, list)): - return obj - return {} diff --git a/src/kscli/commands/auth.py b/src/kscli/commands/auth.py index 377b929..4fc32c3 100644 --- a/src/kscli/commands/auth.py +++ b/src/kscli/commands/auth.py @@ -1,41 +1,42 @@ -"""Authentication commands: assume-user, whoami.""" +"""Authentication commands: login, logout, whoami.""" import click import ksapi -from kscli.auth import ( - assume_user as do_assume_user, - load_credentials, -) -from kscli.client import get_api_client, handle_client_errors, to_dict -from kscli.config import get_admin_api_key, get_base_url +from kscli.auth import clear_credentials, save_api_key +from kscli.client import get_api_client, handle_client_errors +from kscli.config import get_current_environment from kscli.output import print_result -@click.command("assume-user") -@click.option("--tenant-id", required=True, type=click.UUID) -@click.option("--user-id", required=True, type=click.UUID) -@click.pass_context -def assume_user(ctx, tenant_id, user_id): - """Authenticate as a specific user via admin impersonation.""" - base_url = get_base_url(ctx.obj.get("base_url")) - admin_key = get_admin_api_key() - creds = do_assume_user(base_url, admin_key, str(tenant_id), str(user_id)) - click.echo(f"Authenticated as user {creds['user_id']} in tenant {creds['tenant_id']}") - click.echo(f"Token expires: {creds['expires_at']}") +@click.command("login") +@click.option( + "--api-key", + prompt="API key", + hide_input=True, + help="User-scoped API key (sk-user-...).", +) +def login(api_key: str) -> None: + """Authenticate with a user-scoped API key.""" + save_api_key(api_key) + env = get_current_environment() + click.echo(f"Logged in successfully ({env}).") + + +@click.command("logout") +def logout() -> None: + """Remove stored credentials for the current environment.""" + env = get_current_environment() + clear_credentials() + click.echo(f"Logged out ({env}).") @click.command("whoami") @click.pass_context -def whoami(ctx): +def whoami(ctx: click.Context) -> None: """Show current authenticated identity.""" api_client = get_api_client(ctx) with handle_client_errors(): api = ksapi.UsersApi(api_client) data = api.get_me() - creds = load_credentials() - user_data = to_dict(data) - if isinstance(user_data, dict): - user_data["tenant_id"] = creds.get("tenant_id", "-") - user_data["expires_at"] = creds.get("expires_at", "-") - print_result(ctx, user_data) + print_result(ctx, data.model_dump()) diff --git a/src/kscli/commands/chunk_lineages.py b/src/kscli/commands/chunk_lineages.py index 431ec57..9f34e4d 100644 --- a/src/kscli/commands/chunk_lineages.py +++ b/src/kscli/commands/chunk_lineages.py @@ -3,7 +3,7 @@ import click import ksapi -from kscli.client import get_api_client, handle_client_errors, to_dict +from kscli.client import get_api_client, handle_client_errors from kscli.output import print_result @@ -21,7 +21,7 @@ def describe_chunk_lineage(ctx, chunk_id): with handle_client_errors(): api = ksapi.ChunkLineagesApi(api_client) result = api.get_chunk_lineage(chunk_id) - print_result(ctx, to_dict(result)) + print_result(ctx, result.model_dump()) @chunk_lineages.command("create") @@ -39,7 +39,7 @@ def create_chunk_lineage(ctx, parent_chunk_id, child_chunk_id): parent_chunk_ids=[parent_chunk_id], ) ) - print_result(ctx, to_dict(result)) + print_result(ctx, [r.model_dump(mode="json") for r in result]) @chunk_lineages.command("delete") diff --git a/src/kscli/commands/chunks.py b/src/kscli/commands/chunks.py index 36cfc12..a1bf7c6 100644 --- a/src/kscli/commands/chunks.py +++ b/src/kscli/commands/chunks.py @@ -5,9 +5,23 @@ import click import ksapi -from kscli.client import get_api_client, handle_client_errors, to_dict +from kscli.client import get_api_client, handle_client_errors from kscli.output import print_result +_SEARCH_FILTER_KEYS = { + "model", + "parent_path_ids", + "chunk_type", + "updated_at", + "score_threshold", + "search_type", + "tag_ids", + "chunk_types", + "ingestion_time_after", + "active_version_only", + "top_k", +} + @click.group("chunks") def chunks(): @@ -23,7 +37,7 @@ def describe_chunk(ctx, chunk_id): with handle_client_errors(): api = ksapi.ChunksApi(api_client) result = api.get_chunk(chunk_id) - print_result(ctx, to_dict(result)) + print_result(ctx, result.model_dump(mode="json")) @chunks.command("create") @@ -40,9 +54,7 @@ def describe_chunk(ctx, chunk_id): def create_chunk(ctx, content, version_id, section_id, chunk_type, meta): """Create a chunk.""" if version_id is not None and section_id is not None: - raise click.UsageError( - "Provide only one of --version-id or --section-id" - ) + raise click.UsageError("Provide only one of --version-id or --section-id") parent_path_id = version_id or section_id if parent_path_id is None: raise click.UsageError("Provide either --version-id or --section-id") @@ -50,9 +62,10 @@ def create_chunk(ctx, content, version_id, section_id, chunk_type, meta): with handle_client_errors(): api = ksapi.ChunksApi(api_client) metadata = json.loads(meta) if meta else None - chunk_metadata = ksapi.ChunkMetadataInput.from_dict( - metadata or {} - ) or ksapi.ChunkMetadataInput() + chunk_metadata = ( + ksapi.ChunkMetadataInput.from_dict(metadata or {}) + or ksapi.ChunkMetadataInput() + ) result = api.create_chunk( ksapi.CreateChunkRequest( parent_path_id=parent_path_id, @@ -61,7 +74,7 @@ def create_chunk(ctx, content, version_id, section_id, chunk_type, meta): chunk_metadata=chunk_metadata, ) ) - print_result(ctx, to_dict(result)) + print_result(ctx, result.model_dump(mode="json")) @chunks.command("update") @@ -74,14 +87,15 @@ def update_chunk(ctx, chunk_id, meta): with handle_client_errors(): api = ksapi.ChunksApi(api_client) metadata = json.loads(meta) if meta else None - chunk_metadata = ksapi.ChunkMetadataInput.from_dict( - metadata or {} - ) or ksapi.ChunkMetadataInput() + chunk_metadata = ( + ksapi.ChunkMetadataInput.from_dict(metadata or {}) + or ksapi.ChunkMetadataInput() + ) result = api.update_chunk_metadata( chunk_id, ksapi.UpdateChunkMetadataRequest(chunk_metadata=chunk_metadata), ) - print_result(ctx, to_dict(result)) + print_result(ctx, result.model_dump(mode="json")) @chunks.command("update-content") @@ -97,7 +111,7 @@ def update_chunk_content(ctx, chunk_id, content): chunk_id, ksapi.UpdateChunkContentRequest(content=content), ) - print_result(ctx, to_dict(result)) + print_result(ctx, result.model_dump(mode="json")) @chunks.command("delete") @@ -112,22 +126,111 @@ def delete_chunk(ctx, chunk_id): click.echo(f"Deleted chunk {chunk_id}") +@chunks.command("get-bulk") +@click.option( + "--chunk-ids", + type=click.UUID, + multiple=True, + required=True, + help="Chunk IDs to fetch (max 200).", +) +@click.pass_context +def get_chunks_bulk(ctx, chunk_ids): + """Batch-fetch chunks by IDs (max 200).""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.ChunksApi(api_client) + result = api.get_chunks_bulk(chunk_ids=list(chunk_ids)) + print_result(ctx, [r.model_dump(mode="json") for r in result]) + + +@chunks.command("version-chunk-ids") +@click.argument("version_id", type=click.UUID) +@click.pass_context +def get_version_chunk_ids(ctx, version_id): + """Get all chunk IDs belonging to a document version.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.ChunksApi(api_client) + result = api.get_version_chunk_ids(version_id) + print_result(ctx, result.model_dump(mode="json")) + + @chunks.command("search") @click.option("--query", required=True) @click.option("--limit", type=int, default=10) +@click.option( + "--search-type", + type=click.Choice(["dense_only", "full_text"], case_sensitive=False), + default=None, + help="Search mode: dense_only (semantic) or full_text.", +) +@click.option( + "--parent-path-ids", + type=click.UUID, + multiple=True, + default=(), + help="Path part IDs to scope search within.", +) +@click.option( + "--tag-ids", + type=click.UUID, + multiple=True, + default=(), + help="Tag IDs to filter by (AND logic).", +) +@click.option( + "--chunk-types", + type=click.Choice(["TEXT", "TABLE", "IMAGE", "UNKNOWN"]), + multiple=True, + default=(), + help="Chunk types to include.", +) +@click.option( + "--score-threshold", + type=float, + default=None, + help="Minimum relevance score threshold.", +) +@click.option( + "--active-version-only/--no-active-version-only", + default=None, + help="Restrict search to active document versions.", +) @click.option("--filters", default=None, help="JSON string of filters") @click.pass_context -def search_chunks(ctx, query, limit, filters): +def search_chunks( + ctx, + query, + limit, + search_type, + parent_path_ids, + tag_ids, + chunk_types, + score_threshold, + active_version_only, + filters, +): """Search chunks (semantic search).""" api_client = get_api_client(ctx) with handle_client_errors(): api = ksapi.ChunksApi(api_client) - filter_dict = json.loads(filters) if filters else None - _SEARCH_FILTER_KEYS = {"model", "parent_path_ids", "chunk_type", "updated_at", "score_threshold"} - request_kwargs = {"query": query, "top_k": limit} - if filter_dict: - request_kwargs.update({k: v for k, v in filter_dict.items() if k in _SEARCH_FILTER_KEYS}) - result = api.search_chunks( - ksapi.ChunkSearchRequest(**request_kwargs) - ) - print_result(ctx, to_dict(result)) + filter_dict = json.loads(filters) if filters else {} + request_kwargs = { + k: v for k, v in filter_dict.items() if k in _SEARCH_FILTER_KEYS + } + request_kwargs.update({"query": query, "top_k": limit}) + if search_type: + request_kwargs["search_type"] = search_type.lower() + if parent_path_ids: + request_kwargs["parent_path_ids"] = list(parent_path_ids) + if tag_ids: + request_kwargs["tag_ids"] = list(tag_ids) + if chunk_types: + request_kwargs["chunk_types"] = list(chunk_types) + if score_threshold is not None: + request_kwargs["score_threshold"] = score_threshold + if active_version_only is not None: + request_kwargs["active_version_only"] = active_version_only + result = api.search_chunks(ksapi.ChunkSearchRequest(**request_kwargs)) + print_result(ctx, [r.model_dump(mode="json") for r in result]) diff --git a/src/kscli/commands/document_versions.py b/src/kscli/commands/document_versions.py index cac0ec9..8f2e521 100644 --- a/src/kscli/commands/document_versions.py +++ b/src/kscli/commands/document_versions.py @@ -3,7 +3,7 @@ import click import ksapi -from kscli.client import get_api_client, handle_client_errors, to_dict +from kscli.client import get_api_client, handle_client_errors from kscli.output import print_result COLUMNS = ["id", "document_id", "name", "created_at"] @@ -27,7 +27,7 @@ def list_versions(ctx, document_id, limit, offset): result = api.list_document_versions( document_id=document_id, limit=limit, offset=offset ) - print_result(ctx, to_dict(result), columns=COLUMNS) + print_result(ctx, result.model_dump(mode="json"), columns=COLUMNS) @document_versions.command("describe") @@ -39,7 +39,7 @@ def describe_version(ctx, version_id): with handle_client_errors(): api = ksapi.DocumentVersionsApi(api_client) result = api.get_document_version(version_id) - print_result(ctx, to_dict(result)) + print_result(ctx, result.model_dump(mode="json")) @document_versions.command("contents") @@ -63,7 +63,12 @@ def version_contents(ctx, version_id, show_content, sections_only): with handle_client_errors(): api = ksapi.DocumentVersionsApi(api_client) result = api.get_document_version_contents(version_id) - print_result(ctx, to_dict(result), show_content=show_content, sections_only=sections_only) + print_result( + ctx, + result.model_dump(mode="json"), + show_content=show_content, + sections_only=sections_only, + ) @document_versions.command("create") @@ -75,7 +80,7 @@ def create_version(ctx, document_id): with handle_client_errors(): api = ksapi.DocumentVersionsApi(api_client) result = api.create_document_version(document_id=document_id) - print_result(ctx, to_dict(result)) + print_result(ctx, result.model_dump(mode="json")) @document_versions.command("update") @@ -91,7 +96,7 @@ def update_version(ctx, version_id, source_s3): version_id, ksapi.DocumentVersionMetadataUpdate(source_s3=source_s3), ) - print_result(ctx, to_dict(result)) + print_result(ctx, result.model_dump(mode="json")) @document_versions.command("delete") diff --git a/src/kscli/commands/documents.py b/src/kscli/commands/documents.py index c112de7..7ce591d 100644 --- a/src/kscli/commands/documents.py +++ b/src/kscli/commands/documents.py @@ -5,7 +5,7 @@ import click import ksapi -from kscli.client import get_api_client, handle_client_errors, to_dict +from kscli.client import get_api_client, handle_client_errors from kscli.output import print_result COLUMNS = ["id", "name", "type", "origin", "parent_path_part_id", "created_at"] @@ -37,7 +37,7 @@ def list_documents(ctx, parent_path_part_id, limit, offset): offset=offset, parent_path_part_id=parent_path_part_id, ) - print_result(ctx, to_dict(result), columns=COLUMNS) + print_result(ctx, result.model_dump(mode="json"), columns=COLUMNS) @documents.command("describe") @@ -49,7 +49,7 @@ def describe_document(ctx, document_id): with handle_client_errors(): api = ksapi.DocumentsApi(api_client) result = api.get_document(document_id) - print_result(ctx, to_dict(result)) + print_result(ctx, result.model_dump(mode="json")) @documents.command("create") @@ -61,7 +61,9 @@ def describe_document(ctx, document_id): required=True, help="Parent path part ID (e.g. folder's path_part_id from 'describe folder').", ) -@click.option("--type", "doc_type", required=True, type=click.Choice(["PDF", "DOCX", "UNKNOWN"])) +@click.option( + "--type", "doc_type", required=True, type=click.Choice(["PDF", "DOCX", "UNKNOWN"]) +) @click.option("--origin", required=True, type=click.Choice(["SOURCE", "GENERATED"])) @click.pass_context def create_document(ctx, name, parent_path_part_id, doc_type, origin): @@ -77,7 +79,7 @@ def create_document(ctx, name, parent_path_part_id, doc_type, origin): document_origin=origin, ) ) - print_result(ctx, to_dict(result)) + print_result(ctx, result.model_dump(mode="json")) @documents.command("update") @@ -92,9 +94,7 @@ def create_document(ctx, name, parent_path_part_id, doc_type, origin): ) @click.option("--active-version-id", type=click.UUID, default=None) @click.pass_context -def update_document( - ctx, document_id, name, parent_path_part_id, active_version_id -): +def update_document(ctx, document_id, name, parent_path_part_id, active_version_id): """Update a document.""" api_client = get_api_client(ctx) with handle_client_errors(): @@ -107,7 +107,7 @@ def update_document( active_version_id=active_version_id, ), ) - print_result(ctx, to_dict(result)) + print_result(ctx, result.model_dump(mode="json")) @documents.command("delete") @@ -146,4 +146,4 @@ def ingest_document(ctx, file_path, path_part_id, name): path_part_id=path_part_id, name=file_name, ) - print_result(ctx, to_dict(result)) + print_result(ctx, result.model_dump(mode="json")) diff --git a/src/kscli/commands/folders.py b/src/kscli/commands/folders.py index a063f29..f6ae190 100644 --- a/src/kscli/commands/folders.py +++ b/src/kscli/commands/folders.py @@ -1,12 +1,134 @@ """Folder commands.""" +import os +import uuid +from pathlib import Path + import click import ksapi -from kscli.client import get_api_client, handle_client_errors, to_dict +from kscli.client import get_api_client, handle_client_errors from kscli.output import print_result +from kscli.utils.error import format_api_error + +COLUMNS = [ + "id", + "path_part_id", + "name", + "parent_path_part_id", + "materialized_path", + "created_at", +] + + +type IngestFailure = tuple[str, str] +type IngestStats = tuple[int, int, int, list[IngestFailure]] + + +def _run_ingest_dry_run(local_path: Path, extensions: set[str]) -> IngestStats: + folders_created = 0 + files_ingested = 0 + files_skipped = 0 + failures: list[IngestFailure] = [] + + path_map: dict[str, str] = {".": ""} + + for root, dirs, file_names in os.walk(local_path, topdown=True): + rel_dir = os.path.normpath(os.path.relpath(root, local_path)).replace("\\", "/") + if rel_dir not in path_map: + dirs[:] = [] + continue + + for dir_name in dirs: + rel_subdir = str(Path(rel_dir) / dir_name).replace("\\", "/") + click.echo(f"Would create folder: {rel_subdir}") + path_map[rel_subdir] = "" + folders_created += 1 + + for file_name in file_names: + rel_file = str(Path(rel_dir) / file_name).replace("\\", "/") + ext = (Path(file_name).suffix or "").lower() + if ext not in extensions: + click.echo(f"Skipped: {rel_file} (unsupported extension)") + files_skipped += 1 + continue + click.echo(f"Would ingest: {rel_file}") + files_ingested += 1 + + return folders_created, files_ingested, files_skipped, failures + + +def _run_ingest_live( + local_path: Path, + extensions: set[str], + parent_path_part_id: uuid.UUID, + folders_api: ksapi.FoldersApi, + documents_api: ksapi.DocumentsApi, +) -> IngestStats: + folders_created = 0 + files_ingested = 0 + files_skipped = 0 + failures: list[IngestFailure] = [] + + path_map: dict[str, str] = {".": str(parent_path_part_id)} + + for root, dirs, file_names in os.walk(local_path, topdown=True): + rel_dir = os.path.normpath(os.path.relpath(root, local_path)).replace("\\", "/") + current_path_part_id = path_map.get(rel_dir) + + if current_path_part_id is None: + dirs[:] = [] + continue + + dirs_to_remove = [] + for dir_name in dirs: + rel_subdir = str(Path(rel_dir) / dir_name).replace("\\", "/") + try: + result = folders_api.create_folder( + ksapi.CreateFolderRequest( + name=dir_name, + parent_path_part_id=uuid.UUID(current_path_part_id), + ) + ) + path_map[rel_subdir] = str(result.path_part_id) + click.echo( + f"Creating folder: {rel_subdir} ... ok (path_part_id={result.path_part_id})" + ) + folders_created += 1 + except ksapi.ApiException as e: + msg = format_api_error(e) + click.echo(f"Creating folder: {rel_subdir} ... FAILED: {msg}") + failures.append((rel_subdir, msg)) + dirs_to_remove.append(dir_name) -COLUMNS = ["id", "path_part_id", "name", "parent_path_part_id", "materialized_path", "created_at"] + for failed_dir in dirs_to_remove: + dirs.remove(failed_dir) + + for file_name in file_names: + file_path = Path(root) / file_name + rel_file = str(Path(rel_dir) / file_name).replace("\\", "/") + ext = (Path(file_name).suffix or "").lower() + if ext not in extensions: + click.echo(f"Skipped: {rel_file} (unsupported extension)") + files_skipped += 1 + continue + try: + with file_path.open("rb") as f: + result = documents_api.ingest_document( + file=(file_name, f.read()), + path_part_id=uuid.UUID(current_path_part_id), + name=file_name, + ) + click.echo( + f"Ingesting: {rel_file} ... ok (document_id={result.document_id})" + ) + files_ingested += 1 + except ksapi.ApiException as e: + msg = format_api_error(e) + click.echo(f"Ingesting: {rel_file} ... FAILED: {msg}") + failures.append((rel_file, msg)) + + return folders_created, files_ingested, files_skipped, failures @click.group("folders") @@ -20,7 +142,7 @@ def folders(): "parent_path_part_id", type=click.UUID, default=None, - help="Parent path part ID; omit for root/top-level.", + help="Parent path part ID; omit for root/top-level. Mutually exclusive with --folder-id.", ) @click.option( "--show-content", @@ -34,44 +156,96 @@ def folders(): "folder_id", type=click.UUID, default=None, - help="Folder ID to list contents for (used with --show-content).", + help="Folder ID to list contents for. Auto-resolves to path_part_id for listing subfolders. Mutually exclusive with --parent-path-part-id.", +) +@click.option( + "--max-depth", type=int, default=None, help="Max depth (with --show-content)." ) -@click.option("--max-depth", type=int, default=None, help="Max depth (with --show-content).") @click.option( "--sort-order", - type=click.Choice(["asc", "desc"]), + type=click.Choice(["LOGICAL", "NAME", "UPDATED_AT", "CREATED_AT"]), default=None, - help="Sort order (with --show-content).", + help="Sort order.", +) +@click.option( + "--with-tags", + "with_tags", + is_flag=True, + default=False, + help="Include tags in the response.", ) @click.option("--limit", type=int, default=20) @click.option("--offset", type=int, default=0) @click.pass_context -def list_folders(ctx, parent_path_part_id, show_content, folder_id, max_depth, sort_order, limit, offset): +def list_folders( + ctx, + parent_path_part_id, + show_content, + folder_id, + max_depth, + sort_order, + with_tags, + limit, + offset, +): """List folders.""" + # Validation: mutual exclusivity + if parent_path_part_id is not None and folder_id is not None: + raise click.UsageError( + "--folder-id and --parent-path-part-id are mutually exclusive." + ) + + # Validation: show_content requires folder_id + if show_content and folder_id is None: + raise click.UsageError("--show-content requires --folder-id.") + + # Validation: max_depth requires show_content + if max_depth is not None and not show_content: + raise click.UsageError("--max-depth is only valid with --show-content.") + + api_client = get_api_client(ctx) + resolved_parent_path_part_id = parent_path_part_id + + # Resolve folder_id → path_part_id if needed + if folder_id is not None and not show_content: + with handle_client_errors(): + folders_api = ksapi.FoldersApi(api_client) + folder = folders_api.get_folder(folder_id) + resolved_parent_path_part_id = folder.path_part_id + + # Route to appropriate API if show_content: - if not folder_id: - raise click.UsageError("--folder-id is required when using --show-content.") - api_client = get_api_client(ctx) + # list_folder_contents (mixed folders + documents) with handle_client_errors(): api = ksapi.FoldersApi(api_client) result = api.list_folder_contents( folder_id, max_depth=max_depth, sort_order=sort_order, + with_tags=with_tags, limit=limit, offset=offset, ) - print_result(ctx, to_dict(result)) - return - api_client = get_api_client(ctx) - with handle_client_errors(): - api = ksapi.FoldersApi(api_client) - result = api.list_folders( - limit=limit, - offset=offset, - parent_path_part_id=parent_path_part_id, - ) - print_result(ctx, to_dict(result), columns=COLUMNS) + # Items are oneOf union wrappers; resolve actual_instance for serialization. + data = result.model_dump(mode="json") + data["items"] = [ + item.actual_instance.model_dump(mode="json") + for item in result.items + if item.actual_instance is not None + ] + print_result(ctx, data) + else: + # list_folders (folders only) + with handle_client_errors(): + api = ksapi.FoldersApi(api_client) + result = api.list_folders( + parent_path_part_id=resolved_parent_path_part_id, + sort_order=sort_order, + with_tags=with_tags, + limit=limit, + offset=offset, + ) + print_result(ctx, result.model_dump(mode="json"), columns=COLUMNS) @folders.command("describe") @@ -83,7 +257,7 @@ def describe_folder(ctx, folder_id): with handle_client_errors(): api = ksapi.FoldersApi(api_client) result = api.get_folder(folder_id) - print_result(ctx, to_dict(result)) + print_result(ctx, result.model_dump(mode="json")) @folders.command("create") @@ -107,7 +281,7 @@ def create_folder(ctx, name, parent_path_part_id): parent_path_part_id=parent_path_part_id, ) ) - print_result(ctx, to_dict(result)) + print_result(ctx, result.model_dump(mode="json")) @folders.command("update") @@ -133,7 +307,7 @@ def update_folder(ctx, folder_id, name, parent_path_part_id): parent_path_part_id=parent_path_part_id, ), ) - print_result(ctx, to_dict(result)) + print_result(ctx, result.model_dump(mode="json")) @folders.command("delete") @@ -146,3 +320,87 @@ def delete_folder(ctx, folder_id): api = ksapi.FoldersApi(api_client) api.delete_folder(folder_id) click.echo(f"Deleted folder {folder_id}") + + +@folders.command("bulk-ingest") +@click.argument( + "local_path", + type=click.Path(exists=True, file_okay=False, path_type=Path), +) +@click.option( + "--folder-id", + "folder_id", + type=click.UUID, + default=None, + help="Parent folder ID; resolves to path_part_id internally.", +) +@click.option( + "--path-part-id", + "path_part_id", + type=click.UUID, + default=None, + help="Parent path part ID (e.g. folder's path_part_id from 'describe folder').", +) +@click.option( + "--extensions", + "extensions_str", + default=".pdf,.docx", + help="Comma-separated file extensions to ingest (default: .pdf,.docx).", +) +@click.option("--dry-run", is_flag=True, help="Print plan without uploading.") +@click.pass_context +def ingest_folders( + ctx: click.Context, + local_path: Path, + folder_id: uuid.UUID | None, + path_part_id: uuid.UUID | None, + extensions_str: str, + dry_run: bool, +) -> None: + """Bulk-ingest a local folder tree: mirror directory structure and upload supported files.""" + if (folder_id is None) == (path_part_id is None): + raise click.UsageError( + "Exactly one of --folder-id or --path-part-id is required." + ) + + extensions = { + ext.strip().lower() for ext in extensions_str.split(",") if ext.strip() + } + if not extensions: + raise click.UsageError("--extensions must include at least one extension.") + + local_path = local_path.resolve() + if dry_run: + folders_created, files_ingested, files_skipped, failures = _run_ingest_dry_run( + local_path=local_path, + extensions=extensions, + ) + else: + api_client = get_api_client(ctx) + with handle_client_errors(): + folders_api = ksapi.FoldersApi(api_client) + documents_api = ksapi.DocumentsApi(api_client) + if folder_id is not None: + folder = folders_api.get_folder(folder_id) + parent_path_part_id = folder.path_part_id + else: + assert path_part_id is not None + parent_path_part_id = path_part_id + + folders_created, files_ingested, files_skipped, failures = _run_ingest_live( + local_path=local_path, + extensions=extensions, + parent_path_part_id=parent_path_part_id, + folders_api=folders_api, + documents_api=documents_api, + ) + + summary_parts = [ + f"{folders_created} folder(s) created", + f"{files_ingested} file(s) ingested", + f"{files_skipped} skipped", + *([f"{len(failures)} failed"] if failures else []), + ] + click.echo(f"\nSummary: {'\n * '.join(summary_parts)}") + if failures: + raise SystemExit(1) diff --git a/src/kscli/commands/invites.py b/src/kscli/commands/invites.py index 156af3d..14a6dec 100644 --- a/src/kscli/commands/invites.py +++ b/src/kscli/commands/invites.py @@ -1,12 +1,13 @@ """Invite commands.""" -from uuid import UUID - import click import ksapi -from kscli.auth import load_credentials -from kscli.client import get_api_client, handle_client_errors, to_dict +from kscli.client import ( + get_api_client, + get_current_identity, + handle_client_errors, +) from kscli.output import print_result COLUMNS = ["id", "email", "role", "status", "created_at"] @@ -27,7 +28,7 @@ def list_invites(ctx, limit, offset): with handle_client_errors(): api = ksapi.InvitesApi(api_client) result = api.list_invites(limit=limit, offset=offset) - print_result(ctx, to_dict(result), columns=COLUMNS) + print_result(ctx, result.model_dump(mode="json"), columns=COLUMNS) @invites.command("create") @@ -39,7 +40,7 @@ def create_invite(ctx, tenant_id, email, role): """Create an invite.""" api_client = get_api_client(ctx) with handle_client_errors(): - tid = tenant_id or UUID(load_credentials()["tenant_id"]) + tid = tenant_id or get_current_identity(api_client).current_tenant_id api = ksapi.InvitesApi(api_client) result = api.create_invite( ksapi.InviteUserRequest( @@ -48,7 +49,7 @@ def create_invite(ctx, tenant_id, email, role): role=role, ) ) - print_result(ctx, to_dict(result)) + print_result(ctx, result.model_dump(mode="json")) @invites.command("delete") @@ -72,4 +73,4 @@ def accept_invite(ctx, invite_id): with handle_client_errors(): api = ksapi.InvitesApi(api_client) result = api.accept_invite(invite_id) - print_result(ctx, to_dict(result)) + print_result(ctx, result.model_dump(mode="json")) diff --git a/src/kscli/commands/path_parts.py b/src/kscli/commands/path_parts.py index 3dc1a2b..f5b73c0 100644 --- a/src/kscli/commands/path_parts.py +++ b/src/kscli/commands/path_parts.py @@ -3,7 +3,7 @@ import click import ksapi -from kscli.client import get_api_client, handle_client_errors, to_dict +from kscli.client import get_api_client, handle_client_errors from kscli.output import print_result COLUMNS = ["id", "name", "type", "parent_path_part_id", "created_at"] @@ -33,7 +33,7 @@ def list_path_parts(ctx, parent_path_id, limit, offset): result = api.list_path_parts( limit=limit, offset=offset, parent_path_id=parent_path_id ) - print_result(ctx, to_dict(result), columns=COLUMNS) + print_result(ctx, result.model_dump(mode="json"), columns=COLUMNS) @path_parts.command("describe") @@ -45,4 +45,4 @@ def describe_path_part(ctx, path_part_id): with handle_client_errors(): api = ksapi.PathPartsApi(api_client) result = api.get_path_part(path_part_id) - print_result(ctx, to_dict(result)) + print_result(ctx, result.model_dump(mode="json")) diff --git a/src/kscli/commands/permissions.py b/src/kscli/commands/permissions.py index 27ab2fc..da606ea 100644 --- a/src/kscli/commands/permissions.py +++ b/src/kscli/commands/permissions.py @@ -1,12 +1,13 @@ """User permission commands.""" -from uuid import UUID - import click import ksapi -from kscli.auth import load_credentials -from kscli.client import get_api_client, handle_client_errors, to_dict +from kscli.client import ( + get_api_client, + get_current_identity, + handle_client_errors, +) from kscli.output import print_result COLUMNS = ["id", "user_id", "path_part_id", "capability", "created_at"] @@ -27,27 +28,33 @@ def list_permissions(ctx, tenant_id, user_id, limit, offset): """List permissions for a user in a tenant.""" api_client = get_api_client(ctx) with handle_client_errors(): - creds = load_credentials() - tid = tenant_id or UUID(creds["tenant_id"]) - uid = user_id or UUID(creds["user_id"]) + if tenant_id is None or user_id is None: + me = get_current_identity(api_client) + tid = tenant_id or me.current_tenant_id + uid = user_id or me.id + else: + tid = tenant_id + uid = user_id api = ksapi.UserPermissionsApi(api_client) result = api.list_user_permissions( tenant_id=tid, user_id=uid, limit=limit, offset=offset ) - print_result(ctx, to_dict(result), columns=COLUMNS) + print_result(ctx, result.model_dump(mode="json"), columns=COLUMNS) @permissions.command("create") @click.option("--tenant-id", type=click.UUID, default=None) @click.option("--user-id", type=click.UUID, required=True) @click.option("--path-part-id", type=click.UUID, required=True) -@click.option("--capability", required=True, type=click.Choice(["READ_ONLY", "READ_WRITE"])) +@click.option( + "--capability", required=True, type=click.Choice(["READ_ONLY", "READ_WRITE"]) +) @click.pass_context def create_permission(ctx, tenant_id, user_id, path_part_id, capability): """Create a permission.""" api_client = get_api_client(ctx) with handle_client_errors(): - tid = tenant_id or UUID(load_credentials()["tenant_id"]) + tid = tenant_id or get_current_identity(api_client).current_tenant_id api = ksapi.UserPermissionsApi(api_client) result = api.create_user_permission( ksapi.CreatePermissionRequest( @@ -57,26 +64,28 @@ def create_permission(ctx, tenant_id, user_id, path_part_id, capability): capability=capability, ) ) - print_result(ctx, to_dict(result)) + print_result(ctx, result.model_dump(mode="json")) @permissions.command("update") @click.argument("permission_id", type=click.UUID) @click.option("--tenant-id", type=click.UUID, default=None) -@click.option("--capability", required=True, type=click.Choice(["READ_ONLY", "READ_WRITE"])) +@click.option( + "--capability", required=True, type=click.Choice(["READ_ONLY", "READ_WRITE"]) +) @click.pass_context def update_permission(ctx, permission_id, tenant_id, capability): """Update a permission.""" api_client = get_api_client(ctx) with handle_client_errors(): - tid = tenant_id or UUID(load_credentials()["tenant_id"]) + tid = tenant_id or get_current_identity(api_client).current_tenant_id api = ksapi.UserPermissionsApi(api_client) result = api.update_user_permission( permission_id, tid, ksapi.UpdatePermissionRequest(capability=capability), ) - print_result(ctx, to_dict(result)) + print_result(ctx, result.model_dump(mode="json")) @permissions.command("delete") @@ -87,7 +96,7 @@ def delete_permission(ctx, permission_id, tenant_id): """Delete a permission.""" api_client = get_api_client(ctx) with handle_client_errors(): - tid = tenant_id or UUID(load_credentials()["tenant_id"]) + tid = tenant_id or get_current_identity(api_client).current_tenant_id api = ksapi.UserPermissionsApi(api_client) api.delete_user_permission(permission_id, tid) click.echo(f"Deleted permission {permission_id}") diff --git a/src/kscli/commands/sections.py b/src/kscli/commands/sections.py index c311d9d..7a30b47 100644 --- a/src/kscli/commands/sections.py +++ b/src/kscli/commands/sections.py @@ -3,7 +3,7 @@ import click import ksapi -from kscli.client import get_api_client, handle_client_errors, to_dict +from kscli.client import get_api_client, handle_client_errors from kscli.output import print_result @@ -21,7 +21,7 @@ def describe_section(ctx, section_id): with handle_client_errors(): api = ksapi.SectionsApi(api_client) result = api.get_section(section_id) - print_result(ctx, to_dict(result)) + print_result(ctx, result.model_dump(mode="json")) @sections.command("create") @@ -43,7 +43,7 @@ def create_section(ctx, name, parent_path_id, page_number, prev_sibling_path_id) prev_sibling_path_id=prev_sibling_path_id, ) ) - print_result(ctx, to_dict(result)) + print_result(ctx, result.model_dump(mode="json")) @sections.command("update") @@ -53,7 +53,9 @@ def create_section(ctx, name, parent_path_id, page_number, prev_sibling_path_id) @click.option("--prev-sibling-path-id", type=click.UUID, default=None) @click.option("--move-to-head", is_flag=True, default=False) @click.pass_context -def update_section(ctx, section_id, name, page_number, prev_sibling_path_id, move_to_head): +def update_section( + ctx, section_id, name, page_number, prev_sibling_path_id, move_to_head +): """Update a section.""" api_client = get_api_client(ctx) with handle_client_errors(): @@ -67,7 +69,7 @@ def update_section(ctx, section_id, name, page_number, prev_sibling_path_id, mov move_to_head=move_to_head, ), ) - print_result(ctx, to_dict(result)) + print_result(ctx, result.model_dump(mode="json")) @sections.command("delete") diff --git a/src/kscli/commands/settings.py b/src/kscli/commands/settings.py index f0dfa95..aa1cd5a 100644 --- a/src/kscli/commands/settings.py +++ b/src/kscli/commands/settings.py @@ -1,7 +1,5 @@ """Settings commands: environment, show.""" -import os - import click from kscli.config import ( @@ -17,9 +15,14 @@ _ENV_PRESETS: dict[str, dict[str, object]] = { "local": { "environment": "local", - "base_url": "http://localhost:8000", + "base_url": "http://localhost:18000", "verify_ssl": False, }, + "staging": { + "environment": "staging", + "base_url": "https://api-staging.knowledgestack.ai", + "verify_ssl": True, + }, "prod": { "environment": "prod", "base_url": "https://api.knowledgestack.ai", @@ -34,17 +37,17 @@ def settings(): @settings.command("environment") -@click.argument("env_name", type=click.Choice(["local", "prod"])) +@click.argument("env_name", type=click.Choice(["local", "staging", "prod"])) @click.option( - "--base-url", + "--url", default=None, help="Override default API base URL for the selected environment", ) -def environment(env_name: str, base_url: str | None) -> None: +def environment(env_name: str, url: str | None) -> None: """Set the environment (local, prod) and associated config.""" preset = _ENV_PRESETS[env_name].copy() - if base_url: - preset["base_url"] = base_url + if url: + preset["base_url"] = url write_config(preset) click.echo(f"Environment set to '{env_name}'.") if "base_url" in preset: @@ -54,7 +57,7 @@ def environment(env_name: str, base_url: str | None) -> None: @settings.command("show") @click.pass_context -def show(ctx) -> None: +def show(ctx: click.Context) -> None: """Print current resolved configuration (env + config file + defaults).""" base_url = get_base_url(None) verify_ssl, ca_bundle = get_tls_config() @@ -63,7 +66,6 @@ def show(ctx) -> None: file_config = load_config() environment_label = file_config.get("environment", "(not set)") - admin_set = "admin_api_key" in file_config or bool(os.environ.get("ADMIN_API_KEY")) result = { "config_file": str(path), @@ -72,6 +74,5 @@ def show(ctx) -> None: "ca_bundle": ca_bundle or "(default)", "format": format_, "environment": environment_label, - "admin_api_key": "(set)" if admin_set else "(not set)", } print_result(ctx, result) diff --git a/src/kscli/commands/tags.py b/src/kscli/commands/tags.py index f8941b2..2aa0d4e 100644 --- a/src/kscli/commands/tags.py +++ b/src/kscli/commands/tags.py @@ -3,7 +3,7 @@ import click import ksapi -from kscli.client import get_api_client, handle_client_errors, to_dict +from kscli.client import get_api_client, handle_client_errors from kscli.output import print_result COLUMNS = ["id", "name", "color", "description", "created_at"] @@ -24,7 +24,7 @@ def list_tags(ctx, limit, offset): with handle_client_errors(): api = ksapi.TagsApi(api_client) result = api.list_tags(limit=limit, offset=offset) - print_result(ctx, to_dict(result), columns=COLUMNS) + print_result(ctx, result.model_dump(mode="json"), columns=COLUMNS) @tags.command("describe") @@ -36,7 +36,7 @@ def describe_tag(ctx, tag_id): with handle_client_errors(): api = ksapi.TagsApi(api_client) result = api.get_tag(tag_id) - print_result(ctx, to_dict(result)) + print_result(ctx, result.model_dump(mode="json")) @tags.command("create") @@ -51,11 +51,9 @@ def create_tag(ctx, name, color, description): api = ksapi.TagsApi(api_client) color_val = color.lstrip("#") if color else color result = api.create_tag( - ksapi.CreateTagRequest( - name=name, color=color_val, description=description - ) + ksapi.CreateTagRequest(name=name, color=color_val, description=description) ) - print_result(ctx, to_dict(result)) + print_result(ctx, result.model_dump(mode="json")) @tags.command("update") @@ -72,11 +70,9 @@ def update_tag(ctx, tag_id, name, color, description): color_val = color.lstrip("#") if color else color result = api.update_tag( tag_id, - ksapi.UpdateTagRequest( - name=name, color=color_val, description=description - ), + ksapi.UpdateTagRequest(name=name, color=color_val, description=description), ) - print_result(ctx, to_dict(result)) + print_result(ctx, result.model_dump(mode="json")) @tags.command("delete") @@ -104,7 +100,7 @@ def attach_tag(ctx, tag_id, path_part_id): path_part_id, ksapi.BulkTagRequest(tag_ids=[tag_id]), ) - print_result(ctx, to_dict(result)) + print_result(ctx, result.model_dump(mode="json")) @tags.command("detach") @@ -120,4 +116,4 @@ def detach_tag(ctx, tag_id, path_part_id): path_part_id, ksapi.BulkTagRequest(tag_ids=[tag_id]), ) - print_result(ctx, to_dict(result)) + print_result(ctx, result.model_dump(mode="json")) diff --git a/src/kscli/commands/tenants.py b/src/kscli/commands/tenants.py index 4952859..d0648a3 100644 --- a/src/kscli/commands/tenants.py +++ b/src/kscli/commands/tenants.py @@ -5,7 +5,7 @@ import click import ksapi -from kscli.client import get_api_client, handle_client_errors, to_dict +from kscli.client import get_api_client, handle_client_errors from kscli.output import print_result COLUMNS = ["id", "name", "created_at"] @@ -27,7 +27,7 @@ def list_tenants(ctx, limit, offset): with handle_client_errors(): api = ksapi.TenantsApi(api_client) result = api.list_tenants(limit=limit, offset=offset) - print_result(ctx, to_dict(result), columns=COLUMNS) + print_result(ctx, result.model_dump(mode="json"), columns=COLUMNS) @tenants.command("describe") @@ -39,7 +39,7 @@ def describe_tenant(ctx, tenant_id): with handle_client_errors(): api = ksapi.TenantsApi(api_client) result = api.get_tenant(tenant_id) - print_result(ctx, to_dict(result)) + print_result(ctx, result.model_dump(mode="json")) @tenants.command("create") @@ -52,10 +52,8 @@ def create_tenant(ctx, name, idp_config): with handle_client_errors(): api = ksapi.TenantsApi(api_client) idp = json.loads(idp_config) if idp_config else None - result = api.create_tenant( - ksapi.CreateTenantRequest(name=name, idp_config=idp) - ) - print_result(ctx, to_dict(result)) + result = api.create_tenant(ksapi.CreateTenantRequest(name=name, idp_config=idp)) + print_result(ctx, result.model_dump(mode="json")) @tenants.command("update") @@ -73,7 +71,7 @@ def update_tenant(ctx, tenant_id, name, idp_config): tenant_id, ksapi.UpdateTenantRequest(name=name, idp_config=idp), ) - print_result(ctx, to_dict(result)) + print_result(ctx, result.model_dump(mode="json")) @tenants.command("delete") @@ -99,4 +97,4 @@ def list_tenant_users(ctx, tenant_id, limit, offset): with handle_client_errors(): api = ksapi.TenantsApi(api_client) result = api.list_tenant_users(tenant_id, limit=limit, offset=offset) - print_result(ctx, to_dict(result), columns=USER_COLUMNS) + print_result(ctx, result.model_dump(mode="json"), columns=USER_COLUMNS) diff --git a/src/kscli/commands/thread_messages.py b/src/kscli/commands/thread_messages.py index 4c8224b..82d925c 100644 --- a/src/kscli/commands/thread_messages.py +++ b/src/kscli/commands/thread_messages.py @@ -3,7 +3,7 @@ import click import ksapi -from kscli.client import get_api_client, handle_client_errors, to_dict +from kscli.client import get_api_client, handle_client_errors from kscli.output import print_result COLUMNS = ["id", "role", "content", "created_at"] @@ -25,7 +25,7 @@ def list_messages(ctx, thread_id, limit, offset): with handle_client_errors(): api = ksapi.ThreadMessagesApi(api_client) result = api.list_thread_messages(thread_id, limit=limit, offset=offset) - print_result(ctx, to_dict(result), columns=COLUMNS) + print_result(ctx, result.model_dump(mode="json"), columns=COLUMNS) @thread_messages.command("describe") @@ -38,13 +38,15 @@ def describe_message(ctx, message_id, thread_id): with handle_client_errors(): api = ksapi.ThreadMessagesApi(api_client) result = api.get_thread_message(thread_id, message_id) - print_result(ctx, to_dict(result)) + print_result(ctx, result.model_dump(mode="json")) @thread_messages.command("create") @click.option("--thread-id", type=click.UUID, required=True) @click.option("--content", required=True) -@click.option("--role", required=True, type=click.Choice(["USER", "ASSISTANT", "SYSTEM"])) +@click.option( + "--role", required=True, type=click.Choice(["USER", "ASSISTANT", "SYSTEM"]) +) @click.pass_context def create_message(ctx, thread_id, content, role): """Create a message.""" @@ -54,7 +56,8 @@ def create_message(ctx, thread_id, content, role): result = api.create_thread_message( thread_id, ksapi.CreateThreadMessageRequest( - content={"text": content}, role=role + content=ksapi.ThreadMessageContent(text=content), + role=role, ), ) - print_result(ctx, to_dict(result)) + print_result(ctx, result.model_dump(mode="json")) diff --git a/src/kscli/commands/threads.py b/src/kscli/commands/threads.py index 9cf4d93..9ff2ad8 100644 --- a/src/kscli/commands/threads.py +++ b/src/kscli/commands/threads.py @@ -3,7 +3,7 @@ import click import ksapi -from kscli.client import get_api_client, handle_client_errors, to_dict +from kscli.client import get_api_client, handle_client_errors from kscli.output import print_result COLUMNS = ["id", "title", "parent_path_part_id", "created_at"] @@ -27,7 +27,7 @@ def list_threads(ctx, parent_path_part_id, limit, offset): result = api.list_threads( limit=limit, offset=offset, parent_path_part_id=parent_path_part_id ) - print_result(ctx, to_dict(result), columns=COLUMNS) + print_result(ctx, result.model_dump(mode="json"), columns=COLUMNS) @threads.command("describe") @@ -39,7 +39,7 @@ def describe_thread(ctx, thread_id): with handle_client_errors(): api = ksapi.ThreadsApi(api_client) result = api.get_thread(thread_id) - print_result(ctx, to_dict(result)) + print_result(ctx, result.model_dump(mode="json")) @threads.command("create") @@ -57,7 +57,7 @@ def create_thread(ctx, title, parent_path_part_id): parent_path_part_id=parent_path_part_id, ) ) - print_result(ctx, to_dict(result)) + print_result(ctx, result.model_dump(mode="json")) @threads.command("update") @@ -72,11 +72,9 @@ def update_thread(ctx, thread_id, title, parent_thread_id): api = ksapi.ThreadsApi(api_client) result = api.update_thread( thread_id, - ksapi.UpdateThreadRequest( - title=title, parent_thread_id=parent_thread_id - ), + ksapi.UpdateThreadRequest(title=title, parent_thread_id=parent_thread_id), ) - print_result(ctx, to_dict(result)) + print_result(ctx, result.model_dump(mode="json")) @threads.command("delete") diff --git a/src/kscli/commands/users.py b/src/kscli/commands/users.py index 40ddea3..54bf738 100644 --- a/src/kscli/commands/users.py +++ b/src/kscli/commands/users.py @@ -3,7 +3,7 @@ import click import ksapi -from kscli.client import get_api_client, handle_client_errors, to_dict +from kscli.client import get_api_client, handle_client_errors from kscli.output import print_result @@ -23,4 +23,4 @@ def update_user(ctx, default_tenant_id): result = api.update_me( ksapi.UpdateUserRequest(default_tenant_id=default_tenant_id) ) - print_result(ctx, to_dict(result)) + print_result(ctx, result.model_dump(mode="json")) diff --git a/src/kscli/commands/workflows.py b/src/kscli/commands/workflows.py index 01fb2ac..ea74e8b 100644 --- a/src/kscli/commands/workflows.py +++ b/src/kscli/commands/workflows.py @@ -3,7 +3,7 @@ import click import ksapi -from kscli.client import get_api_client, handle_client_errors, to_dict +from kscli.client import get_api_client, handle_client_errors from kscli.output import print_result COLUMNS = ["workflow_id", "status", "document_id", "created_at", "last_run_timestamp"] @@ -24,7 +24,7 @@ def list_workflows(ctx, limit, offset): with handle_client_errors(): api = ksapi.WorkflowsApi(api_client) result = api.list_workflows(limit=limit, offset=offset) - print_result(ctx, to_dict(result), columns=COLUMNS) + print_result(ctx, result.model_dump(mode="json"), columns=COLUMNS) @workflows.command("describe") @@ -36,7 +36,7 @@ def describe_workflow(ctx, workflow_id): with handle_client_errors(): api = ksapi.WorkflowsApi(api_client) result = api.get_workflow(str(workflow_id)) - print_result(ctx, to_dict(result)) + print_result(ctx, result.model_dump(mode="json")) @workflows.command("cancel") @@ -48,7 +48,7 @@ def cancel_workflow(ctx, workflow_id): with handle_client_errors(): api = ksapi.WorkflowsApi(api_client) result = api.workflow_action(str(workflow_id), ksapi.WorkflowAction.CANCEL) - print_result(ctx, to_dict(result)) + print_result(ctx, result.model_dump(mode="json")) @workflows.command("rerun") @@ -60,4 +60,4 @@ def rerun_workflow(ctx, workflow_id): with handle_client_errors(): api = ksapi.WorkflowsApi(api_client) result = api.workflow_action(str(workflow_id), ksapi.WorkflowAction.RERUN) - print_result(ctx, to_dict(result)) + print_result(ctx, result.model_dump(mode="json")) diff --git a/src/kscli/config.py b/src/kscli/config.py index 5f7c599..67cab21 100644 --- a/src/kscli/config.py +++ b/src/kscli/config.py @@ -26,20 +26,9 @@ def load_config() -> dict[str, Any]: return {} -def _load_config_file() -> dict[str, Any]: - return load_config() - - -def get_admin_api_key() -> str: - key = os.environ.get("ADMIN_API_KEY") - if not key: - key = _load_config_file().get("admin_api_key") - if not key: - raise SystemExit( - f"Error: ADMIN_API_KEY is not set. " - f"Set it via environment variable or in {get_config_path()}" - ) - return key +def get_current_environment() -> str: + """Resolve current environment from config. Defaults to 'local'.""" + return load_config().get("environment", "local") def get_base_url(override: str | None = None) -> str: @@ -47,7 +36,7 @@ def get_base_url(override: str | None = None) -> str: return override return ( os.environ.get("KSCLI_BASE_URL") - or _load_config_file().get("base_url") + or load_config().get("base_url") or _DEFAULT_BASE_URL ) @@ -55,7 +44,7 @@ def get_base_url(override: str | None = None) -> str: def get_default_format() -> str: return ( os.environ.get("KSCLI_FORMAT") - or _load_config_file().get("format") + or load_config().get("format") or _DEFAULT_FORMAT ) @@ -66,7 +55,7 @@ def get_tls_config() -> tuple[bool, str | None]: Returns: (verify_ssl, ca_bundle_path) """ - config = _load_config_file() + config = load_config() # Verify SSL: defaults to True verify_env = os.environ.get("KSCLI_VERIFY_SSL") @@ -97,6 +86,6 @@ def write_config(updates: dict[str, Any]) -> None: path = get_config_path() path.parent.mkdir(parents=True, exist_ok=True) - current = _load_config_file() + current = load_config() merged = {**current, **updates} path.write_text(json.dumps(merged, indent=2)) diff --git a/src/kscli/output.py b/src/kscli/output.py index 27d1405..4274fcd 100644 --- a/src/kscli/output.py +++ b/src/kscli/output.py @@ -104,12 +104,17 @@ def _print_tree( _print_table(data, columns, no_header) return - typed_items = [item for item in items if isinstance(item, dict)] + # All items are guaranteed to be dicts by the guard above. + typed_items: list[dict[str, Any]] = items # type: ignore[assignment] if _is_depth_tree(typed_items): _render_depth_tree(typed_items, show_content, sections_only) return + if _is_path_linked_tree(typed_items): + _render_path_linked_tree(typed_items) + return + if _is_flat_tree(typed_items): _render_flat_tree(typed_items) return @@ -125,6 +130,19 @@ def _is_flat_tree(items: list[dict[str, Any]]) -> bool: return bool(items) and all("part_type" in item for item in items) +def _is_path_linked_tree(items: list[dict[str, Any]]) -> bool: + """True when items contain path linkage to reconstruct hierarchy.""" + if not items: + return False + + has_path_linkage = all( + item.get("part_type") is not None and item.get("path_part_id") is not None + for item in items + ) + has_parent_link = any(item.get("parent_path_part_id") is not None for item in items) + return has_path_linkage and has_parent_link + + def _render_depth_tree( items: list[dict[str, Any]], show_content: bool, sections_only: bool = False ) -> None: @@ -166,6 +184,70 @@ def _render_flat_tree(items: list[dict[str, Any]]) -> None: click.echo(f"{connector}{_build_node_label(item)}") +def _render_path_linked_tree(items: list[dict[str, Any]]) -> None: + """Render tree by reconstructing parent/child links from path IDs.""" + keyed_items = [item for item in items if item.get("path_part_id") is not None] + if len(keyed_items) != len(items): + _render_flat_tree(items) + return + + path_ids = {str(item["path_part_id"]) for item in keyed_items} + children_by_parent: dict[str, list[dict[str, Any]]] = {} + + for item in keyed_items: + parent_key = str(item.get("parent_path_part_id") or "") + children_by_parent.setdefault(parent_key, []).append(item) + + roots = [ + item + for item in keyed_items + if str(item.get("parent_path_part_id") or "") not in path_ids + ] + if not roots: + _render_flat_tree(items) + return + + branch_continues: list[bool] = [] + emitted_nodes: set[str] = set() + + def _emit_node(node: dict[str, Any], depth: int, is_last: bool) -> None: + node_key = str(node["path_part_id"]) + if node_key in emitted_nodes: + return + emitted_nodes.add(node_key) + + if len(branch_continues) > depth: + branch_continues[:] = branch_continues[:depth] + elif len(branch_continues) < depth: + branch_continues.extend([False] * (depth - len(branch_continues))) + + prefix = "".join("│ " if has_more else " " for has_more in branch_continues) + connector = "└── " if is_last else "├── " + click.echo(f"{prefix}{connector}{_build_node_label(node)}") + + children = children_by_parent.get(node_key, []) + if not children: + return + + if len(branch_continues) == depth: + branch_continues.append(not is_last) + else: + branch_continues[depth] = not is_last + + for idx, child in enumerate(children): + _emit_node(child, depth + 1, idx == len(children) - 1) + + for idx, root in enumerate(roots): + _emit_node(root, depth=0, is_last=idx == len(roots) - 1) + + # If pagination returns disconnected slices, still render unreachable items. + unreachable_items = [ + item for item in keyed_items if str(item["path_part_id"]) not in emitted_nodes + ] + if unreachable_items: + _render_flat_tree(unreachable_items) + + def _coerce_depth(depth: Any) -> int: if isinstance(depth, int): return max(depth, 0) @@ -201,9 +283,14 @@ def _build_node_label(item: dict[str, Any]) -> str: details_text = f" [{', '.join(details)}]" if details else "" id_parts: list[str] = [] + + # Show folder/document ID (prefer metadata_obj_id, fall back to id) metadata_obj_id = item.get("metadata_obj_id") + item_id = item.get("id") if metadata_obj_id is not None: id_parts.append(f"id:{metadata_obj_id}") + elif item_id is not None: + id_parts.append(f"id:{item_id}") path_part_id = item.get("path_part_id") if path_part_id is not None: diff --git a/src/kscli/utils/error.py b/src/kscli/utils/error.py new file mode 100644 index 0000000..5e401b2 --- /dev/null +++ b/src/kscli/utils/error.py @@ -0,0 +1,16 @@ +import json + +import ksapi # noqa: TC002 + + +def format_api_error(e: ksapi.ApiException) -> str: + """Format an ApiException for display without exiting.""" + status = e.status or 500 + detail = "" + if e.body: + try: + body = json.loads(e.body) + detail = body.get("detail", body) + except Exception: + detail = e.body + return f"{status}: {detail}" if detail else str(status) diff --git a/tests/e2e/cli_helpers.py b/tests/e2e/cli_helpers.py index 5b9da7c..ab5b541 100644 --- a/tests/e2e/cli_helpers.py +++ b/tests/e2e/cli_helpers.py @@ -10,7 +10,7 @@ # Env vars stripped from the inherited environment before merging test overrides. # Prevents the developer's shell config from contaminating e2e subprocess calls. _SANITIZED_KEYS = frozenset({ - "ADMIN_API_KEY", + "USER_API_KEY", "KSCLI_BASE_URL", "KSCLI_VERIFY_SSL", "KSCLI_CREDENTIALS_PATH", diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 430b1a2..a9cedbd 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -1,14 +1,12 @@ """E2E test fixtures for kscli CLI tests.""" import secrets +from collections.abc import Generator # noqa: TC003 from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import Any import pytest -if TYPE_CHECKING: - from collections.abc import Generator - from tests.e2e.cli_helpers import run_kscli, run_kscli_ok # --------------------------------------------------------------------------- @@ -17,35 +15,16 @@ E2E_BASE_URL = "http://localhost:28000" -# Resolve ADMIN_API_KEY from ks-backend/.env.e2e at import time. +# Resolve USER_API_KEY from ks-backend/.env.e2e at import time. _KS_BACKEND_ENV_E2E = Path(__file__).resolve().parents[3] / "ks-backend" / ".env.e2e" -def _read_admin_api_key() -> str: - """Parse ADMIN_API_KEY from ks-backend/.env.e2e.""" - if not _KS_BACKEND_ENV_E2E.is_file(): - pytest.exit( - f"Cannot find {_KS_BACKEND_ENV_E2E}. " - "Ensure ks-backend is checked out alongside ks-cli.", - returncode=1, - ) - for line in _KS_BACKEND_ENV_E2E.read_text().splitlines(): - stripped = line.strip() - if stripped.startswith("ADMIN_API_KEY=") and not stripped.startswith("#"): - return stripped.split("=", 1)[1].strip().strip('"').strip("'") - pytest.exit( - f"ADMIN_API_KEY not found in {_KS_BACKEND_ENV_E2E}", - returncode=1, - ) - return "" # unreachable, keeps type checker happy - - -E2E_ADMIN_API_KEY = _read_admin_api_key() - # --------------------------------------------------------------------------- # Well-known seed data UUIDs (from ../ks-backend/seed/seed_data.py) # --------------------------------------------------------------------------- +E2E_USER_API_KEY = "sk-user-pwuser1-personal-api-key-secret-pwuser1" + PWUSER1_ID = "00000000-0000-0000-0001-000000000001" PWUSER2_ID = "00000000-0000-0000-0001-000000000002" PWUSER3_ID = "00000000-0000-0000-0001-000000000005" @@ -94,15 +73,15 @@ def cli_env(tmp_path_factory: pytest.TempPathFactory) -> dict[str, str]: Uses an isolated credentials path so tests don't interfere with user's local kscli setup. The subprocess helper (run_kscli) strips any inherited - ADMIN_API_KEY / KSCLI_BASE_URL from os.environ before merging these values, - so the subprocess always targets localhost:28000 with the e2e admin key. + USER_API_KEY / KSCLI_BASE_URL from os.environ before merging these values, + so the subprocess always targets localhost:28000 with the e2e user key. """ tmp = tmp_path_factory.mktemp("kscli") - credentials_path = str(tmp / ".credentials") + credentials_path = str(tmp / "credentials") config_path = str(tmp / "config.json") return { "KSCLI_BASE_URL": E2E_BASE_URL, - "ADMIN_API_KEY": E2E_ADMIN_API_KEY, + "USER_API_KEY": E2E_USER_API_KEY, "KSCLI_VERIFY_SSL": "false", "KSCLI_CREDENTIALS_PATH": credentials_path, "KSCLI_CONFIG": config_path, @@ -111,18 +90,14 @@ def cli_env(tmp_path_factory: pytest.TempPathFactory) -> dict[str, str]: @pytest.fixture(scope="session") def cli_authenticated(cli_env: dict[str, str]) -> dict[str, str]: - """Authenticate as pwuser1 in their personal tenant. Returns env dict. + """Authenticate as pwuser1 via API key. Returns env dict. All seed filesystem data (folders, documents, versions, sections, chunks, lineages, tags, threads) lives in pwuser1's personal tenant, so we authenticate there for the tests to find the seed data. """ run_kscli_ok( - [ - "assume-user", - "--tenant-id", PWUSER1_TENANT_ID, - "--user-id", PWUSER1_ID, - ], + ["login", "--api-key", cli_env["USER_API_KEY"]], env=cli_env, format_json=False, ) @@ -139,9 +114,12 @@ def kscli_parent_folder( """ result = run_kscli_ok( [ - "folders", "create", - "--name", f"kscli_{secrets.token_hex(4)}", - "--parent-path-part-id", AGENTS_FOLDER_PATH_PART_ID, + "folders", + "create", + "--name", + f"kscli_{secrets.token_hex(4)}", + "--parent-path-part-id", + AGENTS_FOLDER_PATH_PART_ID, ], env=cli_authenticated, ) @@ -166,9 +144,12 @@ def isolation_folder( """ result = run_kscli_ok( [ - "folders", "create", - "--name", f"iso_{secrets.token_hex(6)}", - "--parent-path-part-id", kscli_parent_folder["path_part_id"], + "folders", + "create", + "--name", + f"iso_{secrets.token_hex(6)}", + "--parent-path-part-id", + kscli_parent_folder["path_part_id"], ], env=cli_authenticated, ) diff --git a/tests/e2e/test_cli_auth.py b/tests/e2e/test_cli_auth.py index 72fdc3f..1017c59 100644 --- a/tests/e2e/test_cli_auth.py +++ b/tests/e2e/test_cli_auth.py @@ -1,4 +1,4 @@ -"""E2E tests for authentication commands: assume-user, whoami.""" +"""E2E tests for authentication commands: login, logout, whoami.""" import tempfile from pathlib import Path @@ -7,10 +7,8 @@ from tests.e2e.cli_helpers import run_kscli_fail, run_kscli_ok from tests.e2e.conftest import ( - NONEXISTENT_UUID, + E2E_USER_API_KEY, PWUSER1_ID, - PWUSER1_TENANT_ID, - SHARED_TENANT_ID, ) pytestmark = pytest.mark.e2e @@ -19,19 +17,14 @@ class TestCliAuth: """Authentication command tests.""" - def test_assume_user_success(self, cli_env: dict[str, str]) -> None: - """assume-user with valid credentials succeeds.""" + def test_login_success(self, cli_env: dict[str, str]) -> None: + """Login with valid API key succeeds.""" result = run_kscli_ok( - [ - "assume-user", - "--tenant-id", SHARED_TENANT_ID, - "--user-id", PWUSER1_ID, - ], + ["login", "--api-key", E2E_USER_API_KEY], env=cli_env, format_json=False, ) - assert "Authenticated as user" in result.stdout - assert PWUSER1_ID in result.stdout + assert "Logged in successfully" in result.stdout def test_whoami_shows_identity(self, cli_authenticated: dict[str, str]) -> None: """Whoami returns the current user info.""" @@ -40,7 +33,6 @@ def test_whoami_shows_identity(self, cli_authenticated: dict[str, str]) -> None: assert isinstance(data, dict) assert data["id"] == PWUSER1_ID assert data["email"] == "pwuser1@ksdev.mock" - assert data["tenant_id"] == PWUSER1_TENANT_ID def test_unauthenticated_command_fails(self, cli_env: dict[str, str]) -> None: """Running a command without auth fails.""" @@ -52,14 +44,22 @@ def test_unauthenticated_command_fails(self, cli_env: dict[str, str]) -> None: result = run_kscli_fail(["folders", "list"], env=env) assert result.exit_code != 0 - def test_assume_user_bad_user_id_fails(self, cli_env: dict[str, str]) -> None: - """assume-user with a nonexistent user ID fails.""" - run_kscli_fail( - [ - "assume-user", - "--tenant-id", SHARED_TENANT_ID, - "--user-id", NONEXISTENT_UUID, - ], + def test_logout(self, cli_env: dict[str, str]) -> None: + """Logout removes credentials.""" + # Login first + run_kscli_ok( + ["login", "--api-key", E2E_USER_API_KEY], + env=cli_env, + format_json=False, + ) + # Logout + result = run_kscli_ok(["logout"], env=cli_env, format_json=False) + assert "Logged out" in result.stdout + # Verify we're no longer authenticated + run_kscli_fail(["folders", "list"], env=cli_env) + # Re-login for other tests that depend on cli_env credentials + run_kscli_ok( + ["login", "--api-key", E2E_USER_API_KEY], env=cli_env, format_json=False, ) diff --git a/tests/e2e/test_cli_chunks.py b/tests/e2e/test_cli_chunks.py index db9bfbb..01d17ea 100644 --- a/tests/e2e/test_cli_chunks.py +++ b/tests/e2e/test_cli_chunks.py @@ -5,7 +5,12 @@ import pytest from tests.e2e.cli_helpers import run_kscli_fail, run_kscli_ok -from tests.e2e.conftest import FIRST_CHUNK_ID, NONEXISTENT_UUID +from tests.e2e.conftest import ( + FIRST_CHUNK_ID, + FIRST_SIMPLE_VERSION_ID, + NONEXISTENT_UUID, + SECOND_CHUNK_ID, +) pytestmark = pytest.mark.e2e @@ -41,6 +46,137 @@ def test_create_chunk_requires_parent(self, cli_authenticated: dict[str, str]) - env=cli_authenticated, ) + def test_search_chunks_json(self, cli_authenticated: dict[str, str]) -> None: + """Search chunks returns JSON-serializable dict rows.""" + result = run_kscli_ok( + ["chunks", "search", "--query", "configuration", "--limit", "5"], + env=cli_authenticated, + ) + assert isinstance(result.json_output, list) + if result.json_output: + assert isinstance(result.json_output[0], dict) + + def test_search_chunks_table(self, cli_authenticated: dict[str, str]) -> None: + """Search chunks renders table output without serialization errors.""" + result = run_kscli_ok( + ["chunks", "search", "--query", "configuration", "--limit", "5"], + env=cli_authenticated, + format_json=False, + ) + assert "Error:" not in result.stderr + + def test_search_chunks_yaml(self, cli_authenticated: dict[str, str]) -> None: + """Search chunks supports YAML output mode.""" + result = run_kscli_ok( + ["--format", "yaml", "chunks", "search", "--query", "configuration", "--limit", "5"], + env=cli_authenticated, + format_json=False, + ) + assert "Error:" not in result.stderr + + def test_search_chunks_id_only(self, cli_authenticated: dict[str, str]) -> None: + """Search chunks supports id-only output mode.""" + result = run_kscli_ok( + ["--format", "id-only", "chunks", "search", "--query", "configuration", "--limit", "5"], + env=cli_authenticated, + format_json=False, + ) + assert "Error:" not in result.stderr + + def test_search_chunks_full_text(self, cli_authenticated: dict[str, str]) -> None: + """Search chunks supports full-text search mode.""" + result = run_kscli_ok( + [ + "chunks", + "search", + "--query", + "configuration", + "--search-type", + "full_text", + "--limit", + "5", + ], + env=cli_authenticated, + ) + assert isinstance(result.json_output, list) + + def test_search_chunks_dense_only(self, cli_authenticated: dict[str, str]) -> None: + """Search chunks supports dense semantic search mode.""" + result = run_kscli_ok( + [ + "chunks", + "search", + "--query", + "configuration", + "--search-type", + "dense_only", + "--limit", + "5", + ], + env=cli_authenticated, + ) + assert isinstance(result.json_output, list) + + def test_get_bulk_returns_list(self, cli_authenticated: dict[str, str]) -> None: + """get-bulk returns a list with the requested chunks.""" + result = run_kscli_ok( + [ + "chunks", "get-bulk", + "--chunk-ids", FIRST_CHUNK_ID, + "--chunk-ids", SECOND_CHUNK_ID, + ], + env=cli_authenticated, + ) + chunks = result.json_output + assert isinstance(chunks, list) + ids = {row["id"] for row in chunks} + assert FIRST_CHUNK_ID in ids + assert SECOND_CHUNK_ID in ids + + def test_get_bulk_nonexistent_silently_skipped(self, cli_authenticated: dict[str, str]) -> None: + """get-bulk silently skips non-existent IDs.""" + result = run_kscli_ok( + [ + "chunks", "get-bulk", + "--chunk-ids", NONEXISTENT_UUID, + ], + env=cli_authenticated, + ) + assert result.json_output == [] + + def test_version_chunk_ids_returns_list(self, cli_authenticated: dict[str, str]) -> None: + """version-chunk-ids returns a dict with chunk_ids list.""" + result = run_kscli_ok( + ["chunks", "version-chunk-ids", FIRST_SIMPLE_VERSION_ID], + env=cli_authenticated, + ) + data = result.json_output + assert isinstance(data, dict) + assert "chunk_ids" in data + assert isinstance(data["chunk_ids"], list) + assert FIRST_CHUNK_ID in data["chunk_ids"] + + def test_search_chunks_score_threshold(self, cli_authenticated: dict[str, str]) -> None: + """Search chunks supports score threshold filtering.""" + threshold = 0.5 + result = run_kscli_ok( + [ + "chunks", + "search", + "--query", + "configuration", + "--score-threshold", + str(threshold), + "--limit", + "5", + ], + env=cli_authenticated, + ) + assert isinstance(result.json_output, list) + for row in result.json_output: + if "score" in row: + assert row["score"] >= threshold + class TestCliChunksWrite: """Write chunk tests using isolation_folder.""" @@ -179,3 +315,107 @@ def test_update_chunk_metadata( ], env=cli_authenticated, ) + + def test_search_chunks_parent_path_ids_scopes_results( + self, + cli_authenticated: dict[str, str], + isolation_folder: dict[str, Any], + ) -> None: + """Search chunks supports parent-path scoping.""" + ids = self._create_doc_version_section(cli_authenticated, isolation_folder) + unique_query = "kscli-parent-scope-needle-12345" + run_kscli_ok( + [ + "chunks", + "create", + "--content", + unique_query, + "--section-id", + ids["section_path_part_id"], + "--chunk-type", + "TEXT", + ], + env=cli_authenticated, + ) + + # Full-text indexing is async — we can only verify the call succeeds and + # that parent_path_ids scoping doesn't cause an error. + scoped = run_kscli_ok( + [ + "chunks", + "search", + "--query", + unique_query, + "--search-type", + "full_text", + "--parent-path-ids", + isolation_folder["path_part_id"], + "--no-active-version-only", + "--limit", + "5", + ], + env=cli_authenticated, + ) + assert isinstance(scoped.json_output, list) + + # The API now validates parent-path-ids and returns 404 for unknown IDs. + run_kscli_fail( + [ + "chunks", + "search", + "--query", + unique_query, + "--parent-path-ids", + NONEXISTENT_UUID, + "--limit", + "5", + ], + env=cli_authenticated, + expected_code=3, + ) + + def test_search_chunks_combined_options( + self, + cli_authenticated: dict[str, str], + isolation_folder: dict[str, Any], + ) -> None: + """Search chunks supports combining new search options.""" + ids = self._create_doc_version_section(cli_authenticated, isolation_folder) + unique_query = "kscli-combined-search-options-needle" + run_kscli_ok( + [ + "chunks", + "create", + "--content", + unique_query, + "--section-id", + ids["section_path_part_id"], + "--chunk-type", + "TEXT", + ], + env=cli_authenticated, + ) + + result = run_kscli_ok( + [ + "chunks", + "search", + "--query", + unique_query, + "--search-type", + "dense_only", + "--parent-path-ids", + isolation_folder["path_part_id"], + "--chunk-types", + "TEXT", + "--score-threshold", + "0.0", + "--active-version-only", + "--limit", + "5", + ], + env=cli_authenticated, + ) + assert isinstance(result.json_output, list) + if result.json_output: + assert isinstance(result.json_output[0], dict) diff --git a/tests/e2e/test_cli_folders.py b/tests/e2e/test_cli_folders.py index c17dee0..92743f7 100644 --- a/tests/e2e/test_cli_folders.py +++ b/tests/e2e/test_cli_folders.py @@ -1,6 +1,11 @@ """E2E tests for folder commands.""" -from typing import Any +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from pathlib import Path import pytest @@ -156,6 +161,159 @@ def test_list_folders_pagination_all( ids = [f["id"] for f in items] assert len(ids) == len(set(ids)) + def test_list_folders_with_folder_id_resolves_to_children( + self, cli_authenticated: dict[str, str] + ) -> None: + """Using --folder-id lists subfolders of that folder.""" + result = run_kscli_ok( + [ + "folders", "list", + "--folder-id", SHARED_FOLDER_ID, + ], + env=cli_authenticated, + ) + data = result.json_output + folders = data["items"] + names = [f["name"] for f in folders] + # Should list children of /shared + assert "many" in names + assert "nested" in names + + def test_list_folders_with_folder_id_and_show_content( + self, cli_authenticated: dict[str, str] + ) -> None: + """Using --folder-id with --show-content shows mixed content.""" + result = run_kscli_ok( + [ + "folders", "list", + "--folder-id", SHARED_FOLDER_ID, + "--show-content", + ], + env=cli_authenticated, + ) + data = result.json_output + assert data is not None + # Should return mixed content (folders + documents) + + def test_list_folders_mutual_exclusivity_error( + self, cli_authenticated: dict[str, str] + ) -> None: + """Providing both --folder-id and --parent-path-part-id should error.""" + run_kscli_fail( + [ + "folders", "list", + "--folder-id", SHARED_FOLDER_ID, + "--parent-path-part-id", SHARED_FOLDER_PATH_PART_ID, + ], + env=cli_authenticated, + expected_code=2, + ) + + def test_list_folders_show_content_requires_folder_id( + self, cli_authenticated: dict[str, str] + ) -> None: + """Using --show-content without --folder-id should error.""" + run_kscli_fail( + [ + "folders", "list", + "--show-content", + ], + env=cli_authenticated, + expected_code=2, + ) + + def test_list_folders_max_depth_requires_show_content( + self, cli_authenticated: dict[str, str] + ) -> None: + """Using --max-depth without --show-content should error.""" + run_kscli_fail( + [ + "folders", "list", + "--folder-id", SHARED_FOLDER_ID, + "--max-depth", "2", + ], + env=cli_authenticated, + expected_code=2, + ) + + def test_list_folders_with_sort_order_logical( + self, cli_authenticated: dict[str, str] + ) -> None: + """Test LOGICAL sort order.""" + result = run_kscli_ok( + [ + "folders", "list", + "--folder-id", SHARED_FOLDER_ID, + "--sort-order", "LOGICAL", + ], + env=cli_authenticated, + ) + data = result.json_output + assert isinstance(data, dict) + assert "items" in data + + def test_list_folders_with_sort_order_name( + self, cli_authenticated: dict[str, str] + ) -> None: + """Test NAME sort order.""" + result = run_kscli_ok( + [ + "folders", "list", + "--folder-id", SHARED_FOLDER_ID, + "--sort-order", "NAME", + ], + env=cli_authenticated, + ) + data = result.json_output + assert isinstance(data, dict) + assert "items" in data + + def test_list_folders_with_tags_flag( + self, cli_authenticated: dict[str, str] + ) -> None: + """Test --with-tags flag.""" + result = run_kscli_ok( + [ + "folders", "list", + "--folder-id", SHARED_FOLDER_ID, + "--with-tags", + ], + env=cli_authenticated, + ) + data = result.json_output + assert isinstance(data, dict) + assert "items" in data + + def test_list_folders_invalid_folder_id_returns_404( + self, cli_authenticated: dict[str, str] + ) -> None: + """Using an invalid folder ID should return exit code 3 (404).""" + run_kscli_fail( + [ + "folders", "list", + "--folder-id", NONEXISTENT_UUID, + ], + env=cli_authenticated, + expected_code=3, + ) + + def test_list_folders_backward_compat_parent_path_part_id( + self, cli_authenticated: dict[str, str] + ) -> None: + """Existing --parent-path-part-id usage still works.""" + result = run_kscli_ok( + [ + "folders", "list", + "--parent-path-part-id", SHARED_FOLDER_PATH_PART_ID, + ], + env=cli_authenticated, + ) + data = result.json_output + folders = data["items"] + names = [f["name"] for f in folders] + assert "many" in names + assert "nested" in names + class TestCliFoldersWrite: """Write folder tests using isolation_folder.""" @@ -207,3 +365,82 @@ def test_create_update_delete_folder( env=cli_authenticated, expected_code=3, ) + + def test_bulk_ingest_creates_tree_and_ingests_supported_files( + self, + cli_authenticated: dict[str, str], + isolation_folder: dict[str, Any], + tmp_path: Path, + ) -> None: + """Bulk-ingest mirrors local tree and uploads only supported extensions.""" + parent_path_part_id = isolation_folder["path_part_id"] + + # Build local tree: + # /bulk-src/root.docx + # /bulk-src/alpha/nested/keep.DOCX + # /bulk-src/alpha/nested/skip.txt (unsupported, should be skipped) + local_root = tmp_path / "bulk-src" + nested_dir = local_root / "alpha" / "nested" + nested_dir.mkdir(parents=True) + (local_root / "root.docx").write_bytes(b"fake-docx-root") + (nested_dir / "keep.DOCX").write_bytes(b"fake-docx-nested") + (nested_dir / "skip.txt").write_text("ignore me", encoding="utf-8") + + ingest_result = run_kscli_ok( + [ + "folders", "bulk-ingest", + str(local_root), + "--path-part-id", parent_path_part_id, + "--extensions", ".docx", + ], + env=cli_authenticated, + format_json=False, + ) + assert "Summary:" in ingest_result.stdout + assert "2 folder(s) created" in ingest_result.stdout + assert "2 file(s) ingested" in ingest_result.stdout + assert "1 skipped" in ingest_result.stdout + + top_folders = run_kscli_ok( + [ + "folders", "list", + "--parent-path-part-id", parent_path_part_id, + "--limit", "100", + ], + env=cli_authenticated, + ).json_output["items"] + alpha = next((f for f in top_folders if f["name"] == "alpha"), None) + assert alpha is not None + + alpha_children = run_kscli_ok( + [ + "folders", "list", + "--parent-path-part-id", alpha["path_part_id"], + "--limit", "100", + ], + env=cli_authenticated, + ).json_output["items"] + nested = next((f for f in alpha_children if f["name"] == "nested"), None) + assert nested is not None + + root_docs = run_kscli_ok( + [ + "documents", "list", + "--parent-path-part-id", parent_path_part_id, + "--limit", "100", + ], + env=cli_authenticated, + ).json_output["items"] + assert any(d["name"] == "root.docx" for d in root_docs) + + nested_docs = run_kscli_ok( + [ + "documents", "list", + "--parent-path-part-id", nested["path_part_id"], + "--limit", "100", + ], + env=cli_authenticated, + ).json_output["items"] + nested_doc_names = {d["name"] for d in nested_docs} + assert "keep.DOCX" in nested_doc_names + assert "skip.txt" not in nested_doc_names diff --git a/tests/e2e/test_cli_output_formats.py b/tests/e2e/test_cli_output_formats.py index 4d58d17..6c972cb 100644 --- a/tests/e2e/test_cli_output_formats.py +++ b/tests/e2e/test_cli_output_formats.py @@ -1,6 +1,7 @@ """E2E tests for output format options: --format, --no-header.""" import json +import re import pytest @@ -81,3 +82,74 @@ def test_json_describe_single_item(self, cli_authenticated: dict[str, str]) -> N assert isinstance(result.json_output, dict) assert "name" in result.json_output assert "items" not in result.json_output + + def test_tree_output_shows_folder_content_hierarchy( + self, cli_authenticated: dict[str, str] + ) -> None: + """--format tree renders nested items under folder branches.""" + json_result = run_kscli_ok( + [ + "folders", + "list", + "--folder-id", + SHARED_FOLDER_ID, + "--show-content", + "--max-depth", + "3", + "--limit", + "100", + ], + env=cli_authenticated, + ) + items = json_result.json_output["items"] + + folders_by_path_id = { + item["path_part_id"]: item for item in items if item.get("part_type") == "FOLDER" + } + nested_item = next( + ( + item + for item in items + if item.get("parent_path_part_id") in folders_by_path_id + and item.get("part_type") in {"FOLDER", "DOCUMENT"} + ), + None, + ) + assert nested_item is not None, "Expected at least one nested item in seed data" + parent_folder = folders_by_path_id[nested_item["parent_path_part_id"]] + + tree_result = run_kscli_ok( + [ + "--format", + "tree", + "folders", + "list", + "--folder-id", + SHARED_FOLDER_ID, + "--show-content", + "--max-depth", + "3", + "--limit", + "100", + ], + env=cli_authenticated, + format_json=False, + ) + lines = tree_result.stdout.splitlines() + assert any("├── " in line or "└── " in line for line in lines) + + parent_fragment = f"{parent_folder['name']}/ [folder]" + child_name = ( + f"{nested_item['name']}/" + if nested_item.get("part_type") == "FOLDER" + else nested_item["name"] + ) + child_fragment = f"{child_name} [" + parent_line_index = next( + idx for idx, line in enumerate(lines) if parent_fragment in line + ) + child_line_index = next(idx for idx, line in enumerate(lines) if child_fragment in line) + assert child_line_index > parent_line_index + + child_line = lines[child_line_index] + assert re.match(r"^[│ ]{4}[├└]── ", child_line), child_line diff --git a/tests/e2e/test_cli_settings.py b/tests/e2e/test_cli_settings.py index bb4c273..10f481a 100644 --- a/tests/e2e/test_cli_settings.py +++ b/tests/e2e/test_cli_settings.py @@ -50,4 +50,4 @@ def test_settings_environment_resets_to_local( env=cli_authenticated, format_json=False, ) - assert "localhost:8000" in result.stdout + assert "localhost:18000" in result.stdout diff --git a/tests/e2e/test_cli_threads.py b/tests/e2e/test_cli_threads.py index 5d3398a..9da38dc 100644 --- a/tests/e2e/test_cli_threads.py +++ b/tests/e2e/test_cli_threads.py @@ -5,7 +5,7 @@ import pytest from tests.e2e.cli_helpers import run_kscli_fail, run_kscli_ok -from tests.e2e.conftest import NONEXISTENT_UUID, PWUSER1_THREADS_FOLDER_PATH_PART_ID +from tests.e2e.conftest import NONEXISTENT_UUID pytestmark = pytest.mark.e2e @@ -37,12 +37,12 @@ def test_create_describe_update_delete_thread( cli_authenticated: dict[str, str], ) -> None: """Full CRUD lifecycle for a conversation thread under /users/{id}/threads.""" - # Create under pwuser1's threads folder (conversation threads can be deleted) + # Omit --parent-path-part-id so the API auto-provisions under + # /users/{user_id}/threads/ — only those qualify as conversation threads. result = run_kscli_ok( [ "threads", "create", "--title", "e2e test thread", - "--parent-path-part-id", PWUSER1_THREADS_FOLDER_PATH_PART_ID, ], env=cli_authenticated, ) diff --git a/uv.lock b/uv.lock index 9245d5e..743fd5a 100644 --- a/uv.lock +++ b/uv.lock @@ -11,19 +11,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] -[[package]] -name = "anyio" -version = "4.12.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, -] - [[package]] name = "basedpyright" version = "1.38.1" @@ -75,52 +62,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, ] -[[package]] -name = "h11" -version = "0.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, -] - -[[package]] -name = "httpcore" -version = "1.0.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, -] - -[[package]] -name = "httpx" -version = "0.28.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, -] - -[[package]] -name = "idna" -version = "3.11" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, -] - [[package]] name = "iniconfig" version = "2.3.0" @@ -132,7 +73,7 @@ wheels = [ [[package]] name = "ksapi" -version = "1.25.0" +version = "1.44.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, @@ -140,9 +81,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/51/6b/f57684e2699ee21c89c905bf7aba1b89740d74b7091167b2f9d32a9d209c/ksapi-1.25.0.tar.gz", hash = "sha256:f67b73998fc3b6ea1149c8d724bd6044b5fec28ae051bb385f505f2259a075f5", size = 111997, upload-time = "2026-02-21T14:42:02.349Z" } +sdist = { url = "https://files.pythonhosted.org/packages/37/f7/079d088f51f0a33ed3075c3e9c0b2cc39048d9b037d0a14468459a315b2e/ksapi-1.44.2.tar.gz", hash = "sha256:b44d62c57d0f470b14c0a6befb1da6c1e72d9cca081177105c6108023d2dbed9", size = 126621, upload-time = "2026-03-07T10:18:55.674Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/92/f1/0f2f76ca6909e28acb77941083a34342d2625828e44fba4e10970d2c07e4/ksapi-1.25.0-py3-none-any.whl", hash = "sha256:a6d2480897b7c8658335849e82245ddac908ae781ade137a3a1ce113824f9b29", size = 229551, upload-time = "2026-02-21T14:42:01.261Z" }, + { url = "https://files.pythonhosted.org/packages/01/a2/dbf5c7bbd671e0df82379d20028175c90e13c9b5d540b04532c9445a336d/ksapi-1.44.2-py3-none-any.whl", hash = "sha256:b7d416ab5bf4fca7a7c26cdb9f0876e204ab96d3dc7440a0707d41e41b84490e", size = 263251, upload-time = "2026-03-07T10:18:54.076Z" }, ] [[package]] @@ -152,9 +93,7 @@ source = { editable = "." } dependencies = [ { name = "certifi" }, { name = "click" }, - { name = "httpx" }, { name = "ksapi" }, - { name = "pyjwt" }, { name = "rich" }, ] @@ -170,9 +109,7 @@ dev = [ requires-dist = [ { name = "certifi", specifier = ">=2026.1.4" }, { name = "click", specifier = ">=8.3.1" }, - { name = "httpx", specifier = ">=0.28.1" }, { name = "ksapi", specifier = ">=1.25.0" }, - { name = "pyjwt", specifier = ">=2.11.0" }, { name = "rich", specifier = ">=14.3.3" }, ] @@ -334,15 +271,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] -[[package]] -name = "pyjwt" -version = "2.11.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" }, -] - [[package]] name = "pytest" version = "9.0.2"