@lancetw/aiyu 0.3.2 → 0.4.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/README.md +52 -0
- package/aiyu-host.js +7 -6
- package/install.js +135 -45
- package/package.json +10 -3
package/README.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# @lancetw/aiyu
|
|
2
|
+
|
|
3
|
+
Native-messaging **host installer** for the [aiyu](https://github.com/lancetw/aiyu) Chrome extension (*aiyu — AI 譯語*), which translates **YouTube subtitles** and **selected web text** into Traditional Chinese (Taiwan) using an AI CLI already on your own machine.
|
|
4
|
+
|
|
5
|
+
This npm package is the **host half**. It registers a small local bridge so the aiyu browser extension can call your installed `claude` / `codex` / `agy` CLI. The extension itself is installed from the Chrome Web Store.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npx @lancetw/aiyu
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Then restart your browser, click the aiyu icon → **「測試 host 連線」** (Test host connection).
|
|
14
|
+
|
|
15
|
+
By default the installer **lets you choose which browsers** to register (interactive checklist in a terminal). To skip the prompt:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npx @lancetw/aiyu --all # all detected browsers
|
|
19
|
+
npx @lancetw/aiyu --browsers=chrome,brave # only these
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Valid ids: `chrome, canary, beta, dev, testing, chromium, edge, brave, arc`.
|
|
23
|
+
|
|
24
|
+
## Requirements
|
|
25
|
+
|
|
26
|
+
- **Node.js ≥ 20.12**
|
|
27
|
+
- One of these AI CLIs installed **and signed in** (requires that service's account):
|
|
28
|
+
- `claude` — Anthropic Claude Code
|
|
29
|
+
- `codex` — OpenAI Codex
|
|
30
|
+
- `agy` — Google Antigravity
|
|
31
|
+
- The **aiyu** extension installed from the Chrome Web Store
|
|
32
|
+
|
|
33
|
+
> Without a CLI or without running this installer, the extension shows **「連線失敗」(connection failed)**. macOS / Linux are verified; Windows is experimental.
|
|
34
|
+
|
|
35
|
+
## Uninstall
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
npx @lancetw/aiyu --uninstall # remove from all browsers
|
|
39
|
+
npx @lancetw/aiyu --uninstall --browsers=chrome # remove from one only
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## What it does
|
|
43
|
+
|
|
44
|
+
`npx @lancetw/aiyu` copies the host to a stable location, generates a launcher pinned to your Node binary (no reliance on a shebang), and registers the native-messaging manifest for the browsers you choose (interactive by default; or `--browsers=`/`--all`). The host trusts both the off-store (unpacked-dev) and Chrome Web Store extension IDs, so either build connects.
|
|
45
|
+
|
|
46
|
+
## Privacy
|
|
47
|
+
|
|
48
|
+
aiyu has **no developer server** and collects no data. The text you translate goes only — under your own account, via the CLI you installed — to that provider (Anthropic / OpenAI / Google); it never passes through the developer. See the [privacy policy](https://github.com/lancetw/aiyu/blob/master/PRIVACY.md).
|
|
49
|
+
|
|
50
|
+
## License
|
|
51
|
+
|
|
52
|
+
[MIT](https://github.com/lancetw/aiyu/blob/master/LICENSE) © lancetw
|
package/aiyu-host.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
#!/
|
|
1
|
+
#!/usr/bin/env node
|
|
2
2
|
// aiyu native messaging host
|
|
3
3
|
// 從 stdin 讀 4-byte LE length + JSON,呼叫 claude -p 或 codex exec,回 JSON 結果。
|
|
4
4
|
// 所有除錯訊息走 stderr,stdout 嚴格只能寫 framing message。
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
7
11
|
|
|
8
|
-
const
|
|
9
|
-
const path = require("path");
|
|
10
|
-
const fs = require("fs");
|
|
11
|
-
const os = require("os");
|
|
12
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
12
13
|
|
|
13
14
|
const HOST_VERSION = "0.3.2";
|
|
14
15
|
// 單次 CLI 呼叫上限:全後端統一 300s。健康呼叫遠低於此(claude ~24s、codex ~15s),
|
package/install.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
"use strict";
|
|
3
2
|
// aiyu 跨平台 native host 安裝器 —— 取代 install.sh / deploy.sh,並新增 Windows 支援。
|
|
4
3
|
//
|
|
5
4
|
// 做的事:
|
|
@@ -19,10 +18,11 @@
|
|
|
19
18
|
//
|
|
20
19
|
// 擴充 ID 是從 extension/manifest.json 的 key 推導出來的固定值(off-store 自有金鑰,永遠一致)。
|
|
21
20
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
import fs from "node:fs";
|
|
22
|
+
import os from "node:os";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import { execFileSync } from "node:child_process";
|
|
25
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
26
26
|
|
|
27
27
|
const HOST_NAME = "com.lancetw.aiyu";
|
|
28
28
|
// 擴充 ID。host 的 allowed_origins 同時信任兩者(dual-ID):
|
|
@@ -36,15 +36,21 @@ const EXT_IDS = [DEV_EXT_ID, STORE_EXT_ID];
|
|
|
36
36
|
|
|
37
37
|
const PLATFORM = process.platform; // 'darwin' | 'linux' | 'win32'
|
|
38
38
|
const HOME = os.homedir();
|
|
39
|
-
const SRC_DIR =
|
|
39
|
+
const SRC_DIR = path.dirname(fileURLToPath(import.meta.url)); // host/
|
|
40
40
|
const REPO_ROOT = path.join(SRC_DIR, "..");
|
|
41
41
|
const EXT_DIR = path.join(REPO_ROOT, "extension");
|
|
42
42
|
const HOST_JS_SRC = path.join(SRC_DIR, "aiyu-host.js");
|
|
43
43
|
|
|
44
|
-
const
|
|
44
|
+
const argList = process.argv.slice(2);
|
|
45
|
+
const argv = new Set(argList);
|
|
45
46
|
const DRY = argv.has("--dry-run");
|
|
46
47
|
const UNINSTALL = argv.has("--uninstall");
|
|
47
48
|
const HELP = argv.has("--help") || argv.has("-h");
|
|
49
|
+
const ALL = argv.has("--all");
|
|
50
|
+
const BROWSERS_FLAG = (() => {
|
|
51
|
+
const p = argList.find((a) => a.startsWith("--browsers="));
|
|
52
|
+
return p ? p.slice("--browsers=".length) : null;
|
|
53
|
+
})();
|
|
48
54
|
|
|
49
55
|
function log(...a) { console.log(...a); }
|
|
50
56
|
function step(s) { console.log((DRY ? "[dry-run] " : "") + s); }
|
|
@@ -56,30 +62,74 @@ function installDir() {
|
|
|
56
62
|
return path.join(process.env.XDG_DATA_HOME || path.join(HOME, ".local/share"), "aiyu"); // linux
|
|
57
63
|
}
|
|
58
64
|
|
|
59
|
-
//
|
|
60
|
-
|
|
65
|
+
// 單一真相來源:每個瀏覽器一筆。id = 使用者在 --browsers 用的短名;label 給選單顯示。
|
|
66
|
+
// mac/linux 為 NativeMessagingHosts 之上層子路徑;win 為 HKCU 機碼基底(null = 該平台無此瀏覽器)。
|
|
67
|
+
const BROWSERS = [
|
|
68
|
+
{ id: "chrome", label: "Google Chrome", mac: "Google/Chrome", linux: "google-chrome", win: "Software\\Google\\Chrome" },
|
|
69
|
+
{ id: "canary", label: "Google Chrome Canary", mac: "Google/Chrome Canary", linux: null, win: null },
|
|
70
|
+
{ id: "beta", label: "Google Chrome Beta", mac: "Google/Chrome Beta", linux: "google-chrome-beta", win: null },
|
|
71
|
+
{ id: "dev", label: "Google Chrome Dev", mac: "Google/Chrome Dev", linux: "google-chrome-unstable", win: null },
|
|
72
|
+
{ id: "testing", label: "Google Chrome for Testing", mac: "Google/Chrome for Testing", linux: null, win: null },
|
|
73
|
+
{ id: "chromium", label: "Chromium", mac: "Chromium", linux: "chromium", win: "Software\\Chromium" },
|
|
74
|
+
{ id: "edge", label: "Microsoft Edge", mac: "Microsoft Edge", linux: "microsoft-edge", win: "Software\\Microsoft\\Edge" },
|
|
75
|
+
{ id: "brave", label: "Brave", mac: "BraveSoftware/Brave-Browser", linux: "BraveSoftware/Brave-Browser", win: "Software\\BraveSoftware\\Brave-Browser" },
|
|
76
|
+
{ id: "arc", label: "Arc", mac: "Arc/User Data", linux: null, win: null },
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
// 該瀏覽器在本平台的 NativeMessagingHosts 目錄(不支援則回 null)
|
|
80
|
+
function nmhDir(b) {
|
|
61
81
|
const ASUP = path.join(HOME, "Library/Application Support");
|
|
62
82
|
const CFG = process.env.XDG_CONFIG_HOME || path.join(HOME, ".config");
|
|
63
|
-
if (PLATFORM === "darwin")
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
return
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
function
|
|
77
|
-
return
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
+
if (PLATFORM === "darwin") return b.mac ? path.join(ASUP, b.mac, "NativeMessagingHosts") : null;
|
|
84
|
+
return b.linux ? path.join(CFG, b.linux, "NativeMessagingHosts") : null; // linux
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 偵測:unix 上「NativeMessagingHosts 父目錄存在」視為已裝;win best-effort(無法可靠偵測)回傳所有有機碼者
|
|
88
|
+
function detectedBrowsers() {
|
|
89
|
+
if (PLATFORM === "win32") return BROWSERS.filter((b) => b.win);
|
|
90
|
+
return BROWSERS.filter((b) => {
|
|
91
|
+
const d = nmhDir(b);
|
|
92
|
+
return d && fs.existsSync(path.dirname(d));
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function winRegKey(b) {
|
|
97
|
+
return `HKCU\\${b.win}\\NativeMessagingHosts\\${HOST_NAME}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function parseBrowsersFlag(str) {
|
|
101
|
+
const ids = [...new Set(String(str).split(",").map((s) => s.trim().toLowerCase()).filter(Boolean))];
|
|
102
|
+
const valid = new Set(BROWSERS.map((b) => b.id));
|
|
103
|
+
return { known: ids.filter((id) => valid.has(id)), unknown: ids.filter((id) => !valid.has(id)) };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 決定要裝到哪些。回傳 { mode:"list", ids } 或 { mode:"interactive" }
|
|
107
|
+
function resolveSelection({ browsersFlag, all, isTTY, detectedIds }) {
|
|
108
|
+
if (browsersFlag) return { mode: "list", ids: parseBrowsersFlag(browsersFlag).known };
|
|
109
|
+
if (all) return { mode: "list", ids: detectedIds };
|
|
110
|
+
if (isTTY) return { mode: "interactive" };
|
|
111
|
+
return { mode: "list", ids: detectedIds }; // 非 TTY 退路:全裝(保護 auto-deploy)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// TTY 互動多選;只在此分支才動態載入 clack。未安裝則 fail-soft 退回全裝。
|
|
115
|
+
async function pickInteractive(detected) {
|
|
116
|
+
let clack;
|
|
117
|
+
try {
|
|
118
|
+
clack = await import("@clack/prompts");
|
|
119
|
+
} catch {
|
|
120
|
+
console.error("⚠ 互動選單需要 @clack/prompts;未安裝,退回全裝。(在 host/ 跑 npm i 後即可使用選單)");
|
|
121
|
+
return detected.map((b) => b.id);
|
|
122
|
+
}
|
|
123
|
+
const picked = await clack.multiselect({
|
|
124
|
+
message: "要安裝 aiyu host 到哪些瀏覽器?(空白鍵勾選,Enter 確認)",
|
|
125
|
+
options: detected.map((b) => ({ value: b.id, label: b.label })),
|
|
126
|
+
initialValues: detected.map((b) => b.id), // 全選 → Enter = 全裝(向後相容)
|
|
127
|
+
});
|
|
128
|
+
if (clack.isCancel(picked)) {
|
|
129
|
+
clack.cancel("已取消,未變更任何瀏覽器。");
|
|
130
|
+
process.exit(0);
|
|
131
|
+
}
|
|
132
|
+
return picked;
|
|
83
133
|
}
|
|
84
134
|
|
|
85
135
|
function regExe() {
|
|
@@ -104,14 +154,15 @@ function writeFile(p, content, mode) {
|
|
|
104
154
|
if (mode) fs.chmodSync(p, mode);
|
|
105
155
|
}
|
|
106
156
|
|
|
107
|
-
function doInstall() {
|
|
157
|
+
async function doInstall() {
|
|
108
158
|
if (!fs.existsSync(HOST_JS_SRC)) {
|
|
109
159
|
console.error(`✗ 找不到 host:${HOST_JS_SRC}(請從 repo 根目錄附近執行)`);
|
|
110
160
|
process.exit(1);
|
|
111
161
|
}
|
|
112
162
|
|
|
113
163
|
const dir = installDir();
|
|
114
|
-
|
|
164
|
+
// host 被複製到無 package.json 的目錄 → 必須用 .mjs 副檔名才會被當 ESM 跑
|
|
165
|
+
const hostJsDst = path.join(dir, "aiyu-host.mjs");
|
|
115
166
|
|
|
116
167
|
// 1. 複製 host
|
|
117
168
|
step(`複製 host → ${hostJsDst}`);
|
|
@@ -139,11 +190,31 @@ exec "${node}" "${hostJsDst}" "$@"
|
|
|
139
190
|
|
|
140
191
|
// 3. 註冊 manifest
|
|
141
192
|
const manifest = JSON.stringify(manifestObject(launcherPath), null, 2);
|
|
193
|
+
const detected = detectedBrowsers();
|
|
194
|
+
if (BROWSERS_FLAG) {
|
|
195
|
+
const { unknown } = parseBrowsersFlag(BROWSERS_FLAG);
|
|
196
|
+
if (unknown.length)
|
|
197
|
+
console.error(`⚠ 略過未知瀏覽器 id:${unknown.join(", ")}(合法:${BROWSERS.map((b) => b.id).join(", ")})`);
|
|
198
|
+
}
|
|
199
|
+
const sel = resolveSelection({
|
|
200
|
+
browsersFlag: BROWSERS_FLAG,
|
|
201
|
+
all: ALL,
|
|
202
|
+
isTTY: process.stdout.isTTY,
|
|
203
|
+
detectedIds: detected.map((b) => b.id),
|
|
204
|
+
});
|
|
205
|
+
const chosenIds = sel.mode === "interactive" ? await pickInteractive(detected) : sel.ids;
|
|
206
|
+
const detectedIdSet = new Set(detected.map((b) => b.id));
|
|
207
|
+
const targets = BROWSERS.filter((b) => chosenIds.includes(b.id) && detectedIdSet.has(b.id));
|
|
208
|
+
if (BROWSERS_FLAG && targets.length === 0) {
|
|
209
|
+
console.error(`✗ --browsers 指定的瀏覽器都沒偵測到。偵測到的:${[...detectedIdSet].join(", ") || "(無)"}`);
|
|
210
|
+
process.exit(1);
|
|
211
|
+
}
|
|
142
212
|
let registered = 0;
|
|
143
213
|
if (PLATFORM === "win32") {
|
|
144
214
|
const manifestPath = path.join(dir, `${HOST_NAME}.json`);
|
|
145
215
|
writeFile(manifestPath, manifest);
|
|
146
|
-
for (const
|
|
216
|
+
for (const b of targets) {
|
|
217
|
+
const key = winRegKey(b);
|
|
147
218
|
step(`reg add ${key} → ${manifestPath}`);
|
|
148
219
|
if (DRY) { registered++; continue; }
|
|
149
220
|
try {
|
|
@@ -156,9 +227,8 @@ exec "${node}" "${hostJsDst}" "$@"
|
|
|
156
227
|
}
|
|
157
228
|
}
|
|
158
229
|
} else {
|
|
159
|
-
for (const
|
|
160
|
-
|
|
161
|
-
writeFile(path.join(d, `${HOST_NAME}.json`), manifest);
|
|
230
|
+
for (const b of targets) {
|
|
231
|
+
writeFile(path.join(nmhDir(b), `${HOST_NAME}.json`), manifest);
|
|
162
232
|
registered++;
|
|
163
233
|
}
|
|
164
234
|
}
|
|
@@ -192,21 +262,31 @@ function printExtensionSteps() {
|
|
|
192
262
|
|
|
193
263
|
function doUninstall() {
|
|
194
264
|
const dir = installDir();
|
|
265
|
+
const onlyIds = BROWSERS_FLAG ? parseBrowsersFlag(BROWSERS_FLAG).known : null;
|
|
266
|
+
const inScope = (b) => !onlyIds || onlyIds.includes(b.id);
|
|
195
267
|
if (PLATFORM === "win32") {
|
|
196
|
-
for (const
|
|
268
|
+
for (const b of BROWSERS) {
|
|
269
|
+
if (!inScope(b)) continue;
|
|
270
|
+
if (!b.win) continue;
|
|
271
|
+
const key = winRegKey(b);
|
|
197
272
|
step(`reg delete ${key}`);
|
|
198
273
|
if (!DRY) {
|
|
199
274
|
try { execFileSync(regExe(), ["delete", key, "/f"], { stdio: "ignore" }); } catch { /* 沒這個機碼 */ }
|
|
200
275
|
}
|
|
201
276
|
}
|
|
202
277
|
} else {
|
|
203
|
-
for (const
|
|
278
|
+
for (const b of BROWSERS) {
|
|
279
|
+
if (!inScope(b)) continue;
|
|
280
|
+
const d = nmhDir(b);
|
|
281
|
+
if (!d) continue;
|
|
204
282
|
const out = path.join(d, `${HOST_NAME}.json`);
|
|
205
283
|
if (fs.existsSync(out)) { step(`刪除 ${out}`); if (!DRY) fs.rmSync(out); }
|
|
206
284
|
}
|
|
207
285
|
}
|
|
208
|
-
|
|
209
|
-
|
|
286
|
+
if (!onlyIds) {
|
|
287
|
+
step(`刪除安裝目錄 ${dir}`);
|
|
288
|
+
if (!DRY && fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
|
|
289
|
+
}
|
|
210
290
|
log("\n✓ 已移除 host 註冊。擴充請到 chrome://extensions 自行移除。");
|
|
211
291
|
}
|
|
212
292
|
|
|
@@ -214,16 +294,26 @@ function printHelp() {
|
|
|
214
294
|
log(`aiyu 跨平台 native host 安裝器
|
|
215
295
|
|
|
216
296
|
用法:
|
|
217
|
-
node install.js
|
|
218
|
-
node install.js --
|
|
219
|
-
node install.js --
|
|
297
|
+
node install.js 安裝 host(TTY 下互動選擇瀏覽器)
|
|
298
|
+
node install.js --all 安裝到所有偵測到的瀏覽器(跳過提問)
|
|
299
|
+
node install.js --browsers=chrome,brave 只安裝到指定瀏覽器
|
|
300
|
+
node install.js --dry-run 只印出會做什麼,不實際寫入
|
|
301
|
+
node install.js --uninstall 移除 host 註冊(可加 --browsers= 只移除部分)
|
|
220
302
|
|
|
303
|
+
瀏覽器 id:${BROWSERS.map((b) => b.id).join(", ")}
|
|
221
304
|
平台:${PLATFORM} 擴充 ID(dev/store):${DEV_EXT_ID} / ${STORE_EXT_ID}`);
|
|
222
305
|
}
|
|
223
306
|
|
|
224
|
-
|
|
307
|
+
async function main() {
|
|
225
308
|
if (HELP) return printHelp();
|
|
226
309
|
log(`aiyu installer 平台=${PLATFORM} node=${process.version}${DRY ? " (dry-run)" : ""}`);
|
|
227
310
|
if (UNINSTALL) return doUninstall();
|
|
228
|
-
doInstall();
|
|
229
|
-
}
|
|
311
|
+
return doInstall();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export { parseBrowsersFlag, resolveSelection };
|
|
315
|
+
|
|
316
|
+
// 直接執行才跑 main;被 import(測試)時不跑
|
|
317
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
318
|
+
main().catch((e) => { console.error(e); process.exit(1); });
|
|
319
|
+
}
|
package/package.json
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lancetw/aiyu",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"type": "module",
|
|
4
5
|
"description": "Installs the native-messaging host for the aiyu Chrome extension, letting it call your local claude/codex/agy CLI to translate YouTube subtitles and selected text. Run: npx @lancetw/aiyu",
|
|
5
6
|
"bin": {
|
|
6
7
|
"aiyu": "install.js"
|
|
7
8
|
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "node --test"
|
|
11
|
+
},
|
|
8
12
|
"files": [
|
|
9
13
|
"aiyu-host.js",
|
|
10
14
|
"install.js"
|
|
11
15
|
],
|
|
12
16
|
"engines": {
|
|
13
|
-
"node": ">=
|
|
17
|
+
"node": ">=20.12.0"
|
|
14
18
|
},
|
|
15
19
|
"license": "MIT",
|
|
16
20
|
"author": "lancetw",
|
|
@@ -32,5 +36,8 @@
|
|
|
32
36
|
"chrome-extension",
|
|
33
37
|
"claude",
|
|
34
38
|
"codex"
|
|
35
|
-
]
|
|
39
|
+
],
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@clack/prompts": "1.4.0"
|
|
42
|
+
}
|
|
36
43
|
}
|