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
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ jobs:
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # stable
with:
toolchain: stable
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
- run: cargo check --workspace

Expand All @@ -28,6 +30,8 @@ jobs:
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # stable
with:
toolchain: stable
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
- run: cargo test --workspace

Expand All @@ -38,6 +42,7 @@ jobs:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # stable
with:
toolchain: stable
components: clippy
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
- run: cargo clippy --workspace -- -D warnings
Expand All @@ -49,5 +54,6 @@ jobs:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # stable
with:
toolchain: stable
components: rustfmt
- run: cargo fmt --all -- --check
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ jobs:

- uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # stable
with:
toolchain: stable
targets: ${{ matrix.target }}

- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
Expand Down
43 changes: 36 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,15 +111,20 @@ wordpress-cli/
│ │ # AuthCommands
│ ├── context.rs # build_client(): resolves site profile + credentials -> WpClient
│ ├── crud.rs # Generic CRUD helpers: list, get, create, update, delete,
│ │ # list_all_pages (streaming NDJSON), list_object_keyed,
│ │ # get_by_slug, to_query_params, object_values_to_array
│ │ # *_at variants taking an explicit api_path (custom post types),
│ │ # list_all_pages_at (streaming NDJSON), list_object_keyed,
│ │ # get_by_slug, resolve_post_type_path, to_query_params
│ ├── input.rs # json_from_stdin, resolve_content (--content/--content-file/-),
│ │ # parse_key_values (--query k=v)
│ ├── dispatch.rs # Unified dispatcher: dispatch(command_path, args, client, dry_run)
│ │ # used by CLI and fleet exec
│ └── commands/
│ ├── mod.rs # Module declarations
│ ├── api.rs # `wpx api <METHOD> <path>`: raw REST escape hatch (execute() shared with dispatch)
│ ├── post.rs # PostCommands: list, get, create, update, delete, search
│ │ # PostTypeArgs (--type/--rest-base) routes to any post type's rest_base
│ ├── page.rs # PageCommands: list, get, create, update, delete
│ ├── media.rs # MediaCommands: list, get, upload, delete
│ ├── media.rs # MediaCommands: list, get, upload, update, delete
│ ├── user.rs # UserCommands: list, get, me
│ ├── comment.rs # CommentCommands: list, get, create, update, delete
│ ├── category.rs # CategoryCommands: list, get, create, update, delete
Expand Down Expand Up @@ -211,15 +216,35 @@ Located in `crates/wpx-cli/src/crud.rs`. All are generic over `R: Resource`:
| Helper | Signature | Notes |
|--------|-----------|-------|
| `list<R>` | `(client, params) -> RenderPayload` | Converts params to query string via `to_query_params()` |
| `list_all_pages<R>` | `(client, params) -> RenderPayload` | Streams all pages as NDJSON to stdout (100/page) |
| `list_at<R>` | `(client, api_path, params) -> RenderPayload` | Same, against an explicit collection path |
| `list_all_pages_at<R>` | `(client, api_path, params) -> RenderPayload` | Streams all pages as NDJSON to stdout (100/page) |
| `list_object_keyed<R>` | `(client, api_path) -> RenderPayload` | For endpoints returning `{slug: {...}}` instead of arrays |
| `get<R>` | `(client, id) -> RenderPayload` | GET `{API_PATH}/{id}` |
| `get_at<R>` | `(client, api_path, id, params) -> RenderPayload` | GET `{api_path}/{id}?{params}` (e.g. `context=edit` for `content.raw`) |
| `get_by_slug<R>` | `(client, api_path, slug) -> RenderPayload` | GET `{api_path}/{slug}` |
| `create<R>` | `(client, body, dry_run) -> RenderPayload` | POST to `API_PATH`; dry_run returns what would be created |
| `create_at<R>` | `(client, api_path, body, dry_run) -> RenderPayload` | POST to `api_path` |
| `update<R>` | `(client, id, body, dry_run) -> RenderPayload` | POST to `{API_PATH}/{id}` |
| `update_at<R>` | `(client, api_path, id, body, dry_run) -> RenderPayload` | POST to `{api_path}/{id}` |
| `delete<R>` | `(client, id, force, dry_run) -> RenderPayload` | DELETE; force=true permanently deletes, false trashes |
| `delete_at<R>` | `(client, api_path, id, force, dry_run) -> RenderPayload` | DELETE `{api_path}/{id}` |
| `resolve_post_type_path` | `(client, post_type, rest_base) -> String` | `None`/`post` → `wp/v2/posts`, `page` → `wp/v2/pages`, else `GET wp/v2/types/{slug}` → `{rest_namespace}/{rest_base}`; `rest_base` skips the lookup |

