Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .serena/memories/project_overview.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
# Project Overview

## Purpose

`ks-cli` (kscli) is a CLI tool for the Knowledge Stack platform. It wraps the auto-generated `ksapi` Python SDK with a Click-based command interface using a verb-first routing pattern (e.g. `kscli get folders`, `kscli describe document <id>`).

## Tech Stack

- **Language**: Python 3.14+
- **Runtime/Package Manager**: uv
- **CLI Framework**: Click
Expand All @@ -16,6 +18,7 @@
- **Releases**: semantic-release with conventional commits

## Codebase Structure

```
src/kscli/
├── cli.py # Root CLI group, verb-first routing, command registration
Expand Down Expand Up @@ -50,15 +53,19 @@ tests/
## Key Architecture Patterns

### Verb-first CLI routing

Commands are organized as `kscli <verb> <resource>`. Verb groups (get, describe, create, update, delete, search, etc.) are defined in `cli.py`. Each resource module exposes `register_<verb>(group)` functions that add Click commands to those groups.

### Command implementation pattern

Every command follows this pattern:

1. Get authenticated client: `api_client = get_api_client(ctx)`
2. Wrap in error handling: `with handle_client_errors():`
3. Instantiate SDK API: `api = ksapi.<Resource>Api(api_client)`
4. Call SDK method and format: `print_result(ctx, to_dict(result), columns=COLUMNS)`

### Config layering

Environment variables → `~/.config/kscli/config.json` → defaults.
Key env vars: `KSCLI_BASE_URL`, `ADMIN_API_KEY`, `KSCLI_FORMAT`, `KSCLI_VERIFY_SSL`, `KSCLI_CA_BUNDLE`, `KSCLI_CONFIG`, `KSCLI_CREDENTIALS_PATH`.
6 changes: 6 additions & 0 deletions .serena/memories/style_and_conventions.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Style and Conventions

## Code Style

- **Line length**: 88 characters
- **Quote style**: Double quotes
- **Indent style**: Spaces
Expand All @@ -9,24 +10,29 @@
- **Imports**: isort-managed, first-party packages: `shared_utils`, `database_schema`, `private_api_client`

## Ruff Rules

Extensive rule set enabled (E, W, F, I, UP, B, C4, DTZ, T10, G, PIE, PT, RET, SIM, TCH, ARG, PTH, ERA, PD, PGH, PL, RUF, D). Notable ignores:

- All missing docstring rules (D100-D107) are ignored
- E501 (line length) ignored — handled by formatter
- B008 (function call in default argument) ignored — common in Click/FastAPI
- Tests allow assert, unused args (fixtures), magic values

## Naming Conventions

- Command modules: `src/kscli/commands/<resource>.py`
- Test files: `tests/test_cli_<resource>.py`
- Registration functions: `register_<verb>(group: click.Group)`
- Column definitions: `COLUMNS = [...]` at module level

## Design Patterns

- **Verb-first routing**: Commands registered as `<verb> <resource>`, not `<resource> <verb>`
- **Registration pattern**: Each module exposes `register_<verb>()` functions, wired in `cli.py`
- **Error handling**: Always use `with handle_client_errors():` context manager around API calls
- **SDK usage**: `ksapi` is auto-generated — never modify it directly
- **Output**: Always pass through `to_dict()` → `print_result()` pipeline

## Commit Convention

Conventional commits (semantic-release): `feat:`, `fix:`, `perf:`, `chore:`, `ci:`, `docs:`, `style:`, `refactor:`, `test:`
7 changes: 7 additions & 0 deletions .serena/memories/suggested_commands.md
Original file line number Diff line number Diff line change
@@ -1,24 +1,28 @@
# Suggested Commands

## Development Setup

```bash
uv sync --all-extras --group dev # Install all dependencies
uv run pre-commit install # Install pre-commit hooks
```

## Linting & Formatting

```bash
uv run ruff check # Lint
uv run ruff check --fix # Lint + autofix
uv run ruff format # Format code
```

## Type Checking

```bash
uv run basedpyright --stats # Type check
```

## Testing

```bash
uv run pytest # Run all tests
uv run pytest tests/test_cli_folders.py # Single test file
Expand All @@ -27,17 +31,20 @@ uv run pytest -x # Stop on first failure
```

## Pre-commit (all checks)

```bash
make pre-commit # Runs lint + typecheck + test
```

## Running the CLI

```bash
uv run kscli --help # Show CLI help
uv run python -m kscli # Alternative entry point
```

## System utilities (macOS/Darwin)

```bash
git status / git diff / git log # Git operations
ls / find / grep # File operations (standard unix)
Expand Down
1 change: 1 addition & 0 deletions .serena/memories/task_completion_checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ After completing a coding task, run these checks:
Or run all at once: `make pre-commit`

