/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
- npx (no install)
- Global
- Project dependency
@textsetu/cli@1.2.3) once
you depend on it, so a release can’t change your build.Quickstart
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.
- 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.
~/.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 runlogin 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:
translation_read to pull,
translation_create and translation_update to push.
Which credential wins
--api-key → TEXTSETU_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.
$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:
- Script subtags on Android.
zh-Hansneeds Android’sb+zh+Hansform, 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
- Web app
- Android
- iOS
- Monorepo, several platforms
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:
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:
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.
textsetu pull
Downloads translations and writes them to the paths in your config.
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.
--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:
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.
Exit codes
Stable, and part of the contract — a pipeline can tell “translations aren’t ready” from “the token expired”.Continuous integration
- GitHub Action
- GitHub, plain
- GitLab CI
- Anywhere else
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:
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.
pushsends 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:
** 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.” Runtextsetu 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.
