Skip to content

Latest commit

 

History

History
461 lines (376 loc) · 23.8 KB

CLAUDE.md

File metadata and controls

461 lines (376 loc) · 23.8 KB

CLAUDE.md — Agent & LLM Context for wpx

Project Overview

wpx is a Rust-native WordPress CLI designed for both AI agents and humans. Inspired by WP-CLI, but built from the ground up because AI agents shouldn't need PHP to manage WordPress -- wpx communicates purely via the WordPress REST API as a single static binary with zero runtime dependencies. It produces structured machine-readable output (JSON) when piped and human-readable tables when run interactively. The tool is organized as a Cargo workspace of six crates, supports multi-site fleet management, and authentication via WordPress application passwords and OAuth 2.1 with PKCE.

Build & Test

# Build the entire workspace
cargo build

# Build release binary
cargo build --release

# Run all tests across the workspace
cargo test

# Run tests for a specific crate
cargo test -p wpx-core
cargo test -p wpx-cli

# Run the CLI
cargo run -- post list --site default
cargo run -- --help

# Run with verbose logging (debug to stderr)
cargo run -- --verbose post list

# Generate shell completions
cargo run -- completions bash > wpx.bash

Architecture & Code Organization

wordpress-cli/
├── Cargo.toml                          # Workspace root: 6 member crates, shared deps
├── CLAUDE.md                           # This file — agent/LLM context
│
├── crates/
│   ├── wpx-core/                       # Domain types, Resource trait, error types
│   │   └── src/
│   │       ├── lib.rs                  # Re-exports: Resource, WpxError, ExitCode
│   │       ├── error.rs               # WpxError enum, ExitCode enum (0-10), JSON error output
│   │       ├── resource.rs            # Resource trait definition (the core abstraction)
│   │       └── resources/
│   │           ├── mod.rs             # Module declarations for all resource types
│   │           ├── post.rs            # Post struct, PostCreateParams, impl Resource
│   │           ├── page.rs            # Page struct, impl Resource
│   │           ├── media.rs           # Media struct, impl Resource
│   │           ├── user.rs            # User struct, impl Resource
│   │           ├── comment.rs         # Comment struct, impl Resource
│   │           ├── category.rs        # Category struct, impl Resource
│   │           ├── tag.rs             # Tag struct, impl Resource
│   │           ├── taxonomy.rs        # Taxonomy struct, impl Resource
│   │           ├── plugin.rs          # Plugin struct, impl Resource
│   │           ├── theme.rs           # Theme struct, impl Resource
│   │           ├── post_type.rs       # PostType struct, impl Resource
│   │           ├── post_status.rs     # PostStatus struct, impl Resource
│   │           ├── block.rs           # Block (reusable block) struct, impl Resource
│   │           ├── block_type.rs      # BlockType struct, impl Resource
│   │           ├── block_pattern.rs   # BlockPattern struct, impl Resource
│   │           ├── block_pattern_category.rs  # BlockPatternCategory struct, impl Resource
│   │           ├── widget.rs          # Widget struct, impl Resource
│   │           ├── widget_type.rs     # WidgetType struct, impl Resource
│   │           ├── sidebar.rs         # Sidebar struct, impl Resource
│   │           ├── menu.rs            # Menu struct, impl Resource
│   │           ├── menu_item.rs       # MenuItem struct, impl Resource
│   │           ├── menu_location.rs   # MenuLocation struct, impl Resource
│   │           └── search_result.rs   # SearchResult struct, impl Resource
│   │
│   ├── wpx-api/                        # HTTP client layer wrapping reqwest
│   │   └── src/
│   │       ├── lib.rs                 # Re-exports: WpClient, ApiResponse
│   │       ├── client.rs             # WpClient: GET/POST/PUT/DELETE, retry logic, backoff,
│   │       │                          #   multipart upload, discover()
│   │       ├── response.rs           # ApiResponse<T> with total/total_pages from WP headers
│   │       └── error.rs              # Maps reqwest/HTTP errors to WpxError variants
│   │
│   ├── wpx-auth/                       # Authentication providers
│   │   └── src/
│   │       ├── lib.rs                 # Re-exports: AuthProvider, NoAuth, ApplicationPasswordAuth, OAuthAuth
│   │       ├── provider.rs           # AuthProvider trait, NoAuth impl
│   │       ├── basic.rs              # ApplicationPasswordAuth (HTTP Basic with app passwords)
│   │       └── oauth.rs              # OAuthAuth (Bearer token), PkceChallenge, run_oauth_flow(),
│   │                                  #   refresh_token(), local callback server
│   │
│   ├── wpx-config/                     # Configuration and credential management
│   │   └── src/
│   │       ├── lib.rs                 # Re-exports: WpxConfig, CredentialStore, SiteProfile
│   │       ├── config.rs             # WpxConfig: TOML loading, site profiles, precedence:
│   │       │                          #   project (.wpx.toml) > user (~/.config/wpx/config.toml) > defaults
│   │       ├── profile.rs            # SiteProfile struct: url, auth method, username
│   │       └── credentials.rs        # CredentialStore: ~/.config/wpx/credentials.toml,
│   │                                  #   SiteCredentials with app-password and OAuth fields
│   │
│   ├── wpx-output/                     # Output formatting and rendering
│   │   └── src/
│   │       ├── lib.rs                 # Re-exports: OutputFormat, RenderPayload, render, render_with_config
│   │       ├── format.rs             # OutputFormat enum: Auto, Json, Table, Csv, Yaml, Ndjson
│   │       │                          #   Auto resolves to Table (TTY) or Json (piped)
│   │       ├── render.rs             # RenderPayload struct, render functions for each format,
│   │       │                          #   OutputConfig with field mask support
│   │       └── fields.rs             # apply_field_mask(): filters JSON output to requested fields
│   │
│   └── wpx-cli/                        # CLI binary, command routing, CRUD helpers
│       └── src/
│           ├── main.rs               # Entry point: parse CLI, run command, render output, handle errors
│           ├── cli.rs                # Clap derive structs: Cli, GlobalFlags, Commands enum,
│           │                          #   AuthCommands
│           ├── context.rs            # build_client(): resolves site profile + credentials -> WpClient
│           ├── crud.rs               # Generic CRUD helpers: list, get, create, update, delete,
│           │                          #   *_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, update, delete
│               ├── user.rs           # UserCommands: list, get, me
│               ├── comment.rs        # CommentCommands: list, get, create, update, delete
│               ├── category.rs       # CategoryCommands: list, get, create, update, delete
│               ├── tag.rs            # TagCommands: list, get, create, update, delete
│               ├── taxonomy.rs       # TaxonomyCommands: list, get
│               ├── plugin.rs         # PluginCommands: list, install, activate, deactivate, delete
│               ├── theme.rs          # ThemeCommands: list, activate
│               ├── post_type.rs      # PostTypeCommands: list, get
│               ├── post_status.rs    # PostStatusCommands: list, get
│               ├── block.rs          # BlockCommands: list, get, create, update, delete
│               ├── block_type.rs     # BlockTypeCommands: list, get
│               ├── block_pattern.rs  # BlockPatternCommands: list
│               ├── block_pattern_category.rs  # BlockPatternCategoryCommands: list
│               ├── widget.rs         # WidgetCommands: list, get, create, update, delete
│               ├── widget_type.rs    # WidgetTypeCommands: list, get
│               ├── sidebar.rs        # SidebarCommands: list, get
│               ├── menu.rs           # MenuCommands: list, get, create, update, delete
│               ├── menu_item.rs      # MenuItemCommands: list, get, create, update, delete
│               ├── menu_location.rs  # MenuLocationCommands: list, get
│               ├── search.rs         # Global search: SearchArgs, handle()
│               ├── settings.rs       # SettingsCommands: list, get, set (aliased as "option")
│               ├── fleet.rs          # FleetCommands: exec (concurrent multi-site), status
│               ├── discover.rs       # Site capability discovery (REST API, WooCommerce)
│               ├── schema.rs         # JSON Schema introspection for command inputs/outputs
│               └── auth.rs           # AuthCommands: set, test, list, logout, oauth

