@shipi18n/cli 1.1.5 → 2.3.0

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/bin/shipi18n.js CHANGED
@@ -1,55 +1,41 @@
1
1
  #!/usr/bin/env node
2
-
3
- import { Command } from 'commander';
4
- import chalk from 'chalk';
5
- import { translateCommand } from '../src/commands/translate.js';
6
- import { keysCommand } from '../src/commands/keys.js';
7
- import { configCommand } from '../src/commands/config.js';
8
- import { initCommand } from '../src/commands/init.js';
9
- import { readFileSync } from 'fs';
10
- import { dirname, join } from 'path';
11
- import { fileURLToPath } from 'url';
12
-
13
- const __filename = fileURLToPath(import.meta.url);
14
- const __dirname = dirname(__filename);
15
-
16
- // Read package.json for version
17
- const packageJson = JSON.parse(
18
- readFileSync(join(__dirname, '../package.json'), 'utf8')
19
- );
20
-
21
- const program = new Command();
22
-
2
+ import { Command } from 'commander'
3
+ import chalk from 'chalk'
4
+ import { readFileSync } from 'node:fs'
5
+ import { dirname, join } from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+ import { translateCommand } from '../src/commands/translate.js'
8
+ import { checkCommand } from '../src/commands/check.js'
9
+ import { lockCommand } from '../src/commands/lock.js'
10
+
11
+ const __dirname = dirname(fileURLToPath(import.meta.url))
12
+ const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf8'))
13
+
14
+ const program = new Command()
23
15
  program
24
16
  .name('shipi18n')
