This release removes deprecated flags, changes several exit codes, and moves machine-readable output from stderr to stdout. Nothing here is a silent change of meaning: every item below either fails loudly or changes a value you can see.
If you only read one section, read Exit codes that moved. It is the change most likely to alter what your CI pipeline decides, and unlike a removed flag it does not announce itself.
- Requirements
- Removed flags and config keys
- Exit codes that moved
- Machine-readable output moved to stdout
- Output that scripts parse
- Files written to disk
- Tagged translation output
- TypeScript consumers
- Upgrade checklist
Node.js 24.15.0 or later — up from 20. The cache now uses Node's built-in
node:sqlite instead of the better-sqlite3 native addon, so there is no
compilation step and no ERR_DLOPEN_FAILED after a Node upgrade, but the runtime
floor is higher. 24.15.0 is the release where node:sqlite stopped emitting an
ExperimentalWarning, which would otherwise reach stderr on every cache-backed
command. Running under an older Node fails fast with a one-line error and exit 6
rather than crashing later.
The package is published as @deepl/cli. The command is still deepl.
# 1.x
npm install -g deepl-cli
# 2.0.0
npm install -g @deepl/cliYour cache and config are untouched by the rename. The cache database is upgraded in place on first open: rows whose keys can no longer be reached are dropped and every other namespace is left alone, so the first translation after upgrading may be a miss where it used to be a hit.
All of these fail immediately, so nothing here can pass silently.
| Removed | Replacement | Symptom |
|---|---|---|
translate --enable-beta-languages |
Delete it — beta languages are part of the regular language set | error: unknown option, exit 6 |
sync init --source-lang |
--source-locale |
error: unknown option, exit 6 |
sync init --target-langs |
--target-locales |
error: unknown option, exit 6 |
tms.auto_push |
Run deepl sync push after deepl sync |
ConfigError, exit 7 |
tms.auto_pull |
Run deepl sync pull before deepl sync |
ConfigError, exit 7 |
tms.require_review |
Preview with deepl sync pull --dry-run |
ConfigError, exit 7 |
tms.api_key |
TMS_API_KEY environment variable |
ConfigError, exit 7 |
tms.token |
TMS_TOKEN environment variable |
ConfigError, exit 7 |
auth set-key <KEY> (the key as an argument) |
auth set-key --from-stdin, or deepl init |
ValidationError, exit 6 |
# 1.x
deepl sync init --source-lang en --target-langs es,fr
# 2.0.0
deepl sync init --source-locale en --target-locales es,frThe three tms: keys were on the config allowlist and in the documentation but no
code ever read them, so a review gate you configured was doing nothing. They are
rejected by name — with the replacement in the error's suggestion — rather than as
unknown fields, so the message cannot be mistaken for a typo.
# 1.x — accepted and ignored
tms:
enabled: true
server: https://tms.example.com
auto_push: true
auto_pull: true
require_review: true
# 2.0.0 — remove all three; push explicitly instead
tms:
enabled: true
server: https://tms.example.comdeepl auth set-key <KEY> warned as deprecated on every use since 1.0.0 and now
exits 6. A command line is readable by other users through process listings and is
recorded in shell history, where it outlives the process indefinitely.
# 1.x — worked, with a warning on stderr
deepl auth set-key YOUR_API_KEY
# 2.0.0 — pipe it in
echo "YOUR_API_KEY" | deepl auth set-key --from-stdin
# 2.0.0 — or from a file, which also keeps it out of shell history
deepl auth set-key --from-stdin < ~/.deepl-api-key
# 2.0.0 — or be prompted, with masked input
deepl initIn CI, replace the argument with a pipe from the secret, or skip the command and
export DEEPL_API_KEY instead:
# 1.x
- run: deepl auth set-key "$DEEPL_API_KEY"
# 2.0.0
- run: echo "$DEEPL_API_KEY" | deepl auth set-key --from-stdinRedirected stdin without the flag is unchanged, so deepl auth set-key < keyfile
still works. The rejection message never quotes the value you passed, so a broken
CI job does not print your key into its log.
tms.api_key and tms.token were accepted in .deepl-sync.yaml with a warning
through 1.x, and are now refused at config load, which fails every sync
subcommand rather than one run.
# 1.x — accepted, with a warning on stderr
tms:
enabled: true
server: https://tms.example.com
project_id: my-project
api_key: sk-abc123
# 2.0.0 — remove it; set TMS_API_KEY in the environment instead
tms:
enabled: true
server: https://tms.example.com
project_id: my-projectexport TMS_API_KEY=sk-abc123 # or TMS_TOKEN for bearer authRotate any credential you have committed. .deepl-sync.yaml is a committed
file, so a credential that was ever pushed is in every clone and fork of the
repository and survives its own deletion from the file — deleting the line does not
remove it from git history. The environment variable already took precedence
wherever both were set, so nothing that worked before depends on the file.
One behavioural consequence: the TMS destination-trust prompt
previously applied only to environment-supplied credentials, on the reasoning that a
credential inlined in the same file that chose the destination leaked nothing of
yours. Now that every credential comes from the environment, every destination is
checked, so a tms.server host you have not approved will prompt (or exit 7 where
there is no terminal) on a project that previously ran unprompted with an inlined
key. Approve it once with deepl config set tms.allowedServers <host>.
Also gone: the usage command's "Speech-to-Text Usage" section and
speechToTextMilliseconds* fields, following the API's deprecation of
speech_to_text_milliseconds_count/_limit.
A config 1.x accepted can now be refused at load, which fails every sync subcommand
rather than one run. All of these exit 7 with the offending value named:
| Now refused | Fix |
|---|---|
A source_locale or target_locales entry that is not a BCP-47 tag (1.x checked only three forbidden substrings) |
Spell the locale as a language tag: en, pt-BR, zh-Hans |
A target_path_pattern containing a .git or .github path segment |
Move the target out of those directories |
A target path that begins with - |
Rename it, or prefix the pattern with ./ |
A bucket include glob that resolves outside the project root |
Keep globs inside the repository |
--locale <value> not listed in target_locales |
Add it to target_locales, or fix the spelling |
.deepl-sync.yaml is discovered only up to the repository boundary. 1.x walked to the
filesystem root, so a config in an ancestor directory outside the repo was adopted as
project root. If yours lived there, sync now reports no config at all — move it inside
the repository.
Most of these are conditions that used to exit 0 while losing or skipping work, saying so only in a log line. A few move the other way, where 1.x failed on input it should have accepted. In both directions the old code was the wrong answer.
| Command | Condition | 1.x | 2.0.0 |
|---|---|---|---|
| any command | Unknown subcommand, unknown option, invalid --choice value, missing argument |
1 | 6 |
sync |
Target file unreadable or unparseable | 0 | 12 |
sync |
Key could not be written into the target | 0 | 12 |
sync |
Validation error on a translation | 0 | 12 |
sync |
File containing an empty string value | 12, every run | 0 |
sync validate |
Target file cannot be read | 1 | 8 |
sync validate |
PO/XLIFF translation with a placeholder or ICU error the check previously could not see | 0 | 8 |
sync push / pull |
TMS unreachable | 1 | 5 |
sync --force |
Cannot prompt (piped stdin, cron, hook, --no-input) |
0 | 6 |
watch |
Session recorded any failure | 0 | 12 |
watch --auto-commit |
Output directory in no git repository | 0 | 6, at startup |
translate |
Translation lost one of your placeholders | 0, with output | 5 |
translate <file> |
File containing an empty string value | 1 | 0 |
translate <file> |
Rate limit part-way through a structured file | 1 | 3 |
write --check --format json |
Text needs no changes | 8, always | 0 |
translate <dir> |
Every file in the directory failed | 0 | 12 |
translate <dir> |
Stopped by one request-level rejection | 1 | That rejection's code (2, 4, 6) |
translate <file> |
Structured file above the new size ceiling | 0 | 6 |
| any command | Client-side timeout, or a response body cut off mid-send | 6 | 5 |
sync |
One locale failed completely while others succeeded | 0 | 12 |
sync |
--concurrency value is not a number |
0 | 6 |
sync |
--locale value not listed in target_locales |
0 | 7 |
voice |
Audio transcribed but a requested --to produced no translation |
0 | 9 |
| any command | Interrupted with Ctrl-C | 0 | 130 |
Four of these deserve a note:
-
Every parse error is now exit 6, not exit 1. An unknown subcommand, an unknown option, an out-of-range
--choicevalue and a missing argument all exited 1 in 1.x, indistinguishable from a crash. Anything branching on exit 1 to mean "the CLI itself failed" must now treat 6 as "I invoked it wrong" and keep 1 for genuinely unclassified failures. This is also the code the removed flags above report. -
sync --forcenow needs--yesanywhere it cannot prompt, not only underCI=true. Add--yesto any invocation from a git hook, cron job,maketarget or container entrypoint. -
write --check --format jsoncould never pass in 1.x — the verdict was computed against a rendered JSON document. A gate built on it was either unconditionally red or green only because a later step ignored the code. It now returns the truthful answer, which means it will start passing. -
translatewith a lost placeholder now writes nothing and exits 5. In 1.x it wrote output containing the CLI's own internal token, such as__ Var_0 __.
Exit 3 and 5 are retriable; 12 is a partial failure with some locales succeeded; 130 is an interrupt, not a failure of the work. A client-side timeout used to report 6, which is not retriable — so a pipeline that gave up on 6 will now retry it. The full table is in API.md.
Under --format json, a failing command writes its error envelope to stdout.
This applies to every command with a JSON mode — translate, write, correct,
voice, usage, languages, detect, glossary, tm, cache, config,
hooks, admin, style-rules — and to every sync subcommand.
# 1.x — reason on stderr, payload on stdout, two redirections
deepl translate "hi" --to es --format json > out.json 2> err.txt
# 2.0.0 — one stream carries both
deepl translate "hi" --to es --format json > out.jsonThe exit code is still the failure signal. Warnings stay on stderr, but 2.0.0 emits warnings 1.x did not, so a check that treats any stderr output as failure will start tripping. Three are new and unconditional:
- A non-DeepL
--api-urlorapi_urlis announced before the key is sent, loopback included — so local mocks and self-hosted proxies see it on every run. - A
--langshaped like a language tag but absent from the bundled Write list notes that it is deferring to the API instead of rejecting it. - A
config.jsonlooser than0600is repaired to0600with a note suggesting you rotate the key. The settings in it are still honoured.
Anything parsing stderr for a failure reason must switch to stdout. A human reading
--format json output will see the envelope's message and suggestion fields where a
prose sentence used to be.
config get and config list default to json, so their failures carry the
envelope with no flag passed.
Separately, the human-readable reports of sync status, sync validate,
sync audit, sync init and auth show now print to stdout, so > report.txt
captures them.
Language codes are lowercase everywhere. 1.x mixed three casings: languages
printed lowercase, glossary show and tm list uppercased at display time,
translate --format table uppercased the target, and write/correct used BCP-47
(en-GB, zh-Hans). Anything comparing codes scraped from output needs to fold
case or expect lowercase:
| Command | 1.x | 2.0.0 |
|---|---|---|
glossary show |
Source language: EN |
Source language: en |
glossary show |
EN → ES: 5 entries |
en → es: 5 entries |
tm list |
brand-terms (EN → DE, FR) |
brand-terms (en → de, fr) |
translate --format table |
row labelled DE |
row labelled de |
write --format json |
"language": "en-US" |
"language": "en-us" |
No command line has to change — input is case-insensitive everywhere. voice
previously demanded the exact mixed-case spelling of a regional code (--to zh-HANS)
and rejected the lowercase form the rest of the CLI prints; it now accepts both.
Wire parameters that are not display output are untouched: translate and the
glossary create endpoint still send uppercase source_lang/target_lang, as those
endpoints document.
glossary create also prints its success line to stdout and renders the creation
timestamp as a locale-independent ISO string rather than a locale-dependent date on
stderr.
hooks list --format json reports a state string, not a boolean. The values are
installed, modified, unverified and not-installed. A truthiness test now
passes for every state including not-installed, so it must be replaced:
// 1.x
if (hook.installed) { /* ... */ }
// 2.0.0
if (hook.state === 'installed') { /* ... */ }A hook you edited by hand reports modified from then on, since its body no longer
matches the hash recorded at install.
write --alternatives --format json --output <file> writes JSON. It wrote the
numbered prose list in 1.x. To keep prose in the file, drop --format json.
Ten language display names changed to match the API, a consequence of generating the language list rather than hand-writing it.
sync JSON shapes gained fields and skip reasons. A consumer iterating
skipped will meet reasons it has not seen: shared_target, plural_entry,
unusable_target, untranslated and needs_review. Each locale in
sync status --format json gains a needsReview count, and pulled keys no longer
carry review_status.
Coverage numbers drop for PO and XLIFF projects. sync status now counts a
#, fuzzy PO entry and an XLIFF needs-review-translation target as needing
review rather than as complete — which is what msgfmt has reported all along. A
project reported at 100% will drop to the share actually shippable, and
sync push reports a correspondingly lower pushed count. Nothing is re-translated
or re-billed, and sync --frozen still passes.
sync --dry-run may also report a larger character estimate than 1.x, because
it now counts repair work — keys the lockfile calls translated that the target file
no longer holds — which a real run bills. If you tuned sync.max_characters
against the old under-count, raise it to the number --dry-run now reports.
Backups are <file>.deepl.bak, not <file>.bak. The stale-backup sweep only
considers the new suffix, so any .bak files left by 1.x stay on disk untouched —
delete them yourself once you no longer need them.
watch writes a nested source file to a nested output path. Watching docs/
with --output out, the file docs/guide/intro.md now lands at
out/guide/intro.es.md where it used to land at out/intro.es.md. This matches
what deepl translate <dir> --output <dir> has always produced. A file at the top
of the watched directory, and a watched path that is a single file, are unchanged.
sync writes state="translated" on XLIFF targets whose translation it
replaced, where it used to leave the old value. A target that carried no state is
written exactly as before.
sync resolve now takes the newer translation, as documented. 1.x kept the local side
of every conflict regardless of translated_at, so a teammate's newer translation was
discarded silently. The same conflict may now resolve the other way. If you have been
relying on resolve-keeps-mine, review the first few resolves after upgrading.
A YAML source file built on anchors and aliases yields a different key set. Aliases are
no longer expanded; aliased content is translated once, at its anchor. If your catalog uses
<<: merges or repeated aliases, re-check sync status counts after the first run.
An Android XML translation containing ]]> is refused rather than written. It used to
land inside a CDATA section, where it closed the section early. The key is withheld and
counted as failed, so the run reports it.
--tag-handling now pins tag_handling_version=v2 instead of letting the API
choose. Tagged output may differ from 1.x. To restore the old behaviour:
deepl translate input.html --to es --tag-handling html --tag-handling-version v1If you import this package's types, two published shapes changed. The Language
union grew from 121 to 125 members with nothing removed, so it needs no action —
but WriteLanguage did lose members.
WriteLanguage members are lowercase. Five regional codes were re-spelled:
| 1.x | 2.0.0 |
|---|---|
'en-GB' |
'en-gb' |
'en-US' |
'en-us' |
'pt-BR' |
'pt-br' |
'pt-PT' |
'pt-pt' |
'zh-Hans' |
'zh-hans' |
Code that passed a literal in the old casing no longer compiles:
// 1.x
const target: WriteLanguage = 'en-GB';
// 2.0.0
const target: WriteLanguage = 'en-gb';WriteImprovement.targetLanguage widened from WriteLanguage to string. The
API echoes this field in its own casing (en-GB, zh-Hans), which the narrower
type claimed it would not. Assignments from the field still compile; code that
assigned it to a WriteLanguage variable needs a check or a cast.
- Move to Node 24.15.0 or later and reinstall from
@deepl/cli(orbrew install deepl/tap/deepl). - Remove
--enable-beta-languages; renamesync init --source-lang/--target-langs. - Delete
tms.auto_push,tms.auto_pullandtms.require_reviewfrom.deepl-sync.yaml. - Move
tms.api_key/tms.tokenout of.deepl-sync.yamlintoTMS_API_KEY/TMS_TOKEN, and rotate any credential that was ever committed — deleting the line does not remove it from git history. Approve yourtms.serverhost withdeepl config set tms.allowedServers <host>, which is now checked for every credential. - Replace
auth set-key <KEY>withauth set-key --from-stdin(ordeepl init) everywhere it appears — scripts, CI steps, Dockerfiles, runbooks. - Add
--yesto any non-interactivedeepl sync --force. - Re-check every exit-code branch in CI against the table above — especially any
step treating
syncorwatchexit 0 as "complete". - Point JSON error parsing at stdout instead of stderr.
- Fold case when comparing language codes from output; replace
hook.installedwithhook.state === 'installed'. - Expect lower
sync statuscoverage for PO and XLIFF projects, and re-tunesync.max_charactersagainst the new--dry-runestimate. - Follow
watch --outputinto its new nested layout, and clean up leftover.bakfiles. - If you import the types, lowercase your
WriteLanguageliterals.
The complete list of changes, including everything fixed that does not require action, is in CHANGELOG.md.