> ## Documentation Index
> Fetch the complete documentation index at: https://docs.textsetu.com/llms.txt
> Use this file to discover all available pages before exploring further.

# CLI

> Push source strings and pull finished translations between your repository and TextSetu.

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](/api/introduction): 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`](https://www.npmjs.com/package/@textsetu/cli)
* **Binary:** `textsetu`
* **Requires:** Node 20.11 or newer
* **Sign in:** in your browser — no token to copy and paste

## Install

<Tabs>
  <Tab title="npx (no install)">
    ```bash theme={null}
    npx @textsetu/cli@latest --help
    ```

    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.
  </Tab>

  <Tab title="Global">
    ```bash theme={null}
    npm install -g @textsetu/cli
    textsetu --help
    ```
  </Tab>

  <Tab title="Project dependency">
    ```bash theme={null}
    npm install -D @textsetu/cli
    ```

    Then add scripts, so everyone on the team runs the same version:

    ```json theme={null}
    {
      "scripts": {
        "i18n:push": "textsetu push",
        "i18n:pull": "textsetu pull",
        "i18n:check": "textsetu status --check --threshold 95"
      }
    }
    ```
  </Tab>
</Tabs>

## Quickstart

```bash theme={null}
textsetu login          # approve in your browser
textsetu init           # writes textsetu.json
textsetu push           # upload your source strings
textsetu pull           # bring translations back
```

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

***

## What do you want to do?

| I want to…                                                  | Command                                         |
| ----------------------------------------------------------- | ----------------------------------------------- |
| Sign in on this machine                                     | `textsetu login`                                |
| Sign in on a machine with no browser                        | `textsetu login --no-browser`                   |
| Check which token is being used, and whether it still works | `textsetu whoami`                               |
| Set up a repo for the first time                            | `textsetu init`                                 |
| Move a Crowdin project, its TM and its glossary across      | `textsetu migrate crowdin --from <id>`          |
| See which format id my `.json` files need                   | `textsetu formats`                              |
| Upload the English strings I just wrote                     | `textsetu push`                                 |
| See what a push *would* upload, before uploading it         | `textsetu push --dry-run`                       |
| Re-upload everything, ignoring the "unchanged" optimisation | `textsetu push --force`                         |
| Also upload translations I have locally                     | `textsetu push --translations`                  |
| Download finished translations                              | `textsetu pull`                                 |
| Download everything, including drafts and pending review    | `textsetu pull --status all`                    |
| Download only French and German                             | `textsetu pull --locale fr de`                  |
| See where each file would land before writing anything      | `textsetu pull --dry-run`                       |
| Overwrite local files I've edited by hand                   | `textsetu pull --force`                         |
| Check how complete each language is                         | `textsetu status`                               |
| Fail a build when translations aren't ready                 | `textsetu status --check --threshold 95`        |
| Work against a translation branch instead of main           | add `--branch <branchId>` to any command        |
| Work against a self-hosted TextSetu                         | `--api-url https://textsetu.example.com/api/v1` |
| Push to a *different* project from the same checkout        | `textsetu push --project <projectId>`           |
| Get machine-readable output for a script                    | add `--json` to any command                     |
| Run against a specific config file                          | `--config path/to/textsetu.json`                |
| Sign out of this machine                                    | `textsetu logout`                               |

***

## Signing in

`textsetu login` prints a short code, opens your browser, and waits while you
approve the request. Nothing is copied and pasted.

```
  Your code:  F9RT-MTZC
  Approve at: https://app.textsetu.com/device?user_code=F9RT-MTZC

Opened your browser. Waiting for approval…
```

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.

<Info>
  **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.
</Info>

### 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:

```bash theme={null}
TEXTSETU_API_KEY=tsu_proj_… textsetu push
```

