Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
},
"metadata": {
"description": "Skills for OrcaRouter products.",
"version": "1.3.2"
"version": "1.4.0"
},
"plugins": [
{
Expand Down
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "orca-code-review",
"description": "Set up, reconfigure, troubleshoot, and remove OrcaCode Review — AI pull-request review powered by OrcaRouter — in any GitHub repository.",
"version": "1.3.2",
"version": "1.4.0",
"author": {
"name": "Continuum-AI-Corp",
"url": "https://github.com/Continuum-AI-Corp"
Expand Down
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,14 +90,18 @@ An existing identical skill is left unchanged; an existing **different** one is

### Language

The CLI speaks **English and Simplified Chinese**, picked from your locale (`LC_ALL` / `LC_MESSAGES` / `LANG`). Override it per run, or pin it for good:
The CLI speaks **English, Simplified Chinese, Japanese and Korean**, picked from your locale (`LC_ALL` / `LC_MESSAGES` / `LANG`). Override it per run, or pin it for good:

```bash
npx @orcarouter/code-review --lang zh # 中文界面
npx @orcarouter/code-review --lang en # English
export ORCACODE_LANG=zh # 固定下来
npx @orcarouter/code-review --lang zh # 简体中文
npx @orcarouter/code-review --lang ja # 日本語
npx @orcarouter/code-review --lang ko # 한국어
export ORCACODE_LANG=ja # pin it
```

Traditional Chinese locales (`zh-TW`, `zh-HK`) fall back to English on purpose — the vocabulary diverges enough that serving Simplified reads worse than not translating at all.

Guided flows open with a language screen when `--lang` is not given. Add `--no-banner` to skip the wordmark.

Menus are arrow-key driven — `↑↓` to move, `Enter` to pick. Multi-select adds `space` to toggle, `a`/`n` for all/none, and `/` to filter (`ctrl-u` clears it), which is how you find one agent among 36 without scrolling. Terminals without raw mode fall back to typing a number.
Expand Down
14 changes: 10 additions & 4 deletions RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,10 +177,16 @@ the README platform list, and the orcadub README.

#### Adding a user-facing string

`bin/i18n.mjs` holds both languages. `scripts/i18n.test.mjs` fails the build if
the two tables diverge — a missing Chinese key, a key only Chinese has, or a
parameterized string whose two versions take different argument counts (which
would silently drop a branch name or a count from the Chinese output).
`bin/i18n.mjs` holds every language. `scripts/i18n.test.mjs` checks each
translation against English and fails the build on a missing key, a
translation-only key, or a parameterized string whose versions take different
argument counts (which would silently drop a branch name or a count).

Adding a language is three edits: append the code to `LANGUAGES`, add its
locale prefixes to `LOCALE_PREFIX`, add the table. The language picker is
generated from `LANGUAGES`, and a `lang.<code>` label is required in *every*
table so each language can name the others — a test enforces that. Nothing else
needs touching.

Translate prose only. Flags, platform IDs, workflow inputs, severity codes,
paths, and shell commands stay verbatim in both languages: a reader of the
Expand Down
487 changes: 478 additions & 9 deletions bin/i18n.mjs

Large diffs are not rendered by default.

16 changes: 7 additions & 9 deletions bin/orcacode-review.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import { spawnSync } from "node:child_process";

import { SKILL_PLATFORMS, POPULAR_PLATFORM_IDS, findPlatform, detectPlatforms, resolveTargets } from "./platforms.mjs";
import { installTree, STATUS } from "./skill-tree.mjs";
import { makeT, detectLanguage, parseLanguage } from "./i18n.mjs";
import { LANGUAGES, makeT, detectLanguage, parseLanguage } from "./i18n.mjs";
import { renderBanner } from "./banner.mjs";
import * as tui from "./prompt.mjs";

Expand Down Expand Up @@ -224,14 +224,12 @@ async function multiSelectTyped(question, options, { preselected = [] } = {}) {
// language has been chosen.
async function askLanguage(argv) {
if (argv.lang || ASSUME_YES || !process.stdin.isTTY) return;
const picked = await select(
t("lang.question"),
[
{ label: t("lang.en"), value: "en" },
{ label: t("lang.zh"), value: "zh" },
],
{ defaultIndex: LANG === "zh" ? 1 : 0 },
);
// Built from LANGUAGES so adding a table is the only step — a hardcoded list
// here would silently ship a language nobody can select.
const options = LANGUAGES.map((code) => ({ label: t(`lang.${code}`), value: code }));
const picked = await select(t("lang.question"), options, {
defaultIndex: Math.max(0, LANGUAGES.indexOf(LANG)),
});
setLanguage(picked);
}

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@orcarouter/code-review",
"version": "1.3.2",
"version": "1.4.0",
"description": "One-command installer for OrcaCode Review — AI pull-request review powered by OrcaRouter.",
"bin": {
"orcacode-review": "bin/orcacode-review.mjs"
Expand Down
106 changes: 70 additions & 36 deletions scripts/i18n.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,37 +26,44 @@ const lookup = (table, key) => key.split(".").reduce((n, p) => n?.[p], table);

// ------------------------------------------------------------------- table ---

test("Chinese covers every English key", () => {
// A missing key falls back to English, which reads as a half-translated tool.
const missing = flatten(TABLES.en).filter((k) => lookup(TABLES.zh, k) === undefined);
assert.deepEqual(missing, [], `untranslated keys: ${missing.join(", ")}`);
});

test("Chinese adds no keys English lacks", () => {
// A zh-only key is dead weight: nothing reads it, and it hides the fact that
// the English side was never written.
const extra = flatten(TABLES.zh).filter((k) => lookup(TABLES.en, k) === undefined);
assert.deepEqual(extra, [], `zh-only keys: ${extra.join(", ")}`);
});

test("a key is a function in both languages or neither", () => {
// A parameterized English string paired with a plain Chinese one silently
// drops the argument — the branch name or count just vanishes.
const mismatched = flatten(TABLES.en).filter(
(k) => typeof lookup(TABLES.en, k) !== typeof lookup(TABLES.zh, k),
);
assert.deepEqual(mismatched, [], `arity mismatch: ${mismatched.join(", ")}`);
});

test("parameterized strings take the same argument count in both languages", () => {
for (const key of flatten(TABLES.en)) {
const en = lookup(TABLES.en, key);
if (typeof en !== "function") continue;
assert.equal(lookup(TABLES.zh, key).length, en.length, `${key}: differing arity`);
}
});
// Every non-English table is checked against English. The earlier version only
// checked Chinese, so adding Japanese and Korean would have been able to ship
// half-translated with a green suite.
const TRANSLATIONS = LANGUAGES.filter((l) => l !== "en");

for (const lang of TRANSLATIONS) {
test(`${lang} covers every English key`, () => {
// A missing key falls back to English, which reads as a half-translated tool.
const missing = flatten(TABLES.en).filter((k) => lookup(TABLES[lang], k) === undefined);
assert.deepEqual(missing, [], `untranslated ${lang} keys: ${missing.join(", ")}`);
});

test(`${lang} adds no keys English lacks`, () => {
// A language-only key is dead weight, and it hides that the English side
// was never written.
const extra = flatten(TABLES[lang]).filter((k) => lookup(TABLES.en, k) === undefined);
assert.deepEqual(extra, [], `${lang}-only keys: ${extra.join(", ")}`);
});

test(`${lang} keys are functions exactly where English keys are`, () => {
// A parameterized English string paired with a plain translation silently
// drops the argument — the branch name or count just vanishes.
const mismatched = flatten(TABLES.en).filter(
(k) => typeof lookup(TABLES.en, k) !== typeof lookup(TABLES[lang], k),
);
assert.deepEqual(mismatched, [], `${lang} arity mismatch: ${mismatched.join(", ")}`);
});

test(`${lang} takes the same argument count as English`, () => {
for (const key of flatten(TABLES.en)) {
const en = lookup(TABLES.en, key);
if (typeof en !== "function") continue;
assert.equal(lookup(TABLES[lang], key).length, en.length, `${lang}.${key}: differing arity`);
}
});
}

test("every string is non-empty in both languages", () => {
test("every string is non-empty in every language", () => {
for (const lang of LANGUAGES) {
for (const key of flatten(TABLES[lang])) {
const value = lookup(TABLES[lang], key);
Expand All @@ -68,12 +75,22 @@ test("every string is non-empty in both languages", () => {
}
});

test("every language can name every language", () => {
// The language screen is generated from LANGUAGES, so a missing label would
// render an empty row the user cannot identify.
for (const lang of LANGUAGES) {
for (const other of LANGUAGES) {
assert.ok(lookup(TABLES[lang], `lang.${other}`), `${lang} cannot name ${other}`);
}
}
});

test("commands and flags survive translation", () => {
// A reader of the Chinese output still has to type these. Translating or
// A reader of any translation still has to type these. Translating or
// dropping one produces an instruction that cannot be followed.
//
// The invariant is derived from English rather than hardcoded: a literal the
// English table never mentions proves nothing about the Chinese one.
// English table never mentions proves nothing about the others.
const render = (lang) =>
JSON.stringify(
flatten(TABLES[lang]).map((k) => {
Expand All @@ -82,7 +99,6 @@ test("commands and flags survive translation", () => {
}),
);
const en = render("en");
const zh = render("zh");

const candidates = [
"--force", "--platform", "--scope", "--yes", "--help", "--lang",
Expand All @@ -92,7 +108,13 @@ test("commands and flags survive translation", () => {
];
const present = candidates.filter((literal) => en.includes(literal));
assert.ok(present.length >= 10, "the English table stopped mentioning the literals this test guards");
for (const literal of present) assert.ok(zh.includes(literal), `zh table lost the literal: ${literal}`);

for (const lang of TRANSLATIONS) {
const text = render(lang);
for (const literal of present) {
assert.ok(text.includes(literal), `${lang} lost the literal: ${literal}`);
}
}
});

// ----------------------------------------------------------------- selection ---
Expand All @@ -116,13 +138,25 @@ test("parseLanguage accepts zh/en in any casing and rejects the rest", () => {
assert.throws(() => parseLanguage("fr"), /unknown language/);
});

test("locale detection maps the Chinese locales and defaults to English", () => {
test("locale detection maps each supported language and defaults to English", () => {
assert.equal(detectLanguage({ LANG: "zh_CN.UTF-8" }), "zh");
assert.equal(detectLanguage({ LC_ALL: "zh_TW.UTF-8" }), "en"); // Traditional is not translated
assert.equal(detectLanguage({ LANG: "ja_JP.UTF-8" }), "ja");
assert.equal(detectLanguage({ LANG: "ko_KR.UTF-8" }), "ko");
assert.equal(detectLanguage({ LANG: "en_US.UTF-8" }), "en");
assert.equal(detectLanguage({}), "en");
});

test("Traditional Chinese falls back to English rather than Simplified", () => {
// zh-TW/zh-HK diverge enough in vocabulary that serving Simplified reads
// worse than serving English. Deliberate, so pin it.
assert.equal(detectLanguage({ LC_ALL: "zh_TW.UTF-8" }), "en");
assert.equal(detectLanguage({ LC_ALL: "zh_HK.UTF-8" }), "en");
});

test("--lang accepts every language the picker offers", () => {
for (const lang of LANGUAGES) assert.equal(parseLanguage(lang), lang);
});

test("LC_ALL outranks LANG, and ORCACODE_LANG outranks both", () => {
assert.equal(detectLanguage({ LC_ALL: "en_US.UTF-8", LANG: "zh_CN.UTF-8" }), "en");
assert.equal(detectLanguage({ ORCACODE_LANG: "zh", LANG: "en_US.UTF-8" }), "zh");
Expand Down
Loading