Key Patterns

Resource Trait

The core abstraction. Every WordPress entity implements this trait, which unlocks generic CRUD operations:

pub trait Resource:
    serde::Serialize + serde::de::DeserializeOwned + Send + Sync + std::fmt::Debug + 'static
{
    /// Display name (e.g., "post", "page").
    const NAME: &'static str;
    /// Plural form (e.g., "posts", "pages").
    const NAME_PLURAL: &'static str;
    /// REST API path (e.g., "wp/v2/posts").
    const API_PATH: &'static str;
    /// Default columns for table output.
    const DEFAULT_TABLE_FIELDS: &'static [&'static str];
}

Example implementation (crates/wpx-core/src/resources/post.rs):

impl Resource for Post {
    const NAME: &'static str = "post";
    const NAME_PLURAL: &'static str = "posts";
    const API_PATH: &'static str = "wp/v2/posts";
    const DEFAULT_TABLE_FIELDS: &'static [&'static str] =
        &["id", "title", "status", "date", "author"];
}

Adding a New Resource (Step-by-Step)

  1. Define the resource struct in crates/wpx-core/src/resources/<name>.rs:

    • Add a struct with #[derive(Debug, Clone, Serialize, Deserialize)]
    • Implement the Resource trait with NAME, NAME_PLURAL, API_PATH, DEFAULT_TABLE_FIELDS
    • Add CreateParams / UpdateParams structs if the resource supports writes
    • Register the module in crates/wpx-core/src/resources/mod.rs
  2. Add CLI commands in crates/wpx-cli/src/commands/<name>.rs:

    • Define a <Name>Commands enum with subcommands (List, Get, Create, etc.)
    • Define args structs with #[derive(Debug, Args, Serialize)] (Serialize enables query param conversion)
    • Write a handle() function that delegates to the generic CRUD helpers
    • Register the module in crates/wpx-cli/src/commands/mod.rs
  3. Add the top-level command in crates/wpx-cli/src/cli.rs:

    • Add a variant to the Commands enum with #[command(subcommand)]
  4. Wire up in main.rs (crates/wpx-cli/src/main.rs):

    • Add a match arm in run() that calls commands::<name>::handle()
  5. Add dispatch entries in crates/wpx-cli/src/dispatch.rs:

    • Add match arms for each subcommand (e.g., ["<name>", "list"], ["<name>", "get"])
    • Use the generic CRUD functions: crud::list::<Name>(), crud::get::<Name>(), etc.

