wpklx is a WordPress CLI built on Bun that hits /wp-json on first run, parses the full REST API schema, and generates every command from that — no hardcoded endpoints. This is one of ours from Klixpert.
Why I starred it
The classic WordPress CLI (wp-cli) requires SSH. If you're managing a headless WordPress site, a client's server you can't access, or an agency account running twenty sites, wp-cli is out of reach. wpklx goes through the REST API with application passwords — no server access needed.
The interesting engineering choice isn't the REST API wrapper itself, it's the self-building command structure. Most CLIs are static: you hardcode every subcommand. wpklx treats the API schema as the source of truth for what commands exist. If a WooCommerce plugin registers /wp-json/woocommerce/v1/products, you immediately get wpklx woocommerce:product ls without touching the CLI code.
How it works
The entry point in src/index.ts bootstraps cleanly: parse args, extract @profile, load env config, then route to the appropriate handler. Built-in commands (login, discover, config) are handled first. Anything else falls through to executeCommand(), which needs a full schema fetch.
The schema pipeline is three files deep. src/api/discovery.ts calls /wp-json with no path suffix, which returns the full WordPress REST API index — all namespaces and all routes with their accepted parameters. It normalizes this into a flat DiscoveredSchema type, merging params across all endpoints on the same route.
Then src/api/schema.ts runs mapRoutesToCommands() — the interesting bit:
// Sort routes so wp/v2 (core) routes are processed first.
// Combined with first-match-wins below, this ensures core routes
// take priority over plugin routes sharing the same resource name.
const sortedRoutes = [...schema.routes].sort((a, b) => {
const aIsCore = a.namespace === "wp/v2" ? 0 : 1;
const bIsCore = b.namespace === "wp/v2" ? 0 : 1;
return aIsCore - bIsCore;
});
This solves a real ambiguity: WooCommerce and WPML both register routes that collide with core names. By processing wp/v2 first and applying first-match-wins, wpklx post ls always hits the core endpoint, while wpklx wpml:post ls targets the plugin namespace specifically.
The HTTP method-to-action mapping in mapMethodToAction() is minimal but deliberate — it uses hasIdParam (checking for (?P<id> in the route path) to distinguish between GET /posts (list) and GET /posts/42 (get). POST without an ID is create; DELETE without an ID maps to nothing. That single boolean does most of the routing work.
The cache layer in src/api/cache.ts is two-tiered: in-memory for the current session, disk at ~/.config/wpklx/cache/<sha256-of-host>.json with a 1-hour TTL. The host hash prevents collisions when managing multiple sites. Schema re-discovery only triggers when the cache is cold or expired, so typical command invocations after first run are fast.
The stdin handler in src/helpers/stdin.ts handles an ergonomic detail most CLIs skip. It supports both explicit --content - (pipe into a named flag) and bare pipe (auto-maps to the resource's sensible default — content for posts and pages, description for categories, file for media). The auto-map is a simple lookup table that defaults to content for unknown resources.
The retry logic in src/helpers/retry.ts is textbook exponential backoff with a retryable set of status codes: 429, 500, 502, 503, 504. Network errors (TypeError from fetch) also trigger retries. Nothing novel, but it's there — a lot of CLI wrappers just let transient failures surface as ugly stack traces.
Using it
# First run — interactive setup
wpklx login
# Discovery is cached; subsequent commands are fast
wpklx post ls --status draft
# Pipe markdown content directly into a new post
cat article.md | wpklx post create --title "Weekly Update" --status draft
# Multi-site: switch profiles with @ prefix
wpklx @staging post ls
wpklx @client-site woocommerce:product ls --format json
# Check what routes were discovered
wpklx routes
The @profile syntax is extracted before argument parsing in src/cli/parser.ts, so it can appear anywhere in the command. wpklx post ls @staging works the same as wpklx @staging post ls.
Rough edges
No tests. The src/ tree is clean TypeScript but there's no __tests__ directory or test runner config. Given the surface area — schema parsing, namespace resolution, stdin handling, profile merging — that's a meaningful gap if you plan to contribute or fork.
The singularization in schema.ts is hand-rolled English rules (ies → y, ses → se, strip trailing s). It handles most WordPress resource names fine, but edge cases with custom post types or plugins that use unusual naming will silently collide or fall through.
The --fields flag filters table output but doesn't propagate to the API request. If you're listing 100 posts with --fields=id,title, you still fetch the full post objects; the field filtering happens client-side. Not a correctness issue, but worth knowing if you're scripting against a slow remote site.
Docs are the README only. There's no changelog beyond git commit messages, and the only version right now is 0.1.x.
Bottom line
Reach for this if you're managing WordPress over HTTP — headless setups, client sites without SSH, or agency workflows across multiple installations. The schema-driven command generation means it works with any plugin out of the box, which is the one thing static wrappers can't match.
