kctl-payload 0.6.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,33 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ *.egg
6
+ dist/
7
+ build/
8
+ .eggs/
9
+
10
+ # Virtual environments
11
+ .venv/
12
+ venv/
13
+
14
+ # IDE
15
+ .idea/
16
+ .vscode/
17
+ *.swp
18
+ *.swo
19
+
20
+ # Testing
21
+ .pytest_cache/
22
+ .coverage
23
+ htmlcov/
24
+ .mypy_cache/
25
+ .ruff_cache/
26
+
27
+ # OS
28
+ .DS_Store
29
+ Thumbs.db
30
+
31
+ # Environment
32
+ .env
33
+ .env.local
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.4
2
+ Name: kctl-payload
3
+ Version: 0.6.1
4
+ Summary: Kodemeio Payload CMS CLI — manage Payload CMS via REST API
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: httpx>=0.28.0
7
+ Requires-Dist: kctl-lib>=0.7.0
8
+ Requires-Dist: pydantic>=2.10.0
9
+ Requires-Dist: pyyaml>=6.0.2
10
+ Requires-Dist: rich>=13.9.0
11
+ Requires-Dist: typer>=0.15.0
12
+ Provides-Extra: dev
13
+ Requires-Dist: mypy>=1.14.0; extra == 'dev'
14
+ Requires-Dist: pytest-httpx>=0.35.0; extra == 'dev'
15
+ Requires-Dist: pytest>=8.3.0; extra == 'dev'
16
+ Requires-Dist: ruff>=0.9.0; extra == 'dev'
17
+ Requires-Dist: types-pyyaml>=6.0.0; extra == 'dev'
@@ -0,0 +1,120 @@
1
+ # kctl-payload
2
+
3
+ Kodemeio Payload CMS CLI — manage content, media, and globals via REST API.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ # From workspace (development)
9
+ uv tool install --editable packages/kctl-payload
10
+
11
+ # From PyPI
12
+ uv tool install kctl-payload
13
+ ```
14
+
15
+ ## Quick Start
16
+
17
+ ```bash
18
+ # Initialize configuration
19
+ kctl-payload config init
20
+
21
+ # Test API connectivity
22
+ kctl-payload config test
23
+
24
+ # List entries in a collection
25
+ kctl-payload collections list blog-posts
26
+
27
+ # Upload and list media
28
+ kctl-payload media list
29
+
30
+ # Show global settings
31
+ kctl-payload globals show site-settings
32
+ ```
33
+
34
+ ## Command Groups
35
+
36
+ | Group | Commands | Description |
37
+ |-------|----------|-------------|
38
+ | `config` | init, add, use, show, set, remove, profiles, current, test, migrate | Manage profiles and credentials |
39
+ | `collections` | list, show, create, update, delete, query | CRUD operations on collection entries |
40
+ | `media` | list, show, upload, delete | Media library management |
41
+ | `globals` | list, show, update | Global document management |
42
+ | `users` | list, show, create, update, delete | User account management |
43
+ | `doctor` | check | Diagnostic checks (API connectivity, auth, config) |
44
+
45
+ ## Global Options
46
+
47
+ All commands accept these options:
48
+
49
+ | Option | Short | Description |
50
+ |--------|-------|-------------|
51
+ | `--json` | | Output as JSON |
52
+ | `--format` | `-f` | Output format: `pretty`, `json`, `csv`, `yaml` |
53
+ | `--quiet` | `-q` | Suppress info messages |
54
+ | `--no-header` | | Omit header row in table/CSV output |
55
+ | `--profile` | `-p` | Config profile name |
56
+ | `--version` | `-V` | Show version and exit |
57
+
58
+ ## Configuration
59
+
60
+ Config is stored in `~/.config/kodemeio/config.yaml` under the `payload` key.
61
+
62
+ ```bash
63
+ # Create a new profile
64
+ kctl-payload config init
65
+
66
+ # Add a named profile
67
+ kctl-payload config add --profile prod
68
+
69
+ # Switch active profile
70
+ kctl-payload config use prod
71
+
72
+ # Show current config (secrets masked)
73
+ kctl-payload config show
74
+
75
+ # Test connectivity
76
+ kctl-payload config test
77
+ ```
78
+
79
+ ### Config Keys
80
+
81
+ | Key | Description |
82
+ |-----|-------------|
83
+ | `url` | Payload CMS instance URL (e.g. `https://cms.example.com`) |
84
+ | `api_key` | Payload API key for authentication |
85
+
86
+ ### Profile Example
87
+
88
+ ```yaml
89
+ profiles:
90
+ kodemeio:
91
+ payload:
92
+ url: https://cms.kodeme.io
93
+ api_key: ${PAYLOAD_API_KEY}
94
+ ```
95
+
96
+ ## Common Workflows
97
+
98
+ ```bash
99
+ # Query posts with filters
100
+ kctl-payload collections query blog-posts --where '{"status": "published"}'
101
+
102
+ # Upload a media file
103
+ kctl-payload media upload ./banner.png --alt "Homepage banner"
104
+
105
+ # Update a global document
106
+ kctl-payload globals update site-settings --data '{"maintenance": false}'
107
+
108
+ # List all users
109
+ kctl-payload users list --format json
110
+ ```
111
+
112
+ ## Development
113
+
114
+ ```bash
115
+ cd packages/kctl-payload
116
+ uv sync --all-extras
117
+ uv run pytest tests/ -v
118
+ uv run mypy src/
119
+ uv run ruff check src/
120
+ ```
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "kctl-payload"
7
+ version = "0.6.1"
8
+ description = "Kodemeio Payload CMS CLI — manage Payload CMS via REST API"
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "kctl-lib>=0.7.0",
12
+ "typer>=0.15.0",
13
+ "rich>=13.9.0",
14
+ "pydantic>=2.10.0",
15
+ "pyyaml>=6.0.2",
16
+ "httpx>=0.28.0",
17
+ ]
18
+
19
+ [project.optional-dependencies]
20
+ dev = [
21
+ "pytest>=8.3.0",
22
+ "pytest-httpx>=0.35.0",
23
+ "ruff>=0.9.0",
24
+ "mypy>=1.14.0",
25
+ "types-PyYAML>=6.0.0",
26
+ ]
27
+
28
+ [project.scripts]
29
+ kctl-payload = "kctl_payload.cli:_run"
30
+
31
+ [tool.uv.sources]
32
+ kctl-lib = { workspace = true }
33
+
34
+ [tool.hatch.build.targets.wheel]
35
+ packages = ["src/kctl_payload"]
36
+
37
+ [tool.ruff]
38
+ target-version = "py312"
39
+ line-length = 120
40
+
41
+ [tool.mypy]
42
+ python_version = "3.12"
43
+ strict = true
@@ -0,0 +1,74 @@
1
+ ---
2
+ name: payload-admin
3
+ description: >
4
+ Payload CMS management via kctl-payload CLI.
5
+ MUST use for ANY kctl-payload operation.
6
+ Triggers on: "payload, cms, content, blog, testimonials, faqs, media, collections".
7
+ ---
8
+
9
+ # kctl-payload
10
+
11
+ Kodemeio Payload CMS CLI - manage your Payload CMS instance (collections, media, globals, users).
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ uv tool install kctl-payload
17
+ ```
18
+
19
+ ## Global Options
20
+
21
+ | Option | Short | Description |
22
+ |--------|-------|-------------|
23
+ | `--json` | | JSON output (shortcut for --format json) |
24
+ | `--quiet` | `-q` | Suppress info messages |
25
+ | `--format` | `-f` | Output format: pretty/json/csv/yaml |
26
+ | `--no-header` | | Omit headers in CSV output |
27
+ | `--debug` | | Enable debug logging |
28
+ | `--profile` | `-p` | Config profile name |
29
+ | `--url` | | API URL override |
30
+ | `--api-key` | | API key override |
31
+ | `--version` | `-V` | Show version |
32
+
33
+ ## Commands
34
+
35
+ ### config
36
+ - `kctl-payload config init` — Initialize CLI configuration
37
+ - `kctl-payload config show` — Show configuration (keys masked)
38
+ - `kctl-payload config test` — Test API connection
39
+
40
+ ### collections
41
+ - `kctl-payload collections list` — List documents in a collection
42
+ - `kctl-payload collections get` — Get a single document by ID
43
+ - `kctl-payload collections create` — Create a new document in a collection
44
+ - `kctl-payload collections update` — Update an existing document
45
+ - `kctl-payload collections delete` — Delete a document from a collection
46
+ - `kctl-payload collections query` — Query a collection with advanced filters
47
+
48
+ ### media
49
+ - `kctl-payload media list` — List media files
50
+ - `kctl-payload media upload` — Upload a media file (multipart form)
51
+ - `kctl-payload media delete` — Delete a media file
52
+
53
+ ### globals
54
+ - `kctl-payload globals get` — Get a global document
55
+ - `kctl-payload globals update` — Update a global document
56
+
57
+ ### users
58
+ - `kctl-payload users list` — List users
59
+ - `kctl-payload users create` — Create a new user
60
+
61
+ ### doctor
62
+ - `kctl-payload doctor` — Run diagnostic checks
63
+
64
+ ## Configuration
65
+
66
+ Service key: `payload` in `~/.config/kodemeio/config.yaml`
67
+
68
+ ```yaml
69
+ profiles:
70
+ terakidz:
71
+ payload:
72
+ url: https://cms.terakidz.com
73
+ api_key: <key>
74
+ ```
@@ -0,0 +1,3 @@
1
+ """kctl-payload: Kodemeio Payload CMS CLI."""
2
+
3
+ __version__ = "0.6.1"
@@ -0,0 +1,5 @@
1
+ """Allow running as: python -m kctl_payload."""
2
+
3
+ from kctl_payload.cli import _run
4
+
5
+ _run()
@@ -0,0 +1,97 @@
1
+ """Main CLI entry point for kctl-payload."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ import typer
8
+ from kctl_lib import KctlError, handle_cli_error
9
+
10
+ from kctl_payload import __version__
11
+ from kctl_payload.commands.collections import app as collections_app
12
+ from kctl_payload.commands.config_cmd import app as config_app
13
+ from kctl_payload.commands.doctor import app as doctor_app
14
+ from kctl_payload.commands.globals_cmd import app as globals_app
15
+ from kctl_payload.commands.media import app as media_app
16
+ from kctl_payload.commands.users import app as users_app
17
+ from kctl_payload.core.callbacks import AppContext
18
+ from kctl_lib.self_update import notify_if_outdated
19
+ from kctl_lib.tui import add_tui_command
20
+
21
+
22
+ def version_callback(value: bool) -> None:
23
+ if value:
24
+ typer.echo(f"kctl-payload {__version__}")
25
+ raise typer.Exit()
26
+
27
+
28
+ app = typer.Typer(
29
+ name="kctl-payload",
30
+ help="Kodemeio Payload CMS CLI - manage your Payload CMS instance.",
31
+ no_args_is_help=True,
32
+ rich_markup_mode="rich",
33
+ pretty_exceptions_enable=False,
34
+ )
35
+
36
+
37
+ @app.callback()
38
+ def main(
39
+ ctx: typer.Context,
40
+ json_output: Annotated[bool, typer.Option("--json", help="Output as JSON (shortcut for --format json)")] = False,
41
+ quiet: Annotated[bool, typer.Option("--quiet", "-q", help="Suppress info messages")] = False,
42
+ output_format: Annotated[
43
+ str, typer.Option("--format", "-f", help="Output format: pretty, json, csv, yaml")
44
+ ] = "pretty",
45
+ no_header: Annotated[bool, typer.Option("--no-header", help="Omit headers in CSV output")] = False,
46
+ debug: Annotated[bool, typer.Option("--debug", help="Enable debug logging")] = False,
47
+ profile: Annotated[str | None, typer.Option("--profile", "-p", help="Config profile name")] = None,
48
+ url: Annotated[str | None, typer.Option("--url", help="API URL override")] = None,
49
+ api_key: Annotated[str | None, typer.Option("--api-key", help="API key override")] = None,
50
+ version: Annotated[
51
+ bool, typer.Option("--version", "-V", callback=version_callback, is_eager=True, help="Show version")
52
+ ] = False,
53
+ ) -> None:
54
+ """Kodemeio Payload CMS CLI."""
55
+ import os
56
+
57
+ if debug:
58
+ os.environ["KCTL_DEBUG"] = "1"
59
+
60
+ effective_format = "json" if json_output else output_format
61
+
62
+ ctx.ensure_object(dict)
63
+ ctx.obj = AppContext(
64
+ json_mode=json_output or effective_format == "json",
65
+ quiet=quiet,
66
+ format=effective_format,
67
+ no_header=no_header,
68
+ debug=debug,
69
+ profile=profile,
70
+ url_override=url,
71
+ api_key_override=api_key,
72
+ )
73
+ notify_if_outdated(ctx.obj.output, "kctl-payload", __version__)
74
+
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # Command groups
78
+ # ---------------------------------------------------------------------------
79
+ app.add_typer(config_app, name="config")
80
+ app.add_typer(collections_app, name="collections")
81
+ app.add_typer(media_app, name="media")
82
+ app.add_typer(globals_app, name="globals")
83
+ app.add_typer(users_app, name="users")
84
+ app.add_typer(doctor_app, name="doctor")
85
+ add_tui_command(app, service_key="payload", version=__version__)
86
+
87
+
88
+ def _run() -> None:
89
+ """Entry point with error handling."""
90
+ try:
91
+ app()
92
+ except KctlError as e:
93
+ handle_cli_error(e)
94
+
95
+
96
+ if __name__ == "__main__":
97
+ _run()
@@ -0,0 +1,155 @@
1
+ """Collection management commands for Payload CMS.
2
+
3
+ Supports CRUD operations on any Payload collection via REST API.
4
+ Known collections: blog-posts, testimonials, faqs, pages, campaigns, media
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from typing import Annotated
11
+
12
+ import typer
13
+
14
+ from kctl_payload.core.callbacks import AppContext
15
+
16
+ app = typer.Typer(help="Manage Payload CMS collections.")
17
+
18
+
19
+ @app.command("list")
20
+ def list_cmd(
21
+ ctx: typer.Context,
22
+ collection: Annotated[str, typer.Argument(help="Collection slug (e.g. blog-posts, pages)")],
23
+ status: Annotated[str | None, typer.Option("--status", "-s", help="Filter by _status field")] = None,
24
+ limit: Annotated[int, typer.Option("--limit", "-l", help="Number of items to return")] = 10,
25
+ page: Annotated[int, typer.Option("--page", help="Page number")] = 1,
26
+ ) -> None:
27
+ """List documents in a collection."""
28
+ c: AppContext = ctx.obj
29
+ out = c.output
30
+ params: dict[str, str | int] = {"limit": limit, "page": page}
31
+ if status:
32
+ params["where[_status][equals]"] = status
33
+
34
+ result = c.client.get(f"/{collection}", params=params)
35
+ docs = result.get("docs", []) if isinstance(result, dict) else result
36
+ out.table(
37
+ docs,
38
+ columns=["id", "title", "slug", "_status", "createdAt", "updatedAt"],
39
+ title=f"{collection} (page {page})",
40
+ )
41
+
42
+
43
+ @app.command()
44
+ def get(
45
+ ctx: typer.Context,
46
+ collection: Annotated[str, typer.Argument(help="Collection slug")],
47
+ doc_id: Annotated[str, typer.Argument(help="Document ID")],
48
+ ) -> None:
49
+ """Get a single document by ID."""
50
+ c: AppContext = ctx.obj
51
+ out = c.output
52
+ result = c.client.get(f"/{collection}/{doc_id}")
53
+ out.raw_json(result)
54
+
55
+
56
+ @app.command()
57
+ def create(
58
+ ctx: typer.Context,
59
+ collection: Annotated[str, typer.Argument(help="Collection slug")],
60
+ data: Annotated[str, typer.Option("--data", "-d", help="JSON string of document data")],
61
+ ) -> None:
62
+ """Create a new document in a collection."""
63
+ c: AppContext = ctx.obj
64
+ out = c.output
65
+ try:
66
+ payload = json.loads(data)
67
+ except json.JSONDecodeError as e:
68
+ out.error(f"Invalid JSON: {e}")
69
+ raise typer.Exit(1) from e
70
+
71
+ result = c.client.post(f"/{collection}", json=payload)
72
+ doc = result.get("doc", result) if isinstance(result, dict) else result
73
+ out.success(f"Created document in {collection}")
74
+ out.raw_json(doc)
75
+
76
+
77
+ @app.command()
78
+ def update(
79
+ ctx: typer.Context,
80
+ collection: Annotated[str, typer.Argument(help="Collection slug")],
81
+ doc_id: Annotated[str, typer.Argument(help="Document ID")],
82
+ data: Annotated[str, typer.Option("--data", "-d", help="JSON string of fields to update")],
83
+ ) -> None:
84
+ """Update an existing document."""
85
+ c: AppContext = ctx.obj
86
+ out = c.output
87
+ try:
88
+ payload = json.loads(data)
89
+ except json.JSONDecodeError as e:
90
+ out.error(f"Invalid JSON: {e}")
91
+ raise typer.Exit(1) from e
92
+
93
+ result = c.client.patch(f"/{collection}/{doc_id}", json=payload)
94
+ doc = result.get("doc", result) if isinstance(result, dict) else result
95
+ out.success(f"Updated document {doc_id} in {collection}")
96
+ out.raw_json(doc)
97
+
98
+
99
+ @app.command()
100
+ def delete(
101
+ ctx: typer.Context,
102
+ collection: Annotated[str, typer.Argument(help="Collection slug")],
103
+ doc_id: Annotated[str, typer.Argument(help="Document ID")],
104
+ force: Annotated[bool, typer.Option("--force", "-f", help="Skip confirmation")] = False,
105
+ ) -> None:
106
+ """Delete a document from a collection."""
107
+ c: AppContext = ctx.obj
108
+ out = c.output
109
+ if not force:
110
+ typer.confirm(f"Delete document {doc_id} from {collection}?", abort=True)
111
+
112
+ c.client.delete(f"/{collection}/{doc_id}")
113
+ out.success(f"Deleted document {doc_id} from {collection}")
114
+
115
+
116
+ @app.command()
117
+ def query(
118
+ ctx: typer.Context,
119
+ collection: Annotated[str, typer.Argument(help="Collection slug")],
120
+ where: Annotated[str | None, typer.Option("--where", "-w", help="Payload where query as JSON")] = None,
121
+ sort: Annotated[str | None, typer.Option("--sort", help="Sort field (prefix with - for desc)")] = None,
122
+ limit: Annotated[int, typer.Option("--limit", "-l", help="Number of items to return")] = 10,
123
+ page: Annotated[int, typer.Option("--page", help="Page number")] = 1,
124
+ ) -> None:
125
+ """Query a collection with advanced filters."""
126
+ c: AppContext = ctx.obj
127
+ out = c.output
128
+ params: dict[str, str | int] = {"limit": limit, "page": page}
129
+ if sort:
130
+ params["sort"] = sort
131
+
132
+ # Parse where clause from JSON into Payload query params
133
+ if where:
134
+ try:
135
+ where_obj = json.loads(where)
136
+ # Flatten where object into query params
137
+ for field_name, conditions in where_obj.items():
138
+ if isinstance(conditions, dict):
139
+ for op, val in conditions.items():
140
+ params[f"where[{field_name}][{op}]"] = val
141
+ else:
142
+ params[f"where[{field_name}][equals]"] = conditions
143
+ except json.JSONDecodeError as e:
144
+ out.error(f"Invalid JSON in --where: {e}")
145
+ raise typer.Exit(1) from e
146
+
147
+ result = c.client.get(f"/{collection}", params=params)
148
+ docs = result.get("docs", []) if isinstance(result, dict) else result
149
+ total = result.get("totalDocs", "?") if isinstance(result, dict) else "?"
150
+ out.info(f"Found {total} documents (showing page {page})")
151
+ out.table(
152
+ docs,
153
+ columns=["id", "title", "slug", "_status", "createdAt"],
154
+ title=f"{collection} query results",
155
+ )
@@ -0,0 +1,105 @@
1
+ """Configuration management commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ import typer
8
+
9
+ from kctl_payload.core.callbacks import AppContext
10
+ from kctl_payload.core.config import (
11
+ CONFIG_FILE,
12
+ SERVICE_KEY,
13
+ ServiceConfig,
14
+ get_all_services_in_profile,
15
+ get_default_profile,
16
+ get_profile_names,
17
+ resolve_active_profile_name,
18
+ set_default_profile,
19
+ set_service_config,
20
+ )
21
+
22
+ app = typer.Typer(help="Manage CLI configuration and profiles.")
23
+
24
+
25
+ def _mask(val: str) -> str:
26
+ if not val:
27
+ return "[dim]not set[/dim]"
28
+ return f"{val[:4]}{'*' * max(0, len(val) - 8)}{val[-4:]}" if len(val) > 10 else "****"
29
+
30
+
31
+ @app.command()
32
+ def init(
33
+ ctx: typer.Context,
34
+ url: Annotated[str | None, typer.Option("--url")] = None,
35
+ api_key: Annotated[str | None, typer.Option("--api-key")] = None,
36
+ name: Annotated[str | None, typer.Option("--name", "-n")] = None,
37
+ ) -> None:
38
+ """Initialize CLI configuration."""
39
+ c: AppContext = ctx.obj
40
+ out = c.output
41
+ profile_name = name or typer.prompt("Profile name", default="kodemeio")
42
+ api_url = url or typer.prompt("Payload CMS URL (e.g. https://cms.example.com)")
43
+ key = api_key or typer.prompt("API key", hide_input=True)
44
+
45
+ svc = ServiceConfig(url=api_url, api_key=key)
46
+ set_service_config(profile_name, svc)
47
+ if len(get_profile_names()) <= 1:
48
+ set_default_profile(profile_name)
49
+ out.success(f"Configuration saved to {CONFIG_FILE}")
50
+ out.kv("Profile", profile_name)
51
+ out.kv("URL", api_url)
52
+ out.kv("API Key", _mask(key))
53
+
54
+
55
+ @app.command()
56
+ def show(ctx: typer.Context) -> None:
57
+ """Show configuration (keys masked)."""
58
+ c: AppContext = ctx.obj
59
+ out = c.output
60
+ default = get_default_profile()
61
+ sections = [
62
+ (
63
+ "General",
64
+ [
65
+ ("Config file", str(CONFIG_FILE)),
66
+ ("Default profile", default),
67
+ ("Service key", SERVICE_KEY),
68
+ ],
69
+ )
70
+ ]
71
+ for pname in get_profile_names():
72
+ marker = " [green](default)[/green]" if pname == default else ""
73
+ services = get_all_services_in_profile(pname)
74
+ kvs = []
75
+ for svc_name, svc_data in services.items():
76
+ if not isinstance(svc_data, dict):
77
+ continue
78
+ indicator = "[green]●[/green]" if svc_name == SERVICE_KEY else "[dim]○[/dim]"
79
+ kvs.append(
80
+ (
81
+ f"{indicator} {svc_name}",
82
+ f"{svc_data.get('url', '')} key: {_mask(svc_data.get('api_key', ''))}",
83
+ )
84
+ )
85
+ sections.append((f"Profile: {pname}{marker}", kvs or [("(empty)", "")]))
86
+ out.detail("Configuration", sections)
87
+
88
+
89
+ @app.command()
90
+ def test(ctx: typer.Context) -> None:
91
+ """Test API connection."""
92
+ c: AppContext = ctx.obj
93
+ out = c.output
94
+ active = resolve_active_profile_name(c.profile)
95
+ out.info(f"Testing profile '{active}' → {SERVICE_KEY}")
96
+ try:
97
+ status = c.client.check_health()
98
+ if status == 200:
99
+ out.success("Connected to Payload CMS API")
100
+ else:
101
+ out.error(f"Connection failed: HTTP {status}")
102
+ raise typer.Exit(1)
103
+ except Exception as e:
104
+ out.error(f"Connection failed: {e}")
105
+ raise typer.Exit(1) from e
@@ -0,0 +1,82 @@
1
+ """Doctor diagnostic checks for kctl-payload."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ import typer
8
+
9
+ from kctl_payload.core.callbacks import AppContext
10
+ from kctl_lib.doctor_base import CheckResult, DoctorCheck, run_doctor
11
+
12
+
13
+ @dataclass
14
+ class APIConnectivityCheck:
15
+ """Check that the configured API endpoint is reachable."""
16
+
17
+ name: str = "API Connectivity"
18
+
19
+ def run(self) -> CheckResult:
20
+ try:
21
+ from kctl_payload.core.config import get_service_config, resolve_active_profile_name
22
+
23
+ profile = resolve_active_profile_name()
24
+ cfg = get_service_config(profile)
25
+ url = cfg.url or ""
26
+ if not url:
27
+ return CheckResult(
28
+ name=self.name,
29
+ status="fail",
30
+ message="No URL configured",
31
+ fix_command="kctl-payload config init",
32
+ )
33
+ return CheckResult(name=self.name, status="ok", message=f"URL: {url}")
34
+ except Exception as e:
35
+ return CheckResult(name=self.name, status="warn", message=str(e))
36
+
37
+
38
+ @dataclass
39
+ class AuthCheck:
40
+ """Check that authentication credentials are configured."""
41
+
42
+ name: str = "Authentication"
43
+
44
+ def run(self) -> CheckResult:
45
+ try:
46
+ from kctl_payload.core.config import get_service_config, resolve_active_profile_name
47
+
48
+ profile = resolve_active_profile_name()
49
+ cfg = get_service_config(profile)
50
+ token = cfg.api_key or ""
51
+ if not token:
52
+ return CheckResult(
53
+ name=self.name,
54
+ status="fail",
55
+ message="No API key configured",
56
+ fix_command="kctl-payload config init",
57
+ )
58
+ masked = token[:4] + "****" + token[-4:] if len(token) > 8 else "****"
59
+ return CheckResult(name=self.name, status="ok", message=f"API key configured ({masked})")
60
+ except Exception as e:
61
+ return CheckResult(name=self.name, status="warn", message=str(e))
62
+
63
+
64
+ app = typer.Typer(help="Run diagnostic checks.", no_args_is_help=False, invoke_without_command=True)
65
+
66
+
67
+ @app.callback(invoke_without_command=True)
68
+ def doctor(ctx: typer.Context) -> None:
69
+ """Run all diagnostic checks."""
70
+ if ctx.invoked_subcommand is not None:
71
+ return
72
+ actx: AppContext = ctx.obj
73
+ out = actx.output
74
+
75
+ checks: list[DoctorCheck] = [
76
+ APIConnectivityCheck(),
77
+ AuthCheck(),
78
+ ]
79
+
80
+ all_passed = run_doctor(checks, out) # type: ignore[arg-type]
81
+ if not all_passed:
82
+ raise typer.Exit(code=1)
@@ -0,0 +1,48 @@
1
+ """Globals management commands for Payload CMS.
2
+
3
+ Supports get and update operations on global documents.
4
+ Known globals: site-settings, navigation
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from typing import Annotated
11
+
12
+ import typer
13
+
14
+ from kctl_payload.core.callbacks import AppContext
15
+
16
+ app = typer.Typer(help="Manage Payload CMS globals.")
17
+
18
+
19
+ @app.command()
20
+ def get(
21
+ ctx: typer.Context,
22
+ slug: Annotated[str, typer.Argument(help="Global slug (e.g. site-settings, navigation)")],
23
+ ) -> None:
24
+ """Get a global document."""
25
+ c: AppContext = ctx.obj
26
+ out = c.output
27
+ result = c.client.get(f"/globals/{slug}")
28
+ out.raw_json(result)
29
+
30
+
31
+ @app.command()
32
+ def update(
33
+ ctx: typer.Context,
34
+ slug: Annotated[str, typer.Argument(help="Global slug (e.g. site-settings, navigation)")],
35
+ data: Annotated[str, typer.Option("--data", "-d", help="JSON string of fields to update")],
36
+ ) -> None:
37
+ """Update a global document."""
38
+ c: AppContext = ctx.obj
39
+ out = c.output
40
+ try:
41
+ payload = json.loads(data)
42
+ except json.JSONDecodeError as e:
43
+ out.error(f"Invalid JSON: {e}")
44
+ raise typer.Exit(1) from e
45
+
46
+ result = c.client.patch(f"/globals/{slug}", json=payload)
47
+ out.success(f"Updated global '{slug}'")
48
+ out.raw_json(result)
@@ -0,0 +1,85 @@
1
+ """Media management commands for Payload CMS.
2
+
3
+ Supports listing, uploading, and deleting media files.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from pathlib import Path
9
+ from typing import Annotated
10
+
11
+ import typer
12
+
13
+ from kctl_payload.core.callbacks import AppContext
14
+
15
+ app = typer.Typer(help="Manage Payload CMS media files.")
16
+
17
+
18
+ @app.command("list")
19
+ def list_cmd(
20
+ ctx: typer.Context,
21
+ limit: Annotated[int, typer.Option("--limit", "-l", help="Number of items to return")] = 10,
22
+ page: Annotated[int, typer.Option("--page", help="Page number")] = 1,
23
+ ) -> None:
24
+ """List media files."""
25
+ c: AppContext = ctx.obj
26
+ out = c.output
27
+ params: dict[str, str | int] = {"limit": limit, "page": page}
28
+
29
+ result = c.client.get("/media", params=params)
30
+ docs = result.get("docs", []) if isinstance(result, dict) else result
31
+ out.table(
32
+ docs,
33
+ columns=["id", "filename", "mimeType", "filesize", "createdAt"],
34
+ title=f"Media (page {page})",
35
+ )
36
+
37
+
38
+ @app.command()
39
+ def upload(
40
+ ctx: typer.Context,
41
+ file_path: Annotated[Path, typer.Argument(help="Path to file to upload")],
42
+ alt: Annotated[str | None, typer.Option("--alt", help="Alt text for the media")] = None,
43
+ ) -> None:
44
+ """Upload a media file (multipart form)."""
45
+ c: AppContext = ctx.obj
46
+ out = c.output
47
+
48
+ if not file_path.exists():
49
+ out.error(f"File not found: {file_path}")
50
+ raise typer.Exit(1)
51
+
52
+ import httpx
53
+
54
+ # Build multipart upload using raw httpx since APIClient.post is JSON-oriented
55
+ files = {"file": (file_path.name, file_path.open("rb"))}
56
+ data: dict[str, str] = {}
57
+ if alt:
58
+ data["alt"] = alt
59
+
60
+ url = f"{c.client._base_url}/media"
61
+ headers = {c.client.AUTH_HEADER: f"{c.client.AUTH_PREFIX}{c.client._credential}"}
62
+
63
+ response = httpx.post(url, files=files, data=data, headers=headers, timeout=60)
64
+ response.raise_for_status()
65
+ result = response.json()
66
+
67
+ doc = result.get("doc", result) if isinstance(result, dict) else result
68
+ out.success(f"Uploaded {file_path.name}")
69
+ out.raw_json(doc)
70
+
71
+
72
+ @app.command()
73
+ def delete(
74
+ ctx: typer.Context,
75
+ media_id: Annotated[str, typer.Argument(help="Media document ID")],
76
+ force: Annotated[bool, typer.Option("--force", "-f", help="Skip confirmation")] = False,
77
+ ) -> None:
78
+ """Delete a media file."""
79
+ c: AppContext = ctx.obj
80
+ out = c.output
81
+ if not force:
82
+ typer.confirm(f"Delete media {media_id}?", abort=True)
83
+
84
+ c.client.delete(f"/media/{media_id}")
85
+ out.success(f"Deleted media {media_id}")
@@ -0,0 +1,58 @@
1
+ """User management commands for Payload CMS."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ from kctl_payload.core.callbacks import AppContext
11
+
12
+ app = typer.Typer(help="Manage Payload CMS users.")
13
+
14
+
15
+ @app.command("list")
16
+ def list_cmd(
17
+ ctx: typer.Context,
18
+ limit: Annotated[int, typer.Option("--limit", "-l", help="Number of items to return")] = 10,
19
+ page: Annotated[int, typer.Option("--page", help="Page number")] = 1,
20
+ ) -> None:
21
+ """List users."""
22
+ c: AppContext = ctx.obj
23
+ out = c.output
24
+ params: dict[str, str | int] = {"limit": limit, "page": page}
25
+
26
+ result = c.client.get("/users", params=params)
27
+ docs = result.get("docs", []) if isinstance(result, dict) else result
28
+ out.table(
29
+ docs,
30
+ columns=["id", "email", "name", "role", "createdAt"],
31
+ title=f"Users (page {page})",
32
+ )
33
+
34
+
35
+ @app.command()
36
+ def create(
37
+ ctx: typer.Context,
38
+ email: Annotated[str, typer.Option("--email", "-e", help="User email")],
39
+ password: Annotated[str, typer.Option("--password", "-p", help="User password", hide_input=True)],
40
+ data: Annotated[str | None, typer.Option("--data", "-d", help="Additional user data as JSON")] = None,
41
+ ) -> None:
42
+ """Create a new user."""
43
+ c: AppContext = ctx.obj
44
+ out = c.output
45
+ payload: dict = {"email": email, "password": password}
46
+
47
+ if data:
48
+ try:
49
+ extra = json.loads(data)
50
+ payload.update(extra)
51
+ except json.JSONDecodeError as e:
52
+ out.error(f"Invalid JSON in --data: {e}")
53
+ raise typer.Exit(1) from e
54
+
55
+ result = c.client.post("/users", json=payload)
56
+ doc = result.get("doc", result) if isinstance(result, dict) else result
57
+ out.success(f"Created user {email}")
58
+ out.raw_json(doc)
File without changes
@@ -0,0 +1,39 @@
1
+ """Typer global callback and shared context for kctl-payload.
2
+
3
+ Subclasses AppContextBase from kctl-lib with Payload CMS-specific fields.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass, field
9
+
10
+ from kctl_lib.callbacks import AppContextBase
11
+
12
+ from kctl_payload.core.client import PayloadClient
13
+ from kctl_payload.core.config import resolve_connection
14
+
15
+
16
+ @dataclass
17
+ class AppContext(AppContextBase):
18
+ """Payload CMS-specific application context."""
19
+
20
+ debug: bool = False
21
+ url_override: str | None = None
22
+ api_key_override: str | None = None
23
+ _client: PayloadClient | None = field(default=None, repr=False, init=False)
24
+
25
+ @property
26
+ def client(self) -> PayloadClient:
27
+ if self._client is None:
28
+ url, api_key = resolve_connection(
29
+ profile_name=self.profile,
30
+ url_override=self.url_override,
31
+ api_key_override=self.api_key_override,
32
+ )
33
+ self._client = PayloadClient(base_url=url, api_key=api_key)
34
+ return self._client
35
+
36
+ def close(self) -> None:
37
+ """Close underlying HTTP client."""
38
+ if self._client is not None:
39
+ self._client.close()
@@ -0,0 +1,62 @@
1
+ """Payload CMS API client, subclassing kctl-lib's APIClient.
2
+
3
+ Provides Payload-specific auth (Authorization: users API-Key <key>),
4
+ retry support, and health check functionality.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ import httpx
12
+ from kctl_lib.api_client import APIClient
13
+ from kctl_lib.exceptions import ConfigError
14
+
15
+
16
+ class PayloadClient(APIClient):
17
+ """Synchronous httpx client for Payload CMS API with retry support."""
18
+
19
+ AUTH_HEADER = "Authorization"
20
+ AUTH_PREFIX = "users API-Key "
21
+ API_PREFIX = "/api"
22
+
23
+ def __init__(
24
+ self,
25
+ base_url: str = "",
26
+ api_key: str = "",
27
+ timeout: float = 30.0,
28
+ max_retries: int = 3,
29
+ retry_base_delay: float = 2.0,
30
+ retry_max_delay: float = 60.0,
31
+ **kwargs: Any,
32
+ ):
33
+ if not base_url:
34
+ raise ConfigError("No URL configured. Run: kctl-payload config init")
35
+
36
+ super().__init__(
37
+ base_url=base_url,
38
+ credential=api_key or "unset",
39
+ timeout=timeout,
40
+ retry_enabled=True,
41
+ max_retries=max_retries,
42
+ retry_base_delay=retry_base_delay,
43
+ retry_max_delay=retry_max_delay,
44
+ **kwargs,
45
+ )
46
+
47
+ @property
48
+ def root_url(self) -> str:
49
+ """Public accessor for the root URL (without /api)."""
50
+ return self._base_url.rsplit("/api", 1)[0]
51
+
52
+ def check_health(self) -> int:
53
+ """Check health by hitting /api/globals/site-settings, returns HTTP status code."""
54
+ try:
55
+ r = httpx.get(
56
+ f"{self._base_url}/globals/site-settings",
57
+ headers={self.AUTH_HEADER: f"{self.AUTH_PREFIX}{self._credential}"},
58
+ timeout=5,
59
+ )
60
+ return r.status_code
61
+ except httpx.HTTPError:
62
+ return 0
@@ -0,0 +1,134 @@
1
+ """Profile management and configuration resolution for kctl-payload.
2
+
3
+ Delegates to kctl-lib's config framework with Payload CMS-specific settings.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+
10
+ from kctl_lib.config import (
11
+ CONFIG_DIR,
12
+ CONFIG_FILE,
13
+ ConfigFile,
14
+ expand_env,
15
+ get_all_services_in_profile,
16
+ get_default_profile,
17
+ get_profile_names,
18
+ is_service_scoped,
19
+ load_config,
20
+ load_raw_config,
21
+ remove_profile,
22
+ save_raw_config,
23
+ set_default_profile,
24
+ )
25
+ from kctl_lib.config import get_service_config as _get_service_config
26
+ from kctl_lib.config import (
27
+ resolve_active_profile_name as _resolve_active_profile_name,
28
+ )
29
+ from kctl_lib.config import set_service_config as _set_service_config
30
+ from pydantic import BaseModel
31
+
32
+ # This CLI's service key within a profile
33
+ SERVICE_KEY = "payload"
34
+
35
+ # Environment variable prefix for this CLI
36
+ ENV_PREFIX = "KCTL_PAYLOAD"
37
+
38
+ __all__ = [
39
+ "CONFIG_DIR",
40
+ "CONFIG_FILE",
41
+ "ConfigFile",
42
+ "SERVICE_KEY",
43
+ "ServiceConfig",
44
+ "get_all_services_in_profile",
45
+ "get_default_profile",
46
+ "get_profile_names",
47
+ "get_service_config",
48
+ "is_service_scoped",
49
+ "load_config",
50
+ "load_raw_config",
51
+ "remove_profile",
52
+ "resolve_active_profile_name",
53
+ "resolve_connection",
54
+ "save_raw_config",
55
+ "set_default_profile",
56
+ "set_service_config",
57
+ ]
58
+
59
+
60
+ class ServiceConfig(BaseModel):
61
+ """Payload CMS service-specific config within a profile."""
62
+
63
+ url: str = ""
64
+ api_key: str = ""
65
+
66
+
67
+ def get_service_config(profile_name: str) -> ServiceConfig:
68
+ """Get Payload service config from a profile."""
69
+ raw = _get_service_config(
70
+ profile_name,
71
+ SERVICE_KEY,
72
+ valid_fields=list(ServiceConfig.model_fields.keys()),
73
+ )
74
+ if not raw:
75
+ return ServiceConfig()
76
+ return ServiceConfig(**raw)
77
+
78
+
79
+ def set_service_config(profile_name: str, svc_config: ServiceConfig) -> None:
80
+ """Write Payload service config into a profile."""
81
+ svc_data = svc_config.model_dump(exclude_defaults=False)
82
+ # Remove empty values
83
+ cleaned = {k: v for k, v in svc_data.items() if v}
84
+ _set_service_config(profile_name, SERVICE_KEY, cleaned)
85
+
86
+
87
+ def _expand_key(api_key: str) -> str:
88
+ """Expand ${ENV_VAR} references in API key values."""
89
+ return expand_env(api_key)
90
+
91
+
92
+ def resolve_active_profile_name(
93
+ profile_name: str | None = None,
94
+ ) -> str:
95
+ """Resolve the active profile name from all sources."""
96
+ return _resolve_active_profile_name(profile_name, ENV_PREFIX)
97
+
98
+
99
+ def resolve_connection(
100
+ profile_name: str | None = None,
101
+ url_override: str | None = None,
102
+ api_key_override: str | None = None,
103
+ ) -> tuple[str, str]:
104
+ """Resolve API URL and API key from all sources.
105
+
106
+ Priority:
107
+ 1. CLI flags (url_override, api_key_override)
108
+ 2. KCTL_PAYLOAD_URL / KCTL_PAYLOAD_API_KEY env vars
109
+ 3. Profile's payload service config
110
+ """
111
+ url = ""
112
+ api_key = ""
113
+
114
+ # 3. Config file profile (service-scoped)
115
+ pname = resolve_active_profile_name(profile_name)
116
+ svc = get_service_config(pname)
117
+ if svc.url:
118
+ url = svc.url
119
+ if svc.api_key:
120
+ api_key = svc.api_key
121
+
122
+ # 2. KCTL env vars
123
+ if env_url := os.environ.get("KCTL_PAYLOAD_URL"):
124
+ url = env_url
125
+ if env_key := os.environ.get("KCTL_PAYLOAD_API_KEY"):
126
+ api_key = env_key
127
+
128
+ # 1. CLI flags
129
+ if url_override:
130
+ url = url_override
131
+ if api_key_override:
132
+ api_key = api_key_override
133
+
134
+ return url, api_key
File without changes
@@ -0,0 +1,106 @@
1
+ """Shared test fixtures for kctl-payload tests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from unittest.mock import MagicMock, patch
7
+
8
+ import pytest
9
+ from typer.testing import CliRunner
10
+
11
+ from kctl_lib.output import Output
12
+
13
+ from kctl_payload.core.callbacks import AppContext
14
+ from kctl_payload.core.client import PayloadClient
15
+
16
+
17
+ @pytest.fixture
18
+ def runner() -> CliRunner:
19
+ """Typer CLI test runner."""
20
+ return CliRunner()
21
+
22
+
23
+ @pytest.fixture
24
+ def mock_client() -> MagicMock:
25
+ """Mocked PayloadClient with predictable responses."""
26
+ client = MagicMock(spec=PayloadClient)
27
+ client._root_url = "https://cms.test.io"
28
+ client._base_url = "https://cms.test.io/api"
29
+ client._credential = "test-api-key-12345"
30
+ client.AUTH_HEADER = "Authorization"
31
+ client.AUTH_PREFIX = "users API-Key "
32
+ client.check_health.return_value = 200
33
+ client.get.return_value = {"docs": [], "totalDocs": 0}
34
+ client.post.return_value = {"doc": {}}
35
+ client.put.return_value = {"doc": {}}
36
+ client.patch.return_value = {"doc": {}}
37
+ client.delete.return_value = {}
38
+ return client
39
+
40
+
41
+ @pytest.fixture
42
+ def app_context(mock_client: MagicMock) -> AppContext:
43
+ """AppContext with mock client injected."""
44
+ ctx = AppContext(json_mode=True)
45
+ ctx._client = mock_client
46
+ return ctx
47
+
48
+
49
+ @pytest.fixture
50
+ def mock_config(tmp_path: Path):
51
+ """Redirect kctl-lib config to a temp directory."""
52
+ config_dir = tmp_path / "kodemeio"
53
+ config_dir.mkdir(parents=True)
54
+ config_file = config_dir / "config.yaml"
55
+ config_file.write_text("default_profile: default\nprofiles: {}\n")
56
+ with patch("kctl_lib.config.CONFIG_FILE", config_file):
57
+ yield config_file
58
+
59
+
60
+ @pytest.fixture
61
+ def mock_output() -> Output:
62
+ """Output instance for testing."""
63
+ return Output(json_mode=False, quiet=True, format="pretty")
64
+
65
+
66
+ @pytest.fixture
67
+ def mock_context(mock_client: MagicMock, mock_output: Output) -> AppContext:
68
+ """AppContext with mocked client."""
69
+ ctx = AppContext(quiet=True)
70
+ ctx._client = mock_client
71
+ ctx._output = mock_output
72
+ return ctx
73
+
74
+
75
+ @pytest.fixture
76
+ def sample_blog_posts() -> dict:
77
+ """Sample Payload CMS blog posts response."""
78
+ return {
79
+ "docs": [
80
+ {
81
+ "id": "6612a1b2c3d4e5f6a7b8c9d0",
82
+ "title": "Getting Started with Payload CMS",
83
+ "slug": "getting-started",
84
+ "_status": "published",
85
+ "createdAt": "2026-01-15T10:00:00.000Z",
86
+ "updatedAt": "2026-01-20T14:30:00.000Z",
87
+ },
88
+ {
89
+ "id": "6612a1b2c3d4e5f6a7b8c9d1",
90
+ "title": "Advanced Content Modeling",
91
+ "slug": "advanced-content-modeling",
92
+ "_status": "draft",
93
+ "createdAt": "2026-02-01T08:00:00.000Z",
94
+ "updatedAt": "2026-02-05T11:15:00.000Z",
95
+ },
96
+ ],
97
+ "totalDocs": 2,
98
+ "limit": 10,
99
+ "totalPages": 1,
100
+ "page": 1,
101
+ "pagingCounter": 1,
102
+ "hasPrevPage": False,
103
+ "hasNextPage": False,
104
+ "prevPage": None,
105
+ "nextPage": None,
106
+ }
@@ -0,0 +1,30 @@
1
+ """Basic CLI tests for kctl-payload."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typer.testing import CliRunner
6
+
7
+ from kctl_payload.cli import app
8
+
9
+ runner = CliRunner()
10
+
11
+
12
+ def test_app_exists():
13
+ assert app is not None
14
+
15
+
16
+ def test_help():
17
+ result = runner.invoke(app, ["--help"])
18
+ assert result.exit_code == 0
19
+ assert "Usage" in result.output
20
+
21
+
22
+ def test_version():
23
+ result = runner.invoke(app, ["--version"])
24
+ assert result.exit_code == 0
25
+
26
+
27
+ def test_config_help():
28
+ result = runner.invoke(app, ["config", "--help"])
29
+ assert result.exit_code == 0
30
+ assert "Usage" in result.output