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
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
11 changes: 7 additions & 4 deletions askcc/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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__)

Expand Down Expand Up @@ -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",
Expand Down
5 changes: 0 additions & 5 deletions askcc/definitions.py
Original file line number Diff line number Diff line change
Expand Up @@ -630,11 +630,6 @@ class AgentConfig:
)


class SupportedLanguage(StrEnum):
ENGLISH = "english"
JAPANESE = "japanese"


class AgentAction(StrEnum):
PREPARE = "prepare"
PLAN = "plan"
Expand Down
69 changes: 67 additions & 2 deletions askcc/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import logging
import os
import sys
import tomllib
from logging.handlers import RotatingFileHandler
from pathlib import Path

Expand Down Expand Up @@ -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
Expand All @@ -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


Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
91 changes: 90 additions & 1 deletion tests/test_askcc.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
REVIEWPR_AGENT_PROMPT,
AgentAction,
AgentConfig,
SupportedLanguage,
)
from askcc.functions import (
CheckResult,
Expand Down Expand Up @@ -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,
)

Expand Down Expand Up @@ -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."""

Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading