pi-extensions-i18n 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/README.md ADDED
@@ -0,0 +1,43 @@
1
+ # pi-extensions-i18n
2
+
3
+ Shared i18n runtime for pi extensions, with bilingual (zh-CN / en-US) support.
4
+
5
+ pi 扩展的公共中英文运行时。提供:
6
+
7
+ - `zh-CN`、`en-US` 和 `auto` 三种语言偏好;
8
+ - 统一读取 `~/.pi/agent/extensions/pi-extensions-i18n/config.json`;
9
+ - `/pi-language` 终端交互式语言配置命令;
10
+ - `PI_EXTENSIONS_LOCALE` 环境变量覆盖配置;
11
+ - 面向 UI、命令描述和 agent prompt 的翻译器,以及外部 JSON catalog 加载和校验。
12
+
13
+ ## Install / 安装
14
+
15
+ ```bash
16
+ pi install npm:pi-extensions-i18n
17
+ ```
18
+
19
+ 本包是共享 peer dependency,依赖它的扩展(如 `pi-distill`、`pi-tool-supervisor`)会自动引用,不会把配置复制到每个插件目录。
20
+
21
+ ## Locale priority / 语言优先级
22
+
23
+ ```text
24
+ PI_EXTENSIONS_LOCALE 环境变量 > 持久化配置 > 默认 zh-CN
25
+ ```
26
+
27
+ `PI_EXTENSIONS_LOCALE` 和配置文件支持 `zh-CN`、`en-US`、`auto`,也接受 `zh`、`en` 简写。`auto` 根据 `LC_ALL`、`LC_MESSAGES` 或 `LANG` 判断系统语言。
28
+
29
+ ```bash
30
+ PI_EXTENSIONS_LOCALE=en-US pi
31
+ ```
32
+
33
+ 在 pi 中执行 `/pi-language` 可通过 select 菜单保存语言;也可以直接执行 `/pi-language en-US`。
34
+
35
+ ## Catalog layout / 翻译资源约定
36
+
37
+ 扩展自己的翻译资源保留在对应包的 `locales/` 目录中,按源码模块拆分为 JSON 文件,例如 `pi-distill/locales/summary-utils.json`;本包自身的 `/pi-language` 文案位于 `locales/command.json`。
38
+
39
+ 每个 catalog 条目必须同时包含 `zh-CN` 与 `en-US` 两个语言键(CI 强制检查)。运行时只共享语言解析、配置存储、catalog 校验和插值规则,避免各插件重复实现配置路径和 fallback。
40
+
41
+ ## License
42
+
43
+ [MIT](../../LICENSE)
package/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { default } from "./src/index.ts";
2
+ export * from "./src/index.ts";
@@ -0,0 +1,42 @@
1
+ {
2
+ "description": {
3
+ "zh-CN": "配置 PiExtensions 的界面和 prompt 语言",
4
+ "en-US": "Configure the PiExtensions UI and prompt language"
5
+ },
6
+ "noUi": {
7
+ "zh-CN": "语言配置需要在带 UI 的 Pi 会话中运行。",
8
+ "en-US": "Language settings require a Pi session with a UI."
9
+ },
10
+ "title": {
11
+ "zh-CN": "PiExtensions 语言",
12
+ "en-US": "PiExtensions language"
13
+ },
14
+ "zh": {
15
+ "zh-CN": "简体中文 (zh-CN)",
16
+ "en-US": "Simplified Chinese (zh-CN)"
17
+ },
18
+ "en": {
19
+ "zh-CN": "English (en-US)",
20
+ "en-US": "English (en-US)"
21
+ },
22
+ "auto": {
23
+ "zh-CN": "跟随系统语言 (auto)",
24
+ "en-US": "Follow system language (auto)"
25
+ },
26
+ "saved": {
27
+ "zh-CN": "语言已保存为 {locale},新的 UI 和 prompt 将使用该语言。",
28
+ "en-US": "Language saved as {locale}; new UI and prompts will use this language."
29
+ },
30
+ "invalid": {
31
+ "zh-CN": "不支持的语言设置:{value}。请选择 zh-CN、en-US 或 auto。",
32
+ "en-US": "Unsupported language setting: {value}. Choose zh-CN, en-US, or auto."
33
+ },
34
+ "failed": {
35
+ "zh-CN": "保存语言设置失败:{error}",
36
+ "en-US": "Failed to save language settings: {error}"
37
+ },
38
+ "envOverride": {
39
+ "zh-CN": "当前由 {env} 环境变量覆盖,持久化值将在移除环境变量后生效。",
40
+ "en-US": "The {env} environment variable currently overrides this value; the persisted setting will apply after it is removed."
41
+ }
42
+ }
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "pi-extensions-i18n",
3
+ "version": "0.1.0",
4
+ "description": "Shared i18n catalog loader & translator for pi extensions",
5
+ "type": "module",
6
+ "main": "./index.ts",
7
+ "exports": {
8
+ ".": "./index.ts"
9
+ },
10
+ "files": [
11
+ "index.ts",
12
+ "src",
13
+ "locales",
14
+ "README.md",
15
+ "tsconfig.json"
16
+ ],
17
+ "scripts": {
18
+ "test": "tsx --test tests/pi-i18n.test.ts",
19
+ "typecheck": "tsc --noEmit --pretty false",
20
+ "build": "npm run typecheck",
21
+ "check": "npm run typecheck && npm test && npm pack --dry-run --json > /dev/null"
22
+ },
23
+ "pi": {
24
+ "extensions": [
25
+ "./index.ts"
26
+ ]
27
+ },
28
+ "engines": {
29
+ "node": ">=22"
30
+ },
31
+ "license": "MIT",
32
+ "author": "maplezzk",
33
+ "homepage": "https://github.com/maplezzk/pi-extensions/tree/main/packages/pi-extensions-i18n",
34
+ "bugs": "https://github.com/maplezzk/pi-extensions/issues",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/maplezzk/pi-extensions.git",
38
+ "directory": "packages/pi-extensions-i18n"
39
+ },
40
+ "publishConfig": {
41
+ "access": "public",
42
+ "registry": "https://registry.npmjs.org"
43
+ },
44
+ "keywords": [
45
+ "pi-package",
46
+ "pi",
47
+ "pi-extension",
48
+ "i18n",
49
+ "coding-agent"
50
+ ],
51
+ "peerDependencies": {
52
+ "@earendil-works/pi-coding-agent": ">=0.80.0 <0.81.0"
53
+ },
54
+ "devDependencies": {
55
+ "@earendil-works/pi-coding-agent": "0.80.10",
56
+ "@types/node": "24.12.4",
57
+ "tsx": "4.23.1",
58
+ "typescript": "5.9.3"
59
+ }
60
+ }
package/src/index.ts ADDED
@@ -0,0 +1,251 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionCommandContext,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { dirname, join } from "node:path";
7
+ import { homedir } from "node:os";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ export const SUPPORTED_LOCALES = ["zh-CN", "en-US"] as const;
11
+ export type Locale = (typeof SUPPORTED_LOCALES)[number];
12
+ export type LocalePreference = Locale | "auto";
13
+
14
+ export const DEFAULT_LOCALE_PREFERENCE: LocalePreference = "zh-CN";
15
+ export const LOCALE_ENV = "PI_EXTENSIONS_LOCALE";
16
+ export const LOCALE_CONFIG_FILE = "config.json";
17
+ export const LOCALE_CONFIG_DIR = "extensions/pi-extensions-i18n";
18
+
19
+ export interface LocaleConfig {
20
+ locale: LocalePreference;
21
+ }
22
+
23
+ export type MessageCatalog = Record<string, Record<Locale, string>>;
24
+ export type MessageKey<Catalog extends MessageCatalog> = keyof Catalog & string;
25
+ export type MessageParams = Record<string, string | number>;
26
+
27
+ interface Translator<Catalog extends MessageCatalog> {
28
+ locale(): Locale;
29
+ t(key: MessageKey<Catalog>, params?: MessageParams): string;
30
+ }
31
+
32
+ let runtimePreference: LocalePreference | undefined;
33
+
34
+ function normalizeLocale(value: unknown): LocalePreference | undefined {
35
+ if (typeof value !== "string") return undefined;
36
+ const normalized = value.trim().toLowerCase();
37
+ if (normalized === "auto") return "auto";
38
+ if (normalized === "zh" || normalized === "zh-cn") return "zh-CN";
39
+ if (normalized === "en" || normalized === "en-us") return "en-US";
40
+ return undefined;
41
+ }
42
+
43
+ export function parseLocalePreference(value: string): LocalePreference | undefined {
44
+ return normalizeLocale(value);
45
+ }
46
+
47
+ function resolveAgentDir(): string {
48
+ const configured = process.env.PI_CODING_AGENT_DIR;
49
+ if (!configured) return join(homedir(), ".pi", "agent");
50
+ if (configured === "~") return homedir();
51
+ return configured.startsWith("~/")
52
+ ? join(homedir(), configured.slice(2))
53
+ : configured;
54
+ }
55
+
56
+ export function getLocaleConfigPath(agentDir = resolveAgentDir()): string {
57
+ return join(agentDir, LOCALE_CONFIG_DIR, LOCALE_CONFIG_FILE);
58
+ }
59
+
60
+ function readPersistedPreference(agentDir: string): LocalePreference | undefined {
61
+ const configPath = getLocaleConfigPath(agentDir);
62
+ try {
63
+ const raw = readFileSync(configPath, "utf8");
64
+ const parsed: unknown = JSON.parse(raw);
65
+ const value = normalizeLocale(
66
+ typeof parsed === "object" && parsed !== null
67
+ ? (parsed as Record<string, unknown>).locale
68
+ : undefined,
69
+ );
70
+ if (value) return value;
71
+ console.warn(
72
+ `[pi-extensions-i18n] Invalid locale in ${configPath}; using ${DEFAULT_LOCALE_PREFERENCE}.`,
73
+ );
74
+ } catch (error) {
75
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
76
+ console.warn(
77
+ `[pi-extensions-i18n] Failed to read ${configPath}; using ${DEFAULT_LOCALE_PREFERENCE}: ${String(error)}`,
78
+ );
79
+ }
80
+ }
81
+ return undefined;
82
+ }
83
+
84
+ export function getLocalePreference(): LocalePreference {
85
+ const envValue = process.env[LOCALE_ENV];
86
+ if (envValue !== undefined) {
87
+ const parsed = normalizeLocale(envValue);
88
+ if (parsed) return parsed;
89
+ console.warn(
90
+ `[pi-extensions-i18n] Invalid ${LOCALE_ENV}=${JSON.stringify(envValue)}; using ${DEFAULT_LOCALE_PREFERENCE}.`,
91
+ );
92
+ return DEFAULT_LOCALE_PREFERENCE;
93
+ }
94
+
95
+ if (runtimePreference) return runtimePreference;
96
+ runtimePreference =
97
+ readPersistedPreference(resolveAgentDir()) ?? DEFAULT_LOCALE_PREFERENCE;
98
+ return runtimePreference;
99
+ }
100
+
101
+ function detectSystemLocale(): Locale {
102
+ const systemLocale = process.env.LC_ALL ?? process.env.LC_MESSAGES ?? process.env.LANG ?? "";
103
+ return systemLocale.toLowerCase().startsWith("zh") ? "zh-CN" : "en-US";
104
+ }
105
+
106
+ export function getLocale(): Locale {
107
+ const preference = getLocalePreference();
108
+ return preference === "auto" ? detectSystemLocale() : preference;
109
+ }
110
+
111
+ export function resetLocaleState(): void {
112
+ runtimePreference = undefined;
113
+ }
114
+
115
+ export function saveLocalePreference(
116
+ preference: LocalePreference,
117
+ agentDir = resolveAgentDir(),
118
+ ): string {
119
+ const normalized = normalizeLocale(preference);
120
+ if (!normalized) {
121
+ throw new Error(`Unsupported locale preference: ${String(preference)}`);
122
+ }
123
+
124
+ const configPath = getLocaleConfigPath(agentDir);
125
+ mkdirSync(dirname(configPath), { recursive: true });
126
+ const config: LocaleConfig = { locale: normalized };
127
+ writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
128
+ runtimePreference = normalized;
129
+ return configPath;
130
+ }
131
+
132
+ function isRecord(value: unknown): value is Record<string, unknown> {
133
+ return typeof value === "object" && value !== null && !Array.isArray(value);
134
+ }
135
+
136
+ export function loadCatalog(catalogFile: URL | string): MessageCatalog {
137
+ const filePath = catalogFile instanceof URL ? fileURLToPath(catalogFile) : catalogFile;
138
+ const parsed: unknown = JSON.parse(readFileSync(filePath, "utf8"));
139
+ if (!isRecord(parsed)) {
140
+ throw new Error(`Invalid i18n catalog: expected an object in ${filePath}`);
141
+ }
142
+
143
+ const catalog: MessageCatalog = {};
144
+ for (const [key, entry] of Object.entries(parsed)) {
145
+ if (!isRecord(entry) || typeof entry["zh-CN"] !== "string" || typeof entry["en-US"] !== "string") {
146
+ throw new Error(`Invalid i18n catalog entry ${key} in ${filePath}`);
147
+ }
148
+ catalog[key] = {
149
+ "zh-CN": entry["zh-CN"],
150
+ "en-US": entry["en-US"],
151
+ };
152
+ }
153
+ return catalog;
154
+ }
155
+
156
+ function interpolate(template: string, params: MessageParams | undefined): string {
157
+ if (!params) return template;
158
+ return template.replace(/\{([A-Za-z0-9_]+)\}/g, (placeholder, name) => {
159
+ const value = params[name];
160
+ if (value === undefined) {
161
+ throw new Error(`Missing interpolation value for ${placeholder}`);
162
+ }
163
+ return String(value);
164
+ });
165
+ }
166
+
167
+ export function createTranslator<Catalog extends MessageCatalog>(
168
+ catalog: Catalog,
169
+ ): Translator<Catalog> {
170
+ return {
171
+ locale: getLocale,
172
+ t(key, params) {
173
+ const entry = catalog[key];
174
+ if (!entry) {
175
+ throw new Error(`Unknown i18n message key: ${String(key)}`);
176
+ }
177
+ const message = entry[getLocale()];
178
+ if (message === undefined) {
179
+ throw new Error(`Missing ${getLocale()} translation for message key: ${String(key)}`);
180
+ }
181
+ return interpolate(message, params);
182
+ },
183
+ };
184
+ }
185
+
186
+ const commandMessages = loadCatalog(
187
+ new URL("../locales/command.json", import.meta.url),
188
+ );
189
+
190
+ function registerLocaleCommand(pi: ExtensionAPI): void {
191
+ const i18n = createTranslator(commandMessages);
192
+ pi.registerCommand("pi-language", {
193
+ description: i18n.t("description"),
194
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
195
+ if (!ctx.hasUI) {
196
+ ctx.ui.notify(i18n.t("noUi"), "warning");
197
+ return;
198
+ }
199
+
200
+ const requested = args.trim();
201
+ const directPreference = requested ? parseLocalePreference(requested) : undefined;
202
+ if (requested && !directPreference) {
203
+ ctx.ui.notify(i18n.t("invalid", { value: requested }), "error");
204
+ return;
205
+ }
206
+
207
+ let preference = directPreference;
208
+ if (!preference) {
209
+ const options = [
210
+ i18n.t("zh"),
211
+ i18n.t("en"),
212
+ i18n.t("auto"),
213
+ ];
214
+ const current = getLocalePreference();
215
+ const currentOption = current === "zh-CN"
216
+ ? options[0]
217
+ : current === "en-US"
218
+ ? options[1]
219
+ : options[2];
220
+ const selected = await ctx.ui.select(
221
+ `${i18n.t("title")} [${currentOption}]`,
222
+ options,
223
+ );
224
+ if (selected === undefined) return;
225
+ preference = selected === options[0]
226
+ ? "zh-CN"
227
+ : selected === options[1]
228
+ ? "en-US"
229
+ : "auto";
230
+ }
231
+
232
+ try {
233
+ const configPath = saveLocalePreference(preference);
234
+ const envOverride = process.env[LOCALE_ENV];
235
+ const overrideNotice = envOverride
236
+ ? `\n${i18n.t("envOverride", { env: LOCALE_ENV })}`
237
+ : "";
238
+ ctx.ui.notify(
239
+ `${i18n.t("saved", { locale: preference })}${overrideNotice}\n${configPath}`,
240
+ "info",
241
+ );
242
+ } catch (error) {
243
+ ctx.ui.notify(i18n.t("failed", { error: String(error) }), "error");
244
+ }
245
+ },
246
+ });
247
+ }
248
+
249
+ export default function piI18n(pi: ExtensionAPI): void {
250
+ registerLocaleCommand(pi);
251
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "allowImportingTsExtensions": true,
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "skipLibCheck": true
10
+ },
11
+ "include": ["index.ts", "src/**/*.ts"]
12
+ }