The `to_query_params()` helper serializes any `Serialize` struct to `Vec<(String, String)>`, skipping `None` values. This is why list args structs derive both `Args` (for clap) and `Serialize` (for query params).
The non-`_at` helpers are one-line wrappers that pass `R::API_PATH`. The `_at` variants exist so one `Resource` struct (e.g. `Post`) can be used against any post type's collection (`wp/v2/blog`, `wp/v2/case-studies`), which is how `--type` / `--rest-base` work on `post` commands.

The `to_query_params()` helper serializes any `Serialize` struct to `Vec<(String, String)>`, skipping `None` values. This is why list args structs derive both `Args` (for clap) and `Serialize` (for query params). Routing-only args (`PostTypeArgs`) are `#[serde(skip)]` so they never leak into the query string.

### Passthrough Params

`PostCreateParams` / `PageCreateParams` carry `template`, `featured_media`, `meta`, `acf` and a `#[serde(flatten)] extra: Map<String, Value>`. A `--json` stdin payload therefore reaches WordPress verbatim (custom taxonomies like `blog_category`, plugin fields, ...) instead of being trimmed to known keys. The `Post` / `Page` response structs flatten unknown keys the same way, so `--fields` masks can select any key WordPress returns.

### Raw Routes (`wpx api`)

`commands/api.rs` wraps `WpClient::request_raw(method, path, params, body)`: any verb, any route under `/wp-json/`, JSON body from `--data` or stdin (`--json`), query params via repeatable `--query k=v`. Non-GET requests honour `--dry-run`. The same `execute()` backs the `["api"]` dispatch route (`{"method","path","query","body"}`) for fleet use.

### Media Upload

`WpClient::upload_file(path, file_name, bytes, mime, fields)` builds the multipart form (file part + text fields) on top of `post_multipart`; `mime_from_extension()` guesses the MIME type without a dependency. `wpx media upload <file> [--title --alt-text --caption --description --post --mime-type]`.

### API Response Format

