Skip to main content
The TextSetu CLI moves strings between your repository and TextSetu. You push the source language you just wrote, and pull finished translations back into the paths your app already reads from — on your own machine, or from CI. It’s a thin layer over the REST API: every command is one or two calls to /api/v1, so the same authentication, permissions and approval rules apply. Nothing the API won’t let you do is reachable through the CLI.
  • Package: @textsetu/cli
  • Binary: textsetu
  • Requires: Node 20.11 or newer
  • Sign in: in your browser — no token to copy and paste

Install

Good for trying it out and for CI. Pin the version (@textsetu/cli@1.2.3) once you depend on it, so a release can’t change your build.

Quickstart

That’s the whole loop. Everything below is detail on top of those four commands.

What do you want to do?


Signing in

textsetu login prints a short code, opens your browser, and waits while you approve the request. Nothing is copied and pasted.
On the approval screen you choose what the token can do:
  • a personal access token, which acts as you across every project you can reach — the right choice for your own machine, and
  • or a project token, bound to a single project.
The credential is stored in ~/.textsetu/auth.json, readable only by you, and keyed by server URL — so a production login and a localhost login coexist without overwriting each other. Nothing is written into your repository.
No browser on this machine?textsetu login --no-browser prints the URL instead of opening it. Approve it from any device — a phone works — and the terminal picks up the approval. Handy over SSH and in containers.

In CI

Don’t run login in CI. Create a project token in the web app (Project settings → API tokens), store it as a secret, and expose it as an environment variable:
Grant it only what the job needs — translation_read to pull, translation_create and translation_update to push.

Which credential wins

--api-keyTEXTSETU_API_KEY → the stored login. A CI job is therefore never affected by a credential someone left on the runner.
Tokens do not refreshThere is no refresh step in TextSetu. If a command reports a rejected token, run textsetu login again (or rotate the CI secret). That’s the whole recovery path.

Configuration

textsetu init writes a textsetu.json at the root of your repository. Commit it. It contains no credentials — it names the server, and the token for that server lives in your home directory.
The $schema line gives you completion and inline validation in VS Code, JetBrains IDEs and anything else that understands JSON Schema.

Fields

Each entry in files takes:

The [locale] placeholder

One placeholder covers both directions: locales/[locale].json describes locales/en.json and locales/fr.json. It must appear exactly once — a path without it would make every language resolve to the same file, and the last write would win. localeStyle controls how the language code is spelled in the path: android behaves differently from the rest, and it has to: Android puts the default language in values/ and every translation in values-fr/. So under android the placeholder becomes an empty string for your source language, and -fr for a target. That’s why the hyphen belongs inside the placeholder:
res/values/strings.xml for en, res/values-fr/strings.xml for fr.

localeMap, for everything else

Any layout a style can’t express goes in localeMap:
Two cases where you’ll need it:
  • Script subtags on Android. zh-Hans needs Android’s b+zh+Hans form, which is a different grammar rather than a different separator. The CLI warns when it spots this rather than emitting a directory Android silently ignores.
  • A project that really does keep values-en/. Map the source language explicitly and the CLI obeys you.

Real-world configurations

For i18next with one file per namespace, add an entry per namespace:

Why format is required

A file extension doesn’t identify a format. .json alone maps to six of them — json-nested, json-flat, i18next, formatjs, go-i18n and chrome-messages — and .xml, .yml and .xliff are ambiguous too. Guessing would import the wrong shape and leave you deleting keys by hand, so the CLI asks instead. textsetu formats is the authority for your deployment — see also the Supported File Formats reference. It lists every id, and calls out which extensions are ambiguous:
The plurals column matters when your strings have plural forms: exporting them to a format that can’t represent plural categories collapses every category to other. Check it while you’re choosing, not afterwards.

Commands

Every command accepts these global flags: The config is found by walking up from the current directory, so commands work from anywhere inside your repo. Paths in files always resolve relative to the config file, never to where you happened to be standing.

textsetu init

Creates textsetu.json. Looks up the project’s languages, scans for locale files, and writes the result. At a terminal, init asks for anything it can’t work out — which project, and which format when the extension is ambiguous. Passing --project and --format answers those up front and skips the prompts entirely. Without a TTY it never prompts. In a container, in CI, behind a pipe, or under --json, --non-interactive or CI=1, a prompt would hang with nothing explaining why — so it prints the options and asks for a flag, which is what CI sees:
Cancelling a prompt with Ctrl-C or Esc exits 130 and writes nothing. Discovery only proposes the common locales/en.json layout. Android resource trees, .lproj directories and per-language subdirectories are quick to write by hand — see Real-world configurations — and a wrong guess about where your translations live is worse than no guess.

textsetu login / logout / whoami

whoami calls the API rather than just reading the file, so it tells you whether the token still works:

textsetu push

Uploads your source files. Matching keys are updated, new keys are created; nothing is deleted. --translations is opt-in for a reason: pushing target files is the destructive direction. A stale local translation will overwrite work a reviewer already approved. Push sources routinely; push translations deliberately.
Large projects are split across several requests automatically to stay under the API’s 10 MB request limit. Those requests aren’t a single transaction, so if one fails the CLI tells you which, and what already landed.

textsetu pull

Downloads translations and writes them to the paths in your config.
Seeing fewer strings than the editor shows?pull defaults to --status approved, so anything still in review or draft is left out. Use --status all to get everything. The CLI prints the status it used on every run.
Untranslated keys come back empty rather than filled with the source text.

