diff --git a/CHANGELOG.md b/CHANGELOG.md index 056ad246f..3d98adb38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable user-facing changes to ByteRover CLI will be documented in this file. +## [Unreleased] + +### Added +- **ByteRover preserves your input language by default.** When you curate context in Russian, Chinese, Japanese, Vietnamese, or any other language, the calling agent's LLM is now instructed to author body text in the same language (the schema — tag names, attribute names, enum values, paths — stays English so tooling is unaffected). Configure with the new `brv config set` command: + - `brv config set language.mode auto` — match the user's input language (default). + - `brv config set language.mode fixed` + `brv config set language.code ` — force a specific language. ISO 639-1 codes accepted: `ar`, `de`, `el`, `en`, `es`, `fi`, `fr`, `he`, `hi`, `id`, `it`, `ja`, `ko`, `nl`, `no`, `pl`, `pt`, `ru`, `sv`, `th`, `tr`, `uk`, `vi`, `zh`. + - `brv config get language.mode` / `brv config get language.code` — read back the current setting. + + CJK queries (Chinese, Japanese, Korean) are now searchable in BM25 — the tokenizer was previously whitespace-only and treated entire CJK sentences as one token. **Restoration recipe** for users who prefer the prior implicit-English behavior: `brv config set language.code en` then `brv config set language.mode fixed`. Reported by Dmitriy K — thanks for the thorough reproduction in [#616](https://github.com/campfirein/byterover-cli/issues/616). + ## [3.16.0] ### Added diff --git a/src/oclif/commands/config/get.ts b/src/oclif/commands/config/get.ts new file mode 100644 index 000000000..d83240410 --- /dev/null +++ b/src/oclif/commands/config/get.ts @@ -0,0 +1,95 @@ +import {Args, Command, Flags} from '@oclif/core' + +import type {BrvConfig} from '../../../server/core/domain/entities/brv-config.js' + +import {ProjectConfigStore} from '../../../server/infra/config/file-config-store.js' +import {resolveProjectRoot} from '../../lib/curate-session.js' +import {writeJsonResponse} from '../../lib/json-response.js' + +/** + * `brv config get ` — read one field from `.brv/config.json`. + * + * Returns the stored value, or "(not set)" when the field is absent. Keyed + * by the same string map as `config set` so symmetry is preserved. + */ +export default class ConfigGet extends Command { + public static args = { + key: Args.string({description: 'Project config key (e.g. language.mode, language.code)', required: true}), + } + public static description = 'Read a project configuration value from .brv/config.json' + public static examples = [ + '<%= config.bin %> <%= command.id %> language.mode', + '<%= config.bin %> <%= command.id %> language.code', + '<%= config.bin %> <%= command.id %> language.mode --format json', + ] + public static flags = { + format: Flags.string({ + default: 'text', + description: 'Output format (text or json)', + options: ['text', 'json'], + }), + } + + public async run(): Promise { + const {args, flags} = await this.parse(ConfigGet) + const format = flags.format as 'json' | 'text' + + const projectRoot = resolveProjectRoot() + const config = await new ProjectConfigStore().read(projectRoot) + + if (config === undefined) { + this.fail(format, 'no-config', `No .brv/config.json found at ${projectRoot}.`) + return + } + + const result = applyConfigGet(config, args.key) + if (result.kind === 'error') { + this.fail(format, result.code, result.message) + return + } + + if (format === 'json') { + writeJsonResponse({command: 'config get', data: {key: args.key, value: result.value}, success: true}) + } else { + this.log(result.value ?? '(not set)') + } + } + + private fail(format: 'json' | 'text', code: string, message: string): void { + process.exitCode = 1 + if (format === 'json') { + writeJsonResponse({command: 'config get', data: {error: {code, message}}, success: false}) + } else { + this.log(message) + } + } +} + +export type ConfigGetResult = + | {readonly code: string; readonly kind: 'error'; readonly message: string} + | {readonly kind: 'ok'; readonly value: string | undefined} + +type ConfigGetter = (config: BrvConfig) => string | undefined + +const GETTERS: Record = { + 'language.code': (config) => config.language?.code, + 'language.mode': (config) => config.language?.mode, +} + +/** + * Pure dispatcher mirroring `applyConfigSet` so the CLI and unit tests + * share one read-side path. + */ +export function applyConfigGet(config: BrvConfig, key: string): ConfigGetResult { + const getter = GETTERS[key] + if (getter === undefined) { + const supported = Object.keys(GETTERS).sort().join(', ') + return { + code: 'unknown-key', + kind: 'error', + message: `Unknown config key '${key}'. Supported keys: ${supported}.`, + } + } + + return {kind: 'ok', value: getter(config)} +} diff --git a/src/oclif/commands/config/set.ts b/src/oclif/commands/config/set.ts new file mode 100644 index 000000000..26e3d9c04 --- /dev/null +++ b/src/oclif/commands/config/set.ts @@ -0,0 +1,169 @@ +import {Args, Command, Flags} from '@oclif/core' + +import type {BrvConfig, BrvConfigLanguage} from '../../../server/core/domain/entities/brv-config.js' + +import {LANGUAGE_NAMES} from '../../../server/core/domain/render/language-clause.js' +import {ProjectConfigStore} from '../../../server/infra/config/file-config-store.js' +import {resolveProjectRoot} from '../../lib/curate-session.js' +import {writeJsonResponse} from '../../lib/json-response.js' + +/** + * `brv config set ` — mutate one field in `.brv/config.json`. + * + * Today only the language-selection keys are handled (`language.mode` and + * `language.code`); the dispatcher is keyed by string so adding the next + * project-config key is a one-line addition to `SETTERS`. + * + * Daemon-side runtime settings (`agentPool.maxSize`, `llm.iterationBudgetMs`, + * etc.) live behind `brv settings set` instead — those are mutable at + * runtime via transport events. Project config is a flat-file mutation; + * there is no daemon involvement. + */ +export default class ConfigSet extends Command { + public static args = { + key: Args.string({description: 'Project config key (e.g. language.mode, language.code)', required: true}), + value: Args.string({description: 'New value', required: true}), + } + public static description = 'Set a project configuration value in .brv/config.json' + public static examples = [ + '# Force the calling agent\'s LLM to author in Russian on every curate', + '<%= config.bin %> <%= command.id %> language.code ru', + '<%= config.bin %> <%= command.id %> language.mode fixed', + '', + '# Restore auto-detect (the default — match the user\'s input language)', + '<%= config.bin %> <%= command.id %> language.mode auto', + '', + '# Read in JSON for scripting', + '<%= config.bin %> <%= command.id %> language.code ja --format json', + ] + public static flags = { + format: Flags.string({ + default: 'text', + description: 'Output format (text or json)', + options: ['text', 'json'], + }), + } + + public async run(): Promise { + const {args, flags} = await this.parse(ConfigSet) + const format = flags.format as 'json' | 'text' + + const projectRoot = resolveProjectRoot() + const store = new ProjectConfigStore() + const current = await store.read(projectRoot) + + if (current === undefined) { + this.fail( + format, + 'no-config', + `No .brv/config.json found at ${projectRoot}. Run \`brv init\` (or any \`brv\` command in this project) to create one.`, + ) + return + } + + const result = applyConfigSet(current, args.key, args.value) + if (result.kind === 'error') { + this.fail(format, result.code, result.message) + return + } + + await store.write(result.config, projectRoot) + this.success(format, args.key, args.value) + } + + private fail(format: 'json' | 'text', code: string, message: string): void { + process.exitCode = 1 + if (format === 'json') { + writeJsonResponse({command: 'config set', data: {error: {code, message}}, success: false}) + } else { + this.log(message) + } + } + + private success(format: 'json' | 'text', key: string, value: string): void { + if (format === 'json') { + writeJsonResponse({command: 'config set', data: {key, value}, success: true}) + } else { + this.log(`Setting saved: ${key} = ${value}.`) + } + } +} + +export type ConfigSetResult = + | {readonly code: string; readonly kind: 'error'; readonly message: string} + | {readonly config: BrvConfig; readonly kind: 'ok'} + +type ConfigSetter = (config: BrvConfig, value: string) => ConfigSetResult + +const SETTERS: Record = { + 'language.code': setLanguageCode, + 'language.mode': setLanguageMode, +} + +/** + * Dispatch a ` ` set onto a loaded BrvConfig. Pure function so + * the CLI command and the unit tests share one validation path — no + * filesystem or oclif coupling here. + */ +export function applyConfigSet(config: BrvConfig, key: string, value: string): ConfigSetResult { + const setter = SETTERS[key] + if (setter === undefined) { + const supported = Object.keys(SETTERS).sort().join(', ') + return { + code: 'unknown-key', + kind: 'error', + message: `Unknown config key '${key}'. Supported keys: ${supported}.`, + } + } + + return setter(config, value) +} + +function setLanguageMode(config: BrvConfig, value: string): ConfigSetResult { + if (value !== 'auto' && value !== 'fixed') { + return { + code: 'invalid-value', + kind: 'error', + message: `language.mode must be 'auto' or 'fixed', got '${value}'.`, + } + } + + // Reject `fixed` without a code so the on-disk config can never reach an + // invalid intermediate state (`{mode: 'fixed'}` would be rejected by + // `isBrvConfigJson` on next load). Point the user at the unblocking step. + if (value === 'fixed' && config.language?.code === undefined) { + return { + code: 'missing-language-code', + kind: 'error', + message: + 'language.mode \'fixed\' requires language.code to be set first. Run: brv config set language.code ', + } + } + + const next: BrvConfigLanguage = + value === 'fixed' + ? {code: config.language!.code!, mode: 'fixed'} + : config.language?.code === undefined + ? {mode: 'auto'} + : {code: config.language.code, mode: 'auto'} + + return {config: config.withLanguage(next), kind: 'ok'} +} + +function setLanguageCode(config: BrvConfig, code: string): ConfigSetResult { + if (!(code in LANGUAGE_NAMES)) { + const supported = Object.keys(LANGUAGE_NAMES).sort().join(', ') + return { + code: 'unknown-iso-code', + kind: 'error', + message: `Unknown ISO 639-1 code '${code}'. Supported codes: ${supported}.`, + } + } + + // Preserve mode if already set; default to auto when language is being + // initialized for the first time. The combination `{mode: 'auto', code}` + // is intentional — code is vestigial in auto mode but harmless, and + // makes the eventual `set language.mode fixed` a no-roundtrip activation. + const mode = config.language?.mode ?? 'auto' + return {config: config.withLanguage({code, mode}), kind: 'ok'} +} diff --git a/src/server/core/domain/entities/brv-config.ts b/src/server/core/domain/entities/brv-config.ts index 091885228..5ccd35a46 100644 --- a/src/server/core/domain/entities/brv-config.ts +++ b/src/server/core/domain/entities/brv-config.ts @@ -263,6 +263,32 @@ export class BrvConfig { } } + /** + * Creates a new BrvConfig with the language preference replaced + * (or cleared via `undefined`), preserving all other fields. + * + * Used by `brv config set language.*` to mutate the per-project + * language preference without re-instantiating fields by hand. + */ + public withLanguage(language?: BrvConfigLanguage): BrvConfig { + return new BrvConfig({ + chatLogPath: this.chatLogPath, + cipherAgentContext: this.cipherAgentContext, + cipherAgentModes: this.cipherAgentModes, + cipherAgentSystemPrompt: this.cipherAgentSystemPrompt, + createdAt: this.createdAt, + cwd: this.cwd, + ide: this.ide, + language, + reviewDisabled: this.reviewDisabled, + spaceId: this.spaceId, + spaceName: this.spaceName, + teamId: this.teamId, + teamName: this.teamName, + version: this.version, + }) + } + /** * Creates a new BrvConfig with space fields cleared, preserving all other fields. */ diff --git a/test/unit/core/domain/entities/brv-config.test.ts b/test/unit/core/domain/entities/brv-config.test.ts index 32cde94f6..67bbfe5dc 100644 --- a/test/unit/core/domain/entities/brv-config.test.ts +++ b/test/unit/core/domain/entities/brv-config.test.ts @@ -402,4 +402,50 @@ describe('BrvConfig', () => { expect(original.withVersion('9.9.9').language).to.deep.equal(fixedRu) }) }) + + describe('withLanguage', () => { + it('replaces an existing language preference', () => { + const original = new BrvConfig({...validConstructorArgs, language: {code: 'ru', mode: 'fixed'}}) + const updated = original.withLanguage({code: 'zh', mode: 'fixed'}) + + expect(updated.language).to.deep.equal({code: 'zh', mode: 'fixed'}) + }) + + it('sets language when previously unset', () => { + const original = new BrvConfig(validConstructorArgs) + const updated = original.withLanguage({mode: 'auto'}) + + expect(updated.language).to.deep.equal({mode: 'auto'}) + }) + + it('clears language when called with undefined', () => { + const original = new BrvConfig({...validConstructorArgs, language: {code: 'ru', mode: 'fixed'}}) + const updated = original.withLanguage() + + expect(updated.language).to.be.undefined + }) + + it('does not mutate the original config', () => { + const original = new BrvConfig({...validConstructorArgs, language: {mode: 'auto'}}) + original.withLanguage({code: 'ru', mode: 'fixed'}) + + expect(original.language).to.deep.equal({mode: 'auto'}) + }) + + it('preserves all other fields', () => { + const original = new BrvConfig({ + ...validConstructorArgs, + cipherAgentContext: 'context-payload', + reviewDisabled: true, + }) + const updated = original.withLanguage({code: 'ja', mode: 'fixed'}) + + expect(updated.spaceId).to.equal(original.spaceId) + expect(updated.teamId).to.equal(original.teamId) + expect(updated.cipherAgentContext).to.equal('context-payload') + expect(updated.reviewDisabled).to.be.true + expect(updated.createdAt).to.equal(original.createdAt) + expect(updated.version).to.equal(original.version) + }) + }) }) diff --git a/test/unit/oclif/commands/config/get.test.ts b/test/unit/oclif/commands/config/get.test.ts new file mode 100644 index 000000000..1e0c39e86 --- /dev/null +++ b/test/unit/oclif/commands/config/get.test.ts @@ -0,0 +1,61 @@ +/** + * Tests for the pure-function dispatcher inside `brv config get`. Mirrors + * the set-side test pattern. + */ + +import {expect} from 'chai' + +import {applyConfigGet} from '../../../../../src/oclif/commands/config/get.js' +import {BrvConfig} from '../../../../../src/server/core/domain/entities/brv-config.js' + +const validParams = { + createdAt: '2026-05-26T00:00:00.000Z', + cwd: '/tmp/project', + version: '0.0.1', +} + +describe('config get — applyConfigGet', () => { + it("returns undefined when 'language.mode' is unset", () => { + const config = new BrvConfig(validParams) + const result = applyConfigGet(config, 'language.mode') + expect(result.kind).to.equal('ok') + if (result.kind === 'ok') { + expect(result.value).to.be.undefined + } + }) + + it("returns 'auto' when language.mode = auto", () => { + const config = new BrvConfig({...validParams, language: {mode: 'auto'}}) + const result = applyConfigGet(config, 'language.mode') + expect(result.kind).to.equal('ok') + if (result.kind === 'ok') { + expect(result.value).to.equal('auto') + } + }) + + it("returns 'fixed' and the code when language is fully configured", () => { + const config = new BrvConfig({...validParams, language: {code: 'ru', mode: 'fixed'}}) + expect((applyConfigGet(config, 'language.mode') as {value: string}).value).to.equal('fixed') + expect((applyConfigGet(config, 'language.code') as {value: string}).value).to.equal('ru') + }) + + it("returns undefined for 'language.code' when language has only mode", () => { + const config = new BrvConfig({...validParams, language: {mode: 'auto'}}) + const result = applyConfigGet(config, 'language.code') + expect(result.kind).to.equal('ok') + if (result.kind === 'ok') { + expect(result.value).to.be.undefined + } + }) + + it('rejects an unsupported key with a sorted supported-list', () => { + const config = new BrvConfig(validParams) + const result = applyConfigGet(config, 'unsupported.key') + expect(result.kind).to.equal('error') + if (result.kind === 'error') { + expect(result.code).to.equal('unknown-key') + expect(result.message).to.include('language.code') + expect(result.message).to.include('language.mode') + } + }) +}) diff --git a/test/unit/oclif/commands/config/set.test.ts b/test/unit/oclif/commands/config/set.test.ts new file mode 100644 index 000000000..a24dbd24b --- /dev/null +++ b/test/unit/oclif/commands/config/set.test.ts @@ -0,0 +1,158 @@ +/** + * Tests for the pure-function dispatcher inside `brv config set`. The oclif + * wrapper handles arg parsing + filesystem I/O; this suite asserts the + * validation + transformation contract that backs every call. + */ + +import {expect} from 'chai' + +import {applyConfigSet} from '../../../../../src/oclif/commands/config/set.js' +import {BrvConfig} from '../../../../../src/server/core/domain/entities/brv-config.js' + +const validParams = { + createdAt: '2026-05-26T00:00:00.000Z', + cwd: '/tmp/project', + version: '0.0.1', +} + +describe('config set — applyConfigSet', () => { + describe('language.mode', () => { + it("accepts 'auto' and clears the code-defaulted shape", () => { + const config = new BrvConfig(validParams) + const result = applyConfigSet(config, 'language.mode', 'auto') + expect(result.kind).to.equal('ok') + if (result.kind === 'ok') { + expect(result.config.language).to.deep.equal({mode: 'auto'}) + } + }) + + it("accepts 'auto' and preserves an existing code", () => { + // Switching from fixed back to auto keeps the code on disk (it's + // vestigial in auto mode but harmless, and makes a future switch + // back to fixed a one-command re-activation). + const config = new BrvConfig({...validParams, language: {code: 'ru', mode: 'fixed'}}) + const result = applyConfigSet(config, 'language.mode', 'auto') + expect(result.kind).to.equal('ok') + if (result.kind === 'ok') { + expect(result.config.language).to.deep.equal({code: 'ru', mode: 'auto'}) + } + }) + + it("accepts 'fixed' when code is already set", () => { + const config = new BrvConfig({...validParams, language: {code: 'ru', mode: 'auto'}}) + const result = applyConfigSet(config, 'language.mode', 'fixed') + expect(result.kind).to.equal('ok') + if (result.kind === 'ok') { + expect(result.config.language).to.deep.equal({code: 'ru', mode: 'fixed'}) + } + }) + + it("rejects 'fixed' when no code is set, with a redirect message", () => { + // The on-disk config `{language: {mode: 'fixed'}}` would be rejected + // by `isBrvConfigJson` on next load. Reject here so we never write it. + const config = new BrvConfig(validParams) + const result = applyConfigSet(config, 'language.mode', 'fixed') + expect(result.kind).to.equal('error') + if (result.kind === 'error') { + expect(result.code).to.equal('missing-language-code') + expect(result.message).to.include('brv config set language.code') + } + }) + + it("rejects unknown mode values", () => { + const config = new BrvConfig(validParams) + const result = applyConfigSet(config, 'language.mode', 'always-english') + expect(result.kind).to.equal('error') + if (result.kind === 'error') { + expect(result.code).to.equal('invalid-value') + expect(result.message).to.include("must be 'auto' or 'fixed'") + } + }) + }) + + describe('language.code', () => { + it('accepts a known ISO code; defaults mode to auto when language was unset', () => { + const config = new BrvConfig(validParams) + const result = applyConfigSet(config, 'language.code', 'ru') + expect(result.kind).to.equal('ok') + if (result.kind === 'ok') { + expect(result.config.language).to.deep.equal({code: 'ru', mode: 'auto'}) + } + }) + + it('preserves an existing fixed mode when updating code', () => { + // Switching the active fixed language is a one-line operation: + // `brv config set language.code zh`. Mode stays fixed. + const config = new BrvConfig({...validParams, language: {code: 'ru', mode: 'fixed'}}) + const result = applyConfigSet(config, 'language.code', 'zh') + expect(result.kind).to.equal('ok') + if (result.kind === 'ok') { + expect(result.config.language).to.deep.equal({code: 'zh', mode: 'fixed'}) + } + }) + + it('rejects unknown ISO codes with a sorted supported-list message', () => { + const config = new BrvConfig(validParams) + const result = applyConfigSet(config, 'language.code', 'xx') + expect(result.kind).to.equal('error') + if (result.kind === 'error') { + expect(result.code).to.equal('unknown-iso-code') + expect(result.message).to.include("'xx'") + expect(result.message).to.include('Supported codes:') + // Sanity: a few representative codes appear in the suggestion list. + expect(result.message).to.include('en') + expect(result.message).to.include('ru') + expect(result.message).to.include('zh') + } + }) + + it('accepts English so the restoration recipe works', () => { + // The release-notes recipe instructs users to set `code: en` for + // forced-English mode. The CLI must accept it. + const config = new BrvConfig(validParams) + const result = applyConfigSet(config, 'language.code', 'en') + expect(result.kind).to.equal('ok') + if (result.kind === 'ok') { + expect(result.config.language).to.deep.equal({code: 'en', mode: 'auto'}) + } + }) + }) + + describe('unknown key', () => { + it('rejects an unsupported key with a sorted supported-list message', () => { + const config = new BrvConfig(validParams) + const result = applyConfigSet(config, 'language.unknown', 'whatever') + expect(result.kind).to.equal('error') + if (result.kind === 'error') { + expect(result.code).to.equal('unknown-key') + expect(result.message).to.include('language.code') + expect(result.message).to.include('language.mode') + } + }) + + it('rejects a totally unrelated key', () => { + const config = new BrvConfig(validParams) + const result = applyConfigSet(config, 'cipherAgent.context', 'whatever') + expect(result.kind).to.equal('error') + if (result.kind === 'error') { + expect(result.code).to.equal('unknown-key') + } + }) + }) + + describe('restoration recipe — forced English', () => { + it('two-step set produces {mode: fixed, code: en}', () => { + // Mirrors what release notes recommend for users who want the old + // implicit-English behavior. Set code first, then flip mode. + const initial = new BrvConfig(validParams) + const afterCode = applyConfigSet(initial, 'language.code', 'en') + expect(afterCode.kind).to.equal('ok') + if (afterCode.kind !== 'ok') return + const afterMode = applyConfigSet(afterCode.config, 'language.mode', 'fixed') + expect(afterMode.kind).to.equal('ok') + if (afterMode.kind === 'ok') { + expect(afterMode.config.language).to.deep.equal({code: 'en', mode: 'fixed'}) + } + }) + }) +})