25
- .description('🌍 Translate your locale files with Shipi18n')
26
- .version(packageJson.version, '-v, --version', 'Output the current version')
27
- .addHelpText('after', `
17
+ .description('🌍 Open-source i18n translation — bring your own LLM key')
18
+ .version(pkg.version, '-v, --version')
19
+ .addHelpText(
20
+ 'after',
21
+ `
28
22
  ${chalk.cyan('Examples:')}
29
- $ shipi18n init
23
+ $ export ANTHROPIC_API_KEY=sk-ant-...
30
24
  $ shipi18n translate en.json --target es,fr,de
31
- $ shipi18n keys list
32
- $ shipi18n config set apiKey sk_live_...
33
-
34
- ${chalk.cyan('Get started:')}
35
- 1. Run ${chalk.yellow('shipi18n init')} to detect your i18n setup
36
- 2. Sign up at ${chalk.underline('https://shipi18n.com')} and get your API key
37
- 3. Run: ${chalk.yellow('shipi18n config set apiKey YOUR_KEY')}
38
- 4. Translate: ${chalk.yellow('shipi18n translate en.json --target es,fr')}
39
-
40
- ${chalk.gray('Documentation: https://shipi18n.com/docs/cli')}
41
- `);
42
-
43
- // Add commands
44
- initCommand(program);
45
- translateCommand(program);
46
- keysCommand(program);
47
- configCommand(program);
48
-
49
- // Parse arguments
50
- program.parse(process.argv);
51
-
52
- // Show help if no command provided
53
- if (!process.argv.slice(2).length) {
54
- program.outputHelp();
55
- }
25
+ $ shipi18n translate en.json -p openai --target ja --incremental
26
+ $ shipi18n check ./locales --source en --min-coverage 95
27
+ $ shipi18n lock ./locales --keys 'legal.*' # protect hand-edited strings
28
+
29
+ ${chalk.cyan('Bring your own LLM:')}
30
+ Set ${chalk.yellow('ANTHROPIC_API_KEY')} (default) or use ${chalk.yellow('-p openai')} with ${chalk.yellow('OPENAI_API_KEY')}.
31
+ No Shipi18n account or hosted API — your keys, your models. Apache-2.0.
32
+
33
+ ${chalk.gray('https://github.com/Shipi18n/shipi18n')}
34
+ `
35
+ )
36
+
37
+ translateCommand(program)
38
+ checkCommand(program)
39
+ lockCommand(program)
40
+ program.parse(process.argv)
41
+ if (!process.argv.slice(2).length) program.outputHelp()
package/package.json CHANGED
@@ -1,71 +1,64 @@
1
1
  {
2
2
  "name": "@shipi18n/cli",
3
- "version": "1.1.5",
4
- "description": "Command-line tool for translating locale files with Shipi18n",
5
- "main": "src/index.js",
3
+ "version": "2.3.0",
4
+ "description": "Open-source, bring-your-own-LLM i18n translation CLI. Translate locale files with your own OpenAI/Anthropic key.",
6
5
  "type": "module",
6
+ "bin": {
7
+ "shipi18n": "./bin/shipi18n.js"
8
+ },
7
9
  "files": [
8
10
  "bin",
9
- "src/commands",
10
- "src/lib",
11
- "src/utils",
12
- "src/index.js",
11
+ "src",
12
+ "!src/__tests__",
13
+ "LICENSE",
14
+ "NOTICE",
13
15
  "README.md",
14
- "NOTICE"
16
+ "CHANGELOG.md"
15
17
  ],
16
- "bin": {
17
- "shipi18n": "./bin/shipi18n.js"
18
- },
19
- "scripts": {
20
- "dev": "node bin/shipi18n.js",
21
- "build": "echo 'No build step needed for now'",
22
- "test": "NODE_OPTIONS='--experimental-vm-modules' jest",
23
- "test:watch": "NODE_OPTIONS='--experimental-vm-modules' jest --watch",
24
- "test:coverage": "NODE_OPTIONS='--experimental-vm-modules' jest --coverage"
18
+ "publishConfig": {
19
+ "access": "public"
25
20
  },
26
21
  "keywords": [
27
22
  "i18n",
23
+ "cli",
28
24
  "translation",
25
+ "llm",
26
+ "openai",
27
+ "anthropic",
29
28
  "localization",
30
- "internationalization",
31
- "locale",
32
- "translate",
33
- "cli",
34
- "i18next",
35
- "react-intl",
36
- "json-translation",
37
- "locale-files",
38
- "multilingual",
39
- "l10n",
40
- "ai-translation",
41
- "shipi18n"
29
+ "bring-your-own-key"
42
30
  ],
43
- "author": "Shipi18n",
44
31
  "license": "Apache-2.0",
32
+ "author": "Shipi18n",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/Shipi18n/shipi18n.git",
36
+ "directory": "packages/cli"
37
+ },
45
38
  "dependencies": {
46
- "archiver": "^7.0.1",
47
39
  "chalk": "^5.3.0",
48
- "commander": "^11.1.0",
49
- "dotenv": "^16.3.1",
50
- "inquirer": "^9.2.12",
51
- "ora": "^7.0.1",
52
- "yaml": "^2.3.4"
40
+ "commander": "^12.0.0",
41
+ "ora": "^8.0.1",
42
+ "@shipi18n/core": "^2.3.0"
53
43
  },
54
- "devDependencies": {
55
- "jest": "^29.7.0"
44
+ "peerDependencies": {
45
+ "@anthropic-ai/sdk": ">=0.30.0",
46
+ "openai": ">=4.0.0"
56
47
  },
57
- "engines": {
58
- "node": ">=18.0.0"
48
+ "peerDependenciesMeta": {
49
+ "@anthropic-ai/sdk": {
50
+ "optional": true
51
+ },
52
+ "openai": {
53
+ "optional": true
54
+ }
59
55
  },
60
- "repository": {
61
- "type": "git",
62
- "url": "https://github.com/Shipi18n/shipi18n-cli.git"
63
- },
64
- "bugs": {
65
- "url": "https://github.com/Shipi18n/shipi18n-cli/issues"
56
+ "devDependencies": {
57
+ "ajv": "^8.20.0",
58
+ "fast-xml-parser": "^5.10.1",
59
+ "jest": "^29.7.0"
66
60
  },
67
- "homepage": "https://shipi18n.com",
68
- "publishConfig": {
69
- "access": "public"
61
+ "scripts": {
62
+ "test": "NODE_OPTIONS='--experimental-vm-modules' jest --passWithNoTests"
70
63
  }
71
- }
64
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * `shipi18n check` — structural QA for locale files, designed to live in CI.
3
+ *
4
+ * Deterministic and offline: no LLM, no API key, no network. Reports the
5
+ * failure modes machine translation actually produces — dropped placeholders,
6
+ * collapsed plurals, missing keys, untranslated copy — and exits non-zero so a
7
+ * pipeline can gate on it.
8
+ *
9
+ * Formats: plain JSON locale trees (flat `locales/<lang>.json` or nested
10
+ * `locales/<lang>/<ns>.json`), Flutter ARB directories, and Apple String
11
+ * Catalogs (`.xcstrings`). Reporters: human, json, sarif, junit.
12
+ */
13
+ import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'node:fs'
14
+ import { resolve, dirname, join } from 'node:path'
15
+ import { fileURLToPath } from 'node:url'
16
+ import chalk from 'chalk'
17
+ import {
18
+ runCheck,
19
+ runSemantic,
20
+ discoverLayout,
21
+ compileIgnores,
22
+ statsFrom,
23
+ aggregateLanguage,
24
+ SEP,
25
+ } from '@shipi18n/core'
26
+ import { REPORTERS } from '../reporters.js'
27
+ import { locksFor, DEFAULT_LOCKS_PATH } from './lock.js'
28
+
29
+ const pkg = JSON.parse(
30
+ readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json'), 'utf8')
31
+ )
32
+
33
+ /* ------------------------------------------------------------------ engine */
34
+
35
+ // Layout discovery and tree walking live in @shipi18n/core so the CLI and the
36
+ // MCP validator tools cannot drift apart. Re-exported here because the CLI's
37
+ // tests and semantic pass are written against these names.
38
+ export { runCheck, runSemantic, discoverLayout, compileIgnores, statsFrom, aggregateLanguage, SEP }
39
+
40
+ /* ---------------------------------------------------------------- verdict */
41
+
42
+ /** Decide the exit code from findings and flags. Reporters never influence this. */
43
+ export function verdict(result, { failOn = 'error', minCoverage } = {}) {
44
+ const failures = []
45
+ if (failOn === 'error' && result.totals.errors > 0) failures.push(`${result.totals.errors} error(s)`)
46
+ if (failOn === 'warning' && result.totals.errors + result.totals.warnings > 0)
47
+ failures.push(`${result.totals.errors} error(s), ${result.totals.warnings} warning(s)`)
48
+ if (minCoverage != null) {
49
+ for (const l of result.languages) {
50
+ if (l.stats.coverage * 100 < minCoverage)
51
+ failures.push(`${l.lang} coverage ${(l.stats.coverage * 100).toFixed(1)}% < ${minCoverage}%`)
52
+ }
53
+ }
54
+ return { ok: failures.length === 0, failures }
55
+ }
56
+
57
+ /* ---------------------------------------------------------------- command */
58
+
59
+ export function checkCommand(program) {
60
+ program
61
+ .command('check [input]')
62
+ .description('Validate translated locale files against the source language (no LLM, no key)')
63
+ .option('-s, --source <language>', 'Source language', 'en')
64
+ .option('-r, --reporter <name>', 'Output format: human | json | sarif | junit', 'human')
65
+ .option('-o, --output <file>', 'Write the report to a file instead of stdout')
66
+ .option('--json', 'Shorthand for --reporter json')
67
+ .option('--ignore-keys <patterns>', "Comma-separated '*' globs of keys to silence (path or ns:path)")
68
+ .option('--fail-on <level>', 'Exit non-zero on: error | warning | none', 'error')
69
+ .option('--min-coverage <pct>', 'Fail any language below this coverage percentage', parseFloat)
70
+ .option('--glossary <file>', 'Glossary JSON: DNT terms + locked per-language translations (deterministic)')
71
+ .option('--semantic', 'Add the LLM-as-judge pass (BYO key; advisory warnings by default)')
72
+ .option('--semantic-fail', 'Escalate semantic findings to errors (opt-in)')
73
+ .option('-p, --provider <name>', 'LLM provider for --semantic: anthropic | openai', 'anthropic')
74
+ .option('--api-key <key>', 'LLM API key for --semantic (else provider env var)')
75
+ .option('--semantic-model <model>', 'Judge model override')
76
+ .option('--semantic-passes <n>', 'Judge passes for the majority vote', (v) => parseInt(v, 10), 3)
77
+ .option('--semantic-cache <file>', 'Verdict cache path', '.shipi18n/semantic-cache.json')
78
+ .option('--locks <file>', 'Manual-translation lock file', DEFAULT_LOCKS_PATH)
79
+ .option('--no-locks', 'Ignore manual-translation locks')
80
+ .action(async (input = './locales', opts) => {
81
+ let glossary
82
+ if (opts.glossary) {
83
+ try {
84
+ glossary = JSON.parse(readFileSync(opts.glossary, 'utf8'))
85
+ } catch (err) {
86
+ console.error(chalk.red(`Error: cannot read glossary ${opts.glossary}: ${err.message}`))
87
+ process.exitCode = 2
88
+ return
89
+ }
90
+ }
91
+
92
+ let result
93
+ try {
94
+ result = runCheck({
95
+ input,
96
+ source: opts.source,
97
+ ignoreKeys: opts.ignoreKeys,
98
+ glossary,
99
+ locks: locksFor(opts),
100
+ })
101
+ } catch (err) {
102
+ console.error(chalk.red(`Error: ${err.message}`))
103
+ process.exitCode = 2
104
+ return
105
+ }
106
+
107
+ if (opts.semantic) {
108
+ const cachePath = resolve(opts.semanticCache)
109
+ let cache = {}
110
+ if (existsSync(cachePath)) {
111
+ try {
112
+ cache = JSON.parse(readFileSync(cachePath, 'utf8'))
113
+ } catch {
114
+ cache = {} // a corrupt cache is just a cold cache
115
+ }
116
+ }
117
+ try {
118
+ const judge = await runSemantic(result, {
119
+ provider: opts.provider,
120
+ apiKey: opts.apiKey,
121
+ model: opts.semanticModel,
122
+ passes: opts.semanticPasses,
123
+ glossary,
124
+ cache,
125
+ fail: Boolean(opts.semanticFail),
126
+ })
127
+ mkdirSync(dirname(cachePath), { recursive: true })
128
+ writeFileSync(cachePath, JSON.stringify(cache, null, 2) + '\n')
129
+ console.error(
130
+ chalk.gray(
131
+ `semantic: judged ${judge.judged} (${judge.cached} cached), flagged ${judge.flagged}, ` +
132
+ `${judge.calls} model call(s), ${judge.parseFailures} discarded pass(es)`
133
+ )
134
+ )
135
+ } catch (err) {
136
+ console.error(chalk.red(`Semantic pass failed: ${err.message}`))
137
+ process.exitCode = 2
138
+ return
139
+ }
140
+ }
141
+
142
+ const verdictResult = verdict(result, { failOn: opts.failOn, minCoverage: opts.minCoverage })
143
+
144
+ const name = opts.json ? 'json' : opts.reporter
145
+ const reporter = REPORTERS[name]
146
+ if (!reporter) {
147
+ console.error(chalk.red(`Error: unknown reporter '${name}' (human | json | sarif | junit)`))
148
+ process.exitCode = 2
149
+ return
150
+ }
151
+ const report = reporter(result, verdictResult, { toolVersion: pkg.version })
152
+ if (opts.output) {
153
+ writeFileSync(opts.output, report.endsWith('\n') ? report : report + '\n')
154
+ if (name !== 'human') console.error(chalk.gray(`report written to ${opts.output}`))
155
+ } else {
156
+ console.log(report)
157
+ }
158
+ if (!verdictResult.ok) process.exitCode = 1
159
+ })
160
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * `shipi18n lock` — mark translations as hand-edited so `check` can tell you
3
+ * when something overwrites them, or when their source moves underneath.
4
+ *
5
+ * Writes `.shipi18n/locks.json` (safe to commit — it is the record of which
6
+ * translations a human has blessed).
7
+ */
8
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
9
+ import { resolve, dirname } from 'node:path'
10
+ import chalk from 'chalk'
11
+ import {
12
+ runCheck,
13
+ discoverLayout,
14
+ compileIgnores,
15
+ lockId,
16
+ lockEntry,
17
+ emptyLocks,
18
+ normalizeLocks,
19
+ flatten,
20
+ } from '@shipi18n/core'
21
+
22
+ export const DEFAULT_LOCKS_PATH = '.shipi18n/locks.json'
23
+
24
+ /** Tolerant read — a missing or corrupt file is simply "no locks yet". */
25
+ export function readLocks(path) {
26
+ if (!existsSync(path)) return emptyLocks()
27
+ try {
28
+ return normalizeLocks(JSON.parse(readFileSync(path, 'utf8')))
29
+ } catch {
30
+ return emptyLocks()
31
+ }
32
+ }
33
+
34
+ export function writeLocks(path, locks) {
35
+ mkdirSync(dirname(resolve(path)), { recursive: true })
36
+ writeFileSync(resolve(path), JSON.stringify(locks, null, 2) + '\n')
37
+ }
38
+
39
+ /**
40
+ * Build the lock set for a tree. Only pairs that actually exist in both source
41
+ * and target are lockable — you cannot bless a translation that isn't there.
42
+ */
43
+ export function buildLocks({ input, source = 'en', keys, existing = emptyLocks(), langs }) {
44
+ const isSelected = compileIgnores(keys) // same glob syntax as --ignore-keys
45
+ const selectAll = !keys
46
+ const layout = discoverLayout(input, source)
47
+ const locked = { ...existing.locked }
48
+ let added = 0
49
+
50
+ const sourceData = {}
51
+ for (const [ns, file] of Object.entries(layout.source)) {
52
+ sourceData[ns] = JSON.parse(readFileSync(file, 'utf8'))
53
+ }
54
+
55
+ for (const { lang, files } of layout.targets) {
56
+ if (langs && !langs.includes(lang)) continue
57
+ for (const [ns, srcObj] of Object.entries(sourceData)) {
58
+ const file = files[ns]
59
+ if (!file || !existsSync(file)) continue
60
+ let targetObj
61
+ try {
62
+ targetObj = JSON.parse(readFileSync(file, 'utf8'))
63
+ } catch {
64
+ continue // an unparseable file has nothing lockable in it
65
+ }
66
+ const src = flatten(srcObj)
67
+ const tgt = flatten(targetObj)
68
+ for (const [path, value] of Object.entries(src)) {
69
+ if (typeof value !== 'string' || typeof tgt[path] !== 'string') continue
70
+ if (!selectAll && !isSelected(ns, path)) continue
71
+ const id = lockId(lang, ns, path)
72
+ if (!(id in locked)) added++
73
+ locked[id] = lockEntry(value, tgt[path])
74
+ }
75
+ }
76
+ }
77
+ return { locks: { ...emptyLocks(), locked }, added, total: Object.keys(locked).length }
78
+ }
79
+
80
+ export function lockCommand(program) {
81
+ program
82
+ .command('lock [input]')
83
+ .description('Record translations as hand-edited, so check warns when they are overwritten')
84
+ .option('-s, --source <language>', 'Source language', 'en')
85
+ .option('-k, --keys <patterns>', "Only lock keys matching these '*' globs (default: all)")
86
+ .option('-l, --lang <languages>', 'Only lock these languages (comma-separated)')
87
+ .option('--locks <file>', 'Lock file path', DEFAULT_LOCKS_PATH)
88
+ .option('--relock', 'Update hashes for keys already locked (accept current state as blessed)')
89
+ .action((input = './locales', opts) => {
90
+ try {
91
+ const path = resolve(opts.locks)
92
+ const existing = opts.relock ? emptyLocks() : readLocks(path)
93
+ const langs = opts.lang ? opts.lang.split(',').map((l) => l.trim()) : undefined
94
+ const { locks, added, total } = buildLocks({
95
+ input,
96
+ source: opts.source,
97
+ keys: opts.keys,
98
+ existing,
99
+ langs,
100
+ })
101
+ writeLocks(path, locks)
102
+ console.log(
103
+ chalk.green(`✓ locked ${added} new translation(s); ${total} total in ${opts.locks}`)
104
+ )
105
+ if (!opts.keys) {
106
+ console.log(
107
+ chalk.gray(' Tip: --keys narrows this to the strings you actually hand-edited.')
108
+ )
109
+ }
110
+ } catch (err) {
111
+ console.error(chalk.red(`Error: ${err.message}`))
112
+ process.exitCode = 2
113
+ }
114
+ })
115
+ }
116
+
117
+ /**
118
+ * Used by `check` to load locks unless disabled.
119
+ *
120
+ * Commander pairs `--no-locks` with `--locks <file>`, so the negation arrives as
121
+ * `opts.locks === false` rather than `opts.noLocks` — checking only the latter
122
+ * made --no-locks silently do nothing (caught by an end-to-end run, not by a
123
+ * unit test that hand-built the options object).
124
+ */
125
+ export function locksFor(opts = {}) {
126
+ if (opts.locks === false || opts.noLocks) return undefined
127
+ const path = resolve(typeof opts.locks === 'string' ? opts.locks : DEFAULT_LOCKS_PATH)
128
+ if (!existsSync(path)) return undefined
129
+ const locks = readLocks(path)
130
+ return Object.keys(locks.locked).length ? locks : undefined
131
+ }