textsetu status

Per-language completeness, and the CI gate.
--check measures the locales your config actually ships — a language somebody added in the web app but never put in targetLocales won’t fail your build.

textsetu formats

Lists the server’s format catalogue. Needs a token (any token), because format support can differ between deployments.

textsetu migrate crowdin

Reads a Crowdin project, its translation memory and its glossary, and writes all three into a TextSetu project. Nothing in Crowdin is modified.
A personal access token is required for the TM and the glossary, because both belong to the organization; a project token can still do --only translations. Re-running is safe, and is how you recover from a failed run. The full runbook — preparation, every plan line, and what to do about each warning and each error — is Migrating from Crowdin.

The lockfile

textsetu.lock sits next to your config. Commit it. It records the exact content last exchanged with the server, and buys two things: push skips files that haven’t changed. No request, no upload, no waiting. On a monorepo pushing on every merge this is the difference between a second and a minute. pull won’t quietly destroy a hand edit. If a translation file changed since the last pull, the run stops and names every affected file:
Nothing is written when there’s a conflict — not even the files that were safe. A partial pull would leave your repo in a state the lockfile doesn’t describe, and the next run’s report would be measured against the wrong baseline. Your options are then: revert the local edit and pull, push the edit up with push --translations, or accept the loss with pull --force.
What the lockfile tracksWhole files, not individual keys. The format parsers run server-side — the CLI moves files and TextSetu parses them — so it can tell you that a file changed, not which key inside it changed.
Merge conflicts in the lockfile are safe to resolve by deleting the conflicting lines: the worst case is one extra full push or a spurious conflict report on the next pull.

Exit codes

Stable, and part of the contract — a pipeline can tell “translations aren’t ready” from “the token expired”.

Continuous integration

The action wraps the CLI and can open the translations pull request for you.Upload sources when they change:
Bring translations back as a reviewable PR:
Use a project token in CI, not a personal one. Two consequences worth knowing: a project token never counts as an approver, so on a project that requires approval its pushes arrive as pending review rather than live — which is usually what you want from automation — and it can’t complete a branch merge.

Coming from another tool

The CLI follows the conventions of this category deliberately, so most of what you know transfers. What differs is listed per tool below.

Command names

Two differences to note:
  • Sign-in is a browser flow. There’s no token to paste into a config file.
  • push sends sources only unless you add --translations. Some tools upload both by default; here the destructive direction is opt-in.

From Crowdin

Moving your content, not just your config? textsetu migrate crowdin pulls a Crowdin project, its translation memory and its glossary across in one command — see Migrating from Crowdin. The rest of this section is about the config file.
crowdin.yml uses a source + translation pair per entry and around eleven path placeholders. TextSetu uses one placeholder plus a style: So this:
becomes one entry per file you sync:
There’s no ** glob: entries are explicit. That’s more lines for a large tree, and in exchange --dry-run can tell you exactly which files will be touched before anything happens. ignore has no equivalent — leave the files out of files instead.

From Phrase

.phrase.yml splits push.sources from pull.targets. TextSetu has one files list serving both directions, because the placeholder covers your source language too.

From Transifex

Transifex already separates credentials from project config, so that habit carries over unchanged.

From lingo.dev

Closest in spirit — i18n.json also uses a single [locale] placeholder, and both tools keep a committed lockfile. The difference is where translation happens. lingo.dev’s CLI is the translator. Here the CLI is a transport: it moves strings into TextSetu, where AI translation, translation memory, glossaries, review and approval all apply, and moves the results back. So push and pull are separate steps, and a human review step can sit between them. TextSetu’s lockfile tracks whole files rather than individual keys, because the format parsers run server-side.

From Lokalise

lokalise2 takes paths as command-line flags rather than in a config file, so there’s no file to translate. Put the paths in textsetu.json once and the flags mostly disappear:

Troubleshooting

“No textsetu.json found in this directory or any parent.” Run textsetu init, or point at the file with --config. “the server refused this request (PERM_001).” Either the project id is wrong or your token lacks a permission. The API deliberately doesn’t distinguish the two, so that it can’t be used to discover which projects exist. Check projectId, then textsetu whoami. “your credentials were rejected.” Run textsetu login again, or rotate the CI secret. Tokens don’t refresh. “source file not found: locales/en.json (from pattern …)” The pattern didn’t match anything on disk. Run textsetu push --dry-run to see every path the config resolves to — that’s usually enough to spot it. Remember paths are relative to textsetu.json, not to where you ran the command. A pull writes files where I didn’t expect. textsetu pull --dry-run prints the destination for every locale before writing. If the shape is wrong, it’s localeStyle or localeMap you want. “Refusing to overwrite N locally-edited file(s).” (exit 5) Somebody changed those translations locally since the last pull. Revert them, push them up with push --translations, or overwrite with pull --force. A push landed as pending review instead of live. The project requires approval and you pushed with a project token, which never counts as an approver. Use a personal access token, or approve in the app. A direct push is refused on a protected project. Projects can protect their main branch. Import into a branch instead — textsetu push --branch <branchId> — then merge it for review. See Branches.

How this fits with the rest

They all speak to the same /api/v1 endpoints and obey the same permissions, so mixing them is fine — pick per workflow, not per team.