flutter_localisation_cli
Terminal CLI (fl) and MCP server (fl_mcp) for
FlutterLocalisation — add, edit, delete and AI-translate
your app's localization keys from the shell or from Claude, then pull the ARBs. No dashboard
round-trip.
Pure Dart — no Flutter SDK required. This is the tooling companion to the
flutter_localisation runtime package (which
your app depends on to load the ARBs). Strings live in the FlutterLocalisation backend; this
package changes them and the backend regenerates + pushes the ARBs to your git repo.
Install
dart pub global activate flutter_localisation_cli
This installs the fl and fl_mcp executables in ~/.pub-cache/bin (put that on your PATH).
Auth
Create a scoped API token in the dashboard → API Keys (flk_live_…), then either:
fl login --token flk_live_xxx # stored in ~/.config/flutterlocalisation (chmod 600)
# or: export FL_API_TOKEN=flk_live_xxx # or pass --token on each call
The token is workspace-scoped, so you pick projects by name — no numeric ids.
First run: fl init
cd my_app
fl init
One wizard for a fresh checkout. It asks for the token if none is stored, lists the projects
the token sees (and the flavors, when there are several), prefills the folders from the
repository (l10n.yaml arb-dir for ARBs, an existing Localizable.xcstrings or the iOS
target folder for catalogs), writes flutterlocalisation.json, pulls the strings, regenerates
the Dart for a Flutter app, then offers fl guard and fl native. Every question has a
default; fl init -y takes them all, and --project, --flavor, --arb-dir,
--xcstrings-dir answer the ones that have no default. --no-pull only writes the config.
The project itself is still created in the dashboard: the token cannot do that.
CLI
fl init # first run: token, project, folders, first pull
fl projects # list your projects (name, flavors, locales)
fl status --project "Chat Bot" # completion % per locale
fl add greeting --value "Hello" -t # add key + AI-translate every other locale
fl edit greeting --locale fr --value "Bonjour"
fl edit done_body --locale fr --value "{arg1} en {arg2} s" # catalog key by its stem
fl import strings.arb -t # bulk-create MANY keys from one ARB, then translate
fl translate greeting --missing # fill only empty locales
fl languages # list locales + how many keys each has filled
fl languages add id -t # add a locale and AI-fill EVERY existing key
fl languages remove id # drop a locale and its translations
fl delete greeting # whole key (or --locale fr for one locale)
fl repo # print the ARB git repo URL (from the dashboard)
fl clone # first-time: clone that repo into arb_dir
fl pull # sync arb_dir with the service (git or API)
fl sync app_en.arb # import, pull and regenerate the Dart, in one
fl guard # stop AI agents editing ARBs + generated Dart
fl native # tell iOS + Android about the locales, translate
# the iOS permission alerts
fl glossary add Dictee --keep # never translate the app's name
fl glossary add Snippets --as de=Schnipsel --as fr=Extraits
fl style set de --formal # Sie, never du (de-AT, de-CH follow)
fl glossary apply # re-translate strings that use glossary terms
fl lock support_url # this key keeps the base text in every locale
Glossary and voice
The translator sees one batch of strings at a time. Without guidance it translates your product's name like any other word, says "du" in one batch and "Sie" in the next, and calls the same feature two different things. Three settings fix that for every later translation:
- Glossary terms.
--keepkeeps a term exactly as written in every locale (brand and product names); a translation that changes it is refused and the string stays as it was.--as <locale>=<text>pins the translation of a term; a translation that does not use it is saved but marked for review. A term's--notetells the translator what it means.fl glossary suggestproposes terms from your strings (project name, recurring names, acronyms),fl glossary import glossary.csvloads a whole list (term,keep,note,de,fr,...or the JSONfl glossary exportwrites). - Language style.
fl style set <locale> --formal|--informal [--instructions "..."]. A regional variant (de-AT, pt-BR) follows its language unless it has its own style. - Locked keys.
fl lock <key>copies the base text into every locale and never sends the key for translation: URLs, code samples, names. It is the same flag as Xcode's"shouldTranslate": false, and both directions round-trip (@key.x-translate: falsein ARBs).
These apply to what is translated from then on. fl glossary apply re-translates the strings
that contain glossary terms (--term narrows it, --all covers every string after a style
change; preview with --dry-run).
Two more things happen without a setting: a string that quotes another string of your app ("Tap “Tap to speak”") gets that string's existing translation, and a regional variant (de-CH) is written from its language's translation (de) instead of from the source on its own.
fl import <file.arb> bulk-creates every key in an ARB in one request (instead of many
fl add calls) — the right tool for large migrations. --overwrite replaces existing values;
-t/--translate batch-fills the other locales afterwards; --language <code> sets the locale
the file represents (default: the base language). Preview with --dry-run.
The result says what the server actually wrote: keys written, keys skipped because they
already hold a different value (pass --overwrite to replace them), and keys already
identical. Importing one locale's translations replaces rows that still hold a copy of the
base text (what adding a locale seeds them with) without --overwrite; rows marked checked
are kept. Placeholder types always come from the base language, so a translated ARB without
@key metadata cannot turn an int into a String.
fl status and fl retranslate treat a variant of the base language (en-GB, en-US for
an en base) that reads the same as the base as finished, and never flag a value with no
words in it ({count}). fl retranslate --include-variants sends the variant rows anyway.
Bulk rules — read before scripting this CLI
Work must cost O(1) requests, not O(N). Two commands cover every bulk case:
| Goal | Use | Cost |
|---|---|---|
| Add / change many keys | fl import file.arb -t |
1 request |
| Populate new locales | fl languages add <c1> <c2> <c3> -t |
1 per locale, one command (backend back-fills every key) |
| Repair every locale | fl retranslate (no --locale) |
batched |
| Accept the loanword rows | fl retranslate --accept-unchanged |
1 request |
| Add one key | fl add |
1 request |
Never loop fl add, fl edit, fl translate, fl languages add or
fl retranslate --locale. Each is already the bulk form of itself. Looping one is
refused after the fourth single-target call in 15 minutes, and the refusal prints the
command you should have run. If a request times out, do not lower the batch size —
that multiplies request count; the client halves and retries by itself.
Never loop fl add, fl edit or fl translate over a list of keys. Each fl add -t is
~6 backend requests (resolve, create, batch-translate, verify). A 115-key ARB done that way is
~700 requests — enough to exhaust a whole day's quota for the account. The same job via
fl import is one.
This is enforced, not merely advised:
- the backend rate-limits single-key creation to 10/min and answers with a 429 that names the bulk endpoint to use instead;
- the MCP server refuses
add_stringafter a handful of applied calls in a session.
A 429 from either is not a signal to sleep and retry — backing off runs the same wrong algorithm slower. Switch to the bulk call.
Likewise, if fl import -t reports that nothing was translated, that is now a hard error
naming the locales that were left unseeded (usually: re-run the import against the base
language so every locale gets rows). Fix the bulk call; do not fall back to per-key writes.
That silent "nothing to translate" success was the original trap.
fl pull / fl sync — getting the ARBs into the repo
The ARB directory is generated output the backend owns. Nothing in it is a local
contribution, so fl pull does not merge or rebase: it fetches and hard-resets onto the
upstream branch. A dirty file or a stale local commit cannot block it, and whatever it
discards is parked under refs/fl/pre-sync/<stamp>, printed so it can be read back with
git show. You never need to run git in that directory yourself — that is the point.
When the git mirror cannot deliver (no repo linked, repository deleted, the wrong git
credential active on the machine, no git at all), pull writes the same files from the API
instead and says which path it took. The two writers are byte-identical, so failing over
does not produce a diff.
fl pull # sync arb_dir: git mirror if usable, API otherwise
fl pull --no-git # skip the mirror, write from the API
fl sync app_en.arb # import that file, pull, then regenerate the Dart
fl export path/to/dir # just the write, to any directory
Keys that exist only on disk stop a sync. Both paths rewrite the template from the
service, so a key staged by hand for an import that never ran would vanish — it has
happened, and it took an app's build with it. pull and export name those keys and
refuse; fl pull --adopt imports them first, --discard-local takes the service copy on
purpose.
This is the dashboard's Export, from the terminal. The ARBs are the backend's data, not
hand-written: @@locale is written in the same underscored form as the file name (gen_l10n
compares the two and aborts the build when they disagree), and @key metadata goes in the
template ARB only, which is where gen_l10n reads it from.
Prefer pull when the repo is linked — it is the path the backend pushes to.
Native iOS (String Catalogs)
A project can ship a Flutter app, a native iOS app, or both. Set its platforms in the
dashboard (project settings). With ios on, the backend renders every locale into one Xcode
String Catalog, Localizable.xcstrings (plus InfoPlist.xcstrings when the project holds
Info.plist keys such as NSCameraUsageDescription), and the git mirror pushes it like the
ARBs. Xcode 15 or later compiles the catalog for any deployment target. Product page:
https://flutterlocalisation.com/ios (free .xcstrings viewer and ARB converter there too).
fl import Gush/Resources/Localizable.xcstrings # seed a project from the catalog Xcode made
fl pull # write Localizable.xcstrings into xcstrings_dir
fl export Gush/Resources # same, straight from the API, to a directory
fl pull writes the catalog bytes exactly as the backend serves them, in Xcode's own
formatting, so a build does not rewrite the file and the mirror never sees a diff. The
local-only guard applies to the catalog too: a key added in Xcode and never imported stops a
pull; --adopt imports it first. An iOS-only project has no ARBs and fl sync skips the
Dart codegen for it.
Placeholders map by type: {name} becomes %@, an int becomes %lld, a double %lf;
with more than one argument they are numbered (%1$@, %2$lld). ICU plurals become plural
variations. Not supported in a catalog: ICU select, substitutions (several plural arguments
in one string) and device variations; the export names the offending keys and refuses.
fl native — the half of localisation that is not the ARBs
Translating an app is three jobs, and the ARBs are only the first.
The UI comes from the ARBs. The declarations do not: CFBundleLocalizations
in Info.plist and res/xml/locales_config.xml on Android. Neither translates
anything — they are what puts every language in the App Store product page's
Languages row and the per-app language picker in Settings. With the iOS key
absent the bundle reads as English-only, so an app translated into 31 languages
advertises one in every storefront. The permission alerts do not either:
NSCameraUsageDescription and its siblings are drawn by iOS itself, from the
bundle, before the Dart isolate exists. No runtime lookup can reach them. The
only way to translate them is one <code>.lproj/InfoPlist.strings per language,
registered with the Xcode target — an unregistered file is never copied into the
bundle, which looks exactly like never having translated it.
All three rot silently. Nothing fails; the app just stops being translated where
nobody looks. So fl native generates them from the locales the project already
ships (read from the generated Dart, so it needs no ARB checkout, no token and
no network):
fl native # write everything
fl native --check # exit 1 if anything is stale — for CI
fl native --dry-run # list what would change
fl native --init-copy # scaffold the permission-copy file
The permission sentences live in ios/Runner/InfoPlist.copy.json, keyed by
locale:
{
"en": { "NSCameraUsageDescription": "Trace over what your camera sees." },
"fr": { "NSCameraUsageDescription": "Décalquez ce que voit votre appareil photo." }
}
They are not dashboard keys. Nothing in that file ever reaches Dart, so
putting it through the ARB pipeline would buy nothing and cost a translation
round-trip; translate it once and commit it. A locale in the app with no entry
here is a hard failure rather than a silent fallback to English — a fluent-
looking alert in the wrong language is the bug this command exists to prevent.
--init-copy writes a starter from whatever your Info.plist says today.
fl native also writes locales_config.xml if the project has none and points
<application android:localeConfig> at it, since a file nothing references is
the same as no file.
First-time setup on a new machine or project
The backend pushes generated ARBs to a git repo you link in the dashboard (Project → Git).
fl pull only updates a repo that is already on disk, so bootstrap with clone:
fl repo --project "V-Shape" # → https://github.com/you/shape_arbs (github)
fl clone --project "V-Shape" # clones it into arb_dir (default: ./arbs)
echo '/arbs/' >> .gitignore # the backend owns that repo; don't vendor it
Then point l10n.yaml at the clone and run codegen:
arb-dir: arbs/Default
output-dir: lib/localization/generated
output-localization-file: app_localizations.dart
nullable-getter: false
fl repo reads the linked provider off the project detail endpoint (github, gitlab,
bitbucket or bitbucket_server) and warns when the git token needs reconnecting. --json
gives {project, repository: {provider, url, needs_reauth}}; it exits non-zero when no
provider is linked. fl clone [<dir>] refuses to clobber a non-empty directory — use
fl pull to update an existing clone. Preview either with --dry-run. If the clone never
happens, or the machine's git credential cannot see the repo, fl pull still works: it
writes the ARBs from the API.
fl guard writes permissions.deny rules into .claude/settings.json so Claude Code refuses
to Edit/Write the backend-managed files — the ARB directory (arb_dir + l10n.yaml
arb-dir/output-dir) and lib/generated_translation_methods.dart. Those may only change via
fl / flutter_localisation + fl pull; a direct AI edit is silently overwritten on the next
sync (or lost, for the gitignored ARB repo). Run it once per project (--dry-run to preview).
A project-local flutterlocalisation.json is optional — set it to avoid --project each
time:
{ "project": "Chat Bot", "flavor": "Default", "arb_dir": "widget_chat_arbs" }
xcstrings_dir (optional) is where fl pull writes the iOS String Catalogs, relative to the
config file like arb_dir; an iOS-only app points it at its Xcode resources folder. Without
it the catalogs go beside the ARBs.
Global flags: --project <name|id>, --flavor, --config, --dry-run, --json.
MCP server (Claude)
fl_mcp lets Claude manage your translations. Mutating tools are preview-by-default — they
only write when Claude passes apply: true.
Claude Code:
claude mcp add flutter-localisation --env FL_API_TOKEN=flk_live_xxx -- fl_mcp
Claude Desktop (use the absolute path — Desktop's PATH often omits ~/.pub-cache/bin):
{
"mcpServers": {
"flutter-localisation": {
"command": "/Users/you/.pub-cache/bin/fl_mcp",
"env": { "FL_API_TOKEN": "flk_live_xxx" }
}
}
}
Then ask: "list my projects", then "in Chat Bot, add checkout_button = 'Buy now' and
translate it to all locales." Tools: list_projects, list_status, list_languages,
add_language, project_repo, add_string, edit_string, delete_string, translate_key,
retranslate, import_arb, pull_arbs, glossary, language_style, lock_keys (each
takes an optional project/flavor), and
sync_native_locales — the MCP face of fl native, which needs no token and takes an optional
project_root. project_repo returns the linked ARB git repo so the agent can clone it before
codegen; pull_arbs writes the ARBs into arb_dir (and the String Catalogs into
xcstrings_dir) the same way fl pull does. import_arb takes an .xcstrings too.
How strings reach your app
fl add / Claude ─▶ backend creates/translates ─▶ backend pushes ARBs to your git repo
│
fl pull / fl sync ◀───┘ ─▶ codegen ─▶ typed strings
License
MIT.
Libraries
- flutter_localisation_cli
- Programmatic access to FlutterLocalisation — the shared core behind the
flCLI and thefl_mcpMCP server. Pure Dart (no Flutter), so it runs anywhere Dart does.