CLI
Sync translation files from the command line
@locamorph/cli moves translation files between your repo and locamorph with
two commands: download brings finished translations in,
upload sends new source strings out. Most translation CLIs give you the
two verbs and tell you what happened after it happened. This one lets you
ask first: a download parses the files it is about to replace and reports
what would move, and upload --dryrun brings the change set back per key, in a
shape jq can read, without letting the run happen.
It is the same binary the GitHub Action runs, so what you debug in your terminal is what CI executes. API keys work on the free plan, and installing a CLI doesn’t use up an integration slot.
$ locamorph download
✓ Downloaded translations for "Acme Web"
en/*.json (3 files): no update
de/*.json (3 files): 12 added, 4 updated
tr/*.json (3 files): 12 added
Saved to /repo/locales Fetches every language, parses what is already on disk, and reports the counts per language before it writes anything.
$ locamorph upload
✓ Uploaded translations for "Acme Web"
┌───────┬───────┬─────────┬─────────┐
│ │ Added │ Updated │ Removed │
├───────┼───────┼─────────┼─────────┤
│ en │ 3 │ 1 │ 0 │
│ de │ 3 │ 0 │ 0 │
│ tr │ 3 │ 0 │ 0 │
├───────┼───────┼─────────┼─────────┤
│ Total │ 9 │ 1 │ 0 │
└───────┴───────┴─────────┴─────────┘
Run with --verbose to see details. One request per file, sent base language first. New keys enter a project through its base language, which is why that ordering matters.
$ locamorph upload --dryrun --json
{
"project": "Acme Web",
"dryrun": true,
"total": { "added": 9, "updated": 1, "removed": 0, "skipped": 24 },
"languages": ["en", "de", "tr"],
"errors": [],
"en": {
"code": "en",
"name": "English",
"added": 3,
"updated": 1,
"changes": [
{ "key": "checkout.summary.title", "action": "added" },
{ "key": "common.save", "action": "updated" }
]
}
} The same payload, computed by the server and thrown away. With --json the change set comes back as data, per key.
project_id: "your-project-uuid"
translations_dir: ./locales
format: json
file_structure: "{LANG_ISO}/{FILE}" # en/common.json, de/checkout.json
languages: [en, de, tr] # this list filters every run Committed at the repo root. The CLI finds it by walking up from wherever you run, so one file serves a whole monorepo.
Complete commands, not fragments. Paste one in and it runs.
How it works
Three places hold configuration
For anything that varies per run, a flag beats locamorph.yaml, and
locamorph.yaml beats the default. Formatting sits outside that chain
entirely: indentation, key order and nesting come from the project and are fetched on
every download, so two people running the same command end up with byte-identical files.
This run only
Which languages, which directory, dry run or not. Nothing carries over to the next run, and nothing is written down.
your shell, or a CI step
This repo, every run
Project id, translations directory, format, file structure, the language filter,
include and exclude rules, and per-direction defaults under upload and
download.
repo root
Every repo, every developer
Indentation, nested or flat keys, key sort order, plural format, line-break handling, trailing newline. Fetched on every download rather than read locally.
the locamorph app
What each command touches
Two of these columns you can’t get out of --help, and they are the two
that start mattering the moment a command goes into a script: how many API requests it
spends, and what it takes to undo. The rest of the flag surface is in
the README on npm.
| Command | What it does | Requests | Undo |
|---|---|---|---|
locamorph init | Lists your projects, asks where files live and how they are named, writes locamorph.yaml. Interactive every time. | 1–2 | delete the file |
locamorph download | Pulls each language, parses the files already on disk, reports what changed, then writes. | 4 + 1 per language | git checkout |
locamorph download --dryrun | The same run, stopping before the write. | same as a real download | nothing to undo |
locamorph upload | Sends local files up as source strings. Add-only: existing translations are left alone and come back counted as skipped. | 2 + 1 per file | delete the new keys in the app |
locamorph upload --update | Same, except existing values are overwritten — including ones a translator edited in the app. | 2 + 1 per file | none from the CLI |
locamorph upload --dryrun | Posts the payload, takes the change set back, discards the write. Still needs translations:write. | 2 + 1 per file | nothing to undo |
locamorph status | Per-language progress and an overall percentage, without opening the app. | 1–2 | nothing written |
locamorph keys / languages / projects | Inspect and manage keys, languages and projects from the terminal. Two subcommands are destructive — keys delete and languages remove, which takes that language’s translations with it — and every flag is in the README. | 1 per command | the destructive two have none |
--delete-removed prunes every language, not just the file you
sent. A key missing from your base-language file is removed from the project, and
the server drops its translations in every other language sharing that file scope. Pair it
with --dryrun first, every time.
New keys enter a project through its base language. Upload a key that exists only in a
non-base file and the server skips it rather than failing — it lands in the
skipped count and nowhere else. Uploading the base language in the same run
is what fixes it, which is why the CLI sorts base-language files first without being asked.
Built to fit the pipeline you already have
It is a binary on your PATH, so the integration surface is whatever you already use to run
binaries. An npm script. A Makefile target. A pre-commit hook. A stage in a Docker build.
One package of a monorepo, with the config at the root and the script pinning the
directory. A postCreate step in a devcontainer, so a new hire’s first
clone already has translations in it. None of that needs anything from us.
The part that holds up over time is where formatting lives. Indentation, key sort order, nesting and plural style all come from the project rather than from each engineer’s machine, so a pull produces the same bytes for everyone. Some translation platforms treat export formatting as a per-user download preference, which is why the same locale file comes back looking slightly different depending on who fetched it, and why the resulting pull request is a wall of reordered keys with three real changes hidden in it. Those get approved without being read, and reviewable diffs are most of what makes a translation management system worth having in the loop at all.
What a repo has to carry is one committed locamorph.yaml and one environment
variable. Config discovery walks up the tree, so a new engineer clones and runs, and CI
supplies LOCAMORPH_API_KEY and nothing else. Nothing is written to
anyone’s home directory, so there is no per-machine state to drift.
Setup
An API key and one config file
About five minutes, and the last two steps exist so you find out whether the config is right before it touches your working tree.
-
Install it
Node 18 or newer. On a CI runner,
npx @locamorph/cli downloaddoes the same job without installing anything globally.npm install -g @locamorph/cli -
Create an API key and export it
Generate a key with the scopes for the commands you plan to run and put it in
LOCAMORPH_API_KEY— your shell profile locally, your secret store in CI. There is no login command and nothing is written to your home directory, which is why the same command line works in both places: the only difference is where the variable comes from.export LOCAMORPH_API_KEY=lm_... -
Run locamorph init
It lists the projects your key can reach, asks where your files live and how they are named, and writes
locamorph.yaml. Read thelanguages:list it wrote before committing: that is a snapshot of the languages your project has today, and it filters every later run, so a language added in the app stays invisible until you edit the file.npx @locamorph/cli initlocamorph.yaml project_id: "your-project-uuid" translations_dir: ./locales format: json file_structure: "{LANG_ISO}.{FORMAT}" # en.json, de.json, ...If you split strings across several files, use
{LANG_ISO}/{FILE}and nothing else. It is the one multi-file pattern that survives a round trip. -
Do a dry run
download --dryrunfetches the project, reads the files it would replace, and prints each path with the number of keys it would add or update. Your disk is untouched. If the paths look wrong, this is the cheap moment to find out.locamorph download --dryrun -
Check it worked
Run it for real, then
git status. Files land undertranslations_dir, named the wayfile_structuredescribes. “No files changed” is a pass too — it means your repo already matches the project.
What’s supported
Formats, plans and quota
Six formats sync in both directions — the same parser and exporter pairs the web app
and the REST API run, so a file that imports in one place imports in all three. iOS
.strings uploads today and gains download when its exporter lands.
| Format | Web app | REST API | CLI |
|---|---|---|---|
.json | Yes | Yes | Yes |
.yaml / .yml | Yes | Yes | Yes |
.arb | Yes | Yes | Yes |
.xliff / .xlf | Yes | Yes | Yes |
strings.xml (Android) | Yes | Yes | Yes |
.properties | Yes | Yes | Yes |
.strings (iOS) | Yes | Yes | Upload only |
.resx, CSV, Excel | In progress | In progress | In progress |
For the formats still in progress, the two directions behave differently. An upload skips a file it can’t parse and names the file and the reason. A download warns and writes JSON into the configured path anyway, so a project set to one of those formats still produces files — just not in the shape you asked for. If you need one of them next, tell us which.
API keys work on Free
Creating a key isn’t gated on a paid plan, so CLI sync works the day you sign up.
No integration slot used
Integration limits count the hosted integrations you connect in the app. The CLI just carries a key.
Runs are metered
A download spends four requests plus one per language; an upload spends two plus one per file. The Free plan includes 500 a month.
A dry run costs what the real thing costs, because the server has to receive the payload before it can tell you what would change. At human speed that is fine — a twelve-language project is a sixteen-request download. It stops being fine in a watch loop, a pre-commit hook that fires on every save, or a cron set to five minutes. Put sync where a person or a merge triggers it, not where a file-watcher does.
Permissions
What each command needs
One API key is the whole gate: scopes are checked per request, so a key that can download can’t upload, and finding that out costs you a failed command rather than a cleanup.
| Command | API key scopes |
|---|---|
download | projects:read, languages:read, translations:read |
upload, including --dryrun | projects:read, languages:read, translations:write |
status, projects | projects:read |
keys list / search / show | keys:read |
keys add | keys:write |
keys delete | keys:delete |
languages add / remove | languages:write, plus the project-manager role |
Why a dry run needs a write scope
Because the diff is computed server-side. --dryrun still posts the whole
payload to the bulk update endpoint; the server works out what would change, returns the
summary, and discards the write. Your machine never has enough of the project to
calculate that on its own, which is why a read-only key can’t produce a preview.
Failures print on stderr and exit non-zero: 2 for auth, 3 for a
permission, 4 for a project it can’t find, 5 for network
or timeout, 7 for validation. One exception is worth wiring around — an
upload that reports per-key errors prints them and still exits 0, so CI
should read .errors from --json rather than trusting the exit
status alone.
Common workflows
Ask before you push
Run the upload you are about to run, with --dryrun on it. The server computes
the change set, sends it back per key, and drops the write. You get the same numbers you
would get afterwards, at the point where they can still change your mind — and in a
shape a script can act on.
# The CLI can print warnings on stdout ahead of the payload,
# so slice from the first line that opens the JSON.
locamorph upload --dryrun --json | sed -n '/^{/,$p' > diff.json
# .errors is a top-level array; length is the check that works for one.
jq -e '.errors | length == 0' diff.json
jq -r '.total | "\(.added) added, \(.updated) updated"' diff.json What a run actually prints
$ locamorph upload --dryrun --verbose
Dry run mode — nothing will be written
Dry run: Would upload translations for "Acme Web"
┌───────┬───────┬─────────┬─────────┐
│ │ Added │ Updated │ Removed │
├───────┼───────┼─────────┼─────────┤
│ en │ 3 │ 1 │ 0 │
│ de │ 3 │ 0 │ 0 │
│ tr │ 3 │ 0 │ 0 │
├───────┼───────┼─────────┼─────────┤
│ Total │ 9 │ 1 │ 0 │
└───────┴───────┴─────────┴─────────┘
+ [en] checkout.summary.title
+ [en] checkout.summary.tax
+ [en] checkout.summary.total
~ [en] common.save ~ row is the one existing string this branch changes.
Upload from where the strings actually live
Point --source at your component tree instead of a locales directory. Every
match goes to the base language and the file scope comes from the basename, so
src/checkout/locale/common.json lands as common — no
mirrored locales/en/ to maintain just to feed the platform. The trade-offs
are real: directory structure is discarded, so two files both named
common.json merge into one scope, and exclude patterns
don’t apply in this mode. Use a pattern narrow enough that you could name every file
it matches.
Related
Where to go next
The package on npm
Every command and flag with its defaults, in the README.
GitHub Action
The same commands, on your triggers, with a diff commented on the pull request.
All integrations
GitHub, GitLab, Slack, S3, webhooks, the CLI and the REST API.
Don’t see your tool?
Tell us what you’d want connected.
CLI, Action, or the REST API?
Take the CLI when a person decides the moment: before a release, after a refactor that renamed half a namespace, while working out why a key isn’t rendering. It is also the fastest way to find out whether your config is right, because the mistake lands in your terminal instead of a job log you have to go and open.
Take the GitHub Action when the answer is “on every merge to main” and nobody should have to remember. It installs this same package and shells out to the same commands, so starting local and moving into CI later costs nothing — the commands don’t change on the way. That progression is roughly what continuous localization looks like when it grows out of a repo rather than being installed on top of one.
The REST API is the layer under both, reached with the same key and the same scopes. The CLI is opinionated about files: it globs a directory, maps paths to languages, flattens nested keys, diffs against disk. If your strings don’t live in files — a database, a CMS, a build step that generates them — the API is the right surface and the CLI is a detour.
Start with a dry run
Nothing on disk changes, nothing in your project changes, and one command tells you
whether the config is right. Install, init,
download --dryrun — three lines, and you will know in about a minute.
If the paths it prints are the paths you expected, drop the flag.