CRUD Helpers

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_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 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

The WordPress REST API returns pagination info in headers. ApiResponse<T> captures this:

pub struct ApiResponse<T> {
    pub data: T,
    pub total: Option<u64>,       // From X-WP-Total header
    pub total_pages: Option<u64>, // From X-WP-TotalPages header
}

Unified Dispatcher

crates/wpx-cli/src/dispatch.rs provides a single entry point used by two contexts:

  1. CLI (main.rs) -- Clap-parsed commands delegate here for some routes
  2. Fleet exec (commands/fleet.rs) -- Multi-site commands dispatch through here

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"], ["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

Config File Precedence

  1. Project-level: ./.wpx.toml (highest priority)
  2. User-level: ~/.config/wpx/config.toml
  3. Defaults: Built-in defaults (lowest priority)

Config File Format (~/.config/wpx/config.toml)

[default]
output = "json"       # auto | json | table | csv | yaml | ndjson
color = "auto"        # auto | always | never
timeout = 60          # Request timeout in seconds (default: 30)
retries = 5           # Retry count for failed requests (default: 3)

[sites.production]
url = "https://example.com"
auth = "application-password"   # Default auth method
username = "admin"

[sites.staging]
url = "https://staging.example.com"

Credentials File (~/.config/wpx/credentials.toml)

Stored with 0600 permissions on Unix. Separate from config for security.

[sites.production]
auth_type = "application-password"
username = "admin"
password = "xxxx xxxx xxxx xxxx"

