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.
@@ -0,0 +1,116 @@
1
+ # cctranslate
2
+
3
+ [English](README.md) | **简体中文** | [繁體中文](README.zh-Hant.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Русский](README.ru.md) | [हिन्दी](README.hi.md)
4
+
5
+ 给 Claude Code 加一层**双语对照**:每条回复在原始英文行下面自动补一行译文(中/日/韩/俄/印地),**就在对话里**,一行英文一行译文。
6
+
7
+ ```
8
+ ● I will refactor the auth module to use async tokens.
9
+ ↳ 我将重构 auth 模块以使用异步令牌。
10
+ This touches 3 files and adds a retry layer.
11
+ ↳ 这涉及 3 个文件并添加重试层。
12
+ ```
13
+
14
+ - **非破坏**:屏幕上多了译文,但转录文件和模型看到的上下文**仍是纯英文**——技术文档、skills、代码都不受影响。
15
+ - **不污染历史、不耗主对话 token**:翻译由一个**独立的便宜后端**完成,跟你的 Claude Code 会话完全无关。
16
+ - **一个键开关**:默认常开;读纯英文/代码时一键关掉。
17
+
18
+ ## 工作原理
19
+
20
+ 利用 Claude Code 原生的 **`MessageDisplay` 钩子**(v2.1.152+):它在每条助手消息渲染时触发,把完成的文本片段(`delta`)交给钩子;钩子返回的 `displayContent` **替换屏幕显示**,但不改变存储的消息。
21
+
22
+ ```
23
+ Claude 流式输出英文
24
+ │ 每完成一行/段触发一次(stdin: turn_id/message_id/index/final/delta)
25
+
26
+ hook/message-display.js ──► src/interleave.js ──► src/translate.js
27
+ (读 delta、查开关) (区分散文/代码/已是目标语言) (多后端 + 缓存)
28
+
29
+ ▼ 返回 displayContent = "英文行\n↳ 译文行"
30
+ Claude Code 就地替换显示(原文仍在转录/上下文中)
31
+ ```
32
+
33
+ > 已在 CC 2.1.169 实测:`delta` 是**互不重叠**的已完成片段(不是累积文本),普通 `\n` 即可让两种语言分行显示,代码块/路径/已是目标语言的行自动跳过。
34
+
35
+ ## 安装
36
+
37
+ ```bash
38
+ npm install -g cctrans && tt install
39
+
40
+ # from source:
41
+ git clone https://github.com/roy-jiang-opus/cctranslate.git
42
+ cd cctranslate
43
+ node bin/tt.js install # 注册钩子、链接 tt 到 ~/.local/bin,然后运行 setup 向导
44
+ ```
45
+
46
+ 然后**重启 Claude Code**(开新会话)让钩子生效。发任意消息,回复就会双语对照。
47
+
48
+ > 需要 `~/.local/bin` 在 PATH 里;否则用别名:
49
+ > `alias tt='node /path/to/cctranslate/bin/tt.js'`
50
+
51
+ ## 使用
52
+
53
+ | 命令 | 作用 |
54
+ |------|------|
55
+ | `tt on` / `tt off` / `tt toggle` | 开 / 关 / 切换翻译 |
56
+ | `tt status` | 查看状态(开关、钩子、后端、语言) |
57
+ | `tt lang [code]` | 查看/切换目标语言:`zh-Hans` `zh-Hant` `ja` `ko` `ru` `hi` |
58
+ | `tt backend <id>` | 切换翻译引擎 |
59
+ | `tt backends` | 列出所有引擎及其可用性 |
60
+ | `tt setup` | 交互式向导:语言、后端、API key |
61
+ | `tt key [id] [value]` | 管理 `~/.cc-translate/keys.json` 里的 API key |
62
+ | `tt input on` / `tt input off` | 把非英文输入翻译成英文(作为上下文发给模型) |
63
+ | `tt last [N]` | 把最近(或往前第 N 条)回复翻译到终端 |
64
+ | `tt test <文本>` | 翻译一段文本,验证引擎 |
65
+ | `tt install` / `tt uninstall` | 注册 / 移除钩子 |
66
+
67
+ **最快的开关方式**:在 Claude Code 输入框里直接输入 `!tt off` 或 `!tt on`(`!` 是 CC 的内置 bash 模式,不调用模型、不花 token)。
68
+
69
+ ## 翻译后端
70
+
71
+ | 后端 | 前提 | 速度 | 质量 | 说明 |
72
+ |------|------|------|------|------|
73
+ | `openai`(有 key 时默认) | `tt key openai` | ~1.4s/段 | 高 | `gpt-4o-mini` 批量行翻译,保留代码/路径 |
74
+ | `anthropic` | `tt key anthropic` | ~1s/段 | 高 | `claude-haiku-4-5` + structured outputs,严格等长行数组(约 $0.0005/段) |
75
+ | `deepl` | `tt key deepl`(免费档 50 万字符/月) | ~0.5s/段 | 高 | 传统 MT 质量天花板;数组接口天然对齐行 |
76
+ | `azure` | `tt key azure`(免费 200 万字符/月) | ~0.5s/段 | 中高 | 可加 `tt key azure-region` |
77
+ | `google` | 无 | ~0.3s/段 | 中 | 免费非官方接口;**所有后端失败时的兜底** |
78
+ | `claude-code` | `claude` CLI 已登录 | ~3-6s/段 | 高 | 走你的 **Claude 订阅**(`claude -p` headless),零额外费用但明显慢 |
79
+
80
+ 主后端失败/超时会自动**降级到 google**,任何情况下都不会卡住会话。每行译文按「后端+语言+内容」哈希缓存。
81
+
82
+ API key **只**存放在 `~/.cc-translate/keys.json`(chmod 600)——用 `tt setup` / `tt key` 设置,或直接编辑该文件。终端环境变量永远不会被读取,本工具的 key 和终端的 key 互不污染。
83
+
84
+ 其余设置(后端、语言、标记、模型、Azure 端点)都在 `~/.cc-translate/state.json` 里——用 `tt` 命令修改或直接编辑文件。
85
+
86
+ ## 多语言
87
+
88
+ 目标语言支持 **CJK + 俄语 + 印地语**(非拉丁文字,可按 Unicode 区间零成本判断"该行已是目标语言"并跳过):
89
+
90
+ ```bash
91
+ tt lang ja # 日语
92
+ tt lang ko # 韩语
93
+ tt lang ru # 俄语
94
+ tt lang hi # 印地语
95
+ tt lang zh-Hant # 繁体中文
96
+ tt lang zh-Hans # 简体中文(默认)
97
+ ```
98
+
99
+ 中文采用 BCP-47 **文字码**(`zh-Hans`/`zh-Hant`)——繁体是文字系统而非地区;`zh-CN` / `zh-TW` 仍可作为别名使用,会自动归一化。切换语言即刻生效(钩子每次调用都读状态),不同语言的缓存相互独立。
100
+
101
+ ## 输入翻译
102
+
103
+ `tt input on` 启用 `UserPromptSubmit` 钩子:当你的输入大部分是非英文时,英文译文会作为上下文附给模型并被视为权威指令——你继续用母语打字,模型按英文工作。(已在 CC 2.1.169 核实:钩子无法改写 prompt 本身,所以原文仍在历史里,英文随附。)英文输入原样通过;任何错误都安全回退为原样发送。
104
+
105
+ ## 行为与限制(已核实)
106
+
107
+ - 钩子在**流式输出中**按片段触发,每段单独翻译并就地替换——所以译文会随英文逐段出现。
108
+ - 钩子有 **10 秒**超时;本工具内部 9 秒兜底。任何错误/超时/超长(>9000 字符)都会**安全回退成原始英文**,绝不卡住会话。
109
+ - 每行译文按内容哈希**缓存**(`~/.cc-translate/cache`),重绘和重复文本零成本。
110
+ - 用 `openai` 时每段约一次 API 调用(~$0.0001),流式输出会比纯英文多约 1 秒/段的延迟;`google` 更快但质量略低。
111
+
112
+ ## 卸载
113
+
114
+ ```bash
115
+ node bin/tt.js uninstall # 移除钩子;重启 Claude Code 生效
116
+ ```
@@ -0,0 +1,116 @@
1
+ # cctranslate
2
+
3
+ [English](README.md) | [简体中文](README.zh-Hans.md) | **繁體中文** | [日本語](README.ja.md) | [한국어](README.ko.md) | [Русский](README.ru.md) | [हिन्दी](README.hi.md)
4
+
5
+ 為 Claude Code 加上一層**雙語對照**:每則回覆會在原始英文行下方自動補上一行譯文(中/日/韓/俄/印地),**就在對話裡**,一行英文一行譯文。
6
+
7
+ ```
8
+ ● I will refactor the auth module to use async tokens.
9
+ ↳ 我將重構 auth 模組以使用非同步權杖。
10
+ This touches 3 files and adds a retry layer.
11
+ ↳ 這會影響 3 個檔案並加入重試層。
12
+ ```
13
+
14
+ - **非破壞性**:畫面上多了譯文,但轉錄檔與模型看到的上下文**仍是純英文**——技術文件、skills、程式碼都不受影響。
15
+ - **不污染歷史、不耗主對話 token**:翻譯由一個**獨立的低成本後端**完成,與你的 Claude Code 工作階段完全無關。
16
+ - **一鍵開關**:預設常開;想讀純英文/程式碼時一鍵關閉。
17
+
18
+ ## 運作原理
19
+
20
+ 利用 Claude Code 原生的 **`MessageDisplay` 鉤子**(v2.1.152+):它在每則助理訊息渲染時觸發,把完成的文字片段(`delta`)交給鉤子;鉤子回傳的 `displayContent` **只替換螢幕顯示**,不改變儲存的訊息。
21
+
22
+ ```
23
+ Claude 串流輸出英文
24
+ │ 每完成一行/段觸發一次(stdin: turn_id/message_id/index/final/delta)
25
+
26
+ hook/message-display.js ──► src/interleave.js ──► src/translate.js
27
+ (讀 delta、查開關) (區分散文/程式碼/已是目標語言) (多後端 + 快取)
28
+
29
+ ▼ 回傳 displayContent = "英文行\n↳ 譯文行"
30
+ Claude Code 就地替換顯示(原文仍在轉錄/上下文中)
31
+ ```
32
+
33
+ > 已在 CC 2.1.169 實測:`delta` 是**互不重疊**的已完成片段(不是累積文字),普通 `\n` 即可讓兩種語言分行顯示,程式碼區塊/路徑/已是目標語言的行會自動跳過。
34
+
35
+ ## 安裝
36
+
37
+ ```bash
38
+ npm install -g cctrans && tt install
39
+
40
+ # from source:
41
+ git clone https://github.com/roy-jiang-opus/cctranslate.git
42
+ cd cctranslate
43
+ node bin/tt.js install # 註冊鉤子、連結 tt 到 ~/.local/bin,然後執行 setup 精靈
44
+ ```
45
+
46
+ 接著**重新啟動 Claude Code**(開新工作階段)讓鉤子生效。送出任意訊息,回覆就會雙語對照。
47
+
48
+ > 需要 `~/.local/bin` 在 PATH 中;否則使用別名:
49
+ > `alias tt='node /path/to/cctranslate/bin/tt.js'`
50
+
51
+ ## 使用
52
+
53
+ | 指令 | 作用 |
54
+ |------|------|
55
+ | `tt on` / `tt off` / `tt toggle` | 開 / 關 / 切換翻譯 |
56
+ | `tt status` | 檢視狀態(開關、鉤子、後端、語言) |
57
+ | `tt lang [code]` | 檢視/切換目標語言:`zh-Hans` `zh-Hant` `ja` `ko` `ru` `hi` |
58
+ | `tt backend <id>` | 切換翻譯引擎 |
59
+ | `tt backends` | 列出所有引擎及其可用性 |
60
+ | `tt setup` | 互動式精靈:語言、後端、API key |
61
+ | `tt key [id] [value]` | 管理 `~/.cc-translate/keys.json` 中的 API key |
62
+ | `tt input on` / `tt input off` | 把非英文輸入翻譯成英文(作為上下文傳給模型) |
63
+ | `tt last [N]` | 把最近(或往前第 N 則)回覆翻譯到終端機 |
64
+ | `tt test <文字>` | 翻譯一段文字,驗證引擎 |
65
+ | `tt install` / `tt uninstall` | 註冊 / 移除鉤子 |
66
+
67
+ **最快的開關方式**:在 Claude Code 輸入框直接輸入 `!tt off` 或 `!tt on`(`!` 是 CC 內建的 bash 模式,不呼叫模型、不花 token)。
68
+
69
+ ## 翻譯後端
70
+
71
+ | 後端 | 前提 | 速度 | 品質 | 說明 |
72
+ |------|------|------|------|------|
73
+ | `openai`(有 key 時預設) | `tt key openai` | ~1.4s/段 | 高 | `gpt-4o-mini` 批次行翻譯,保留程式碼/路徑 |
74
+ | `anthropic` | `tt key anthropic` | ~1s/段 | 高 | `claude-haiku-4-5` + structured outputs,嚴格等長行陣列(約 $0.0005/段) |
75
+ | `deepl` | `tt key deepl`(免費額度 50 萬字元/月) | ~0.5s/段 | 高 | 傳統 MT 品質天花板;陣列介面天然對齊行 |
76
+ | `azure` | `tt key azure`(免費 200 萬字元/月) | ~0.5s/段 | 中高 | 可加 `tt key azure-region` |
77
+ | `google` | 無 | ~0.3s/段 | 中 | 免費非官方介面;**所有後端失敗時的保底** |
78
+ | `claude-code` | `claude` CLI 已登入 | ~3-6s/段 | 高 | 走你的 **Claude 訂閱**(`claude -p` headless),零額外費用但明顯較慢 |
79
+
80
+ 主後端失敗/逾時會自動**降級到 google**,任何情況下都不會卡住工作階段。每行譯文按「後端+語言+內容」雜湊快取。
81
+
82
+ API key **只**存放在 `~/.cc-translate/keys.json`(chmod 600)——用 `tt setup` / `tt key` 設定,或直接編輯該檔案。終端機環境變數永遠不會被讀取,本工具的 key 與終端機的 key 互不污染。
83
+
84
+ 其餘設定(後端、語言、標記、模型、Azure 端點)都在 `~/.cc-translate/state.json` 中——用 `tt` 指令修改或直接編輯檔案。
85
+
86
+ ## 多語言
87
+
88
+ 目標語言支援 **CJK + 俄語 + 印地語**(非拉丁文字,可按 Unicode 區間零成本判斷「該行已是目標語言」並跳過):
89
+
90
+ ```bash
91
+ tt lang ja # 日語
92
+ tt lang ko # 韓語
93
+ tt lang ru # 俄語
94
+ tt lang hi # 印地語
95
+ tt lang zh-Hant # 繁體中文
96
+ tt lang zh-Hans # 簡體中文(預設)
97
+ ```
98
+
99
+ 中文採用 BCP-47 **文字碼**(`zh-Hans`/`zh-Hant`)——繁體是文字系統而非地區;`zh-CN` / `zh-TW` 仍可作為別名使用,會自動正規化。切換語言立即生效(鉤子每次呼叫都讀取狀態),不同語言的快取相互獨立。
100
+
101
+ ## 輸入翻譯
102
+
103
+ `tt input on` 啟用 `UserPromptSubmit` 鉤子:當你的輸入大多是非英文時,英文譯文會作為上下文附給模型並被視為權威指令——你繼續用母語打字,模型按英文工作。(已在 CC 2.1.169 核實:鉤子無法改寫 prompt 本身,所以原文仍在歷史中,英文隨附。)英文輸入原樣通過;任何錯誤都安全回退為原樣送出。
104
+
105
+ ## 行為與限制(已核實)
106
+
107
+ - 鉤子在**串流輸出中**按片段觸發,每段單獨翻譯並就地替換——所以譯文會隨英文逐段出現。
108
+ - 鉤子有 **10 秒**逾時;本工具內部 9 秒保底。任何錯誤/逾時/超長(>9000 字元)都會**安全回退成原始英文**,絕不卡住工作階段。
109
+ - 每行譯文按內容雜湊**快取**(`~/.cc-translate/cache`),重繪與重複文字零成本。
110
+ - 用 `openai` 時每段約一次 API 呼叫(~$0.0001),串流輸出會比純英文多約 1 秒/段的延遲;`google` 較快但品質略低。
111
+
112
+ ## 解除安裝
113
+
114
+ ```bash
115
+ node bin/tt.js uninstall # 移除鉤子;重新啟動 Claude Code 生效
116
+ ```
package/ROADMAP.md ADDED
@@ -0,0 +1,18 @@
1
+ # Roadmap
2
+
3
+ ## Shipped
4
+
5
+ ### ✅ Input translation — write in your language, send in English
6
+ `tt input on` enables a `UserPromptSubmit` hook: prompts that are mostly non-English get an English translation attached as `additionalContext`, which the model treats as the canonical instruction. Implementation note: Claude Code hooks provably cannot rewrite the prompt itself (verified against the 2.1.169 **and 2.1.170** binaries — the `UserPromptSubmit`/`UserPromptExpansion` output schemas only allow `additionalContext` and block), so attach-as-context is the strongest available form; the original prompt stays in history with the English alongside. If a future CC release adds a prompt-rewrite field, switching to true replacement is a one-line change in `hook/user-prompt-submit.js`.
7
+
8
+ ### ✅ Interactive setup wizard
9
+ `tt install` registers both hooks and launches the wizard; `tt setup` re-runs it anytime. Walks through target language → backend selection → key entry for the chosen backend → live translation verification. Non-interactive flags: `--lang`, `--backend`, `--key`, `--yes`.
10
+
11
+ ### ✅ Per-tool API-key config (no env cross-pollution)
12
+ Keys live **only** in `~/.cc-translate/keys.json` (chmod 600, atomic writes), managed via `tt key <id> [value|--clear]`, the setup wizard, or direct file edits. Shell environment variables are **never** consulted — no overrides, no opt-in. All non-secret settings (backend, language, marker, models, Azure endpoint) live in `~/.cc-translate/state.json`. The only env vars the tool reads are internal plumbing: `TT_HOME` / `TT_TRANSCRIPT` (tests) and `TT_DISABLE` / `TT_DEBUG_STDIN` (hook internals).
13
+
14
+ ## Planned
15
+
16
+ - **Latin-script output targets** (e.g. English, Spanish): the current "already in target language" skip detection relies on Unicode script ranges, which can't separate Latin-script targets from English source; needs a stopword-heuristic detector. (`en` is already wired internally for the input direction.)
17
+ - **Glossary / terminology pinning**: force consistent translations for project-specific terms across all backends.
18
+ - ~~npm publish~~ → **shipped as `cctrans`** (npm's typosquat rule blocks `cctranslate` — too similar to the existing `cc-translate`).
package/bin/tt.js ADDED
@@ -0,0 +1,272 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // tt — control + test CLI for the Claude Code bilingual (EN->ZH) overlay.
4
+ //
5
+ // tt on | off | toggle | status
6
+ // tt backend <openai|google>
7
+ // tt install | uninstall register/remove the MessageDisplay hook
8
+ // tt last [N] translate the latest (or Nth-back) reply -> stdout
9
+ // tt test <text...> translate ad-hoc text -> stdout
10
+ // tt help
11
+
12
+ const fs = require('fs');
13
+ const os = require('os');
14
+ const path = require('path');
15
+
16
+ const { getState, setState, STATE_FILE } = require('../src/config');
17
+ const { buildDisplayContent } = require('../src/interleave');
18
+ const { findTranscript, extractReply } = require('../src/transcript');
19
+ const { listBackends, getBackend } = require('../src/backends');
20
+ const { getLang, listLangs, normalizeLang } = require('../src/langs');
21
+
22
+ const HOOK_PATH = path.resolve(__dirname, '..', 'hook', 'message-display.js');
23
+ const INPUT_HOOK_PATH = path.resolve(__dirname, '..', 'hook', 'user-prompt-submit.js');
24
+ const SETTINGS = path.join(os.homedir(), '.claude', 'settings.json');
25
+ const keys = require('../src/keys');
26
+
27
+ const C = {
28
+ dim: (s) => '\x1b[2m' + s + '\x1b[0m',
29
+ cyan: (s) => '\x1b[36m' + s + '\x1b[0m',
30
+ green: (s) => '\x1b[32m' + s + '\x1b[0m',
31
+ red: (s) => '\x1b[31m' + s + '\x1b[0m',
32
+ bold: (s) => '\x1b[1m' + s + '\x1b[0m',
33
+ };
34
+
35
+ function readSettings() {
36
+ try { return JSON.parse(fs.readFileSync(SETTINGS, 'utf8')); } catch (e) { return {}; }
37
+ }
38
+ function writeSettings(s) {
39
+ try { fs.copyFileSync(SETTINGS, SETTINGS + '.bak-tt'); } catch (e) {}
40
+ const tmp = SETTINGS + '.tt.tmp';
41
+ fs.writeFileSync(tmp, JSON.stringify(s, null, 2));
42
+ fs.renameSync(tmp, SETTINGS);
43
+ }
44
+ function hookInstalled(s) {
45
+ s = s || readSettings();
46
+ const groups = (s.hooks && s.hooks.MessageDisplay) || [];
47
+ return JSON.stringify(groups).includes('message-display.js');
48
+ }
49
+ function inputHookInstalled(s) {
50
+ s = s || readSettings();
51
+ const groups = (s.hooks && s.hooks.UserPromptSubmit) || [];
52
+ return JSON.stringify(groups).includes('user-prompt-submit.js');
53
+ }
54
+
55
+ function install() {
56
+ const s = readSettings();
57
+ s.hooks = s.hooks || {};
58
+ let changed = false;
59
+ if (!hookInstalled(s)) {
60
+ s.hooks.MessageDisplay = s.hooks.MessageDisplay || [];
61
+ s.hooks.MessageDisplay.push({ hooks: [{ type: 'command', command: 'node ' + HOOK_PATH }] });
62
+ changed = true;
63
+ }
64
+ if (!inputHookInstalled(s)) {
65
+ // Registered always; the hook exits instantly unless `tt input on`.
66
+ s.hooks.UserPromptSubmit = s.hooks.UserPromptSubmit || [];
67
+ s.hooks.UserPromptSubmit.push({ hooks: [{ type: 'command', command: 'node ' + INPUT_HOOK_PATH }] });
68
+ changed = true;
69
+ }
70
+ if (changed) {
71
+ writeSettings(s);
72
+ console.log(C.green('✓') + ' registered MessageDisplay + UserPromptSubmit hooks in ' + SETTINGS);
73
+ } else {
74
+ console.log(C.green('✓') + ' hooks already registered in ' + SETTINGS);
75
+ }
76
+ // Make `tt` runnable from anywhere (best-effort symlink on a common PATH dir).
77
+ const linkDir = path.join(os.homedir(), '.local', 'bin');
78
+ const link = path.join(linkDir, 'tt');
79
+ try {
80
+ fs.mkdirSync(linkDir, { recursive: true });
81
+ try { fs.unlinkSync(link); } catch (e) {}
82
+ fs.symlinkSync(path.resolve(__dirname, 'tt.js'), link);
83
+ fs.chmodSync(path.resolve(__dirname, 'tt.js'), 0o755);
84
+ console.log(C.green('✓') + ' linked `tt` -> ' + link + (process.env.PATH.includes(linkDir) ? '' : C.dim(' (add ' + linkDir + ' to PATH)')));
85
+ } catch (e) {
86
+ console.log(C.dim(' (could not symlink tt; add alias: alias tt=\'node ' + path.resolve(__dirname, 'tt.js') + '\')'));
87
+ }
88
+ console.log('');
89
+ console.log('Next:');
90
+ console.log(' 1. Restart Claude Code (new session) so the hook loads.');
91
+ console.log(' 2. Send any message — replies now show ' + C.bold('English + 中文') + ' inline.');
92
+ console.log(' 3. Toggle anytime: ' + C.bold('!tt off') + ' / ' + C.bold('!tt on') + ' (typed inside Claude Code).');
93
+ }
94
+
95
+ function uninstall() {
96
+ const s = readSettings();
97
+ if (s.hooks) {
98
+ for (const [event, file] of [['MessageDisplay', 'message-display.js'], ['UserPromptSubmit', 'user-prompt-submit.js']]) {
99
+ if (Array.isArray(s.hooks[event])) {
100
+ s.hooks[event] = s.hooks[event].filter((g) => !JSON.stringify(g).includes(file));
101
+ if (s.hooks[event].length === 0) delete s.hooks[event];
102
+ }
103
+ }
104
+ if (Object.keys(s.hooks).length === 0) delete s.hooks;
105
+ writeSettings(s);
106
+ }
107
+ console.log(C.green('✓') + ' removed cctranslate hooks. Restart Claude Code to take effect.');
108
+ }
109
+
110
+ function status() {
111
+ const st = getState();
112
+ const installed = hookInstalled();
113
+ const b = getBackend(st.backend);
114
+ const lang = getLang(st.target);
115
+ console.log(C.bold('cctranslate status'));
116
+ console.log(' enabled : ' + (st.enabled ? C.green('ON') : C.red('OFF')));
117
+ console.log(' hook : ' + (installed ? C.green('installed') : C.red('not installed') + C.dim(' (run: tt install)')));
118
+ console.log(' backend : ' + st.backend + (b ? (b.available() ? C.green(' (ready)') : C.red(' (missing: ' + b.needs + ')')) : C.red(' (unknown backend)')));
119
+ console.log(' lang : ' + st.target + (lang ? C.dim(' (' + lang.name + ')') : C.red(' (unsupported — see: tt lang)')));
120
+ console.log(' input : ' + (st.inputEn ? C.green('ON') : 'off') + C.dim(' (prompt -> English; toggle: tt input on|off)'));
121
+ console.log(' keys : ' + Object.keys(keys.readKeys()).length + ' in ' + keys.KEYS_FILE + C.dim(' (manage: tt key)'));
122
+ console.log(' state : ' + STATE_FILE);
123
+ }
124
+
125
+ function keyCmd(rest) {
126
+ const [id, value] = rest;
127
+ if (!id) {
128
+ console.log(C.bold('keys') + C.dim(' (' + keys.KEYS_FILE + ', chmod 600 — the only key source; env vars are never read)'));
129
+ for (const kid of keys.KEY_IDS) {
130
+ const v = keys.getKey(kid);
131
+ console.log(' ' + kid.padEnd(14) + (v ? C.green(keys.mask(v)) : C.dim('(unset)')));
132
+ }
133
+ return;
134
+ }
135
+ if (!keys.KEY_IDS.includes(id)) { console.error('unknown key id: ' + id + '\nvalid: ' + keys.KEY_IDS.join(', ')); process.exit(1); }
136
+ if (!value) { console.log(id + ' = ' + keys.mask(keys.getKey(id))); return; }
137
+ if (value === '--clear') { keys.setKey(id, null); console.log(C.green('✓') + ' cleared ' + id); return; }
138
+ keys.setKey(id, value);
139
+ console.log(C.green('✓') + ' ' + id + ' = ' + keys.mask(value) + C.dim(' saved to ' + keys.KEYS_FILE));
140
+ }
141
+
142
+ function backends() {
143
+ const st = getState();
144
+ console.log(C.bold('backends') + C.dim(' (switch: tt backend <id>)'));
145
+ for (const b of listBackends()) {
146
+ const mark = b.id === st.backend ? C.cyan('▶ ') : ' ';
147
+ const ok = b.available() ? C.green('ready ') : C.red('missing ');
148
+ console.log(mark + b.id.padEnd(12) + ok + C.dim('needs: ' + b.needs));
149
+ }
150
+ }
151
+
152
+ function colorizeForTerminal(displayContent, marker) {
153
+ return displayContent
154
+ .split('\n')
155
+ .map((l) => (l.startsWith(marker) ? C.cyan(l) : l))
156
+ .join('\n');
157
+ }
158
+
159
+ async function renderText(text) {
160
+ const st = getState();
161
+ const { displayContent } = await buildDisplayContent(text, {
162
+ target: st.target, backend: st.backend, model: st.model, marker: st.marker, timeoutMs: 12000,
163
+ });
164
+ if (displayContent == null) { process.stdout.write(text + '\n'); return; }
165
+ process.stdout.write(colorizeForTerminal(displayContent, st.marker) + '\n');
166
+ }
167
+
168
+ async function last(nBack) {
169
+ const file = findTranscript(process.cwd());
170
+ if (!file) { console.error(C.red('no transcript found for this directory')); process.exit(1); }
171
+ const { text, total, index } = extractReply(file, nBack || 0);
172
+ if (!text) { console.error(C.dim('(no assistant reply ' + (nBack ? nBack + ' turns back' : '') + ' to translate)')); process.exit(0); }
173
+ console.error(C.dim('# reply ' + index + '/' + (total - 1) + ' from ' + path.basename(file)));
174
+ await renderText(text);
175
+ }
176
+
177
+ function help() {
178
+ console.log(`${C.bold('tt')} — cctranslate: bilingual overlay for Claude Code
179
+
180
+ ${C.bold('Control')}
181
+ tt on | off | toggle turn the inline translation on/off
182
+ tt input on | off translate non-English input to English (as context)
183
+ tt status show current state
184
+ tt lang [code] show/set target language (zh-Hans, zh-Hant, ja, ko, ru, hi)
185
+ tt backend <id> choose translation engine
186
+ tt backends list engines + availability
187
+
188
+ ${C.bold('Setup')}
189
+ tt install register hooks (+ link tt), then run setup
190
+ tt setup interactive wizard: language, backend, API keys
191
+ (flags: --lang --backend --key --yes)
192
+ tt key [id] [value] manage API keys in ~/.cc-translate/keys.json
193
+ (ids: openai, anthropic, deepl, azure, azure-region)
194
+ tt uninstall remove the hooks
195
+
196
+ ${C.bold('Manual / test')}
197
+ tt last [N] translate the latest (or N-back) assistant reply
198
+ tt test <text...> translate ad-hoc English text
199
+
200
+ ${C.dim('Tip: toggle from inside Claude Code by typing !tt off / !tt on')}`);
201
+ }
202
+
203
+ async function main() {
204
+ const [cmd, ...rest] = process.argv.slice(2);
205
+ switch (cmd) {
206
+ case 'on': setState({ enabled: true }); console.log(C.green('✓ translation ON')); break;
207
+ case 'off': setState({ enabled: false }); console.log('✓ translation ' + C.red('OFF')); break;
208
+ case 'toggle': { const s = getState(); const n = setState({ enabled: !s.enabled }); console.log('✓ translation ' + (n.enabled ? C.green('ON') : C.red('OFF'))); break; }
209
+ case 'backend': {
210
+ const id = rest[0];
211
+ const b = id && getBackend(id);
212
+ if (!b) {
213
+ console.error('usage: tt backend <' + listBackends().map((x) => x.id).join('|') + '>');
214
+ process.exit(1);
215
+ }
216
+ setState({ backend: id });
217
+ console.log(C.green('✓') + ' backend = ' + id + (b.available() ? '' : C.red(' (warning — missing: ' + b.needs + '; will fall back to google)')));
218
+ break;
219
+ }
220
+ case 'backends': backends(); break;
221
+ case 'lang': {
222
+ const code = rest[0];
223
+ if (!code) { const st = getState(); console.log('lang = ' + st.target + C.dim(' (available: ' + listLangs().join(', ') + '; aliases: zh-CN, zh-TW)')); break; }
224
+ const lang = getLang(code);
225
+ if (!lang) { console.error('unsupported lang: ' + code + '\navailable: ' + listLangs().join(', ') + ' (aliases: zh-CN, zh-TW)'); process.exit(1); }
226
+ const canonical = normalizeLang(code);
227
+ setState({ target: canonical });
228
+ console.log(C.green('✓') + ' lang = ' + canonical + C.dim(' (' + lang.name + (canonical !== code ? ', normalized from ' + code : '') + ')'));
229
+ break;
230
+ }
231
+ case 'install': {
232
+ install();
233
+ if (process.stdin.isTTY && !rest.includes('--no-setup')) {
234
+ console.log('');
235
+ await require('../src/setup').runSetup({});
236
+ }
237
+ break;
238
+ }
239
+ case 'setup': {
240
+ const flag = (name) => { const i = rest.indexOf(name); return i > -1 ? rest[i + 1] : undefined; };
241
+ await require('../src/setup').runSetup({
242
+ lang: flag('--lang'),
243
+ backend: flag('--backend'),
244
+ key: flag('--key'),
245
+ yes: rest.includes('--yes'),
246
+ });
247
+ break;
248
+ }
249
+ case 'key': keyCmd(rest); break;
250
+ case 'input': {
251
+ const sub = rest[0];
252
+ if (sub === 'on' || sub === 'off') setState({ inputEn: sub === 'on' });
253
+ else if (sub === 'toggle') setState({ inputEn: !getState().inputEn });
254
+ const st = getState();
255
+ console.log('input translation (prompt -> English): ' + (st.inputEn ? C.green('ON') : C.red('OFF')) +
256
+ (inputHookInstalled() ? '' : C.red(' (hook not installed — run: tt install)')));
257
+ break;
258
+ }
259
+ case 'uninstall': uninstall(); break;
260
+ case 'status': status(); break;
261
+ case 'last': await last(parseInt(rest[0], 10) || 0); break;
262
+ case 'test': {
263
+ const text = rest.join(' ');
264
+ if (!text) { console.error('usage: tt test <text>'); process.exit(1); }
265
+ await renderText(text); break;
266
+ }
267
+ case 'help': case '--help': case '-h': case undefined: help(); break;
268
+ default: console.error('unknown command: ' + cmd + '\n'); help(); process.exit(1);
269
+ }
270
+ }
271
+
272
+ main().catch((e) => { console.error(C.red('error: ') + (e && e.message)); process.exit(1); });
@@ -0,0 +1,85 @@
1
+ 'use strict';
2
+ // Claude Code MessageDisplay hook entry point.
3
+ //
4
+ // stdin (JSON): { session_id, transcript_path, cwd, permission_mode,
5
+ // hook_event_name, turn_id, message_id, index, final, delta }
6
+ // delta = the newly completed lines of the streaming assistant message.
7
+ // Deltas are non-overlapping; a code fence (```) can span deltas.
8
+ // stdout (JSON, exit 0): { hookSpecificOutput: { hookEventName: "MessageDisplay",
9
+ // displayContent: "<EN line>\n↳ <ZH line> ..." } }
10
+ // displayContent REPLACES the delta on screen. Display-only: the transcript
11
+ // and the model's context keep the original English.
12
+ //
13
+ // Safety contract: on disabled / empty / error / timeout, emit NOTHING and
14
+ // exit 0 so Claude Code renders the original English delta unchanged. This hook
15
+ // must never break or stall the user's session.
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const { getState, BASE } = require('../src/config');
20
+ const { buildDisplayContent } = require('../src/interleave');
21
+
22
+ function showOriginal() { process.exit(0); } // no stdout => CC keeps the original delta
23
+
24
+ // Per-message code-fence state, so "inside a ``` block?" carries across the
25
+ // deltas of one message. Reset at index 0 (also covers full repaints, which
26
+ // re-send the message from index 0); removed when the message completes.
27
+ const MSGDIR = path.join(BASE, 'msgstate');
28
+ function fenceFile(id) { return path.join(MSGDIR, String(id).replace(/[^\w.-]/g, '_') + '.json'); }
29
+ function loadFence(id, index) {
30
+ if (!id || index === 0) return false;
31
+ try { return !!JSON.parse(fs.readFileSync(fenceFile(id), 'utf8')).inFence; } catch (e) { return false; }
32
+ }
33
+ function saveFence(id, index, inFence, final) {
34
+ if (!id) return;
35
+ try {
36
+ if (final) { try { fs.unlinkSync(fenceFile(id)); } catch (e) {} return; }
37
+ fs.mkdirSync(MSGDIR, { recursive: true });
38
+ fs.writeFileSync(fenceFile(id), JSON.stringify({ index, inFence }));
39
+ } catch (e) {}
40
+ }
41
+
42
+ let data = '';
43
+ process.stdin.on('data', (d) => (data += d));
44
+ process.stdin.on('end', async () => {
45
+ if (process.env.TT_DEBUG_STDIN) {
46
+ try { fs.appendFileSync(process.env.TT_DEBUG_STDIN, '\n===== delta =====\n' + data + '\n'); } catch (e) {}
47
+ }
48
+ let inp = {};
49
+ try { inp = JSON.parse(data); } catch (e) { return showOriginal(); }
50
+
51
+ const delta = typeof inp.delta === 'string' ? inp.delta : '';
52
+ if (!delta) return showOriginal();
53
+
54
+ // Recursion guard: the claude-code backend spawns `claude -p` with
55
+ // TT_DISABLE=1 so a child Claude process can never re-enter this hook.
56
+ if (process.env.TT_DISABLE) return showOriginal();
57
+
58
+ let st;
59
+ try { st = getState(); } catch (e) { return showOriginal(); }
60
+ if (!st.enabled) return showOriginal();
61
+
62
+ const id = inp.message_id;
63
+ const index = typeof inp.index === 'number' ? inp.index : 0;
64
+ const final = inp.final === true;
65
+ const inFence0 = loadFence(id, index);
66
+
67
+ // Guard below Claude Code's 10s MessageDisplay timeout so we always exit clean.
68
+ const guard = setTimeout(showOriginal, 9000);
69
+ try {
70
+ const { displayContent, inFence } = await buildDisplayContent(delta, {
71
+ target: st.target, backend: st.backend, model: st.model,
72
+ marker: st.marker, timeoutMs: 8000, inFence: inFence0,
73
+ });
74
+ clearTimeout(guard);
75
+ saveFence(id, index, inFence, final); // persist even when nothing was translated
76
+ if (displayContent == null) return showOriginal();
77
+ process.stdout.write(JSON.stringify({
78
+ hookSpecificOutput: { hookEventName: 'MessageDisplay', displayContent: displayContent },
79
+ }));
80
+ process.exit(0);
81
+ } catch (e) {
82
+ clearTimeout(guard);
83
+ return showOriginal();
84
+ }
85
+ });