easyi18n_cli 0.12.0 copy "easyi18n_cli: ^0.12.0" to clipboard
easyi18n_cli: ^0.12.0 copied to clipboard

Command-line tool for easyi18n. Pulls translated localization files (Mode A, native .arb) and pushes your tr() source strings for translation.

Changelog #

0.12.0 #

  • easyi18n mcp runs an MCP server on stdio for an AI agent to drive. It takes the key from EASYI18N_TOKEN in the server's env and the project from the project_dir argument, then EASYI18N_PROJECT_DIR, then the working directory. It refuses to send the key to a baseUrl other than the easyi18n API over HTTPS or a loopback host, unless EASYI18N_BASE_URL names that exact origin. Tools: doctor, status, init, extract, pull, apply, push_preview, push, fill_status, authorize, publish, rollback and add_language; prompts: setup and translate. A push that costs credits needs the operation_id of an approved authorize {action: translate}, whose max_credits is clamped to EASYI18N_MCP_MAX_CREDITS (default 50, 0 disables paid ingests); a push that costs nothing needs no approval. fill_status reports what is still pending and settles any request that left without an answer. Every tool result is capped at 32 KiB. extract, apply, push_preview, push and fill_status read tr() calls, so they serve Flutter projects; the tools for an Xcode catalog are not in this release.

  • easyi18n --version prints easyi18n <version>, or {"version": ...} under --json.

  • A global --json flag makes init, doctor, status, extract, pull, apply, push, sync, rollback, languages add and formats add print one JSON object on stdout; warnings stay on stderr and the text output is unchanged. A failure prints {"error": {"message", "code", "statusCode", "retryable"}}, where retryable is true for transport failures and for the HTTP statuses the CLI already retried. Under --json nothing prompts: push and sync refuse to spend credits without --yes and fail with confirmation_required.

  • push and sync no longer read a confirmation from piped stdin. Without a terminal they decline and say to pass --yes, as before when the answer was not y.

  • init --stack flutter|xcode|auto (default flutter). xcode finds the project's String Catalog (Localizable.xcstrings first, InfoPlist.xcstrings never), writes catalog:, format: xcstrings and mode: a, and scaffolds nothing else; --catalog <path> names a different one. auto detects the stack and refuses a tree it does not recognize. doctor reports the detected stack and family and checks the catalog, format, mode and Xcode setup of a file-first project.

  • The MCP tools publish and rollback freeze and restore a version behind a human approval: authorize {action: publish|rollback} returns the approval_url, and the tool refuses without the approved operation_id. A rollback names its target as previous by default; the server resolves that alias when it mints the operation, so what the human approves is the version id that goes live.

  • push --json --publish works: the record gains a published block with version_id, key_count, unchanged and refused (the ICU and markup rows plus the server's totals, which exceed the rows once the 50-row cap bites). Until now the combination was refused with json_unsupported, so a CI job that published had no way to read which version it minted.

  • easyi18n languages add <code> [--fill-mode ai|manual] and the MCP tool add_language add a target language through POST /v1/projects/{id}/languages, with a key that has the write scope. Re-adding a removed language revives it. The command prints the code as the server stored it (PT_BR becomes pt-br); --json carries code, fill_mode, project_dir, base_url and next. Against a backend without the route the server answers 404 and the command fails with it.

  • The MCP doctor tool asks the server whether the key in EASYI18N_TOKEN is an Agent key (GET /v1/projects/{id}/credential) and warns when it is not: that key spends and publishes without a human approval. Against a backend without the route the check reads unverified. easyi18n doctor does not ask, because a CI key is the other valid kind.

  • A failed command on a 5xx now prints the server's correlation id (from the error body, else the x-correlation-id header) to quote in a support ticket, and a 500 gets its own hint instead of the bare internal error.

  • sync prints one line per diagnostic code the server cut from its listing, with the number of rows left out and the languages they came from; --json carries them as diagnostics_omitted. The backend now lists 10 rows per (code, language).

  • Breaking file-workflow requirements: sync with publication and Mode A push --publish now stop before preview if metadata is unreadable, malformed or lacks output-formats API version 1, even for an already configured project. Mode A push --publish now requires read in addition to translate,publish; adopting a format also requires write.

  • Remove the exported SyncRunner.fetchMeta and checkOutputFormat helpers; file workflow preflight now belongs to OutputFormatAdoption.

  • Adopt formats conditionally in file-delivery flows after budget/consent, require backend capability before preview, and publish with requiredOutputFormat. Preserve confirmed configuration changes in later errors and sync JSON. Mode B push and sync without publication retain their existing permissions. Empty Mode A pushes are covered.

  • Recover formatless pulls after a real format error, retrying the same version/lang once. Healthy pulls add no lookup; watch adopts at most once, read-only keys receive write-scope advice, and dry-run never adopts.

  • Add easyi18n formats add <id> with the write scope, project ID/alias resolution and text/JSON confirmation of the persisted format list. Repeating the command reports whether configuration changed, including materialization of legacy formats. It does not publish translations.

  • Share typed POST transport with publish/rollback while preserving their success statuses, parsers and replay policy. Invalid add responses provide add-specific recovery advice.

  • One noun for the refusal counts (#1175). push, sync and rollback report the same two counters (icuRejected / markupRejected) and printed three different words for them — translation(s), value(s) and cell(s). Since #1000 a refused row can name the base language, where the refused value is the stored source and not a translation, so two of the three were wrong for part of what they counted. All three now print cell(s), the one noun true of both natures. The number itself has not changed.

  • pull --require-complete --dry-run gates without writing (#1178). The gate runs before the writes and does not read --dry-run, so the pair is the combination a CI job wants: it answers "is the published version complete?" and leaves the working tree untouched. Behaviour is unchanged — this pins it, because a dry run used as a cheap gate that exits 0 over the state the real run refuses is the defect sync settled in #955. The --require-complete and --dry-run rows of the README say so.

  • A parse finding now names the language column it was lost from (#726). /v1/sync raises the parsers' findings per language pass and forwards the pass on each row; the CLI grouped them by code and message shape, and the messages of PLURAL_SHAPE_MISMATCH and DEVICE_VARIATION_FALLBACK name nothing (the key is in field, which the shape blanks out), so two languages losing the same string printed as one line naming neither. The line now carries — in: en, fr, capped and counted like also on:, and the language joins the identity of an already-printed row so a row only the real run carries is no longer discarded against the preview's. A row with no language — the base parse's and the LANG_* family — prints exactly the line it always printed.

  • pull --require-complete — the completeness gate a Mode A CI job can fail on (#1052). A Mode A project's translations live on the server and its publish skips the cells it has no translation for, so an incomplete locale rendered as a shorter file that no command could turn red: sync --require-complete needs a catalog: a gen_l10n project does not have, push has no tr() units to gate, pull and status never looked at per-locale coverage, and doctor skipped its grading on Mode A and printed All checks passed. over half-translated locales. The export now reports per-language coverage off the published snapshot, and pull --require-complete fails, naming each incomplete language and its missing count, when any target language is short. doctor grades the same thing on a Mode A project instead of skipping it (#1176): a short language is a FAIL, and a version it cannot judge — or a missing credential — reports "could not verify", which keeps the run from printing All checks passed. Back on the gate: pull --require-complete refuses a backend that does not report coverage ("cannot verify"), never passes it — and so refuses a version the count cannot judge: one published with incompleteMode: source (the base text is baked into every untranslated cell, so it always reads complete), one published before the server started stamping how it treated those cells, and one with no target language at all. Only a cell that renders as non-empty text counts as translated. The flag refuses outright, before any request, on a mode: other than a (a config with no mode: line at all reads as b — the refusal names adding mode: a as the fix), on --lang naming the base language, and with --watch (the gate is one-shot) — nine refusals in all, so a CI step gets a verdict or an error, never a green it did not earn. The ninth is a basis the version states and this client does not recognize, which is separate from a version that states none. doctor reads the same set the gate does — one symbol, not a copied predicate (#1248): only skip is judgeable, so source, a basis this client cannot read, and an unstamped version are all reported as unverified rather than graded, and each is reported with its OWN message and its own remedy, the same three the gate refuses on. A version whose basis this client does not recognize is told to upgrade the CLI, not to republish — republishing stamps the same basis again. A version with no target language at all is reported as nothing to grade rather than as a check that could not run (#1176). That last state is the one where the two commands answer the same version differently, on purpose: a base-only project is healthy for a health check and has nothing to gate on for a gate, so doctor passes it while pull --require-complete refuses it. doctor names the sibling's refusal in that line, so the disagreement is not something a red CI job has to be reverse-engineered from.

doctor can now exit 1 where it exited 0 — read this before upgrading. A Mode A project with a short target language used to print All checks passed.; it is now a FAIL naming each language and its missing count. Every "could not verify" arm — no credential, a 403, a version the basis cannot judge, a backend that reports no coverage, an unreachable API — still exits 0, so a job only turns red on a state that is actually incomplete. On a Mode A project doctor also makes its first authenticated request: it sends EASYI18N_TOKEN to baseUrl to read the published coverage, and warns first when that baseUrl is not the production origin, like the other commands that send the token.

A green CI job can start writing something different — read this before upgrading. sync used to tell the server that its catalog was current as of the moment it ran pull. It now sends the instant the content it pulled was published, which is earlier — often by a day, and by weeks under pull --version. That is the point: the pull renders the last published snapshot, never the live keys, so a correction made in the dashboard since that publish was never in the file. Claiming the pull moment told the server the file already carried it, and the server silently reverted the correction with no conflict row and no trace (#898).

With the correct claim, those keys now come back as base-language conflict rows. Under the default onConflict: server the project's source is kept and your file's base text is no longer imported for them; --on-conflict file keeps today's outcome and still reports the row. A plain pull refuses nothing new and adds no exit path: a conflict row is a warning, never a failure (the two new refusals in this version are both opt-in flags: sync --dry-run --require-complete and pull --require-complete, both in this section). What it can change is what the run has left to do — --require-complete still decides by what the server reports as unfilled — and it can write less than it did and print warning lines it did not. This affects a plain pull too, not only --version, and it closes the rollback → pull → sync sequence, which reproduced the same silent revert with no flag at all. A caret constraint over 0.x pins the minor, so this does not install itself into an existing job.

  • pull records what its files are not behind, and sync claims that. The server now reports snapshotAt on the pull response, and the CLI stores it in .easyi18n/state.json next to pulledAt. The two are separate on purpose: snapshotAt is the claim, pulledAt is what easyi18n status displays, and a pull --version from two weeks ago must not make status say "pulled 13 days ago" a second after it ran. (#898)
  • Nothing new can fail because of it. A server that does not report the field, a value that names no timezone, and a value of the wrong type all fall back to dating the pull by the local clock — the previous behaviour — instead of failing the pull. State written by an older CLI keeps arming the guard through its pulledAt, so upgrading never drops the claim. (#898)
  • Known residual — a version restored before the server side of this shipped. Its stored instant is the restore's, not its content's, so a pull of it still over-claims until the project publishes or restores again. That is today's behaviour, unchanged, not a new failure.
  • Known residual. sync's own final pull deliberately records no state, so after a sync that publishes, the stored claim still names the previous version. The next sync can therefore report one conflict row too many for a key the dashboard moved and that publish already carried. The direction is safe — a row, never a silent revert — and an explicit pull resets it.
  • sync --dry-run now reports exhausted and held pairs, and --require-complete gates it. The preview always carried the exhausted rows and the pendingSkipped count the real run refuses on, but the dry run returned before reading them: it said nothing where push --dry-run warns, and sync --dry-run --require-complete exited 0 in silence. It now warns naming each exhausted pair and counting the held ones and, under --require-complete, fails (exit ≠ 0): a CI job using the dry run as a cheap gate can go red on upgrade, on exactly the state the real run would have refused. What only a fill decides (a job stopping short) is still the real run's call. (#955)

0.11.0 #

Two new refusals — read before upgrading a CI job. push --publish --require-complete now fails (exit ≠ 0, nothing published) when the AI retry budget is spent, exactly as sync --require-complete already did, or when the scan finds no extractable units. A previously-green CI job may go red on upgrade: #752 means untranslated pairs would have shipped as source text; #952 means push scanned no units and could not verify the remote catalog at all. Everything else below is new output printed permanently, a new option whose default preserves today's behaviour, or a crash that becomes a success. A caret constraint over 0.x pins the minor, so none of this installs itself into an existing job.

  • A malformed baked email bundle now blocks deploy with an attributed error instead of a stack trace. Invalid UTF-8, invalid JSON, a non-object root, or a non-object tokenIndex names the exact bundle to replace or regenerate and exits 2. Filesystem and extractor failures keep their original types, while tokenIndex values remain intentionally unvalidated because this gate consumes only its keys. (#1018)
  • A 200 whose body is JSON of the wrong shape now fails with a sentence instead of a stack trace. A response that parsed as JSON but carried an array, a string, or a field of the wrong type left the CLI with an uncaught TypeError and exit 255 — from a proxy that answers with its own envelope, a baseUrl aimed at the wrong service, or a schema regression. Every success body is now read field by field: the failure names the endpoint and the field, and exits 1. This covers every HTTP response reader in the CLI, not just the API client — sync no longer dies on a diagnostic row whose alsoSpelled is not a list, and doctor no longer dies on a delivery manifest whose tokenAlgoVersions is not one (it reports the manifest as unreadable instead). No response that succeeds today starts failing — the readers accept exactly what the casts they replace accepted (a field that is absent or null still falls back to its default), and the lists the CLI never iterates stay tolerant of a stray non-string element, as they are today. Two observable changes that are not refusals: the exit code of an already-crashing response moves from 255 to 1, and a pending list holding no usable language no longer counts its unit as needing a fill. A real push or sync that fails this way adds one line: the server may already have reserved credits and queued a fill, so the line names that re-running is safe and does not pay twice. A --dry-run (and the cost preview every sync opens with) does not print it — that request is answered before the hold and before the enqueue, so there is nothing to re-run into. (#972)
  • A non-string project in the local easyi18n.lock is now treated as absent. The field is inert when the lockfile is read; extract and push consume its units, while a push writes the resolved project id. Any value that is not a string — a number, a boolean, an array, an object — now falls back to the config seed without losing units. This is a crash-to-success tolerance fix, not another refusal. (#989)
  • push and sync can no longer disagree about an exhausted pair. The server reports a pair whose AI retry budget is spent in both commands' responses, deliberately outside pending (the fill will never retry it on its own), and push read only pending — so it printed "Everything is already translated.", exited 0, and published the pair as source text, under the very flag whose job is to refuse that. sync refused correctly. Both commands now share one decision: under --require-complete the run fails listing every key [lang] before anything is published; without the flag every push surface — the real push, the zero-estimate path, and --dry-run — names the pairs on stderr instead of counting them as translated, and the completion claim is never printed over them. Retry the pairs from the dashboard; a source edit or a model change also re-arms the fill. (#752)
  • An empty push --publish --require-complete now fails before any request. With no extracted units, push has no pending/exhausted snapshot and cannot prove that the remote catalog is complete; it previously published that state and returned 0. Code-first projects are told to check --source-dir. Mode A recommends sync --require-complete only for a non-empty, supported single-file catalog:; canonical gen_l10n/ARB configs instead name the dashboard because sync cannot evaluate them. Removing the flag preserves deliberate publication of the current remote state. (#952)
  • pull now reports every replacement of your catalog's local source text, with the text it discarded. A pull run between an Xcode edit of the base language and the next sync used to revert that edit silently — in an identifier-keyed catalog (InfoPlist.xcstrings, #612) the English is prose the developer wrote, so the loss was authorship, not a derived render. The pull still applies the served value (exempting the base language is refuted in the issue: it breaks converted printf values, base plural forms, and the empty shells Xcode creates), but each pair is named on stderr as Conflict: <key> [<lang>] with one line per discarded form, so the text survives in the log and in the pull action's PR diff. Remedy: re-apply the edit in the dashboard, or in the file followed by easyi18n sync. pull --dry-run prints the same rows in the conditional, before anything is written. sync's own write-back stays quiet — the server already reports that pair on the conflicts channel. (#646)
  • sync now tells the server when this catalog was pulled, and --on-conflict decides who wins. With a dated pull the server can see that the project's text changed after your file was written, and reports the row instead of letting the upload bury it. The new option chooses the outcome: server (the default, and what every earlier version did) keeps the project's text and names the row; file imports the catalog's over it — including over a source correction made in the dashboard, which is the one case worth reading twice before switching. The claim is only sent when the recorded pull describes this file (catalog and format both match the config), so a config switch cannot arm the guard with a timestamp that dates another file, and a repo that only ever runs sync — the CI flow — sends nothing and is served exactly as before. A row on the base language is about the key's source, and every line that describes one now says so rather than calling it a translation; under --no-publish those rows are counted apart instead of folded into "N kept value(s)". A pulledAt in .easyi18n/state.json written without a zone is dropped rather than guessed — reading it as process-local time moves the instant west of UTC and would silently revert an in-app source edit made inside that window. Known residual: pull --version <old> dates the state with now (#898). (#671)
  • A malformed served entry no longer crashes the xcstrings merge. If the server delivered a key whose value was a bare string instead of an object, pull and sync's write-back died on a TypeError mid-merge (present since 0.8.1) and the catalog was left unwritten. Such an entry is now skipped and the local entry is kept verbatim, under both key policies — there are no translations inside a scalar to apply, and a payload the CLI cannot read is never a reason to overwrite the developer's file. (#966)
  • sync states which languages it is about to translate, before the credit estimate. The estimate has always covered the project's configured, non-manual languages and nothing else; it just never said which those were, so there was no moment at which the number could be checked against the intent. Target languages (N): … now precedes it (#592).
  • The two ways your catalog and your project can disagree are named, with the remedy. A language the project configures that your catalog has no entry for is translated from scratch and paid for — and when your catalog spells the same language differently (project cs-cz, file cs), both spellings are named and neither is changed for you: which one is meant is yours to decide. A locale your catalog carries that the project does not configure is reported too; it was always ignored, it just said so nowhere. One grouped line per kind, never one per locale — and never the same locale twice across the two moments.
  • After the pull, sync reads the catalog it just wrote and warns by name for any configured language missing from it, and for any locale in it that the project configures nowhere. This is the check that could not be fooled: a language with nothing to deliver produces no entry at all, and the pull merges into your file rather than replacing it, so a locale you already had survives untouched and used to read as one we produced. manual-fill languages are excluded — nothing was going to translate them, so naming them would contradict the line above that just excused them. A catalog whose every entry is shouldTranslate: false is excluded too: no fill will ever put a locale in one, so "re-run the sync" would be advice that can never work. Warnings only; --require-complete is still the flag that makes CI red.
  • A backend that does not say what the project is configured for gets none of this rather than a guess. An empty target list is not that case — it is a project whose only language is its base, and it still hears about the locales its catalog carries that nobody configured.
  • A grouped parse-diagnostic line no longer speaks for a row it does not describe. One code can print several lines — one per message shape — and past a cap of five shapes the extra rows folded into the LAST line printed, taking that line's wording with them. Since the server now emits two different consequences under DUPLICATE_PLURAL_FORM (#878), a sixth shape could tell an operator that a value was truncated when it was in fact stored intact. Rows past the cap now get their own line, which quotes no row's message and names the keys it covers up to the same cap every other line uses, counting the rest. That line says how many rows it stands for and nothing else about them, and the count a line prints is now the rows on that line — it used to be labelled "for this code in total" while counting one message shape. Output-only; no run changes result.

0.10.0 #

Behaviour changes — read before upgrading a CI job. Five of the fixes below change what a run that passes today does. sync now keeps a newer server-side translation instead of overwriting it with the file's (--on-conflict file restores the old default, #550), and it fails where it used to exit 0: a key lost to a concurrent sync (#564), a fill that stopped short (#602), and pairs already held by another fill under --require-complete (#591). An output format the project has not enabled is now caught before the run spends anything rather than after (#530). Hence the minor bump — a ^0.9.0 constraint will not pick this up on its own.

  • A busy backend no longer kills the run on the first try. Firestore's write-bandwidth throttle is quota-wide — it is a property of the database, shared by every client of it, not of your catalog — and a large first-time sync is exactly the run that meets it. It used to arrive as an opaque 500 and end the job, with the project half-applied; the server now reports it per entry, so the keys that landed keep their work and sync retries just those entries with exponential backoff and jitter instead of re-posting instantly. A run that meets the throttle leaves the translation for that retry rather than queuing one it might not be able to take back. GET requests (the fill poll, the pull) replay a transient failure as well — the poll runs after the credits were spent, so dying there used to lose a paid job, and the cost preview retries too — losing a whole run to a busy moment before a single write had been attempted was the cheapest failure to avoid. What is never repeated automatically is a request that may have changed something: with no answer from the server, "it failed" and "it is still being applied" are indistinguishable, and a run that failed after reserving credits can still be holding them — an automatic retry would then report a balance problem you do not have. In that case the run says the service was busy and re-running picks up exactly what is left. (#591)

  • A run whose pairs are held by another fill no longer passes for complete. When every pair the catalog needs is already marked as being filled, the server has nothing to queue and accepts instantly with no tracking token — so the wait loop never started, --require-complete passed, and the publish minted a version without those cells. The response now reports how many pairs it skipped for that reason; sync warns on every run and refuses to publish under --require-complete. (#591)

  • A translation the markup gate refused is now named in the output. The publish response carries two rejection lists and the CLI read one: a markdown or html translation that lost the source's structure (a dropped ** span, a mangled <a>) was refused at publish and reported in markupRejected, which nothing printed. push showed a successful publish and sync a clean run, while the string you wrote never reached users — under push the base language shipped in its place, under sync the locale simply had no value for that key. Both commands now print the markup rejections under their own heading, next to the ICU ones: same consequence, different fix. Neither list changes the exit code — a version that is missing some cells is still a version, and --require-complete governs untranslated pairs, not a gate refusal. Both counts are now the server's own: the arrays are capped at 50, so the CLI reported "50" on exactly the publishes with the most damage. (#609)

  • sync checks the output format before it spends anything. A project that has not enabled the format in format: still translated, charged and published, and only then failed on the pull with format "xcstrings" not configured for this project — credits gone, a version cut, and no catalog written. The format is now checked against the project's configuration up front, so the run stops before the first upload and says what to enable. --max-credits answered "is this run too expensive?" but never "can this run deliver anything at all?". Skipped by --no-publish, which never pulls. (#530)

  • sync no longer reverts a translation the project produced after your catalog was pulled. The per-string decision was "the file differs, so the file wins", and a dashboard edit, an AI fill or a reviewer's sign-off made between your last pull and your next sync went back to the file's older text — counted as "imported", with nothing in the log. It is now kept on the server and reported, one warning per key [lang] naming who wrote it and when (also under --dry-run); the pull at the end of the run brings the kept value into your catalog, so the PR diff is where you review it (under --no-publish that pull is skipped and the CLI says so — the kept value stays server-side and the conflict reports again on the next run). Pass --on-conflict file to make the catalog win anyway — the pair is still reported, including the human sign-off it wipes. A value that came from a file in the first place is still yours to change from the file, silently, as before. Conflicts are reported off every request, not just the cost preview, so one that only appears once the run starts is named too, and the summary counts the server's total rather than the rows it listed. (#550)

  • push now tells you when a string was skipped because its key is switched off. A key switched off in the app is excluded from published output, so the backend never translates it — but the push looked clean while those strings silently stayed untranslated. It now warns, and says what to do (re-enable the key). Unlike the source-verification gate this is not a deferral: nothing will translate them until someone switches the key back on. The same push also stopped being billed for those keys. (#563)

  • sync no longer exits 0 after losing a key to a concurrent sync. When two CI jobs sync at the same time, the one that loses a key's create gets that entry back as contended instead of a 500. sync now re-POSTs the catalog (up to twice) so the entry lands on the key the other run created, and fails the run if it is still unwritten — the two jobs may carry different catalogs, in which case the peer's write is not yours and a green job would drop this branch's strings until someone pushed again. (#564)

  • sync now reports the strings your code no longer references. A String Catalog carries Xcode's own verdict per phrase (extractionState: "stale" — its compiler pass over your sources could not find the string), and nothing read it, so there was no way to see which of your keys were leftovers from a reworked screen. The summary now ends with N no longer in your code, and the dashboard flags those keys with a "Not in code" badge and a filter to review them in bulk. It is a signal, not an action: nothing is disabled or deleted, and nothing stops publishing, because stale is a conjecture — a string reached through a computed key is marked stale while still on screen, so the call is yours. (#558)

0.9.0 #

  • pull now writes to the catalog: you configured. It resolved its destination from output: alone, so a project whose catalog lives at EscapeCall/Localizable.xcstrings got a stray lib/l10n/ tree instead — in an Xcode project with no lib/ at all — while the run reported success and recorded the wrong path as fact. catalog: names one file and now decides where pull writes it; output: stays what it always was, the directory for formats that render one file per locale.

  • pull merges into a String Catalog instead of replacing it. A .xcstrings is written by two tools, and only one of them is us: Xcode owns the file's structure, key order, per-key metadata (extractionState, isCommentAutoGenerated) and every key added since the last publish. Overwriting it deleted them, and only an Xcode rebuild brought them back. The pull now injects the served translations into the local file and re-prints it in Xcode's exact style — the same merge sync has done since 0.8.1, with one difference: a key that exists only on the server is added here, since a pull is the only way a key created in the dashboard reaches your file. A pull that changes no translations is now a byte-exact no-op, including the first one on a fresh checkout. The trade is that a pull no longer removes anything: a key deleted from the project stays in your catalog until Xcode's own extraction pass drops it, which is the right owner for that decision but worth knowing.

  • Unknown settings in easyi18n.yaml are reported. ouput: used to be read past in silence, leaving the run on a default. It is now a warning naming the key and listing the ones the CLI reads — a warning and not an error, so a config written for a newer CLI keeps working on a pinned one.

  • catalog: is refused with a per-locale format. It names one file holding every language, so pairing it with a format that renders one file per locale is a contradiction — and a silent one when the project has a single language, because then exactly one file is served and that locale's render would land inside the catalog reporting success. The run now stops before the request, naming both settings.

  • --lang is refused with format: xcstrings. A String Catalog holds every language in one file, and a single-locale render drops the base values its keys are addressed by — it would have merged nothing while reporting success.

  • easyi18n status now sees a catalog pull (it recorded nothing before).

  • push now checks --lang against the project before spending a request. The server does not reject an unknown code — it intersects the list with the project's targets, so a typo is dropped silently and the run reports success having translated nothing for it. That silence is what this catches: an unknown code now fails locally with the configured targets listed. Naming only the base language fails too (the base is never a fill target, so the server answers no_target_langs), and a language set to manual translation is refused when named explicitly — the server's one real rejection here — or reported when it is merely part of the default set. Codes are matched the way the backend stores them, so pt_BR and pt-br are the same language.

  • push --publish says what will happen to strings that are still untranslated, instead of describing both outcomes and committing to neither: /meta now carries the project's integration mode.

  • push --publish warns when the local mode: and the dashboard's project mode disagree. They are set independently and never synced, and until now nothing could see both halves at once.

  • All three degrade quietly: push needs the translate scope and /meta needs read, so a narrower key keeps working exactly as before.

  • push --publish on a Mode A config (mode: a) now publishes with incompleteMode: skip explicitly. Baking source text into the pulled files marks untranslated keys as done forever — a TMS workflow's own untranslated report is the signal that matters. Other configs keep omitting the field; the server now resolves an omitted value from the project's declared integration mode (skip for TMS, source otherwise).

0.8.1 #

  • sync now merges translations into your catalog instead of replacing it. Opening a catalog in Xcode rewrites the file — its own " : " spacing, a localized key ordering, entries re-synced from code, and extractionState metadata. Replacing the file with the server's rendering reverted all of that, so Xcode and the sync PR would reformat each other's work forever. The local file now owns structure, order and metadata; the sync only injects the translated localizations per key, serialized in Xcode's exact style (pinned byte-for-byte by a golden captured from a real Xcode save). A converged catalog round-trips byte-identical.

0.8.0 #

  • New easyi18n sync — continuous i18n for file-first projects (iOS String Catalogs first). Point catalog: in easyi18n.yaml at your Localizable.xcstrings and one command does the whole loop: upload the catalog, translate only what changed (translations the file already carries are imported verbatim, never re-charged), publish, and write the translated catalog back onto the same path. Built for CI: --yes, --dry-run, --max-credits, --no-publish, --require-complete. Re-running on an unchanged catalog is a byte-exact no-op, so a workflow can run it on every push and only open a PR when git sees a diff. Keys removed from the catalog are disabled server-side (and re-enabled if the phrase returns) — nothing is ever deleted. Pairs whose AI retry budget is spent are reported separately with a dashboard pointer instead of spinning the retry loop.
  • publish (used by push and sync) understands the backend's new skipIfIdentical answer: identical content returns the current version as unchanged instead of minting a new one.
  • The Run 'flutter gen-l10n' hint after a pull now only prints for arb pulls — it made no sense after pulling an .xcstrings or .po.

0.7.1 #

  • Installable in a Flutter project again. The analyzer floor was ^13.3.0, but analyzer >=13.1.0 requires meta ^1.18.3 while the Flutter SDK pins meta 1.18.0 — so dart pub add dev:easyi18n_cli, the documented install path, failed version solving in every Flutter app. The floor is now >=13.0.0 <14.0.0; nothing in the CLI used an API newer than 13.0.0. This hit Mode A users hardest: they only run pull, and the analyzer is used solely by push/apply. Global activation (dart pub global activate easyi18n_cli) was, and remains, unaffected.

0.7.0 #

  • push --publish is no longer all-or-nothing. A string whose AI fill failed (e.g. a transient backend error dropped its chunk) used to abort the whole publish, holding every completed translation hostage. The CLI now re-queues failed strings up to 2 times (a failed fill releases its hold, so the retry only charges work that was never paid for) and, if stragglers remain after the retries or the 10-minute wait, publishes anyway with a warning — the missing cells serve the source text until a later push --publish fills them. Pass the new --require-complete flag to restore the previous fail-instead-of-publishing behaviour.

0.6.0 #

  • Fix --max-credits units. The API reports estimates and balances in millicredits (1 credit = 1000 mc), and easyi18n push compared the raw mc value against --max-credits — so a documented-as-credits ceiling was enforced 1000× too strictly (--max-credits 500 really meant 0.5 credits). The flag is now read in credits (decimals allowed, e.g. --max-credits 0.5) and converted at the edge. Cost previews, confirmation prompts, and abort messages now print credits (~107.3 credit(s)) instead of raw millicredits. If you were compensating by passing mc-scale ceilings, divide them by 1000.

0.5.0 #

  • easyi18n pull now records what it writes (.easyi18n/state.json) and, on the next full pull of the same format and output dir, removes recorded files the server no longer serves — e.g. a locale renamed to its platform-canonical filename (app_zh-hant.arb → app_zh_Hant.arb), sweeping stranded .tmp files and emptied dirs along. A --lang pull never prunes; a format: or output: switch lists the old target's stranded files instead of deleting across targets; --dry-run previews removals. The first pull that records its list warns about files it cannot attribute instead of deleting them.

0.4.0 #

  • easyi18n push --publish: after the push, waits for the AI fill to complete (polling the dry-run estimate) and publishes a new version via the public publish endpoint — no dashboard needed. --approved-only treats unapproved translations as missing when publishing. Needs an API key with the publish scope.

  • New easyi18n rollback [version]: restores a prior published version as a NEW version (history is never rewritten). With no argument it restores the version just before the current one.

  • New easyi18n doctor: read-only health check of the whole integration. Verifies the local wiring (config, easyi18n dependency, assets/easyi18n/ floor, com.apple.security.network.client in both macOS entitlements, Easyi18nScope mounted), lints your tr() source strings as ICU (malformed ICU renders raw to users), and actively probes delivery — manifest reachable, CORS served, then your tr() scan graded against the published bundles: missing (never pushed/published), untranslated per locale, and unused keys. Bundles are integrity-checked against their content address (a stale CDN object fails doctor the same way the runtime rejects it), a 200 that isn't a manifest is a failure (SPA catch-all / captive portal), and a CORS header locked to one origin is surfaced instead of passing as "present". Exit 1 on blocking problems; warnings don't block.

  • push / extract: a tr() call whose ctx: is not a string literal is now reported as dynamic instead of being registered as the (wrong) no-ctx unit.

0.3.0 #

  • easyi18n init is now one-command project setup: besides easyi18n.yaml it adds the easyi18n: dependency and the assets/easyi18n/ offline floor to pubspec.yaml (creating the dir), ignores the derived .easyi18n/ state in .gitignore, grants com.apple.security.network.client in BOTH macOS .entitlements (delivery fetches fail silently in the sandbox without it), and wires Easyi18nScope around a trivial runApp(...) (printed as a snippet when main.dart is not trivially patchable). Idempotent; new --dry-run flag reports without writing. An existing easyi18n.yaml is now kept (re-run friendly) instead of an error — --force still rewrites it.

0.2.0 #

  • easyi18n push: statically extracts tr() source strings from your source tree (canonical context.tr('…') and the '…'.tr() sugar), diffs them against a committed easyi18n.lock, previews the credit cost, and registers + translates them via the backend. Flags: --dry-run, --yes, --max-credits, --prune, --lang, --source-dir.
  • easyi18n extract: the same scan, offline and read-only: reports extractable strings, new/orphaned vs the lockfile, and dynamic (non-literal) calls. --fail-on-orphans for CI drift checks.

0.1.0 #

  • Initial CLI (easyi18n) for Mode A (native .arb).
  • easyi18n init writes an easyi18n.yaml config.
  • easyi18n pull downloads translated files from the authenticated backend (GET /v1/projects/{id}/translations) and writes them to disk so you can run flutter gen-l10n. Supports --version, --lang, and --dry-run.
0
likes
140
points
449
downloads

Documentation

API reference

Publisher

verified publisherliontude.com

Weekly Downloads

Command-line tool for easyi18n. Pulls translated localization files (Mode A, native .arb) and pushes your tr() source strings for translation.

Repository (GitHub)
View/report issues

Topics

#i18n #l10n #translation #cli #arb

License

MIT (license)

Dependencies

analyzer, args, crypto, dart_mcp, http, path, unorm_dart, yaml

More

Packages that depend on easyi18n_cli