diff --git a/README.md b/README.md index 5a4d673..29a8da1 100644 --- a/README.md +++ b/README.md @@ -97,9 +97,28 @@ Supporting commands can be used at any point: |-------------|--------------------------------------------|---------| | `LOG_LEVEL` | Logging verbosity (`DEBUG`, `INFO`, `WARNING`, etc.) | `INFO` | | `ASKCC_HOME` | Root directory for askcc configuration and templates | `~/.askcc` | +| `ASKCC_LANGUAGE` | Default output language for agent comments (`english`, `japanese`) | `english` | | `DECISION_ISSUE_LABEL` | GitHub label applied when an agent flags a decision is needed | `needs:decision` | | `ENABLE_ISSUE_LABEL_PREFIX_VALIDATION` | Enable/disable issue label prefix validation before agent execution | `true` | +### User Configuration File + +askcc reads optional defaults from `~/.askcc/config.toml` (resolved relative to `ASKCC_HOME`): + +```toml +[defaults] +language = "japanese" +``` + +The output language for agent comments resolves in this order (highest wins): + +1. CLI flag (`--language`) +2. Environment variable (`ASKCC_LANGUAGE`) +3. User config file (`~/.askcc/config.toml` `[defaults] language`) +4. Built-in default (`english`) + +A missing config file is silently ignored. A malformed file or invalid value logs a warning and falls back to the next layer — the CLI never crashes on bad config. + ### Customizing Prompts On first run, askcc creates `~/.askcc/templates/` with default template files: diff --git a/askcc/cli.py b/askcc/cli.py index b82392a..32b1ef9 100644 --- a/askcc/cli.py +++ b/askcc/cli.py @@ -8,7 +8,7 @@ from string import Template from . import __version__, settings -from .definitions import AgentAction, AgentConfig, SupportedLanguage +from .definitions import AgentAction, AgentConfig from .functions import ( CheckResult, _parse_issue_url, @@ -27,7 +27,7 @@ write_prompt_content, ) from .runners import DEFAULT_RUNNER, RUNNER_REGISTRY, get_runner -from .settings import VALID_EFFORT_LEVELS, configure_logging +from .settings import VALID_EFFORT_LEVELS, SupportedLanguage, configure_logging logger = logging.getLogger(__name__) @@ -123,8 +123,11 @@ def main() -> None: # noqa: PLR0912, PLR0915, C901 "-l", "--language", choices=[lang.value for lang in SupportedLanguage], - default=SupportedLanguage.ENGLISH, - help="Language for agent output comments (default: english).", + default=settings.DEFAULT_LANGUAGE, + help=f"Language for agent output comments. " + f"Precedence: CLI > env (ASKCC_LANGUAGE) > user config " + f"(~/.askcc/config.toml [defaults].language) > built-in default " + f"({SupportedLanguage.ENGLISH}).", ) parser.add_argument( "-r", diff --git a/askcc/definitions.py b/askcc/definitions.py index ff52f50..1827882 100644 --- a/askcc/definitions.py +++ b/askcc/definitions.py @@ -630,11 +630,6 @@ class AgentConfig: ) -class SupportedLanguage(StrEnum): - ENGLISH = "english" - JAPANESE = "japanese" - - class AgentAction(StrEnum): PREPARE = "prepare" PLAN = "plan" diff --git a/askcc/settings.py b/askcc/settings.py index 3a4ded9..1be06d6 100644 --- a/askcc/settings.py +++ b/askcc/settings.py @@ -2,6 +2,7 @@ import logging import os import sys +import tomllib from logging.handlers import RotatingFileHandler from pathlib import Path @@ -32,8 +33,8 @@ REVIEW_STATUS_OPTIONS: tuple[str, ...] = ("in-internal-review", "in-review") # -- Claude thinking/reasoning controls -- -# NOTE: VALID_EFFORT_LEVELS lives here (not definitions.py) to avoid a circular import; -# definitions.py already imports from settings.py. +# NOTE: VALID_EFFORT_LEVELS and SupportedLanguage live here (not definitions.py) to avoid +# a circular import; definitions.py already imports from settings.py. class VALID_EFFORT_LEVELS(enum.StrEnum): # noqa: N801 @@ -44,6 +45,11 @@ class VALID_EFFORT_LEVELS(enum.StrEnum): # noqa: N801 MAX = "max" +class SupportedLanguage(enum.StrEnum): + ENGLISH = "english" + JAPANESE = "japanese" + + DEFAULT_EFFORT_LEVEL = VALID_EFFORT_LEVELS.XHIGH @@ -86,6 +92,65 @@ def _resolve_effort_level() -> VALID_EFFORT_LEVELS: ASKCC_HOME: Path = Path(os.getenv("ASKCC_HOME") or str(Path.home() / ".askcc")).expanduser().resolve() TEMPLATES_DIR: Path = ASKCC_HOME / "templates" LOG_DIR: Path = ASKCC_HOME / "logs" +USER_CONFIG_PATH: Path = ASKCC_HOME / "config.toml" + + +def _load_user_config(path: Path | None = None) -> dict: + """Load the user's TOML config file. Missing file → silent {}; malformed → warned {}. + + The optional `path` argument is for tests — it lets the loader target a tmp_path + without re-importing the module. Production callers should rely on ``_USER_CONFIG``. + """ + target = path if path is not None else USER_CONFIG_PATH + if not target.is_file(): + return {} + try: + with target.open("rb") as f: + return tomllib.load(f) + except (tomllib.TOMLDecodeError, OSError): + # Path is logged; exception detail is intentionally omitted to avoid echoing + # config contents into logs. + logger.warning("Failed to read user config %s. Ignoring.", target) + return {} + + +_USER_CONFIG: dict = _load_user_config() + + +def _resolve_default_language(user_config: dict | None = None) -> SupportedLanguage: + """Resolve the default language: env var > user config > built-in default. + + The CLI flag layer is handled by argparse (its `default=` is set to the value + returned here); this function only covers the env-var and config-file layers. + """ + raw_env = os.getenv("ASKCC_LANGUAGE") or None + if raw_env is not None: + try: + return SupportedLanguage(raw_env) + except ValueError: + logger.warning( + "Invalid ASKCC_LANGUAGE=%r (valid: %s). Ignoring.", + raw_env, + ", ".join(SupportedLanguage), + ) + + config = user_config if user_config is not None else _USER_CONFIG + config_value = config.get("defaults", {}).get("language") + if config_value is not None: + try: + return SupportedLanguage(config_value) + except ValueError: + logger.warning( + "Invalid [defaults].language=%r in %s (valid: %s). Ignoring.", + config_value, + USER_CONFIG_PATH, + ", ".join(SupportedLanguage), + ) + + return SupportedLanguage.ENGLISH + + +DEFAULT_LANGUAGE: SupportedLanguage = _resolve_default_language() LOG_FORMAT = "%(asctime)s [%(levelname)s] (%(name)s) %(funcName)s: %(message)s" LOG_MAX_BYTES = 5 * 1024 * 1024 # 5 MB diff --git a/pyproject.toml b/pyproject.toml index c7bde45..e273180 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "askcc" -version = "0.2.8" +version = "0.2.9" description = "A one-shot cc cli executor" authors = [{ name = "mknt", email = "shane.cousins@gmail.com" }] readme = "README.md" diff --git a/tests/test_askcc.py b/tests/test_askcc.py index 2d6e2e5..c128b60 100644 --- a/tests/test_askcc.py +++ b/tests/test_askcc.py @@ -16,7 +16,6 @@ REVIEWPR_AGENT_PROMPT, AgentAction, AgentConfig, - SupportedLanguage, ) from askcc.functions import ( CheckResult, @@ -47,12 +46,17 @@ ) from askcc.runners import ClaudeRunner, get_runner from askcc.settings import ( + ASKCC_HOME, CLAUDE_ENV_DISABLE_ADAPTIVE_THINKING, CLAUDE_ENV_DISABLE_THINKING, CLAUDE_ENV_MAX_THINKING_TOKENS, DEFAULT_EFFORT_LEVEL, DEFAULT_MAX_THINKING_TOKENS, + USER_CONFIG_PATH, VALID_EFFORT_LEVELS, + SupportedLanguage, + _load_user_config, + _resolve_default_language, _resolve_effort_level, ) @@ -1673,6 +1677,91 @@ def test_disable_adaptive_thinking_explicit_false(self, monkeypatch: pytest.Monk assert result is False +class TestDefaultLanguageResolution: + """Tests for ASKCC_LANGUAGE env var and ~/.askcc/config.toml default-language resolution.""" + + def test_default_language_from_env_var(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("ASKCC_LANGUAGE", "japanese") + assert _resolve_default_language(user_config={}) == SupportedLanguage.JAPANESE + + def test_default_language_from_config_file(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path): + monkeypatch.delenv("ASKCC_LANGUAGE", raising=False) + config_path = tmp_path / "config.toml" + config_path.write_text('[defaults]\nlanguage = "japanese"\n') + loaded = _load_user_config(config_path) + assert _resolve_default_language(user_config=loaded) == SupportedLanguage.JAPANESE + + def test_env_var_overrides_config_file(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("ASKCC_LANGUAGE", "japanese") + assert ( + _resolve_default_language(user_config={"defaults": {"language": "english"}}) == SupportedLanguage.JAPANESE + ) + + def test_config_file_overrides_builtin_default(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.delenv("ASKCC_LANGUAGE", raising=False) + assert ( + _resolve_default_language(user_config={"defaults": {"language": "japanese"}}) == SupportedLanguage.JAPANESE + ) + + def test_default_language_unset_returns_english(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.delenv("ASKCC_LANGUAGE", raising=False) + assert _resolve_default_language(user_config={}) == SupportedLanguage.ENGLISH + + def test_invalid_env_value_warns_and_falls_back( + self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture + ): + monkeypatch.setenv("ASKCC_LANGUAGE", "klingon") + with caplog.at_level("WARNING", logger="askcc.settings"): + result = _resolve_default_language(user_config={}) + assert result == SupportedLanguage.ENGLISH + assert "Invalid ASKCC_LANGUAGE" in caplog.text + + def test_invalid_config_value_warns_and_falls_back( + self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture + ): + monkeypatch.delenv("ASKCC_LANGUAGE", raising=False) + with caplog.at_level("WARNING", logger="askcc.settings"): + result = _resolve_default_language(user_config={"defaults": {"language": "klingon"}}) + assert result == SupportedLanguage.ENGLISH + assert "Invalid [defaults].language" in caplog.text + + def test_missing_config_file_silent(self, tmp_path: Path, caplog: pytest.LogCaptureFixture): + with caplog.at_level("WARNING", logger="askcc.settings"): + result = _load_user_config(tmp_path / "missing.toml") + assert result == {} + assert caplog.text == "" + + def test_malformed_toml_warns(self, tmp_path: Path, caplog: pytest.LogCaptureFixture): + config_path = tmp_path / "config.toml" + config_path.write_text("not = valid = toml\n[broken") + with caplog.at_level("WARNING", logger="askcc.settings"): + result = _load_user_config(config_path) + assert result == {} + assert "Failed to read user config" in caplog.text + + def test_user_config_path_uses_askcc_home(self): + # USER_CONFIG_PATH must resolve relative to ASKCC_HOME (so ASKCC_HOME overrides apply). + assert USER_CONFIG_PATH == ASKCC_HOME / "config.toml" + + def test_cli_flag_overrides_env(self, monkeypatch: pytest.MonkeyPatch): + # CLI flag precedence is enforced by argparse — passing --language overrides + # whatever DEFAULT_LANGUAGE was resolved to at module import. + monkeypatch.setenv("ASKCC_LANGUAGE", "japanese") + mock_runner = _mock_runner() + issue_url = "https://github.com/monkut/askcc-cli/issues/1" + with ( + patch("askcc.cli.bootstrap_templates"), + patch("askcc.cli.validate_issue_labels", return_value=[]), + patch("askcc.cli.fetch_github_issue", return_value="issue body"), + patch("askcc.cli.get_runner", return_value=mock_runner), + patch("sys.argv", ["askcc", "--language", "english", AgentAction.PLAN, "-g", issue_url]), + pytest.raises(SystemExit), + ): + main() + prompt = mock_runner.run.call_args[0][0] + assert "Output all comments in" not in prompt + + class TestThinkingCLIFlags: """Tests for --effort, --max-thinking-tokens, --disable-thinking, --disable-adaptive-thinking CLI flags.""" diff --git a/uv.lock b/uv.lock index dc9f9d5..c9780e4 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = "==3.14.*" [[package]] name = "askcc" -version = "0.2.8" +version = "0.2.9" source = { editable = "." } [package.dev-dependencies]