Expand All @@ -242,7 +267,9 @@ pub struct ApiResponse<T> {

Signature: `dispatch(command_path: &[&str], args: &Value, client: &WpClient, dry_run: bool) -> Result<RenderPayload, WpxError>`

Command paths are string slices like `["post", "list"]`, `["plugin", "activate"]`, `["search"]`.
Command paths are string slices like `["post", "list"]`, `["plugin", "activate"]`, `["search"]`, `["api"]`, `["media", "upload"]`.

`["post", *]` routes read `type` / `rest_base` from args (resolved via `resolve_post_type_path`) and strip `type`, `rest_base` and `id` from the body before sending (`type` is read-only in the REST schema; `id` on create triggers `rest_post_exists`).

## Configuration

Expand Down Expand Up @@ -296,6 +323,8 @@ token_url = "https://staging.example.com/oauth/token"
|----------|-------------|---------|
| `WPX_SITE` | Target site profile name | `default` |
| `WPX_URL` | Direct URL override (skips profile lookup) | — |
| `WPX_USERNAME` | Username for application-password auth; overrides `credentials.toml` for every site | — |
| `WPX_PASSWORD` | Application password (alias `WPX_APP_PASSWORD`); both must be set to take effect. With `WPX_URL`, no profile or credentials file is needed (CI) | — |
| `WPX_OUTPUT` | Output format: json, table, csv, yaml, ndjson, auto | `auto` |
| `WPX_TIMEOUT` | Request timeout in seconds | `30` |
| `WPX_RETRIES` | Retry count for failed requests | `3` |
Expand All @@ -306,7 +335,7 @@ token_url = "https://staging.example.com/oauth/token"

All flags are available on every command via `--flag`:

`--site`, `--url`, `--output`, `--fields` (comma-separated field mask), `--no-color`, `--no-prompt`, `--quiet`, `--verbose`, `--timeout`, `--retries`, `--dry-run`, `--confirm`, `--all-pages`
`--site`, `--url`, `--output`, `--fields` (comma-separated field mask; dotted paths such as `content.raw` select nested keys), `--no-color`, `--no-prompt`, `--quiet`, `--verbose`, `--timeout`, `--retries`, `--dry-run`, `--confirm`, `--all-pages`

## Error Handling

Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

42 changes: 39 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,15 +75,40 @@ wpx post create --site production --title "Hello from wpx" --status draft
wpx search "migration guide" --site production
```

### Custom Post Types, Content Files, Media and Raw Routes

```bash
# Any post type exposed in REST: --type resolves wp/v2/types/<slug> to its rest_base
wpx post list --type blog --per-page 5
wpx post get 6166 --type blog --context edit --fields id,slug,content.raw,acf,meta

# Push block markup from a file (or "-" for stdin); unknown JSON keys pass through verbatim
wpx post create --type blog --title "Hello" --status draft --content-file ./post.html
echo '{"title":"Hi","status":"draft","blog_category":[9],"acf":{"hero":{"title":"x"}}}' \
| wpx post create --type blog --json

# Pages: template + featured image + ACF/meta via --json
wpx page update 4338 --template template-kafka-services.php --featured-media 6168 --content-file page.html

# Upload media
wpx media upload ./hero.png --title "Hero" --alt-text "Kafka cluster diagram"

# Anything else: raw REST route (relative to /wp-json/)
wpx api GET wp/v2/types/blog --fields rest_base
wpx api POST rankmath/v1/updateMeta --data '{"objectType":"post","objectID":42,"meta":{"rank_math_title":"x"}}'
```

Every write honours `--dry-run`.

## Command Reference

### Content

| Command | Description | Subcommands |
|---------|-------------|-------------|
| `post` | Manage posts | `list`, `get`, `create`, `update`, `delete`, `search` |
| `post` | Manage posts of any type (`--type blog`, `--type page`, ...) | `list`, `get`, `create`, `update`, `delete`, `search` |
| `page` | Manage pages | `list`, `get`, `create`, `update`, `delete` |
| `media` | Manage media attachments | `list`, `get`, `update`, `delete` |
| `media` | Manage media attachments | `list`, `get`, `upload`, `update`, `delete` |
| `comment` | Manage comments | `list`, `get`, `create`, `update`, `delete` |
| `block` | Manage reusable blocks | `list`, `get`, `create`, `update`, `delete`, `search`, `render` |
| `search` | Global search across content | *(direct command -- takes a query argument)* |
Expand Down Expand Up @@ -138,6 +163,7 @@ wpx search "migration guide" --site production
| `post-status` | List and inspect post statuses | `list`, `get` |
| `discover` | Probe a site's REST API capabilities | *(direct command -- takes a URL argument)* |
| `schema` | Show JSON Schema for a command | *(direct command -- takes a command path)* |
| `api` | Call any REST route under `/wp-json/` | *(direct command -- `api <METHOD> <path>`)* |

### Utilities

Expand All @@ -156,7 +182,7 @@ wpx search "migration guide" --site production
| `--site <name>` | `WPX_SITE` | `default` | Target site profile name |
| `--url <url>` | `WPX_URL` | -- | Direct URL override (skips profile lookup) |
| `--output <fmt>` | `WPX_OUTPUT` | `auto` | Output format: `json`, `table`, `csv`, `yaml`, `ndjson`, `auto` |
| `--fields <f1,f2>` | -- | -- | Comma-separated field mask to reduce output |
| `--fields <f1,f2>` | -- | -- | Comma-separated field mask to reduce output; dotted paths select nested keys (`content.raw`, `acf.hero.title`) |
| `--no-color` | `NO_COLOR` | -- | Disable colored output |
| `--no-prompt` | `WPX_NO_PROMPT` | -- | Disable all interactive prompts |
| `--quiet` | -- | -- | Suppress non-essential output |
Expand Down Expand Up @@ -200,6 +226,8 @@ username = "editor"
|----------|-------------|
| `WPX_SITE` | Default site profile name |
| `WPX_URL` | Direct WordPress URL (bypasses profile lookup) |
| `WPX_USERNAME` | Username for application-password auth (overrides `credentials.toml`) |
| `WPX_PASSWORD` | Application password (alias: `WPX_APP_PASSWORD`); with `WPX_URL` no site profile is needed -- ideal for CI |
| `WPX_OUTPUT` | Default output format |
| `WPX_TIMEOUT` | Request timeout in seconds |
| `WPX_RETRIES` | Retry count for failed requests |
Expand Down Expand Up @@ -228,6 +256,14 @@ WordPress 5.6+ supports Application Passwords natively. No plugins required.
wpx auth set --site production --username admin --password "XXXX XXXX XXXX XXXX"
```

`auth set` stores the password; the site URL comes from a `[sites.production]` profile in
`~/.config/wpx/config.toml` or a project `.wpx.toml`. For CI, skip the files entirely:

```bash
export WPX_URL=https://example.com WPX_USERNAME=ci-bot WPX_PASSWORD="xxxx xxxx xxxx xxxx"
wpx auth test
```

### OAuth 2.1

For environments that require OAuth (headless WordPress, enterprise SSO):
Expand Down
Loading
Loading