Skip to content
Open
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
85 changes: 85 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,91 @@ The way to land there on Windows is **Git Bash**: MSYS2/mintty pipes stdin throu

**The `Boolean` in `useCanType` is load-bearing.** Node leaves `isTTY` **undefined** on a pipe rather than setting it false, while `useInput` skips raw mode only on `options.isActive === false` — a strict comparison. Forwarding the raw `undefined` reads as "active" and throws the very error the gate exists to prevent. No unit test can catch it, since `ink-testing-library`'s fake stdin sets a real boolean; it was found by running the built CLI with `< /dev/null`, which is the only way to reproduce it. `tests/lib/tty.test.ts` pins `toBe(false)` rather than falsiness for that reason. **Do the same for any new prompt: gate on `useCanType()`, and smoke-test it with piped stdin, not only under vitest.**

## Localization

`src/lib/i18n.ts` plus `src/lib/locales/{en,vi}.ts` render the CLI in English or
Vietnamese. `en.ts` is the source of truth; every other catalog is typed
`Record<MessageKey, string>`, so **a key added to `en.ts` and not to `vi.ts` is a
`pnpm typecheck` failure** — that type annotation *is* the completeness
guarantee, and it is the reason the catalogs are `.ts` modules rather than JSON.
They are statically imported, so esbuild inlines them and `build.ts` needs no
change (nothing outside `dist/` ships).

**Resolution is lazy and memoized on first `t()` call — deliberately not an
`initLocale()` the dispatcher calls.** ESM imports evaluate before `index.tsx`'s
body runs, so any module-level string would read the locale before an explicit
init could set it; the Node-version gate at the top of `index.tsx` fires before
argv is even destructured and still calls `t()` safely. Every input is an
environment variable, fixed before the process starts, so there is no ordering
hazard at all. This is also why **there is no `--lang` flag**: argv would put
that hazard back, and a stray `--lang` would have to be stripped before
`parsePullArgs` (which errors on unknown flags) and before the passthrough
`default:` case forwards argv verbatim to the `codev` agent.

Precedence: `CODEV_LANG` → `LC_ALL` → `LC_MESSAGES` → `LANG` → `Intl` (the
Windows path, where `LANG` is normally unset) → `en`. Each spelling is
normalized **independently** — a plain `??` chain over raw values would let an
exported-but-empty `LC_ALL` mask a good `LANG`, the same trap `lib/proxy.ts`
documents for `HTTP_PROXY`/`http_proxy`. Unshipped values fall *through* to the
next source rather than pinning themselves. `resetLocaleCache()` is the test
hook, alongside `resetLogging()` / `resetModelLimitsCache()`.

**Scope is UI only, and the boundary is load-bearing rather than laziness.**
Translated: `src/components/*`, the `src/*App.tsx` roots, `help.ts`, and
`index.tsx`'s console output. Still English: all `src/lib/` diagnostic prose
(`doctor.ts`, `tty.ts`, `proxy.ts`, `remove.ts`, `shims.ts`, `upload.ts`,
`restore.ts`, `logs.ts`), the markdown export prose in `tool-render.ts` /
`markdown.ts` / `providers/*` (archival structure — keep exports diffable), and
every log message. The reason to leave lib alone is concrete:
`upload.ts#isRefreshableError` matches on `msg.includes("Missing supabase_")`
and `/failed \((\d{3})\)/` — **English message text** produced by `const.ts` and
the `backend.ts` / `auth.ts` throwers. Translating lib prose silently breaks the
upload retry. Replace that coupling with typed error codes *before* any
lib-prose round.

Rules for new strings:

- **Never translate** brand names (`CoDev Code`, `Claude Code`, `VS Code`),
command/flag names, env var names, provider and model ids, URLs, status-union
literals, server-side role/status values (`ADMIN`, `DRAFT`, `PUBLIC`), the
`[Y/n]` / `(y/N)` letters (they are matched against typed input), or the
control-flow `new Error("aborted")` sentinels the Ink apps throw to force a
non-zero exit.
- **No sentence assembly from grammatical fragments.** `TaskList` used to take
`verb: {infinitive, present, past}` and substitute into English word order;
nothing outside English conjugates that way, so it is now a `TaskVerb` id
selecting complete per-state messages. Same for `toolSelectTitle`, which is
one full sentence per mode rather than a verb dropped into a shared frame.
- **List joins go through `formatList` / `formatListParts`** (`Intl.ListFormat`),
never hand-written `", and "`. `formatListParts` exists so `Confirm` can style
the items and the separators differently. `text.ts#formatToolList` is now a
thin delegate kept for the non-UI callers.
- **Plurals** are explicit `<key>_one` / `<key>_other` pairs read through
`tCount`. Vietnamese does not inflect, so both halves match there; that is
intentional, not a copy-paste slip. No plural-rules engine until a locale with
a real `few`/`many` category arrives.
- **No module-level resolved strings.** A `const` label is correct at runtime
(the locale never changes mid-process) but freezes before `resetLocaleCache()`
can reach it in a test. Hold message *keys* at module level and call `t()`
during render — see `ToolSelect`, `AdminLogin`, `ManualCredentials`,
`DoctorApp`'s `GROUP_TITLE_KEYS`, `SkillPushApp`'s `STEP_LABEL_KEYS`.
- **Width gutters derive from the active locale.** `CheckList`'s diagnosis
labels and both credential forms size their column from `t(...)` rather than a
hard-coded constant, and render it as an Ink `<Box width>` rather than
`String.padEnd` — Yoga measures display width, `padEnd` counts UTF-16 code
units. `.length` is accurate while the shipped locales are Latin-script
(Vietnamese is precomposed NFC and single-width); a CJK locale would need a
real `stringWidth()`, which Ink already carries transitively.
- **Don't shadow `t`.** `tools.map((t) => …)` in a file that imports `t` compiles
fine and is a live trap for the next edit; use `tool`, `task`, `target`.