Grant it only what the job needs — `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.

<Note>
  **Tokens do not refresh**

  There 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.
</Note>

***

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

```json theme={null}
{
  "$schema": "https://docs.textsetu.com/schema/textsetu-1.json",
  "version": 1,
  "projectId": "9f8e7d6c-…",
  "sourceLocale": "en",
  "targetLocales": ["fr", "de", "ja"],
  "files": [{ "format": "json-nested", "path": "locales/[locale].json" }]
}
```

The `$schema` line gives you completion and inline validation in VS Code, JetBrains
IDEs and anything else that understands JSON Schema.

### Fields

| Field           | Required | Meaning                                                                                                                                      |
| --------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `version`       | yes      | Config format version. Currently `1`.                                                                                                        |
| `projectId`     | yes      | Which TextSetu project to sync with. `textsetu init` fills it in; `--project` overrides it per run.                                          |
| `sourceLocale`  | yes      | The language you author in. Its file is what `push` uploads.                                                                                 |
| `targetLocales` | no       | Which languages to pull. **Omit it** to mean "every language on the project" — then adding a language in the web app needs no config change. |
| `files`         | yes      | One entry per group of files. See below.                                                                                                     |
| `apiUrl`        | no       | Only for self-hosted. Omit for `api.textsetu.com`.                                                                                           |
| `$schema`       | no       | Editor support. Harmless and recommended.                                                                                                    |

Each entry in `files` takes:

| Field         | Required | Meaning                                                                                |
| ------------- | -------- | -------------------------------------------------------------------------------------- |
| `format`      | yes      | A format id from `textsetu formats`.                                                   |
| `path`        | yes      | Where the files live, containing `[locale]` exactly once. Relative to `textsetu.json`. |
| `localeStyle` | no       | How a language code is written into the path. Default `bcp47`.                         |
| `localeMap`   | no       | Per-language override of the substituted text. Always wins over `localeStyle`.         |

### 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:

| `localeStyle`       | `fr`  | `pt-BR`   | Typical use                     |
| ------------------- | ----- | --------- | ------------------------------- |
| `bcp47` *(default)* | `fr`  | `pt-BR`   | Web apps, Xcode `.lproj`        |
| `underscore`        | `fr`  | `pt_BR`   | Java, Rails, Gettext            |
| `two-letter`        | `fr`  | `pt`      | Trees with no regional variants |
| `android`           | `-fr` | `-pt-rBR` | Android `res/values…`           |

`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:

```json theme={null}
{
  "format": "android-xml",
  "path": "app/src/main/res/values[locale]/strings.xml",
  "localeStyle": "android"
}
```

→ `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`:

```json theme={null}
{
  "format": "android-xml",
  "path": "app/src/main/res/values[locale]/strings.xml",
  "localeStyle": "android",
  "localeMap": {
    "zh-Hans": "-b+zh+Hans",
    "en": "-en"
  }
}
```

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

