Expose your Rails app's existing controllers as a manifest-driven CLI surface — no new API layer, no duplicated business logic: the route is the contract, and the gem only derives a manifest from what already exists.
CliExposable does not add a /api/v1 layer, a GraphQL schema, or any new endpoint. It exposes
the format.json your controllers can already render, and turns a handful of annotations plus
your real routes into a cli-manifest.json that a separate CLI/agent consumer can read.
A few rules follow from that:
- The route is the contract. Verb, path and path params are read straight from
Rails.application.routes.routes— the gem never invents a URL. - Strong params stay authoritative. The manifest documents params for the humans/agents reading it, but the controller action's own params validation is what actually enforces them at request time.
- Auth and authorization stay the app's job. CliExposable never decides what counts as a trusted request; see Security boundary below.
- Incremental and reversible. Exposing (or un-exposing) a command is a small, local diff:
one
cli_exposemapping, one YAML doc, one route that was probably already there.
Add to your Gemfile, pinned to a release tag:
gem "cli_exposable", github: "linkanalabs/cli_exposable", tag: "v0.1.0"Then run the install generator:
bundle install
bin/rails g cli_exposable:installcli_exposable:install scaffolds config/cli/ and config/initializers/cli_exposable.rb, and
checks a few requirements first (jbuilder in your Gemfile.lock, an ApplicationController,
Rails >= 8) — it fails loudly, before writing anything, if one is missing.
Include the concern in your ApplicationController:
class ApplicationController < ActionController::API
include CliExposable
endThen annotate any controller with cli_expose, mapping actions to CLI command strings:
class WidgetsController < ApplicationController
cli_expose index: "widget list", show: "widget show", update: "widget update"
endcli_exposed?(action_name) and cli_exposure_for(action_name) are private instance predicates
your own code (typically a request-authorization concern) can query — see
Security boundary.
Each exposed command needs a matching entry in a config/cli/<controller_file_name>.yml doc
file — the YAML's basename is the controller's file_name (e.g. WidgetsController →
config/cli/widgets.yml; a namespaced Billing::ChargesController →
config/cli/charges.yml, no namespace in the path). A read command needs no params; a write
command must declare body_root + params:
# config/cli/widgets.yml
"widget show":
summary: Show a widget
description: "Shows one widget by id."
response: "200 with {id, name}. 404 Rails default {status, error} if the widget is unknown."
"widget update":
summary: Update a widget
description: Updates a widget's name.
body_root: widget
params:
- name: name
type: string
required: true
desc: New widget name
in: body
response: "200 with {id, name}. 422 {errors} on validation (e.g. name blank)."Generate the manifest:
bin/rails cli:manifest # writes cli-manifest.json
bin/rails cli:manifest:check # CI: fails if the committed file is stalecli-manifest.json looks like:
{
"manifest_version": 1,
"generated_at": "2026-07-23T12:00:00Z",
"source": "myapp@abc1234",
"endpoints": [
{
"command": ["widget", "show"],
"summary": "Show a widget",
"description": "Shows one widget by id.",
"method": "GET",
"path": "/widgets/:id",
"path_params": ["id"],
"params": [],
"response": "200 with {id, name}. 404 Rails default {status, error} if the widget is unknown.",
"auth": "pat",
"controller": "widgets#show"
}
]
}All five points are set in an initializer (config/initializers/cli_exposable.rb, scaffolded by
cli_exposable:install), and every default is resolved lazily — at use time, not at gem load,
since Rails.root isn't available while the gem loads:
CliExposable.configure do |config|
config.config_dir = Rails.root.join("config/cli") # default
config.output_path = Rails.root.join("cli-manifest.json") # default
config.source_label = ->(root) { "#{File.basename(root.to_s)}@#{CliExposable.git_sha(root)}" } # default
config.app_load_path = Rails.root.join("app") # default
config.command_prefixes = {} # default
end| Key | Purpose | Default |
|---|---|---|
config_dir |
Where per-command YAML docs live | Rails.root.join("config/cli") |
output_path |
Where cli:manifest writes the manifest |
Rails.root.join("cli-manifest.json") |
source_label |
Callable producing the manifest's "source" field |
app dirname + short git SHA |
app_load_path |
Filters controller discovery to files under this path | Rails.root.join("app") |
command_prefixes |
Namespace → command-prefix words, used only by expose |
{} |
command_prefixes only affects the expose generator's derived command name, not the
manifest itself:
config.command_prefixes = {
"Billing" => %w[billing],
"Admin" => %w[admin]
}With that, rails g cli_exposable:expose billing/charges index derives "billing charge list"
instead of "charge list".
CliExposable does not grant CSRF exemptions and does not implement authentication or
authorization. It only records which actions are exposed and answers whether a given action is
one of them. Deciding what counts as a trusted request — verified_request? overrides, token
schemes, PAT bearer strategies, whatever your app uses — is entirely your app's call.
A minimal request-authorization concern in your app might look like:
module CliRequestAuthorization
extend ActiveSupport::Concern
private
def verified_request?
return super unless cli_exposed?
# your app's own trust decision for this request, e.g. a bearer token check
valid_cli_token?
end
endExposing a new CLI command is a four-step loop:
- Scaffold it:
This matches each action to its real route, inserts (or merges into)
bin/rails g cli_exposable:expose <controller> <action> [<action> ...]
cli_exposeon the controller, scaffoldsconfig/cli/<file_name>.yml(inferring write params from the action's real strong params on a best-effort basis — see Generators) and a jbuilder stub per action. - Finish the scaffold by hand: fill in the YAML's
TODOs (summary/description/response, and params for writes if inference couldn't fill them in); add arespond_tohtml/json branch (or equivalent format negotiation) and the real jbuilder body; double-check the controller's actual strong params against what got inferred. Then:and write tests (authenticated request → 200 + 401/403/404/422 as applicable; HTML behavior untouched).bin/rails cli:manifest && bin/rails cli:manifest:check - Update your manifest consumer — the CLI (or agent) that vendors
cli-manifest.json— so it knows about the new command. - Update the documentation or skill that teaches agents/users how to call the new command.
cli_exposable:expose prints this as a checklist when it finishes.
The generator validates six things before a command can end up in the manifest; any failure
raises CliExposable::Error instead of producing a broken cli-manifest.json:
| Check | What it catches |
|---|---|
| Schema | Malformed config/cli/*.yml entries (missing/unknown keys, bad param types, in: body without body_root or vice versa, duplicate param names) |
| Route | An exposed action with no matching route, or with more than one ambiguous route |
| Registry ↔ exposure match | A config/cli entry with no matching cli_expose, or a cli_exposed action with no doc entry |
| Uniqueness | Two exposed actions resolving to the same CLI command string |
| Write-param coherence | A write endpoint (non-GET) that doesn't declare params, or a GET endpoint that declares body content |
| Freshness | cli:manifest:check diffs the committed manifest's manifest_version/endpoints against a freshly generated one — CI fails on drift |
rails g cli_exposable:install— scaffoldsconfig/cli/and the initializer; checks jbuilder,ApplicationControllerand Rails version up front; idempotent (safe to re-run).rails g cli_exposable:expose <controller> <action> [<action> ...]— see Exposure flow. Param inference for write actions uses Prism (bundled with Ruby ≥ 3.3) to recognize two common Rails idioms,params.expect(root: [...])andparams.require(:root).permit(...), on a best-effort basis — anything else (no params call, a shape it doesn't model) is left as an explicitTODOrather than a guess. It prints a checklist when done:
Command 'widget update' scaffolded. Pending before merging:
[ ] Fill in the TODOs in config/cli/widgets.yml (summary/description/response; params for writes)
[ ] On exposed actions: add a respond_to html/json branch and finish the jbuilder stubs
[ ] Check the real strong params in the controller — the inference above is best-effort
[ ] Run: bin/rails cli:manifest && bin/rails cli:manifest:check
[ ] Tests: authenticated request (200 + 401/403/404/422), HTML untouched
--- cross-repo (see README, "Exposure flow" section) ---
[ ] Update the manifest consumer (CLI) and the agent's docs/skill
After checking out the repo, run bin/setup to install dependencies. rake test runs the
suite, rake standard lints, and the default rake task (used by CI) runs both. bin/console
gives you an interactive prompt.
Bug reports and pull requests are welcome on GitHub at https://github.com/linkanalabs/cli_exposable.