flutter_localisation_cli 1.14.3
flutter_localisation_cli: ^1.14.3 copied to clipboard
CLI (fl) and MCP server (fl_mcp) for FlutterLocalisation: add, edit and AI-translate ARB keys from the terminal, then pull ARBs or iOS String Catalogs.
Changelog #
1.14.3 - 2026-11-01 #
Changed #
fl editsends a String Catalog value as typed (%@,%1$lld) and lets the service convert it, plural forms included, so the CLI, the dashboard and the API share one conversion.
1.14.2 - 2026-11-01 #
Fixed #
fl editon a String Catalog key takes the value as written in Xcode (%@,%1$lld) and stores it with the service's argument names, instead of refusing anything but{arg1}.fl edit --description ""clears the description. An empty value from a tool call is still ignored, so an agent filling unused arguments cannot wipe descriptions.
1.14.1 - 2026-11-01 #
Fixed #
- An import with
--overwriteno longer says its keys still hold the base text in other locales; overwriting the base text leaves translations alone.
1.14.0 - 2026-11-01 #
Changed #
fl import --translateno longer waits on the request. The server queues the translation and the CLI follows the job, printing how far it is. Large imports used to end on "Gateway Time-out" while the keys had in fact landed. Older servers keep the previous behaviour.fl languages addadds every locale in one request and follows the translation as a job with progress. 36 locales were 36 requests, eleven silent minutes with--auto-translate, and without it the API throttle refused everything past twenty. A single locale, and servers without the bulk endpoint, keep the per-locale call, now printed as each one lands.- Strings that come back identical to the source (names, loanwords) are reported apart from real failures, with what to do about them.
- An import without
--translatesays when its new keys still hold the base text in other locales.
Fixed #
"project": 129influtterlocalisation.jsonis read as the project id instead of crashing with a cast error; a value of the wrong type names the field.fl translateno longer lists en-GB, en-US and the other variants of the base language as "came back identical to the source"; reading like the base is their correct translation.fl exportno longer refuses to run because of the empty key Xcode extracts fromText("").fl languages add --auto-translateno longer claims that keys added later are translated by themselves; they are translated by the import that adds them.
1.13.0 - 2026-10-27 #
Added #
- Glossary.
fl glossary list|add|remove|import|export|suggest|apply. A term is kept as written in every locale (--keep, for brand names) or pinned to a translation per locale (--as de=Schnipsel). The backend sends the terms of each batch to the translator, refuses a translation that changes a kept term, and marks for review one that skips a pinned term.applyre-translates the strings that use glossary terms. - Language style.
fl style list|set|clear: formal or informal address per locale, plus instructions. Regional variants follow their language. - Locked keys.
fl lock <key>/fl unlock <key>: the key keeps the base text in every locale and is never sent for translation (Xcode'sshouldTranslate: false). - MCP tools
glossary,language_styleandlock_keys.
1.12.0 - 2026-10-24 #
Added #
fl init: first-run wizard. Stores the token if none is there, picks the project and flavor, prefillsarb_dirfroml10n.yamlandxcstrings_dirfrom an existing catalog or the iOS target, writesflutterlocalisation.json, pulls, regenerates the Dart, offersfl guardandfl native.-yand--project/--flavor/--arb-dir/--xcstrings-dirrun it without a terminal.
1.11.1 - 2026-10-24 #
Fixed #
fl languages addaccepts codes glued into one argument ("fr de es",fr,de,es) instead of forwarding the whole string and getting "is not a valid locale code" back.
1.11.0 - 2026-10-22 #
Changed #
fl loginchecks the token before saving it. A token the API rejects is not written, so a stale credentials file can no longer sit there until the next command fails with a bare 401.--no-verifystores it anyway.- A 401 now says where the rejected token came from (
--token,$FL_API_TOKENor the credentials file) and how to replace it. - Catalog keys by their stem.
fl edit done_bodyfindsdone_body %@ %lld; a stem shared by two keys is refused with both names, and an unknown key lists the closest matches.fl deleteandfl translateresolve the same way. fl edit --valuerefuses a value that drops one of the key's placeholders ({arg1},{count}...), naming them and the base value, instead of letting a format string go out without its argument.fl projectsprints each flavor's id, in text and--json, so the id a direct export call needs is no longer hidden.
1.10.0 - 2026-10-22 #
Added #
- Native iOS projects. A project can ship Flutter, iOS or both (platforms
are set in the dashboard). For a project that ships iOS,
fl pullandfl exportwriteLocalizable.xcstrings(andInfoPlist.xcstringswhen the project holds Info.plist keys) intoxcstrings_dir, bytes as the backend serves them.fl importaccepts an.xcstrings, with no--language: the catalog carries every locale. The local-only guard covers the catalog, the git mirror cleans*.xcstrings,fl syncskips codegen for an iOS-only project, andfl projects/fl statusshow the platforms. The MCPimport_arbandpull_arbstools do the same. - New config key
xcstrings_dirinflutterlocalisation.json.
1.9.0 - 2026-10-18 #
Fixed #
fl importreports what was written, not how many keys the file had. Importing a locale's translations afterfl languages addprinted "Imported 191 key(s)" while every key was skipped, because each row already held the seeded base text. The backend now replaces those stand-in rows in a single-locale import, and the CLI prints written / skipped / identical counts from the server, with the skipped keys and the--overwriteremedy. Older backends keep the old wording.- Placeholder types come from the base language. A translated ARB has no
@keymetadata, so its placeholders were stored and exported asStringwhile the template saidint, andflutter gen-l10nrefused the build.fl export/fl pull(API path) now type every translated locale's placeholders as the base types them. - "Project not found" names the workspace the token searched, and says a token only reaches the workspace it was created in.
Changed #
- Variants of the base language are not "untranslated" when they match it.
fl statuscountsen-GB/en-USrows identical toenas done and labels the locale(variant of en);fl retranslateskips them unless the locale is named or--include-variantsis passed (include_variantson the MCP tool). Values with no words in them ({count},100%) are never flagged.
1.8.0 - 2026-10-15 #
Fixed #
arb_dirinflutterlocalisation.jsonis relative to that file, not to the shell's working directory. Runningfl pullfrom a subfolder (or from an IDE task whose cwd is not the repo root) wrote the ARBs next to wherever the shell happened to be. An absolutearb_diris left as is.
Changed #
fl pullis a sync, not agit pull. The ARB directory is generated output the backend owns, so there is never a local contribution to reconcile — but the old command rangit pull --rebaseinside it, and that made every bit of local state a blocker. An editedapp_en.arb(which is exactly what the documented import flow tells you to create) stopped the pull with "cannot pull with rebase: You have unstaged changes"; a stale local commit turned it into a 36-file rebase conflict. Both ended with a user hand-running git inside a directory they are told never to hand-edit.fl pullnow fetches and hard-resets onto the upstream branch, so neither can block it, and parks anything it discards underrefs/fl/pre-sync/<stamp>— printed, so "it threw away my edit" always has an answer.fl pullfalls back to the API when git cannot deliver. The clone is authenticated by whatever ambient git credential is active, so the wrong logged-in account, no credential helper, or a repo that was never linked all surfaced the same way:remote: Repository not found, and a dead end — while the same ARBs sat behind the API token the CLI already holds. A missing git binary, a plain directory, a missing remote and an unreachable remote now all fail over to writing the files from the API, and say so. Git also runs withGIT_TERMINAL_PROMPT=0, so a credential prompt can never hang the command (or raise a GUI keychain dialog) instead of failing.fl exportwrites exactly what the backend pushes. It used to put@keymetadata in the template only and omit@@last_modified, so the first fallback export rewrote all 36 locale files: a 19,575-line diff that was pure formatting. The two writers now agree byte for byte, and an unchanged locale keeps its existing timestamp, so re-running an export is a no-op in git. (The backend's own writer gained the trailing newline that was the last byte of difference.)
Added #
fl sync [<file.arb>]— import, pull, and regenerate the Dart in one command. Every consuming repo was documenting the same three steps in its own words, which meant every repo also documented its own way of getting them wrong.- A guard against losing keys that exist only on disk. Both refresh paths
rewrite the template from the service, so a key staged by hand for an import
that never ran would disappear — this is how eight onboarding keys were lost
in one repo, and why that repo's CLAUDE.md carried a "never run
fl exporthere" rule.fl pullandfl exportnow name those keys and refuse;fl pull --adoptimports them first,--discard-localtakes the service copy on purpose. The rule can go. - MCP
pull_arbs. The server used to tell the agent to ask the user to runfl pull— the step most likely to be skipped, by the party that just wrote the strings. The tool carries the same guard, the same fallback and the same preview/apply split as the CLI, and the server instructions now say plainly that git is never to be run in the ARB directory.
1.7.0 - 2026-08-26 #
Added #
-
The loop is refused, and the refusal names the command to run instead. A caller that runs a bulk-capable command one target at a time is stopped on the fourth such call in 15 minutes (
lib/src/call_budget.dart), with the runnable bulk form printed. The ledger is on disk, not in memory: everyflrun is a fresh VM, so an in-process counter sees exactly one call and never fires — the loop is only visible across processes. The guard sits belowOperations, so the CLI and the MCP server inherit it from one place; a guard only the CLI enforced would be one an agent routes around by calling the MCP tool. A wide call is never refused, and clears the history — penalising the correct command is the one outcome worse than not guarding at all. -
fl languages addtakes many codes.fl languages add ja ko th --auto-translatewalks the list in a single command, orders base languages before their script/region variants (zhbeforezh-Hant, which the backend requires and which callers previously had to rediscover by hitting the error), and stops on the first throttle instead of collecting one 429 per locale. The MCPadd_languagetool takescodes: []to match; the singularcoderemains for a genuine one-off. -
fl export— write every locale's ARB straight from the API.clone/pullonly work when the dashboard has a git provider linked and the repository it names exists; when it does not, a project can be fully translated while the app still ships one language, and nothing in the tool could close the gap. This is the dashboard's Export, from the terminal. The ARBs are still the backend's data —@@localematches the file name (gen_l10n aborts the build when they disagree) and@keymetadata goes in the template only. -
POST /api/translations/bulk-check/andbulkCheckTranslations, removing the last per-row loop in the pipeline — and the worst one.--accept-unchangedwalked every identical-to-source row throughupdate_translation, which regenerates and git-pushes every ARB per call: ~1,400 requests and ~1,400 ARB pushes for one pass, an hour of wall clock. Now one request. It also fixes a dead end: a row missing a plural case its locale requires could not be marked checked at all, because the single-row serializer revalidated the whole row and rejected an update touching nothing but the flag — so those rows were re-translated on every run for ever.
Removed #
fl retranslate --chunk-size. It read as a tuning knob and behaved as a quota multiplier: lowering it eightfold to clear one gateway timeout turned a 33-locale repair into ~260 requests. Chunking is now adaptive — a timeout halves the batch and retries, down to a 100-row floor. The machine can see the timeout; a human reading a help string cannot see the request count they just signed up for.
Notes #
- Prompted by a real incident on 26 Aug 2026: an agent localising a Flutter app
into 36 locales ran
fl languages add <code>34 times andfl retranslate --locale <one> --chunk-size 20033 times, and drained a 1000/day budget in under an hour. Every request was valid; only the shape was wrong. The existingSingleKeyWriteThrottledid not cover these doors. The backend now throttles them too (localization/api/throttles.py), so the wall holds even for a client that has not been updated.
1.6.0 - 2026-08-23 #
Added #
fl native— declare the app's locales to iOS and Android, and translate the iOS permission alerts. The ARBs only cover the UI. Three things sit outside them and rot silently, because nothing at runtime notices when they are wrong:CFBundleLocalizations(absent, iOS reads the bundle as English-only and the App Store Languages row says so — a 31-language app advertised one),res/xml/locales_config.xml(absent, Android never offers a per-app language), and theNS*UsageDescriptionstrings, which iOS draws itself from the bundle before the Dart isolate exists and which therefore nocontext.trcan ever reach. The command generates all three from the locales the project already ships, writes one<code>.lproj/InfoPlist.stringsper language and registers it with the Xcode target — an unregistered resource is never copied into the bundle, which is indistinguishable from never having translated it.--checkfails on drift for CI,--dry-runpreviews,--init-copyscaffolds the copy file. Offline and tokenless: a drift check should not need credentials.sync_native_localesMCP tool, the same thing for Claude, preview-by- default like every other mutating tool.
Notes #
- The permission sentences live in
<ios-target>/InfoPlist.copy.json, not on the dashboard: they never reach Dart, so the ARB pipeline would add a round-trip and buy nothing. A locale with no entry is a hard failure, not a silent fallback to the development language — a fluent alert in the wrong language reads as finished and ships. - iOS spellings are handled:
filbesidetl,zh-Hans/zh-Hantinstead of a barezhthat modern iOS will not match, and an exact locale always beats the expansion that would otherwise shadow it (Traditional Chinese was the case that mattered).
1.5.0 - 2026-08-22 #
Fixed #
- Regional and script locales are handled as one identity. Locale codes
arrived as whatever was typed, so
fl languages add pt_brcomparedpt_bragainst the project'spt-BR, found no match, and asked the API to create a locale that already existed. Every locale argument is now canonicalised to BCP-47 (pt_br/PT-BR→pt-BR,zh_hant→zh-Hant,es_419unchanged) before it is compared or sent. Junk is passed through untouched so the API's own error still names the field. - The ARB uploaded by
importcarries the filename gen_l10n reads. A variant producedapp_pt-BR.arb, which Flutter's generator does not pick up at all; it is nowapp_pt_BR.arb. add --auto-translateno longer reports a success as a failure. The server-side fill of a large project can outlive the socket, and the retry then reached a locale the first attempt had already created. That 400 is now followed by the same verify-and-report read as the happy path.- A rejected request shows the server's own reason. Errors carrying
errorrather thanmessage/detailprinted "POST /api/... failed" while the server had said exactly what was wrong.
1.4.0 - 2026-08-20 #
Changed #
- A value that comes back identical to its source is no longer reported as a
failure. It is almost always the correct answer — "OK", "Premium", "Normal",
"Start", French Nature, German Name — and calling it a failure cost more
than tidiness. It trained readers to skip the number; it hid the genuine
errors sitting in the same count; and because identical-to-source is
retranslate's selection criterion, it left those rows queued to be re-translated and re-reported on every run for ever.translate,add,importandretranslatenow reporttranslated/failed/unchangedseparately, and only a real error affects the exit code. - The backend's own per-row reasons are printed instead of summed. A count
of failures cannot distinguish a malformed plural row from a dead service;
the reason string can, and it was already being parsed and then discarded.
This applies to the whole-batch rejection path too, where the detail lives in
results.failedbehind a top-level "All translations failed."
Added #
fl retranslate --accept-unchanged(accept_unchangedon MCP) marks every value that came back identical to its source as checked, whichstatusandretranslatealready honour. This is what makes the queue closable: nothing in the pipeline previously set checked, so a deliberately-identical row could never leave the work list. Reviewers can flip any of them back withfl edit --no-checked.fl editgained the plural fields:--singular-case,--plural-case,--zero-case,--two-case,--few-case,--many-case,--plural-param,--plural. The backend stores plural cases separately and rejects a--valuecarrying the placeholder, so a bad plural translation previously could not be repaired from the terminal at all.- The client models every CLDR plural category. It had
zero,one(singular_case) andother(plural_case) only — notwo,fewormany. French, Spanish, Italian, Portuguese, Polish and Czech all requiremany; Polish and Czech also requirefew; Arabic requirestwo. Because the backend re-validates the whole row on any write, a row missing a category its locale requires could not be saved at all — not even an update touching nothing butis_checked. Those rows were unfixable through the CLI and unexplainable from its output, since the reason was being discarded.
Fixed #
import --translateno longer reports a successful import as a total failure. The import is written before the translate phase runs, so a timeout there — the likeliest outcome for a large import, and the one that actually happened with 837 keys — surfaced as a bareError: Gateway Time-outfrom a run whose keys were all safely created. Nothing said the keys were in, and nothing named the one-command remedy. It now reports what was imported, what failed, and points atfl retranslate, which chunks and therefore survives what one large request does not.- A whole-batch 400 whose rows are all identical-to-source no longer aborts
the run. "Every row in the chunk failed" is a 400 rather than a 207, and it
is the normal outcome once the only gap left is loanwords — so a project in
exactly that state could never complete a run, and therefore could never
reach
--accept-unchangedto get out of it. A rejection carrying no per-row detail is still fatal, so a genuine outage is not absorbed.
1.3.0 - 2026-07-31 #
Added #
fl guardnow teaches, not just blocks. Alongside the.claude/settings.jsondeny rules, it writes an AI-guidance block intoAGENTS.mdandCLAUDE.mdin the consuming repo, stating that the FlutterLocalisation SaaS is the source of truth and showing the correctfl add/fl edit/fl pullworkflow. A bare write refusal made agents route around the guard (writing the string elsewhere); the docs turn the block into an instruction. The block is delimited by<!-- fl:guard:start/end -->markers and refreshed idempotently, so a project's own notes in those files are preserved.- MCP server advertises
instructionsoninitialize. Any AI client connecting tofl_mcpnow receives top-level guidance (SaaS is authoritative, never hand-edit ARBs, mutate via tools thenfl pull, preview-before-apply) before it calls a tool.
1.2.1 - 2026-07-21 #
Fixed #
import --language <code>now writes ONLY that locale. It was sendingapply_to_all_languages=trueregardless, so a targeted per-locale import clobbered every other language with the file's values. A base-language import (no--language) still seeds all locales so fresh keys stay translatable.
1.2.0 - 2026-07-21 #
Added #
- ARB
@keymetadata now round-trips through the API.add/fl addandadd_string(MCP) gained--placeholder name:type[:format](repeatable) and--description, so a key can be created with typed placeholders (e.g.count:int:decimalPattern) and an ICU@key.description.edit/fl editandedit_string(MCP) gained--description.TranslationEntrynow readsdescription+placeholdersback from the backend.
Fixed #
edit_string(MCP) no longer drops placeholders. The edit dispatch previously omitted placeholder metadata entirely; it now forwardsplaceholders+is_placeholders_enabledalongside the newdescription.
1.1.0 - 2026-07-14 #
Fixed #
--translateno longer over-reports success.add/translate/import --translatetrusted the backend'ssuccess_count, which countssource == outputas translated (a silent false success). The CLI now re-fetches after translating and flags any locale whose value still equals its key's non-empty base text as failed, sotranslated N/Nnever lies.import --translate'sfailedfield is now a list (matchingadd/translate).
Added #
- Bulk ARB import —
import_arbMCP tool +fl importCLI command. Instead of many per-keyadd_string/fl addcalls, put all strings in an ARB JSON and import it in a single request (pathto a file or inlinecontentfor the MCP tool;fl import <file.arb>for the CLI).translate=true/-tthen batch-translates the other locales in one pass;overwrite/--overwritereplaces existing values;--language <code>sets the file's locale (default: base). Preview by default (apply=true/ no--dry-runto write). Backed byManagementClient.importArb()(multipart to/api/project/{id}/import-arb/) andOperations.importArb(). Requires astrings:writetoken. fl guardcommand — lock the backend-managed files against AI edits. Writespermissions.denyrules into the project's.claude/settings.jsonso Claude Code refuses toEdit/Writethe ARB directory (arb_dir+l10n.yamlarb-dir/output-dir) andlib/generated_translation_methods.dart— files that only ever change via the SaaS tooling +git pull, and whose hand-edits are otherwise silently overwritten on the next sync. Idempotent; merges into existing settings;--dry-runpreviews.
1.0.1 - 2026-07-14 #
Fixed #
fl_mcpnow finds the token fromfl login. It previously read only$FL_API_TOKENand ignored~/.config/flutterlocalisation/credentials.json, forcing users to duplicate the token into their MCP client config. It now uses the same resolution as theflCLI (explicit →$FL_API_TOKEN→ credentials file).Credentials.resolvegained an injectablecredentialsFilefor tests.
1.0.0 - 2026-07-07 #
Added #
- Initial release. The
flCLI andfl_mcpMCP server, extracted from theflutter_localisationpackage into a standalone pure-Dart package so they can be installed and run without the Flutter SDK (dart pub global runnow works, and the runtime library no longer carries CLI dependencies). flcommands:login,projects,add(--translate),edit,delete,translate,status,pull; global--project,--flavor,--config,--dry-run,--json.fl_mcp: stdio MCP server exposinglist_projects,list_status,add_string,edit_string,delete_string,translate_key— preview-by-default (only writes withapply: true), project selection by name.- Auth via a scoped
flk_live_API token; project config file is optional (workspace-level commands and--project <name>work token-only).