From 513b2956caa57c3d1e68c3003fea4f6e13ee13c1 Mon Sep 17 00:00:00 2001 From: "(Frank) Yu Cheng Gu" Date: Sun, 22 Feb 2026 19:20:55 +0800 Subject: [PATCH 1/2] feat: complete release process (#1) --- .github/workflows/workflow.yml | 72 +++++- CLAUDE.md | 16 +- Makefile | 21 ++ README.md | 186 ++++++++++++++++ docs/authentication.md | 147 +++++++++++++ docs/ci.md | 102 +++++++++ docs/configuration.md | 135 ++++++++++++ docs/design_patterns.md | 97 +++++--- docs/e2e-testing.md | 165 ++++++++++++++ pyproject.toml | 15 +- src/kscli/cli.py | 279 ++++++++++-------------- src/kscli/commands/chunk_lineages.py | 92 ++++---- src/kscli/commands/chunks.py | 251 ++++++++++----------- src/kscli/commands/document_versions.py | 118 ++++++++++ src/kscli/commands/documents.py | 267 +++++++++++------------ src/kscli/commands/folders.py | 244 ++++++++++----------- src/kscli/commands/invites.py | 107 ++++----- src/kscli/commands/path_parts.py | 71 +++--- src/kscli/commands/permissions.py | 143 ++++++------ src/kscli/commands/sections.py | 131 +++++------ src/kscli/commands/settings.py | 9 +- src/kscli/commands/tags.py | 158 +++++++------- src/kscli/commands/tenants.py | 177 ++++++++------- src/kscli/commands/thread_messages.py | 60 +++++ src/kscli/commands/threads.py | 216 +++++++----------- src/kscli/commands/users.py | 30 +-- src/kscli/commands/versions.py | 120 ---------- src/kscli/commands/workflows.py | 78 ++++--- src/kscli/config.py | 9 + tests/__init__.py | 0 tests/e2e/__init__.py | 0 tests/e2e/cli_helpers.py | 127 +++++++++++ tests/e2e/conftest.py | 181 +++++++++++++++ tests/e2e/test_cli_auth.py | 65 ++++++ tests/e2e/test_cli_chunk_lineages.py | 108 +++++++++ tests/e2e/test_cli_chunks.py | 181 +++++++++++++++ tests/e2e/test_cli_document_versions.py | 160 ++++++++++++++ tests/e2e/test_cli_documents.py | 217 ++++++++++++++++++ tests/e2e/test_cli_errors.py | 47 ++++ tests/e2e/test_cli_folders.py | 209 ++++++++++++++++++ tests/e2e/test_cli_invites.py | 45 ++++ tests/e2e/test_cli_output_formats.py | 83 +++++++ tests/e2e/test_cli_path_parts.py | 44 ++++ tests/e2e/test_cli_permissions.py | 77 +++++++ tests/e2e/test_cli_sections.py | 104 +++++++++ tests/e2e/test_cli_settings.py | 53 +++++ tests/e2e/test_cli_tags.py | 179 +++++++++++++++ tests/e2e/test_cli_tenants.py | 51 +++++ tests/e2e/test_cli_thread_messages.py | 111 ++++++++++ tests/e2e/test_cli_threads.py | 110 ++++++++++ tests/e2e/test_cli_users.py | 24 ++ tests/e2e/test_cli_workflows.py | 26 +++ uv.lock | 24 ++ 53 files changed, 4406 insertions(+), 1336 deletions(-) create mode 100644 docs/authentication.md create mode 100644 docs/ci.md create mode 100644 docs/configuration.md create mode 100644 docs/e2e-testing.md create mode 100644 src/kscli/commands/document_versions.py create mode 100644 src/kscli/commands/thread_messages.py delete mode 100644 src/kscli/commands/versions.py create mode 100644 tests/__init__.py create mode 100644 tests/e2e/__init__.py create mode 100644 tests/e2e/cli_helpers.py create mode 100644 tests/e2e/conftest.py create mode 100644 tests/e2e/test_cli_auth.py create mode 100644 tests/e2e/test_cli_chunk_lineages.py create mode 100644 tests/e2e/test_cli_chunks.py create mode 100644 tests/e2e/test_cli_document_versions.py create mode 100644 tests/e2e/test_cli_documents.py create mode 100644 tests/e2e/test_cli_errors.py create mode 100644 tests/e2e/test_cli_folders.py create mode 100644 tests/e2e/test_cli_invites.py create mode 100644 tests/e2e/test_cli_output_formats.py create mode 100644 tests/e2e/test_cli_path_parts.py create mode 100644 tests/e2e/test_cli_permissions.py create mode 100644 tests/e2e/test_cli_sections.py create mode 100644 tests/e2e/test_cli_settings.py create mode 100644 tests/e2e/test_cli_tags.py create mode 100644 tests/e2e/test_cli_tenants.py create mode 100644 tests/e2e/test_cli_thread_messages.py create mode 100644 tests/e2e/test_cli_threads.py create mode 100644 tests/e2e/test_cli_users.py create mode 100644 tests/e2e/test_cli_workflows.py diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index a6bac77..9897398 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -45,13 +45,75 @@ jobs: - name: Run type checker run: make typecheck + e2e: + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' || !startsWith(github.event.head_commit.message, 'chore(release):') + steps: + - name: Generate GitHub App token + uses: actions/create-github-app-token@v1 + id: app-token + with: + app-id: ${{ secrets.RELEASE_GH_APP_ID }} + private-key: ${{ secrets.RELEASE_GH_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ks-backend + + - name: Checkout ks-cli + uses: actions/checkout@v6 + with: + path: ks-cli + + - name: Checkout ks-backend + uses: actions/checkout@v6 + with: + repository: knowledgestack/ks-backend + token: ${{ steps.app-token.outputs.token }} + path: ks-backend + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + version: "latest" + enable-cache: true + cache-dependency-glob: | + ks-backend/uv.lock + ks-cli/uv.lock + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.14" + + - name: Install ks-backend dependencies + working-directory: ks-backend + run: make install-dev + + - name: Create shared email directory + run: mkdir -p /tmp/ks/e2e-testing/emails && chmod 777 /tmp/ks/e2e-testing/emails + + - name: Start e2e stack + working-directory: ks-backend + run: make e2e-stack + + - name: Seed database + working-directory: ks-backend + run: make e2e-prep + + - name: Install ks-cli dependencies + working-directory: ks-cli + run: uv sync --all-extras --group dev + + - name: Run e2e tests + working-directory: ks-cli + run: make e2e-test + release: runs-on: ubuntu-latest environment: "production" concurrency: group: release cancel-in-progress: false - needs: [lint] + needs: [lint, e2e] if: github.ref == 'refs/heads/main' && github.event_name == 'push' permissions: contents: write @@ -113,12 +175,6 @@ jobs: echo "has_dist=false" >> $GITHUB_OUTPUT fi - - name: Publish to Test PyPI + - name: Publish to PyPI if: steps.check-dist.outputs.has_dist == 'true' uses: pypa/gh-action-pypi-publish@release/v1 - with: - repository-url: https://test.pypi.org/legacy/ - - # - name: Publish to PyPI - # if: steps.check-dist.outputs.has_dist == 'true' - # uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/CLAUDE.md b/CLAUDE.md index b5726ee..7163dfd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -`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 `, `kscli create chunk ...`). +`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 **resource-first routing pattern** (e.g. `kscli folders list`, `kscli folders describe `, `kscli chunks create ...`). ## Commands @@ -34,17 +34,19 @@ make pre-commit ## Architecture -### Verb-first CLI routing (`src/kscli/cli.py`) +### Resource-first CLI routing (`src/kscli/cli.py`) -The CLI uses verb groups (`get`, `describe`, `create`, `update`, `delete`, `search`, `ingest`, `attach`, `detach`, `accept`, `action`) as top-level subcommands. Each resource module registers its commands onto these verb groups via `register_(group)` functions — e.g. `folders.register_get(get)` adds the `folders` subcommand to `kscli get`. This is wired up in `cli.py`. +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 verb groups: `assume-user`, `whoami`, `settings`. +Top-level commands outside resource groups: `assume-user`, `whoami`, `settings`. + +Resource groups: `folders`, `documents`, `document-versions`, `sections`, `chunks`, `tags`, `workflows`, `tenants`, `users`, `permissions`, `invites`, `threads`, `thread-messages`, `chunk-lineages`, `path-parts`. ### Resource command modules (`src/kscli/commands/`) Each resource (folders, documents, chunks, etc.) follows the same pattern: -- Define a `register_(group)` function per supported CRUD verb -- Inside each register function, define the Click command with `@group.command("resource-name")` +- Define a `@click.group("resource-name")` at module level +- Add verb subcommands via `@group.command("verb")` (e.g. `list`, `describe`, `create`, `update`, `delete`) - Use `get_api_client(ctx)` to get an authenticated `ksapi.ApiClient` - Wrap API calls in `with handle_client_errors():` - Pass results through `to_dict(result)` → `print_result(ctx, data, columns=COLUMNS)` @@ -63,7 +65,7 @@ JWT-based auth via admin impersonation. `assume_user()` calls `/v1/auth/assume_u 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`. -Environment presets: `local` (localhost:8000), `dev` (api-staging.knowledgestack.ai), `prod` (api.knowledgestack.ai) — set via `kscli settings environment `. +Environment presets: `local` (localhost:8000), `prod` (api.knowledgestack.ai) — set via `kscli settings environment `. ### Output formatting (`src/kscli/output.py`) diff --git a/Makefile b/Makefile index 46c9b7e..7d432f6 100644 --- a/Makefile +++ b/Makefile @@ -24,5 +24,26 @@ lint: ## Run the linter typecheck: ## Run the type checker @uv run basedpyright --stats +.PHONY: test +test: ## Run unit tests + @uv run pytest tests/ -v --ignore=tests/e2e + +.PHONY: wait-for-api +wait-for-api: ## Wait for the e2e API to be ready + @echo "Waiting for API at http://localhost:28000..." + @for i in $$(seq 1 120); do \ + if curl -sf http://localhost:28000/healthz > /dev/null 2>&1; then \ + echo "API is ready"; \ + exit 0; \ + fi; \ + sleep 1; \ + done; \ + echo "Timed out waiting for API after 120s"; \ + exit 1 + +.PHONY: e2e-test +e2e-test: wait-for-api ## Run e2e tests (requires running backend) + @uv run pytest tests/e2e/ -v -m e2e -n 2 + .PHONY: pre-commit pre-commit: lint typecheck test ## Run linting, typechecking, and tests \ No newline at end of file diff --git a/README.md b/README.md index e69de29..e0c1321 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,186 @@ +# kscli + +CLI tool for the [Knowledge Stack](https://knowledgestack.ai) platform. Wraps the auto-generated `ksapi` Python SDK with a Click-based command interface using a resource-first routing pattern. + +``` +kscli folders list +kscli folders describe +kscli documents create --name "My Doc" --parent-path-part-id +kscli chunks search --query "semantic search" --folder-id +``` + +## Installation + +Requires Python 3.12+ and [uv](https://docs.astral.sh/uv/). + +```bash +# From PyPI +uv tool install kscli + +# From source +git clone https://github.com/knowledgestack/ks-cli.git +cd ks-cli +uv sync --all-extras --group dev +``` + +## Quick Start + +### 1. Configure the environment + +```bash +# Point at a running Knowledge Stack instance +kscli settings environment local # http://localhost:8000 +kscli settings environment prod # https://api.knowledgestack.ai +``` + +Or set environment variables directly: + +```bash +export KSCLI_BASE_URL=http://localhost:8000 +export ADMIN_API_KEY=your-admin-key +``` + +### 2. Authenticate + +```bash +kscli assume-user --tenant-id --user-id +``` + +This obtains a JWT via the `/v1/auth/assume_user` admin endpoint and caches it locally. The token auto-refreshes on expiry. See [docs/authentication.md](docs/authentication.md) for details. + +### 3. Use the CLI + +```bash +# Verify identity +kscli whoami + +# Browse folders +kscli folders list +kscli folders list --format tree + +# Work with documents +kscli documents list --folder-id +kscli documents describe +kscli documents ingest --name "report.pdf" --file ./report.pdf --parent-path-part-id + +# Search chunks +kscli chunks search --query "quarterly revenue" --folder-id +``` + +## Commands + +### Top-level + +| Command | Description | +|---------|-------------| +| `assume-user` | Authenticate as a specific user via admin impersonation | +| `whoami` | Show current authenticated identity | +| `settings environment ` | Set environment preset (local/prod) | +| `settings show` | Print resolved configuration | + +### Resource groups + +Each resource group supports a subset of verbs (`list`, `describe`, `create`, `update`, `delete`, and resource-specific actions): + +| Resource | Verbs | +|----------|-------| +| `folders` | list, describe, create, update, delete | +| `documents` | list, describe, create, update, delete, ingest | +| `document-versions` | list, describe, create, update, delete, contents, clear-contents | +| `sections` | describe, create, update, delete | +| `chunks` | describe, create, update, update-content, delete, search | +| `tags` | list, describe, create, update, delete, attach, detach | +| `workflows` | list, describe, cancel, rerun | +| `tenants` | list, describe, create, update, delete, list-users | +| `users` | update | +| `permissions` | list, create, update, delete | +| `invites` | list, create, delete, accept | +| `threads` | list, describe, create, update, delete | +| `thread-messages` | list, describe, create | +| `chunk-lineages` | describe, create, delete | +| `path-parts` | list, describe | + +## Output Formats + +Control output with `--format` / `-f` (can appear anywhere in the command): + +```bash +kscli folders list --format json # JSON output +kscli folders list -f yaml # YAML output +kscli folders list -f table # Rich table (default) +kscli folders list -f tree # Tree view for hierarchical data +kscli folders list -f id-only # Just IDs, one per line (useful for piping) +kscli folders list --no-header # Suppress table headers +``` + +The default format can be set via `KSCLI_FORMAT` env var or `kscli settings`. + +## Configuration + +Configuration resolves in order: **CLI flags > environment variables > config file > defaults**. + +| Environment Variable | Description | Default | +|---------------------|-------------|---------| +| `KSCLI_BASE_URL` | API base URL | `http://localhost:8000` | +| `ADMIN_API_KEY` | Admin API key for authentication | _(required)_ | +| `KSCLI_FORMAT` | Default output format | `table` | +| `KSCLI_VERIFY_SSL` | Enable SSL verification | `true` | +| `KSCLI_CA_BUNDLE` | Path to custom CA certificate bundle | _(system default)_ | +| `KSCLI_CONFIG` | Config file path | `~/.config/kscli/config.json` | +| `KSCLI_CREDENTIALS_PATH` | Credentials file path | `/tmp/kscli/.credentials` | + +See [docs/configuration.md](docs/configuration.md) for the full configuration reference. + +## Development + +```bash +# Install dev dependencies +make install-dev + +# Lint +make lint + +# Lint + autofix +make fix + +# Type check +make typecheck + +# Run unit tests +make test + +# Run full pre-commit checks (lint + typecheck + tests) +make pre-commit +``` + +### Running E2E Tests + +E2E tests require a running `ks-backend` instance. See [docs/e2e-testing.md](docs/e2e-testing.md) for the full guide. + +```bash +# Quick start (with ks-backend checked out alongside ks-cli): +cd ../ks-backend +make e2e-stack # Start Docker stack (postgres, API, worker) +make e2e-prep # Seed database + +cd ../ks-cli +make e2e-test # Waits for API readiness, then runs tests +``` + +## CI/CD + +The GitHub Actions pipeline (`.github/workflows/workflow.yml`) runs three jobs: + +1. **lint** — ruff + basedpyright +2. **e2e** — spins up the ks-backend Docker stack, seeds data, runs CLI e2e tests +3. **release** — semantic-release to PyPI (gated on both lint and e2e passing) + +See [docs/ci.md](docs/ci.md) for pipeline details. + +## Documentation + +- [Authentication](docs/authentication.md) — Auth flow, credential caching, token refresh +- [Configuration](docs/configuration.md) — Environment variables, config file, presets +- [E2E Testing](docs/e2e-testing.md) — Running and writing e2e tests +- [CI/CD Pipeline](docs/ci.md) — GitHub Actions workflow details +- [Design Patterns](docs/design_patterns.md) — Architecture and code patterns diff --git a/docs/authentication.md b/docs/authentication.md new file mode 100644 index 0000000..b3bdf6c --- /dev/null +++ b/docs/authentication.md @@ -0,0 +1,147 @@ +# Authentication + +kscli uses JWT-based authentication via admin impersonation. An admin API key is used to obtain a short-lived JWT token for a specific user/tenant pair, and the token is cached locally for reuse. + +## Auth Flow + +``` + ┌──────────────┐ + kscli assume-user │ ks-backend │ + --tenant-id T │ │ + --user-id U ──────────> │ POST │ + X-Admin- │ /v1/auth/ │ + Api-Key │ assume_user │ + └──────┬───────┘ + │ { "token": "" } + v + ┌─────────────────────┐ + │ ~/.credentials file │ + │ (0600 permissions) │ + │ │ + │ token, user_id, │ + │ tenant_id, │ + │ expires_at, │ + │ admin_api_key │ + └─────────┬───────────┘ + │ + subsequent commands │ auto-loaded + v + ┌─────────────────────┐ + │ ksapi.ApiClient │ + │ cookie: ks_uat=JWT │ + └─────────────────────┘ +``` + +## Commands + +### `assume-user` + +Authenticates as a specific user in a specific tenant. + +```bash +kscli assume-user --tenant-id --user-id +``` + +**What it does** (`src/kscli/commands/auth.py:19-25`): + +1. Reads `ADMIN_API_KEY` from env or config (`src/kscli/config.py:33-42`) +2. Sends `POST /v1/auth/assume_user` with `X-Admin-Api-Key` header (`src/kscli/auth.py:26-34`) +3. Decodes the JWT (without verification) to extract the expiration claim (`src/kscli/auth.py:43`) +4. Writes the credentials file with `0600` permissions (`src/kscli/auth.py:46-57`) + +**Output:** + +``` +Authenticated as user 00000000-... in tenant 00000000-... +Token expires: 2026-02-23T12:00:00+00:00 +``` + +### `whoami` + +Shows the currently authenticated identity by calling the `/users/me` API endpoint. + +```bash +kscli whoami +``` + +**Output** (table format): + +``` +┌─────────────┬──────────────────────────────────────┐ +│ Key │ Value │ +├─────────────┼──────────────────────────────────────┤ +│ id │ 00000000-0000-0000-0001-000000000001 │ +│ email │ user@example.com │ +│ tenant_id │ 00000000-0000-0000-0002-000000000001 │ +│ expires_at │ 2026-02-23T12:00:00+00:00 │ +└─────────────┴──────────────────────────────────────┘ +``` + +## Credential Caching + +Credentials are stored at `/tmp/kscli/.credentials` by default (override with `KSCLI_CREDENTIALS_PATH`). + +**File format** (JSON): + +```json +{ + "token": "", + "user_id": "00000000-...", + "tenant_id": "00000000-...", + "expires_at": "2026-02-23T12:00:00+00:00", + "admin_api_key": "your-admin-key" +} +``` + +The `admin_api_key` is persisted alongside the token so that automatic token refresh works without requiring the env var to be set on every invocation. + +## Auto-Refresh + +When any command calls `get_api_client()` (`src/kscli/client.py:38-50`), it triggers `load_credentials()` (`src/kscli/auth.py:60-80`) which: + +1. Loads the credentials file +2. Compares `expires_at` against the current UTC time +3. If expired, calls `assume_user()` again using the cached `admin_api_key`, `tenant_id`, and `user_id` +4. Returns the refreshed credentials transparently + +This means long-running scripts or interactive sessions never break due to token expiry. + +## Error Handling + +Authentication-related errors produce specific exit codes (`src/kscli/client.py:24-30`): + +| HTTP Status | Message | Exit Code | +|-------------|---------|-----------| +| 401 | `Session expired. Run: kscli assume-user ...` | 2 | +| 403 | `Permission denied` | 1 | + +If you see exit code 2, re-run `assume-user` to re-authenticate: + +```bash +kscli assume-user --tenant-id --user-id +``` + +## TLS / SSL + +For environments using self-signed certificates or corporate proxies: + +```bash +# Use a custom CA bundle +export KSCLI_CA_BUNDLE=/path/to/ca-bundle.crt + +# Disable SSL verification (development only) +export KSCLI_VERIFY_SSL=false +``` + +SSL configuration is resolved in `get_tls_config()` (`src/kscli/config.py:63-83`) and applied when building the API client (`src/kscli/client.py:43-48`). + +If SSL verification fails, the CLI prints troubleshooting steps (`src/kscli/client.py:88-98`): + +``` +Error: SSL certificate verification failed. + +Solutions: +1. Install Python certificates (macOS): Run 'Install Certificates.command' +2. Set KSCLI_CA_BUNDLE to your custom CA bundle path +3. (Insecure) Set KSCLI_VERIFY_SSL=false for development +``` diff --git a/docs/ci.md b/docs/ci.md new file mode 100644 index 0000000..d9f07c0 --- /dev/null +++ b/docs/ci.md @@ -0,0 +1,102 @@ +# CI/CD Pipeline + +The GitHub Actions workflow (`.github/workflows/workflow.yml`) runs on every pull request and push to `main`. It has three jobs: **lint**, **e2e**, and **release**. + +## Pipeline Overview + +``` + PR / push to main + │ + ├──────────────────┐ + v v + ┌────────┐ ┌────────┐ + │ lint │ │ e2e │ + │ │ │ │ + │ ruff │ │ docker │ + │ pyright│ │ seed │ + └───┬────┘ │ pytest │ + │ └───┬────┘ + │ │ + └────────┬────────┘ + v + ┌───────────┐ + │ release │ (main only) + │ │ + │ semantic- │ + │ release │ + │ → PyPI │ + └───────────┘ +``` + +## Jobs + +### lint (`.github/workflows/workflow.yml:11-46`) + +Runs on every PR and push to main. Skipped for `chore(release):` commits. + +| Step | Command | +|------|---------| +| Install deps | `uv sync --all-extras --group dev` | +| Lint | `make lint` (ruff check) | +| Type check | `make typecheck` (basedpyright) | + +Caches both the uv package cache (`cache-dependency-glob: "uv.lock"`) and the `.venv` directory (`actions/cache` keyed on `uv.lock`). + +### e2e (`.github/workflows/workflow.yml:48-108`) + +Runs in parallel with `lint`. Spins up the full ks-backend stack and runs CLI e2e tests. + +**Directory layout** in `$GITHUB_WORKSPACE`: + +``` +├── ks-cli/ # this repo +└── ks-backend/ # checked out via GitHub App token +``` + +This layout matches the path resolution in `tests/e2e/conftest.py:21`: + +```python +_KS_BACKEND_ENV_E2E = Path(__file__).resolve().parents[3] / "ks-backend" / ".env.e2e" +``` + +| Step | Working Dir | Details | +|------|-------------|---------| +| Generate GitHub App token | — | `actions/create-github-app-token@v1` scoped to `ks-backend` repo | +| Checkout ks-cli | — | `actions/checkout@v6` into `ks-cli/` | +| Checkout ks-backend | — | `actions/checkout@v6` with app token into `ks-backend/` | +| Setup uv + Python 3.14 | — | uv cache keyed on both `ks-backend/uv.lock` and `ks-cli/uv.lock` | +| Install backend deps | `ks-backend` | `make install-dev` | +| Create email directory | — | `mkdir -p /tmp/ks/e2e-testing/emails` (required by invite tests) | +| Start e2e stack | `ks-backend` | `make e2e-stack` (postgres + API + worker containers) | +| Seed database | `ks-backend` | `make e2e-prep` (create DB, migrations, seed data) | +| Install CLI deps | `ks-cli` | `uv sync --all-extras --group dev` | +| Run e2e tests | `ks-cli` | `make e2e-test` (waits for API, then runs pytest with 2 workers) | + +**uv cache strategy**: The `setup-uv` action caches the uv package download cache keyed on both lock files (`workflow.yml:78-80`). Since ks-backend's dependency set is a superset of ks-cli's, a warm cache means both `uv sync` calls resolve entirely from local cache with no network downloads. + +### release (`.github/workflows/workflow.yml:110-181`) + +Runs **only** on pushes to `main`, gated on both `lint` and `e2e` passing (`needs: [lint, e2e]`). + +| Step | Details | +|------|---------| +| Generate token | GitHub App token for git push and release publishing | +| Checkout | `main` branch with full history (`fetch-depth: 0`) | +| Semantic release | `python-semantic-release` reads conventional commits, bumps version, creates changelog and GitHub release | +| Publish | Uploads wheel + sdist to PyPI via `pypa/gh-action-pypi-publish` (if artifacts exist) | + +**Concurrency**: Only one release job runs at a time (`concurrency.group: release`). + +**Permissions**: Requires `contents: write` (for git tags/releases) and `id-token: write` (for PyPI trusted publishing). + +## Secrets + +CI uses a GitHub App for authentication. The required secrets are configured as GitHub repository secrets. + +## Skip Conditions + +All jobs skip runs triggered by `chore(release):` commit messages (created by semantic-release itself) to avoid infinite loops: + +```yaml +if: github.event_name == 'pull_request' || !startsWith(github.event.head_commit.message, 'chore(release):') +``` diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..fbffef6 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,135 @@ +# Configuration + +kscli resolves configuration from multiple sources in strict precedence order: + +**CLI flags > environment variables > config file > defaults** + +This is implemented per-setting in `src/kscli/config.py` — each getter function checks sources in order. + +## Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `KSCLI_BASE_URL` | API base URL | `http://localhost:8000` | +| `ADMIN_API_KEY` | Admin API key for `assume-user` authentication | _(required)_ | +| `KSCLI_FORMAT` | Default output format (`table`, `json`, `yaml`, `id-only`, `tree`) | `table` | +| `KSCLI_VERIFY_SSL` | Enable SSL certificate verification (`true`/`false`) | `true` | +| `KSCLI_CA_BUNDLE` | Path to custom CA certificate bundle | _(system default via certifi)_ | +| `KSCLI_CONFIG` | Path to config file | `~/.config/kscli/config.json` | +| `KSCLI_CREDENTIALS_PATH` | Path to credentials cache file | `/tmp/kscli/.credentials` | + +## Config File + +Default location: `~/.config/kscli/config.json` (override with `KSCLI_CONFIG`). + +Created automatically on first run (`src/kscli/config.py:86-92`). Example: + +```json +{ + "environment": "prod", + "base_url": "https://api.knowledgestack.ai", + "verify_ssl": true, + "admin_api_key": "your-admin-key", + "format": "json", + "ca_bundle": "/path/to/ca-bundle.crt" +} +``` + +All fields are optional. Missing fields fall through to environment variables or defaults. + +## Global CLI Flags + +These flags can appear **anywhere** in the command (not just before the subcommand), thanks to `GlobalOptionsGroup` (`src/kscli/cli.py:41-95`): + +| Flag | Description | +|------|-------------| +| `--format` / `-f` | Output format for this command | +| `--no-header` | Suppress table headers | +| `--base-url` | Override API base URL for this command | + +```bash +# These are equivalent: +kscli --format json folders list +kscli folders list --format json +kscli folders --format json list +``` + +## Environment Presets + +The `settings environment` command sets multiple config values at once (`src/kscli/commands/settings.py:17-33`): + +```bash +kscli settings environment local # http://localhost:8000, verify_ssl=false +kscli settings environment prod # https://api.knowledgestack.ai, verify_ssl=true +``` + +You can override the base URL for a preset: + +```bash +kscli settings environment prod --base-url https://custom.example.com +``` + +Presets write to the config file. The specific values (`src/kscli/commands/settings.py:17-28`): + +| Preset | `base_url` | `verify_ssl` | +|--------|-----------|--------------| +| `local` | `http://localhost:8000` | `false` | +| `prod` | `https://api.knowledgestack.ai` | `true` | + +## Viewing Current Config + +```bash +kscli settings show +``` + +Displays the fully resolved configuration — the merged result of all sources (`src/kscli/commands/settings.py:60-82`): + +``` +┌──────────────┬──────────────────────────────────────────────┐ +│ Key │ Value │ +├──────────────┼──────────────────────────────────────────────┤ +│ config_file │ /Users/you/.config/kscli/config.json │ +│ base_url │ https://api.knowledgestack.ai │ +│ verify_ssl │ True │ +│ ca_bundle │ (default) │ +│ format │ table │ +│ environment │ prod │ +│ admin_api_key│ (set) │ +└──────────────┴──────────────────────────────────────────────┘ +``` + +## Resolution Details + +### `base_url` (`src/kscli/config.py:45-52`) + +1. `--base-url` CLI flag (stored in Click context) +2. `KSCLI_BASE_URL` environment variable +3. `base_url` field in config file +4. Default: `http://localhost:8000` + +### `admin_api_key` (`src/kscli/config.py:33-42`) + +1. `ADMIN_API_KEY` environment variable +2. `admin_api_key` field in config file +3. Error if neither is set + +### `format` (`src/kscli/config.py:55-60`) + +1. `--format` / `-f` CLI flag +2. `KSCLI_FORMAT` environment variable +3. `format` field in config file +4. Default: `table` + +### TLS settings (`src/kscli/config.py:63-83`) + +**verify_ssl:** + +1. `KSCLI_VERIFY_SSL` environment variable (`true`/`1`/`yes`) +2. `verify_ssl` field in config file +3. Default: `true` + +**ca_bundle:** + +1. `KSCLI_CA_BUNDLE` environment variable +2. `ca_bundle` field in config file +3. Default: system certificates via `certifi` diff --git a/docs/design_patterns.md b/docs/design_patterns.md index 72317c8..ec8b23b 100644 --- a/docs/design_patterns.md +++ b/docs/design_patterns.md @@ -2,25 +2,30 @@ This document catalogs the design patterns used throughout `ks-cli`. It is intended for anyone who wants to understand the tool at a glance or needs to maintain or extend it. -## Verb-First Command Routing +## Resource-First Command Routing -The CLI organizes commands as **verb then resource** rather than resource then verb. Users type `kscli get folders` or `kscli describe document `, not `kscli folders get`. +The CLI organizes commands as **resource then verb**. Users type `kscli folders list`, `kscli folders describe `, or `kscli chunks search --query "..."`. -The root Click group in `src/kscli/cli.py` defines a fixed set of verb groups — `get`, `describe`, `create`, `update`, `delete`, `search`, `ingest`, `attach`, `detach`, `accept`, and `action`. Each verb group is empty on its own; resource modules populate them at import time. +The root Click group in `src/kscli/cli.py` registers resource groups as top-level subcommands (`cli.py:126-140`). Each resource module in `src/kscli/commands/` defines a `@click.group("resource-name")` with verb subcommands attached via `@group.command("verb")`. -This means the set of verbs is stable and centrally controlled, while the set of resources under each verb is open for extension. Adding a new resource never requires touching verb definitions, and adding a new verb is a single change in `cli.py` that all resources can opt into. +Top-level commands that aren't resource groups: `assume-user`, `whoami`, `settings` (`cli.py:120-122`). -## Decentralized Command Registration +Resource groups: `folders`, `documents`, `document-versions`, `sections`, `chunks`, `tags`, `workflows`, `tenants`, `users`, `permissions`, `invites`, `threads`, `thread-messages`, `chunk-lineages`, `path-parts`. -Each resource module in `src/kscli/commands/` exposes one or more `register_()` functions. These accept a Click group and attach a command to it. +Adding a new resource means creating a module in `src/kscli/commands/`, defining a Click group with verb commands, and adding one `main.add_command()` line in `cli.py`. -For example, a module supporting full CRUD would expose `register_get`, `register_describe`, `register_create`, `register_update`, and `register_delete`. A read-only resource might expose only `register_get` and `register_describe`. Resources with special operations expose additional registrations like `register_search`, `register_ingest`, `register_attach`, or `register_action`. +## Position-Independent Global Options -The wiring happens in `cli.py`, where every resource's registration functions are called against the appropriate verb groups. This keeps each module self-contained — it defines its own Click options, arguments, help text, and column lists — while the central file controls which verbs each resource participates in. +Global options (`--format`, `--no-header`, `--base-url`) can appear **anywhere** in the command, not just before the subcommand. This is implemented via `GlobalOptionsGroup` (`src/kscli/cli.py:41-95`), a custom `click.Group` subclass that extracts known options from the argument list before Click's normal parsing. -## Naming Conventions for Singular vs. Plural +```bash +# All equivalent: +kscli --format json folders list +kscli folders list --format json +kscli folders --format json list +``` -Commands that operate on collections use the **plural** resource name (`kscli get folders`, `kscli search chunks`). Commands that operate on a single resource use the **singular** name (`kscli describe folder `, `kscli delete chunk `). This convention is consistent across all resource modules and acts as a quick signal to the user about whether the command expects an ID argument. +The extracted values are stored in `ctx.obj` and carried through the Click context to all subcommands. ## Thin SDK Wrapper @@ -28,52 +33,88 @@ Command functions are intentionally thin. They parse CLI arguments, build an SDK The `ksapi` package is auto-generated from an OpenAPI spec and treated as an external dependency. The CLI never patches, subclasses, or extends SDK types. +A typical command follows this pattern (from any module in `src/kscli/commands/`): + +```python +@resource_group.command("list") +@click.option("--folder-id", required=True, type=click.UUID) +@click.pass_context +def list_items(ctx, folder_id): + """List items in a folder.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.SomeApi(api_client) + result = api.list_items(folder_id=str(folder_id)) + print_result(ctx, to_dict(result), columns=COLUMNS) +``` + ## Centralized Error Handling via Context Manager -All SDK calls are wrapped in a `handle_client_errors()` context manager defined in `src/kscli/client.py`. This context manager catches `ksapi.ApiException`, SSL errors, and connection failures, then translates them into user-facing error messages and deterministic exit codes. +All SDK calls are wrapped in a `handle_client_errors()` context manager (`src/kscli/client.py:68-85`). This context manager catches `ksapi.ApiException`, SSL errors, and connection failures, then translates them into user-facing error messages and deterministic exit codes. + +The exit code mapping is intentional and stable (`src/kscli/client.py:24-30`): -The exit code mapping is intentional and stable: 401 maps to exit code 2, 404 to 3, 422 to 4, and all other errors to 1. This allows callers and scripts to branch on exit codes without parsing stderr. +| HTTP Status | Exit Code | Message | +|-------------|-----------|---------| +| 401 | 2 | Session expired. Run: kscli assume-user ... | +| 403 | 1 | Permission denied | +| 404 | 3 | Not found | +| 409 | 1 | Conflict | +| 422 | 4 | Validation error | +| Other | 1 | Server error: {status} | -By centralizing this in a context manager rather than per-command try/except blocks, every command gets consistent error behavior automatically. +This allows scripts to branch on exit codes without parsing stderr. ## SDK Model-to-Dict Conversion Pipeline -SDK response objects are not passed directly to output formatters. Instead, every command passes its result through `to_dict()` in `src/kscli/client.py`, which converts SDK models into plain Python dicts and lists. The output layer (`src/kscli/output.py`) then receives only standard Python data structures. +SDK response objects are not passed directly to output formatters. Every command passes its result through `to_dict()` (`src/kscli/client.py:101-109`), which converts SDK models into plain Python dicts and lists. The output layer (`src/kscli/output.py`) then receives only standard Python data structures. -This creates a clean boundary: the output layer has no knowledge of SDK types, and the command layer has no knowledge of formatting. It also means the output formatters are reusable for any data source, not just the SDK. +This creates a clean boundary: the output layer has no knowledge of SDK types, and the command layer has no knowledge of formatting. ## Strategy-Based Output Formatting -The `print_result()` function in `src/kscli/output.py` dispatches to one of five formatters based on the `--format` flag: `table`, `json`, `yaml`, `id-only`, or `tree`. The format is resolved once at the root group level and stored in the Click context, so individual commands never need to handle formatting logic. +The `print_result()` function (`src/kscli/output.py:16-36`) dispatches to one of five formatters based on the `--format` flag: + +| Format | Renderer | Description | +|--------|----------|-------------| +| `table` | Rich `Table` | Structured terminal tables (default) | +| `json` | `json.dumps` | Machine-readable JSON with `indent=2` | +| `yaml` | Custom renderer | Lightweight YAML without pyyaml dependency (`output.py:43-75`) | +| `id-only` | ID extractor | Just the `id` field, one per line — useful for piping | +| `tree` | ASCII tree | Hierarchical view for folder contents and path parts (`output.py:90-167`) | -The `table` formatter uses Rich for structured terminal tables. The `json` and `yaml` formatters produce machine-readable output. The `id-only` formatter extracts just the `id` field, which is useful for piping into other commands. The `tree` formatter renders hierarchical data (like folder contents) as an ASCII tree, auto-detecting whether the data uses depth-based or flat structure. +The format is resolved once at the root group level and stored in the Click context, so individual commands never need to handle formatting logic. -Each command can optionally pass a `columns` list to control which fields appear in table mode, but the formatter itself decides how to render them. +Each command can pass a `columns` list to control which fields appear in table mode, but the formatter decides how to render them. ## Layered Configuration Resolution Configuration in `src/kscli/config.py` follows a strict precedence chain: **CLI flags** override **environment variables**, which override the **config file** (`~/.config/kscli/config.json`), which overrides **hardcoded defaults**. -This is implemented per-setting rather than as a generic framework — each getter function (e.g., `get_base_url`, `get_default_format`, `get_tls_config`) checks the sources in order. The config file path itself is overridable via the `KSCLI_CONFIG` environment variable. +This is implemented per-setting — each getter function (`get_base_url`, `get_default_format`, `get_tls_config`) checks the sources in order. See [configuration.md](configuration.md) for the full reference. -Environment presets (`local`, `dev`, `prod`) bundle multiple settings together and are persisted to the config file via `kscli settings environment `, giving users a one-command way to switch contexts. +Environment presets (`local`, `prod`) bundle multiple settings together and are persisted to the config file via `kscli settings environment ` (`src/kscli/commands/settings.py:17-28`). ## Auto-Refreshing Credential Cache -Authentication tokens are cached to a local file (default: `/tmp/kscli/.credentials`). When `load_credentials()` is called, it checks the JWT's expiration claim. If the token has expired, it transparently re-authenticates using the cached admin API key, tenant ID, and user ID — no user intervention required. +Authentication tokens are cached to a local file (default: `/tmp/kscli/.credentials`). When `load_credentials()` is called (`src/kscli/auth.py:60-80`), it checks the JWT's expiration claim. If the token has expired, it transparently re-authenticates using the cached admin API key, tenant ID, and user ID. -This means long-running scripts or interactive sessions do not break due to token expiry, and every command that calls `get_api_client()` gets a valid token without explicit refresh logic. +This means long-running scripts or interactive sessions do not break due to token expiry, and every command that calls `get_api_client()` gets a valid token without explicit refresh logic. See [authentication.md](authentication.md) for details. ## Click Context as Shared State -Global options (`--format`, `--no-header`, `--base-url`) are parsed once in the root group and stored in `ctx.obj`, a dict carried through the Click context. Every subcommand accesses these via `@click.pass_context` without redeclaring them. - -This avoids passing global options through every function signature and keeps the command tree DRY. The context dict is also how the output layer discovers which formatter to use. +Global options (`--format`, `--no-header`, `--base-url`) are parsed once in the root group and stored in `ctx.obj`, a dict carried through the Click context (`src/kscli/cli.py:112-115`). Every subcommand accesses these via `@click.pass_context` without redeclaring them. ## Subprocess-Based E2E Testing -Tests invoke `kscli` as a real subprocess rather than calling Click commands programmatically. The `tests/cli_helpers.py` module provides three helpers: `run_kscli` (raw invocation), `run_kscli_ok` (asserts exit code 0), and `run_kscli_fail` (asserts non-zero exit code, optionally a specific code). +Tests invoke `kscli` as a real subprocess rather than calling Click commands programmatically. The `tests/e2e/cli_helpers.py` module provides three helpers: + +| Helper | Purpose | Reference | +|--------|---------|-----------| +| `run_kscli(args, env)` | Run `kscli` and return a `CliResult` | `cli_helpers.py:33-85` | +| `run_kscli_ok(args, env)` | Run and assert exit code 0 | `cli_helpers.py:88-103` | +| `run_kscli_fail(args, env, expected_code)` | Run and assert non-zero exit code | `cli_helpers.py:106-127` | -This approach tests the full CLI surface — argument parsing, config resolution, auth flow, SDK calls, error handling, and output formatting — as a user would experience it. Tests control behavior via environment variables (`KSCLI_BASE_URL`, `KSCLI_CREDENTIALS_PATH`, etc.) to isolate test runs from real credentials and servers. +This approach tests the full CLI surface — argument parsing, config resolution, auth flow, SDK calls, error handling, and output formatting — as a user would experience it. -The `cli_authenticated` pytest fixture authenticates once per session and shares the environment dict across all tests, avoiding repeated auth round-trips. +The `cli_authenticated` pytest fixture authenticates once per session and shares the environment dict across all tests, avoiding repeated auth round-trips. See [e2e-testing.md](e2e-testing.md) for the full guide. diff --git a/docs/e2e-testing.md b/docs/e2e-testing.md new file mode 100644 index 0000000..9df3aa4 --- /dev/null +++ b/docs/e2e-testing.md @@ -0,0 +1,165 @@ +# E2E Testing + +E2E tests exercise the full CLI surface by running `kscli` as a subprocess against a live `ks-backend` instance. Tests cover argument parsing, config resolution, authentication, SDK calls, error handling, and output formatting. + +## Prerequisites + +- **ks-backend** checked out alongside ks-cli: + + ``` + workspace/ + ├── ks-cli/ + └── ks-backend/ + ``` + + The test fixtures resolve the backend path relative to the test file location (`tests/e2e/conftest.py:21`). + +- **Docker** running (for the backend's postgres + API containers) + +## Running E2E Tests + +### Start the backend stack + +```bash +cd ../ks-backend +make e2e-stack # Builds and starts postgres, API, and worker containers +make e2e-prep # Creates the e2e database, runs migrations, seeds data +``` + +### Run the tests + +```bash +cd ../ks-cli +make e2e-test +``` + +`make e2e-test` does two things (`Makefile:31-46`): + +1. **`wait-for-api`** — polls `http://localhost:28000/healthz` every second for up to 120 seconds +2. **`pytest tests/e2e/ -v -m e2e -n 2`** — runs all e2e tests with 2 parallel workers + +### Run a single test file + +```bash +uv run pytest tests/e2e/test_cli_folders.py -v -m e2e +``` + +### Run a specific test + +```bash +uv run pytest tests/e2e/test_cli_folders.py::TestCliFoldersRead::test_list_folders_root -v -m e2e +``` + +## Test Architecture + +### Subprocess execution (`tests/e2e/cli_helpers.py`) + +Tests invoke `kscli` as a real subprocess, not via Click's test runner. Three helpers are provided: + +| Helper | Purpose | Reference | +|--------|---------|-----------| +| `run_kscli(args, env)` | Run `kscli` and return a `CliResult` | `cli_helpers.py:33-85` | +| `run_kscli_ok(args, env)` | Run and assert exit code 0 | `cli_helpers.py:88-103` | +| `run_kscli_fail(args, env, expected_code)` | Run and assert non-zero exit code | `cli_helpers.py:106-127` | + +`CliResult` contains `exit_code`, `stdout`, `stderr`, and `json_output` (auto-parsed if `format_json=True`). + +**Environment isolation**: `run_kscli` strips inherited env vars (`ADMIN_API_KEY`, `KSCLI_BASE_URL`, etc.) before merging the test-provided env dict (`cli_helpers.py:12-20`). This ensures tests always target `localhost:28000` with the e2e admin key, regardless of the developer's local config. + +### Fixtures (`tests/e2e/conftest.py`) + +| Fixture | Scope | Purpose | Reference | +|---------|-------|---------|-----------| +| `cli_env` | session | Isolated env dict pointing at e2e backend (`http://localhost:28000`), with temp credentials/config paths | `conftest.py:92-109` | +| `cli_authenticated` | session | Authenticates as `PWUSER1` in their personal tenant; returns env dict used by all tests | `conftest.py:113-129` | +| `kscli_parent_folder` | session | Creates a session-scoped folder under `/agents/` for test isolation; deleted at teardown | `conftest.py:133-155` | +| `isolation_folder` | function | Per-test ephemeral folder under the parent; cascade-deleted at teardown | `conftest.py:159-181` | + +### Seed data constants (`tests/e2e/conftest.py:47-83`) + +Tests reference well-known UUIDs from the backend's seed data: + +```python +PWUSER1_ID = "00000000-0000-0000-0001-000000000001" +PWUSER1_TENANT_ID = "00000000-0000-0000-0002-000000000001" +SHARED_FOLDER_ID = "00000000-0000-0000-0003-000000000100" +FIRST_SIMPLE_DOC_ID = "00000000-0000-0000-0004-000000000001" +FIRST_CHUNK_ID = "00000000-0000-0000-0007-000000000001" +# ... see conftest.py for the full list +``` + +These UUIDs are deterministic and match the `ks-backend/seed/seed_data.py` seeder. + +### Admin API key + +The `ADMIN_API_KEY` is read from `ks-backend/.env.e2e` at import time (`conftest.py:24-39`). If the file doesn't exist or lacks the key, pytest exits immediately with an error. + +## Test Organization + +Each resource has its own test file with read-only and write test classes: + +``` +tests/e2e/ +├── conftest.py # Fixtures and seed data +├── cli_helpers.py # Subprocess runners +├── test_cli_auth.py # assume-user, whoami +├── test_cli_chunks.py # Chunk CRUD + search +├── test_cli_chunk_lineages.py # Parent-child chunk relationships +├── test_cli_documents.py # Document CRUD + ingest +├── test_cli_document_versions.py # Version CRUD + contents +├── test_cli_errors.py # Error handling / exit codes +├── test_cli_folders.py # Folder CRUD + tree listing +├── test_cli_invites.py # Invite lifecycle +├── test_cli_output_formats.py # json/yaml/table/tree/id-only +├── test_cli_path_parts.py # Path part listing +├── test_cli_permissions.py # Permission CRUD +├── test_cli_sections.py # Section CRUD +├── test_cli_settings.py # Settings environment/show +├── test_cli_tags.py # Tag CRUD + attach/detach +├── test_cli_tenants.py # Tenant CRUD +├── test_cli_thread_messages.py # Thread message CRUD +├── test_cli_threads.py # Thread CRUD +├── test_cli_users.py # User update +└── test_cli_workflows.py # Workflow listing +``` + +### Test pattern + +```python +class TestCliFoldersRead: + """Read-only tests using seed data.""" + + def test_list_folders_root(self, cli_authenticated: dict[str, str]) -> None: + result = run_kscli_ok(["folders", "list"], env=cli_authenticated) + data = result.json_output + assert isinstance(data, dict) + assert "items" in data + +class TestCliFoldersWrite: + """Write tests using isolation fixtures.""" + + def test_create_folder(self, cli_authenticated, kscli_parent_folder) -> None: + result = run_kscli_ok( + ["folders", "create", "--name", "test", "--parent-path-part-id", parent_id], + env=cli_authenticated, + ) + assert result.json_output["name"] == "test" +``` + +- **Read tests** use `cli_authenticated` only and operate on seed data +- **Write tests** use `isolation_folder` or `kscli_parent_folder` for cleanup isolation + +## Writing New E2E Tests + +1. Add a new test file `tests/e2e/test_cli_.py` +2. Import helpers: `from tests.e2e.cli_helpers import run_kscli_ok, run_kscli_fail` +3. Use `cli_authenticated` fixture for auth +4. Use `isolation_folder` if your tests create data that needs cleanup +5. Mark the test class or module with `pytestmark = pytest.mark.e2e` +6. Reference seed data constants from `conftest.py` + +## CI Integration + +The e2e tests run automatically in GitHub Actions on every PR and push to main. The CI workflow checks out both repositories side by side, starts the backend Docker stack, seeds the database, and runs `make e2e-test`. See [docs/ci.md](ci.md) for pipeline details. + +The `release` job is gated on both `lint` and `e2e` passing. diff --git a/pyproject.toml b/pyproject.toml index 71dddd8..443d1b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,11 @@ name = "kscli" version = "1.0.1" description = "Add your description here" readme = "README.md" +license = "MIT" requires-python = ">=3.12" +authors = [{ name = "Knowledge Stack", email = "support@knowledgestack.com" }] +urls = { Repository = "https://github.com/knowledgestack/ks-cli" } + dependencies = [ "certifi>=2026.1.4", "click>=8.3.1", @@ -24,7 +28,12 @@ build-backend = "hatchling.build" packages = ["src/kscli"] [dependency-groups] -dev = ["basedpyright>=1.38.1", "pytest>=9.0.2", "ruff>=0.15.2"] +dev = [ + "basedpyright>=1.38.1", + "pytest>=9.0.2", + "pytest-xdist>=3.8.0", + "ruff>=0.15.2", +] # Pyright configuration - strict type checking [tool.basedpyright] @@ -50,3 +59,7 @@ reportMissingTypeArgument = "error" reportAny = "none" reportMissingImports = "error" reportMissingTypeStubs = "error" + +[tool.pytest.ini_options] +testpaths = ["tests"] +markers = ["e2e: end-to-end tests requiring a running backend"] diff --git a/src/kscli/cli.py b/src/kscli/cli.py index 91b47fd..0061dc0 100644 --- a/src/kscli/cli.py +++ b/src/kscli/cli.py @@ -1,189 +1,140 @@ -"""Root CLI group with verb-first command routing.""" +"""Root CLI group with resource-based command routing.""" import click from kscli.commands import ( auth, - chunk_lineages, - chunks, - documents, - folders, - invites, - path_parts, - permissions, - sections, settings, - tags, - tenants, - threads, - users, - versions, - workflows, ) -from kscli.config import get_default_format - - -@click.group() +from kscli.commands.chunk_lineages import chunk_lineages +from kscli.commands.chunks import chunks +from kscli.commands.document_versions import document_versions +from kscli.commands.documents import documents +from kscli.commands.folders import folders +from kscli.commands.invites import invites +from kscli.commands.path_parts import path_parts +from kscli.commands.permissions import permissions +from kscli.commands.sections import sections +from kscli.commands.tags import tags +from kscli.commands.tenants import tenants +from kscli.commands.thread_messages import thread_messages +from kscli.commands.threads import threads +from kscli.commands.users import users +from kscli.commands.workflows import workflows +from kscli.config import ensure_config, get_default_format + +_VALUED_OPTS = {"--format": "format", "-f": "format", "--base-url": "base_url"} +_FLAG_OPTS = {"--no-header": "no_header"} +_FORMAT_CHOICES_LIST = ["table", "json", "yaml", "id-only", "tree"] +_FORMAT_CHOICES = frozenset(_FORMAT_CHOICES_LIST) + + +def _validate_format(val: str) -> str: + if val not in _FORMAT_CHOICES: + choices = ", ".join(sorted(_FORMAT_CHOICES)) + raise click.UsageError( + f"Invalid value for '--format': '{val}'. Choose from {choices}." + ) + return val + + +class GlobalOptionsGroup(click.Group): + """click.Group that allows global options (--format, --no-header, --base-url) at any position.""" + + def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: + extracted: dict[str, str | bool] = {} + remaining: list[str] = [] + i = 0 + while i < len(args): + arg = args[i] + + # Stop extracting after bare -- + if arg == "--": + remaining.append(arg) + remaining.extend(args[i + 1 :]) + break + + # --key=value + if "=" in arg: + key_part = arg.split("=", 1)[0] + if key_part in _VALUED_OPTS: + key = _VALUED_OPTS[key_part] + val = arg.split("=", 1)[1] + if key == "format": + _validate_format(val) + extracted[key] = val + i += 1 + continue + + # --key value + if arg in _VALUED_OPTS: + if i + 1 < len(args): + key = _VALUED_OPTS[arg] + val = args[i + 1] + if key == "format": + _validate_format(val) + extracted[key] = val + i += 2 + continue + # Missing value — leave for Click to report the error + remaining.append(arg) + i += 1 + continue + + # --flag + if arg in _FLAG_OPTS: + extracted[_FLAG_OPTS[arg]] = True + i += 1 + continue + + remaining.append(arg) + i += 1 + + ctx.ensure_object(dict) + ctx.obj.update(extracted) + return super().parse_args(ctx, remaining) + + +@click.group(cls=GlobalOptionsGroup) @click.option( "--format", "-f", "format_", - type=click.Choice(["table", "json", "yaml", "id-only", "tree"]), + type=click.Choice(_FORMAT_CHOICES_LIST), default=None, ) @click.option("--no-header", is_flag=True, default=False) @click.option("--base-url", default=None) @click.pass_context -def main(ctx, format_, no_header, base_url): +def main(ctx, format_, no_header, base_url): # noqa: ARG001 — params required by Click for --help """Kscli — Knowledge Stack CLI.""" + ensure_config() ctx.ensure_object(dict) - ctx.obj["format"] = format_ or get_default_format() - ctx.obj["no_header"] = no_header - ctx.obj["base_url"] = base_url + ctx.obj.setdefault("format", get_default_format()) + ctx.obj.setdefault("no_header", False) + ctx.obj.setdefault("base_url", None) -# ── Top-level auth commands ────────────────────────────────────────────────── +# ── Top-level commands ────────────────────────────────────────────────────── main.add_command(auth.assume_user) main.add_command(auth.whoami) main.add_command(settings.settings) -main.add_command(tags.attach_tag) -main.add_command(tags.detach_tag) -main.add_command(workflows.workflow_action) - - -# ── Verb groups ────────────────────────────────────────────────────────────── - -@main.group() -def get(): - """List resources.""" - - -@main.group() -def describe(): - """Describe a single resource.""" - - -@main.group() -def create(): - """Create a resource.""" - - -@main.group() -def update(): - """Update a resource.""" - - -@main.group() -def delete(): - """Delete a resource.""" - - -@main.group() -def search(): - """Search resources.""" - - -@main.group() -def ingest(): - """Ingest resources.""" - - -@main.group() -def accept(): - """Accept resources.""" - - -# ── Register commands on verb groups ───────────────────────────────────────── - -# Folders -folders.register_get(get) -folders.register_describe(describe) -folders.register_create(create) -folders.register_update(update) -folders.register_delete(delete) - -# Documents -documents.register_get(get) -documents.register_describe(describe) -documents.register_create(create) -documents.register_update(update) -documents.register_delete(delete) -documents.register_ingest(ingest) - -# Versions -versions.register_get(get) -versions.register_describe(describe) -versions.register_describe_contents(describe) -versions.register_create(create) -versions.register_update(update) -versions.register_delete(delete) -versions.register_delete_contents(delete) - -# Sections -sections.register_describe(describe) -sections.register_create(create) -sections.register_update(update) -sections.register_delete(delete) - -# Chunks -chunks.register_describe(describe) -chunks.register_create(create) -chunks.register_update(update) -chunks.register_update_content(update) -chunks.register_delete(delete) -chunks.register_search(search) - -# Chunk lineages -chunk_lineages.register_describe(describe) -chunk_lineages.register_create(create) -chunk_lineages.register_delete(delete) - -# Path parts -path_parts.register_get(get) -path_parts.register_describe(describe) - -# Tags -tags.register_get(get) -tags.register_describe(describe) -tags.register_create(create) -tags.register_update(update) -tags.register_delete(delete) -# Tenants -tenants.register_get(get) -tenants.register_get_users(get) -tenants.register_describe(describe) -tenants.register_create(create) -tenants.register_update(update) -tenants.register_delete(delete) - -# Invites -invites.register_get(get) -invites.register_create(create) -invites.register_accept(accept) -invites.register_delete(delete) - -# Permissions -permissions.register_get(get) -permissions.register_create(create) -permissions.register_update(update) -permissions.register_delete(delete) - -# Threads & messages -threads.register_get_threads(get) -threads.register_get_messages(get) -threads.register_describe_thread(describe) -threads.register_describe_message(describe) -threads.register_create_thread(create) -threads.register_create_message(create) -threads.register_update_thread(update) -threads.register_delete_thread(delete) - -# Workflows -workflows.register_get(get) -workflows.register_describe(describe) - - -# Users (update only) -users.register_update(update) +# ── Resource groups ───────────────────────────────────────────────────────── + +main.add_command(folders) +main.add_command(documents) +main.add_command(document_versions) +main.add_command(sections) +main.add_command(chunks) +main.add_command(tags) +main.add_command(workflows) +main.add_command(tenants) +main.add_command(users) +main.add_command(permissions) +main.add_command(invites) +main.add_command(threads) +main.add_command(thread_messages) +main.add_command(chunk_lineages) +main.add_command(path_parts) diff --git a/src/kscli/commands/chunk_lineages.py b/src/kscli/commands/chunk_lineages.py index 1b6d1e7..431ec57 100644 --- a/src/kscli/commands/chunk_lineages.py +++ b/src/kscli/commands/chunk_lineages.py @@ -7,50 +7,52 @@ from kscli.output import print_result -def register_describe(group: click.Group) -> None: - @group.command("chunk-lineage") - @click.argument("chunk_id", type=click.UUID) - @click.pass_context - def describe_chunk_lineage(ctx, chunk_id): - """Get chunk lineage graph.""" - api_client = get_api_client(ctx) - with handle_client_errors(): - api = ksapi.ChunkLineagesApi(api_client) - result = api.get_chunk_lineage(chunk_id) - print_result(ctx, to_dict(result)) - - -def register_create(group: click.Group) -> None: - @group.command("chunk-lineage") - @click.option("--parent-chunk-id", type=click.UUID, required=True) - @click.option("--child-chunk-id", type=click.UUID, required=True) - @click.pass_context - def create_chunk_lineage(ctx, parent_chunk_id, child_chunk_id): - """Create a lineage link.""" - api_client = get_api_client(ctx) - with handle_client_errors(): - api = ksapi.ChunkLineagesApi(api_client) - result = api.create_chunk_lineage( - ksapi.CreateChunkLineageRequest( - chunk_id=child_chunk_id, - parent_chunk_ids=[parent_chunk_id], - ) - ) - print_result(ctx, to_dict(result)) - - -def register_delete(group: click.Group) -> None: - @group.command("chunk-lineage") - @click.option("--parent-chunk-id", type=click.UUID, required=True) - @click.option("--child-chunk-id", type=click.UUID, required=True) - @click.pass_context - def delete_chunk_lineage(ctx, parent_chunk_id, child_chunk_id): - """Delete a lineage link.""" - api_client = get_api_client(ctx) - with handle_client_errors(): - api = ksapi.ChunkLineagesApi(api_client) - api.delete_chunk_lineage( - parent_chunk_id=parent_chunk_id, +@click.group("chunk-lineages") +def chunk_lineages(): + """Manage chunk lineages.""" + + +@chunk_lineages.command("describe") +@click.argument("chunk_id", type=click.UUID) +@click.pass_context +def describe_chunk_lineage(ctx, chunk_id): + """Get chunk lineage graph.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.ChunkLineagesApi(api_client) + result = api.get_chunk_lineage(chunk_id) + print_result(ctx, to_dict(result)) + + +@chunk_lineages.command("create") +@click.option("--parent-chunk-id", type=click.UUID, required=True) +@click.option("--child-chunk-id", type=click.UUID, required=True) +@click.pass_context +def create_chunk_lineage(ctx, parent_chunk_id, child_chunk_id): + """Create a lineage link.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.ChunkLineagesApi(api_client) + result = api.create_chunk_lineage( + ksapi.CreateChunkLineageRequest( chunk_id=child_chunk_id, + parent_chunk_ids=[parent_chunk_id], ) - click.echo("Deleted chunk lineage link") + ) + print_result(ctx, to_dict(result)) + + +@chunk_lineages.command("delete") +@click.option("--parent-chunk-id", type=click.UUID, required=True) +@click.option("--child-chunk-id", type=click.UUID, required=True) +@click.pass_context +def delete_chunk_lineage(ctx, parent_chunk_id, child_chunk_id): + """Delete a lineage link.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.ChunkLineagesApi(api_client) + api.delete_chunk_lineage( + parent_chunk_id=parent_chunk_id, + chunk_id=child_chunk_id, + ) + click.echo("Deleted chunk lineage link") diff --git a/src/kscli/commands/chunks.py b/src/kscli/commands/chunks.py index e0bda4e..36cfc12 100644 --- a/src/kscli/commands/chunks.py +++ b/src/kscli/commands/chunks.py @@ -9,134 +9,125 @@ from kscli.output import print_result -def register_describe(group: click.Group) -> None: - @group.command("chunk") - @click.argument("chunk_id", type=click.UUID) - @click.pass_context - def describe_chunk(ctx, chunk_id): - """Describe a chunk.""" - api_client = get_api_client(ctx) - with handle_client_errors(): - api = ksapi.ChunksApi(api_client) - result = api.get_chunk(chunk_id) - print_result(ctx, to_dict(result)) - - -def register_create(group: click.Group) -> None: - @group.command("chunk") - @click.option("--content", required=True) - @click.option("--version-id", type=click.UUID, default=None) - @click.option("--section-id", type=click.UUID, default=None) - @click.option( - "--chunk-type", - default="TEXT", - type=click.Choice(["TEXT", "TABLE", "IMAGE", "UNKNOWN"]), - ) - @click.option("--metadata", "meta", default=None, help="JSON string of metadata") - @click.pass_context - 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" - ) - parent_path_id = version_id or section_id - if parent_path_id is None: - raise click.UsageError("Provide either --version-id or --section-id") - api_client = get_api_client(ctx) - 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() - result = api.create_chunk( - ksapi.CreateChunkRequest( - parent_path_id=parent_path_id, - content=content, - chunk_type=chunk_type, - chunk_metadata=chunk_metadata, - ) - ) - print_result(ctx, to_dict(result)) - - -def register_update(group: click.Group) -> None: - @group.command("chunk") - @click.argument("chunk_id", type=click.UUID) - @click.option("--metadata", "meta", default=None, help="JSON string of metadata") - @click.pass_context - def update_chunk(ctx, chunk_id, meta): - """Update chunk metadata.""" - api_client = get_api_client(ctx) - 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() - result = api.update_chunk_metadata( - chunk_id, - ksapi.UpdateChunkMetadataRequest(chunk_metadata=chunk_metadata), - ) - print_result(ctx, to_dict(result)) - - -def register_update_content(group: click.Group) -> None: - @group.command("chunk-content") - @click.argument("chunk_id", type=click.UUID) - @click.option("--content", required=True) - @click.pass_context - def update_chunk_content(ctx, chunk_id, content): - """Update chunk content.""" - api_client = get_api_client(ctx) - with handle_client_errors(): - api = ksapi.ChunksApi(api_client) - result = api.update_chunk_content( - chunk_id, - ksapi.UpdateChunkContentRequest(content=content), - ) - print_result(ctx, to_dict(result)) - - -def register_delete(group: click.Group) -> None: - @group.command("chunk") - @click.argument("chunk_id", type=click.UUID) - @click.pass_context - def delete_chunk(ctx, chunk_id): - """Delete a chunk.""" - api_client = get_api_client(ctx) - with handle_client_errors(): - api = ksapi.ChunksApi(api_client) - api.delete_chunk(chunk_id) - click.echo(f"Deleted chunk {chunk_id}") - - -def register_search(group: click.Group) -> None: - @group.command("chunks") - @click.option("--query", required=True) - @click.option("--limit", type=int, default=10) - @click.option("--filters", default=None, help="JSON string of filters") - @click.pass_context - def search_chunks(ctx, query, limit, 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 - request_kwargs = {"query": query, "top_k": limit} - if filter_dict: - if "model" in filter_dict: - request_kwargs["model"] = filter_dict["model"] - if "parent_path_ids" in filter_dict: - request_kwargs["parent_path_ids"] = filter_dict["parent_path_ids"] - if "chunk_type" in filter_dict: - request_kwargs["chunk_type"] = filter_dict["chunk_type"] - if "updated_at" in filter_dict: - request_kwargs["updated_at"] = filter_dict["updated_at"] - if "score_threshold" in filter_dict: - request_kwargs["score_threshold"] = filter_dict["score_threshold"] - result = api.search_chunks( - ksapi.ChunkSearchRequest(**request_kwargs) +@click.group("chunks") +def chunks(): + """Manage chunks.""" + + +@chunks.command("describe") +@click.argument("chunk_id", type=click.UUID) +@click.pass_context +def describe_chunk(ctx, chunk_id): + """Describe a chunk.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.ChunksApi(api_client) + result = api.get_chunk(chunk_id) + print_result(ctx, to_dict(result)) + + +@chunks.command("create") +@click.option("--content", required=True) +@click.option("--version-id", type=click.UUID, default=None) +@click.option("--section-id", type=click.UUID, default=None) +@click.option( + "--chunk-type", + default="TEXT", + type=click.Choice(["TEXT", "TABLE", "IMAGE", "UNKNOWN"]), +) +@click.option("--metadata", "meta", default=None, help="JSON string of metadata") +@click.pass_context +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" + ) + parent_path_id = version_id or section_id + if parent_path_id is None: + raise click.UsageError("Provide either --version-id or --section-id") + api_client = get_api_client(ctx) + 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() + result = api.create_chunk( + ksapi.CreateChunkRequest( + parent_path_id=parent_path_id, + content=content, + chunk_type=chunk_type, + chunk_metadata=chunk_metadata, ) - print_result(ctx, to_dict(result)) + ) + print_result(ctx, to_dict(result)) + + +@chunks.command("update") +@click.argument("chunk_id", type=click.UUID) +@click.option("--metadata", "meta", default=None, help="JSON string of metadata") +@click.pass_context +def update_chunk(ctx, chunk_id, meta): + """Update chunk metadata.""" + api_client = get_api_client(ctx) + 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() + result = api.update_chunk_metadata( + chunk_id, + ksapi.UpdateChunkMetadataRequest(chunk_metadata=chunk_metadata), + ) + print_result(ctx, to_dict(result)) + + +@chunks.command("update-content") +@click.argument("chunk_id", type=click.UUID) +@click.option("--content", required=True) +@click.pass_context +def update_chunk_content(ctx, chunk_id, content): + """Update chunk content.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.ChunksApi(api_client) + result = api.update_chunk_content( + chunk_id, + ksapi.UpdateChunkContentRequest(content=content), + ) + print_result(ctx, to_dict(result)) + + +@chunks.command("delete") +@click.argument("chunk_id", type=click.UUID) +@click.pass_context +def delete_chunk(ctx, chunk_id): + """Delete a chunk.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.ChunksApi(api_client) + api.delete_chunk(chunk_id) + click.echo(f"Deleted chunk {chunk_id}") + + +@chunks.command("search") +@click.option("--query", required=True) +@click.option("--limit", type=int, default=10) +@click.option("--filters", default=None, help="JSON string of filters") +@click.pass_context +def search_chunks(ctx, query, limit, 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)) diff --git a/src/kscli/commands/document_versions.py b/src/kscli/commands/document_versions.py new file mode 100644 index 0000000..cac0ec9 --- /dev/null +++ b/src/kscli/commands/document_versions.py @@ -0,0 +1,118 @@ +"""Document version commands.""" + +import click +import ksapi + +from kscli.client import get_api_client, handle_client_errors, to_dict +from kscli.output import print_result + +COLUMNS = ["id", "document_id", "name", "created_at"] + + +@click.group("document-versions") +def document_versions(): + """Manage document versions.""" + + +@document_versions.command("list") +@click.option("--document-id", type=click.UUID, required=True) +@click.option("--limit", type=int, default=20) +@click.option("--offset", type=int, default=0) +@click.pass_context +def list_versions(ctx, document_id, limit, offset): + """List versions for a document.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.DocumentVersionsApi(api_client) + result = api.list_document_versions( + document_id=document_id, limit=limit, offset=offset + ) + print_result(ctx, to_dict(result), columns=COLUMNS) + + +@document_versions.command("describe") +@click.argument("version_id", type=click.UUID) +@click.pass_context +def describe_version(ctx, version_id): + """Describe a version.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.DocumentVersionsApi(api_client) + result = api.get_document_version(version_id) + print_result(ctx, to_dict(result)) + + +@document_versions.command("contents") +@click.argument("version_id", type=click.UUID) +@click.option( + "--show-content", + is_flag=True, + default=False, + help="Show chunk content inline when --format tree is used.", +) +@click.option( + "--sections-only", + is_flag=True, + default=False, + help="Exclude chunks from the tree output (sections only).", +) +@click.pass_context +def version_contents(ctx, version_id, show_content, sections_only): + """Get version contents (sections + chunks tree).""" + api_client = get_api_client(ctx) + 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) + + +@document_versions.command("create") +@click.option("--document-id", type=click.UUID, required=True) +@click.pass_context +def create_version(ctx, document_id): + """Create a new version.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.DocumentVersionsApi(api_client) + result = api.create_document_version(document_id=document_id) + print_result(ctx, to_dict(result)) + + +@document_versions.command("update") +@click.argument("version_id", type=click.UUID) +@click.option("--source-s3", default=None) +@click.pass_context +def update_version(ctx, version_id, source_s3): + """Update version metadata.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.DocumentVersionsApi(api_client) + result = api.update_document_version_metadata( + version_id, + ksapi.DocumentVersionMetadataUpdate(source_s3=source_s3), + ) + print_result(ctx, to_dict(result)) + + +@document_versions.command("delete") +@click.argument("version_id", type=click.UUID) +@click.pass_context +def delete_version(ctx, version_id): + """Delete a version.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.DocumentVersionsApi(api_client) + api.delete_document_version(version_id) + click.echo(f"Deleted version {version_id}") + + +@document_versions.command("clear-contents") +@click.argument("version_id", type=click.UUID) +@click.pass_context +def clear_version_contents(ctx, version_id): + """Clear all contents under a version.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.DocumentVersionsApi(api_client) + api.clear_document_version_contents(version_id) + click.echo(f"Cleared contents of version {version_id}") diff --git a/src/kscli/commands/documents.py b/src/kscli/commands/documents.py index 90993b7..c112de7 100644 --- a/src/kscli/commands/documents.py +++ b/src/kscli/commands/documents.py @@ -11,140 +11,139 @@ COLUMNS = ["id", "name", "type", "origin", "parent_path_part_id", "created_at"] -def register_get(group: click.Group) -> None: - @group.command("documents") - @click.option( - "--parent-path-part-id", - "parent_path_part_id", - type=click.UUID, - default=None, - help="Parent path part ID; omit for root/top-level.", - ) - @click.option("--limit", type=int, default=20) - @click.option("--offset", type=int, default=0) - @click.pass_context - def get_documents(ctx, parent_path_part_id, limit, offset): - """List documents.""" - api_client = get_api_client(ctx) - with handle_client_errors(): - api = ksapi.DocumentsApi(api_client) - result = api.list_documents( - limit=limit, - offset=offset, +@click.group("documents") +def documents(): + """Manage documents.""" + + +@documents.command("list") +@click.option( + "--parent-path-part-id", + "parent_path_part_id", + type=click.UUID, + default=None, + help="Parent path part ID; omit for root/top-level.", +) +@click.option("--limit", type=int, default=20) +@click.option("--offset", type=int, default=0) +@click.pass_context +def list_documents(ctx, parent_path_part_id, limit, offset): + """List documents.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.DocumentsApi(api_client) + result = api.list_documents( + limit=limit, + offset=offset, + parent_path_part_id=parent_path_part_id, + ) + print_result(ctx, to_dict(result), columns=COLUMNS) + + +@documents.command("describe") +@click.argument("document_id", type=click.UUID) +@click.pass_context +def describe_document(ctx, document_id): + """Describe a document.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.DocumentsApi(api_client) + result = api.get_document(document_id) + print_result(ctx, to_dict(result)) + + +@documents.command("create") +@click.option("--name", required=True) +@click.option( + "--parent-path-part-id", + "parent_path_part_id", + type=click.UUID, + 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("--origin", required=True, type=click.Choice(["SOURCE", "GENERATED"])) +@click.pass_context +def create_document(ctx, name, parent_path_part_id, doc_type, origin): + """Create a document (metadata only).""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.DocumentsApi(api_client) + result = api.create_document( + ksapi.CreateDocumentRequest( + name=name, parent_path_part_id=parent_path_part_id, + document_type=doc_type, + document_origin=origin, ) - print_result(ctx, to_dict(result), columns=COLUMNS) - - -def register_describe(group: click.Group) -> None: - @group.command("document") - @click.argument("document_id", type=click.UUID) - @click.pass_context - def describe_document(ctx, document_id): - """Describe a document.""" - api_client = get_api_client(ctx) - with handle_client_errors(): - api = ksapi.DocumentsApi(api_client) - result = api.get_document(document_id) - print_result(ctx, to_dict(result)) - - -def register_create(group: click.Group) -> None: - @group.command("document") - @click.option("--name", required=True) - @click.option( - "--parent-path-part-id", - "parent_path_part_id", - type=click.UUID, - 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("--origin", required=True, type=click.Choice(["SOURCE", "GENERATED"])) - @click.pass_context - def create_document(ctx, name, parent_path_part_id, doc_type, origin): - """Create a document (metadata only).""" - api_client = get_api_client(ctx) - with handle_client_errors(): - api = ksapi.DocumentsApi(api_client) - result = api.create_document( - ksapi.CreateDocumentRequest( - name=name, - parent_path_part_id=parent_path_part_id, - document_type=doc_type, - document_origin=origin, - ) - ) - print_result(ctx, to_dict(result)) - - -def register_update(group: click.Group) -> None: - @group.command("document") - @click.argument("document_id", type=click.UUID) - @click.option("--name", default=None) - @click.option( - "--parent-path-part-id", - "parent_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("--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 - ): - """Update a document.""" - api_client = get_api_client(ctx) - with handle_client_errors(): - api = ksapi.DocumentsApi(api_client) - result = api.update_document( - document_id, - ksapi.UpdateDocumentRequest( - name=name, - parent_path_part_id=parent_path_part_id, - active_version_id=active_version_id, - ), + ) + print_result(ctx, to_dict(result)) + + +@documents.command("update") +@click.argument("document_id", type=click.UUID) +@click.option("--name", default=None) +@click.option( + "--parent-path-part-id", + "parent_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("--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 +): + """Update a document.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.DocumentsApi(api_client) + result = api.update_document( + document_id, + ksapi.UpdateDocumentRequest( + name=name, + parent_path_part_id=parent_path_part_id, + active_version_id=active_version_id, + ), + ) + print_result(ctx, to_dict(result)) + + +@documents.command("delete") +@click.argument("document_id", type=click.UUID) +@click.pass_context +def delete_document(ctx, document_id): + """Delete a document.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.DocumentsApi(api_client) + api.delete_document(document_id) + click.echo(f"Deleted document {document_id}") + + +@documents.command("ingest") +@click.option("--file", "file_path", required=True, type=click.Path(exists=True)) +@click.option( + "--path-part-id", + "path_part_id", + type=click.UUID, + required=True, + help="Parent path part ID (e.g. folder's path_part_id from 'describe folder').", +) +@click.option("--name", default=None) +@click.pass_context +def ingest_document(ctx, file_path, path_part_id, name): + """Ingest a document (upload file + start processing). Parent is the folder's path_part_id.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.DocumentsApi(api_client) + p = Path(file_path) + file_name = name or p.name + with p.open("rb") as f: + result = api.ingest_document( + file=(file_name, f.read()), + path_part_id=path_part_id, + name=file_name, ) - print_result(ctx, to_dict(result)) - - -def register_delete(group: click.Group) -> None: - @group.command("document") - @click.argument("document_id", type=click.UUID) - @click.pass_context - def delete_document(ctx, document_id): - """Delete a document.""" - api_client = get_api_client(ctx) - with handle_client_errors(): - api = ksapi.DocumentsApi(api_client) - api.delete_document(document_id) - click.echo(f"Deleted document {document_id}") - - -def register_ingest(group: click.Group) -> None: - @group.command("document") - @click.option("--file", "file_path", required=True, type=click.Path(exists=True)) - @click.option( - "--path-part-id", - "path_part_id", - type=click.UUID, - required=True, - help="Parent path part ID (e.g. folder's path_part_id from 'describe folder').", - ) - @click.option("--name", default=None) - @click.pass_context - def ingest_document(ctx, file_path, path_part_id, name): - """Ingest a document (upload file + start processing). Parent is the folder's path_part_id.""" - api_client = get_api_client(ctx) - with handle_client_errors(): - api = ksapi.DocumentsApi(api_client) - p = Path(file_path) - file_name = name or p.name - with p.open("rb") as f: - result = api.ingest_document( - file=(file_name, f.read()), - path_part_id=path_part_id, - name=file_name, - ) - print_result(ctx, to_dict(result)) + print_result(ctx, to_dict(result)) diff --git a/src/kscli/commands/folders.py b/src/kscli/commands/folders.py index 3f9f44c..a063f29 100644 --- a/src/kscli/commands/folders.py +++ b/src/kscli/commands/folders.py @@ -9,142 +9,140 @@ COLUMNS = ["id", "path_part_id", "name", "parent_path_part_id", "materialized_path", "created_at"] -def register_get(group: click.Group) -> None: - @group.command("folders") - @click.option( - "--parent-path-part-id", - "parent_path_part_id", - type=click.UUID, - default=None, - help="Parent path part ID; omit for root/top-level.", - ) - @click.option( - "--show-content", - "show_content", - is_flag=True, - default=False, - help="Show folder content (requires --folder-id).", - ) - @click.option( - "--folder-id", - "folder_id", - type=click.UUID, - default=None, - help="Folder ID to list contents for (used 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"]), - default=None, - help="Sort order (with --show-content).", - ) - @click.option("--limit", type=int, default=20) - @click.option("--offset", type=int, default=0) - @click.pass_context - def get_folders(ctx, parent_path_part_id, show_content, folder_id, max_depth, sort_order, limit, offset): - """List folders.""" - if show_content: - if not folder_id: - raise click.UsageError("--folder-id is required when using --show-content.") - api_client = get_api_client(ctx) - with handle_client_errors(): - api = ksapi.FoldersApi(api_client) - result = api.list_folder_contents( - folder_id, - max_depth=max_depth, - sort_order=sort_order, - limit=limit, - offset=offset, - ) - print_result(ctx, to_dict(result)) - return +@click.group("folders") +def folders(): + """Manage folders.""" + + +@folders.command("list") +@click.option( + "--parent-path-part-id", + "parent_path_part_id", + type=click.UUID, + default=None, + help="Parent path part ID; omit for root/top-level.", +) +@click.option( + "--show-content", + "show_content", + is_flag=True, + default=False, + help="Show folder content (requires --folder-id).", +) +@click.option( + "--folder-id", + "folder_id", + type=click.UUID, + default=None, + help="Folder ID to list contents for (used 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"]), + default=None, + help="Sort order (with --show-content).", +) +@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): + """List folders.""" + if show_content: + if not folder_id: + raise click.UsageError("--folder-id is required when using --show-content.") api_client = get_api_client(ctx) with handle_client_errors(): api = ksapi.FoldersApi(api_client) - result = api.list_folders( + result = api.list_folder_contents( + folder_id, + max_depth=max_depth, + sort_order=sort_order, limit=limit, offset=offset, - parent_path_part_id=parent_path_part_id, ) - print_result(ctx, to_dict(result), columns=COLUMNS) - - -def register_describe(group: click.Group) -> None: - @group.command("folder") - @click.argument("folder_id", type=click.UUID) - @click.pass_context - def describe_folder(ctx, folder_id): - """Describe a folder.""" - api_client = get_api_client(ctx) - with handle_client_errors(): - api = ksapi.FoldersApi(api_client) - result = api.get_folder(folder_id) 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) -def register_create(group: click.Group) -> None: - @group.command("folder") - @click.option("--name", required=True) - @click.option( - "--parent-path-part-id", - "parent_path_part_id", - type=click.UUID, - required=True, - help="Parent path part ID (e.g. folder's path_part_id from 'describe folder').", - ) - @click.pass_context - def create_folder(ctx, name, parent_path_part_id): - """Create a folder.""" - api_client = get_api_client(ctx) - with handle_client_errors(): - api = ksapi.FoldersApi(api_client) - result = api.create_folder( - ksapi.CreateFolderRequest( - name=name, - parent_path_part_id=parent_path_part_id, - ) - ) - print_result(ctx, to_dict(result)) +@folders.command("describe") +@click.argument("folder_id", type=click.UUID) +@click.pass_context +def describe_folder(ctx, folder_id): + """Describe a folder.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.FoldersApi(api_client) + result = api.get_folder(folder_id) + print_result(ctx, to_dict(result)) -def register_update(group: click.Group) -> None: - @group.command("folder") - @click.argument("folder_id", type=click.UUID) - @click.option("--name", default=None) - @click.option( - "--parent-path-part-id", - "parent_path_part_id", - type=click.UUID, - default=None, - help="Parent path part ID (e.g. folder's path_part_id from 'describe folder').", - ) - @click.pass_context - def update_folder(ctx, folder_id, name, parent_path_part_id): - """Update a folder.""" - api_client = get_api_client(ctx) - with handle_client_errors(): - api = ksapi.FoldersApi(api_client) - result = api.update_folder( - folder_id, - ksapi.UpdateFolderRequest( - name=name, - parent_path_part_id=parent_path_part_id, - ), +@folders.command("create") +@click.option("--name", required=True) +@click.option( + "--parent-path-part-id", + "parent_path_part_id", + type=click.UUID, + required=True, + help="Parent path part ID (e.g. folder's path_part_id from 'describe folder').", +) +@click.pass_context +def create_folder(ctx, name, parent_path_part_id): + """Create a folder.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.FoldersApi(api_client) + result = api.create_folder( + ksapi.CreateFolderRequest( + name=name, + parent_path_part_id=parent_path_part_id, ) - print_result(ctx, to_dict(result)) + ) + print_result(ctx, to_dict(result)) -def register_delete(group: click.Group) -> None: - @group.command("folder") - @click.argument("folder_id", type=click.UUID) - @click.pass_context - def delete_folder(ctx, folder_id): - """Delete a folder.""" - api_client = get_api_client(ctx) - with handle_client_errors(): - api = ksapi.FoldersApi(api_client) - api.delete_folder(folder_id) - click.echo(f"Deleted folder {folder_id}") +@folders.command("update") +@click.argument("folder_id", type=click.UUID) +@click.option("--name", default=None) +@click.option( + "--parent-path-part-id", + "parent_path_part_id", + type=click.UUID, + default=None, + help="Parent path part ID (e.g. folder's path_part_id from 'describe folder').", +) +@click.pass_context +def update_folder(ctx, folder_id, name, parent_path_part_id): + """Update a folder.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.FoldersApi(api_client) + result = api.update_folder( + folder_id, + ksapi.UpdateFolderRequest( + name=name, + parent_path_part_id=parent_path_part_id, + ), + ) + print_result(ctx, to_dict(result)) +@folders.command("delete") +@click.argument("folder_id", type=click.UUID) +@click.pass_context +def delete_folder(ctx, folder_id): + """Delete a folder.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.FoldersApi(api_client) + api.delete_folder(folder_id) + click.echo(f"Deleted folder {folder_id}") diff --git a/src/kscli/commands/invites.py b/src/kscli/commands/invites.py index 57b4a6f..156af3d 100644 --- a/src/kscli/commands/invites.py +++ b/src/kscli/commands/invites.py @@ -12,63 +12,64 @@ COLUMNS = ["id", "email", "role", "status", "created_at"] -def register_get(group: click.Group) -> None: - @group.command("invites") - @click.option("--limit", type=int, default=20) - @click.option("--offset", type=int, default=0) - @click.pass_context - def get_invites(ctx, limit, offset): - """List invites.""" - api_client = get_api_client(ctx) - 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) +@click.group("invites") +def invites(): + """Manage invites.""" -def register_create(group: click.Group) -> None: - @group.command("invite") - @click.option("--tenant-id", type=click.UUID, default=None) - @click.option("--email", required=True) - @click.option("--role", required=True, type=click.Choice(["USER", "OWNER", "ADMIN"])) - @click.pass_context - 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"]) - api = ksapi.InvitesApi(api_client) - result = api.create_invite( - ksapi.InviteUserRequest( - tenant_id=tid, - email=email, - role=role, - ) +@invites.command("list") +@click.option("--limit", type=int, default=20) +@click.option("--offset", type=int, default=0) +@click.pass_context +def list_invites(ctx, limit, offset): + """List invites.""" + api_client = get_api_client(ctx) + 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) + + +@invites.command("create") +@click.option("--tenant-id", type=click.UUID, default=None) +@click.option("--email", required=True) +@click.option("--role", required=True, type=click.Choice(["USER", "OWNER", "ADMIN"])) +@click.pass_context +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"]) + api = ksapi.InvitesApi(api_client) + result = api.create_invite( + ksapi.InviteUserRequest( + tenant_id=tid, + email=email, + role=role, ) - print_result(ctx, to_dict(result)) + ) + print_result(ctx, to_dict(result)) -def register_accept(group: click.Group) -> None: - @group.command("invite") - @click.argument("invite_id", type=click.UUID) - @click.pass_context - def accept_invite(ctx, invite_id): - """Accept an invite.""" - api_client = get_api_client(ctx) - with handle_client_errors(): - api = ksapi.InvitesApi(api_client) - result = api.accept_invite(invite_id) - print_result(ctx, to_dict(result)) +@invites.command("delete") +@click.argument("invite_id", type=click.UUID) +@click.pass_context +def delete_invite(ctx, invite_id): + """Delete an invite.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.InvitesApi(api_client) + api.delete_invite(invite_id) + click.echo(f"Deleted invite {invite_id}") -def register_delete(group: click.Group) -> None: - @group.command("invite") - @click.argument("invite_id", type=click.UUID) - @click.pass_context - def delete_invite(ctx, invite_id): - """Delete an invite.""" - api_client = get_api_client(ctx) - with handle_client_errors(): - api = ksapi.InvitesApi(api_client) - api.delete_invite(invite_id) - click.echo(f"Deleted invite {invite_id}") +@invites.command("accept") +@click.argument("invite_id", type=click.UUID) +@click.pass_context +def accept_invite(ctx, invite_id): + """Accept an invite.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.InvitesApi(api_client) + result = api.accept_invite(invite_id) + print_result(ctx, to_dict(result)) diff --git a/src/kscli/commands/path_parts.py b/src/kscli/commands/path_parts.py index 1b137cf..3dc1a2b 100644 --- a/src/kscli/commands/path_parts.py +++ b/src/kscli/commands/path_parts.py @@ -9,37 +9,40 @@ COLUMNS = ["id", "name", "type", "parent_path_part_id", "created_at"] -def register_get(group: click.Group) -> None: - @group.command("path-parts") - @click.option( - "--parent-path-id", - "parent_path_id", - type=click.UUID, - default=None, - help="Parent path ID; omit for root/top-level.", - ) - @click.option("--limit", type=int, default=20) - @click.option("--offset", type=int, default=0) - @click.pass_context - def get_path_parts(ctx, parent_path_id, limit, offset): - """List path parts.""" - api_client = get_api_client(ctx) - with handle_client_errors(): - api = ksapi.PathPartsApi(api_client) - result = api.list_path_parts( - limit=limit, offset=offset, parent_path_id=parent_path_id - ) - print_result(ctx, to_dict(result), columns=COLUMNS) - - -def register_describe(group: click.Group) -> None: - @group.command("path-part") - @click.argument("path_part_id", type=click.UUID) - @click.pass_context - def describe_path_part(ctx, path_part_id): - """Describe a path part.""" - api_client = get_api_client(ctx) - with handle_client_errors(): - api = ksapi.PathPartsApi(api_client) - result = api.get_path_part(path_part_id) - print_result(ctx, to_dict(result)) +@click.group("path-parts") +def path_parts(): + """Manage path parts.""" + + +@path_parts.command("list") +@click.option( + "--parent-path-id", + "parent_path_id", + type=click.UUID, + default=None, + help="Parent path ID; omit for root/top-level.", +) +@click.option("--limit", type=int, default=20) +@click.option("--offset", type=int, default=0) +@click.pass_context +def list_path_parts(ctx, parent_path_id, limit, offset): + """List path parts.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.PathPartsApi(api_client) + result = api.list_path_parts( + limit=limit, offset=offset, parent_path_id=parent_path_id + ) + print_result(ctx, to_dict(result), columns=COLUMNS) + + +@path_parts.command("describe") +@click.argument("path_part_id", type=click.UUID) +@click.pass_context +def describe_path_part(ctx, path_part_id): + """Describe a path part.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.PathPartsApi(api_client) + result = api.get_path_part(path_part_id) + print_result(ctx, to_dict(result)) diff --git a/src/kscli/commands/permissions.py b/src/kscli/commands/permissions.py index 2374624..27ab2fc 100644 --- a/src/kscli/commands/permissions.py +++ b/src/kscli/commands/permissions.py @@ -12,81 +12,82 @@ COLUMNS = ["id", "user_id", "path_part_id", "capability", "created_at"] -def register_get(group: click.Group) -> None: - @group.command("permissions") - @click.option("--tenant-id", type=click.UUID, default=None) - @click.option("--user-id", type=click.UUID, default=None) - @click.option("--limit", type=int, default=20) - @click.option("--offset", type=int, default=0) - @click.pass_context - def get_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"]) - 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) +@click.group("permissions") +def permissions(): + """Manage permissions.""" -def register_create(group: click.Group) -> None: - @group.command("permission") - @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.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"]) - api = ksapi.UserPermissionsApi(api_client) - result = api.create_user_permission( - ksapi.CreatePermissionRequest( - tenant_id=tid, - user_id=user_id, - path_part_id=path_part_id, - capability=capability, - ) - ) - print_result(ctx, to_dict(result)) +@permissions.command("list") +@click.option("--tenant-id", type=click.UUID, default=None) +@click.option("--user-id", type=click.UUID, default=None) +@click.option("--limit", type=int, default=20) +@click.option("--offset", type=int, default=0) +@click.pass_context +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"]) + 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) -def register_update(group: click.Group) -> None: - @group.command("permission") - @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.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"]) - api = ksapi.UserPermissionsApi(api_client) - result = api.update_user_permission( - permission_id, - tid, - ksapi.UpdatePermissionRequest(capability=capability), +@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.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"]) + api = ksapi.UserPermissionsApi(api_client) + result = api.create_user_permission( + ksapi.CreatePermissionRequest( + tenant_id=tid, + user_id=user_id, + path_part_id=path_part_id, + capability=capability, ) - print_result(ctx, to_dict(result)) + ) + print_result(ctx, to_dict(result)) + + +@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.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"]) + api = ksapi.UserPermissionsApi(api_client) + result = api.update_user_permission( + permission_id, + tid, + ksapi.UpdatePermissionRequest(capability=capability), + ) + print_result(ctx, to_dict(result)) -def register_delete(group: click.Group) -> None: - @group.command("permission") - @click.argument("permission_id", type=click.UUID) - @click.option("--tenant-id", type=click.UUID, default=None) - @click.pass_context - 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"]) - api = ksapi.UserPermissionsApi(api_client) - api.delete_user_permission(permission_id, tid) - click.echo(f"Deleted permission {permission_id}") +@permissions.command("delete") +@click.argument("permission_id", type=click.UUID) +@click.option("--tenant-id", type=click.UUID, default=None) +@click.pass_context +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"]) + 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 fe3640c..c311d9d 100644 --- a/src/kscli/commands/sections.py +++ b/src/kscli/commands/sections.py @@ -7,75 +7,76 @@ from kscli.output import print_result -def register_describe(group: click.Group) -> None: - @group.command("section") - @click.argument("section_id", type=click.UUID) - @click.pass_context - def describe_section(ctx, section_id): - """Describe a section.""" - api_client = get_api_client(ctx) - with handle_client_errors(): - api = ksapi.SectionsApi(api_client) - result = api.get_section(section_id) - print_result(ctx, to_dict(result)) +@click.group("sections") +def sections(): + """Manage sections.""" -def register_create(group: click.Group) -> None: - @group.command("section") - @click.option("--name", required=True) - @click.option("--parent-path-id", type=click.UUID, required=True) - @click.option("--page-number", type=int, default=None) - @click.option("--prev-sibling-path-id", type=click.UUID, default=None) - @click.pass_context - def create_section(ctx, name, parent_path_id, page_number, prev_sibling_path_id): - """Create a section.""" - api_client = get_api_client(ctx) - with handle_client_errors(): - api = ksapi.SectionsApi(api_client) - result = api.create_section( - ksapi.CreateSectionRequest( - name=name, - parent_path_id=parent_path_id, - page_number=page_number, - prev_sibling_path_id=prev_sibling_path_id, - ) - ) - print_result(ctx, to_dict(result)) +@sections.command("describe") +@click.argument("section_id", type=click.UUID) +@click.pass_context +def describe_section(ctx, section_id): + """Describe a section.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.SectionsApi(api_client) + result = api.get_section(section_id) + print_result(ctx, to_dict(result)) -def register_update(group: click.Group) -> None: - @group.command("section") - @click.argument("section_id", type=click.UUID) - @click.option("--name", default=None) - @click.option("--page-number", type=int, default=None) - @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): - """Update a section.""" - api_client = get_api_client(ctx) - with handle_client_errors(): - api = ksapi.SectionsApi(api_client) - result = api.update_section( - section_id, - ksapi.UpdateSectionRequest( - name=name, - page_number=page_number, - prev_sibling_path_id=prev_sibling_path_id, - move_to_head=move_to_head, - ), +@sections.command("create") +@click.option("--name", required=True) +@click.option("--parent-path-id", type=click.UUID, required=True) +@click.option("--page-number", type=int, default=None) +@click.option("--prev-sibling-path-id", type=click.UUID, default=None) +@click.pass_context +def create_section(ctx, name, parent_path_id, page_number, prev_sibling_path_id): + """Create a section.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.SectionsApi(api_client) + result = api.create_section( + ksapi.CreateSectionRequest( + name=name, + parent_path_id=parent_path_id, + page_number=page_number, + prev_sibling_path_id=prev_sibling_path_id, ) - print_result(ctx, to_dict(result)) + ) + print_result(ctx, to_dict(result)) + + +@sections.command("update") +@click.argument("section_id", type=click.UUID) +@click.option("--name", default=None) +@click.option("--page-number", type=int, default=None) +@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): + """Update a section.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.SectionsApi(api_client) + result = api.update_section( + section_id, + ksapi.UpdateSectionRequest( + name=name, + page_number=page_number, + prev_sibling_path_id=prev_sibling_path_id, + move_to_head=move_to_head, + ), + ) + print_result(ctx, to_dict(result)) -def register_delete(group: click.Group) -> None: - @group.command("section") - @click.argument("section_id", type=click.UUID) - @click.pass_context - def delete_section(ctx, section_id): - """Delete a section.""" - api_client = get_api_client(ctx) - with handle_client_errors(): - api = ksapi.SectionsApi(api_client) - api.delete_section(section_id) - click.echo(f"Deleted section {section_id}") +@sections.command("delete") +@click.argument("section_id", type=click.UUID) +@click.pass_context +def delete_section(ctx, section_id): + """Delete a section.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.SectionsApi(api_client) + api.delete_section(section_id) + click.echo(f"Deleted section {section_id}") diff --git a/src/kscli/commands/settings.py b/src/kscli/commands/settings.py index 513aff7..f0dfa95 100644 --- a/src/kscli/commands/settings.py +++ b/src/kscli/commands/settings.py @@ -20,11 +20,6 @@ "base_url": "http://localhost:8000", "verify_ssl": False, }, - "dev": { - "environment": "dev", - "base_url": "https://api-staging.knowledgestack.ai", - "verify_ssl": True, - }, "prod": { "environment": "prod", "base_url": "https://api.knowledgestack.ai", @@ -39,14 +34,14 @@ def settings(): @settings.command("environment") -@click.argument("env_name", type=click.Choice(["local", "dev", "prod"])) +@click.argument("env_name", type=click.Choice(["local", "prod"])) @click.option( "--base-url", default=None, help="Override default API base URL for the selected environment", ) def environment(env_name: str, base_url: str | None) -> None: - """Set the environment (local, dev, prod) and associated config.""" + """Set the environment (local, prod) and associated config.""" preset = _ENV_PRESETS[env_name].copy() if base_url: preset["base_url"] = base_url diff --git a/src/kscli/commands/tags.py b/src/kscli/commands/tags.py index c0e13b3..f8941b2 100644 --- a/src/kscli/commands/tags.py +++ b/src/kscli/commands/tags.py @@ -9,89 +9,89 @@ COLUMNS = ["id", "name", "color", "description", "created_at"] -def register_get(group: click.Group) -> None: - @group.command("tags") - @click.option("--limit", type=int, default=20) - @click.option("--offset", type=int, default=0) - @click.pass_context - def get_tags(ctx, limit, offset): - """List tags.""" - api_client = get_api_client(ctx) - 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) - - -def register_describe(group: click.Group) -> None: - @group.command("tag") - @click.argument("tag_id", type=click.UUID) - @click.pass_context - def describe_tag(ctx, tag_id): - """Describe a tag.""" - api_client = get_api_client(ctx) - with handle_client_errors(): - api = ksapi.TagsApi(api_client) - result = api.get_tag(tag_id) - print_result(ctx, to_dict(result)) - - -def register_create(group: click.Group) -> None: - @group.command("tag") - @click.option("--name", required=True) - @click.option("--color", default=None) - @click.option("--description", default=None) - @click.pass_context - def create_tag(ctx, name, color, description): - """Create a tag.""" - api_client = get_api_client(ctx) - with handle_client_errors(): - 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 - ) - ) - print_result(ctx, to_dict(result)) - - -def register_update(group: click.Group) -> None: - @group.command("tag") - @click.argument("tag_id", type=click.UUID) - @click.option("--name", default=None) - @click.option("--color", default=None) - @click.option("--description", default=None) - @click.pass_context - def update_tag(ctx, tag_id, name, color, description): - """Update a tag.""" - api_client = get_api_client(ctx) - with handle_client_errors(): - api = ksapi.TagsApi(api_client) - color_val = color.lstrip("#") if color else color - result = api.update_tag( - tag_id, - ksapi.UpdateTagRequest( - name=name, color=color_val, description=description - ), +@click.group("tags") +def tags(): + """Manage tags.""" + + +@tags.command("list") +@click.option("--limit", type=int, default=20) +@click.option("--offset", type=int, default=0) +@click.pass_context +def list_tags(ctx, limit, offset): + """List tags.""" + api_client = get_api_client(ctx) + 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) + + +@tags.command("describe") +@click.argument("tag_id", type=click.UUID) +@click.pass_context +def describe_tag(ctx, tag_id): + """Describe a tag.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.TagsApi(api_client) + result = api.get_tag(tag_id) + print_result(ctx, to_dict(result)) + + +@tags.command("create") +@click.option("--name", required=True) +@click.option("--color", default=None) +@click.option("--description", default=None) +@click.pass_context +def create_tag(ctx, name, color, description): + """Create a tag.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + 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 ) - print_result(ctx, to_dict(result)) + ) + print_result(ctx, to_dict(result)) + + +@tags.command("update") +@click.argument("tag_id", type=click.UUID) +@click.option("--name", default=None) +@click.option("--color", default=None) +@click.option("--description", default=None) +@click.pass_context +def update_tag(ctx, tag_id, name, color, description): + """Update a tag.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.TagsApi(api_client) + color_val = color.lstrip("#") if color else color + result = api.update_tag( + tag_id, + ksapi.UpdateTagRequest( + name=name, color=color_val, description=description + ), + ) + print_result(ctx, to_dict(result)) -def register_delete(group: click.Group) -> None: - @group.command("tag") - @click.argument("tag_id", type=click.UUID) - @click.pass_context - def delete_tag(ctx, tag_id): - """Delete a tag.""" - api_client = get_api_client(ctx) - with handle_client_errors(): - api = ksapi.TagsApi(api_client) - api.delete_tag(tag_id) - click.echo(f"Deleted tag {tag_id}") +@tags.command("delete") +@click.argument("tag_id", type=click.UUID) +@click.pass_context +def delete_tag(ctx, tag_id): + """Delete a tag.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.TagsApi(api_client) + api.delete_tag(tag_id) + click.echo(f"Deleted tag {tag_id}") -@click.command("attach-tag") +@tags.command("attach") @click.argument("tag_id", type=click.UUID) @click.option("--path-part-id", type=click.UUID, required=True) @click.pass_context @@ -107,7 +107,7 @@ def attach_tag(ctx, tag_id, path_part_id): print_result(ctx, to_dict(result)) -@click.command("detach-tag") +@tags.command("detach") @click.argument("tag_id", type=click.UUID) @click.option("--path-part-id", type=click.UUID, required=True) @click.pass_context diff --git a/src/kscli/commands/tenants.py b/src/kscli/commands/tenants.py index dcf44d3..4952859 100644 --- a/src/kscli/commands/tenants.py +++ b/src/kscli/commands/tenants.py @@ -12,92 +12,91 @@ USER_COLUMNS = ["id", "email", "current_tenant_role", "created_at"] -def register_get(group: click.Group) -> None: - @group.command("tenants") - @click.option("--limit", type=int, default=20) - @click.option("--offset", type=int, default=0) - @click.pass_context - def get_tenants(ctx, limit, offset): - """List tenants.""" - api_client = get_api_client(ctx) - 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) - - -def register_get_users(group: click.Group) -> None: - @group.command("tenant-users") - @click.argument("tenant_id", type=click.UUID) - @click.option("--limit", type=int, default=20) - @click.option("--offset", type=int, default=0) - @click.pass_context - def get_tenant_users(ctx, tenant_id, limit, offset): - """List users in a tenant.""" - api_client = get_api_client(ctx) - 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) - - -def register_describe(group: click.Group) -> None: - @group.command("tenant") - @click.argument("tenant_id", type=click.UUID) - @click.pass_context - def describe_tenant(ctx, tenant_id): - """Describe a tenant.""" - api_client = get_api_client(ctx) - with handle_client_errors(): - api = ksapi.TenantsApi(api_client) - result = api.get_tenant(tenant_id) - print_result(ctx, to_dict(result)) - - -def register_create(group: click.Group) -> None: - @group.command("tenant") - @click.option("--name", required=True) - @click.option("--idp-config", default=None, help="JSON string of IDP config") - @click.pass_context - def create_tenant(ctx, name, idp_config): - """Create a tenant.""" - api_client = get_api_client(ctx) - 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)) - - -def register_update(group: click.Group) -> None: - @group.command("tenant") - @click.argument("tenant_id", type=click.UUID) - @click.option("--name", default=None) - @click.option("--idp-config", default=None, help="JSON string of IDP config") - @click.pass_context - def update_tenant(ctx, tenant_id, name, idp_config): - """Update a tenant.""" - api_client = get_api_client(ctx) - with handle_client_errors(): - api = ksapi.TenantsApi(api_client) - idp = json.loads(idp_config) if idp_config else None - result = api.update_tenant( - tenant_id, - ksapi.UpdateTenantRequest(name=name, idp_config=idp), - ) - print_result(ctx, to_dict(result)) - - -def register_delete(group: click.Group) -> None: - @group.command("tenant") - @click.argument("tenant_id", type=click.UUID) - @click.pass_context - def delete_tenant(ctx, tenant_id): - """Delete a tenant.""" - api_client = get_api_client(ctx) - with handle_client_errors(): - api = ksapi.TenantsApi(api_client) - api.delete_tenant(tenant_id) - click.echo(f"Deleted tenant {tenant_id}") +@click.group("tenants") +def tenants(): + """Manage tenants.""" + + +@tenants.command("list") +@click.option("--limit", type=int, default=20) +@click.option("--offset", type=int, default=0) +@click.pass_context +def list_tenants(ctx, limit, offset): + """List tenants.""" + api_client = get_api_client(ctx) + 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) + + +@tenants.command("describe") +@click.argument("tenant_id", type=click.UUID) +@click.pass_context +def describe_tenant(ctx, tenant_id): + """Describe a tenant.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.TenantsApi(api_client) + result = api.get_tenant(tenant_id) + print_result(ctx, to_dict(result)) + + +@tenants.command("create") +@click.option("--name", required=True) +@click.option("--idp-config", default=None, help="JSON string of IDP config") +@click.pass_context +def create_tenant(ctx, name, idp_config): + """Create a tenant.""" + api_client = get_api_client(ctx) + 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)) + + +@tenants.command("update") +@click.argument("tenant_id", type=click.UUID) +@click.option("--name", default=None) +@click.option("--idp-config", default=None, help="JSON string of IDP config") +@click.pass_context +def update_tenant(ctx, tenant_id, name, idp_config): + """Update a tenant.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.TenantsApi(api_client) + idp = json.loads(idp_config) if idp_config else None + result = api.update_tenant( + tenant_id, + ksapi.UpdateTenantRequest(name=name, idp_config=idp), + ) + print_result(ctx, to_dict(result)) + + +@tenants.command("delete") +@click.argument("tenant_id", type=click.UUID) +@click.pass_context +def delete_tenant(ctx, tenant_id): + """Delete a tenant.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.TenantsApi(api_client) + api.delete_tenant(tenant_id) + click.echo(f"Deleted tenant {tenant_id}") + + +@tenants.command("list-users") +@click.argument("tenant_id", type=click.UUID) +@click.option("--limit", type=int, default=20) +@click.option("--offset", type=int, default=0) +@click.pass_context +def list_tenant_users(ctx, tenant_id, limit, offset): + """List users in a tenant.""" + api_client = get_api_client(ctx) + 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) diff --git a/src/kscli/commands/thread_messages.py b/src/kscli/commands/thread_messages.py new file mode 100644 index 0000000..4c8224b --- /dev/null +++ b/src/kscli/commands/thread_messages.py @@ -0,0 +1,60 @@ +"""Thread message commands.""" + +import click +import ksapi + +from kscli.client import get_api_client, handle_client_errors, to_dict +from kscli.output import print_result + +COLUMNS = ["id", "role", "content", "created_at"] + + +@click.group("thread-messages") +def thread_messages(): + """Manage thread messages.""" + + +@thread_messages.command("list") +@click.option("--thread-id", type=click.UUID, required=True) +@click.option("--limit", type=int, default=20) +@click.option("--offset", type=int, default=0) +@click.pass_context +def list_messages(ctx, thread_id, limit, offset): + """List messages in a thread.""" + api_client = get_api_client(ctx) + 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) + + +@thread_messages.command("describe") +@click.argument("message_id", type=click.UUID) +@click.option("--thread-id", type=click.UUID, required=True) +@click.pass_context +def describe_message(ctx, message_id, thread_id): + """Describe a message.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.ThreadMessagesApi(api_client) + result = api.get_thread_message(thread_id, message_id) + print_result(ctx, to_dict(result)) + + +@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.pass_context +def create_message(ctx, thread_id, content, role): + """Create a message.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.ThreadMessagesApi(api_client) + result = api.create_thread_message( + thread_id, + ksapi.CreateThreadMessageRequest( + content={"text": content}, role=role + ), + ) + print_result(ctx, to_dict(result)) diff --git a/src/kscli/commands/threads.py b/src/kscli/commands/threads.py index 9b3dc20..9cf4d93 100644 --- a/src/kscli/commands/threads.py +++ b/src/kscli/commands/threads.py @@ -1,4 +1,4 @@ -"""Thread and message commands.""" +"""Thread commands.""" import click import ksapi @@ -6,136 +6,86 @@ from kscli.client import get_api_client, handle_client_errors, to_dict from kscli.output import print_result -THREAD_COLUMNS = ["id", "title", "parent_path_part_id", "created_at"] -MESSAGE_COLUMNS = ["id", "role", "content", "created_at"] - - -def register_get_threads(group: click.Group) -> None: - @group.command("threads") - @click.option("--parent-path-part-id", type=click.UUID, default=None) - @click.option("--limit", type=int, default=20) - @click.option("--offset", type=int, default=0) - @click.pass_context - def get_threads(ctx, parent_path_part_id, limit, offset): - """List threads.""" - api_client = get_api_client(ctx) - with handle_client_errors(): - api = ksapi.ThreadsApi(api_client) - result = api.list_threads( - limit=limit, offset=offset, parent_path_part_id=parent_path_part_id +COLUMNS = ["id", "title", "parent_path_part_id", "created_at"] + + +@click.group("threads") +def threads(): + """Manage threads.""" + + +@threads.command("list") +@click.option("--parent-path-part-id", type=click.UUID, default=None) +@click.option("--limit", type=int, default=20) +@click.option("--offset", type=int, default=0) +@click.pass_context +def list_threads(ctx, parent_path_part_id, limit, offset): + """List threads.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.ThreadsApi(api_client) + result = api.list_threads( + limit=limit, offset=offset, parent_path_part_id=parent_path_part_id + ) + print_result(ctx, to_dict(result), columns=COLUMNS) + + +@threads.command("describe") +@click.argument("thread_id", type=click.UUID) +@click.pass_context +def describe_thread(ctx, thread_id): + """Describe a thread.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.ThreadsApi(api_client) + result = api.get_thread(thread_id) + print_result(ctx, to_dict(result)) + + +@threads.command("create") +@click.option("--title", required=True) +@click.option("--parent-path-part-id", type=click.UUID, default=None) +@click.pass_context +def create_thread(ctx, title, parent_path_part_id): + """Create a thread.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.ThreadsApi(api_client) + result = api.create_thread( + ksapi.CreateThreadRequest( + title=title, + parent_path_part_id=parent_path_part_id, ) - print_result(ctx, to_dict(result), columns=THREAD_COLUMNS) - - -def register_get_messages(group: click.Group) -> None: - @group.command("messages") - @click.option("--thread-id", type=click.UUID, required=True) - @click.option("--limit", type=int, default=20) - @click.option("--offset", type=int, default=0) - @click.pass_context - def get_messages(ctx, thread_id, limit, offset): - """List messages in a thread.""" - api_client = get_api_client(ctx) - 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=MESSAGE_COLUMNS) - - -def register_describe_thread(group: click.Group) -> None: - @group.command("thread") - @click.argument("thread_id", type=click.UUID) - @click.pass_context - def describe_thread(ctx, thread_id): - """Describe a thread.""" - api_client = get_api_client(ctx) - with handle_client_errors(): - api = ksapi.ThreadsApi(api_client) - result = api.get_thread(thread_id) - print_result(ctx, to_dict(result)) - - -def register_describe_message(group: click.Group) -> None: - @group.command("message") - @click.argument("message_id", type=click.UUID) - @click.option("--thread-id", type=click.UUID, required=True) - @click.pass_context - def describe_message(ctx, message_id, thread_id): - """Describe a message.""" - api_client = get_api_client(ctx) - with handle_client_errors(): - api = ksapi.ThreadMessagesApi(api_client) - result = api.get_thread_message(thread_id, message_id) - print_result(ctx, to_dict(result)) - - -def register_create_thread(group: click.Group) -> None: - @group.command("thread") - @click.option("--title", required=True) - @click.option("--parent-path-part-id", type=click.UUID, default=None) - @click.pass_context - def create_thread(ctx, title, parent_path_part_id): - """Create a thread.""" - api_client = get_api_client(ctx) - with handle_client_errors(): - api = ksapi.ThreadsApi(api_client) - result = api.create_thread( - ksapi.CreateThreadRequest( - title=title, - parent_path_part_id=parent_path_part_id, - ) - ) - print_result(ctx, to_dict(result)) - - -def register_create_message(group: click.Group) -> None: - @group.command("message") - @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.pass_context - def create_message(ctx, thread_id, content, role): - """Create a message.""" - api_client = get_api_client(ctx) - with handle_client_errors(): - api = ksapi.ThreadMessagesApi(api_client) - result = api.create_thread_message( - thread_id, - ksapi.CreateThreadMessageRequest( - content={"text": content}, role=role - ), - ) - print_result(ctx, to_dict(result)) - - -def register_update_thread(group: click.Group) -> None: - @group.command("thread") - @click.argument("thread_id", type=click.UUID) - @click.option("--title", default=None) - @click.option("--parent-thread-id", type=click.UUID, default=None) - @click.pass_context - def update_thread(ctx, thread_id, title, parent_thread_id): - """Update a thread.""" - api_client = get_api_client(ctx) - with handle_client_errors(): - api = ksapi.ThreadsApi(api_client) - result = api.update_thread( - thread_id, - ksapi.UpdateThreadRequest( - title=title, parent_thread_id=parent_thread_id - ), - ) - print_result(ctx, to_dict(result)) - - -def register_delete_thread(group: click.Group) -> None: - @group.command("thread") - @click.argument("thread_id", type=click.UUID) - @click.pass_context - def delete_thread(ctx, thread_id): - """Delete a thread.""" - api_client = get_api_client(ctx) - with handle_client_errors(): - api = ksapi.ThreadsApi(api_client) - api.delete_thread(thread_id) - click.echo(f"Deleted thread {thread_id}") + ) + print_result(ctx, to_dict(result)) + + +@threads.command("update") +@click.argument("thread_id", type=click.UUID) +@click.option("--title", default=None) +@click.option("--parent-thread-id", type=click.UUID, default=None) +@click.pass_context +def update_thread(ctx, thread_id, title, parent_thread_id): + """Update a thread.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.ThreadsApi(api_client) + result = api.update_thread( + thread_id, + ksapi.UpdateThreadRequest( + title=title, parent_thread_id=parent_thread_id + ), + ) + print_result(ctx, to_dict(result)) + + +@threads.command("delete") +@click.argument("thread_id", type=click.UUID) +@click.pass_context +def delete_thread(ctx, thread_id): + """Delete a thread.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.ThreadsApi(api_client) + api.delete_thread(thread_id) + click.echo(f"Deleted thread {thread_id}") diff --git a/src/kscli/commands/users.py b/src/kscli/commands/users.py index ba6f7d4..40ddea3 100644 --- a/src/kscli/commands/users.py +++ b/src/kscli/commands/users.py @@ -7,16 +7,20 @@ from kscli.output import print_result -def register_update(group: click.Group) -> None: - @group.command("user") - @click.option("--default-tenant-id", type=click.UUID, required=True) - @click.pass_context - def update_user(ctx, default_tenant_id): - """Update current user preferences.""" - api_client = get_api_client(ctx) - with handle_client_errors(): - api = ksapi.UsersApi(api_client) - result = api.update_me( - ksapi.UpdateUserRequest(default_tenant_id=default_tenant_id) - ) - print_result(ctx, to_dict(result)) +@click.group("users") +def users(): + """Manage users.""" + + +@users.command("update") +@click.option("--default-tenant-id", type=click.UUID, required=True) +@click.pass_context +def update_user(ctx, default_tenant_id): + """Update current user preferences.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.UsersApi(api_client) + result = api.update_me( + ksapi.UpdateUserRequest(default_tenant_id=default_tenant_id) + ) + print_result(ctx, to_dict(result)) diff --git a/src/kscli/commands/versions.py b/src/kscli/commands/versions.py deleted file mode 100644 index 911683b..0000000 --- a/src/kscli/commands/versions.py +++ /dev/null @@ -1,120 +0,0 @@ -"""Document version commands.""" - -import click -import ksapi - -from kscli.client import get_api_client, handle_client_errors, to_dict -from kscli.output import print_result - -COLUMNS = ["id", "document_id", "name", "created_at"] - - -def register_get(group: click.Group) -> None: - @group.command("versions") - @click.option("--document-id", type=click.UUID, required=True) - @click.option("--limit", type=int, default=20) - @click.option("--offset", type=int, default=0) - @click.pass_context - def get_versions(ctx, document_id, limit, offset): - """List versions for a document.""" - api_client = get_api_client(ctx) - with handle_client_errors(): - api = ksapi.DocumentVersionsApi(api_client) - result = api.list_document_versions( - document_id=document_id, limit=limit, offset=offset - ) - print_result(ctx, to_dict(result), columns=COLUMNS) - - -def register_describe(group: click.Group) -> None: - @group.command("version") - @click.argument("version_id", type=click.UUID) - @click.pass_context - def describe_version(ctx, version_id): - """Describe a version.""" - api_client = get_api_client(ctx) - with handle_client_errors(): - api = ksapi.DocumentVersionsApi(api_client) - result = api.get_document_version(version_id) - print_result(ctx, to_dict(result)) - - -def register_describe_contents(group: click.Group) -> None: - @group.command("version-contents") - @click.argument("version_id", type=click.UUID) - @click.option( - "--show-content", - is_flag=True, - default=False, - help="Show chunk content inline when --format tree is used.", - ) - @click.option( - "--sections-only", - is_flag=True, - default=False, - help="Exclude chunks from the tree output (sections only).", - ) - @click.pass_context - def describe_version_contents(ctx, version_id, show_content, sections_only): - """Get version contents (sections + chunks tree).""" - api_client = get_api_client(ctx) - 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) - - -def register_create(group: click.Group) -> None: - @group.command("version") - @click.option("--document-id", type=click.UUID, required=True) - @click.pass_context - def create_version(ctx, document_id): - """Create a new version.""" - api_client = get_api_client(ctx) - with handle_client_errors(): - api = ksapi.DocumentVersionsApi(api_client) - result = api.create_document_version(document_id=document_id) - print_result(ctx, to_dict(result)) - - -def register_update(group: click.Group) -> None: - @group.command("version") - @click.argument("version_id", type=click.UUID) - @click.option("--source-s3", default=None) - @click.pass_context - def update_version(ctx, version_id, source_s3): - """Update version metadata.""" - api_client = get_api_client(ctx) - with handle_client_errors(): - api = ksapi.DocumentVersionsApi(api_client) - result = api.update_document_version_metadata( - version_id, - ksapi.DocumentVersionMetadataUpdate(source_s3=source_s3), - ) - print_result(ctx, to_dict(result)) - - -def register_delete(group: click.Group) -> None: - @group.command("version") - @click.argument("version_id", type=click.UUID) - @click.pass_context - def delete_version(ctx, version_id): - """Delete a version.""" - api_client = get_api_client(ctx) - with handle_client_errors(): - api = ksapi.DocumentVersionsApi(api_client) - api.delete_document_version(version_id) - click.echo(f"Deleted version {version_id}") - - -def register_delete_contents(group: click.Group) -> None: - @group.command("version-contents") - @click.argument("version_id", type=click.UUID) - @click.pass_context - def delete_version_contents(ctx, version_id): - """Clear all contents under a version.""" - api_client = get_api_client(ctx) - with handle_client_errors(): - api = ksapi.DocumentVersionsApi(api_client) - api.clear_document_version_contents(version_id) - click.echo(f"Cleared contents of version {version_id}") diff --git a/src/kscli/commands/workflows.py b/src/kscli/commands/workflows.py index 0918e6a..01fb2ac 100644 --- a/src/kscli/commands/workflows.py +++ b/src/kscli/commands/workflows.py @@ -9,41 +9,55 @@ COLUMNS = ["workflow_id", "status", "document_id", "created_at", "last_run_timestamp"] -def register_get(group: click.Group) -> None: - @group.command("workflows") - @click.option("--limit", type=int, default=20) - @click.option("--offset", type=int, default=0) - @click.pass_context - def get_workflows(ctx, limit, offset): - """List workflows.""" - api_client = get_api_client(ctx) - 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) - - -def register_describe(group: click.Group) -> None: - @group.command("workflow") - @click.argument("workflow_id", type=click.UUID) - @click.pass_context - def describe_workflow(ctx, workflow_id): - """Describe a workflow.""" - api_client = get_api_client(ctx) - with handle_client_errors(): - api = ksapi.WorkflowsApi(api_client) - result = api.get_workflow(str(workflow_id)) - print_result(ctx, to_dict(result)) - - -@click.command("workflow") +@click.group("workflows") +def workflows(): + """Manage workflows.""" + + +@workflows.command("list") +@click.option("--limit", type=int, default=20) +@click.option("--offset", type=int, default=0) +@click.pass_context +def list_workflows(ctx, limit, offset): + """List workflows.""" + api_client = get_api_client(ctx) + 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) + + +@workflows.command("describe") +@click.argument("workflow_id", type=click.UUID) +@click.pass_context +def describe_workflow(ctx, workflow_id): + """Describe a workflow.""" + api_client = get_api_client(ctx) + with handle_client_errors(): + api = ksapi.WorkflowsApi(api_client) + result = api.get_workflow(str(workflow_id)) + print_result(ctx, to_dict(result)) + + +@workflows.command("cancel") +@click.argument("workflow_id", type=click.UUID) +@click.pass_context +def cancel_workflow(ctx, workflow_id): + """Cancel a workflow.""" + api_client = get_api_client(ctx) + 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)) + + +@workflows.command("rerun") @click.argument("workflow_id", type=click.UUID) -@click.option("--action", required=True) @click.pass_context -def workflow_action(ctx, workflow_id, action): - """Perform a workflow action.""" +def rerun_workflow(ctx, workflow_id): + """Rerun a workflow.""" api_client = get_api_client(ctx) with handle_client_errors(): api = ksapi.WorkflowsApi(api_client) - result = api.workflow_action(str(workflow_id), action) + result = api.workflow_action(str(workflow_id), ksapi.WorkflowAction.RERUN) print_result(ctx, to_dict(result)) diff --git a/src/kscli/config.py b/src/kscli/config.py index 8a546e8..5f7c599 100644 --- a/src/kscli/config.py +++ b/src/kscli/config.py @@ -83,6 +83,15 @@ def get_tls_config() -> tuple[bool, str | None]: return verify, bundle +def ensure_config() -> None: + """Create config directory and empty config file if they do not exist yet.""" + path = get_config_path() + if path.exists(): + return + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("{}\n") + + def write_config(updates: dict[str, Any]) -> None: """Merge updates into config file and write. Creates parent directory and file if needed.""" path = get_config_path() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/cli_helpers.py b/tests/e2e/cli_helpers.py new file mode 100644 index 0000000..5b9da7c --- /dev/null +++ b/tests/e2e/cli_helpers.py @@ -0,0 +1,127 @@ +"""Subprocess helpers for invoking kscli in e2e tests.""" + +import contextlib +import json +import os +import subprocess +from dataclasses import dataclass, field +from typing import Any + +# 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", + "KSCLI_BASE_URL", + "KSCLI_VERIFY_SSL", + "KSCLI_CREDENTIALS_PATH", + "KSCLI_CONFIG", + "KSCLI_FORMAT", + "KSCLI_CA_BUNDLE", +}) + + +@dataclass +class CliResult: + """Result of a kscli subprocess invocation.""" + + exit_code: int + stdout: str + stderr: str + json_output: Any = field(default=None) + + +def run_kscli( + args: list[str], + *, + env: dict[str, str], + timeout: int = 30, + format_json: bool = True, +) -> CliResult: + """Run kscli as a subprocess and return the result. + + The inherited os.environ is sanitized (kscli/admin keys removed) before + the test-provided ``env`` dict is merged on top, so the subprocess always + uses exactly the values the test intends. + + Args: + args: CLI arguments (e.g. ["folders", "list"]). + env: Environment variables (merged on top of sanitized os.environ). + timeout: Subprocess timeout in seconds. + format_json: If True, prepend --format json to args. + """ + cmd = ["kscli"] + # Always enforce the base URL via CLI flag (highest precedence), + # so config-file writes from settings tests can't redirect requests. + base_url = env.get("KSCLI_BASE_URL") + if base_url: + cmd.extend(["--base-url", base_url]) + if format_json: + cmd.extend(["--format", "json"]) + cmd.extend(args) + + # Start from os.environ, strip vars that could contaminate, then overlay test env. + merged_env = {k: v for k, v in os.environ.items() if k not in _SANITIZED_KEYS} + merged_env.update(env) + + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=timeout, + env=merged_env, + check=False, + ) + + json_output = None + if proc.stdout.strip(): + with contextlib.suppress(json.JSONDecodeError): + json_output = json.loads(proc.stdout) + + return CliResult( + exit_code=proc.returncode, + stdout=proc.stdout, + stderr=proc.stderr, + json_output=json_output, + ) + + +def run_kscli_ok( + args: list[str], + *, + env: dict[str, str], + timeout: int = 30, + format_json: bool = True, +) -> CliResult: + """Run kscli and assert exit code 0.""" + result = run_kscli(args, env=env, timeout=timeout, format_json=format_json) + assert result.exit_code == 0, ( + f"Expected exit code 0, got {result.exit_code}.\n" + f"Command: kscli {' '.join(args)}\n" + f"stdout: {result.stdout}\n" + f"stderr: {result.stderr}" + ) + return result + + +def run_kscli_fail( + args: list[str], + *, + env: dict[str, str], + expected_code: int | None = None, + timeout: int = 30, + format_json: bool = True, +) -> CliResult: + """Run kscli and assert non-zero exit code.""" + result = run_kscli(args, env=env, timeout=timeout, format_json=format_json) + assert result.exit_code != 0, ( + f"Expected non-zero exit code, got 0.\n" + f"Command: kscli {' '.join(args)}\n" + f"stdout: {result.stdout}" + ) + if expected_code is not None: + assert result.exit_code == expected_code, ( + f"Expected exit code {expected_code}, got {result.exit_code}.\n" + f"Command: kscli {' '.join(args)}\n" + f"stderr: {result.stderr}" + ) + return result diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py new file mode 100644 index 0000000..430b1a2 --- /dev/null +++ b/tests/e2e/conftest.py @@ -0,0 +1,181 @@ +"""E2E test fixtures for kscli CLI tests.""" + +import secrets +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import pytest + +if TYPE_CHECKING: + from collections.abc import Generator + +from tests.e2e.cli_helpers import run_kscli, run_kscli_ok + +# --------------------------------------------------------------------------- +# E2E environment constants +# --------------------------------------------------------------------------- + +E2E_BASE_URL = "http://localhost:28000" + +# Resolve ADMIN_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) +# --------------------------------------------------------------------------- + +PWUSER1_ID = "00000000-0000-0000-0001-000000000001" +PWUSER2_ID = "00000000-0000-0000-0001-000000000002" +PWUSER3_ID = "00000000-0000-0000-0001-000000000005" + +SHARED_TENANT_ID = "00000000-0000-0000-0002-000000000005" +PWUSER1_TENANT_ID = "00000000-0000-0000-0002-000000000001" + +AGENTS_FOLDER_PATH_PART_ID = "00000000-0000-0000-0003-000000000219" +SHARED_FOLDER_PATH_PART_ID = "00000000-0000-0000-0003-000000000100" +MANY_FOLDER_PATH_PART_ID = "00000000-0000-0000-0003-000000000101" +MANY_DOCS_FOLDER_PATH_PART_ID = "00000000-0000-0000-0003-000000000202" +NESTED_FOLDER_PATH_PART_ID = "00000000-0000-0000-0003-000000000203" +READONLY_TEST_FOLDER_PATH_PART_ID = "00000000-0000-0000-0003-000000000222" +USERS_FOLDER_PATH_PART_ID = "00000000-0000-0000-0003-000000000208" +PWUSER1_THREADS_FOLDER_PATH_PART_ID = "00000000-0000-0000-0003-000000000210" + +# In seed data, folder id == path_part_id +SHARED_FOLDER_ID = SHARED_FOLDER_PATH_PART_ID + +COMPLEX_DOC_ID = "00000000-0000-0000-0004-000000000051" +COMPLEX_DOC_ACTIVE_VERSION_ID = "00000000-0000-0000-0005-000000000061" +FIRST_SIMPLE_DOC_ID = "00000000-0000-0000-0004-000000000001" +FIRST_SIMPLE_VERSION_ID = "00000000-0000-0000-0005-000000000001" +FIRST_SECTION_ID = "00000000-0000-0000-0006-000000000001" +FIRST_CHUNK_ID = "00000000-0000-0000-0007-000000000001" +SECOND_CHUNK_ID = "00000000-0000-0000-0007-000000000002" +CHUNK_101_ID = "00000000-0000-0000-0007-000000000101" + +TAG_MANY_ID = "00000000-0000-0000-0012-000000000001" +TAG_NESTED_ID = "00000000-0000-0000-0012-000000000002" +TAG_SYSTEM_ID = "00000000-0000-0000-0012-000000000003" + +PERMISSION_PWUSER3_SHARED_READ = "00000000-0000-0000-0013-000000000001" + +NONEXISTENT_UUID = "00000000-0000-0000-0000-999999999999" + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="session") +def cli_env(tmp_path_factory: pytest.TempPathFactory) -> dict[str, str]: + """Session-scoped env dict with kscli config pointing at the e2e backend. + + 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. + """ + tmp = tmp_path_factory.mktemp("kscli") + 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, + "KSCLI_VERIFY_SSL": "false", + "KSCLI_CREDENTIALS_PATH": credentials_path, + "KSCLI_CONFIG": config_path, + } + + +@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. + + 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, + ], + env=cli_env, + format_json=False, + ) + return cli_env + + +@pytest.fixture(scope="session") +def kscli_parent_folder( + cli_authenticated: dict[str, str], +) -> Generator[dict[str, Any]]: + """Session-scoped folder at /agents/kscli_ for test isolation. + + Creates the folder at session start; deletes it at session teardown. + """ + result = run_kscli_ok( + [ + "folders", "create", + "--name", f"kscli_{secrets.token_hex(4)}", + "--parent-path-part-id", AGENTS_FOLDER_PATH_PART_ID, + ], + env=cli_authenticated, + ) + folder = result.json_output + yield folder + # Teardown: delete (suppress errors if already gone) + run_kscli( + ["folders", "delete", folder["id"]], + env=cli_authenticated, + format_json=False, + ) + + +@pytest.fixture +def isolation_folder( + cli_authenticated: dict[str, str], + kscli_parent_folder: dict[str, Any], +) -> Generator[dict[str, Any]]: + """Per-test ephemeral folder for write-test isolation. + + Creates iso_{hex} under the session parent; cascade-deletes on teardown. + """ + result = run_kscli_ok( + [ + "folders", "create", + "--name", f"iso_{secrets.token_hex(6)}", + "--parent-path-part-id", kscli_parent_folder["path_part_id"], + ], + env=cli_authenticated, + ) + folder = result.json_output + yield folder + run_kscli( + ["folders", "delete", folder["id"]], + env=cli_authenticated, + format_json=False, + ) diff --git a/tests/e2e/test_cli_auth.py b/tests/e2e/test_cli_auth.py new file mode 100644 index 0000000..72fdc3f --- /dev/null +++ b/tests/e2e/test_cli_auth.py @@ -0,0 +1,65 @@ +"""E2E tests for authentication commands: assume-user, whoami.""" + +import tempfile +from pathlib import Path + +import pytest + +from tests.e2e.cli_helpers import run_kscli_fail, run_kscli_ok +from tests.e2e.conftest import ( + NONEXISTENT_UUID, + PWUSER1_ID, + PWUSER1_TENANT_ID, + SHARED_TENANT_ID, +) + +pytestmark = pytest.mark.e2e + + +class TestCliAuth: + """Authentication command tests.""" + + def test_assume_user_success(self, cli_env: dict[str, str]) -> None: + """assume-user with valid credentials succeeds.""" + result = run_kscli_ok( + [ + "assume-user", + "--tenant-id", SHARED_TENANT_ID, + "--user-id", PWUSER1_ID, + ], + env=cli_env, + format_json=False, + ) + assert "Authenticated as user" in result.stdout + assert PWUSER1_ID in result.stdout + + def test_whoami_shows_identity(self, cli_authenticated: dict[str, str]) -> None: + """Whoami returns the current user info.""" + result = run_kscli_ok(["whoami"], env=cli_authenticated) + data = result.json_output + 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.""" + with tempfile.TemporaryDirectory() as tmp: + env = { + **cli_env, + "KSCLI_CREDENTIALS_PATH": str(Path(tmp) / "no_creds"), + } + 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, + ], + env=cli_env, + format_json=False, + ) diff --git a/tests/e2e/test_cli_chunk_lineages.py b/tests/e2e/test_cli_chunk_lineages.py new file mode 100644 index 0000000..b002a41 --- /dev/null +++ b/tests/e2e/test_cli_chunk_lineages.py @@ -0,0 +1,108 @@ +"""E2E tests for chunk-lineages commands.""" + +from typing import Any + +import pytest + +from tests.e2e.cli_helpers import run_kscli_ok +from tests.e2e.conftest import CHUNK_101_ID + +pytestmark = pytest.mark.e2e + + +class TestCliChunkLineagesRead: + """Read-only chunk lineage tests.""" + + def test_describe_chunk_lineage(self, cli_authenticated: dict[str, str]) -> None: + """Describe lineage for a chunk that has parents and children.""" + result = run_kscli_ok( + ["chunk-lineages", "describe", CHUNK_101_ID], + env=cli_authenticated, + ) + data = result.json_output + assert data is not None + + +class TestCliChunkLineagesWrite: + """Write chunk lineage tests.""" + + def test_create_and_delete_chunk_lineage( + self, + cli_authenticated: dict[str, str], + isolation_folder: dict[str, Any], + ) -> None: + """Create and delete a lineage link between two new chunks.""" + parent_id = isolation_folder["path_part_id"] + + # Create document + version + section + doc = run_kscli_ok( + [ + "documents", "create", + "--name", "lineage-doc", + "--parent-path-part-id", parent_id, + "--type", "UNKNOWN", + "--origin", "SOURCE", + ], + env=cli_authenticated, + ).json_output + + version = run_kscli_ok( + ["document-versions", "create", "--document-id", doc["id"]], + env=cli_authenticated, + ).json_output + + section = run_kscli_ok( + [ + "sections", "create", + "--name", "lineage-section", + "--parent-path-id", version["path_part_id"], + ], + env=cli_authenticated, + ).json_output + + # Create two chunks + parent_chunk = run_kscli_ok( + [ + "chunks", "create", + "--content", "Parent chunk", + "--section-id", section["path_part_id"], + ], + env=cli_authenticated, + ).json_output + + child_chunk = run_kscli_ok( + [ + "chunks", "create", + "--content", "Child chunk", + "--section-id", section["path_part_id"], + ], + env=cli_authenticated, + ).json_output + + # Create lineage link + run_kscli_ok( + [ + "chunk-lineages", "create", + "--parent-chunk-id", parent_chunk["id"], + "--child-chunk-id", child_chunk["id"], + ], + env=cli_authenticated, + ) + + # Verify lineage exists + result = run_kscli_ok( + ["chunk-lineages", "describe", child_chunk["id"]], + env=cli_authenticated, + ) + assert result.json_output is not None + + # Delete lineage link + run_kscli_ok( + [ + "chunk-lineages", "delete", + "--parent-chunk-id", parent_chunk["id"], + "--child-chunk-id", child_chunk["id"], + ], + env=cli_authenticated, + format_json=False, + ) diff --git a/tests/e2e/test_cli_chunks.py b/tests/e2e/test_cli_chunks.py new file mode 100644 index 0000000..db9bfbb --- /dev/null +++ b/tests/e2e/test_cli_chunks.py @@ -0,0 +1,181 @@ +"""E2E tests for chunk commands.""" + +from typing import Any + +import pytest + +from tests.e2e.cli_helpers import run_kscli_fail, run_kscli_ok +from tests.e2e.conftest import FIRST_CHUNK_ID, NONEXISTENT_UUID + +pytestmark = pytest.mark.e2e + + +class TestCliChunksRead: + """Read-only chunk tests.""" + + def test_describe_chunk(self, cli_authenticated: dict[str, str]) -> None: + """Describe a known seed chunk.""" + result = run_kscli_ok( + ["chunks", "describe", FIRST_CHUNK_ID], + env=cli_authenticated, + ) + chunk = result.json_output + assert isinstance(chunk, dict) + assert chunk["id"] == FIRST_CHUNK_ID + + def test_describe_chunk_not_found(self, cli_authenticated: dict[str, str]) -> None: + """Describe a nonexistent chunk returns exit code 3.""" + run_kscli_fail( + ["chunks", "describe", NONEXISTENT_UUID], + env=cli_authenticated, + expected_code=3, + ) + + def test_create_chunk_requires_parent(self, cli_authenticated: dict[str, str]) -> None: + """Creating a chunk without --version-id or --section-id fails.""" + run_kscli_fail( + [ + "chunks", "create", + "--content", "test content", + ], + env=cli_authenticated, + ) + + +class TestCliChunksWrite: + """Write chunk tests using isolation_folder.""" + + def _create_doc_version_section( + self, + cli_authenticated: dict[str, str], + isolation_folder: dict[str, Any], + ) -> dict[str, str]: + """Helper: create doc + version + section, return IDs.""" + parent_id = isolation_folder["path_part_id"] + + doc = run_kscli_ok( + [ + "documents", "create", + "--name", "chunk-test-doc", + "--parent-path-part-id", parent_id, + "--type", "UNKNOWN", + "--origin", "SOURCE", + ], + env=cli_authenticated, + ).json_output + + version = run_kscli_ok( + ["document-versions", "create", "--document-id", doc["id"]], + env=cli_authenticated, + ).json_output + + section = run_kscli_ok( + [ + "sections", "create", + "--name", "chunk-section", + "--parent-path-id", version["path_part_id"], + ], + env=cli_authenticated, + ).json_output + + return { + "doc_id": doc["id"], + "version_path_part_id": version["path_part_id"], + "section_path_part_id": section["path_part_id"], + } + + def test_create_describe_update_delete_chunk( + self, + cli_authenticated: dict[str, str], + isolation_folder: dict[str, Any], + ) -> None: + """Full CRUD lifecycle for a chunk.""" + ids = self._create_doc_version_section(cli_authenticated, isolation_folder) + + # Create chunk under section + result = run_kscli_ok( + [ + "chunks", "create", + "--content", "Hello, world!", + "--section-id", ids["section_path_part_id"], + "--chunk-type", "TEXT", + ], + env=cli_authenticated, + ) + chunk = result.json_output + chunk_id = chunk["id"] + + # Describe + result = run_kscli_ok( + ["chunks", "describe", chunk_id], + env=cli_authenticated, + ) + assert result.json_output["id"] == chunk_id + + # Update content + run_kscli_ok( + [ + "chunks", "update-content", chunk_id, + "--content", "Updated content", + ], + env=cli_authenticated, + ) + + # Delete + run_kscli_ok( + ["chunks", "delete", chunk_id], + env=cli_authenticated, + format_json=False, + ) + + # Verify deleted + run_kscli_fail( + ["chunks", "describe", chunk_id], + env=cli_authenticated, + expected_code=3, + ) + + def test_create_chunk_with_metadata( + self, + cli_authenticated: dict[str, str], + isolation_folder: dict[str, Any], + ) -> None: + """Create a chunk with custom metadata.""" + ids = self._create_doc_version_section(cli_authenticated, isolation_folder) + + result = run_kscli_ok( + [ + "chunks", "create", + "--content", "Metadata test", + "--version-id", ids["version_path_part_id"], + "--metadata", '{"polygons": [{"page": 1, "polygon": {"x": 0, "y": 0, "width": 100, "height": 50}}]}', + ], + env=cli_authenticated, + ) + chunk = result.json_output + assert chunk is not None + + def test_update_chunk_metadata( + self, + cli_authenticated: dict[str, str], + isolation_folder: dict[str, Any], + ) -> None: + """Update chunk metadata.""" + ids = self._create_doc_version_section(cli_authenticated, isolation_folder) + + chunk = run_kscli_ok( + [ + "chunks", "create", + "--content", "Meta update test", + "--section-id", ids["section_path_part_id"], + ], + env=cli_authenticated, + ).json_output + + run_kscli_ok( + [ + "chunks", "update", chunk["id"], + "--metadata", '{"polygons": []}', + ], + env=cli_authenticated, + ) diff --git a/tests/e2e/test_cli_document_versions.py b/tests/e2e/test_cli_document_versions.py new file mode 100644 index 0000000..482e3d7 --- /dev/null +++ b/tests/e2e/test_cli_document_versions.py @@ -0,0 +1,160 @@ +"""E2E tests for document-versions commands.""" + +from typing import Any + +import pytest + +from tests.e2e.cli_helpers import run_kscli_fail, run_kscli_ok +from tests.e2e.conftest import ( + COMPLEX_DOC_ACTIVE_VERSION_ID, + COMPLEX_DOC_ID, + FIRST_SIMPLE_VERSION_ID, + NONEXISTENT_UUID, +) + +pytestmark = pytest.mark.e2e + + +class TestCliDocumentVersionsRead: + """Read-only document version tests.""" + + def test_list_versions(self, cli_authenticated: dict[str, str]) -> None: + """List versions for the complex document (20 versions).""" + result = run_kscli_ok( + [ + "document-versions", "list", + "--document-id", COMPLEX_DOC_ID, + "--limit", "50", + ], + env=cli_authenticated, + ) + data = result.json_output + assert isinstance(data, dict) + versions = data["items"] + assert len(versions) == 20 + + def test_describe_version(self, cli_authenticated: dict[str, str]) -> None: + """Describe the active version of the complex document.""" + result = run_kscli_ok( + ["document-versions", "describe", COMPLEX_DOC_ACTIVE_VERSION_ID], + env=cli_authenticated, + ) + version = result.json_output + assert isinstance(version, dict) + assert version["id"] == COMPLEX_DOC_ACTIVE_VERSION_ID + + def test_describe_version_not_found(self, cli_authenticated: dict[str, str]) -> None: + """Describe a nonexistent version returns exit code 3.""" + run_kscli_fail( + ["document-versions", "describe", NONEXISTENT_UUID], + env=cli_authenticated, + expected_code=3, + ) + + def test_describe_version_contents(self, cli_authenticated: dict[str, str]) -> None: + """Get version contents returns section/chunk tree.""" + result = run_kscli_ok( + ["document-versions", "contents", FIRST_SIMPLE_VERSION_ID], + env=cli_authenticated, + ) + data = result.json_output + assert data is not None + + +class TestCliDocumentVersionsWrite: + """Write document version tests using isolation_folder.""" + + def test_create_and_delete_version( + self, + cli_authenticated: dict[str, str], + isolation_folder: dict[str, Any], + ) -> None: + """Create a document, add a version, then delete the version.""" + parent_id = isolation_folder["path_part_id"] + + # Create a document first + doc_result = run_kscli_ok( + [ + "documents", "create", + "--name", "version-test-doc", + "--parent-path-part-id", parent_id, + "--type", "UNKNOWN", + "--origin", "SOURCE", + ], + env=cli_authenticated, + ) + doc_id = doc_result.json_output["id"] + + # Create a version + ver_result = run_kscli_ok( + [ + "document-versions", "create", + "--document-id", doc_id, + ], + env=cli_authenticated, + ) + version = ver_result.json_output + version_id = version["id"] + + # Delete the version + run_kscli_ok( + ["document-versions", "delete", version_id], + env=cli_authenticated, + format_json=False, + ) + + # Verify deleted + run_kscli_fail( + ["document-versions", "describe", version_id], + env=cli_authenticated, + expected_code=3, + ) + + def test_clear_version_contents( + self, + cli_authenticated: dict[str, str], + isolation_folder: dict[str, Any], + ) -> None: + """Create a document with version, add content, then clear it.""" + parent_id = isolation_folder["path_part_id"] + + # Create document + doc_result = run_kscli_ok( + [ + "documents", "create", + "--name", "clear-test-doc", + "--parent-path-part-id", parent_id, + "--type", "UNKNOWN", + "--origin", "SOURCE", + ], + env=cli_authenticated, + ) + doc_id = doc_result.json_output["id"] + + # Create version + ver_result = run_kscli_ok( + [ + "document-versions", "create", + "--document-id", doc_id, + ], + env=cli_authenticated, + ) + version_id = ver_result.json_output["id"] + version_path_part_id = ver_result.json_output["path_part_id"] + + # Add a section + run_kscli_ok( + [ + "sections", "create", + "--name", "test-section", + "--parent-path-id", version_path_part_id, + ], + env=cli_authenticated, + ) + + # Clear contents + run_kscli_ok( + ["document-versions", "clear-contents", version_id], + env=cli_authenticated, + format_json=False, + ) diff --git a/tests/e2e/test_cli_documents.py b/tests/e2e/test_cli_documents.py new file mode 100644 index 0000000..e3ee258 --- /dev/null +++ b/tests/e2e/test_cli_documents.py @@ -0,0 +1,217 @@ +"""E2E tests for document commands.""" + +from typing import Any + +import pytest + +from tests.e2e.cli_helpers import run_kscli_fail, run_kscli_ok +from tests.e2e.conftest import ( + COMPLEX_DOC_ID, + MANY_DOCS_FOLDER_PATH_PART_ID, + NONEXISTENT_UUID, +) + +pytestmark = pytest.mark.e2e + + +class TestCliDocumentsRead: + """Read-only document tests using seed data.""" + + def test_list_documents(self, cli_authenticated: dict[str, str]) -> None: + """List documents returns results.""" + result = run_kscli_ok(["documents", "list"], env=cli_authenticated) + data = result.json_output + assert isinstance(data, dict) + assert isinstance(data["items"], list) + assert len(data["items"]) > 0 + + def test_list_documents_with_folder(self, cli_authenticated: dict[str, str]) -> None: + """List documents under /shared/many/documents returns seed docs.""" + result = run_kscli_ok( + [ + "documents", "list", + "--parent-path-part-id", MANY_DOCS_FOLDER_PATH_PART_ID, + "--limit", "20", + ], + env=cli_authenticated, + ) + data = result.json_output + items = data["items"] + assert len(items) == 20 # Default limit, 50 total + + def test_describe_document(self, cli_authenticated: dict[str, str]) -> None: + """Describe the complex document returns correct details.""" + result = run_kscli_ok( + ["documents", "describe", COMPLEX_DOC_ID], + env=cli_authenticated, + ) + doc = result.json_output + assert isinstance(doc, dict) + assert doc["id"] == COMPLEX_DOC_ID + + def test_describe_document_not_found(self, cli_authenticated: dict[str, str]) -> None: + """Describe a nonexistent document returns exit code 3.""" + run_kscli_fail( + ["documents", "describe", NONEXISTENT_UUID], + env=cli_authenticated, + expected_code=3, + ) + + def test_list_documents_pagination_first_page( + self, cli_authenticated: dict[str, str] + ) -> None: + """First page of documents under /shared/many/documents.""" + result = run_kscli_ok( + [ + "documents", "list", + "--parent-path-part-id", MANY_DOCS_FOLDER_PATH_PART_ID, + "--limit", "10", + "--offset", "0", + ], + env=cli_authenticated, + ) + data = result.json_output + assert len(data["items"]) == 10 + assert data["total"] == 50 + + def test_list_documents_pagination_no_overlap( + self, cli_authenticated: dict[str, str] + ) -> None: + """Page 1 and page 2 have no overlap.""" + page1 = run_kscli_ok( + [ + "documents", "list", + "--parent-path-part-id", MANY_DOCS_FOLDER_PATH_PART_ID, + "--limit", "10", + "--offset", "0", + ], + env=cli_authenticated, + ) + page2 = run_kscli_ok( + [ + "documents", "list", + "--parent-path-part-id", MANY_DOCS_FOLDER_PATH_PART_ID, + "--limit", "10", + "--offset", "10", + ], + env=cli_authenticated, + ) + ids1 = {d["id"] for d in page1.json_output["items"]} + ids2 = {d["id"] for d in page2.json_output["items"]} + assert ids1.isdisjoint(ids2) + + def test_list_documents_pagination_all( + self, cli_authenticated: dict[str, str] + ) -> None: + """Fetching all 50 documents returns exactly 50.""" + result = run_kscli_ok( + [ + "documents", "list", + "--parent-path-part-id", MANY_DOCS_FOLDER_PATH_PART_ID, + "--limit", "100", + ], + env=cli_authenticated, + ) + items = result.json_output["items"] + assert len(items) == 50 + ids = [d["id"] for d in items] + assert len(ids) == len(set(ids)) + + +class TestCliDocumentsWrite: + """Write document tests using isolation_folder.""" + + def test_create_and_delete_document( + self, + cli_authenticated: dict[str, str], + isolation_folder: dict[str, Any], + ) -> None: + """Create and delete a document.""" + parent_id = isolation_folder["path_part_id"] + + # Create + result = run_kscli_ok( + [ + "documents", "create", + "--name", "test-doc", + "--parent-path-part-id", parent_id, + "--type", "UNKNOWN", + "--origin", "SOURCE", + ], + env=cli_authenticated, + ) + doc = result.json_output + doc_id = doc["id"] + assert doc["name"] == "test-doc" + + # Verify exists + result = run_kscli_ok( + ["documents", "describe", doc_id], + env=cli_authenticated, + ) + assert result.json_output["name"] == "test-doc" + + # Delete + run_kscli_ok( + ["documents", "delete", doc_id], + env=cli_authenticated, + format_json=False, + ) + + # Verify deleted + run_kscli_fail( + ["documents", "describe", doc_id], + env=cli_authenticated, + expected_code=3, + ) + + def test_create_document_types( + self, + cli_authenticated: dict[str, str], + isolation_folder: dict[str, Any], + ) -> None: + """Create documents with different type and origin combinations.""" + parent_id = isolation_folder["path_part_id"] + + for doc_type in ["PDF", "DOCX", "UNKNOWN"]: + result = run_kscli_ok( + [ + "documents", "create", + "--name", f"doc-{doc_type.lower()}", + "--parent-path-part-id", parent_id, + "--type", doc_type, + "--origin", "SOURCE", + ], + env=cli_authenticated, + ) + assert result.json_output["document_type"] == doc_type + + def test_update_document_name( + self, + cli_authenticated: dict[str, str], + isolation_folder: dict[str, Any], + ) -> None: + """Update a document's name.""" + parent_id = isolation_folder["path_part_id"] + + doc = run_kscli_ok( + [ + "documents", "create", + "--name", "original-name", + "--parent-path-part-id", parent_id, + "--type", "UNKNOWN", + "--origin", "SOURCE", + ], + env=cli_authenticated, + ).json_output + + run_kscli_ok( + ["documents", "update", doc["id"], "--name", "updated-name"], + env=cli_authenticated, + ) + + result = run_kscli_ok( + ["documents", "describe", doc["id"]], + env=cli_authenticated, + ) + assert result.json_output["name"] == "updated-name" diff --git a/tests/e2e/test_cli_errors.py b/tests/e2e/test_cli_errors.py new file mode 100644 index 0000000..47e473f --- /dev/null +++ b/tests/e2e/test_cli_errors.py @@ -0,0 +1,47 @@ +"""E2E tests for error handling and exit codes.""" + +import tempfile +from pathlib import Path + +import pytest + +from tests.e2e.cli_helpers import run_kscli_fail, run_kscli_ok +from tests.e2e.conftest import NONEXISTENT_UUID + +pytestmark = pytest.mark.e2e + + +class TestCliExitCodes: + """Exit code and error message tests.""" + + def test_exit_code_0_success(self, cli_authenticated: dict[str, str]) -> None: + """Successful command returns exit code 0.""" + result = run_kscli_ok(["folders", "list"], env=cli_authenticated) + assert result.exit_code == 0 + + def test_exit_code_3_not_found(self, cli_authenticated: dict[str, str]) -> None: + """Describing a nonexistent resource returns exit code 3.""" + run_kscli_fail( + ["folders", "describe", NONEXISTENT_UUID], + env=cli_authenticated, + expected_code=3, + ) + + def test_exit_code_2_unauthenticated(self, cli_env: dict[str, str]) -> None: + """Running without credentials returns non-zero exit code.""" + with tempfile.TemporaryDirectory() as tmp: + env = { + **cli_env, + "KSCLI_CREDENTIALS_PATH": str(Path(tmp) / "missing"), + } + result = run_kscli_fail(["folders", "list"], env=env) + assert result.exit_code != 0 + + def test_error_message_on_not_found(self, cli_authenticated: dict[str, str]) -> None: + """Not-found errors produce a user-friendly message on stderr.""" + result = run_kscli_fail( + ["folders", "describe", NONEXISTENT_UUID], + env=cli_authenticated, + expected_code=3, + ) + assert "error" in result.stderr.lower() or "not found" in result.stderr.lower() diff --git a/tests/e2e/test_cli_folders.py b/tests/e2e/test_cli_folders.py new file mode 100644 index 0000000..c17dee0 --- /dev/null +++ b/tests/e2e/test_cli_folders.py @@ -0,0 +1,209 @@ +"""E2E tests for folder commands.""" + +from typing import Any + +import pytest + +from tests.e2e.cli_helpers import run_kscli_fail, run_kscli_ok +from tests.e2e.conftest import ( + MANY_FOLDER_PATH_PART_ID, + NONEXISTENT_UUID, + SHARED_FOLDER_ID, + SHARED_FOLDER_PATH_PART_ID, +) + +pytestmark = pytest.mark.e2e + + +class TestCliFoldersRead: + """Read-only folder tests using seed data.""" + + def test_list_folders_root(self, cli_authenticated: dict[str, str]) -> None: + """List root folders returns expected seed folders.""" + result = run_kscli_ok(["folders", "list"], env=cli_authenticated) + data = result.json_output + assert isinstance(data, dict) + folders = data["items"] + assert isinstance(folders, list) + assert len(folders) > 0 + names = [f["name"] for f in folders] + assert "shared" in names + assert "agents" in names + + def test_list_folders_with_parent(self, cli_authenticated: dict[str, str]) -> None: + """List folders under /shared returns known children.""" + 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 + + def test_describe_folder(self, cli_authenticated: dict[str, str]) -> None: + """Describe the /shared folder returns correct details.""" + result = run_kscli_ok( + ["folders", "describe", SHARED_FOLDER_ID], + env=cli_authenticated, + ) + folder = result.json_output + assert isinstance(folder, dict) + assert folder["name"] == "shared" + assert folder["path_part_id"] == SHARED_FOLDER_PATH_PART_ID + + def test_describe_folder_not_found(self, cli_authenticated: dict[str, str]) -> None: + """Describe a nonexistent folder returns exit code 3.""" + run_kscli_fail( + ["folders", "describe", NONEXISTENT_UUID], + env=cli_authenticated, + expected_code=3, + ) + + def test_list_folder_contents(self, cli_authenticated: dict[str, str]) -> None: + """List folder contents with --show-content returns nested items.""" + result = run_kscli_ok( + [ + "folders", "list", + "--show-content", + "--folder-id", SHARED_FOLDER_ID, + ], + env=cli_authenticated, + ) + data = result.json_output + assert data is not None + + def test_list_folder_contents_with_max_depth( + self, cli_authenticated: dict[str, str] + ) -> None: + """List folder contents with --max-depth limits nesting.""" + result = run_kscli_ok( + [ + "folders", "list", + "--show-content", + "--folder-id", SHARED_FOLDER_ID, + "--max-depth", "1", + ], + env=cli_authenticated, + ) + data = result.json_output + assert data is not None + + def test_list_folders_pagination_first_page( + self, cli_authenticated: dict[str, str] + ) -> None: + """First page of /shared/many subfolders returns exactly 10 items.""" + result = run_kscli_ok( + [ + "folders", "list", + "--parent-path-part-id", MANY_FOLDER_PATH_PART_ID, + "--limit", "10", + "--offset", "0", + ], + env=cli_authenticated, + ) + data = result.json_output + items = data["items"] + assert len(items) == 10 + assert data["total"] >= 100 + + def test_list_folders_pagination_no_overlap( + self, cli_authenticated: dict[str, str] + ) -> None: + """Second page has no overlap with first page.""" + page1 = run_kscli_ok( + [ + "folders", "list", + "--parent-path-part-id", MANY_FOLDER_PATH_PART_ID, + "--limit", "10", + "--offset", "0", + ], + env=cli_authenticated, + ) + page2 = run_kscli_ok( + [ + "folders", "list", + "--parent-path-part-id", MANY_FOLDER_PATH_PART_ID, + "--limit", "10", + "--offset", "10", + ], + env=cli_authenticated, + ) + ids1 = {f["id"] for f in page1.json_output["items"]} + ids2 = {f["id"] for f in page2.json_output["items"]} + assert ids1.isdisjoint(ids2), "Pages should not overlap" + + def test_list_folders_pagination_all( + self, cli_authenticated: dict[str, str] + ) -> None: + """Paginating through all items collects >= 100 unique folders.""" + all_items = run_kscli_ok( + [ + "folders", "list", + "--parent-path-part-id", MANY_FOLDER_PATH_PART_ID, + "--limit", "100", + ], + env=cli_authenticated, + ) + items = all_items.json_output["items"] + # /shared/many has 100 测试_N folders + 1 documents folder = 101 + assert len(items) >= 100 + # All IDs should be unique + ids = [f["id"] for f in items] + assert len(ids) == len(set(ids)) + + +class TestCliFoldersWrite: + """Write folder tests using isolation_folder.""" + + def test_create_update_delete_folder( + self, + cli_authenticated: dict[str, str], + isolation_folder: dict[str, Any], + ) -> None: + """Full CRUD lifecycle for a folder.""" + parent_id = isolation_folder["path_part_id"] + + # Create + result = run_kscli_ok( + [ + "folders", "create", + "--name", "test-folder", + "--parent-path-part-id", parent_id, + ], + env=cli_authenticated, + ) + folder = result.json_output + folder_id = folder["id"] + assert folder["name"] == "test-folder" + + # Update + run_kscli_ok( + ["folders", "update", folder_id, "--name", "renamed-folder"], + env=cli_authenticated, + ) + + # Verify update + result = run_kscli_ok( + ["folders", "describe", folder_id], + env=cli_authenticated, + ) + assert result.json_output["name"] == "renamed-folder" + + # Delete + run_kscli_ok( + ["folders", "delete", folder_id], + env=cli_authenticated, + format_json=False, + ) + + # Verify deleted + run_kscli_fail( + ["folders", "describe", folder_id], + env=cli_authenticated, + expected_code=3, + ) diff --git a/tests/e2e/test_cli_invites.py b/tests/e2e/test_cli_invites.py new file mode 100644 index 0000000..bafc88d --- /dev/null +++ b/tests/e2e/test_cli_invites.py @@ -0,0 +1,45 @@ +"""E2E tests for invite commands.""" + +import pytest + +from tests.e2e.cli_helpers import run_kscli_ok + +pytestmark = pytest.mark.e2e + + +class TestCliInvitesRead: + """Read-only invite tests.""" + + def test_list_invites(self, cli_authenticated: dict[str, str]) -> None: + """List invites returns seed invites.""" + result = run_kscli_ok(["invites", "list"], env=cli_authenticated) + data = result.json_output + assert isinstance(data, dict) + invites = data["items"] + assert isinstance(invites, list) + assert len(invites) > 0 + + +class TestCliInvitesWrite: + """Write invite tests.""" + + def test_create_and_delete_invite(self, cli_authenticated: dict[str, str]) -> None: + """Create and delete an invite.""" + result = run_kscli_ok( + [ + "invites", "create", + "--email", "e2e-test-invite@ksdev.mock", + "--role", "USER", + ], + env=cli_authenticated, + ) + invite = result.json_output + invite_id = invite["id"] + assert invite["email"] == "e2e-test-invite@ksdev.mock" + + # Delete + run_kscli_ok( + ["invites", "delete", invite_id], + env=cli_authenticated, + format_json=False, + ) diff --git a/tests/e2e/test_cli_output_formats.py b/tests/e2e/test_cli_output_formats.py new file mode 100644 index 0000000..4d58d17 --- /dev/null +++ b/tests/e2e/test_cli_output_formats.py @@ -0,0 +1,83 @@ +"""E2E tests for output format options: --format, --no-header.""" + +import json + +import pytest + +from tests.e2e.cli_helpers import run_kscli_ok +from tests.e2e.conftest import SHARED_FOLDER_ID + +pytestmark = pytest.mark.e2e + + +class TestCliOutputFormats: + """Output format tests using folders as the test resource.""" + + def test_json_output(self, cli_authenticated: dict[str, str]) -> None: + """--format json produces valid JSON with items.""" + result = run_kscli_ok(["folders", "list"], env=cli_authenticated) + assert result.json_output is not None + assert isinstance(result.json_output, dict) + assert "items" in result.json_output + assert isinstance(result.json_output["items"], list) + + def test_yaml_output(self, cli_authenticated: dict[str, str]) -> None: + """--format yaml produces YAML-like output (not JSON).""" + result = run_kscli_ok( + ["--format", "yaml", "folders", "list"], + env=cli_authenticated, + format_json=False, + ) + # YAML output should not be valid JSON + with pytest.raises(json.JSONDecodeError): + json.loads(result.stdout) + # But should contain key fields + assert "name:" in result.stdout or "id:" in result.stdout + + def test_table_output_default(self, cli_authenticated: dict[str, str]) -> None: + """Default table format produces human-readable table text.""" + result = run_kscli_ok( + ["--format", "table", "folders", "list"], + env=cli_authenticated, + format_json=False, + ) + # Table output contains column headers + assert "name" in result.stdout.lower() + + def test_id_only_output(self, cli_authenticated: dict[str, str]) -> None: + """--format id-only returns just IDs, one per line.""" + result = run_kscli_ok( + ["--format", "id-only", "folders", "list"], + env=cli_authenticated, + format_json=False, + ) + lines = [line.strip() for line in result.stdout.strip().splitlines() if line.strip()] + assert len(lines) > 0 + # Each line should look like a UUID + for line in lines: + assert len(line) == 36, f"Expected UUID, got: {line}" + + def test_no_header_flag(self, cli_authenticated: dict[str, str]) -> None: + """--no-header suppresses table column headers.""" + with_header = run_kscli_ok( + ["--format", "table", "folders", "list"], + env=cli_authenticated, + format_json=False, + ) + without_header = run_kscli_ok( + ["--format", "table", "--no-header", "folders", "list"], + env=cli_authenticated, + format_json=False, + ) + # Without header should have fewer lines + assert len(without_header.stdout.splitlines()) < len(with_header.stdout.splitlines()) + + def test_json_describe_single_item(self, cli_authenticated: dict[str, str]) -> None: + """Describe returns a single dict, not a paginated wrapper.""" + result = run_kscli_ok( + ["folders", "describe", SHARED_FOLDER_ID], + env=cli_authenticated, + ) + assert isinstance(result.json_output, dict) + assert "name" in result.json_output + assert "items" not in result.json_output diff --git a/tests/e2e/test_cli_path_parts.py b/tests/e2e/test_cli_path_parts.py new file mode 100644 index 0000000..f4f26bf --- /dev/null +++ b/tests/e2e/test_cli_path_parts.py @@ -0,0 +1,44 @@ +"""E2E tests for path-parts commands (read-only).""" + +import pytest + +from tests.e2e.cli_helpers import run_kscli_ok +from tests.e2e.conftest import SHARED_FOLDER_PATH_PART_ID + +pytestmark = pytest.mark.e2e + + +class TestCliPathParts: + """Read-only path part tests.""" + + def test_list_path_parts(self, cli_authenticated: dict[str, str]) -> None: + """List root path parts returns top-level items.""" + result = run_kscli_ok(["path-parts", "list"], env=cli_authenticated) + data = result.json_output + assert isinstance(data, dict) + parts = data["items"] + assert isinstance(parts, list) + assert len(parts) > 0 + + def test_list_path_parts_with_parent(self, cli_authenticated: dict[str, str]) -> None: + """List children of /shared.""" + result = run_kscli_ok( + [ + "path-parts", "list", + "--parent-path-id", SHARED_FOLDER_PATH_PART_ID, + ], + env=cli_authenticated, + ) + data = result.json_output + parts = data["items"] + assert len(parts) > 0 + + def test_describe_path_part(self, cli_authenticated: dict[str, str]) -> None: + """Describe a known path part.""" + result = run_kscli_ok( + ["path-parts", "describe", SHARED_FOLDER_PATH_PART_ID], + env=cli_authenticated, + ) + part = result.json_output + assert isinstance(part, dict) + assert part["path_part_id"] == SHARED_FOLDER_PATH_PART_ID diff --git a/tests/e2e/test_cli_permissions.py b/tests/e2e/test_cli_permissions.py new file mode 100644 index 0000000..d4d2bd7 --- /dev/null +++ b/tests/e2e/test_cli_permissions.py @@ -0,0 +1,77 @@ +"""E2E tests for permission commands.""" + +from typing import Any + +import pytest + +from tests.e2e.cli_helpers import run_kscli_ok +from tests.e2e.conftest import PWUSER3_ID + +pytestmark = pytest.mark.e2e + + +class TestCliPermissionsRead: + """Read-only permission tests.""" + + def test_list_permissions(self, cli_authenticated: dict[str, str]) -> None: + """List permissions returns results.""" + result = run_kscli_ok(["permissions", "list"], env=cli_authenticated) + data = result.json_output + assert isinstance(data, dict) + perms = data["items"] + assert isinstance(perms, list) + + def test_list_permissions_for_user(self, cli_authenticated: dict[str, str]) -> None: + """List permissions for pwuser3 (has scoped permissions).""" + result = run_kscli_ok( + [ + "permissions", "list", + "--user-id", PWUSER3_ID, + ], + env=cli_authenticated, + ) + data = result.json_output + perms = data["items"] + assert isinstance(perms, list) + + +class TestCliPermissionsWrite: + """Write permission tests.""" + + def test_create_update_delete_permission( + self, + cli_authenticated: dict[str, str], + isolation_folder: dict[str, Any], + ) -> None: + """Full CRUD lifecycle for a permission.""" + path_part_id = isolation_folder["path_part_id"] + + # Create + result = run_kscli_ok( + [ + "permissions", "create", + "--user-id", PWUSER3_ID, + "--path-part-id", path_part_id, + "--capability", "READ_ONLY", + ], + env=cli_authenticated, + ) + perm = result.json_output + perm_id = perm["id"] + assert perm["capability"] == "READ_ONLY" + + # Update + run_kscli_ok( + [ + "permissions", "update", perm_id, + "--capability", "READ_WRITE", + ], + env=cli_authenticated, + ) + + # Delete + run_kscli_ok( + ["permissions", "delete", perm_id], + env=cli_authenticated, + format_json=False, + ) diff --git a/tests/e2e/test_cli_sections.py b/tests/e2e/test_cli_sections.py new file mode 100644 index 0000000..c56ef13 --- /dev/null +++ b/tests/e2e/test_cli_sections.py @@ -0,0 +1,104 @@ +"""E2E tests for section commands.""" + +from typing import Any + +import pytest + +from tests.e2e.cli_helpers import run_kscli_fail, run_kscli_ok +from tests.e2e.conftest import FIRST_SECTION_ID, NONEXISTENT_UUID + +pytestmark = pytest.mark.e2e + + +class TestCliSectionsRead: + """Read-only section tests.""" + + def test_describe_section(self, cli_authenticated: dict[str, str]) -> None: + """Describe a known seed section.""" + result = run_kscli_ok( + ["sections", "describe", FIRST_SECTION_ID], + env=cli_authenticated, + ) + section = result.json_output + assert isinstance(section, dict) + assert section["id"] == FIRST_SECTION_ID + + def test_describe_section_not_found(self, cli_authenticated: dict[str, str]) -> None: + """Describe a nonexistent section returns exit code 3.""" + run_kscli_fail( + ["sections", "describe", NONEXISTENT_UUID], + env=cli_authenticated, + expected_code=3, + ) + + +class TestCliSectionsWrite: + """Write section tests using isolation_folder.""" + + def test_create_update_delete_section( + self, + cli_authenticated: dict[str, str], + isolation_folder: dict[str, Any], + ) -> None: + """Full CRUD lifecycle for a section.""" + parent_id = isolation_folder["path_part_id"] + + # Create a document and version to hold the section + doc_result = run_kscli_ok( + [ + "documents", "create", + "--name", "section-test-doc", + "--parent-path-part-id", parent_id, + "--type", "UNKNOWN", + "--origin", "SOURCE", + ], + env=cli_authenticated, + ) + doc_id = doc_result.json_output["id"] + + ver_result = run_kscli_ok( + ["document-versions", "create", "--document-id", doc_id], + env=cli_authenticated, + ) + version_path_part_id = ver_result.json_output["path_part_id"] + + # Create section + result = run_kscli_ok( + [ + "sections", "create", + "--name", "test-section", + "--parent-path-id", version_path_part_id, + "--page-number", "1", + ], + env=cli_authenticated, + ) + section = result.json_output + section_id = section["id"] + assert section["name"] == "test-section" + + # Update + run_kscli_ok( + ["sections", "update", section_id, "--name", "renamed-section"], + env=cli_authenticated, + ) + + # Verify + result = run_kscli_ok( + ["sections", "describe", section_id], + env=cli_authenticated, + ) + assert result.json_output["name"] == "renamed-section" + + # Delete + run_kscli_ok( + ["sections", "delete", section_id], + env=cli_authenticated, + format_json=False, + ) + + # Verify deleted + run_kscli_fail( + ["sections", "describe", section_id], + env=cli_authenticated, + expected_code=3, + ) diff --git a/tests/e2e/test_cli_settings.py b/tests/e2e/test_cli_settings.py new file mode 100644 index 0000000..bb4c273 --- /dev/null +++ b/tests/e2e/test_cli_settings.py @@ -0,0 +1,53 @@ +"""E2E tests for settings commands.""" + +import pytest + +from tests.e2e.cli_helpers import run_kscli_ok + +pytestmark = pytest.mark.e2e + + +class TestCliSettings: + """Settings command tests.""" + + def test_settings_show(self, cli_authenticated: dict[str, str]) -> None: + """Settings show returns current config.""" + result = run_kscli_ok(["settings", "show"], env=cli_authenticated) + data = result.json_output + assert isinstance(data, dict) + assert "base_url" in data + assert "format" in data + + def test_settings_environment_local(self, cli_authenticated: dict[str, str]) -> None: + """Settings environment local sets the local preset.""" + result = run_kscli_ok( + ["settings", "environment", "local"], + env=cli_authenticated, + format_json=False, + ) + assert "local" in result.stdout + + def test_settings_environment_prod(self, cli_authenticated: dict[str, str]) -> None: + """Settings environment prod sets the prod preset.""" + result = run_kscli_ok( + ["settings", "environment", "prod"], + env=cli_authenticated, + format_json=False, + ) + assert "prod" in result.stdout + + def test_settings_environment_resets_to_local( + self, cli_authenticated: dict[str, str] + ) -> None: + """Settings environment local restores the local preset.""" + run_kscli_ok( + ["settings", "environment", "local"], + env=cli_authenticated, + format_json=False, + ) + result = run_kscli_ok( + ["settings", "environment", "local"], + env=cli_authenticated, + format_json=False, + ) + assert "localhost:8000" in result.stdout diff --git a/tests/e2e/test_cli_tags.py b/tests/e2e/test_cli_tags.py new file mode 100644 index 0000000..deb421b --- /dev/null +++ b/tests/e2e/test_cli_tags.py @@ -0,0 +1,179 @@ +"""E2E tests for tag commands.""" + +from typing import Any + +import pytest + +from tests.e2e.cli_helpers import run_kscli_fail, run_kscli_ok +from tests.e2e.conftest import NONEXISTENT_UUID, TAG_MANY_ID + +pytestmark = pytest.mark.e2e + + +class TestCliTagsRead: + """Read-only tag tests.""" + + def test_list_tags(self, cli_authenticated: dict[str, str]) -> None: + """List tags returns seed tags.""" + result = run_kscli_ok(["tags", "list"], env=cli_authenticated) + data = result.json_output + assert isinstance(data, dict) + tags = data["items"] + assert len(tags) >= 5 + names = [t["name"] for t in tags] + assert "Many" in names + assert "Nested" in names + + def test_describe_tag(self, cli_authenticated: dict[str, str]) -> None: + """Describe the Many tag.""" + result = run_kscli_ok( + ["tags", "describe", TAG_MANY_ID], + env=cli_authenticated, + ) + tag = result.json_output + assert isinstance(tag, dict) + assert tag["name"] == "Many" + + def test_describe_tag_not_found(self, cli_authenticated: dict[str, str]) -> None: + """Describe a nonexistent tag returns exit code 3.""" + run_kscli_fail( + ["tags", "describe", NONEXISTENT_UUID], + env=cli_authenticated, + expected_code=3, + ) + + def test_list_tags_pagination(self, cli_authenticated: dict[str, str]) -> None: + """Pagination through tags.""" + page1 = run_kscli_ok( + ["tags", "list", "--limit", "2", "--offset", "0"], + env=cli_authenticated, + ) + assert len(page1.json_output["items"]) == 2 + + page2 = run_kscli_ok( + ["tags", "list", "--limit", "2", "--offset", "2"], + env=cli_authenticated, + ) + assert len(page2.json_output["items"]) == 2 + + ids1 = {t["id"] for t in page1.json_output["items"]} + ids2 = {t["id"] for t in page2.json_output["items"]} + assert ids1.isdisjoint(ids2) + + +class TestCliTagsWrite: + """Write tag tests.""" + + def test_create_update_delete_tag( + self, + cli_authenticated: dict[str, str], + isolation_folder: dict[str, Any], + ) -> None: + """Full CRUD lifecycle for a tag.""" + # Create + result = run_kscli_ok( + [ + "tags", "create", + "--name", "e2e-test-tag", + "--color", "#AABBCC", + "--description", "Test tag for e2e", + ], + env=cli_authenticated, + ) + tag = result.json_output + tag_id = tag["id"] + assert tag["name"] == "e2e-test-tag" + + # Update + run_kscli_ok( + [ + "tags", "update", tag_id, + "--name", "renamed-tag", + "--color", "#112233", + ], + env=cli_authenticated, + ) + + # Verify + result = run_kscli_ok( + ["tags", "describe", tag_id], + env=cli_authenticated, + ) + assert result.json_output["name"] == "renamed-tag" + + # Delete + run_kscli_ok( + ["tags", "delete", tag_id], + env=cli_authenticated, + format_json=False, + ) + + # Verify deleted + run_kscli_fail( + ["tags", "describe", tag_id], + env=cli_authenticated, + expected_code=3, + ) + + def test_attach_and_detach_tag( + self, + cli_authenticated: dict[str, str], + isolation_folder: dict[str, Any], + ) -> None: + """Attach and detach a tag from a path part.""" + # Create a tag + tag = run_kscli_ok( + [ + "tags", "create", + "--name", "attach-test-tag", + "--color", "#000000", + ], + env=cli_authenticated, + ).json_output + tag_id = tag["id"] + path_part_id = isolation_folder["path_part_id"] + + # Attach + run_kscli_ok( + [ + "tags", "attach", tag_id, + "--path-part-id", path_part_id, + ], + env=cli_authenticated, + ) + + # Detach + run_kscli_ok( + [ + "tags", "detach", tag_id, + "--path-part-id", path_part_id, + ], + env=cli_authenticated, + ) + + # Cleanup: delete tag + run_kscli_ok( + ["tags", "delete", tag_id], + env=cli_authenticated, + format_json=False, + ) + + def test_create_tag_minimal( + self, + cli_authenticated: dict[str, str], + isolation_folder: dict[str, Any], + ) -> None: + """Create a tag with only the required --name.""" + result = run_kscli_ok( + ["tags", "create", "--name", "minimal-tag"], + env=cli_authenticated, + ) + tag = result.json_output + assert tag["name"] == "minimal-tag" + + # Cleanup + run_kscli_ok( + ["tags", "delete", tag["id"]], + env=cli_authenticated, + format_json=False, + ) diff --git a/tests/e2e/test_cli_tenants.py b/tests/e2e/test_cli_tenants.py new file mode 100644 index 0000000..9168f42 --- /dev/null +++ b/tests/e2e/test_cli_tenants.py @@ -0,0 +1,51 @@ +"""E2E tests for tenant commands (read-only).""" + +import pytest + +from tests.e2e.cli_helpers import run_kscli_fail, run_kscli_ok +from tests.e2e.conftest import NONEXISTENT_UUID, SHARED_TENANT_ID + +pytestmark = pytest.mark.e2e + + +class TestCliTenants: + """Read-only tenant tests.""" + + def test_list_tenants(self, cli_authenticated: dict[str, str]) -> None: + """List tenants returns seed tenants.""" + result = run_kscli_ok(["tenants", "list"], env=cli_authenticated) + data = result.json_output + assert isinstance(data, dict) + tenants = data["items"] + assert isinstance(tenants, list) + assert len(tenants) > 0 + + def test_describe_tenant(self, cli_authenticated: dict[str, str]) -> None: + """Describe the shared tenant.""" + result = run_kscli_ok( + ["tenants", "describe", SHARED_TENANT_ID], + env=cli_authenticated, + ) + tenant = result.json_output + assert isinstance(tenant, dict) + assert tenant["name"] == "shared" + + def test_describe_tenant_not_found(self, cli_authenticated: dict[str, str]) -> None: + """Describe a nonexistent tenant returns exit code 3.""" + run_kscli_fail( + ["tenants", "describe", NONEXISTENT_UUID], + env=cli_authenticated, + expected_code=3, + ) + + def test_list_tenant_users(self, cli_authenticated: dict[str, str]) -> None: + """List users in the shared tenant.""" + result = run_kscli_ok( + ["tenants", "list-users", SHARED_TENANT_ID], + env=cli_authenticated, + ) + data = result.json_output + assert isinstance(data, dict) + users = data["items"] + assert isinstance(users, list) + assert len(users) >= 2 # At least pwuser1 + pwuser2 diff --git a/tests/e2e/test_cli_thread_messages.py b/tests/e2e/test_cli_thread_messages.py new file mode 100644 index 0000000..97fd204 --- /dev/null +++ b/tests/e2e/test_cli_thread_messages.py @@ -0,0 +1,111 @@ +"""E2E tests for thread-messages commands.""" + +from typing import Any + +import pytest + +from tests.e2e.cli_helpers import run_kscli_ok + +pytestmark = pytest.mark.e2e + + +class TestCliThreadMessages: + """Thread message tests (always write, need a thread).""" + + def test_create_and_list_messages( + self, + cli_authenticated: dict[str, str], + isolation_folder: dict[str, Any], + ) -> None: + """Create a thread, add a message, list messages.""" + parent_id = isolation_folder["path_part_id"] + + # Create thread + thread = run_kscli_ok( + [ + "threads", "create", + "--title", "msg test thread", + "--parent-path-part-id", parent_id, + ], + env=cli_authenticated, + ).json_output + thread_id = thread["id"] + + # Create message + msg = run_kscli_ok( + [ + "thread-messages", "create", + "--thread-id", thread_id, + "--content", "Hello from e2e test", + "--role", "USER", + ], + env=cli_authenticated, + ).json_output + msg_id = msg["id"] + + # List messages + result = run_kscli_ok( + ["thread-messages", "list", "--thread-id", thread_id], + env=cli_authenticated, + ) + messages = result.json_output["items"] + assert isinstance(messages, list) + assert len(messages) >= 1 + assert any(m["id"] == msg_id for m in messages) + + # Describe message + result = run_kscli_ok( + [ + "thread-messages", "describe", msg_id, + "--thread-id", thread_id, + ], + env=cli_authenticated, + ) + assert result.json_output["id"] == msg_id + + def test_create_multiple_messages( + self, + cli_authenticated: dict[str, str], + isolation_folder: dict[str, Any], + ) -> None: + """Create multiple messages in a thread.""" + parent_id = isolation_folder["path_part_id"] + + thread = run_kscli_ok( + [ + "threads", "create", + "--title", "multi-msg thread", + "--parent-path-part-id", parent_id, + ], + env=cli_authenticated, + ).json_output + thread_id = thread["id"] + + # Add USER message + run_kscli_ok( + [ + "thread-messages", "create", + "--thread-id", thread_id, + "--content", "User message", + "--role", "USER", + ], + env=cli_authenticated, + ) + + # Add ASSISTANT message + run_kscli_ok( + [ + "thread-messages", "create", + "--thread-id", thread_id, + "--content", "Assistant response", + "--role", "ASSISTANT", + ], + env=cli_authenticated, + ) + + # Verify both + result = run_kscli_ok( + ["thread-messages", "list", "--thread-id", thread_id], + env=cli_authenticated, + ) + assert len(result.json_output["items"]) >= 2 diff --git a/tests/e2e/test_cli_threads.py b/tests/e2e/test_cli_threads.py new file mode 100644 index 0000000..5d3398a --- /dev/null +++ b/tests/e2e/test_cli_threads.py @@ -0,0 +1,110 @@ +"""E2E tests for thread commands.""" + +from typing import Any + +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 + +pytestmark = pytest.mark.e2e + + +class TestCliThreadsRead: + """Read-only thread tests.""" + + def test_list_threads(self, cli_authenticated: dict[str, str]) -> None: + """List threads returns a list.""" + result = run_kscli_ok(["threads", "list"], env=cli_authenticated) + data = result.json_output + assert isinstance(data, dict) + assert isinstance(data["items"], list) + + def test_describe_thread_not_found(self, cli_authenticated: dict[str, str]) -> None: + """Describe a nonexistent thread returns exit code 3.""" + run_kscli_fail( + ["threads", "describe", NONEXISTENT_UUID], + env=cli_authenticated, + expected_code=3, + ) + + +class TestCliThreadsWrite: + """Write thread tests.""" + + def test_create_describe_update_delete_thread( + self, + 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) + result = run_kscli_ok( + [ + "threads", "create", + "--title", "e2e test thread", + "--parent-path-part-id", PWUSER1_THREADS_FOLDER_PATH_PART_ID, + ], + env=cli_authenticated, + ) + thread = result.json_output + thread_id = thread["id"] + assert thread["title"] == "e2e test thread" + + # Describe + result = run_kscli_ok( + ["threads", "describe", thread_id], + env=cli_authenticated, + ) + assert result.json_output["title"] == "e2e test thread" + + # Update + run_kscli_ok( + ["threads", "update", thread_id, "--title", "updated title"], + env=cli_authenticated, + ) + + # Verify + result = run_kscli_ok( + ["threads", "describe", thread_id], + env=cli_authenticated, + ) + assert result.json_output["title"] == "updated title" + + # Delete + run_kscli_ok( + ["threads", "delete", thread_id], + env=cli_authenticated, + format_json=False, + ) + + # Verify deleted + run_kscli_fail( + ["threads", "describe", thread_id], + env=cli_authenticated, + expected_code=3, + ) + + def test_delete_non_conversation_thread_fails( + self, + cli_authenticated: dict[str, str], + isolation_folder: dict[str, Any], + ) -> None: + """Deleting a thread not under /users/{id} fails.""" + parent_id = isolation_folder["path_part_id"] + + result = run_kscli_ok( + [ + "threads", "create", + "--title", "non-conversation thread", + "--parent-path-part-id", parent_id, + ], + env=cli_authenticated, + ) + thread_id = result.json_output["id"] + + # Should fail — only conversation threads can be deleted + run_kscli_fail( + ["threads", "delete", thread_id], + env=cli_authenticated, + format_json=False, + ) diff --git a/tests/e2e/test_cli_users.py b/tests/e2e/test_cli_users.py new file mode 100644 index 0000000..55456ea --- /dev/null +++ b/tests/e2e/test_cli_users.py @@ -0,0 +1,24 @@ +"""E2E tests for users commands.""" + +import pytest + +from tests.e2e.cli_helpers import run_kscli_ok +from tests.e2e.conftest import SHARED_TENANT_ID + +pytestmark = pytest.mark.e2e + + +class TestCliUsers: + """User command tests.""" + + def test_update_user(self, cli_authenticated: dict[str, str]) -> None: + """Update current user's default tenant (idempotent - set to current).""" + result = run_kscli_ok( + [ + "users", "update", + "--default-tenant-id", SHARED_TENANT_ID, + ], + env=cli_authenticated, + ) + user = result.json_output + assert isinstance(user, dict) diff --git a/tests/e2e/test_cli_workflows.py b/tests/e2e/test_cli_workflows.py new file mode 100644 index 0000000..be52e7e --- /dev/null +++ b/tests/e2e/test_cli_workflows.py @@ -0,0 +1,26 @@ +"""E2E tests for workflow commands (read-only).""" + +import pytest + +from tests.e2e.cli_helpers import run_kscli_fail, run_kscli_ok +from tests.e2e.conftest import NONEXISTENT_UUID + +pytestmark = pytest.mark.e2e + + +class TestCliWorkflows: + """Read-only workflow tests.""" + + def test_list_workflows(self, cli_authenticated: dict[str, str]) -> None: + """List workflows returns a list (may be empty).""" + result = run_kscli_ok(["workflows", "list"], env=cli_authenticated) + data = result.json_output + assert isinstance(data, dict) + assert isinstance(data["items"], list) + + def test_describe_workflow_not_found(self, cli_authenticated: dict[str, str]) -> None: + """Describe a nonexistent workflow returns error.""" + run_kscli_fail( + ["workflows", "describe", NONEXISTENT_UUID], + env=cli_authenticated, + ) diff --git a/uv.lock b/uv.lock index 0c4a66b..f474dfe 100644 --- a/uv.lock +++ b/uv.lock @@ -66,6 +66,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +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" @@ -153,6 +162,7 @@ dependencies = [ dev = [ { name = "basedpyright" }, { name = "pytest" }, + { name = "pytest-xdist" }, { name = "ruff" }, ] @@ -170,6 +180,7 @@ requires-dist = [ dev = [ { name = "basedpyright", specifier = ">=1.38.1" }, { name = "pytest", specifier = ">=9.0.2" }, + { name = "pytest-xdist", specifier = ">=3.8.0" }, { name = "ruff", specifier = ">=0.15.2" }, ] @@ -348,6 +359,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" From 01e65c8b221e53cbf1a772c1b6f01af6fefec3f6 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Sun, 22 Feb 2026 11:28:00 +0000 Subject: [PATCH 2/2] chore(release): v1.1.0 --- CHANGELOG.md | 8 ++++++++ pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ddffb0d..acd8d9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ +## v1.1.0 (2026-02-22) + +### Features + +- Complete release process ([#1](https://github.com/knowledgestack/ks-cli/pull/1), + [`513b295`](https://github.com/knowledgestack/ks-cli/commit/513b2956caa57c3d1e68c3003fea4f6e13ee13c1)) + + ## v1.0.1 (2026-02-22) ### Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index 443d1b9..0fe941e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "kscli" -version = "1.0.1" +version = "1.1.0" description = "Add your description here" readme = "README.md" license = "MIT" diff --git a/uv.lock b/uv.lock index f474dfe..9245d5e 100644 --- a/uv.lock +++ b/uv.lock @@ -147,7 +147,7 @@ wheels = [ [[package]] name = "kscli" -version = "1.0.1" +version = "1.1.0" source = { editable = "." } dependencies = [ { name = "certifi" },