[sites.staging]
auth_type = "oauth2"
access_token = "eyJ..."
refresh_token = "eyJ..."
token_expiry = "2026-04-01T00:00:00Z"
client_id = "wp-client-id"
authorize_url = "https://staging.example.com/oauth/authorize"
token_url = "https://staging.example.com/oauth/token"

Environment Variables

Variable Description Default
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
WPX_NO_PROMPT Disable interactive prompts —
NO_COLOR Disable colored output —

Global CLI Flags

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

--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

WpxError Variants

All errors in the application funnel through a single WpxError enum defined in crates/wpx-core/src/error.rs:

Variant Description
Api { code, message, status, suggestion } WordPress REST API error response
Auth { message } Authentication failure
Config { message } Configuration error
Network(String) Network/connection error
Io(std::io::Error) File system I/O error
Validation { field, message } Input validation error
NotFound { resource, id } Resource not found
PermissionDenied { message } Insufficient permissions
RateLimited { retry_after_secs } Rate limit exceeded
Server { status, message } 5xx server error
Conflict { message } 409 conflict
Other(String) Catch-all

Exit Codes

Semantic exit codes for agent-friendly error handling. Agents can programmatically decide retry/abort/fix strategies based on these codes:

Code Name Description
0 Success Success
1 GeneralError General error
2 InvalidArgs Invalid arguments
3 AuthFailure Authentication failure
4 NotFound Resource not found
5 PermissionDenied Permission denied
6 RateLimited Rate limited
7 NetworkError Network error
8 ServerError Server error (5xx)
9 Conflict Conflict (409)
10 ValidationError Validation error

Structured Error Output

Errors are written to stderr as JSON:

{
  "error": true,
  "message": "Resource not found: post 99",
  "exit_code": 4,
  "resource": "post",
  "id": "99",
  "suggestion": "Use 'wpx post list' to find valid IDs"
}

Retry Logic

The WpClient retries on transient failures (429 Too Many Requests, 5xx Server Error, timeouts, connection errors) with exponential backoff (1s, 2s, 4s, 8s...). Respects the Retry-After header when present. Default: 3 retries.

Dependencies (Key)

Crate Version Purpose
clap 4.5 CLI argument parsing (derive mode)
reqwest 0.12 HTTP client (rustls-tls, JSON, multipart)
tokio 1 Async runtime (full features)
serde / serde_json 1.0 Serialization/deserialization
serde_yaml 0.9 YAML output format
toml 0.8 Config file parsing
csv 1.3 CSV output format
tabled 0.16 Table rendering
indicatif 0.17 Progress indicators
thiserror 2 Error derive macros
anyhow 1.0 Error context
tracing 0.1 Structured logging
keyring 3 OS keyring integration (apple-native, linux-native)
url 2 URL parsing
open 5 Open browser for OAuth
sha2 / base64 / rand — PKCE challenge generation
wiremock 0.6 HTTP mocking (dev)
assert_cmd 2.0 CLI integration tests (dev)
predicates 3.0 Test assertions (dev)

Testing

Test Organization

  • Unit tests: Inline #[cfg(test)] mod tests in most source files
  • Integration tests: Via assert_cmd for CLI binary testing
  • HTTP mocking: Via wiremock for API client tests

Running Tests

cargo test                             # All workspace tests
cargo test -p wpx-core                 # Core types and resource tests
cargo test -p wpx-cli                  # CLI and CRUD helper tests
cargo test -p wpx-api                  # HTTP client tests
cargo test -p wpx-auth                 # Auth provider tests
cargo test -p wpx-config               # Config parsing tests
cargo test -p wpx-output               # Output rendering tests

Key Test Patterns

  • Resource structs have deserialization tests with real WordPress JSON payloads
  • CRUD helpers test query param generation and object-to-array conversion
  • The dispatcher tests ID/string extraction from JSON args
  • Config tests verify TOML parsing and merge precedence
  • Error tests verify exit code mapping and structured JSON output

Useful Test Commands

# Run a specific test by name
cargo test -p wpx-core deserialize_post

# Run tests with output visible
cargo test -- --nocapture

# Check compilation without running tests
cargo check --workspace