<Tabs>
  <Tab title="Web app">
    ```json theme={null}
    {
      "$schema": "https://docs.textsetu.com/schema/textsetu-1.json",
      "version": 1,
      "projectId": "…",
      "sourceLocale": "en",
      "files": [{ "format": "json-nested", "path": "public/locales/[locale].json" }]
    }
    ```

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

    ```json theme={null}
    {
      "files": [
        { "format": "i18next", "path": "public/locales/[locale]/common.json" },
        { "format": "i18next", "path": "public/locales/[locale]/checkout.json" }
      ]
    }
    ```
  </Tab>

  <Tab title="Android">
    ```json theme={null}
    {
      "$schema": "https://docs.textsetu.com/schema/textsetu-1.json",
      "version": 1,
      "projectId": "…",
      "sourceLocale": "en",
      "files": [
        {
          "format": "android-xml",
          "path": "app/src/main/res/values[locale]/strings.xml",
          "localeStyle": "android"
        }
      ]
    }
    ```
  </Tab>

  <Tab title="iOS">
    ```json theme={null}
    {
      "$schema": "https://docs.textsetu.com/schema/textsetu-1.json",
      "version": 1,
      "projectId": "…",
      "sourceLocale": "en",
      "files": [
        {
          "format": "apple-strings",
          "path": "MyApp/Resources/[locale].lproj/Localizable.strings"
        }
      ]
    }
    ```

    Xcode's `.lproj` directories use the plain language code, so the default
    `bcp47` style is correct — there's no separate Apple style to remember.
  </Tab>

  <Tab title="Monorepo, several platforms">
    One config can drive several platforms at once. Each entry keeps its own format
    and path shape:

    ```json theme={null}
    {
      "$schema": "https://docs.textsetu.com/schema/textsetu-1.json",
      "version": 1,
      "projectId": "…",
      "sourceLocale": "en",
      "targetLocales": ["fr", "de", "pt-BR"],
      "files": [
        { "format": "json-nested", "path": "apps/web/locales/[locale].json" },
        {
          "format": "android-xml",
          "path": "apps/android/src/main/res/values[locale]/strings.xml",
          "localeStyle": "android"
        },
        {
          "format": "apple-strings",
          "path": "apps/ios/Resources/[locale].lproj/Localizable.strings"
        }
      ]
    }
    ```

    `textsetu pull --dry-run` is the quickest way to confirm all three resolve where
    you expect.
  </Tab>
</Tabs>

### 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](/docs/reference/file-formats) reference.

It lists every id, and calls out which extensions are ambiguous:

```
id                   name                        extensions     plurals
json-nested          JSON (Nested)               .json          yes
json-flat            Key-Value JSON              .json          no
…
37 formats. These extensions are ambiguous, which
is why `format` must be set explicitly in textsetu.json:
  .json        chrome-messages, formatjs, go-i18n, i18next, json-flat, json-nested
  .xml         android-xml, java-properties-xml
```

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:

| Flag                  | Meaning                                                        |
| --------------------- | -------------------------------------------------------------- |
| `--api-key <token>`   | Use this token, ignoring the environment and the stored login. |
| `--api-url <url>`     | Server base URL. Overrides `apiUrl` in the config.             |
| `-p, --project <id>`  | Project id, overriding `projectId` in the config.              |
| `-c, --config <path>` | Use this config file instead of searching for one.             |
| `--json`              | Emit a single JSON document on stdout.                         |
| `--no-color`          | No ANSI colour.                                                |
| `--non-interactive`   | Never prompt. Print the options and exit `2` instead.          |
| `-v, --verbose`       | Print a stack trace on failure.                                |
| `-V, --version`       | Print the CLI version.                                         |

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.

| Flag                | Meaning                                                             |
| ------------------- | ------------------------------------------------------------------- |
| `--format <id>`     | Format id to use. Skips the prompt when the extension is ambiguous. |
| `--force`           | Overwrite an existing config.                                       |
| `--non-interactive` | Never prompt. Print the options and exit `2` instead.               |

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:

