@drone1/alt
Version:
An AI-powered localization tool
188 lines (168 loc) • 9.99 kB
JavaScript
import * as path from 'path'
import { program } from 'commander'
import { fileURLToPath } from 'url'
import { initLocalizer } from './localizer/localize.js'
import {
DEFAULT_ACCESS_METHOD,
DEFAULT_BATCH_SIZE,
DEFAULT_CONCURRENCY,
DEFAULT_CONFIG_FILENAME,
DEFAULT_LLM_MODELS,
ENV_VARS,
LANGTAG_DEFAULT,
LOCALIZATION_SRC_DIR,
PROVIDER_ACCESS_METHODS,
VALID_TRANSLATION_PROVIDERS
} from './lib/consts.js'
import { readJsonFile } from './lib/io.js'
import { printLogo } from './lib/logo.js'
import { createLog, initLogFromOptions } from './lib/logging.js'
import { keyList, languageList } from './lib/options.js'
import { runTranslation } from './commands/translate.js'
import { registerSignalHandlers } from './shutdown.js'
import { runListModels } from './commands/list-models.js'
import { runPrune } from './commands/prune.js'
const __dirname = path.dirname(
fileURLToPath(import.meta.url)
)
// Main function
export async function run() {
const log = createLog(process.argv.includes('--dev'))
const appState = {
__dirname: path.dirname(fileURLToPath(import.meta.url)),
lang: null, // The app language, for output display (unrelated to translator)
tmpDir: null,
filesToWrite: {}, // Map of file path => JSON data to write
errors: [],
log
}
try {
registerSignalHandlers(appState)
appState.lang = await initLocalizer({
defaultAppLanguage: LANGTAG_DEFAULT,
appLanguage: process.env?.ALT_LANGUAGE,
srcDir: path.resolve(__dirname, '..', LOCALIZATION_SRC_DIR),
log
})
const p = await readJsonFile(path.resolve(__dirname, '../package.json'))
if (!p) throw new Error(`Couldn't read 'package.json'`)
// Define CLI options
program
.version(p.version)
.description(p.description)
.on('--help', () => {
log.I()
log.I('Environment variables:')
ENV_VARS.forEach(v => {
log.I(` ${v.name.padEnd(37)} ${v.description}`)
})
})
const SHARED_OPTIONS = {
'provider': {
flags: '-p, --provider <name>',
description: `AI provider vendor to use for translations (${VALID_TRANSLATION_PROVIDERS.join(', ')}); overrides any 'provider' config setting`
},
'access': {
flags: '-a, --access <method>',
description: `Access method for the provider. \`api\` uses the vendor's HTTP API with an API key (env: <PROVIDER>_API_KEY). \`harness\` uses the vendor's own CLI tool with that tool's own auth — currently only supported by anthropic via Claude Code. Per-provider support: ${Object.entries(PROVIDER_ACCESS_METHODS).map(([p, ms]) => `${p}: ${ms.join('|')}`).join('; ')}. Defaults to "${DEFAULT_ACCESS_METHOD}"; overrides any 'access' config setting`
}
}
const addSharedOptions = ({ required, notRequired, program }) => {
required = (required || []).map(k => ({ k, f: 'requiredOption' }))
notRequired = (notRequired || []).map(k => ({ k, f: 'option' }))
;[
...required,
...notRequired
].forEach(({ k, f }) => {
const so = SHARED_OPTIONS[k]
program[f](so.flags, so.description, so?.defaultValue)
})
}
const runCommand = async function() {
const command = this.name()
const options = this.opts()
if (options.logo && process.env.ALT_TEST !== '1') {
await printLogo({
fontsSrcDir: path.resolve(__dirname, '../assets/figlet-fonts/'),
tagline: p.description,
log
})
}
initLogFromOptions({ options, log })
switch (command) {
case 'translate':
await runTranslation({ appState, options, log })
break
case 'list-models':
await runListModels({ appState, options, log })
break
case 'prune':
await runPrune({ appState, options, log })
break
}
}
addSharedOptions({
notRequired: [ 'provider', 'access' ],
program: program
.command('translate', { isDefault: true })
.option('-c, --config-file <path>', `Path to config file; defaults to "${DEFAULT_CONFIG_FILENAME}" in the current working directory if not specified`)
.option('-r, --reference-file <path>', `Path to reference file of source strings to be translated. This file can be in .js, .mjs, .json, or .jsonc formats and is presumed to be` +
` in the reference language specified by --reference-language; overrides any 'referenceFile' config setting`)
.option('-o, --output-dir <path>', `Output directory for localized files; overrides any 'outputDir' config setting`)
.option('-rl, --reference-language <language>', `The reference file's language; overrides any 'referenceLanguage' config setting`)
.option('-tl, --target-languages <list>', `Comma-separated list of language codes; overrides any 'targetLanguages' config setting`, value => languageList(value, log))
.option('-k, --keys <list>', 'Comma-separated list of keys to process; if none are processed, all keys in the reference file will be processed', keyList)
.option('-R, --reference-exported-var-name <var name>', `For .js or .mjs reference files only, this will be the exported variable, e.g. for 'export default = {...}' you'd use 'default' here, or 'data' for 'export const data = { ... }'. For .json or .jsonc reference files, this value is ignored.`, 'default')
.option('-m, --app-context-message <message>', `Description of your app, to be passed along to the AI, per translation request; overrides any 'appContextMessage' config setting`)
.option('-f, --force', `Force regeneration of all keys; if no '--keys' argument is specified, all keys will be processed`, false)
.option('-rtw, --realtime-writes', 'Write updates to disk immediately, rather than on shutdown', false)
.option('-y, --tty', 'Use tty/simple renderer; useful for CI', false)
.option('-M, --model <name>', `LLM model name to use; defaults per provider/access: ${Object.entries(DEFAULT_LLM_MODELS).flatMap(([p, methods]) => Object.entries(methods).map(([a, m]) => `${p}/${a}="${m}"`)).join(', ')}; use the 'list-models' command to view all models`)
.option('-x, --max-retries <integer>', 'Maximum retries on failure', 3)
.option('-bs, --batch-size <integer>', `When the access method supports batched translation (currently only 'harness'), translate this many keys per call. Larger batches amortize CLI cold-start better but lose more work on a failed batch. Defaults to ${DEFAULT_BATCH_SIZE}; overrides any 'batchSize' config setting`, v => parseInt(v, 10))
.option('-C, --concurrency <integer>', `When the access method supports batched translation, run this many language batches in parallel. Defaults to ${DEFAULT_CONCURRENCY}; overrides any 'concurrency' config setting`, v => parseInt(v, 10))
.option('-n, --normalize-output-filenames', `Normalizes output filenames (to all lower-case); overrides any 'normalizeOutputFilenames' in config setting`, false)
.option('-N, --no-logo', `Suppress logo printout`, true) // NB: maps to options.logo, not options.noLogo
.option('-cp, --context-prefix <value>', `String to be prefixed to all keys to search for additional context, which are passed along to the AI for context`)
.option('-cs, --context-suffix <value>', `String to be suffixed to all keys to search for additional context, which are passed along to the AI for context`)
.option('-L, --look-for-context-data', `If specified, ALT will pass any context data specified in the reference file to the AI provider for translation. At least one of --contextPrefix or --contextSuffix must be specified`, false)
.option('-v, --verbose', `Enables verbose spew; forces --tty mode`, false)
.option('-d, --debug', `Enables debug spew; forces --tty mode`, false)
.option('-t, --trace', `Enables trace spew; forces --tty mode`, false)
.option('--dev', `Enable dev mode, which prints stack traces with errors`, false)
.hook('preAction', cmd => {
const opts = cmd.opts()
if (opts.lookForContextData && !(opts.contextPrefix?.length || opts.contextSuffix?.length)) {
cmd.error('--lookForContextData requires at least 1 of --contextPrefix or --contextSuffix be defined and non-empty')
}
})
.action(runCommand)
})
addSharedOptions({
required: [ 'provider' ],
notRequired: [ 'access' ],
program: program
.command('list-models')
.action(runCommand)
})
program
.command('prune')
.description('Remove keys from target files that no longer exist in the reference file')
.option('-c, --config-file <path>', `Path to config file; defaults to "${DEFAULT_CONFIG_FILENAME}" in the current working directory if not specified`)
.option('-r, --reference-file <path>', `Path to reference file of source strings. This file can be in .js, .mjs, .json, or .jsonc formats; overrides any 'referenceFile' config setting`)
.option('-o, --output-dir <path>', `Output directory for localized files; overrides any 'outputDir' config setting`)
.option('-tl, --target-languages <list>', `Comma-separated list of language codes; overrides any 'targetLanguages' config setting`, value => languageList(value, log))
.option('-R, --reference-exported-var-name <var name>', `For .js or .mjs reference files only, this will be the exported variable, e.g. for 'export default = {...}' you'd use 'default' here, or 'data' for 'export const data = { ... }'. For .json or .jsonc reference files, this value is ignored.`, 'default')
.option('-n, --normalize-output-filenames', `Normalizes output filenames (to all lower-case); overrides any 'normalizeOutputFilenames' in config setting`, false)
.option('--dry-run', 'Show what would be removed without actually modifying files', false)
.option('-N, --no-logo', `Suppress logo printout`, true) // NB: maps to options.logo, not options.noLogo
.option('-v, --verbose', `Enables verbose spew`, false)
.option('-d, --debug', `Enables debug spew`, false)
.option('-t, --trace', `Enables trace spew`, false)
.option('--dev', `Enable dev mode, which prints stack traces with errors`, false)
.action(runCommand)
program.parse(process.argv)
} catch (error) {
log.E(error)
}
}