opencode-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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 huahai0202
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,106 @@
1
+ # OpenCode i18n
2
+
3
+ OpenCode TUI 界面本地化插件。
4
+
5
+ 支持语言:
6
+
7
+ - `English`:原始英文,不改标题和描述
8
+ - `简体中文`
9
+ - `繁體中文`
10
+
11
+ 安装后运行 `/i18n`,用选项切换语言。选择中文会自动开启本地化;选择 `English` 会回到原始英文。
12
+
13
+ ## 一键安装(npm)
14
+
15
+ ```bash
16
+ opencode plugin opencode-i18n
17
+ ```
18
+
19
+ 安装后重启 OpenCode,运行 `/i18n` 选择 English、简体中文或繁體中文。
20
+
21
+ 说明:
22
+
23
+ - 该命令把插件安装到全局配置(`~/.config/opencode`),自动合并 `tui.json` 的 `plugin` 列表。
24
+ - 插件自带默认语言包(`i18n/locales/*.json`),开箱即用。
25
+ - 如需自定义语言包,把文件放到 `~/.config/opencode/i18n/locales/` 即可覆盖默认值。
26
+
27
+ ## 一句提示词安装
28
+
29
+ 把下面这一句发给 OpenCode:
30
+
31
+ ```text
32
+ 请从 https://github.com/huahai0202/opencode-i18n 安装 OpenCode i18n 插件:克隆仓库,Windows 运行 install.ps1、macOS/Linux 运行 install.sh,或按 README 手动复制文件到我的全局 OpenCode 配置目录,保留我已有配置并合并 tui.json 的 plugin 列表;完成后告诉我重启 OpenCode,并运行 /i18n 选择 English、简体中文或繁體中文。
33
+ ```
34
+
35
+ ## 手动安装(复制式)
36
+
37
+ macOS / Linux:
38
+
39
+ ```bash
40
+ git clone https://github.com/huahai0202/opencode-i18n.git
41
+ cd opencode-i18n
42
+ ./install.sh
43
+ ```
44
+
45
+ Windows PowerShell:
46
+
47
+ ```powershell
48
+ git clone https://github.com/huahai0202/opencode-i18n.git
49
+ cd opencode-i18n
50
+ .\install.ps1
51
+ ```
52
+
53
+ 脚本会安装到(尊重 `XDG_CONFIG_HOME`,默认 `~/.config/opencode`):
54
+
55
+ ```text
56
+ ~/.config/opencode # macOS / Linux
57
+ %USERPROFILE%\.config\opencode # Windows
58
+ ```
59
+
60
+ 自定义安装路径:
61
+
62
+ ```bash
63
+ ./install.sh --config-root /path/to/opencode-config # macOS / Linux
64
+ .\install.ps1 -ConfigRoot C:\path\to\opencode-config # Windows
65
+ ```
66
+
67
+ 它会复制这些文件:
68
+
69
+ - `plugins/i18n/index.ts`
70
+ - `tools/i18n-state.ts`
71
+ - `commands/i18n.md`
72
+ - `i18n/`
73
+
74
+ 并合并:
75
+
76
+ - `tui.json` 的 `plugin` 列表
77
+ - `package.json` 的 `@opencode-ai/plugin` 依赖
78
+
79
+ ## 使用
80
+
81
+ ```text
82
+ /i18n
83
+ ```
84
+
85
+ 打开语言选择。
86
+
87
+ ```text
88
+ /i18n status
89
+ /i18n on
90
+ /i18n off
91
+ /i18n toggle
92
+ ```
93
+
94
+ 管理开关状态。
95
+
96
+ 如果界面没有立即刷新,请重启 OpenCode。
97
+
98
+ ## 文件说明
99
+
100
+ - `plugins/i18n/index.ts`:TUI 插件,读取语言包和状态,改写界面标题/已有描述。
101
+ - `plugins/i18n/server.ts`:server 插件,向 OpenCode 注册 `i18n-state` 工具(npm 安装模式下工具不会自动从 `tools/` 加载)。
102
+ - `tools/i18n-state.ts`:状态工具,负责开关和语言选择。
103
+ - `i18n/lib.ts`:共享路径、状态读取和语言解析逻辑。配置优先读用户目录 `~/.config/opencode/i18n/`,缺失时回退到包内默认数据。
104
+ - `commands/i18n.md`:OpenCode 自定义 command(复制式安装时随脚本复制;npm 安装时请手动放到 `~/.config/opencode/commands/` 或直接调用 `i18n-state` 工具)。
105
+ - `i18n/config.json`:默认语言和内置语言排序。
106
+ - `i18n/locales/*.json`:独立语言包。新增语言时只要添加一个 locale JSON 即可被自动识别;如果想调整显示顺序,可把语言代码加入 `config.json` 的 `locales` 列表。
@@ -0,0 +1,18 @@
1
+ ---
2
+ description: 管理 OpenCode 界面本地化开关和语言
3
+ ---
4
+
5
+ 用户想管理 OpenCode 界面的本地化开关和语言。请按以下规则解析参数,并调用 `i18n-state` 工具。
6
+
7
+ 参数内容:$ARGUMENTS
8
+
9
+ 规则:
10
+
11
+ 1. 如果参数为空,先调用 `i18n-state`,传入 `action: "locales"`,读取返回内容末尾的 `QUESTION_DATA:` JSON;然后使用 `question` 工具让用户图形化选择语言。question 的问题文字必须逐字使用 JSON 里的 `question` 字段。选项必须逐项使用 JSON 里的 `options[].label` 和 `options[].description`,不要自行翻译、补充、改写,也不要给选项追加“当前”或“默认”。用户选择后调用 `i18n-state`,传入 `action: "locale"` 和用户选择的语言名称,也就是被选中的 `label`。
12
+ 2. 如果参数为 `status`、`状态`,调用 `i18n-state`,传入 `action: "status"`。
13
+ 3. 如果参数为 `on`、`enable`、`开启`、`开`,调用 `i18n-state`,传入 `action: "set"` 和 `enabled: true`。
14
+ 4. 如果参数为 `off`、`disable`、`关闭`、`关`,调用 `i18n-state`,传入 `action: "set"` 和 `enabled: false`。
15
+ 5. 如果参数为 `toggle`、`切换`,调用 `i18n-state`,传入 `action: "toggle"`。
16
+ 6. 如果参数无法识别,调用 `i18n-state` 的 `status`,然后简短说明支持的参数只有 `status`、`on`、`off`、`toggle`;切换语言请直接运行 `/i18n` 并在选项中选择。
17
+
18
+ 切换开关后,提醒用户:如果当前界面没有立即刷新,请重启 OpenCode。
@@ -0,0 +1,23 @@
1
+ {
2
+ "defaultLocale": "zh-Hans",
3
+ "locales": [
4
+ "en",
5
+ "zh-Hans",
6
+ "zh-Hant",
7
+ "ko",
8
+ "de",
9
+ "es",
10
+ "fr",
11
+ "da",
12
+ "ja",
13
+ "pl",
14
+ "ru",
15
+ "uk",
16
+ "bs",
17
+ "ar",
18
+ "no",
19
+ "pt-BR",
20
+ "th",
21
+ "tr"
22
+ ]
23
+ }
package/i18n/lib.ts ADDED
@@ -0,0 +1,307 @@
1
+ import { readdirSync, readFileSync } from "node:fs"
2
+ import { readdir, readFile } from "node:fs/promises"
3
+ import os from "node:os"
4
+ import path from "node:path"
5
+ import { fileURLToPath } from "node:url"
6
+
7
+ export type LocaleCode = string
8
+
9
+ export type I18nState = {
10
+ version: 1
11
+ enabled: boolean
12
+ locale?: LocaleCode
13
+ updatedAt?: string
14
+ }
15
+
16
+ export type I18nLocaleConfig = {
17
+ name: string
18
+ language_picker: {
19
+ question?: string
20
+ option_descriptions: Record<string, string>
21
+ }
22
+ commands: Record<string, Record<string, string>>
23
+ descriptions: Record<string, string>
24
+ slash_commands: Record<string, string>
25
+ }
26
+
27
+ export type I18nConfig = {
28
+ defaultLocale?: LocaleCode
29
+ locales: Record<LocaleCode, I18nLocaleConfig>
30
+ }
31
+
32
+ type I18nIndexConfig = {
33
+ defaultLocale?: LocaleCode
34
+ locales: LocaleCode[]
35
+ }
36
+
37
+ export type LocaleInfo = {
38
+ defaultLocale?: LocaleCode
39
+ activeLocale?: LocaleCode
40
+ available: LocaleCode[]
41
+ labels: Map<LocaleCode, string>
42
+ config?: I18nConfig
43
+ }
44
+
45
+ type JsonObject = Record<string, unknown>
46
+
47
+ const MODULE_ROOT = path.dirname(fileURLToPath(import.meta.url))
48
+
49
+ /**
50
+ * Package root. When installed as an npm plugin this is the package directory
51
+ * (bundled default data). When installed by copying files into the config
52
+ * directory it is the config directory itself.
53
+ */
54
+ export const PACKAGE_ROOT = path.resolve(MODULE_ROOT, "..")
55
+
56
+ function resolveConfigRoot() {
57
+ const env = process.env.OPENCODE_CONFIG_DIR
58
+ if (env) return path.resolve(env)
59
+ return path.join(os.homedir(), ".config", "opencode")
60
+ }
61
+
62
+ /** User config root. User-customized i18n data takes priority over bundled defaults. */
63
+ export const CONFIG_ROOT = resolveConfigRoot()
64
+ export const CONFIG_PATH = path.join(CONFIG_ROOT, "i18n", "config.json")
65
+ export const LOCALES_ROOT = path.join(CONFIG_ROOT, "i18n", "locales")
66
+ export const PACKAGE_CONFIG_PATH = path.join(PACKAGE_ROOT, "i18n", "config.json")
67
+ export const PACKAGE_LOCALES_ROOT = path.join(PACKAGE_ROOT, "i18n", "locales")
68
+ export const STATE_ROOT = path.join(process.env.XDG_STATE_HOME ?? path.join(os.homedir(), ".local", "state"), "opencode")
69
+ export const STATE_PATH = path.join(STATE_ROOT, "i18n-state.json")
70
+
71
+ export function readJsonFileSync<T>(file: string): T | undefined {
72
+ try {
73
+ return JSON.parse(readFileSync(file, "utf8")) as T
74
+ } catch {
75
+ return undefined
76
+ }
77
+ }
78
+
79
+ export async function readJsonFile<T>(file: string): Promise<T | undefined> {
80
+ try {
81
+ return JSON.parse(await readFile(file, "utf8")) as T
82
+ } catch {
83
+ return undefined
84
+ }
85
+ }
86
+
87
+ function isObject(value: unknown): value is JsonObject {
88
+ return typeof value === "object" && value !== null && !Array.isArray(value)
89
+ }
90
+
91
+ function stringRecord(value: unknown): Record<string, string> {
92
+ if (!isObject(value)) return {}
93
+
94
+ const result: Record<string, string> = {}
95
+ for (const [key, item] of Object.entries(value)) {
96
+ if (typeof item === "string") result[key] = item
97
+ }
98
+
99
+ return result
100
+ }
101
+
102
+ function commandGroups(value: unknown): Record<string, Record<string, string>> {
103
+ if (!isObject(value)) return {}
104
+
105
+ const result: Record<string, Record<string, string>> = {}
106
+ for (const [group, commands] of Object.entries(value)) {
107
+ result[group] = stringRecord(commands)
108
+ }
109
+
110
+ return result
111
+ }
112
+
113
+ function languagePicker(value: unknown): I18nLocaleConfig["language_picker"] {
114
+ if (!isObject(value)) {
115
+ return {
116
+ option_descriptions: {},
117
+ }
118
+ }
119
+
120
+ return {
121
+ question: typeof value.question === "string" && value.question.trim() ? value.question.trim() : undefined,
122
+ option_descriptions: stringRecord(value.option_descriptions),
123
+ }
124
+ }
125
+
126
+ function normalizeLocaleConfig(value: unknown, fallbackName: string): I18nLocaleConfig {
127
+ const locale = isObject(value) ? value : {}
128
+ const name = typeof locale.name === "string" && locale.name.trim() ? locale.name.trim() : fallbackName
129
+
130
+ return {
131
+ name,
132
+ language_picker: languagePicker(locale.language_picker),
133
+ commands: commandGroups(locale.commands),
134
+ descriptions: stringRecord(locale.descriptions),
135
+ slash_commands: stringRecord(locale.slash_commands),
136
+ }
137
+ }
138
+
139
+ export function normalizeState(state: unknown): I18nState {
140
+ const raw = isObject(state) ? state : {}
141
+
142
+ return {
143
+ version: 1,
144
+ enabled: raw.enabled === true,
145
+ locale: typeof raw.locale === "string" ? raw.locale : undefined,
146
+ updatedAt: typeof raw.updatedAt === "string" ? raw.updatedAt : undefined,
147
+ }
148
+ }
149
+
150
+ function normalizeIndexConfig(config: unknown): I18nIndexConfig | undefined {
151
+ if (!isObject(config)) return undefined
152
+
153
+ const locales = Array.isArray(config.locales)
154
+ ? config.locales.filter((locale): locale is string => typeof locale === "string" && locale.trim().length > 0)
155
+ : []
156
+
157
+ const defaultLocale = typeof config.defaultLocale === "string" && locales.includes(config.defaultLocale) ? config.defaultLocale : undefined
158
+ return { defaultLocale, locales }
159
+ }
160
+
161
+ function localeCodeFromFile(file: string) {
162
+ return file.endsWith(".json") ? file.slice(0, -".json".length) : undefined
163
+ }
164
+
165
+ function mergeLocaleNames(indexLocales: readonly LocaleCode[], discoveredLocales: readonly LocaleCode[]) {
166
+ return Array.from(new Set([...indexLocales, ...discoveredLocales])).filter(Boolean)
167
+ }
168
+
169
+ function discoverLocalesInSync(root: string) {
170
+ try {
171
+ return readdirSync(root, { withFileTypes: true })
172
+ .filter((entry) => entry.isFile())
173
+ .map((entry) => localeCodeFromFile(entry.name))
174
+ .filter((locale): locale is string => typeof locale === "string" && locale.length > 0)
175
+ .sort((a, b) => a.localeCompare(b))
176
+ } catch {
177
+ return []
178
+ }
179
+ }
180
+
181
+ function discoverLocalesSync() {
182
+ return Array.from(new Set([...discoverLocalesInSync(LOCALES_ROOT), ...discoverLocalesInSync(PACKAGE_LOCALES_ROOT)])).sort(
183
+ (a, b) => a.localeCompare(b),
184
+ )
185
+ }
186
+
187
+ async function discoverLocalesIn(root: string) {
188
+ try {
189
+ return (await readdir(root, { withFileTypes: true }))
190
+ .filter((entry) => entry.isFile())
191
+ .map((entry) => localeCodeFromFile(entry.name))
192
+ .filter((locale): locale is string => typeof locale === "string" && locale.length > 0)
193
+ .sort((a, b) => a.localeCompare(b))
194
+ } catch {
195
+ return []
196
+ }
197
+ }
198
+
199
+ async function discoverLocales() {
200
+ const [user, pkg] = await Promise.all([discoverLocalesIn(LOCALES_ROOT), discoverLocalesIn(PACKAGE_LOCALES_ROOT)])
201
+ return Array.from(new Set([...user, ...pkg])).sort((a, b) => a.localeCompare(b))
202
+ }
203
+
204
+ function readLocaleFileSync(locale: LocaleCode) {
205
+ const user = readJsonFileSync<unknown>(path.join(LOCALES_ROOT, `${locale}.json`))
206
+ if (user !== undefined) return user
207
+ return readJsonFileSync<unknown>(path.join(PACKAGE_LOCALES_ROOT, `${locale}.json`))
208
+ }
209
+
210
+ async function readLocaleFile(locale: LocaleCode) {
211
+ const user = await readJsonFile<unknown>(path.join(LOCALES_ROOT, `${locale}.json`))
212
+ if (user !== undefined) return user
213
+ return await readJsonFile<unknown>(path.join(PACKAGE_LOCALES_ROOT, `${locale}.json`))
214
+ }
215
+
216
+ function buildConfig(index: I18nIndexConfig | undefined, discoveredLocales: readonly LocaleCode[], readLocale: (locale: LocaleCode) => unknown): I18nConfig | undefined {
217
+ if (!index) return undefined
218
+
219
+ const locales: Record<LocaleCode, I18nLocaleConfig> = {}
220
+ for (const locale of mergeLocaleNames(index.locales, discoveredLocales)) {
221
+ const value = readLocale(locale)
222
+ if (value === undefined) continue
223
+ locales[locale] = normalizeLocaleConfig(value, locale)
224
+ }
225
+
226
+ if (Object.keys(locales).length === 0) return undefined
227
+ const defaultLocale = index.defaultLocale && locales[index.defaultLocale] ? index.defaultLocale : undefined
228
+ return { defaultLocale, locales }
229
+ }
230
+
231
+ export function readStateSync(): I18nState {
232
+ return normalizeState(readJsonFileSync<unknown>(STATE_PATH))
233
+ }
234
+
235
+ export async function readState(): Promise<I18nState> {
236
+ return normalizeState(await readJsonFile<unknown>(STATE_PATH))
237
+ }
238
+
239
+ export function readConfigSync(): I18nConfig | undefined {
240
+ const index = normalizeIndexConfig(readJsonFileSync<unknown>(CONFIG_PATH) ?? readJsonFileSync<unknown>(PACKAGE_CONFIG_PATH))
241
+ return buildConfig(index, discoverLocalesSync(), readLocaleFileSync)
242
+ }
243
+
244
+ export async function readConfig(): Promise<I18nConfig | undefined> {
245
+ const index = normalizeIndexConfig((await readJsonFile<unknown>(CONFIG_PATH)) ?? (await readJsonFile<unknown>(PACKAGE_CONFIG_PATH)))
246
+ if (!index) return undefined
247
+
248
+ const localeNames = mergeLocaleNames(index.locales, await discoverLocales())
249
+ const entries = await Promise.all(
250
+ localeNames.map(async (locale) => [
251
+ locale,
252
+ normalizeLocaleConfig(await readLocaleFile(locale), locale),
253
+ ] as const),
254
+ )
255
+ const locales = Object.fromEntries(entries) as Record<LocaleCode, I18nLocaleConfig>
256
+
257
+ if (Object.keys(locales).length === 0) return undefined
258
+ return { defaultLocale: index.defaultLocale, locales }
259
+ }
260
+
261
+ export function localeNames(config: I18nConfig | undefined) {
262
+ return Object.keys(config?.locales ?? {})
263
+ }
264
+
265
+ export function resolveLocale(config: I18nConfig | undefined, state: Pick<I18nState, "locale"> | undefined) {
266
+ const available = localeNames(config)
267
+ const stateLocale = state?.locale
268
+
269
+ if (available.length === 0) return stateLocale
270
+ if (stateLocale && available.includes(stateLocale)) return stateLocale
271
+ if (config?.defaultLocale && available.includes(config.defaultLocale)) return config.defaultLocale
272
+
273
+ return available[0]
274
+ }
275
+
276
+ export function localeInfo(config: I18nConfig | undefined, state: I18nState): LocaleInfo {
277
+ const available = localeNames(config)
278
+ const labels = new Map<LocaleCode, string>()
279
+
280
+ for (const locale of available) {
281
+ labels.set(locale, config?.locales[locale]?.name ?? locale)
282
+ }
283
+
284
+ return {
285
+ defaultLocale: config?.defaultLocale,
286
+ activeLocale: resolveLocale(config, state),
287
+ available,
288
+ labels,
289
+ config,
290
+ }
291
+ }
292
+
293
+ export function resolveLocaleInput(locale: string, info: LocaleInfo) {
294
+ const value = locale.trim()
295
+ const normalizedLower = value.toLocaleLowerCase()
296
+
297
+ for (const available of info.available) {
298
+ if (available === value || available.toLocaleLowerCase() === normalizedLower) return available
299
+ }
300
+
301
+ for (const available of info.available) {
302
+ const label = info.labels.get(available)
303
+ if (label === value || label?.toLocaleLowerCase() === normalizedLower) return available
304
+ }
305
+
306
+ return value
307
+ }