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.

npm install -g @locamorph/cli
terminal
$ 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.

terminal
$ 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.

terminal
$ 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.

locamorph.yaml
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.

command flags

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

locamorph.yaml

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

project settings

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.

CommandWhat it doesRequestsUndo
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.

  1. Install it

    Node 18 or newer. On a CI runner, npx @locamorph/cli download does the same job without installing anything globally.

    npm install -g @locamorph/cli
  2. 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_...
  3. 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 the languages: 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 init
    locamorph.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.

  4. Do a dry run

    download --dryrun fetches 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
  5. Check it worked

    Run it for real, then git status. Files land under translations_dir, named the way file_structure describes. “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.

FormatWeb appREST APICLI
.jsonYesYesYes
.yaml / .ymlYesYesYes
.arbYesYesYes
.xliff / .xlfYesYesYes
strings.xml (Android)YesYesYes
.propertiesYesYesYes
.strings (iOS)YesYesUpload only
.resx, CSV, ExcelIn progressIn progressIn 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.

CommandAPI 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.

check-strings.sh
# 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

bash
 $ 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 
Illustration of a real run. The table has a row per project language rather than per file you sent: a key exists in every language the moment it lands, untranslated and waiting for someone, so three new keys count as added three times over. The ~ 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.

locamorph upload --source "./src/**/locale/*.json"

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.