msgai-cli 1.0.2 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,8 +1,36 @@
1
1
  # Changelog
2
2
 
3
- All notable changes to this project will be documented in this file.
3
+ All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines.
4
4
 
5
- The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
5
+ ## [1.1.1](https://github.com/AlexMost/msgai/compare/v1.1.0...v1.1.1) (2026-03-01)
6
+
7
+ ### Bug Fixes
8
+
9
+ - normalize msgctxt validation and add debug mode ([7488a78](https://github.com/AlexMost/msgai/commit/7488a782594253c4cfc46b56500ca7fa7b102766))
10
+
11
+ ## [1.1.0](https://github.com/AlexMost/msgai/compare/v1.0.2...v1.1.0) (2026-03-01)
12
+
13
+ ### Features
14
+
15
+ - **cli:** add --model option for translation ([61a2ed8](https://github.com/AlexMost/msgai/commit/61a2ed8502a0f90bbb09a1715bbb58d0ddabe01e))
16
+ - use structured outputs for translations ([c57e75b](https://github.com/AlexMost/msgai/commit/c57e75bda4229923dfca1daf1b8146a50f1d9b7f))
17
+
18
+ ## 1.0.2 (2026-02-27)
19
+
20
+ ### Bug Fixes
21
+
22
+ - **po:** preserve PO file order in `getEntriesToTranslate` ([55ba8d8](https://github.com/AlexMost/msgai/commit/55ba8d8c4c43d26adf4d1e23fa5dac2bebbf2052))
23
+
24
+ ## 1.0.1 (2026-02-27)
25
+
26
+ ### Documentation
27
+
28
+ - run formatter after each change and verify formatting in agent workflow ([1a1d909](https://github.com/AlexMost/msgai/commit/1a1d9097f9a88f6b260f5564fc6c800b52ea95a3))
29
+
30
+ ### Chores
31
+
32
+ - clean `dist` folder before each build ([0e65234](https://github.com/AlexMost/msgai/commit/0e652342d076867d1795b69ca1cb07d7988b92d2))
33
+ - fix formatting ([0cb5742](https://github.com/AlexMost/msgai/commit/0cb5742452f37f03a292f70ff6acc433f6f3666f))
6
34
 
7
35
  ## 1.0.0 (2026-02-27)
8
36
 
@@ -17,16 +45,3 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
17
45
  - **ci:** use github.event.workflow_run.conclusion in release-please ([0892b17](https://github.com/AlexMost/msgai/commit/0892b17aad1ec95a4e96a8208cb31d197264830f))
18
46
  - **ci:** use RELEASE_PLEASE_TOKEN so release-please can create PRs ([204c6cd](https://github.com/AlexMost/msgai/commit/204c6cdf88d480decae841f91c9fae168c4c0ce9))
19
47
  - use console.warn instead of console.error for pipeline compatibility ([8b30ebf](https://github.com/AlexMost/msgai/commit/8b30ebffba7c7447db3f51d931398938c39b6b85))
20
-
21
- ## [Unreleased]
22
-
23
- ## [1.0.0] - 2025-02-27
24
-
25
- ### Added
26
-
27
- - CLI to translate untranslated strings in gettext (`.po`) files using AI (OpenAI LLM)
28
- - `msgai <file.po>`: translate empty `msgstr` entries and write back to the file
29
- - `--dry-run`: list untranslated `msgid` values without API calls or file changes
30
- - `--source-lang LANG`: specify source language (ISO 639-1); optional, model can infer
31
- - `--include-fuzzy`: include fuzzy entries for re-translation and clear fuzzy flag
32
- - `--api-key KEY` and `OPENAI_API_KEY` environment variable for API authentication
package/README.md CHANGED
@@ -1,71 +1,169 @@
1
1
  # msgai
2
2
 
3
- `msgai` is a Node.js CLI that **automatically translates all untranslated strings in gettext (`.po`) files using AI (LLM)**. It reads your `.po` file, detects entries with empty or missing translations, sends them to an LLM (OpenAI), and writes the translations back into the file.
3
+ `msgai` is an AI-powered CLI for translating gettext `.po` files. It finds untranslated entries, sends them to an LLM, and writes the translated strings back into the same file.
4
4
 
5
- **Install:** `npm install -g msgai-cli` (then run `msgai`).
5
+ ## 🤖 Project Purpose
6
6
 
7
- ## Usage
7
+ `msgai` is built for teams that already use gettext and want a simple way to translate missing strings without building a separate localization workflow.
8
8
 
9
- ### Commands
9
+ Main features:
10
10
 
11
- - `msgai <file.po>`: translates all untranslated `msgid` / `msgid_plural` entries in the file using AI and updates the `.po` file in place.
12
- - `msgai <file.po> --dry-run`: only lists untranslated `msgid` values (no API calls, no file changes).
13
- - `msgai --help`: prints command usage.
11
+ - `📝` Works directly with gettext `.po` files
12
+ - `🤖` Translates only untranslated entries using AI
13
+ - `🧠` Uses OpenAI `gpt-4o` by default for translation
14
+ - `🏷️` Respects gettext context (`msgctxt`) when translating entries
15
+ - `🔁` Supports singular and plural translations
16
+ - `⚠️` Skips fuzzy entries by default
17
+ - `🧭` Can infer source language or use `--source-lang`
18
+ - `💻` Runs as a small CLI that updates files in place
14
19
 
15
- ### Fuzzy entries
20
+ ## ⚙️ How It Works
16
21
 
17
- - By default, entries marked as **fuzzy** in the `.po` file (e.g. `#, fuzzy`) are **skipped** and not sent for translation.
18
- - **`--include-fuzzy`**: include fuzzy entries. They are sent to the LLM with empty `msgstr` (like untranslated strings). After the translation is applied, the fuzzy flag is removed from those entries in the `.po` file.
22
+ 1. Read the `.po` file and parse its entries.
23
+ 2. Find entries with empty or missing translations.
24
+ 3. Send those strings to OpenAI `gpt-4o` for translation while preserving gettext context such as `msgctxt`.
25
+ 4. Write the translated values back into the same `.po` file.
19
26
 
20
- ### Source language
27
+ The translation API uses OpenAI `json_schema` structured outputs. Only models that support `json_schema` structured outputs are valid for `msgai`.
21
28
 
22
- - **`--source-lang LANG`**: source language of `msgid` strings as an ISO 639-1 code (e.g. `en`, `uk`). If omitted, the model will infer the source language. Invalid codes cause the CLI to exit with an error.
29
+ <details>
30
+ <summary>Supported model families</summary>
23
31
 
24
- ### API key (for translation)
32
+ - `gpt-4o`
33
+ - `gpt-4o-mini`
34
+ - `gpt-4.1`
35
+ - `gpt-4.1-mini`
36
+ - `gpt-4.1-nano`
37
+ - `gpt-5`
38
+ - `gpt-5-mini`
39
+ - `gpt-5-nano`
40
+ - `gpt-5-pro`
41
+ - `gpt-5.1`
42
+ - `gpt-5.2`
43
+ - `gpt-5-codex`
44
+ - `gpt-5.1-codex`
45
+ - `gpt-5.1-codex-mini`
46
+ - `gpt-5.1-codex-max`
47
+ - `gpt-5.2-codex`
25
48
 
26
- When running without `--dry-run`, the CLI needs an OpenAI API key. You can pass it in either of these ways:
49
+ Dated snapshots are accepted where the model family supports them.
27
50
 
28
- - **Environment variable**: set `OPENAI_API_KEY` (e.g. in your shell or a `.env` file in the current directory).
29
- - **CLI option**: pass `--api-key KEY` (e.g. `msgai messages.po --api-key sk-...`).
51
+ </details>
30
52
 
31
- If neither is set, the CLI exits with code 1 and a message asking you to set the key.
53
+ By default, entries marked as `fuzzy` are skipped. If you use `--include-fuzzy`, `msgai` will translate those entries too and remove the fuzzy flag after applying the result.
32
54
 
33
- On API errors (e.g. rate limit, quota, server errors), the CLI shows a status-specific message and exits with code 1. For error code reference, see [OpenAI API error codes](https://developers.openai.com/api/docs/guides/error-codes#api-errors).
55
+ ## 📦 Install
34
56
 
35
- ## Development environment
57
+ Install the CLI globally:
36
58
 
37
- ### Requirements
59
+ ```bash
60
+ npm install -g msgai-cli
61
+ ```
62
+
63
+ Set your OpenAI API key before running translations:
64
+
65
+ ```bash
66
+ export OPENAI_API_KEY=your_api_key_here
67
+ ```
68
+
69
+ You can also pass the key directly:
70
+
71
+ ```bash
72
+ msgai messages.po --api-key sk-...
73
+ ```
74
+
75
+ `OPENAI_API_KEY` can be loaded from your environment or from a `.env` file in the current directory.
76
+
77
+ ## 💻 CLI Usage
78
+
79
+ Usage:
80
+
81
+ ```bash
82
+ msgai <file.po> [--dry-run] [--api-key KEY] [--source-lang LANG] [--model MODEL] [--include-fuzzy] [--debug]
83
+ ```
84
+
85
+ Options:
86
+
87
+ - `--dry-run`: list untranslated `msgid` values only, with no API calls and no file changes
88
+ - `--include-fuzzy`: include fuzzy entries for translation and clear their fuzzy flag after translation
89
+ - `--source-lang LANG`: set the source language of `msgid` strings as an ISO 639-1 code such as `en` or `uk`
90
+ - `--model MODEL`: set the OpenAI model used for translation; default is `gpt-4o`. Only models with `json_schema` structured outputs are supported.
91
+ - `--api-key KEY`: pass the OpenAI API key directly instead of using `OPENAI_API_KEY`
92
+ - `--debug`: print debug logs for batch preparation, OpenAI request retries, request payloads, and raw response validation
93
+ - `--help`: print command usage
94
+
95
+ You can also enable the same debug logging with the environment variable `DEBUG=1`:
96
+
97
+ ```bash
98
+ DEBUG=1 msgai messages.po
99
+ ```
100
+
101
+ If no API key is provided for a non-dry run, the CLI exits with code `1` and prints an error message.
102
+
103
+ On API failures such as rate limits, quota issues, or server errors, the CLI exits with code `1` and shows a status-specific message. Validation errors for protected fields such as `msgid`, `msgid_plural`, or `msgctxt` now tell you whether a retry is reasonable and when to rerun with `--debug` or `DEBUG=1` to inspect the request/response flow. For API error details, see [OpenAI API error codes](https://developers.openai.com/api/docs/guides/error-codes#api-errors).
104
+
105
+ ## 🧪 Development
38
106
 
39
- - Node.js 20+ (recommended latest LTS)
40
- - npm 10+
107
+ Requirements:
41
108
 
42
- ### Setup
109
+ - Node.js `20+`
110
+ - npm `10+`
111
+
112
+ Install dependencies:
43
113
 
44
114
  ```bash
45
115
  npm install
46
116
  ```
47
117
 
48
- ### Commit messages
118
+ Useful scripts:
119
+
120
+ - `npm run build`: compile TypeScript to `dist/`
121
+ - `npm test`: build the project and run Jest tests
122
+ - `npm run test:integration`: run integration tests
123
+ - `npm run test:watch`: run tests in watch mode
124
+ - `npm run lint`: run ESLint
125
+ - `npm run lint:format`: check formatting with Prettier
126
+ - `npm run format`: format the repository with Prettier
127
+ - `npm run release:dry-run`: preview the `commit-and-tag-version` release without writing files
128
+ - `npm run release`: run release checks, update `CHANGELOG.md`, bump the npm version, create a release commit, and create a local tag
129
+
130
+ This repo follows [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) for commit messages.
131
+
132
+ ### Release Flow
133
+
134
+ Maintainer releases are local-first and use `commit-and-tag-version`. The release command does not publish to npm or push tags for you.
49
135
 
50
- This repo follows [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) for commit messages: use `feat:` for new features, `fix:` for bug fixes, optional scope (e.g. `feat(cli):`), and `BREAKING CHANGE:` or `!` for major changes. This drives version bumps and CHANGELOG updates via release-please.
136
+ Preview the next release:
51
137
 
52
- ### Scripts
138
+ ```bash
139
+ npm run release:dry-run
140
+ ```
141
+
142
+ Create the release locally:
143
+
144
+ ```bash
145
+ npm run release
146
+ ```
53
147
 
54
- - `npm run build`: compile TypeScript to `dist/`.
55
- - `npm test`: build project and run Jest tests.
56
- - `npm run test:watch`: build project and run Jest in watch mode.
57
- - `npm run format`: format code with Prettier.
58
- - `npm run lint:format`: check formatting with Prettier.
148
+ This command:
59
149
 
60
- ## Publishing
150
+ - runs `build`, unit tests, integration tests, lint, and formatting checks through the `prerelease` lifecycle hook
151
+ - lets `commit-and-tag-version` infer `major`, `minor`, or `patch` from Conventional Commits since the latest `v*` tag
152
+ - updates `CHANGELOG.md`
153
+ - creates `chore(release): X.Y.Z`
154
+ - creates a local annotated tag `vX.Y.Z`
61
155
 
62
- Releases are driven by [release-please](https://github.com/googleapis/release-please): it opens a **Release PR** that bumps the version and updates `CHANGELOG.md` from conventional commits. After the Release PR is merged, release-please creates the release tag on `main`.
156
+ For reliable version bumps and changelog entries, keep commits in Conventional Commit format.
63
157
 
64
- **Release-please setup:** In the repo go to **Settings → Actions → General → Workflow permissions** and set to **Read and write** and enable **Allow GitHub Actions to create and approve pull requests**. You can then use the default `GITHUB_TOKEN` (no secret). If you see "Error adding to tree" or PR creation blocked, add a Personal Access Token as secret `RELEASE_PLEASE_TOKEN` (classic: `repo` + `workflow` scope; fine-grained: Contents + Pull requests + Workflows write).
158
+ If you need to override the inferred bump manually:
65
159
 
66
- **Publishing to npm (local):**
160
+ ```bash
161
+ npm run release -- --release-as minor
162
+ ```
67
163
 
68
- 1. Pull `main` with the new release tag.
69
- 2. Run `npm publish`.
164
+ After the local release is created:
70
165
 
71
- Before publishing, `prepublishOnly` runs build, unit tests, integration tests, lint, and format checks. Set `OPENAI_API_KEY` so integration tests pass.
166
+ ```bash
167
+ git push --follow-tags
168
+ npm publish
169
+ ```
@@ -6,12 +6,13 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
7
  const yargs_1 = __importDefault(require("yargs/yargs"));
8
8
  const helpers_1 = require("yargs/helpers");
9
+ const debug_1 = require("../debug");
9
10
  const runTranslate_1 = require("./runTranslate");
10
11
  function parseArgs(argv) {
11
12
  try {
12
13
  const parsedArgs = (0, yargs_1.default)(argv)
13
14
  .scriptName('msgai')
14
- .usage('Usage: msgai <file.po> [--dry-run] [--api-key KEY] [--source-lang LANG] [--include-fuzzy]')
15
+ .usage('Usage: msgai <file.po> [--dry-run] [--api-key KEY] [--source-lang LANG] [--model MODEL] [--include-fuzzy] [--debug]')
15
16
  .option('dry-run', {
16
17
  type: 'boolean',
17
18
  default: false,
@@ -28,11 +29,20 @@ function parseArgs(argv) {
28
29
  .option('source-lang', {
29
30
  type: 'string',
30
31
  description: 'Source language of msgid strings (ISO 639-1 code, e.g. en, uk). If omitted, the model will detect it.',
32
+ })
33
+ .option('model', {
34
+ type: 'string',
35
+ description: 'OpenAI model to use for translation. Default: gpt-4o',
31
36
  })
32
37
  .option('help', {
33
38
  alias: 'h',
34
39
  type: 'boolean',
35
40
  default: false,
41
+ })
42
+ .option('debug', {
43
+ type: 'boolean',
44
+ default: false,
45
+ description: 'Print debug logs for request/response validation and batch processing',
36
46
  })
37
47
  .strictOptions()
38
48
  .version(false)
@@ -49,53 +59,71 @@ function parseArgs(argv) {
49
59
  const sourceLang = sourceLangRaw != null && String(sourceLangRaw).trim() !== ''
50
60
  ? String(sourceLangRaw).trim().toLowerCase()
51
61
  : undefined;
62
+ const modelRaw = parsedArgs.model;
63
+ const model = modelRaw != null && String(modelRaw).trim() !== '' ? String(modelRaw).trim() : undefined;
52
64
  if (positionalArgs.length > 1) {
53
- return {
65
+ const result = {
54
66
  dryRun: Boolean(parsedArgs['dry-run']),
55
67
  help: Boolean(parsedArgs.help),
56
68
  apiKey: parsedArgs['api-key'],
57
69
  sourceLang,
70
+ model,
58
71
  includeFuzzy: Boolean(parsedArgs['include-fuzzy']),
72
+ debug: Boolean(parsedArgs.debug),
59
73
  error: `Unexpected argument: ${positionalArgs[1]}`,
60
74
  };
75
+ return result;
61
76
  }
62
- return {
77
+ const result = {
63
78
  poFilePath: positionalArgs[0],
64
79
  dryRun: Boolean(parsedArgs['dry-run']),
65
80
  help: Boolean(parsedArgs.help),
66
81
  apiKey: parsedArgs['api-key'],
67
82
  sourceLang,
83
+ model,
68
84
  includeFuzzy: Boolean(parsedArgs['include-fuzzy']),
85
+ debug: Boolean(parsedArgs.debug),
69
86
  };
87
+ return result;
70
88
  }
71
89
  catch (error) {
72
90
  const message = error instanceof Error ? error.message : String(error);
73
91
  return { dryRun: false, help: false, error: message };
74
92
  }
75
93
  }
76
- const USAGE = 'Usage: msgai <file.po> [--dry-run] [--api-key KEY] [--source-lang LANG] [--include-fuzzy]';
94
+ const USAGE = 'Usage: msgai <file.po> [--dry-run] [--api-key KEY] [--source-lang LANG] [--model MODEL] [--include-fuzzy] [--debug]';
77
95
  function main(argv) {
78
96
  const args = parseArgs(argv);
97
+ (0, debug_1.initDebugLogger)(args.debug);
98
+ const debugLogger = (0, debug_1.getDebugLogger)();
99
+ debugLogger.log('cli.main', 'Entering CLI main', { argv, args });
79
100
  if (args.error) {
101
+ debugLogger.log('cli.main', 'Exiting because args contained an error', { error: args.error });
80
102
  console.warn(args.error);
81
103
  console.warn(USAGE);
82
104
  return 1;
83
105
  }
84
106
  if (args.help) {
107
+ debugLogger.log('cli.main', 'Printing help output');
85
108
  console.log(USAGE);
86
109
  return 0;
87
110
  }
111
+ debugLogger.log('cli.main', 'Dispatching runTranslateCommand');
88
112
  const result = (0, runTranslate_1.runTranslateCommand)({
89
113
  poFilePath: args.poFilePath,
90
114
  dryRun: args.dryRun,
91
115
  apiKey: args.apiKey,
92
116
  sourceLang: args.sourceLang,
117
+ model: args.model,
93
118
  includeFuzzy: args.includeFuzzy,
119
+ debug: args.debug,
94
120
  });
95
121
  if (result instanceof Promise) {
122
+ debugLogger.log('cli.main', 'runTranslateCommand returned a promise');
96
123
  result.then((code) => process.exit(code));
97
124
  return undefined;
98
125
  }
126
+ debugLogger.log('cli.main', 'runTranslateCommand returned synchronously', { exitCode: result });
99
127
  return result;
100
128
  }
101
129
  const exitCode = main((0, helpers_1.hideBin)(process.argv));
@@ -7,6 +7,7 @@ exports.runTranslate = runTranslate;
7
7
  exports.runTranslateCommand = runTranslateCommand;
8
8
  const node_fs_1 = __importDefault(require("node:fs"));
9
9
  const plural_forms_1 = require("plural-forms");
10
+ const debug_1 = require("../debug");
10
11
  const po_1 = require("../po");
11
12
  const translate_1 = require("../translate");
12
13
  const validate_source_lang_1 = require("../validate-source-lang");
@@ -41,9 +42,27 @@ function getApiErrorMessage(err) {
41
42
  return null;
42
43
  }
43
44
  }
44
- async function runTranslate(poFilePath, apiKey, sourceLang, includeFuzzy) {
45
+ function getInvalidModelMessage(model) {
46
+ return [
47
+ `Invalid --model "${model}". msgai only supports OpenAI models with json_schema structured outputs.`,
48
+ `Supported model families: ${translate_1.SUPPORTED_STRUCTURED_OUTPUT_MODELS.join(', ')}.`,
49
+ ].join(' ');
50
+ }
51
+ async function runTranslate(poFilePath, apiKey, sourceLang, model, includeFuzzy, debug) {
52
+ (0, debug_1.initDebugLogger)(debug);
53
+ const debugLogger = (0, debug_1.getDebugLogger)();
45
54
  try {
55
+ debugLogger.log('cli.runTranslate', 'Starting translation run', {
56
+ poFilePath,
57
+ sourceLang,
58
+ model: model ?? 'gpt-4o',
59
+ includeFuzzy: includeFuzzy === true,
60
+ });
46
61
  const poContent = node_fs_1.default.readFileSync(poFilePath, 'utf8');
62
+ debugLogger.log('cli.runTranslate', 'Read PO file', {
63
+ poFilePath,
64
+ bytes: Buffer.byteLength(poContent, 'utf8'),
65
+ });
47
66
  const parsedPo = (0, po_1.parsePoContent)(poContent);
48
67
  const { entries } = (0, po_1.getEntriesToTranslate)(parsedPo, { includeFuzzy });
49
68
  if (entries.length === 0) {
@@ -62,13 +81,30 @@ async function runTranslate(poFilePath, apiKey, sourceLang, includeFuzzy) {
62
81
  // locale not in plural-forms; rely on formula only
63
82
  }
64
83
  }
65
- const options = { apiKey, sourceLanguage: sourceLang, formula, pluralSamples };
84
+ const options = { apiKey, sourceLanguage: sourceLang, formula, pluralSamples, model, debug };
85
+ debugLogger.log('cli.runTranslate', 'Computed translation run inputs', {
86
+ targetLanguage,
87
+ formula,
88
+ pluralSamples,
89
+ entryCount: entries.length,
90
+ entries,
91
+ });
66
92
  for (let i = 0; i < entries.length; i += TRANSLATE_BATCH_SIZE) {
67
93
  const batch = entries.slice(i, i + TRANSLATE_BATCH_SIZE);
68
94
  const batchNum = Math.floor(i / TRANSLATE_BATCH_SIZE) + 1;
69
95
  const totalBatches = Math.ceil(entries.length / TRANSLATE_BATCH_SIZE);
96
+ debugLogger.log('cli.runTranslate', 'Preparing translation batch', {
97
+ batch: batchNum,
98
+ totalBatches,
99
+ batchSize: batch.length,
100
+ entries: batch,
101
+ });
70
102
  console.log(`Translating batch ${batchNum}/${totalBatches} (${batch.length} phrase${batch.length === 1 ? '' : 's'})...`);
71
103
  const batchResults = await (0, translate_1.translateStrings)(batch, targetLanguage, options);
104
+ debugLogger.log('cli.runTranslate', 'Received translation batch results', {
105
+ batch: batchNum,
106
+ results: batchResults,
107
+ });
72
108
  for (const r of batchResults) {
73
109
  if (typeof r.msgstr === 'string') {
74
110
  console.log(` ${r.msgid} => ${r.msgstr}`);
@@ -82,10 +118,17 @@ async function runTranslate(poFilePath, apiKey, sourceLang, includeFuzzy) {
82
118
  (0, po_1.clearFuzzyFromEntries)(parsedPo, batchResults);
83
119
  }
84
120
  node_fs_1.default.writeFileSync(poFilePath, (0, po_1.compilePo)(parsedPo));
121
+ debugLogger.log('cli.runTranslate', 'Wrote translated batch back to PO file', {
122
+ batch: batchNum,
123
+ poFilePath,
124
+ });
85
125
  }
86
126
  return 0;
87
127
  }
88
128
  catch (error) {
129
+ debugLogger.log('cli.runTranslate', 'Translation run failed', {
130
+ error: error instanceof Error ? error.message : String(error),
131
+ });
89
132
  const apiMessage = getApiErrorMessage(error);
90
133
  if (apiMessage != null) {
91
134
  console.warn(apiMessage);
@@ -96,8 +139,11 @@ async function runTranslate(poFilePath, apiKey, sourceLang, includeFuzzy) {
96
139
  return 1;
97
140
  }
98
141
  }
99
- const USAGE = 'Usage: msgai <file.po> [--dry-run] [--api-key KEY] [--source-lang LANG] [--include-fuzzy]';
142
+ const USAGE = 'Usage: msgai <file.po> [--dry-run] [--api-key KEY] [--source-lang LANG] [--model MODEL] [--include-fuzzy] [--debug]';
100
143
  function runTranslateCommand(args) {
144
+ (0, debug_1.initDebugLogger)(args.debug);
145
+ const debugLogger = (0, debug_1.getDebugLogger)();
146
+ debugLogger.log('cli.runTranslateCommand', 'Received command args', args);
101
147
  if (!args.poFilePath) {
102
148
  console.warn(USAGE);
103
149
  return 1;
@@ -107,29 +153,59 @@ function runTranslateCommand(args) {
107
153
  (0, validate_source_lang_1.validateSourceLang)(args.sourceLang);
108
154
  }
109
155
  catch (error) {
156
+ debugLogger.log('cli.runTranslateCommand', 'Source language validation failed', {
157
+ sourceLang: args.sourceLang,
158
+ error: error instanceof Error ? error.message : String(error),
159
+ });
110
160
  const message = error instanceof Error ? error.message : String(error);
111
161
  console.warn(message);
112
162
  return 1;
113
163
  }
114
164
  }
165
+ if (args.model != null) {
166
+ try {
167
+ (0, translate_1.validateModel)(args.model);
168
+ }
169
+ catch {
170
+ debugLogger.log('cli.runTranslateCommand', 'Model validation failed', {
171
+ model: args.model,
172
+ });
173
+ console.warn(getInvalidModelMessage(args.model));
174
+ return 1;
175
+ }
176
+ }
115
177
  if (!args.dryRun) {
116
178
  let resultApiKey;
117
179
  try {
118
180
  resultApiKey = (0, translate_1.resolveApiKey)(args.apiKey);
181
+ debugLogger.log('cli.runTranslateCommand', 'Resolved API key for translation run', {
182
+ source: args.apiKey != null && args.apiKey.trim() !== '' ? 'cli-arg' : 'env',
183
+ });
119
184
  }
120
185
  catch (error) {
186
+ debugLogger.log('cli.runTranslateCommand', 'API key resolution failed', {
187
+ error: error instanceof Error ? error.message : String(error),
188
+ });
121
189
  const message = error instanceof Error ? error.message : String(error);
122
190
  console.warn(message.replace('pass apiKey in options', 'pass --api-key'));
123
191
  return 1;
124
192
  }
125
- return runTranslate(args.poFilePath, resultApiKey, args.sourceLang, args.includeFuzzy);
193
+ return runTranslate(args.poFilePath, resultApiKey, args.sourceLang, args.model, args.includeFuzzy, args.debug);
126
194
  }
127
195
  try {
128
196
  const poContent = node_fs_1.default.readFileSync(args.poFilePath, 'utf8');
197
+ debugLogger.log('cli.runTranslateCommand', 'Dry-run read PO file', {
198
+ poFilePath: args.poFilePath,
199
+ bytes: Buffer.byteLength(poContent, 'utf8'),
200
+ });
129
201
  const parsedPo = (0, po_1.parsePoContent)(poContent);
130
202
  const { entries } = (0, po_1.getEntriesToTranslate)(parsedPo, {
131
203
  includeFuzzy: args.includeFuzzy,
132
204
  });
205
+ debugLogger.log('cli.runTranslateCommand', 'Dry-run extracted entries', {
206
+ entryCount: entries.length,
207
+ entries,
208
+ });
133
209
  const msgidsToShow = entries.map((e) => e.msgid);
134
210
  for (const msgid of msgidsToShow) {
135
211
  console.log(msgid);
@@ -137,6 +213,9 @@ function runTranslateCommand(args) {
137
213
  return 0;
138
214
  }
139
215
  catch (error) {
216
+ debugLogger.log('cli.runTranslateCommand', 'Dry-run failed', {
217
+ error: error instanceof Error ? error.message : String(error),
218
+ });
140
219
  const message = error instanceof Error ? error.message : String(error);
141
220
  console.warn(`Failed to process PO file: ${message}`);
142
221
  return 1;
@@ -0,0 +1,36 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.hasDebugFlag = hasDebugFlag;
4
+ exports.initDebugLogger = initDebugLogger;
5
+ exports.getDebugLogger = getDebugLogger;
6
+ const debugState = {
7
+ enabled: false,
8
+ };
9
+ const debugLogger = {
10
+ get enabled() {
11
+ return debugState.enabled;
12
+ },
13
+ log(scope, message, details) {
14
+ if (!debugState.enabled)
15
+ return;
16
+ const prefix = `[debug] [${scope}] ${message}`;
17
+ if (details === undefined) {
18
+ console.warn(prefix);
19
+ return;
20
+ }
21
+ console.warn(prefix, details);
22
+ },
23
+ };
24
+ function hasDebugFlag(argv) {
25
+ return argv.includes('--debug');
26
+ }
27
+ function isDebugEnvEnabled() {
28
+ return process.env['DEBUG'] === '1';
29
+ }
30
+ function initDebugLogger(enabled) {
31
+ debugState.enabled = enabled === true || isDebugEnvEnabled();
32
+ return debugLogger;
33
+ }
34
+ function getDebugLogger() {
35
+ return debugLogger;
36
+ }
@@ -3,11 +3,14 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.SUPPORTED_STRUCTURED_OUTPUT_MODELS = void 0;
6
7
  exports.resolveApiKey = resolveApiKey;
8
+ exports.validateModel = validateModel;
7
9
  exports.translatePayload = translatePayload;
8
10
  exports.translateItems = translateItems;
9
11
  exports.translateStrings = translateStrings;
10
12
  const openai_1 = __importDefault(require("openai"));
13
+ const debug_1 = require("./debug");
11
14
  const loadEnv_1 = require("./loadEnv");
12
15
  function resolveApiKey(apiKey) {
13
16
  (0, loadEnv_1.loadEnv)();
@@ -19,6 +22,42 @@ function resolveApiKey(apiKey) {
19
22
  throw new Error('OpenAI API key not set. Set OPENAI_API_KEY in the environment or pass apiKey in options.');
20
23
  }
21
24
  const DEFAULT_MODEL = 'gpt-4o';
25
+ const SUPPORTED_STRUCTURED_OUTPUT_MODEL_PATTERNS = [
26
+ /^gpt-4o(?:-\d{4}-\d{2}-\d{2})?$/,
27
+ /^gpt-4o-mini(?:-\d{4}-\d{2}-\d{2})?$/,
28
+ /^gpt-4\.1(?:-\d{4}-\d{2}-\d{2})?$/,
29
+ /^gpt-4\.1-mini(?:-\d{4}-\d{2}-\d{2})?$/,
30
+ /^gpt-4\.1-nano(?:-\d{4}-\d{2}-\d{2})?$/,
31
+ /^gpt-5(?:-\d{4}-\d{2}-\d{2}|-chat-latest)?$/,
32
+ /^gpt-5-mini(?:-\d{4}-\d{2}-\d{2})?$/,
33
+ /^gpt-5-nano(?:-\d{4}-\d{2}-\d{2})?$/,
34
+ /^gpt-5-pro(?:-\d{4}-\d{2}-\d{2})?$/,
35
+ /^gpt-5\.1(?:-\d{4}-\d{2}-\d{2}|-chat-latest)?$/,
36
+ /^gpt-5\.2(?:-\d{4}-\d{2}-\d{2}|-chat-latest)?$/,
37
+ /^gpt-5-codex$/,
38
+ /^gpt-5\.1-codex$/,
39
+ /^gpt-5\.1-codex-mini$/,
40
+ /^gpt-5\.1-codex-max$/,
41
+ /^gpt-5\.2-codex$/,
42
+ ];
43
+ exports.SUPPORTED_STRUCTURED_OUTPUT_MODELS = [
44
+ 'gpt-4o',
45
+ 'gpt-4o-mini',
46
+ 'gpt-4.1',
47
+ 'gpt-4.1-mini',
48
+ 'gpt-4.1-nano',
49
+ 'gpt-5',
50
+ 'gpt-5-mini',
51
+ 'gpt-5-nano',
52
+ 'gpt-5-pro',
53
+ 'gpt-5.1',
54
+ 'gpt-5.2',
55
+ 'gpt-5-codex',
56
+ 'gpt-5.1-codex',
57
+ 'gpt-5.1-codex-mini',
58
+ 'gpt-5.1-codex-max',
59
+ 'gpt-5.2-codex',
60
+ ];
22
61
  /** Error codes: https://developers.openai.com/api/docs/guides/error-codes#api-errors */
23
62
  const MAX_RETRIES = 3;
24
63
  const RETRY_DELAYS_MS = [1000, 2000, 4000];
@@ -34,20 +73,94 @@ function isApiError(err) {
34
73
  function isRetryableStatus(status) {
35
74
  return status === 429 || status === 500 || status === 503;
36
75
  }
76
+ function isSupportedStructuredOutputModel(model) {
77
+ return SUPPORTED_STRUCTURED_OUTPUT_MODEL_PATTERNS.some((pattern) => pattern.test(model));
78
+ }
79
+ function validateStructuredOutputModel(model) {
80
+ if (isSupportedStructuredOutputModel(model))
81
+ return;
82
+ throw new Error(`Model "${model}" is not supported. This package requires an OpenAI Chat Completions model with json_schema structured outputs. Supported model families: ${exports.SUPPORTED_STRUCTURED_OUTPUT_MODELS.join(', ')}.`);
83
+ }
84
+ function validateModel(model) {
85
+ validateStructuredOutputModel(model);
86
+ }
87
+ const TRANSLATION_RESPONSE_SCHEMA = {
88
+ name: 'translation_payload',
89
+ strict: true,
90
+ schema: {
91
+ type: 'object',
92
+ additionalProperties: false,
93
+ required: ['translations'],
94
+ properties: {
95
+ translations: {
96
+ type: 'array',
97
+ items: {
98
+ anyOf: [
99
+ {
100
+ type: 'object',
101
+ additionalProperties: false,
102
+ required: ['msgid', 'msgstr'],
103
+ properties: {
104
+ msgid: { type: 'string' },
105
+ msgstr: { type: 'string' },
106
+ },
107
+ },
108
+ {
109
+ type: 'object',
110
+ additionalProperties: false,
111
+ required: ['msgid', 'msgctxt', 'msgstr'],
112
+ properties: {
113
+ msgid: { type: 'string' },
114
+ msgctxt: { type: 'string' },
115
+ msgstr: { type: 'string' },
116
+ },
117
+ },
118
+ {
119
+ type: 'object',
120
+ additionalProperties: false,
121
+ required: ['msgid_plural', 'msgstr'],
122
+ properties: {
123
+ msgid_plural: { type: 'string' },
124
+ msgstr: {
125
+ type: 'array',
126
+ items: { type: 'string' },
127
+ },
128
+ },
129
+ },
130
+ {
131
+ type: 'object',
132
+ additionalProperties: false,
133
+ required: ['msgid_plural', 'msgctxt', 'msgstr'],
134
+ properties: {
135
+ msgid_plural: { type: 'string' },
136
+ msgctxt: { type: 'string' },
137
+ msgstr: {
138
+ type: 'array',
139
+ items: { type: 'string' },
140
+ },
141
+ },
142
+ },
143
+ ],
144
+ },
145
+ },
146
+ },
147
+ },
148
+ };
37
149
  function buildSystemMessage() {
38
150
  return `You are a deterministic translation engine for gettext PO entries.
39
151
 
152
+ Return exactly one JSON object that matches the provided response schema.
153
+
40
154
  Your task:
41
155
  For each input entry, produce a translation in the corresponding "msgstr" field.
42
156
  Each output entry MUST correspond exactly to the matching input entry.
43
- Do not change, remove, or reorder any "msgid" or "msgid_plural" values.
157
+ Do not change, remove, or reorder any "msgid", "msgid_plural", or "msgctxt" values.
44
158
  Only fill the "msgstr" field.
45
- If an entry has "msgctxt", leave it unchanged and return it in the output (so same msgids for different contexts are not mixed).
46
159
 
47
160
  Critical rules:
48
161
 
49
- - Preserve ALL placeholders exactly as in the source text.
50
- - NEVER translate, modify, reorder, or remove placeholders.
162
+ - Copy ALL placeholders and non-linguistic tokens exactly, byte-for-byte.
163
+ - NEVER translate, modify, reorder, remove, escape, or unescape placeholders.
51
164
 
52
165
  Placeholders include (but are not limited to):
53
166
  - printf-style specifiers: %s, %d, %f, %1$s, %(name)s
@@ -87,22 +200,9 @@ Output:
87
200
 
88
201
  You MUST respond with nothing but a single JSON object. No markdown, no code fences (no \`\`\`json or \`\`\`), no explanatory text before or after. The response must be parseable by JSON.parse() directly.
89
202
 
90
- Return EXACTLY this structure (and nothing else):
91
-
92
- {
93
- "formula": "...",
94
- "target_language": "...",
95
- "source_language": "...",
96
- "translations": [
97
- { "msgid": "...", "msgstr": "..." },
98
- { "msgid_plural": "...", "msgstr": ["...", "..."] }
99
- ]
100
- }
101
-
102
203
  Additional constraints:
103
204
 
104
205
  - Preserve the exact input order of entries.
105
- - Do not modify "formula", "target_language", or "source_language".
106
206
  - Do not add, remove, or rename fields.`;
107
207
  }
108
208
  /** Strip markdown code fences if the model wrapped JSON in ```json ... ``` */
@@ -112,11 +212,26 @@ function stripJsonFences(raw) {
112
212
  const match = trimmed.match(jsonBlock);
113
213
  return match ? match[1].trim() : trimmed;
114
214
  }
115
- function parsePayloadResponse(content) {
215
+ function normalizeMsgctxt(msgctxt) {
216
+ return typeof msgctxt === 'string' ? msgctxt : '';
217
+ }
218
+ function buildProtectedFieldMismatchMessage(index, field) {
219
+ const entryRef = `OpenAI response translations[${index}].${field}`;
220
+ const retryHint = 'Retry the command once because this can be a transient structured-output formatting issue.';
221
+ const debugHint = 'If it keeps happening, rerun with --debug and double-check that the PO entry content matches the returned protected fields.';
222
+ if (field === 'msgctxt') {
223
+ return `${entryRef} must match the input exactly. ${retryHint} If it keeps happening, rerun with --debug and check whether empty gettext context is being returned as omitted vs empty string.`;
224
+ }
225
+ return `${entryRef} must match the input exactly. ${retryHint} ${debugHint}`;
226
+ }
227
+ function parsePayloadResponse(request, content, options) {
228
+ (0, debug_1.initDebugLogger)(options?.debug);
229
+ const debug = (0, debug_1.getDebugLogger)();
116
230
  if (content == null || content.trim() === '') {
117
231
  throw new Error('Empty response from OpenAI');
118
232
  }
119
233
  const raw = content.trim();
234
+ debug.log('translate', 'Raw OpenAI response content received', raw);
120
235
  const toParse = stripJsonFences(raw);
121
236
  let parsed;
122
237
  try {
@@ -135,6 +250,9 @@ function parsePayloadResponse(content) {
135
250
  console.warn('OpenAI model returned (raw):', raw);
136
251
  throw new Error(`OpenAI response "translations" must be an array: ${raw.slice(0, 200)}`);
137
252
  }
253
+ if (payload.translations.length !== request.translations.length) {
254
+ throw new Error('OpenAI response "translations" must have the same number of entries as input');
255
+ }
138
256
  for (let i = 0; i < payload.translations.length; i++) {
139
257
  const t = payload.translations[i];
140
258
  if (t == null || typeof t !== 'object' || !('msgstr' in t)) {
@@ -143,12 +261,38 @@ function parsePayloadResponse(content) {
143
261
  }
144
262
  const entry = t;
145
263
  const msgstr = entry.msgstr;
146
- if (typeof msgstr === 'string')
147
- continue;
148
- if (Array.isArray(msgstr) && msgstr.every((s) => typeof s === 'string'))
264
+ const requestEntry = request.translations[i];
265
+ const requestContext = normalizeMsgctxt(requestEntry.msgctxt);
266
+ const responseContext = normalizeMsgctxt(entry.msgctxt);
267
+ if (responseContext !== requestContext) {
268
+ throw new Error(buildProtectedFieldMismatchMessage(i, 'msgctxt'));
269
+ }
270
+ if ('msgid' in requestEntry) {
271
+ if (entry.msgid !== requestEntry.msgid) {
272
+ throw new Error(buildProtectedFieldMismatchMessage(i, 'msgid'));
273
+ }
274
+ if ('msgid_plural' in entry) {
275
+ throw new Error(`OpenAI response translations[${i}] must not include msgid_plural`);
276
+ }
277
+ if (typeof msgstr === 'string')
278
+ continue;
279
+ console.warn('OpenAI model returned (raw):', raw);
280
+ throw new Error(`OpenAI response translations[${i}].msgstr must be a string`);
281
+ }
282
+ if (entry.msgid_plural !== requestEntry.msgid_plural) {
283
+ throw new Error(buildProtectedFieldMismatchMessage(i, 'msgid_plural'));
284
+ }
285
+ if ('msgid' in entry) {
286
+ throw new Error(`OpenAI response translations[${i}] must not include msgid`);
287
+ }
288
+ if (Array.isArray(msgstr) && msgstr.every((s) => typeof s === 'string')) {
289
+ if (request.plural_samples != null && msgstr.length !== request.plural_samples.length) {
290
+ throw new Error(`OpenAI response translations[${i}].msgstr must have length ${request.plural_samples.length}`);
291
+ }
149
292
  continue;
293
+ }
150
294
  console.warn('OpenAI model returned (raw):', raw);
151
- throw new Error(`OpenAI response translations[${i}].msgstr must be a string or array of strings`);
295
+ throw new Error(`OpenAI response translations[${i}].msgstr must be an array of strings`);
152
296
  }
153
297
  return payload;
154
298
  }
@@ -156,27 +300,61 @@ async function translatePayload(payload, options) {
156
300
  if (payload.translations.length === 0) {
157
301
  return { ...payload, translations: [] };
158
302
  }
303
+ (0, debug_1.initDebugLogger)(options?.debug);
304
+ const debug = (0, debug_1.getDebugLogger)();
159
305
  const client = options?.client ??
160
306
  new openai_1.default({
161
307
  apiKey: options.apiKey,
162
308
  });
163
309
  const model = options?.model ?? DEFAULT_MODEL;
310
+ validateStructuredOutputModel(model);
311
+ debug.log('translate', 'Prepared translatePayload request summary', {
312
+ model,
313
+ target_language: payload.target_language,
314
+ source_language: payload.source_language,
315
+ translation_count: payload.translations.length,
316
+ plural_samples: payload.plural_samples?.length ?? 0,
317
+ });
318
+ debug.log('translate', 'translatePayload request payload', payload);
319
+ const requestParams = {
320
+ model,
321
+ temperature: 0,
322
+ response_format: {
323
+ type: 'json_schema',
324
+ json_schema: TRANSLATION_RESPONSE_SCHEMA,
325
+ },
326
+ messages: [
327
+ { role: 'system', content: buildSystemMessage() },
328
+ { role: 'user', content: JSON.stringify(payload) },
329
+ ],
330
+ };
331
+ debug.log('translate', 'OpenAI chat.completions.create request', requestParams);
164
332
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
165
333
  try {
166
- const response = await client.chat.completions.create({
167
- model,
168
- messages: [
169
- { role: 'system', content: buildSystemMessage() },
170
- { role: 'user', content: JSON.stringify(payload) },
171
- ],
334
+ debug.log('translate', 'Sending request to OpenAI', {
335
+ attempt: attempt + 1,
336
+ max_attempts: MAX_RETRIES + 1,
337
+ });
338
+ const response = await client.chat.completions.create(requestParams);
339
+ debug.log('translate', 'OpenAI chat.completions.create response metadata', {
340
+ id: response.id,
341
+ model: response.model,
342
+ finish_reason: response.choices[0]?.finish_reason ?? null,
343
+ choices: response.choices.length,
172
344
  });
173
345
  const content = response.choices[0]?.message?.content ?? null;
174
- return parsePayloadResponse(content);
346
+ return parsePayloadResponse(payload, content, { debug: options?.debug });
175
347
  }
176
348
  catch (err) {
177
349
  const shouldRetry = attempt < MAX_RETRIES && isApiError(err) && isRetryableStatus(err.status);
350
+ debug.log('translate', 'translatePayload request failed', {
351
+ attempt: attempt + 1,
352
+ shouldRetry,
353
+ error: err instanceof Error ? err.message : String(err),
354
+ });
178
355
  if (shouldRetry) {
179
356
  const delayMs = RETRY_DELAYS_MS[attempt] ?? RETRY_DELAYS_MS[RETRY_DELAYS_MS.length - 1];
357
+ debug.log('translate', 'Retrying after backoff', { delay_ms: delayMs });
180
358
  await sleep(delayMs);
181
359
  continue;
182
360
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "msgai-cli",
3
- "version": "1.0.2",
3
+ "version": "1.1.1",
4
4
  "description": "CLI that automatically translates all untranslated strings in gettext (.po) files using AI (LLM)",
5
5
  "main": "dist/src/cli/index.js",
6
6
  "bin": {
@@ -13,8 +13,12 @@
13
13
  "test:integration": "npm run build && jest -c jest.integration.config.cjs",
14
14
  "test:watch": "npm run build && jest --watch",
15
15
  "format": "prettier --write .",
16
+ "format:changelog": "prettier --write CHANGELOG.md",
16
17
  "lint": "eslint .",
17
18
  "lint:format": "prettier --check .",
19
+ "prerelease": "npm run build && npm test && npm run test:integration && npm run lint && npm run lint:format",
20
+ "release": "commit-and-tag-version",
21
+ "release:dry-run": "commit-and-tag-version --dry-run",
18
22
  "script:test-openai": "ts-node scripts/test-openai.ts",
19
23
  "prepublishOnly": "npm run build && npm test && npm run test:integration && npm run lint && npm run lint:format"
20
24
  },
@@ -46,6 +50,13 @@
46
50
  "url": "https://github.com/AlexMost/msgai/issues"
47
51
  },
48
52
  "homepage": "https://github.com/AlexMost/msgai#readme",
53
+ "commit-and-tag-version": {
54
+ "tagPrefix": "v",
55
+ "releaseCommitMessageFormat": "chore(release): {{currentTag}}",
56
+ "scripts": {
57
+ "postchangelog": "npm run format:changelog"
58
+ }
59
+ },
49
60
  "devDependencies": {
50
61
  "@babel/core": "^7.29.0",
51
62
  "@babel/preset-env": "^7.29.0",
@@ -54,6 +65,7 @@
54
65
  "@types/jest": "^30.0.0",
55
66
  "@types/node": "^25.3.0",
56
67
  "babel-jest": "^30.2.0",
68
+ "commit-and-tag-version": "^12.6.1",
57
69
  "eslint": "^10.0.2",
58
70
  "jest": "^30.2.0",
59
71
  "prettier": "^3.8.1",