Embed a terminal-style command console directly inside your React app.
Citadel helps you turn repetitive UI workflows into fast keyboard commands for developers, support engineers, and power users, without sending them to a separate admin tool.
The key interaction model is prefix expansion: users usually do not type the
full command. They type the shortest unambiguous prefix and Citadel expands it
for them. For example, us can expand to user show.
- Move faster in existing apps: expose internal actions as commands instead of building more buttons and forms
- Debug in context: call APIs, inspect JSON, clear storage, and run app actions without leaving the page
- Keep UI clean: hidden-by-default overlay (toggle key is configurable;
default is
., and can be shown on load if desired) - Scale safely: typed command DSL with argument help, async handlers, and
structured result rendering (
text,json,image,error,bool)
- Internal Tools: replace repetitive click paths with direct commands
- Support & Operations: add safe operational commands to admin dashboards
- API Testing & Debugging: execute REST calls and inspect responses inline
- Power User Workflows: give advanced users terminal speed in web UI
npm i citadel_cliStart with the docs in docs/:
docs/README.mdfor the full guidedocs/01-installing-citadel-in-an-existing-react-app.mdfor the fastest setupdocs/02-defining-commands.mdfor the command DSL
Commands are the core concept in Citadel. Think user add 1234 or
qa deploy my_feature_branch.
Users usually enter prefixes rather than full commands. If your command is
greet, typing g is enough. If your command is user.show, typing us is
enough as long as that prefix is unambiguous.
To get running:
- Define commands with the typed DSL
- Build a
CommandRegistryfrom those definitions - Pass the registry to
Citadel
import {
Citadel,
command,
createCommandRegistry,
text,
} from "citadel_cli";
// 1. Define and register commands
const registry = createCommandRegistry([
command("greet")
.describe("Say hello to someone")
.arg("name", (arg) => arg.describe("Who are we greeting?"))
.handle(async ({ namedArgs }) => text(`Hello ${namedArgs.name} world!`)),
]);
// 2. Pass the registry to the component
function App() {
return <Citadel commandRegistry={registry} />;
}Prefix expansion is the core way users interact with Citadel.
Users usually do not type full command names. They type the shortest unambiguous prefix, and Citadel expands it in place.
For the quick start example above, typing g expands to greet
(with a trailing space), and the user can then enter the name argument.
For hierarchical commands, expansion is prefix-based:
uscan resolve touser showudcan resolve touser deactivate- If two options share a prefix (
showandsearch), continue until unique:ush=>user show,use=>user search
Think of the DSL path as the canonical command definition and the prefix as the normal way the user enters it.
Argument segment description values are shown as argument-level help text.
Example built-in help output:
user show <userId> - Show user details
<userId>: Enter user ID
Handlers must return one of the following:
TextCommandResultJsonCommandResultImageCommandResultErrorCommandResultBooleanCommandResult
For clearer command authoring, you can define commands with a DSL and compile
them into a CommandRegistry:
import {
Citadel,
command,
createCommandRegistry,
text,
} from "citadel_cli";
const registry = createCommandRegistry([
command("user.show")
.describe("Show user details")
.arg("userId", (arg) => arg.describe("Enter user ID"))
.handle(async ({ namedArgs }) => {
return text(`Showing user ${namedArgs.userId}`);
}),
]);
function App() {
return <Citadel commandRegistry={registry} />;
}DSL handlers receive:
rawArgs: positional values (string[])namedArgs: argument-name map (Record<string, string | undefined>)commandPath: dot-delimited path string
Helper constructors exported by the DSL:
text(value)json(value)image(url, altText?)error(message)bool(value, trueText?, falseText?)
import { command, createCommandRegistry, bool } from "citadel_cli";
const registry = createCommandRegistry([
command("bool.random")
.describe("Return a random boolean")
.handle(async () => bool(Math.random() >= 0.5, "π", "π")),
]);Demo registries include boolean commands:
- Basic example:
bool.true,bool.false,bool.random - DevOps example:
bool.deploy.window,bool.error.budget.healthy,bool.autoscale.recommended
CommandRegistry#addCommand still works and is fully supported. The DSL is now
the recommended authoring path for new command definitions.
- Each command can have zero or more arguments
- Argument values are passed to the handler as a
String[] - Arguments can be single- or double-quoted
Clearing localstorage:
async () => {
localStorage.clear();
return new TextCommandResult('localStorage cleared!');
}
Make an HTTP POST with a body containing a given name:
async (args: string[]) => {
const response = await fetch('https://api.example.com/endpoint', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: args[0] }),
});
return new JsonCommandResult(await response.json());
}
Certain configuration options can be passed to the Citadel component. These are given below, along with their default values.
const config = {
commandTimeoutMs: 10000,
includeHelpCommand: true,
fontFamily: '"JetBrains Mono", monospace',
fontSize: '0.875rem', // CSS font-size value (e.g. '14px', '0.875rem')
maxHeight: '80vh',
initialHeight: '50vh',
minHeight: '200',
outputFontSize: '0.75rem', // optional CSS font-size override for output text
showOutputPane: true, // set false to hide command output pane
resetStateOnHide: false,
closeOnEscape: true,
showCitadelKey: '.',
showOnLoad: false,
cursorType: 'blink', // 'blink', 'spin', 'solid', or 'bbs'
cursorSpeed: 530,
storage: {
type: 'localStorage',
maxCommands: 100
}
};
Then to make the component aware of them:
<Citadel commandRegistry={cmdRegistry} config={config} />
Citadel includes scripts to capture and compare before/after performance and size metrics.
- Build metrics:
- Bundle size (raw + gzip) for
dist/citadel.es.js,dist/citadel.umd.cjs, anddist/citadel.css - Total LOC and extension breakdown
- Dependency presence for
tailwindcss,postcss, andautoprefixer node_modulessize (du -sk)
- Bundle size (raw + gzip) for
- Runtime metrics (Chromium):
- JS heap usage before/after interaction
- Input latency (keydown to input update)
- FPS sample over a short window
- Long task count and duration
- DOM node count
All outputs are written to test-results/metrics/.
npm run metrics:build
npm run metrics:runtime
npm run metrics:compare -- --before <before.json> --after <after.json>
npm run metrics:all -- --label <label>
npm run metrics:report -- --label <label> --before-build <before-build.json> --before-runtime <before-runtime.json>- Capture a baseline snapshot:
npm run metrics:all -- --label before- After your changes, capture the new snapshot and generate comparisons:
npm run metrics:all -- --label after \
--before-build test-results/metrics/build-before-<timestamp>.json \
--before-runtime test-results/metrics/runtime-before-<timestamp>.json- Open generated reports:
test-results/metrics/run-after.mdtest-results/metrics/compare-build-after.md(if--before-buildprovided)test-results/metrics/compare-runtime-after.md(if--before-runtimeprovided)
Notes:
metrics:runtimestarts a local dev server and requires local port binding.- If you only want comparison output from existing snapshots, use
npm run metrics:report.
See CONTRIBUTING.md for guidelines on developing, testing, and releasing Citadel CLI.