## When adding a new resource command

- Create `src/kscli/commands/<resource>.py` with `register_<verb>()` functions
- Register the commands in `src/kscli/cli.py` on the appropriate verb groups
- Add tests in `tests/test_cli_<resource>.py` using `cli_helpers.run_kscli_ok`/`run_kscli_fail`
11 changes: 11 additions & 0 deletions .serena/project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,14 @@ initial_prompt: ""
# override of the corresponding setting in serena_config.yml, see the documentation there.
# If null or missing, the value from the global config is used.
symbol_info_budget:

# The language backend to use for this project.
# If not set, the global setting from serena_config.yml is used.
# Valid values: LSP, JetBrains
# Note: the backend is fixed at startup. If a project with a different backend
# is activated post-init, an error will be returned.
language_backend:

# list of regex patterns which, when matched, mark a memory entry as read‑only.
# Extends the list from the global configuration, merging the two lists.
read_only_memory_patterns: []
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ make pre-commit

The CLI uses resource groups as top-level subcommands (e.g. `folders`, `documents`, `chunks`, `tags`). Each resource module defines a `@click.group()` with verb subcommands — e.g. `kscli folders list`, `kscli folders describe <id>`, `kscli folders create`. The groups are registered in `cli.py` via `main.add_command(resource_group)`.

Top-level commands outside resource groups: `assume-user`, `whoami`, `settings`.
Top-level commands outside resource groups: `login`, `logout`, `whoami`, `settings`.

Resource groups: `folders`, `documents`, `document-versions`, `sections`, `chunks`, `tags`, `workflows`, `tenants`, `users`, `permissions`, `invites`, `threads`, `thread-messages`, `chunk-lineages`, `path-parts`.

Expand All @@ -59,11 +59,11 @@ Each resource (folders, documents, chunks, etc.) follows the same pattern:

### Auth (`src/kscli/auth.py`)

JWT-based auth via admin impersonation. `assume_user()` calls `/v1/auth/assume_user`, caches the token to a credentials file (default: `/tmp/kscli/.credentials`). `load_credentials()` auto-refreshes expired tokens.
API key auth via `kscli login --api-key <key>`. `save_api_key()` stores the key to a credentials file (default: `/tmp/kscli/.credentials`). `load_credentials()` reads the stored API key. `kscli logout` removes credentials.

### Config (`src/kscli/config.py`)

Layered config resolution: environment variables → config file (`~/.config/kscli/config.json`) → defaults. Key env vars: `KSCLI_BASE_URL`, `ADMIN_API_KEY`, `KSCLI_FORMAT`, `KSCLI_VERIFY_SSL`, `KSCLI_CA_BUNDLE`, `KSCLI_CONFIG`, `KSCLI_CREDENTIALS_PATH`.
Layered config resolution: environment variables → config file (`~/.config/kscli/config.json`) → defaults. Key env vars: `KSCLI_BASE_URL`, `KSCLI_FORMAT`, `KSCLI_VERIFY_SSL`, `KSCLI_CA_BUNDLE`, `KSCLI_CONFIG`, `KSCLI_CREDENTIALS_PATH`.

Environment presets: `local` (localhost:8000), `prod` (api.knowledgestack.ai) — set via `kscli settings environment <name>`.