`vitest.config.ts` pins `env: { CODEV_LANG: "en" }`. Hundreds of assertions
match English literals, and without the pin a developer whose machine is
`LANG=vi_VN` gets a red suite for no reason. Tests that exercise another locale
stub `CODEV_LANG` themselves and call `resetLocaleCache()`.
`tests/lib/locales.test.ts` covers what types cannot see: blank values,
mismatched `{placeholder}` sets between locales, and half-declared plurals.

## Diagnostic logging

`~/.codev-hub` has two log homes — don't mix them up:
Expand Down
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,32 @@ npm uninstall -g codev-ai

Then restart your terminal.

## Language

The CLI speaks **English** and **Vietnamese**. It picks the language from your
operating system automatically, so on a Vietnamese machine there is nothing to
configure.

To choose explicitly, set `CODEV_LANG`:

```bash
CODEV_LANG=vi codevhub install # one command
export CODEV_LANG=vi # every command in this shell
```

Accepted values are `vi` and `en`, in any of the usual spellings (`vi`,
`vi-VN`, `vi_VN.UTF-8`). Anything unrecognized falls back to English rather than
erroring. Without `CODEV_LANG`, the standard `LC_ALL` / `LC_MESSAGES` / `LANG`
variables are consulted in that order, then the OS locale.

Two things stay in English on purpose:

- **Diagnostic prose** — `codevhub doctor`'s findings, network error
explanations and remediation steps. These are the messages most often pasted
into a ticket or a search box, and keeping one wording makes them matchable.
- **Names you type or that identify things** — command and flag names, agent and
model names, config keys, and the diagnostic log.

## Diagnostic logs

Every codevhub command appends a structured diagnostic log to `~/.codev-hub/logs/codev-YYYYMMDD.ndjson` — one [Elastic Common Schema](https://www.elastic.co/guide/en/ecs/current/index.html) JSON document per line. If a command misbehaves, this file shows what actually happened: each network request with its status and duration, every child process with its exit code and stderr tail, step-by-step flow progress, and any crash with a stack trace.
Expand Down
47 changes: 23 additions & 24 deletions src/DoctorApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
startCommandRecording,
writeDoctorReport,
} from "@/lib/doctor.js";
import { type MessageKey, t } from "@/lib/i18n.js";
import { logDebug, type RequestRecord } from "@/lib/log.js";
import type { CommandRecord } from "@/lib/npm.js";
import { readProxyEnv } from "@/lib/proxy.js";
Expand All @@ -50,13 +51,15 @@ type Phase =
| "state"
| "done";

const GROUP_TITLES: Record<CheckGroup, string> = {
environment: "Environment",
network: "Network",
account: "Account & credentials",
llm: "LLM access",
state: "This machine",
};
// Message keys, not resolved titles — a Record of strings here would freeze
// the English text at import time.
const GROUP_TITLE_KEYS = {
environment: "doctor.group.environment",
network: "doctor.group.network",
account: "doctor.group.account",
llm: "doctor.group.llm",
state: "doctor.group.state",
} as const satisfies Record<CheckGroup, MessageKey>;

