@llm-translate/cli 1.0.0-next.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.
Files changed (157) hide show
  1. package/.dockerignore +51 -0
  2. package/.env.example +33 -0
  3. package/.github/workflows/docs-pages.yml +57 -0
  4. package/.github/workflows/release.yml +49 -0
  5. package/.translaterc.json +44 -0
  6. package/CLAUDE.md +243 -0
  7. package/Dockerfile +55 -0
  8. package/README.md +371 -0
  9. package/RFC.md +1595 -0
  10. package/dist/cli/index.d.ts +2 -0
  11. package/dist/cli/index.js +4494 -0
  12. package/dist/cli/index.js.map +1 -0
  13. package/dist/index.d.ts +1152 -0
  14. package/dist/index.js +3841 -0
  15. package/dist/index.js.map +1 -0
  16. package/docker-compose.yml +56 -0
  17. package/docs/.vitepress/config.ts +161 -0
  18. package/docs/api/agent.md +262 -0
  19. package/docs/api/engine.md +274 -0
  20. package/docs/api/index.md +171 -0
  21. package/docs/api/providers.md +304 -0
  22. package/docs/changelog.md +64 -0
  23. package/docs/cli/dir.md +243 -0
  24. package/docs/cli/file.md +213 -0
  25. package/docs/cli/glossary.md +273 -0
  26. package/docs/cli/index.md +129 -0
  27. package/docs/cli/init.md +158 -0
  28. package/docs/cli/serve.md +211 -0
  29. package/docs/glossary.json +235 -0
  30. package/docs/guide/chunking.md +272 -0
  31. package/docs/guide/configuration.md +139 -0
  32. package/docs/guide/cost-optimization.md +237 -0
  33. package/docs/guide/docker.md +371 -0
  34. package/docs/guide/getting-started.md +150 -0
  35. package/docs/guide/glossary.md +241 -0
  36. package/docs/guide/index.md +86 -0
  37. package/docs/guide/ollama.md +515 -0
  38. package/docs/guide/prompt-caching.md +221 -0
  39. package/docs/guide/providers.md +232 -0
  40. package/docs/guide/quality-control.md +206 -0
  41. package/docs/guide/vitepress-integration.md +265 -0
  42. package/docs/index.md +63 -0
  43. package/docs/ja/api/agent.md +262 -0
  44. package/docs/ja/api/engine.md +274 -0
  45. package/docs/ja/api/index.md +171 -0
  46. package/docs/ja/api/providers.md +304 -0
  47. package/docs/ja/changelog.md +64 -0
  48. package/docs/ja/cli/dir.md +243 -0
  49. package/docs/ja/cli/file.md +213 -0
  50. package/docs/ja/cli/glossary.md +273 -0
  51. package/docs/ja/cli/index.md +111 -0
  52. package/docs/ja/cli/init.md +158 -0
  53. package/docs/ja/guide/chunking.md +271 -0
  54. package/docs/ja/guide/configuration.md +139 -0
  55. package/docs/ja/guide/cost-optimization.md +30 -0
  56. package/docs/ja/guide/getting-started.md +150 -0
  57. package/docs/ja/guide/glossary.md +214 -0
  58. package/docs/ja/guide/index.md +32 -0
  59. package/docs/ja/guide/ollama.md +410 -0
  60. package/docs/ja/guide/prompt-caching.md +221 -0
  61. package/docs/ja/guide/providers.md +232 -0
  62. package/docs/ja/guide/quality-control.md +137 -0
  63. package/docs/ja/guide/vitepress-integration.md +265 -0
  64. package/docs/ja/index.md +58 -0
  65. package/docs/ko/api/agent.md +262 -0
  66. package/docs/ko/api/engine.md +274 -0
  67. package/docs/ko/api/index.md +171 -0
  68. package/docs/ko/api/providers.md +304 -0
  69. package/docs/ko/changelog.md +64 -0
  70. package/docs/ko/cli/dir.md +243 -0
  71. package/docs/ko/cli/file.md +213 -0
  72. package/docs/ko/cli/glossary.md +273 -0
  73. package/docs/ko/cli/index.md +111 -0
  74. package/docs/ko/cli/init.md +158 -0
  75. package/docs/ko/guide/chunking.md +271 -0
  76. package/docs/ko/guide/configuration.md +139 -0
  77. package/docs/ko/guide/cost-optimization.md +30 -0
  78. package/docs/ko/guide/getting-started.md +150 -0
  79. package/docs/ko/guide/glossary.md +214 -0
  80. package/docs/ko/guide/index.md +32 -0
  81. package/docs/ko/guide/ollama.md +410 -0
  82. package/docs/ko/guide/prompt-caching.md +221 -0
  83. package/docs/ko/guide/providers.md +232 -0
  84. package/docs/ko/guide/quality-control.md +137 -0
  85. package/docs/ko/guide/vitepress-integration.md +265 -0
  86. package/docs/ko/index.md +58 -0
  87. package/docs/zh/api/agent.md +262 -0
  88. package/docs/zh/api/engine.md +274 -0
  89. package/docs/zh/api/index.md +171 -0
  90. package/docs/zh/api/providers.md +304 -0
  91. package/docs/zh/changelog.md +64 -0
  92. package/docs/zh/cli/dir.md +243 -0
  93. package/docs/zh/cli/file.md +213 -0
  94. package/docs/zh/cli/glossary.md +273 -0
  95. package/docs/zh/cli/index.md +111 -0
  96. package/docs/zh/cli/init.md +158 -0
  97. package/docs/zh/guide/chunking.md +271 -0
  98. package/docs/zh/guide/configuration.md +139 -0
  99. package/docs/zh/guide/cost-optimization.md +30 -0
  100. package/docs/zh/guide/getting-started.md +150 -0
  101. package/docs/zh/guide/glossary.md +214 -0
  102. package/docs/zh/guide/index.md +32 -0
  103. package/docs/zh/guide/ollama.md +410 -0
  104. package/docs/zh/guide/prompt-caching.md +221 -0
  105. package/docs/zh/guide/providers.md +232 -0
  106. package/docs/zh/guide/quality-control.md +137 -0
  107. package/docs/zh/guide/vitepress-integration.md +265 -0
  108. package/docs/zh/index.md +58 -0
  109. package/package.json +91 -0
  110. package/release.config.mjs +15 -0
  111. package/schemas/glossary.schema.json +110 -0
  112. package/src/cli/commands/dir.ts +469 -0
  113. package/src/cli/commands/file.ts +291 -0
  114. package/src/cli/commands/glossary.ts +221 -0
  115. package/src/cli/commands/init.ts +68 -0
  116. package/src/cli/commands/serve.ts +60 -0
  117. package/src/cli/index.ts +64 -0
  118. package/src/cli/options.ts +59 -0
  119. package/src/core/agent.ts +1119 -0
  120. package/src/core/chunker.ts +391 -0
  121. package/src/core/engine.ts +634 -0
  122. package/src/errors.ts +188 -0
  123. package/src/index.ts +147 -0
  124. package/src/integrations/vitepress.ts +549 -0
  125. package/src/parsers/markdown.ts +383 -0
  126. package/src/providers/claude.ts +259 -0
  127. package/src/providers/interface.ts +109 -0
  128. package/src/providers/ollama.ts +379 -0
  129. package/src/providers/openai.ts +308 -0
  130. package/src/providers/registry.ts +153 -0
  131. package/src/server/index.ts +152 -0
  132. package/src/server/middleware/auth.ts +93 -0
  133. package/src/server/middleware/logger.ts +90 -0
  134. package/src/server/routes/health.ts +84 -0
  135. package/src/server/routes/translate.ts +210 -0
  136. package/src/server/types.ts +138 -0
  137. package/src/services/cache.ts +899 -0
  138. package/src/services/config.ts +217 -0
  139. package/src/services/glossary.ts +247 -0
  140. package/src/types/analysis.ts +164 -0
  141. package/src/types/index.ts +265 -0
  142. package/src/types/modes.ts +121 -0
  143. package/src/types/mqm.ts +157 -0
  144. package/src/utils/logger.ts +141 -0
  145. package/src/utils/tokens.ts +116 -0
  146. package/tests/fixtures/glossaries/ml-glossary.json +53 -0
  147. package/tests/fixtures/input/lynq-installation.ko.md +350 -0
  148. package/tests/fixtures/input/lynq-installation.md +350 -0
  149. package/tests/fixtures/input/simple.ko.md +27 -0
  150. package/tests/fixtures/input/simple.md +27 -0
  151. package/tests/unit/chunker.test.ts +229 -0
  152. package/tests/unit/glossary.test.ts +146 -0
  153. package/tests/unit/markdown.test.ts +205 -0
  154. package/tests/unit/tokens.test.ts +81 -0
  155. package/tsconfig.json +28 -0
  156. package/tsup.config.ts +34 -0
  157. package/vitest.config.ts +16 -0