Expand Down
19 changes: 19 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -45,5 +45,24 @@ wait-for-api: ## Wait for the e2e API to be ready
e2e-test: wait-for-api ## Run e2e tests (requires running backend)
@uv run pytest tests/e2e/ -v -m e2e -n 2

.PHONY: local-login
local-login: ## Login to local dev API (assumes dev-stack + dev-api running)
@API_URL=https://localhost:18000; \
USER_ID=00000000-0000-0000-0001-000000000001; \
TENANT_ID=00000000-0000-0000-0002-000000000001; \
TOKEN=$$(curl -sk -X POST $$API_URL/system/auth/assume_user \
-H "Authorization: Bearer dev-admin-api-key" \
-H "Content-Type: application/json" \
-d "{\"user_id\": \"$$USER_ID\", \"tenant_id\": \"$$TENANT_ID\"}" \
| python3 -c "import sys,json; print(json.load(sys.stdin)['token'])") && \
API_KEY=$$(curl -sk -X POST $$API_URL/v1/api-keys \
-H "Cookie: ks_uat=$$TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "cli-dev"}' \
| python3 -c "import sys,json; print(json.load(sys.stdin)['key'])") && \
uv run kscli settings environment local --url $$API_URL && \
uv run kscli login --api-key $$API_KEY && \
uv run kscli whoami

.PHONY: pre-commit
pre-commit: lint typecheck test ## Run linting, typechecking, and tests
2 changes: 0 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,7 @@ urls = { Repository = "https://github.com/knowledgestack/ks-cli" }
dependencies = [
"certifi>=2026.1.4",
"click>=8.3.1",
"httpx>=0.28.1",
"ksapi>=1.25.0",
"pyjwt>=2.11.0",
"rich>=14.3.3",
]

Expand Down
88 changes: 22 additions & 66 deletions src/kscli/auth.py
Original file line number Diff line number Diff line change
@@ -1,85 +1,41 @@
"""Credential caching and token management."""
"""Credential caching for API key authentication."""

import json
import os
from datetime import UTC, datetime
from pathlib import Path

import certifi
import httpx
import jwt
from kscli.config import get_current_environment

from kscli.config import get_tls_config

CREDENTIALS_PATH = Path(
os.environ.get("KSCLI_CREDENTIALS_PATH", "/tmp/kscli/.credentials")
_CREDENTIALS_DIR = Path(
os.environ.get("KSCLI_CREDENTIALS_PATH", "/tmp/kscli")
)


def assume_user(
base_url: str, admin_api_key: str, tenant_id: str, user_id: str
) -> dict[str, str]:
"""Call assume_user endpoint and cache the resulting token."""
verify_ssl, ca_bundle = get_tls_config()
verify = ca_bundle or certifi.where() if verify_ssl else False

resp = httpx.post(
f"{base_url}/v1/auth/assume_user",
json={"tenant_id": tenant_id, "user_id": user_id},
headers={"X-Admin-Api-Key": admin_api_key},
timeout=30.0,
verify=verify,
)
resp.raise_for_status()
token = resp.json()["token"]
save_credentials(token, user_id, tenant_id, admin_api_key)
return load_credentials()
def _credentials_path() -> Path:
"""Resolve per-environment credentials file path."""
env = get_current_environment()
return _CREDENTIALS_DIR / f".credentials_{env}"


def save_credentials(
token: str, user_id: str, tenant_id: str, admin_api_key: str
) -> None:
"""Decode JWT exp claim and write credentials file."""
payload = jwt.decode(token, options={"verify_signature": False})
expires_at = datetime.fromtimestamp(payload["exp"], tz=UTC).isoformat()
CREDENTIALS_PATH.parent.mkdir(parents=True, exist_ok=True)
CREDENTIALS_PATH.write_text(
json.dumps(
{
"token": token,
"user_id": user_id,
"tenant_id": tenant_id,
"expires_at": expires_at,
"admin_api_key": admin_api_key,
}
)
)
CREDENTIALS_PATH.chmod(0o600)
def save_api_key(api_key: str) -> None:
"""Store API key to per-environment credentials file with restricted permissions."""
path = _credentials_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({"api_key": api_key}))
path.chmod(0o600)


def load_credentials() -> dict[str, str]:
"""Load and validate credentials; re-assume if expired."""
if not CREDENTIALS_PATH.exists():
"""Load credentials for the current environment."""
path = _credentials_path()
if not path.exists():
env = get_current_environment()
raise SystemExit(
"Not authenticated. Run: kscli assume-user --tenant-id <id> --user-id <id>"
)
creds = json.loads(CREDENTIALS_PATH.read_text())
expires_at = datetime.fromisoformat(creds["expires_at"])
if datetime.now(tz=UTC) >= expires_at:
from kscli.config import get_admin_api_key, get_base_url # noqa: PLC0415

# Prefer credential-cached admin key; fallback keeps compatibility
# with credentials created before this field existed.
admin_api_key = creds.get("admin_api_key") or get_admin_api_key()
return assume_user(
get_base_url(),
admin_api_key,
creds["tenant_id"],
creds["user_id"],
f"Not authenticated for '{env}' environment. Run: kscli login --api-key <key>"
)
return creds
return json.loads(path.read_text())


def clear_credentials() -> None:
"""Remove credentials file."""
CREDENTIALS_PATH.unlink(missing_ok=True)
"""Remove credentials file for the current environment."""
_credentials_path().unlink(missing_ok=True)
3 changes: 2 additions & 1 deletion src/kscli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,8 @@ def main(ctx, format_, no_header, base_url): # noqa: ARG001 — params required

# ── Top-level commands ──────────────────────────────────────────────────────

main.add_command(auth.assume_user)
main.add_command(auth.login)
main.add_command(auth.logout)
main.add_command(auth.whoami)
main.add_command(settings.settings)

Expand Down
32 changes: 11 additions & 21 deletions src/kscli/client.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
"""SDK client helpers for kscli."""

import json
import sys
from contextlib import contextmanager
from typing import Any, Protocol, runtime_checkable
Expand All @@ -12,9 +11,10 @@

from kscli.auth import load_credentials
from kscli.config import get_base_url, get_tls_config
from kscli.utils.error import format_api_error

_STATUS_MESSAGES = {
401: "Session expired. Run: kscli assume-user --tenant-id <id> --user-id <id>",
401: "Session expired. Run: kscli login --api-key <key>",
403: "Permission denied",
404: "Not found",
409: "Conflict",
Expand Down Expand Up @@ -47,19 +47,20 @@ def get_api_client(ctx: click.Context) -> ksapi.ApiClient:
if verify_ssl:
config.ssl_ca_cert = ca_bundle or certifi.where()

return ksapi.ApiClient(config, cookie=f"ks_uat={creds['token']}")
client = ksapi.ApiClient(config)
client.default_headers["authorization"] = f"Bearer {creds['api_key']}"
return client


def get_current_identity(api_client: ksapi.ApiClient) -> ksapi.UserResponse:
"""Fetch the current user's identity via /me. Returns the user dict."""
return ksapi.UsersApi(api_client).get_me()


def handle_api_error(e: ksapi.ApiException) -> None:
"""Map SDK ApiException to error messages and exit codes matching original behavior."""
status = e.status or 500
detail = ""
if e.body:
try:
body = json.loads(e.body)
detail = body.get("detail", body)
except Exception:
detail = e.body
detail = format_api_error(e)
prefix = _STATUS_MESSAGES.get(status, f"Server error: {status}")
click.echo(f"Error: {prefix}: {detail}", err=True)
sys.exit(_EXIT_CODES.get(status, 1))
Expand Down Expand Up @@ -96,14 +97,3 @@ def _handle_ssl_error() -> None:
err=True,
)
sys.exit(1)


def to_dict(obj: object) -> dict[str, Any] | list[Any]:
"""Convert an SDK response model to a plain dict/list for print_result()."""
if obj is None:
return {}
if isinstance(obj, _SupportsToDict):
return obj.to_dict()
if isinstance(obj, (dict, list)):
return obj
return {}
Loading