```
This token can read 2 projects:

id                                    name               source  keySeparator
────────────────────────────────────  ─────────────────  ──────  ────────────
8af3472d-bf23-4fb1-bb07-f2180cded090  Marketing & Email  en      .
d1d18ab3-261a-488a-85ba-4329f181daba  Storefront         en      .

error More than one project is available, so which one to use cannot be guessed.
Re-run with --project <id> using one of the ids above.
```

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](#real-world-configurations) — and a wrong
guess about where your translations live is worse than no guess.

### `textsetu login` / `logout` / `whoami`

| Command  | Flags                                                      |
| -------- | ---------------------------------------------------------- |
| `login`  | `--no-browser`, `--force` (replace an existing credential) |
| `logout` | —                                                          |
| `whoami` | —                                                          |

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

```
server      https://api.textsetu.com/api/v1
token       tsu_pat_Mayh…  (personal access token)
from        ~/.textsetu/auth.json
permissions 12
status      valid — 2 readable project(s)
```

### `textsetu push`

Uploads your **source** files. Matching keys are updated, new keys are created;
nothing is deleted.

| Flag                   | Meaning                                                        |
| ---------------------- | -------------------------------------------------------------- |
| `--translations`       | Also upload target files.                                      |
| `-l, --locale <code…>` | Limit target uploads to these locales (with `--translations`). |
| `-b, --branch <id>`    | Import into a translation branch instead of main.              |
| `--dry-run`            | Print the file plan and exit.                                  |
| `--force`              | Ignore the lockfile and re-upload everything.                  |

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

```
action  path             locale  format       size
──────  ───────────────  ──────  ───────────  ────
upload  locales/en.json  en      json-nested  4 KB

✓ 12 key(s) added, 12 value(s) added, 3 updated, 0 skipped
```

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.

| Flag                    | Meaning                                                   |
| ----------------------- | --------------------------------------------------------- |
| `-l, --locale <code…>`  | Only these locales.                                       |
| `-s, --status <status>` | `approved` *(default)*, `all`, `pending_review`, `draft`. |
| `-b, --branch <id>`     | Read a translation branch instead of main.                |
| `--dry-run`             | Print the file plan and exit.                             |
| `--force`               | Overwrite files edited since the last pull.               |

<Tip>
  **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.
</Tip>

Untranslated keys come back empty rather than filled with the source text.

### `textsetu status`

Per-language completeness, and the CI gate.

| Flag                    | Meaning                                        |
| ----------------------- | ---------------------------------------------- |
| `-l, --locale <code…>`  | Only these locales.                            |
| `-b, --branch <id>`     | Report on a translation branch.                |
| `--check`               | Exit `4` if any locale is below the threshold. |
| `--threshold <percent>` | What `--check` requires. Default `100`.        |

```
project  d1d18ab3-261a-488a-85ba-4329f181daba
keys     85
source   en

locale  language    translated  missing  complete
──────  ──────────  ──────────  ───────  ────────
de      German      83/85       2        98%
fr      French      80/85       5        94%
ja      Japanese    82/85       3        96%
```

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

| Flag                                            | Meaning                                                                |
| ----------------------------------------------- | ---------------------------------------------------------------------- |
| `--from <id>`                                   | The Crowdin project. Prompts at a terminal when omitted.               |
| `--crowdin-token <t>`                           | Prefer `CROWDIN_TOKEN` — a flag lands in your shell history.           |
| `--organization <name>`                         | Crowdin Enterprise, the `<name>` in `<name>.crowdin.com`.              |
| `--crowdin-tm <id>` / `--crowdin-glossary <id>` | Which TM and glossary to bring.                                        |
| `--only <part…>`                                | `translations`, `tm`, `glossary`. Default: all three.                  |
| `--locale-map <from=to…>`                       | Map a Crowdin language onto a different TextSetu code.                 |
| `--on-collision <fail\|prefix>`                 | What to do when two Crowdin files share an identifier. Default `fail`. |
| `--dry-run`                                     | Print the plan and write nothing.                                      |

```bash theme={null}
export CROWDIN_TOKEN=…
textsetu migrate crowdin --from 123456 --dry-run
textsetu migrate crowdin --from 123456
```

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](/docs/guides/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:

```
action     path             locale  format
─────────  ───────────────  ──────  ───────────
unchanged  locales/de.json  de      json-nested
CONFLICT   locales/fr.json  fr      json-nested

1 file(s) have been edited since the last pull:
  locales/fr.json
error Refusing to overwrite 1 locally-edited file(s).
Commit or revert those files, or re-run with --force to overwrite them.
```

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

<Note>
  **What the lockfile tracks**

  Whole 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.
</Note>

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

| Code  | Meaning                                                      |
| ----- | ------------------------------------------------------------ |
| `0`   | Success                                                      |
| `1`   | Unexpected error                                             |
| `2`   | Bad config, or bad flags                                     |
| `3`   | Not signed in, or the token was rejected                     |
| `4`   | `--check` ran fine, and the answer was "not complete enough" |
| `5`   | `pull` would have overwritten locally-edited files           |
| `130` | You cancelled a prompt (Ctrl-C / Esc). 128 + SIGINT          |

***

## Continuous integration

<Tabs>
  <Tab title="GitHub Action">
    The action wraps the CLI and can open the translations pull request for you.

    Upload sources when they change:

    ```yaml theme={null}
    name: Upload strings to TextSetu
    on:
      push:
        branches: [main]
        paths: ["locales/**"]

    jobs:
      upload:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
          - uses: TextSetu/github-action@v1
            with:
              api_key: ${{ secrets.TEXTSETU_API_KEY }}
              upload_sources: true
    ```

    Bring translations back as a reviewable PR:

    ```yaml theme={null}
    name: Download translations
    on:
      schedule: [{ cron: "0 6 * * 1" }]
      workflow_dispatch:

    permissions:
      contents: write
      pull-requests: write

    jobs:
      download:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
          - uses: TextSetu/github-action@v1
            with:
              api_key: ${{ secrets.TEXTSETU_API_KEY }}
              upload_sources: false
              download_translations: true
              create_pull_request: true
    ```
  </Tab>

  <Tab title="GitHub, plain">
    ```yaml theme={null}
    - uses: actions/setup-node@v4
      with:
        node-version: "22"

    - run: npx @textsetu/cli@latest push
      env:
        TEXTSETU_API_KEY: ${{ secrets.TEXTSETU_API_KEY }}

    - run: npx @textsetu/cli@latest status --check --threshold 95
      env:
        TEXTSETU_API_KEY: ${{ secrets.TEXTSETU_API_KEY }}
    ```
  </Tab>

  <Tab title="GitLab CI">
    ```yaml theme={null}
    i18n:
      image: node:22
      script:
        - npx @textsetu/cli@latest push
        - npx @textsetu/cli@latest status --check --threshold 95
      variables:
        TEXTSETU_API_KEY: $TEXTSETU_API_KEY
    ```
  </Tab>

  <Tab title="Anywhere else">
    Any runner with Node 20.11+ works. Set `TEXTSETU_API_KEY` and call the binary:

    ```bash theme={null}
    npx @textsetu/cli@latest push
    npx @textsetu/cli@latest pull --force
    npx @textsetu/cli@latest status --check --threshold 95
    ```

    In CI, pass `--force` to `pull`: a runner is a fresh clone, so the local-edit
    guard has nothing real to protect and would only ever be comparing against what's
    committed.
  </Tab>
</Tabs>

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

| You know                                   | Here                                 |
| ------------------------------------------ | ------------------------------------ |
| `crowdin upload sources` / `crowdin push`  | `textsetu push`                      |
| `crowdin download` / `crowdin pull`        | `textsetu pull`                      |
| `phrase push` / `phrase pull`              | `textsetu push` / `textsetu pull`    |
| `tx push -s` / `tx pull -a`                | `textsetu push` / `textsetu pull`    |
| `lokalise2 file upload` / `file download`  | `textsetu push` / `textsetu pull`    |
| `lingo.dev i18n`                           | `textsetu push` then `textsetu pull` |
| `crowdin status`                           | `textsetu status`                    |
| `crowdin init` / `phrase init` / `tx init` | `textsetu init`                      |

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

<Note>
  **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](/docs/guides/migrating-from-crowdin). The rest of this section is
  about the config file.