package/package.json ADDED
@@ -0,0 +1,91 @@
1
+ {
2
+ "name": "@llm-translate/cli",
3
+ "version": "1.0.0-next.1",
4
+ "description": "CLI-based document translation tool powered by LLMs with glossary enforcement and quality-aware refinement",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "bin": {
9
+ "llm-translate": "./dist/cli/index.js"
10
+ },
11
+ "exports": {
12
+ ".": {
13
+ "import": "./dist/index.js",
14
+ "types": "./dist/index.d.ts"
15
+ }
16
+ },
17
+ "scripts": {
18
+ "build": "tsup",
19
+ "dev": "tsup --watch",
20
+ "start": "node ./dist/cli/index.js",
21
+ "serve": "node ./dist/cli/index.js serve",
22
+ "test": "vitest",
23
+ "test:run": "vitest run",
24
+ "test:coverage": "vitest run --coverage",
25
+ "lint": "eslint src --ext .ts",
26
+ "typecheck": "tsc --noEmit",
27
+ "prepublishOnly": "npm run build",
28
+ "docs:dev": "vitepress dev docs",
29
+ "docs:build": "vitepress build docs",
30
+ "docs:preview": "vitepress preview docs",
31
+ "docs:translate": "npm run docs:translate:ko && npm run docs:translate:ja && npm run docs:translate:zh",
32
+ "docs:translate:ko": "node ./dist/cli/index.js dir ./docs ./docs/ko --target-lang ko --glossary ./docs/glossary.json --parallel 8 --no-cache",
33
+ "docs:translate:ja": "node ./dist/cli/index.js dir ./docs ./docs/ja --target-lang ja --glossary ./docs/glossary.json --parallel 8 --no-cache",
34
+ "docs:translate:zh": "node ./dist/cli/index.js dir ./docs ./docs/zh --target-lang zh --glossary ./docs/glossary.json --parallel 8 --no-cache"
35
+ },
36
+ "keywords": [
37
+ "translation",
38
+ "llm",
39
+ "cli",
40
+ "markdown",
41
+ "glossary",
42
+ "ai",
43
+ "claude",
44
+ "openai"
45
+ ],
46
+ "author": "Tim Kang",
47
+ "license": "MIT",
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "https://github.com/selenehyun/llm-translate.git"
51
+ },
52
+ "publishConfig": {
53
+ "access": "public"
54
+ },
55
+ "engines": {
56
+ "node": ">=20.0.0"
57
+ },
58
+ "dependencies": {
59
+ "@ai-sdk/anthropic": "^1.0.0",
60
+ "@ai-sdk/openai": "^1.0.0",
61
+ "@anthropic-ai/sdk": "^0.39.0",
62
+ "@hono/node-server": "^1.14.0",
63
+ "@hono/zod-validator": "^0.4.3",
64
+ "ai": "^4.0.0",
65
+ "chalk": "^5.3.0",
66
+ "cheerio": "^1.0.0",
67
+ "commander": "^12.1.0",
68
+ "cosmiconfig": "^9.0.0",
69
+ "hono": "^4.7.0",
70
+ "ora": "^8.1.0",
71
+ "remark-gfm": "^4.0.0",
72
+ "remark-parse": "^11.0.0",
73
+ "remark-stringify": "^11.0.0",
74
+ "unified": "^11.0.5",
75
+ "unist-util-visit": "^5.0.0",
76
+ "zod": "^3.23.8"
77
+ },
78
+ "devDependencies": {
79
+ "@types/mdast": "^4.0.4",
80
+ "semantic-release": "^24.0.0",
81
+ "@types/node": "^22.10.1",
82
+ "@typescript-eslint/eslint-plugin": "^8.17.0",
83
+ "@typescript-eslint/parser": "^8.17.0",
84
+ "@vitest/coverage-v8": "^2.1.8",
85
+ "eslint": "^9.16.0",
86
+ "tsup": "^8.3.5",
87
+ "typescript": "^5.7.2",
88
+ "vitepress": "^1.6.4",
89
+ "vitest": "^2.1.8"
90
+ }
91
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * @type {import('semantic-release').GlobalConfig}
3
+ */
4
+ export default {
5
+ branches: [
6
+ 'main',
7
+ { name: 'develop', channel: 'next', prerelease: 'next' },
8
+ ],
9
+ plugins: [
10
+ '@semantic-release/commit-analyzer',
11
+ '@semantic-release/release-notes-generator',
12
+ '@semantic-release/npm',
13
+ '@semantic-release/github',
14
+ ],
15
+ };
@@ -0,0 +1,110 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://github.com/selenehyun/llm-translate/schemas/glossary.schema.json",
4
+ "title": "llm-translate Glossary",
5
+ "description": "Schema for llm-translate glossary files",
6
+ "type": "object",
7
+ "required": ["metadata", "terms"],
8
+ "properties": {
9
+ "$schema": {
10
+ "type": "string",
11
+ "description": "JSON Schema reference"
12
+ },
13
+ "metadata": {
14
+ "type": "object",
15
+ "description": "Glossary metadata",
16
+ "required": ["name", "sourceLang", "targetLangs", "version"],
17
+ "properties": {
18
+ "name": {
19
+ "type": "string",
20
+ "description": "Unique identifier for the glossary"
21
+ },
22
+ "sourceLang": {
23
+ "type": "string",
24
+ "description": "Source language code (e.g., 'en', 'ko')",
25
+ "pattern": "^[a-z]{2}(-[A-Z]{2})?$"
26
+ },
27
+ "targetLangs": {
28
+ "type": "array",
29
+ "description": "List of target language codes",
30
+ "items": {
31
+ "type": "string",
32
+ "pattern": "^[a-z]{2}(-[A-Z]{2})?$"
33
+ },
34
+ "minItems": 1
35
+ },
36
+ "version": {
37
+ "type": "string",
38
+ "description": "Glossary version (semver recommended)",
39
+ "pattern": "^\\d+\\.\\d+\\.\\d+$"
40
+ },
41
+ "domain": {
42
+ "type": "string",
43
+ "description": "Domain or category of the glossary (e.g., 'technical-documentation', 'legal')"
44
+ }
45
+ },
46
+ "additionalProperties": false
47
+ },
48
+ "terms": {
49
+ "type": "array",
50
+ "description": "List of glossary terms",
51
+ "items": {
52
+ "$ref": "#/$defs/term"
53
+ }
54
+ }
55
+ },
56
+ "additionalProperties": false,
57
+ "$defs": {
58
+ "term": {
59
+ "type": "object",
60
+ "description": "A single glossary term",
61
+ "required": ["source", "targets"],
62
+ "properties": {
63
+ "source": {
64
+ "type": "string",
65
+ "description": "Source term to match",
66
+ "minLength": 1
67
+ },
68
+ "targets": {
69
+ "type": "object",
70
+ "description": "Target translations keyed by language code",
71
+ "additionalProperties": {
72
+ "type": "string"
73
+ }
74
+ },
75
+ "context": {
76
+ "type": "string",
77
+ "description": "Context or usage hint for the translator"
78
+ },
79
+ "caseSensitive": {
80
+ "type": "boolean",
81
+ "description": "Whether matching should be case-sensitive. When false (default), 'API' matches 'api', 'Api', 'API', etc.",
82
+ "default": false
83
+ },
84
+ "doNotTranslate": {
85
+ "type": "boolean",
86
+ "description": "Keep the source term unchanged in all languages",
87
+ "default": false
88
+ },
89
+ "doNotTranslateFor": {
90
+ "type": "array",
91
+ "description": "Keep the source term unchanged for specific languages",
92
+ "items": {
93
+ "type": "string",
94
+ "pattern": "^[a-z]{2}(-[A-Z]{2})?$"
95
+ }
96
+ },
97
+ "partOfSpeech": {
98
+ "type": "string",
99
+ "description": "Part of speech for the term",
100
+ "enum": ["noun", "verb", "adjective", "other"]
101
+ },
102
+ "notes": {
103
+ "type": "string",
104
+ "description": "Additional notes for translators"
105
+ }
106
+ },
107
+ "additionalProperties": false
108
+ }
109
+ }
110
+ }
@@ -0,0 +1,469 @@
1
+ import { Command } from 'commander';
2
+ import { readFile, writeFile, readdir, mkdir } from 'node:fs/promises';
3
+ import { resolve, dirname, relative, join } from 'node:path';
4
+ import type { DirCommandOptions } from '../options.js';
5
+ import { defaults } from '../options.js';
6
+ import { loadConfig, mergeConfig } from '../../services/config.js';
7
+ import { createTranslationEngine } from '../../core/engine.js';
8
+ import { logger, configureLogger } from '../../utils/logger.js';
9
+ import { TranslationError, getExitCode } from '../../errors.js';
10
+ import type { DocumentFormat, DocumentResult } from '../../types/index.js';
11
+
12
+ export const dirCommand = new Command('dir')
13
+ .description('Translate all files in a directory')
14
+ .argument('<input>', 'Input directory path')
15
+ .argument('<output>', 'Output directory path')
16
+ .option('-s, --source-lang <lang>', 'Source language code')
17
+ .option('-t, --target-lang <lang>', 'Target language code')
18
+ .option('-g, --glossary <path>', 'Path to glossary file')
19
+ .option(
20
+ '-p, --provider <name>',
21
+ 'LLM provider (claude|openai|ollama)',
22
+ defaults.provider
23
+ )
24
+ .option('-m, --model <name>', 'Model name')
25
+ .option(
26
+ '--quality <0-100>',
27
+ 'Quality threshold',
28
+ String(defaults.quality)
29
+ )
30
+ .option(
31
+ '--max-iterations <n>',
32
+ 'Max refinement iterations',
33
+ String(defaults.maxIterations)
34
+ )
35
+ .option('-f, --format <fmt>', 'Force output format (md|html|txt)')
36
+ .option('--dry-run', 'Show what would be translated')
37
+ .option('--json', 'Output results as JSON')
38
+ .option(
39
+ '--chunk-size <tokens>',
40
+ 'Max tokens per chunk',
41
+ String(defaults.chunkSize)
42
+ )
43
+ .option(
44
+ '--parallel <n>',
45
+ 'Parallel file processing',
46
+ String(defaults.parallel)
47
+ )
48
+ .option('--no-cache', 'Disable translation cache')
49
+ .option('--context <text>', 'Additional context for translation')
50
+ .option('--include <patterns>', 'File patterns to include (comma-separated)', '*.md,*.markdown')
51
+ .option('--exclude <patterns>', 'File patterns to exclude (comma-separated)')
52
+ .option('-v, --verbose', 'Enable verbose logging')
53
+ .option('-q, --quiet', 'Suppress non-error output')
54
+ .action(async (input: string, output: string, options: DirCommandOptions & { include?: string; exclude?: string }) => {
55
+ try {
56
+ // Configure logger
57
+ configureLogger({
58
+ level: options.verbose ? 'debug' : 'info',
59
+ quiet: options.quiet ?? false,
60
+ json: options.json ?? false,
61
+ });
62
+
63
+ // Validate required options
64
+ if (!options.targetLang) {
65
+ console.error('Error: Target language (-t, --target-lang) is required');
66
+ process.exit(2);
67
+ }
68
+
69
+ // Resolve paths
70
+ const inputDir = resolve(input);
71
+ const outputDir = resolve(output);
72
+
73
+ // Parse include/exclude patterns
74
+ const includePatterns = options.include?.split(',').map(p => p.trim()) ?? ['*.md', '*.markdown'];
75
+ const excludePatterns = options.exclude?.split(',').map(p => p.trim()) ?? [];
76
+
77
+ // Find all matching files (exclude output directory and locale directories)
78
+ const files = await findFiles(inputDir, includePatterns, excludePatterns, outputDir);
79
+
80
+ if (files.length === 0) {
81
+ console.log('No files found matching the specified patterns');
82
+ return;
83
+ }
84
+
85
+ if (!options.quiet) {
86
+ logger.info(`Found ${files.length} file(s) to translate`);
87
+ logger.info(`Input: ${inputDir}`);
88
+ logger.info(`Output: ${outputDir}`);
89
+ logger.info(`Target language: ${options.targetLang}`);
90
+ if (options.glossary) {
91
+ logger.info(`Glossary: ${resolve(options.glossary)}`);
92
+ }
93
+ }
94
+
95
+ // Dry run - just show what would be done
96
+ if (options.dryRun) {
97
+ console.log('\nDry run mode - no translation will be performed\n');
98
+ console.log('Files to translate:');
99
+ for (const file of files) {
100
+ const relativePath = relative(inputDir, file);
101
+ const outputPath = join(outputDir, relativePath);
102
+ console.log(` ${relativePath} → ${relative(process.cwd(), outputPath)}`);
103
+ }
104
+ console.log(`\nTotal: ${files.length} file(s)`);
105
+ return;
106
+ }
107
+
108
+ // Load configuration
109
+ const baseConfig = await loadConfig({ configPath: options.config });
110
+ const config = mergeConfig(baseConfig, {
111
+ sourceLang: options.sourceLang,
112
+ targetLang: options.targetLang,
113
+ provider: options.provider,
114
+ model: options.model,
115
+ quality: options.quality ? parseInt(options.quality, 10) : undefined,
116
+ maxIterations: options.maxIterations ? parseInt(options.maxIterations, 10) : undefined,
117
+ chunkSize: options.chunkSize ? parseInt(options.chunkSize, 10) : undefined,
118
+ glossary: options.glossary,
119
+ noCache: options.cache === false,
120
+ });
121
+
122
+ // Create translation engine
123
+ const engine = createTranslationEngine({
124
+ config,
125
+ verbose: options.verbose,
126
+ noCache: options.cache === false,
127
+ });
128
+
129
+ // Process files
130
+ const parallelCount = typeof options.parallel === 'string'
131
+ ? parseInt(options.parallel, 10)
132
+ : (options.parallel ?? defaults.parallel);
133
+
134
+ if (!options.quiet) {
135
+ logger.info(`Parallel processing: ${parallelCount} file(s) at a time`);
136
+ }
137
+
138
+ const results = await processFiles(
139
+ files,
140
+ inputDir,
141
+ outputDir,
142
+ engine,
143
+ options,
144
+ parallelCount
145
+ );
146
+
147
+ // Output results
148
+ outputResults(results, options);
149
+
150
+ } catch (error) {
151
+ if (error instanceof TranslationError) {
152
+ console.error(`Error: ${error.message}`);
153
+ process.exit(getExitCode(error));
154
+ }
155
+ console.error('Error:', error instanceof Error ? error.message : error);
156
+ process.exit(1);
157
+ }
158
+ });
159
+
160
+ // ============================================================================
161
+ // Types
162
+ // ============================================================================
163
+
164
+ interface FileResult {
165
+ inputPath: string;
166
+ outputPath: string;
167
+ relativePath: string;
168
+ success: boolean;
169
+ error?: string;
170
+ result?: DocumentResult;
171
+ duration: number;
172
+ }
173
+
174
+ interface DirResults {
175
+ files: FileResult[];
176
+ totalDuration: number;
177
+ successCount: number;
178
+ failCount: number;
179
+ totalTokensInput: number;
180
+ totalTokensOutput: number;
181
+ totalCacheRead: number;
182
+ totalCacheWrite: number;
183
+ }
184
+
185
+ // ============================================================================
186
+ // Helper Functions
187
+ // ============================================================================
188
+
189
+ /**
190
+ * Find all files matching the include patterns and not matching exclude patterns
191
+ */
192
+ async function findFiles(
193
+ dir: string,
194
+ includePatterns: string[],
195
+ excludePatterns: string[],
196
+ outputDir?: string
197
+ ): Promise<string[]> {
198
+ const files: string[] = [];
199
+
200
+ // If output is inside input dir, exclude it
201
+ const outputRelative = outputDir ? relative(dir, outputDir) : null;
202
+ const isOutputInsideInput = outputRelative && !outputRelative.startsWith('..');
203
+
204
+ async function scan(currentDir: string): Promise<void> {
205
+ const entries = await readdir(currentDir, { withFileTypes: true });
206
+
207
+ for (const entry of entries) {
208
+ const fullPath = join(currentDir, entry.name);
209
+ const relativePath = relative(dir, fullPath);
210
+
211
+ // Skip hidden files and directories
212
+ if (entry.name.startsWith('.')) {
213
+ continue;
214
+ }
215
+
216
+ if (entry.isDirectory()) {
217
+ // Skip output directory if it's inside input directory
218
+ if (isOutputInsideInput && relativePath === outputRelative) {
219
+ continue;
220
+ }
221
+
222
+ // Also skip common locale directories (2-letter codes like ko, ja, zh)
223
+ if (/^[a-z]{2}(-[A-Z]{2})?$/.test(entry.name)) {
224
+ continue;
225
+ }
226
+
227
+ // Check if directory should be excluded
228
+ if (!matchesPatterns(relativePath + '/', excludePatterns)) {
229
+ await scan(fullPath);
230
+ }
231
+ } else if (entry.isFile()) {
232
+ // Check if file matches include patterns and not exclude patterns
233
+ if (
234
+ matchesPatterns(entry.name, includePatterns) &&
235
+ !matchesPatterns(relativePath, excludePatterns)
236
+ ) {
237
+ files.push(fullPath);
238
+ }
239
+ }
240
+ }
241
+ }
242
+
243
+ await scan(dir);
244
+ return files.sort();
245
+ }
246
+
247
+ /**
248
+ * Check if a path matches any of the given glob patterns
249
+ */
250
+ function matchesPatterns(path: string, patterns: string[]): boolean {
251
+ for (const pattern of patterns) {
252
+ if (matchGlob(path, pattern)) {
253
+ return true;
254
+ }
255
+ }
256
+ return false;
257
+ }
258
+
259
+ /**
260
+ * Simple glob matching (supports * and **)
261
+ */
262
+ function matchGlob(path: string, pattern: string): boolean {
263
+ // Convert glob pattern to regex
264
+ const regexPattern = pattern
265
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape special chars
266
+ .replace(/\*\*/g, '{{DOUBLESTAR}}') // Temp placeholder for **
267
+ .replace(/\*/g, '[^/]*') // * matches anything except /
268
+ .replace(/{{DOUBLESTAR}}/g, '.*') // ** matches anything including /
269
+ .replace(/\?/g, '.'); // ? matches single char
270
+
271
+ const regex = new RegExp(`^${regexPattern}$`);
272
+ return regex.test(path);
273
+ }
274
+
275
+ /**
276
+ * Process files with parallel execution using worker pool pattern.
277
+ * As soon as one file completes, the next file starts immediately.
278
+ */
279
+ async function processFiles(
280
+ files: string[],
281
+ inputDir: string,
282
+ outputDir: string,
283
+ engine: ReturnType<typeof createTranslationEngine>,
284
+ options: DirCommandOptions & { include?: string; exclude?: string },
285
+ parallelCount: number
286
+ ): Promise<DirResults> {
287
+ const startTime = Date.now();
288
+ const results: FileResult[] = new Array(files.length);
289
+ let completed = 0;
290
+ let nextIndex = 0;
291
+
292
+ // Process a single file and return the result
293
+ const processFile = async (inputPath: string, _index: number): Promise<FileResult> => {
294
+ const relativePath = relative(inputDir, inputPath);
295
+ const outputPath = join(outputDir, relativePath);
296
+ const fileStartTime = Date.now();
297
+
298
+ try {
299
+ // Read input file
300
+ const content = await readFile(inputPath, 'utf-8');
301
+
302
+ // Translate
303
+ const result = await engine.translateContent({
304
+ content,
305
+ sourceLang: options.sourceLang,
306
+ targetLang: options.targetLang!,
307
+ format: mapFormat(options.format),
308
+ glossaryPath: options.glossary,
309
+ qualityThreshold: options.quality ? parseInt(options.quality, 10) : undefined,
310
+ maxIterations: options.maxIterations ? parseInt(options.maxIterations, 10) : undefined,
311
+ context: options.context,
312
+ });
313
+
314
+ // Ensure output directory exists
315
+ await mkdir(dirname(outputPath), { recursive: true });
316
+
317
+ // Write output
318
+ await writeFile(outputPath, result.content, 'utf-8');
319
+
320
+ completed++;
321
+ if (!options.quiet && !options.json) {
322
+ const progress = `[${completed}/${files.length}]`;
323
+ logger.success(`${progress} ${relativePath}`);
324
+ }
325
+
326
+ return {
327
+ inputPath,
328
+ outputPath,
329
+ relativePath,
330
+ success: true,
331
+ result,
332
+ duration: Date.now() - fileStartTime,
333
+ };
334
+ } catch (error) {
335
+ completed++;
336
+ const errorMessage = error instanceof Error ? error.message : String(error);
337
+
338
+ if (!options.quiet && !options.json) {
339
+ const progress = `[${completed}/${files.length}]`;
340
+ logger.error(`${progress} ${relativePath}: ${errorMessage}`);
341
+ }
342
+
343
+ return {
344
+ inputPath,
345
+ outputPath,
346
+ relativePath,
347
+ success: false,
348
+ error: errorMessage,
349
+ duration: Date.now() - fileStartTime,
350
+ };
351
+ }
352
+ };
353
+
354
+ // Worker function: continuously process files until none remain
355
+ const worker = async (): Promise<void> => {
356
+ while (true) {
357
+ const index = nextIndex++;
358
+ if (index >= files.length) break;
359
+
360
+ const inputPath = files[index];
361
+ if (!inputPath) break;
362
+
363
+ const result = await processFile(inputPath, index);
364
+ results[index] = result;
365
+ }
366
+ };
367
+
368
+ // Start workers (up to parallelCount or file count, whichever is smaller)
369
+ const workerCount = Math.min(parallelCount, files.length);
370
+ const workers = Array.from({ length: workerCount }, () => worker());
371
+
372
+ // Wait for all workers to complete
373
+ await Promise.all(workers);
374
+
375
+ // Calculate totals
376
+ const successResults = results.filter(r => r.success && r.result);
377
+ const totalTokensInput = successResults.reduce((sum, r) => sum + (r.result?.metadata.tokensUsed.input ?? 0), 0);
378
+ const totalTokensOutput = successResults.reduce((sum, r) => sum + (r.result?.metadata.tokensUsed.output ?? 0), 0);
379
+ const totalCacheRead = successResults.reduce((sum, r) => sum + (r.result?.metadata.tokensUsed.cacheRead ?? 0), 0);
380
+ const totalCacheWrite = successResults.reduce((sum, r) => sum + (r.result?.metadata.tokensUsed.cacheWrite ?? 0), 0);
381
+
382
+ return {
383
+ files: results,
384
+ totalDuration: Date.now() - startTime,
385
+ successCount: results.filter(r => r.success).length,
386
+ failCount: results.filter(r => !r.success).length,
387
+ totalTokensInput,
388
+ totalTokensOutput,
389
+ totalCacheRead,
390
+ totalCacheWrite,
391
+ };
392
+ }
393
+
394
+ /**
395
+ * Output results summary
396
+ */
397
+ function outputResults(results: DirResults, options: DirCommandOptions): void {
398
+ if (options.json) {
399
+ console.log(JSON.stringify({
400
+ success: results.failCount === 0,
401
+ totalFiles: results.files.length,
402
+ successCount: results.successCount,
403
+ failCount: results.failCount,
404
+ totalDuration: results.totalDuration,
405
+ tokensUsed: {
406
+ input: results.totalTokensInput,
407
+ output: results.totalTokensOutput,
408
+ cacheRead: results.totalCacheRead,
409
+ cacheWrite: results.totalCacheWrite,
410
+ },
411
+ files: results.files.map(f => ({
412
+ input: f.relativePath,
413
+ output: f.outputPath,
414
+ success: f.success,
415
+ error: f.error,
416
+ duration: f.duration,
417
+ quality: f.result?.metadata.averageQuality ?? 0,
418
+ tokens: f.result ? {
419
+ input: f.result.metadata.tokensUsed.input,
420
+ output: f.result.metadata.tokensUsed.output,
421
+ } : undefined,
422
+ })),
423
+ }, null, 2));
424
+ return;
425
+ }
426
+
427
+ if (options.quiet) {
428
+ return;
429
+ }
430
+
431
+ console.log('');
432
+ console.log('─'.repeat(60));
433
+ console.log(' Translation Summary');
434
+ console.log('─'.repeat(60));
435
+ console.log(` Files: ${results.successCount} succeeded, ${results.failCount} failed`);
436
+ console.log(` Duration: ${(results.totalDuration / 1000).toFixed(1)}s`);
437
+ console.log(` Tokens: ${results.totalTokensInput.toLocaleString()} input / ${results.totalTokensOutput.toLocaleString()} output`);
438
+
439
+ if (results.totalCacheRead > 0 || results.totalCacheWrite > 0) {
440
+ console.log(` Cache: ${results.totalCacheRead.toLocaleString()} read / ${results.totalCacheWrite.toLocaleString()} write`);
441
+ }
442
+
443
+ if (results.failCount > 0) {
444
+ console.log('');
445
+ console.log(' Failed files:');
446
+ for (const file of results.files.filter(f => !f.success)) {
447
+ console.log(` - ${file.relativePath}: ${file.error}`);
448
+ }
449
+ }
450
+
451
+ console.log('─'.repeat(60));
452
+ }
453
+
454
+ function mapFormat(format: string | undefined): DocumentFormat | undefined {
455
+ if (!format) return undefined;
456
+
457
+ switch (format.toLowerCase()) {
458
+ case 'md':
459
+ case 'markdown':
460
+ return 'markdown';
461
+ case 'html':
462
+ return 'html';
463
+ case 'txt':
464
+ case 'text':
465
+ return 'text';
466
+ default:
467
+ return undefined;
468
+ }
469
+ }