cctrans 0.1.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/src/langs.js ADDED
@@ -0,0 +1,94 @@
1
+ 'use strict';
2
+ // Supported target languages (CJK + Russian + Hindi — non-Latin scripts only,
3
+ // so "already in target language" detection can be done by Unicode script
4
+ // ranges).
5
+ //
6
+ // Canonical codes use BCP-47 SCRIPT subtags for Chinese (zh-Hans / zh-Hant):
7
+ // Traditional Chinese is a script, not a region — zh-TW/zh-HK are kept as
8
+ // ALIASES for muscle memory and normalize to the script code.
9
+ //
10
+ // Each entry: display name (for LLM prompts), per-backend language codes, and
11
+ // a script regex used to skip lines that are already in the target language.
12
+
13
+ const LANGS = {
14
+ 'zh-Hans': {
15
+ name: 'Simplified Chinese',
16
+ google: 'zh-CN', deepl: 'ZH-HANS', azure: 'zh-Hans',
17
+ script: /[一-鿿㐀-䶿]/g, // Han
18
+ },
19
+ 'zh-Hant': {
20
+ name: 'Traditional Chinese',
21
+ google: 'zh-TW', deepl: 'ZH-HANT', azure: 'zh-Hant',
22
+ script: /[一-鿿㐀-䶿]/g, // Han
23
+ },
24
+ ja: {
25
+ name: 'Japanese',
26
+ google: 'ja', deepl: 'JA', azure: 'ja',
27
+ script: /[぀-ゟ゠-ヿ一-鿿]/g, // Kana + Han
28
+ },
29
+ ko: {
30
+ name: 'Korean',
31
+ google: 'ko', deepl: 'KO', azure: 'ko',
32
+ script: /[가-힯ᄀ-ᇿ㄰-㆏]/g, // Hangul
33
+ },
34
+ ru: {
35
+ name: 'Russian',
36
+ google: 'ru', deepl: 'RU', azure: 'ru',
37
+ script: /[Ѐ-ӿ]/g, // Cyrillic
38
+ },
39
+ hi: {
40
+ name: 'Hindi',
41
+ google: 'hi', deepl: 'HI', azure: 'hi',
42
+ script: /[ऀ-ॿ]/g, // Devanagari
43
+ },
44
+ en: {
45
+ name: 'English',
46
+ google: 'en', deepl: 'EN-US', azure: 'en',
47
+ script: /[A-Za-z]/g, // Latin — used by input translation (prompt -> English)
48
+ },
49
+ };
50
+
51
+ // Combined non-Latin script regex: "is this text written in one of the
52
+ // supported non-English languages?" Used by the input-translation hook.
53
+ const NON_LATIN = /[一-鿿㐀-䶿぀-ゟ゠-ヿ가-힯ᄀ-ᇿЀ-ӿऀ-ॿ]/g;
54
+
55
+ function nonLatinRatio(text) {
56
+ const hits = (text.match(NON_LATIN) || []).length;
57
+ const nonspace = text.replace(/\s/g, '').length;
58
+ return nonspace === 0 ? 0 : hits / nonspace;
59
+ }
60
+
61
+ // Region-code (and bare-zh) aliases -> canonical script codes.
62
+ const ALIASES = {
63
+ zh: 'zh-Hans',
64
+ 'zh-CN': 'zh-Hans',
65
+ 'zh-SG': 'zh-Hans',
66
+ 'zh-TW': 'zh-Hant',
67
+ 'zh-HK': 'zh-Hant',
68
+ 'zh-MO': 'zh-Hant',
69
+ };
70
+
71
+ function normalizeLang(code) {
72
+ return ALIASES[code] || code;
73
+ }
74
+
75
+ function getLang(code) {
76
+ return LANGS[normalizeLang(code)] || null;
77
+ }
78
+
79
+ function listLangs() {
80
+ // 'en' is reserved for the input-translation direction (prompt -> English);
81
+ // it's resolvable via getLang but not advertised as an overlay target.
82
+ return Object.keys(LANGS).filter((k) => k !== 'en');
83
+ }
84
+
85
+ // True if the line is (mostly) already written in the target language's script.
86
+ function isProbablyTarget(line, code) {
87
+ const lang = getLang(code);
88
+ if (!lang) return false;
89
+ const hits = (line.match(lang.script) || []).length;
90
+ const nonspace = line.replace(/\s/g, '').length;
91
+ return nonspace > 0 && hits / nonspace >= 0.3;
92
+ }
93
+
94
+ module.exports = { LANGS, getLang, listLangs, isProbablyTarget, normalizeLang, nonLatinRatio };
package/src/setup.js ADDED
@@ -0,0 +1,99 @@
1
+ 'use strict';
2
+ // Interactive setup wizard: language -> backend -> API-key entry -> live
3
+ // verification -> save. Re-runnable via `tt setup`; non-interactive with
4
+ // flags (--lang, --backend, --key, --yes). Keys go to keys.json only — the
5
+ // shell environment is never read.
6
+
7
+ const readline = require('node:readline/promises');
8
+ const { getState, setState } = require('./config');
9
+ const { listLangs, getLang, normalizeLang } = require('./langs');
10
+ const { listBackends, getBackend } = require('./backends');
11
+ const keys = require('./keys');
12
+ const { buildDisplayContent } = require('./interleave');
13
+
14
+ const C = {
15
+ dim: (s) => '\x1b[2m' + s + '\x1b[0m',
16
+ cyan: (s) => '\x1b[36m' + s + '\x1b[0m',
17
+ green: (s) => '\x1b[32m' + s + '\x1b[0m',
18
+ red: (s) => '\x1b[31m' + s + '\x1b[0m',
19
+ bold: (s) => '\x1b[1m' + s + '\x1b[0m',
20
+ };
21
+
22
+ async function runSetup(opts) {
23
+ opts = opts || {};
24
+ const interactive = !opts.yes && process.stdin.isTTY;
25
+ const rl = interactive
26
+ ? readline.createInterface({ input: process.stdin, output: process.stdout })
27
+ : null;
28
+ const ask = async (q, def) => {
29
+ if (!rl) return def;
30
+ const a = (await rl.question(q + (def ? C.dim(' [' + def + '] ') : ' '))).trim();
31
+ return a || def;
32
+ };
33
+
34
+ try {
35
+ console.log(C.bold('cctranslate setup') + C.dim(' (re-run anytime: tt setup)'));
36
+
37
+ // 1. Target language
38
+ let lang = opts.lang;
39
+ if (!lang) {
40
+ const codes = listLangs();
41
+ console.log('\n' + C.bold('Target language') + ' — translations appear under each English line:');
42
+ codes.forEach((c, i) => console.log(' ' + (i + 1) + '. ' + c.padEnd(8) + C.dim(getLang(c).name)));
43
+ const cur = getState().target;
44
+ const a = await ask('Pick a number or code', cur);
45
+ lang = /^\d+$/.test(a) ? codes[parseInt(a, 10) - 1] : a;
46
+ }
47
+ if (!getLang(lang)) { console.error(C.red('unsupported language: ' + lang)); return false; }
48
+ lang = normalizeLang(lang);
49
+
50
+ // 2. Backend
51
+ let backend = opts.backend;
52
+ if (!backend) {
53
+ console.log('\n' + C.bold('Translation backend') + ':');
54
+ for (const b of listBackends()) {
55
+ console.log(' ' + b.id.padEnd(12) + (b.available() ? C.green('ready ') : C.red('no key ')) + C.dim(b.needs));
56
+ }
57
+ const def = getState().backend && getBackend(getState().backend) && getBackend(getState().backend).available()
58
+ ? getState().backend
59
+ : (getBackend('openai').available() ? 'openai' : 'google');
60
+ backend = await ask('Pick a backend', def);
61
+ }
62
+ const b = getBackend(backend);
63
+ if (!b) { console.error(C.red('unknown backend: ' + backend)); return false; }
64
+
65
+ // 3. Key entry for the chosen backend, if missing (keys live ONLY in
66
+ // keys.json — shell env vars are never read)
67
+ if (!b.available() && keys.KEY_IDS.includes(b.id)) {
68
+ const v = opts.key || (await ask('Paste your ' + b.id + ' API key (enter to skip)', ''));
69
+ if (v) { keys.setKey(b.id, v); console.log(C.green('✓') + ' key saved to ' + keys.KEYS_FILE + C.dim(' (chmod 600)')); }
70
+ if (b.id === 'azure' && !keys.getKey('azure-region')) {
71
+ const r = await ask('Azure region (enter to skip)', '');
72
+ if (r) keys.setKey('azure-region', r);
73
+ }
74
+ }
75
+
76
+ // 4. Save config
77
+ setState({ target: lang, backend });
78
+ console.log('\n' + C.green('✓') + ' saved: lang=' + lang + ' (' + getLang(lang).name + '), backend=' + backend +
79
+ (b.available() ? '' : C.red(' (no key yet — will fall back to google)')));
80
+
81
+ // 5. Live verification
82
+ process.stdout.write(C.dim('verifying… '));
83
+ try {
84
+ const { displayContent } = await buildDisplayContent('Setup verification: translation works.\n', {
85
+ target: lang, backend, timeoutMs: 12000,
86
+ });
87
+ console.log('\n' + (displayContent || C.red('(nothing translated — check the backend)')));
88
+ } catch (e) {
89
+ console.log(C.red('verification failed: ' + e.message));
90
+ }
91
+
92
+ console.log(C.dim('\nNext: restart Claude Code (new session). Toggle with `!tt off` / `!tt on`; input translation: `tt input on`.'));
93
+ return true;
94
+ } finally {
95
+ if (rl) rl.close();
96
+ }
97
+ }
98
+
99
+ module.exports = { runSetup };
@@ -0,0 +1,166 @@
1
+ 'use strict';
2
+ // Locate and parse the active Claude Code session transcript (JSONL).
3
+ // Claude Code writes one transcript per session at:
4
+ // ~/.claude/projects/<cwd-slug>/<sessionId>.jsonl
5
+ // where <cwd-slug> is the working dir with every non-alphanumeric char -> '-'.
6
+
7
+ const fs = require('fs');
8
+ const os = require('os');
9
+ const path = require('path');
10
+
11
+ function projectsRoot() {
12
+ return path.join(os.homedir(), '.claude', 'projects');
13
+ }
14
+
15
+ // Replicate Claude Code's directory-slug rule for a cwd.
16
+ // e.g. /home/roy/terminal-translate -> -home-roy-terminal-translate
17
+ function slugForCwd(cwd) {
18
+ return cwd.replace(/[^a-zA-Z0-9]/g, '-');
19
+ }
20
+
21
+ function newestJsonlIn(dir) {
22
+ let best = null;
23
+ let bestMtime = -1;
24
+ let entries;
25
+ try {
26
+ entries = fs.readdirSync(dir);
27
+ } catch (e) {
28
+ return null;
29
+ }
30
+ for (const name of entries) {
31
+ if (!name.endsWith('.jsonl')) continue;
32
+ const fp = path.join(dir, name);
33
+ let st;
34
+ try {
35
+ st = fs.statSync(fp);
36
+ } catch (e) {
37
+ continue;
38
+ }
39
+ if (st.mtimeMs > bestMtime) {
40
+ bestMtime = st.mtimeMs;
41
+ best = fp;
42
+ }
43
+ }
44
+ return best;
45
+ }
46
+
47
+ // Find the transcript file for the current session.
48
+ // Strategy: 1) explicit override; 2) newest .jsonl in the cwd-slug dir;
49
+ // 3) globally newest .jsonl across all projects (the active session is
50
+ // almost always the most recently written one).
51
+ function findTranscript(cwd) {
52
+ if (process.env.TT_TRANSCRIPT) return process.env.TT_TRANSCRIPT;
53
+
54
+ const root = projectsRoot();
55
+ const dir = path.join(root, slugForCwd(cwd || process.cwd()));
56
+ const local = newestJsonlIn(dir);
57
+ if (local) return local;
58
+
59
+ // Fallback: scan every project dir for the globally newest transcript.
60
+ let best = null;
61
+ let bestMtime = -1;
62
+ let projectDirs;
63
+ try {
64
+ projectDirs = fs.readdirSync(root);
65
+ } catch (e) {
66
+ return null;
67
+ }
68
+ for (const d of projectDirs) {
69
+ const candidate = newestJsonlIn(path.join(root, d));
70
+ if (!candidate) continue;
71
+ const m = fs.statSync(candidate).mtimeMs;
72
+ if (m > bestMtime) {
73
+ bestMtime = m;
74
+ best = candidate;
75
+ }
76
+ }
77
+ return best;
78
+ }
79
+
80
+ // A "real" user prompt = something the human typed (a turn boundary),
81
+ // as opposed to a tool_result or a meta/system event.
82
+ function isRealUserPrompt(o) {
83
+ if (!o || o.type !== 'user') return false;
84
+ if (o.isMeta) return false;
85
+ const c = o.message && o.message.content;
86
+ if (typeof c === 'string') return c.trim().length > 0;
87
+ if (Array.isArray(c)) {
88
+ if (c.some((b) => b && b.type === 'tool_result')) return false;
89
+ return c.some((b) => b && b.type === 'text' && b.text && b.text.trim().length > 0);
90
+ }
91
+ return false;
92
+ }
93
+
94
+ function readEvents(file) {
95
+ const raw = fs.readFileSync(file, 'utf8').split('\n');
96
+ const events = [];
97
+ for (const ln of raw) {
98
+ if (!ln) continue;
99
+ try {
100
+ events.push(JSON.parse(ln));
101
+ } catch (e) {
102
+ /* ignore partial/corrupt lines */
103
+ }
104
+ }
105
+ return events;
106
+ }
107
+
108
+ // Indices (into events) of every real user-prompt turn boundary.
109
+ function boundaryIndices(events) {
110
+ const out = [];
111
+ for (let i = 0; i < events.length; i++) {
112
+ if (isRealUserPrompt(events[i])) out.push(i);
113
+ }
114
+ return out;
115
+ }
116
+
117
+ // Concatenate the assistant's natural-language text (text blocks only;
118
+ // thinking + tool_use excluded) for the reply that follows a given boundary,
119
+ // up to the next boundary.
120
+ function assistantTextBetween(events, startIdx, endIdx) {
121
+ const texts = [];
122
+ for (let i = startIdx + 1; i < endIdx; i++) {
123
+ const o = events[i];
124
+ if (!o || o.type !== 'assistant') continue;
125
+ const c = o.message && o.message.content;
126
+ if (Array.isArray(c)) {
127
+ for (const b of c) {
128
+ if (b && b.type === 'text' && typeof b.text === 'string') texts.push(b.text);
129
+ }
130
+ } else if (typeof c === 'string') {
131
+ texts.push(c);
132
+ }
133
+ }
134
+ return texts.join('\n').trim();
135
+ }
136
+
137
+ // Extract an assistant reply. back=0 -> most recent reply, back=1 -> the one
138
+ // before it, etc. Returns { text, total, index } where index is 0-based from
139
+ // the latest.
140
+ function extractReply(file, back) {
141
+ back = back || 0;
142
+ const events = readEvents(file);
143
+ const bounds = boundaryIndices(events);
144
+ if (bounds.length === 0) {
145
+ // No human prompt found; treat the whole file as one reply.
146
+ return { text: assistantTextBetween(events, -1, events.length), total: 1, index: 0 };
147
+ }
148
+ const pick = bounds.length - 1 - back;
149
+ if (pick < 0) return { text: '', total: bounds.length, index: back };
150
+ const start = bounds[pick];
151
+ const end = pick + 1 < bounds.length ? bounds[pick + 1] : events.length;
152
+ return {
153
+ text: assistantTextBetween(events, start, end),
154
+ total: bounds.length,
155
+ index: back,
156
+ };
157
+ }
158
+
159
+ module.exports = {
160
+ projectsRoot,
161
+ slugForCwd,
162
+ findTranscript,
163
+ readEvents,
164
+ isRealUserPrompt,
165
+ extractReply,
166
+ };
@@ -0,0 +1,76 @@
1
+ 'use strict';
2
+ // Translation orchestrator: content-addressed cache + backend fallback chain.
3
+ // Backends live in src/backends/ (openai, anthropic, deepl, azure, google,
4
+ // claude-code). On primary failure/timeout the chain falls through (free
5
+ // Google last); on total failure a line echoes its source so the caller still
6
+ // shows the English.
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const crypto = require('crypto');
11
+ const { CACHE_DIR, ensureDirs } = require('./config');
12
+ const { fallbackChain } = require('./backends');
13
+ const { normalizeLang } = require('./langs');
14
+
15
+ function cacheKey(line, target, backend) {
16
+ return crypto.createHash('sha1').update(backend + '|' + target + '|' + line).digest('hex');
17
+ }
18
+ function cacheGet(key) {
19
+ try { return fs.readFileSync(path.join(CACHE_DIR, key + '.txt'), 'utf8'); } catch (e) { return null; }
20
+ }
21
+ function cacheSet(key, val) {
22
+ try {
23
+ ensureDirs();
24
+ const f = path.join(CACHE_DIR, key + '.txt');
25
+ const tmp = f + '.' + process.pid + '.tmp';
26
+ fs.writeFileSync(tmp, val);
27
+ fs.renameSync(tmp, f);
28
+ } catch (e) {}
29
+ }
30
+
31
+ function withTimeout(promise, ms) {
32
+ return new Promise((resolve, reject) => {
33
+ const t = setTimeout(() => reject(new Error('timeout')), ms);
34
+ promise.then((v) => { clearTimeout(t); resolve(v); }, (e) => { clearTimeout(t); reject(e); });
35
+ });
36
+ }
37
+
38
+ // Translate source lines -> translations, in order, using cache + the chosen
39
+ // backend with fallback. opts: {target, backend, model, timeoutMs}
40
+ async function translateLines(lines, opts) {
41
+ opts = opts || {};
42
+ // Normalize aliases (zh-CN -> zh-Hans, zh-TW -> zh-Hant) so cache keys are
43
+ // canonical regardless of how the user spelled the code.
44
+ const target = normalizeLang(opts.target || 'zh-Hans');
45
+ const primary = opts.backend || 'google';
46
+ const timeoutMs = opts.timeoutMs || 8000;
47
+
48
+ const out = new Array(lines.length);
49
+ const need = [];
50
+ const needIdx = [];
51
+ for (let i = 0; i < lines.length; i++) {
52
+ const c = cacheGet(cacheKey(lines[i], target, primary));
53
+ if (c !== null) out[i] = c;
54
+ else { need.push(lines[i]); needIdx.push(i); }
55
+ }
56
+ if (need.length === 0) return out;
57
+
58
+ let fresh = null;
59
+ for (const backend of fallbackChain(primary)) {
60
+ try {
61
+ fresh = await withTimeout(backend.translate(need, target, opts), timeoutMs);
62
+ break;
63
+ } catch (e) {
64
+ fresh = null; // try next in chain
65
+ }
66
+ }
67
+ if (!fresh) fresh = need.slice(); // give up -> echo source
68
+
69
+ for (let j = 0; j < needIdx.length; j++) {
70
+ out[needIdx[j]] = fresh[j];
71
+ if (fresh[j] !== need[j]) cacheSet(cacheKey(need[j], target, primary), fresh[j]);
72
+ }
73
+ return out;
74
+ }
75
+
76
+ module.exports = { translateLines, cacheKey };