</Note>

`crowdin.yml` uses a `source` + `translation` pair per entry and around eleven
path placeholders. TextSetu uses one placeholder plus a style:

| `crowdin.yml`                                             | `textsetu.json`                                             |
| --------------------------------------------------------- | ----------------------------------------------------------- |
| `source` + `translation`                                  | a single `path` with `[locale]`                             |
| `%two_letters_code%`                                      | `[locale]` with `"localeStyle": "two-letter"`               |
| `%locale%`                                                | `[locale]` (default style)                                  |
| `%locale_with_underscore%`                                | `[locale]` with `"localeStyle": "underscore"`               |
| `%android_code%`                                          | `[locale]` with `"localeStyle": "android"`                  |
| `%osx_locale%`                                            | `[locale]` (default style — `.lproj` uses the plain code)   |
| `%original_file_name%`, `%file_name%`, `%file_extension%` | write the filename literally in `path`                      |
| `languages_mapping`                                       | `localeMap`                                                 |
| `project_id`                                              | `projectId`                                                 |
| `api_token` in the config                                 | not in the config — `textsetu login`, or `TEXTSETU_API_KEY` |
| `preserve_hierarchy`                                      | not needed; your paths are the layout                       |

So this:

```yaml theme={null}
files:
  - source: /locales/en/**/*.json
    translation: /locales/%two_letters_code%/**/%original_file_name%
```

