@lionrapid/cli
v0.5.1
Published
LionRapid CLI — sync translation files (iOS, Android, Flutter, JSON, YAML, PO) with the LionRapid backend
Maintainers
Readme
@lionrapid/cli
The LionRapid command-line tool. Syncs translation files in many formats
(iOS .strings/.xcstrings, Android strings.xml, Flutter .arb, generic
JSON, YAML, PO) with the LionRapid backend, and generates type-safe TypeScript
keys from your translations.
This is the non-web integration wedge: it reaches platforms the web
surfaces (WordPress, edge proxy, JS snippet, React SDK) can't — mobile apps,
backend services, static-site generators, and CI/CD — by speaking the existing
/api/integrations/* REST surface instead of the DOM.
New here? Start with the Getting Started guide (install → init → push → translate → pull), then use this README as the reference.
Status
Phase 1 (file-sync dev-loop) is implemented. Command surface:
| Command | Status | What it does |
| ----------------- | ------ | ------------------------------------------------------------------- |
| init | ✅ | Scaffold a lionrapid.yml (detects Flutter/iOS/Android/Rails/JS) |
| push | ✅ | Upload source strings to the backend (server handles MT) |
| status [job-id] | ✅ | What the backend actually did with what push queued |
| pull [--types] | ✅ | Download translations into target files; optionally regen types |
| validate [--ci] | ✅ | Check local files vs the backend for missing/orphan/empty keys |
| types generate | ✅ | Generate a typed .d.ts from your source files (offline) |
| types check | ✅ | Report ICU issues in your source translations |
| scan [--json] | ✅ | Find user-facing strings in source → inventory keyed by auto_key |
| scan --push | ✅ | Seed the safe wrap candidates to the backend under their auto_key |
| snapshot | ✅ | Write offline-resilience bundles (raw ContentUnit) per locale/ns |
| doctor | ✅ | Preflight: config, credentials, server connectivity, source files |
Formats: JSON, YAML, PO (incl. plurals + msgctxt), Flutter .arb, iOS
.strings + .xcstrings, Android strings.xml, XLIFF (1.2/2.0, CAT-tool
interchange).
Multiple namespaces: give any files entry its own namespace to map
different file trees to different backend namespaces (defaults to the top-level
namespace). Each is pushed/pulled/validated independently, so the same key name
in two namespaces never collides:
namespace: app
files:
- { format: json, source: common/en.json, target: common/{locale}.json, namespace: common }
- { format: json, source: app/en.json, target: app/{locale}.json } # → namespace: appGlob entries: source may be a glob (*/?); target and namespace
must then carry a {basename} token, replaced per matched file with its
extension-less name. One stanza covers a whole directory of per-page (or
per-feature) catalogs, each in its own namespace:
files:
- format: json
source: locales/en/*.json # locales/en/home.json, pricing.json, …
target: locales/{locale}/{basename}.json
namespace: '{basename}' # → namespaces home, pricing, …Globs expand once at config load (sorted, so behavior is deterministic) —
push/pull/validate see one concrete entry per matched file, and doctor
reports the expanded count. A glob that matches nothing prints a warning
instead of silently doing nothing; a glob entry without {basename} in both
target and namespace is rejected as ambiguous.
Flat output: JSON/YAML targets are written nested by default (a pushed
hero.title comes back as {"hero": {"title": …}}). Set flat: true on a
files entry to keep dotted keys literal on pull, so a flat-keyed file
round-trips flat for flat-keyed consumers (CMS imports, webhook payloads):
files:
- { format: json, source: locales/en.json, target: locales/{locale}.json, flat: true }Reliability & automation
Built for CI and AI agents — every networked command is resilient and scriptable.
--json(all commands): emit one result envelope{ ok, command, … }on stdout; human prose goes to stderr. On failure, a structured error envelope{ ok: false, error: { type, message, status? }, exitCode }is written to stdout. Parse stdout, read stderr.--dry-run(push,pull): report exactly what would happen — counts, keys, target files — without any network mutation (push) or file write (pull).pull --dry-runstill does the read-only fetch so the counts are real.- Exit codes (stable, gate CI on them):
0ok ·1unexpected ·2validation/config ·3auth ·4network/5xx ·5partial. - Automatic retries: transient failures (network error,
5xx,429) are retried with exponential backoff + jitter. A429honorsRetry-Afterwhen the server sends it; when it doesn't, the CLI waits a rate-limit-window 30 s instead of jittering, so a many-namespace push rides out a per-minute limit. Tune withLIONRAPID_RETRIES(default3;0disables). Writes are safe to retry — the integration endpoints upsert by composite key. - Partial progress (
push,pull): one failing namespace or file doesn't zero the run — the rest complete, failures are listed per file (and aserrorfields under--json), and the exit code is5(partial). Re-running retries just the failures; pushes upsert, so that's always safe. - Pushed ≠ stored (
push→status):sync/batchanswers202 Accepted, sopush's counts are what the CLI sent; the backend stores them in a background job that can still fail or dead-letter.pushtherefore prints the job ids it queued and saves them to~/.lionrapid/jobs.json, andlionrapid status <job-id>reports the server's verdict — stored, still queued, retrying, or dead-lettered (with the reason and every failed attempt). Barelionrapid statuslists every job this machine pushed, newest first, re-checking only the unresolved ones; with nothing recorded it falls back to the server's queue health + dead-letter queue. Offline, it still prints the local record, labelled(last known). History is capped at 500 entries / 30 days and never shares a file with your credentials. - Concurrent
pull: namespace fetches fan out with bounded concurrency (LIONRAPID_CONCURRENCY, default6) and show interactive progress on a TTY. Output stays deterministic — only the network fan-out is parallelized. doctor: one command to verify config validity, credential resolution, backend reachability/auth, and that declared source files exist — each checked independently so you get an actionable list, not a stack trace.
Offline resilience (snapshot)
lionrapid snapshot writes the raw ContentUnit bundle ({ meta, translations })
per locale/namespace, byte-shaped like the API response — bypassing the format
adapters (which flatten to display strings and drop the styles map). Bundle
these with your app and load them via the runtime so translations keep working
with the server down:
import bundle from './snapshots/es.app.json' assert { type: 'json' };
import {
LionRapidBuilder,
MemoryRepository,
StaticRepository,
} from '@lionrapid/core';
const core = await LionRapidBuilder.init({
defaultLocale: 'es',
namespace: 'app',
})
.use(new MemoryRepository({ enabled: true }))
.useStaticBundle({ es: { app: bundle.translations } }) // offline layer (memory → static → network)
.build();
core.t('welcome'); // works offline, styles intactNode apps can read a snapshot directory at runtime via
new FileRepository('./snapshots') from @lionrapid/storage/node.
No-library localization (scan)
For a codebase with no i18n library (hardcoded strings, no t()), scan
finds user-facing strings via the TypeScript AST over a finite allowlist of UI
sinks (JSX text + placeholder/alt/aria-label/…), classifies them, and keys
each by content-hash auto_key — the same key the web edge proxy / DOM
auto-detect produces, so a string already translated on your website is reused
in your app.
action: wrap— static text/attributes (deterministic, safe).action: review— interpolation / inline markup / dynamic values (left for a human or AI agent to author correctly).
scan --json emits a machine-readable inventory on stdout (human prose on
stderr) so an AI coding agent can drive the judgment-heavy wrapping while the CLI
owns the correctness-critical parts (the auto_key hash, file writes, sync).
scan --push seeds the safe candidates into the translation memory.
Wrapping (done by you / your agent)
The CLI intentionally does not rewrite your source. It hands you the
inventory — each candidate carries its autoKey, and the result carries the
namespace — and you (or your AI coding agent, e.g. Claude Code / Cursor) wrap
using the SDK, which also lets the agent handle the review cases (interpolation,
plurals) a deterministic codemod can't:
// inventory: { namespace: "app", autoKey: "auto_2d0f6b8300be", text: "Add to cart", sink: "jsx-text" }
<Text>Add to cart</Text>
// →
<Text><Trans namespace="app" i18nKey="auto_2d0f6b8300be">Add to cart</Trans></Text>
// inventory: { autoKey: "auto_13348442cc6a", text: "Search", sink: "jsx-attr:placeholder" }
placeholder="Search" → placeholder={t('app:auto_13348442cc6a', 'Search')}Because the key is the same content-hash the web produces, any of these strings already translated on your website are translated in the app for free.
Usage
lionrapid init --project <id> --targets es,fr
lionrapid push # prints the job ids it queued
lionrapid status # …and what the backend did with them
lionrapid status <job-id> # the full story on one job
lionrapid pull --types
lionrapid validate --ci
# no-library localization
lionrapid scan --json # inventory of discovered strings (auto_key-keyed)
lionrapid scan --push # seed the safe candidates into the TMThe API key is read from LIONRAPID_API_KEY (or ~/.lionrapid/credentials),
never from the committed lionrapid.yml.
Development
npm run build -w packages/cli
npm test -w packages/cli