const GROUP_CHECKS: Record<CheckGroup, Check[]> = {
environment: ENVIRONMENT_CHECKS,
Expand Down Expand Up @@ -346,8 +349,8 @@ export function DoctorApp({ force = false }: DoctorAppProps) {
<Banner />
<Frame tag="CoDev">
{phase === "preparing" && (
<Step active title={<Text bold>Signing out previous session</Text>}>
<Text dimColor>Revoking tokens...</Text>
<Step active title={<Text bold>{t("login.signing_out")}</Text>}>
<Text dimColor>{t("login.revoking")}</Text>
</Step>
)}

Expand All @@ -357,7 +360,7 @@ export function DoctorApp({ force = false }: DoctorAppProps) {
<Step
key={group}
active={phase === group}
title={<Text bold>{GROUP_TITLES[group]}</Text>}
title={<Text bold>{t(GROUP_TITLE_KEYS[group])}</Text>}
>
<CheckList {...groupProps(group)} />
</Step>
Expand Down Expand Up @@ -395,7 +398,7 @@ export function DoctorApp({ force = false }: DoctorAppProps) {
<Step
key={group}
active={phase === group}
title={<Text bold>{GROUP_TITLES[group]}</Text>}
title={<Text bold>{t(GROUP_TITLE_KEYS[group])}</Text>}
>
<CheckList {...groupProps(group)} />
</Step>
Expand All @@ -409,13 +412,13 @@ export function DoctorApp({ force = false }: DoctorAppProps) {
the numbered next steps and the report path. A list of every
command and endpoint printed after those would scroll them away. */}
{phase === "done" && (
<Step title={<Text bold>Activity</Text>}>
<Step title={<Text bold>{t("doctor.step.activity")}</Text>}>
<ActivityLog commands={commands} requests={requests} />
</Step>
)}

{phase === "done" && (
<Step title={<Text bold>Result</Text>}>
<Step title={<Text bold>{t("doctor.step.result")}</Text>}>
<Summary
outcomes={allOutcomes}
nextSteps={nextSteps}
Expand Down Expand Up @@ -453,28 +456,24 @@ function Summary({
return (
<Box flexDirection="column">
{failed === 0 && warned === 0 && (
<Text color="green">
{"✓ Everything checks out. You're ready to run `codevhub install`."}
</Text>
<Text color="green">{t("doctor.summary.ok")}</Text>
)}
{failed === 0 && warned > 0 && (
<Text color="yellow">
{`▲ ${warned} warning(s). \`codevhub install\` should work, but read the notes below first.`}
</Text>
<Text color="yellow">{t("doctor.summary.warned", { warned })}</Text>
)}
{failed > 0 && (
// Name the warnings too: the Next steps list below numbers failures
// and warnings together, so a bare "5 failed" above 7 numbered
// items reads as a contradiction.
<Text color="red">
{`✗ ${failed} check(s) failed${
warned > 0 ? `, ${warned} warning(s)` : ""
}. Fix these before running \`codevhub install\`.`}
{warned > 0
? t("doctor.summary.failed_with_warnings", { failed, warned })
: t("doctor.summary.failed", { failed })}
</Text>
)}
{nextSteps.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text bold>{"Next steps"}</Text>
<Text bold>{t("doctor.next_steps")}</Text>
{nextSteps.map((line, i) => (
<Text key={`next-${i.toString()}`} dimColor={line.startsWith(" ")}>
{line}
Expand All @@ -490,7 +489,7 @@ function Summary({
{reportPath && (
<Box marginTop={1}>
<Text dimColor>
{`Full report saved to ${abbreviateHome(reportPath)} — attach it to a support ticket.`}
{t("doctor.report_saved", { path: abbreviateHome(reportPath) })}
</Text>
</Box>
)}
Expand Down
16 changes: 11 additions & 5 deletions src/LoginApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
refreshCodevConfig,
saveSkillhubCookie,
} from "@/lib/auth.js";
import { t } from "@/lib/i18n.js";
import { type SkillhubUser, skillhubSignIn } from "@/lib/skillhub.js";
import { describeNetworkError } from "@/lib/tls.js";

Expand Down Expand Up @@ -111,17 +112,22 @@ function AdminLoginApp({
let content: ReactNode;
if (user) {
content = (
<Text color="green">{`✓ Logged in as ${user.username} (${user.role})`}</Text>
<Text color="green">
{t("login.admin.logged_in_as", {
username: user.username,
role: user.role,
})}
</Text>
);
} else if (error) {
content = <Text color="red">{`Login failed: ${error}`}</Text>;
content = <Text color="red">{t("login.failed", { reason: error })}</Text>;
} else if (nonInteractive) {
content = (
<Box>
<Text color="cyan">
<Spinner />
</Text>
<Text>{" Signing in..."}</Text>
<Text>{` ${t("admin_login.signing_in")}`}</Text>
</Box>
);
} else {
Expand Down Expand Up @@ -172,8 +178,8 @@ function SsoLoginApp({ force = false }: { force?: boolean }) {
<Banner />
<Frame tag="CoDev">
{phase === "preparing" && (
<Step active title={<Text bold>Signing out previous session</Text>}>
<Text dimColor>Revoking tokens...</Text>
<Step active title={<Text bold>{t("login.signing_out")}</Text>}>
<Text dimColor>{t("login.revoking")}</Text>
</Step>
)}
{phase !== "preparing" && (
Expand Down
Loading
Loading