becomes one entry per file you sync:

```json theme={null}
{
  "files": [
    { "format": "i18next", "path": "locales/[locale]/common.json" },
    { "format": "i18next", "path": "locales/[locale]/checkout.json" }
  ]
}
```

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.

| `.phrase.yml`                                 | `textsetu.json`                                   |
| --------------------------------------------- | ------------------------------------------------- |
| `push.sources[].file` + `pull.targets[].file` | one `path` per entry                              |
| `<locale_name>` / `<locale_code>`             | `[locale]`                                        |
| `project_id`                                  | `projectId`                                       |
| `file_format`                                 | `format` (per entry, so one repo can mix formats) |
| `access_token`                                | `textsetu login`, or `TEXTSETU_API_KEY`           |
| `locale_mapping`                              | `localeMap`                                       |
| `pull.targets[].tags`                         | not supported by the CLI yet                      |

### From Transifex

| `.tx/config`                            | `textsetu.json`                                      |
| --------------------------------------- | ---------------------------------------------------- |
| `source_file` + `file_filter`           | one `path` with `[locale]`                           |
| `<lang>`                                | `[locale]`                                           |
| `type`                                  | `format`                                             |
| `[o:org:p:project:r:resource]` sections | one entry in `files`                                 |
| `~/.transifexrc`                        | `~/.textsetu/auth.json`, written by `textsetu login` |

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.

| `i18n.json`                        | `textsetu.json`                  |
| ---------------------------------- | -------------------------------- |
| `buckets` keyed by format          | `files[]`, each with a `format`  |
| `[locale]`                         | `[locale]` — identical           |
| `locale.source` / `locale.targets` | `sourceLocale` / `targetLocales` |
| `i18n.lock`                        | `textsetu.lock`                  |

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:

| `lokalise2`                             | Here                                         |
| --------------------------------------- | -------------------------------------------- |
| `--token` on every call                 | `textsetu login` once, or `TEXTSETU_API_KEY` |
| `--project-id` on every call            | `projectId` in the config                    |
| `file upload --file … --lang-iso …`     | `textsetu push`                              |
| `file download --format … --unzip-to …` | `textsetu pull`                              |
| `--poll`                                | not needed; the CLI's calls are synchronous  |
| branch appended to the project id       | `--branch <id>`                              |

***

## 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](/docs/guides/branches).

***

## How this fits with the rest

| Tool                                        | Runs where            | Best for                                      |
| ------------------------------------------- | --------------------- | --------------------------------------------- |
| **CLI**                                     | Your machine, your CI | Files in a repo, scripted and gated           |
| **[GitHub Sync](/docs/guides/github-sync)** | TextSetu's servers    | Repo syncing with no CI to maintain           |
| **[MCP server](/api/mcp)**                  | Your AI agent         | Asking an assistant to read or change strings |
| **[REST API](/api/introduction)**           | Anywhere              | Anything the above don't cover                |

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.
