From 498495d2fad3f393bdc9b4e37c70caa047d66b2e Mon Sep 17 00:00:00 2001 From: monkut Date: Thu, 16 Apr 2026 11:28:40 +0900 Subject: [PATCH 1/6] :sparkles: Expose Claude thinking/reasoning controls via ASKCC_CLAUDE_* env vars and CLI flags (#78) Add env var defaults in settings.py and matching CLI flags on the top-level parser so they apply to every subcommand. Precedence: CLI flag > ASKCC_CLAUDE_* env var > Claude Code default. Settings: ASKCC_CLAUDE_EFFORT_LEVEL, ASKCC_CLAUDE_MAX_THINKING_TOKENS, ASKCC_CLAUDE_DISABLE_THINKING, ASKCC_CLAUDE_DISABLE_ADAPTIVE_THINKING. CLI flags: --effort, --max-thinking-tokens, --disable-thinking, --disable-adaptive-thinking / --no-disable-adaptive-thinking. --- askcc/cli.py | 40 ++++++- askcc/runners.py | 18 ++++ askcc/settings.py | 37 +++++++ tests/test_askcc.py | 246 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 338 insertions(+), 3 deletions(-) diff --git a/askcc/cli.py b/askcc/cli.py index 7d2e145..e1dc830 100644 --- a/askcc/cli.py +++ b/askcc/cli.py @@ -6,7 +6,7 @@ from pathlib import Path from string import Template -from . import __version__ +from . import __version__, settings from .definitions import AgentAction, AgentConfig, SupportedLanguage from .functions import ( CheckResult, @@ -26,7 +26,7 @@ write_prompt_content, ) from .runners import DEFAULT_RUNNER, RUNNER_REGISTRY, get_runner -from .settings import configure_logging +from .settings import VALID_EFFORT_LEVELS, configure_logging logger = logging.getLogger(__name__) @@ -105,6 +105,31 @@ def main() -> None: # noqa: PLR0912, PLR0915, C901 default=DEFAULT_RUNNER, help=f"Runner to execute the task (default: {DEFAULT_RUNNER}).", ) + parser.add_argument( + "--effort", + choices=VALID_EFFORT_LEVELS, + default=settings.ASKCC_CLAUDE_EFFORT_LEVEL, + help="Claude thinking effort level. Env default: ASKCC_CLAUDE_EFFORT_LEVEL.", + ) + parser.add_argument( + "--max-thinking-tokens", + type=int, + default=settings.ASKCC_CLAUDE_MAX_THINKING_TOKENS, + help="Thinking token budget (default: 21000). Env default: ASKCC_CLAUDE_MAX_THINKING_TOKENS.", + ) + parser.add_argument( + "--disable-thinking", + action="store_true", + default=settings.ASKCC_CLAUDE_DISABLE_THINKING, + help="Force-disable extended thinking. Env default: ASKCC_CLAUDE_DISABLE_THINKING.", + ) + parser.add_argument( + "--disable-adaptive-thinking", + action=argparse.BooleanOptionalAction, + default=settings.ASKCC_CLAUDE_DISABLE_ADAPTIVE_THINKING, + help="Disable adaptive reasoning (Opus 4.6, Sonnet 4.6); default: true. " + "Env default: ASKCC_CLAUDE_DISABLE_ADAPTIVE_THINKING.", + ) subparsers = parser.add_subparsers(dest="command", required=True) @@ -222,7 +247,16 @@ def main() -> None: # noqa: PLR0912, PLR0915, C901 prompt += f"\nOutput all comments in {args.language}." runner = get_runner(args.runner) try: - return_code, usage = runner.run(prompt, config=config, issue_url=issue_url, cwd=cwd) + return_code, usage = runner.run( + prompt, + config=config, + issue_url=issue_url, + cwd=cwd, + effort_level=args.effort, + max_thinking_tokens=args.max_thinking_tokens, + disable_thinking=args.disable_thinking, + disable_adaptive_thinking=args.disable_adaptive_thinking, + ) finally: # Clean up /tmp files created by _build_prompt (not user templates) for f in prompt_tempfiles: diff --git a/askcc/runners.py b/askcc/runners.py index f3c875b..ece28f4 100644 --- a/askcc/runners.py +++ b/askcc/runners.py @@ -28,6 +28,10 @@ def run( *, issue_url: str, cwd: Path, + effort_level: str | None = None, + max_thinking_tokens: int | None = None, + disable_thinking: bool = False, + disable_adaptive_thinking: bool = False, ) -> tuple[int, dict | None]: """Execute a prompt and return (exit_code, usage_dict_or_none).""" @@ -42,6 +46,10 @@ def run( *, issue_url: str, cwd: Path, + effort_level: str | None = None, + max_thinking_tokens: int | None = None, + disable_thinking: bool = False, + disable_adaptive_thinking: bool = False, ) -> tuple[int, dict | None]: agent_definition = {config.action_name: {"description": config.description, "prompt": config.system_prompt}} @@ -57,10 +65,20 @@ def run( json.dumps(agent_definition), ] + if effort_level: + cmd.extend(["--effort", effort_level]) + # Remove CLAUDECODE env var so the child claude process doesn't think it's nested inside Claude Code env = os.environ.copy() env.pop("CLAUDECODE", None) + if max_thinking_tokens is not None: + env["MAX_THINKING_TOKENS"] = str(max_thinking_tokens) + if disable_thinking: + env["CLAUDE_CODE_DISABLE_THINKING"] = "1" + if disable_adaptive_thinking: + env["CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING"] = "1" + logger.info("[%s] Requesting '%s' from Claude Code ...", issue_url, config.action_name) logger.info("[%s] Working directory: %s", issue_url, cwd) logger.debug("[%s] Command: %s", issue_url, " ".join("" if arg is prompt else arg for arg in cmd)) diff --git a/askcc/settings.py b/askcc/settings.py index b38acf0..1511dfa 100644 --- a/askcc/settings.py +++ b/askcc/settings.py @@ -4,6 +4,8 @@ from logging.handlers import RotatingFileHandler from pathlib import Path +logger = logging.getLogger(__name__) + DEFAULT_LOG_LEVEL = "INFO" LOG_LEVEL = os.getenv("LOG_LEVEL", DEFAULT_LOG_LEVEL).upper() @@ -28,6 +30,41 @@ # Project field transition REVIEW_STATUS_OPTIONS: tuple[str, ...] = ("in-internal-review", "in-review") +# -- Claude thinking/reasoning controls -- +VALID_EFFORT_LEVELS: tuple[str, ...] = ("low", "medium", "high", "max") + + +def _resolve_effort_level() -> str | None: + """Resolve ASKCC_CLAUDE_EFFORT_LEVEL, warning on invalid values.""" + raw = os.getenv("ASKCC_CLAUDE_EFFORT_LEVEL") or None + if raw is None: + return None + if raw not in VALID_EFFORT_LEVELS: + logger.warning( + "Invalid ASKCC_CLAUDE_EFFORT_LEVEL=%r (valid: %s). Ignoring.", + raw, + ", ".join(VALID_EFFORT_LEVELS), + ) + return None + return raw + + +ASKCC_CLAUDE_EFFORT_LEVEL: str | None = _resolve_effort_level() + +DEFAULT_MAX_THINKING_TOKENS = 21000 # ~5% of Max5 plan daily token budget (~422K tokens/day) + +ASKCC_CLAUDE_MAX_THINKING_TOKENS: int = ( + int(os.environ["ASKCC_CLAUDE_MAX_THINKING_TOKENS"]) + if os.getenv("ASKCC_CLAUDE_MAX_THINKING_TOKENS", "").isdigit() + else DEFAULT_MAX_THINKING_TOKENS +) + +ASKCC_CLAUDE_DISABLE_THINKING: bool = os.getenv("ASKCC_CLAUDE_DISABLE_THINKING", "").lower() in ("1", "true") + +ASKCC_CLAUDE_DISABLE_ADAPTIVE_THINKING: bool = os.getenv( + "ASKCC_CLAUDE_DISABLE_ADAPTIVE_THINKING", "true" +).lower() not in ("0", "false") + 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" diff --git a/tests/test_askcc.py b/tests/test_askcc.py index f0acb61..244b75b 100644 --- a/tests/test_askcc.py +++ b/tests/test_askcc.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import os import subprocess from pathlib import Path from string import Template @@ -43,6 +44,7 @@ write_prompt_content, ) from askcc.runners import ClaudeRunner, get_runner +from askcc.settings import DEFAULT_MAX_THINKING_TOKENS, _resolve_effort_level def _mock_runner(return_code: int = 0, usage: dict | None = None) -> MagicMock: @@ -1543,3 +1545,247 @@ def test_reviewpr_no_transition_on_success(self): mock_review.assert_not_called() mock_planning.assert_not_called() + + +class TestThinkingSettings: + """Tests for ASKCC_CLAUDE_* thinking/reasoning env var settings.""" + + def test_effort_level_from_env(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("ASKCC_CLAUDE_EFFORT_LEVEL", "high") + # Re-evaluate the module-level expression + result = os.getenv("ASKCC_CLAUDE_EFFORT_LEVEL") or None + assert result == "high" + + def test_effort_level_empty_string_is_none(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("ASKCC_CLAUDE_EFFORT_LEVEL", "") + result = os.getenv("ASKCC_CLAUDE_EFFORT_LEVEL") or None + assert result is None + + def test_effort_level_unset_is_none(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.delenv("ASKCC_CLAUDE_EFFORT_LEVEL", raising=False) + result = os.getenv("ASKCC_CLAUDE_EFFORT_LEVEL") or None + assert result is None + + def test_invalid_effort_level_logged_and_ignored( + self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture + ): + monkeypatch.setenv("ASKCC_CLAUDE_EFFORT_LEVEL", "turbo") + with caplog.at_level("WARNING", logger="askcc.settings"): + result = _resolve_effort_level() + assert result is None + assert "Invalid ASKCC_CLAUDE_EFFORT_LEVEL" in caplog.text + + def test_max_thinking_tokens_from_env(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("ASKCC_CLAUDE_MAX_THINKING_TOKENS", "50000") + raw = os.getenv("ASKCC_CLAUDE_MAX_THINKING_TOKENS", "") + assert raw.isdigit() + assert int(raw) == 50000 + + def test_max_thinking_tokens_default(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.delenv("ASKCC_CLAUDE_MAX_THINKING_TOKENS", raising=False) + raw = os.getenv("ASKCC_CLAUDE_MAX_THINKING_TOKENS", "") + assert not raw.isdigit() + assert DEFAULT_MAX_THINKING_TOKENS == 21000 + + def test_disable_thinking_truthy(self, monkeypatch: pytest.MonkeyPatch): + for val in ("1", "true", "True", "TRUE"): + monkeypatch.setenv("ASKCC_CLAUDE_DISABLE_THINKING", val) + assert os.getenv("ASKCC_CLAUDE_DISABLE_THINKING", "").lower() in ("1", "true") + + def test_disable_thinking_falsy(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("ASKCC_CLAUDE_DISABLE_THINKING", "0") + assert os.getenv("ASKCC_CLAUDE_DISABLE_THINKING", "").lower() not in ("1", "true") + + def test_disable_adaptive_thinking_default_true(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.delenv("ASKCC_CLAUDE_DISABLE_ADAPTIVE_THINKING", raising=False) + result = os.getenv("ASKCC_CLAUDE_DISABLE_ADAPTIVE_THINKING", "true").lower() not in ("0", "false") + assert result is True + + def test_disable_adaptive_thinking_explicit_false(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("ASKCC_CLAUDE_DISABLE_ADAPTIVE_THINKING", "false") + result = os.getenv("ASKCC_CLAUDE_DISABLE_ADAPTIVE_THINKING", "true").lower() not in ("0", "false") + assert result is False + + +class TestThinkingCLIFlags: + """Tests for --effort, --max-thinking-tokens, --disable-thinking, --disable-adaptive-thinking CLI flags.""" + + ISSUE_URL = "https://github.com/monkut/askcc-cli/issues/1" + + def _run_main_with_args(self, extra_args: list[str]) -> MagicMock: + mock_runner = _mock_runner() + 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", *extra_args, "plan", "-g", self.ISSUE_URL]), + pytest.raises(SystemExit), + ): + main() + return mock_runner + + def test_effort_flag_passed_to_runner(self): + mock_runner = self._run_main_with_args(["--effort", "high"]) + call_kwargs = mock_runner.run.call_args + assert call_kwargs.kwargs["effort_level"] == "high" + + def test_effort_flag_invalid_rejected_by_argparse(self): + with ( + pytest.raises(SystemExit, match="2"), + patch("sys.argv", ["askcc", "--effort", "turbo", "plan", "-g", self.ISSUE_URL]), + ): + main() + + def test_max_thinking_tokens_flag_passed_to_runner(self): + mock_runner = self._run_main_with_args(["--max-thinking-tokens", "50000"]) + call_kwargs = mock_runner.run.call_args + assert call_kwargs.kwargs["max_thinking_tokens"] == 50000 + + def test_disable_thinking_flag_passed_to_runner(self): + mock_runner = self._run_main_with_args(["--disable-thinking"]) + call_kwargs = mock_runner.run.call_args + assert call_kwargs.kwargs["disable_thinking"] is True + + def test_disable_adaptive_thinking_flag_passed_to_runner(self): + mock_runner = self._run_main_with_args(["--disable-adaptive-thinking"]) + call_kwargs = mock_runner.run.call_args + assert call_kwargs.kwargs["disable_adaptive_thinking"] is True + + def test_no_disable_adaptive_thinking_flag(self): + mock_runner = self._run_main_with_args(["--no-disable-adaptive-thinking"]) + call_kwargs = mock_runner.run.call_args + assert call_kwargs.kwargs["disable_adaptive_thinking"] is False + + def test_effort_env_default_used(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr("askcc.cli.settings.ASKCC_CLAUDE_EFFORT_LEVEL", "low") + mock_runner = self._run_main_with_args([]) + call_kwargs = mock_runner.run.call_args + assert call_kwargs.kwargs["effort_level"] == "low" + + def test_cli_flag_overrides_env_default(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr("askcc.cli.settings.ASKCC_CLAUDE_EFFORT_LEVEL", "low") + mock_runner = self._run_main_with_args(["--effort", "max"]) + call_kwargs = mock_runner.run.call_args + assert call_kwargs.kwargs["effort_level"] == "max" + + +class TestClaudeRunnerThinkingOptions: + """Tests that ClaudeRunner passes thinking options to the subprocess.""" + + ISSUE_URL = "https://github.com/test/repo/issues/1" + + @pytest.fixture + def agent_config(self) -> AgentConfig: + return AgentConfig( + action_name="test-agent", + description="A test agent", + system_prompt="You are a test agent.", + user_prompt_template="$issue_content", + system_prompt_file="TEST_SYSTEM_PROMPT.md", + user_prompt_file="TEST_USER_PROMPT.md", + ) + + @pytest.fixture + def runner(self) -> ClaudeRunner: + return ClaudeRunner() + + def test_effort_level_appended_to_cmd(self, runner: ClaudeRunner, agent_config: AgentConfig): + mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="{}", stderr="") + with patch("askcc.runners.subprocess.run", return_value=mock_result) as mock_run: + runner.run( + "prompt", + config=agent_config, + issue_url=self.ISSUE_URL, + cwd=Path.cwd(), + effort_level="high", + ) + cmd = mock_run.call_args[0][0] + assert "--effort" in cmd + idx = cmd.index("--effort") + assert cmd[idx + 1] == "high" + + def test_max_thinking_tokens_in_env(self, runner: ClaudeRunner, agent_config: AgentConfig): + mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="{}", stderr="") + with patch("askcc.runners.subprocess.run", return_value=mock_result) as mock_run: + runner.run( + "prompt", + config=agent_config, + issue_url=self.ISSUE_URL, + cwd=Path.cwd(), + max_thinking_tokens=50000, + ) + env = mock_run.call_args[1]["env"] + assert env["MAX_THINKING_TOKENS"] == "50000" + + def test_disable_thinking_in_env(self, runner: ClaudeRunner, agent_config: AgentConfig): + mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="{}", stderr="") + with patch("askcc.runners.subprocess.run", return_value=mock_result) as mock_run: + runner.run( + "prompt", + config=agent_config, + issue_url=self.ISSUE_URL, + cwd=Path.cwd(), + disable_thinking=True, + ) + env = mock_run.call_args[1]["env"] + assert env["CLAUDE_CODE_DISABLE_THINKING"] == "1" + + def test_disable_adaptive_thinking_in_env(self, runner: ClaudeRunner, agent_config: AgentConfig): + mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="{}", stderr="") + with patch("askcc.runners.subprocess.run", return_value=mock_result) as mock_run: + runner.run( + "prompt", + config=agent_config, + issue_url=self.ISSUE_URL, + cwd=Path.cwd(), + disable_adaptive_thinking=True, + ) + env = mock_run.call_args[1]["env"] + assert env["CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING"] == "1" + + def test_no_thinking_env_when_defaults( + self, runner: ClaudeRunner, agent_config: AgentConfig, monkeypatch: pytest.MonkeyPatch + ): + monkeypatch.delenv("CLAUDE_CODE_DISABLE_THINKING", raising=False) + monkeypatch.delenv("CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING", raising=False) + monkeypatch.delenv("MAX_THINKING_TOKENS", raising=False) + mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="{}", stderr="") + with patch("askcc.runners.subprocess.run", return_value=mock_result) as mock_run: + runner.run( + "prompt", + config=agent_config, + issue_url=self.ISSUE_URL, + cwd=Path.cwd(), + ) + cmd = mock_run.call_args[0][0] + env = mock_run.call_args[1]["env"] + assert "--effort" not in cmd + assert "CLAUDE_CODE_DISABLE_THINKING" not in env + assert "CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING" not in env + + def test_effort_none_not_appended(self, runner: ClaudeRunner, agent_config: AgentConfig): + mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="{}", stderr="") + with patch("askcc.runners.subprocess.run", return_value=mock_result) as mock_run: + runner.run( + "prompt", + config=agent_config, + issue_url=self.ISSUE_URL, + cwd=Path.cwd(), + effort_level=None, + ) + cmd = mock_run.call_args[0][0] + assert "--effort" not in cmd + + def test_disable_thinking_false_not_set(self, runner: ClaudeRunner, agent_config: AgentConfig): + mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="{}", stderr="") + with patch("askcc.runners.subprocess.run", return_value=mock_result) as mock_run: + runner.run( + "prompt", + config=agent_config, + issue_url=self.ISSUE_URL, + cwd=Path.cwd(), + disable_thinking=False, + ) + env = mock_run.call_args[1]["env"] + assert "CLAUDE_CODE_DISABLE_THINKING" not in env From c7fc7842ea685870c59258a23072fa4bb20d02a2 Mon Sep 17 00:00:00 2001 From: monkut Date: Thu, 16 Apr 2026 11:54:58 +0900 Subject: [PATCH 2/6] :lipstick: Display actual default values in CLI help text Use f-strings to show resolved settings values in --effort, --max-thinking-tokens, --disable-thinking, and --disable-adaptive-thinking help text. --- askcc/cli.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/askcc/cli.py b/askcc/cli.py index e1dc830..dfc9c0a 100644 --- a/askcc/cli.py +++ b/askcc/cli.py @@ -109,26 +109,30 @@ def main() -> None: # noqa: PLR0912, PLR0915, C901 "--effort", choices=VALID_EFFORT_LEVELS, default=settings.ASKCC_CLAUDE_EFFORT_LEVEL, - help="Claude thinking effort level. Env default: ASKCC_CLAUDE_EFFORT_LEVEL.", + help=f"Claude thinking effort level (default: {settings.ASKCC_CLAUDE_EFFORT_LEVEL}). " + "Env: ASKCC_CLAUDE_EFFORT_LEVEL.", ) parser.add_argument( "--max-thinking-tokens", type=int, default=settings.ASKCC_CLAUDE_MAX_THINKING_TOKENS, - help="Thinking token budget (default: 21000). Env default: ASKCC_CLAUDE_MAX_THINKING_TOKENS.", + help=f"Thinking token budget (default: {settings.ASKCC_CLAUDE_MAX_THINKING_TOKENS}). " + "Env: ASKCC_CLAUDE_MAX_THINKING_TOKENS.", ) parser.add_argument( "--disable-thinking", action="store_true", default=settings.ASKCC_CLAUDE_DISABLE_THINKING, - help="Force-disable extended thinking. Env default: ASKCC_CLAUDE_DISABLE_THINKING.", + help=f"Force-disable extended thinking (default: {settings.ASKCC_CLAUDE_DISABLE_THINKING}). " + "Env: ASKCC_CLAUDE_DISABLE_THINKING.", ) parser.add_argument( "--disable-adaptive-thinking", action=argparse.BooleanOptionalAction, default=settings.ASKCC_CLAUDE_DISABLE_ADAPTIVE_THINKING, - help="Disable adaptive reasoning (Opus 4.6, Sonnet 4.6); default: true. " - "Env default: ASKCC_CLAUDE_DISABLE_ADAPTIVE_THINKING.", + help=f"Disable adaptive reasoning (Opus 4.6, Sonnet 4.6) " + f"(default: {settings.ASKCC_CLAUDE_DISABLE_ADAPTIVE_THINKING}). " + "Env: ASKCC_CLAUDE_DISABLE_ADAPTIVE_THINKING.", ) subparsers = parser.add_subparsers(dest="command", required=True) From 663650f0284ed25015fbf59ee0b91aff7c83c050 Mon Sep 17 00:00:00 2001 From: monkut Date: Thu, 16 Apr 2026 14:29:37 +0900 Subject: [PATCH 3/6] :recycle: Extract Claude env var names as constants, fix double read, bump to 0.2.4 - Define CLAUDE_ENV_* constants in settings.py for subprocess env var names - Eliminate double os.getenv read for MAX_THINKING_TOKENS - Use constants in runners.py and tests instead of string literals - Bump version to 0.2.4 --- askcc/runners.py | 8 +++++--- askcc/settings.py | 10 +++++++--- pyproject.toml | 2 +- tests/test_askcc.py | 26 ++++++++++++++++---------- uv.lock | 2 +- 5 files changed, 30 insertions(+), 18 deletions(-) diff --git a/askcc/runners.py b/askcc/runners.py index ece28f4..b6c3af9 100644 --- a/askcc/runners.py +++ b/askcc/runners.py @@ -7,6 +7,8 @@ from abc import ABC, abstractmethod from typing import TYPE_CHECKING +from .settings import CLAUDE_ENV_DISABLE_ADAPTIVE_THINKING, CLAUDE_ENV_DISABLE_THINKING, CLAUDE_ENV_MAX_THINKING_TOKENS + if TYPE_CHECKING: from pathlib import Path @@ -73,11 +75,11 @@ def run( env.pop("CLAUDECODE", None) if max_thinking_tokens is not None: - env["MAX_THINKING_TOKENS"] = str(max_thinking_tokens) + env[CLAUDE_ENV_MAX_THINKING_TOKENS] = str(max_thinking_tokens) if disable_thinking: - env["CLAUDE_CODE_DISABLE_THINKING"] = "1" + env[CLAUDE_ENV_DISABLE_THINKING] = "1" if disable_adaptive_thinking: - env["CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING"] = "1" + env[CLAUDE_ENV_DISABLE_ADAPTIVE_THINKING] = "1" logger.info("[%s] Requesting '%s' from Claude Code ...", issue_url, config.action_name) logger.info("[%s] Working directory: %s", issue_url, cwd) diff --git a/askcc/settings.py b/askcc/settings.py index 1511dfa..a6a309c 100644 --- a/askcc/settings.py +++ b/askcc/settings.py @@ -53,10 +53,9 @@ def _resolve_effort_level() -> str | None: DEFAULT_MAX_THINKING_TOKENS = 21000 # ~5% of Max5 plan daily token budget (~422K tokens/day) +_raw_max_thinking = os.getenv("ASKCC_CLAUDE_MAX_THINKING_TOKENS", "") ASKCC_CLAUDE_MAX_THINKING_TOKENS: int = ( - int(os.environ["ASKCC_CLAUDE_MAX_THINKING_TOKENS"]) - if os.getenv("ASKCC_CLAUDE_MAX_THINKING_TOKENS", "").isdigit() - else DEFAULT_MAX_THINKING_TOKENS + int(_raw_max_thinking) if _raw_max_thinking.isdigit() else DEFAULT_MAX_THINKING_TOKENS ) ASKCC_CLAUDE_DISABLE_THINKING: bool = os.getenv("ASKCC_CLAUDE_DISABLE_THINKING", "").lower() in ("1", "true") @@ -65,6 +64,11 @@ def _resolve_effort_level() -> str | None: "ASKCC_CLAUDE_DISABLE_ADAPTIVE_THINKING", "true" ).lower() not in ("0", "false") +# Claude Code subprocess env var names +CLAUDE_ENV_MAX_THINKING_TOKENS = "MAX_THINKING_TOKENS" +CLAUDE_ENV_DISABLE_THINKING = "CLAUDE_CODE_DISABLE_THINKING" +CLAUDE_ENV_DISABLE_ADAPTIVE_THINKING = "CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING" + 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" diff --git a/pyproject.toml b/pyproject.toml index e053219..3c586bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "askcc" -version = "0.2.3" +version = "0.2.4" 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 244b75b..c88a737 100644 --- a/tests/test_askcc.py +++ b/tests/test_askcc.py @@ -44,7 +44,13 @@ write_prompt_content, ) from askcc.runners import ClaudeRunner, get_runner -from askcc.settings import DEFAULT_MAX_THINKING_TOKENS, _resolve_effort_level +from askcc.settings import ( + CLAUDE_ENV_DISABLE_ADAPTIVE_THINKING, + CLAUDE_ENV_DISABLE_THINKING, + CLAUDE_ENV_MAX_THINKING_TOKENS, + DEFAULT_MAX_THINKING_TOKENS, + _resolve_effort_level, +) def _mock_runner(return_code: int = 0, usage: dict | None = None) -> MagicMock: @@ -1716,7 +1722,7 @@ def test_max_thinking_tokens_in_env(self, runner: ClaudeRunner, agent_config: Ag max_thinking_tokens=50000, ) env = mock_run.call_args[1]["env"] - assert env["MAX_THINKING_TOKENS"] == "50000" + assert env[CLAUDE_ENV_MAX_THINKING_TOKENS] == "50000" def test_disable_thinking_in_env(self, runner: ClaudeRunner, agent_config: AgentConfig): mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="{}", stderr="") @@ -1729,7 +1735,7 @@ def test_disable_thinking_in_env(self, runner: ClaudeRunner, agent_config: Agent disable_thinking=True, ) env = mock_run.call_args[1]["env"] - assert env["CLAUDE_CODE_DISABLE_THINKING"] == "1" + assert env[CLAUDE_ENV_DISABLE_THINKING] == "1" def test_disable_adaptive_thinking_in_env(self, runner: ClaudeRunner, agent_config: AgentConfig): mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="{}", stderr="") @@ -1742,14 +1748,14 @@ def test_disable_adaptive_thinking_in_env(self, runner: ClaudeRunner, agent_conf disable_adaptive_thinking=True, ) env = mock_run.call_args[1]["env"] - assert env["CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING"] == "1" + assert env[CLAUDE_ENV_DISABLE_ADAPTIVE_THINKING] == "1" def test_no_thinking_env_when_defaults( self, runner: ClaudeRunner, agent_config: AgentConfig, monkeypatch: pytest.MonkeyPatch ): - monkeypatch.delenv("CLAUDE_CODE_DISABLE_THINKING", raising=False) - monkeypatch.delenv("CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING", raising=False) - monkeypatch.delenv("MAX_THINKING_TOKENS", raising=False) + monkeypatch.delenv(CLAUDE_ENV_DISABLE_THINKING, raising=False) + monkeypatch.delenv(CLAUDE_ENV_DISABLE_ADAPTIVE_THINKING, raising=False) + monkeypatch.delenv(CLAUDE_ENV_MAX_THINKING_TOKENS, raising=False) mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="{}", stderr="") with patch("askcc.runners.subprocess.run", return_value=mock_result) as mock_run: runner.run( @@ -1761,8 +1767,8 @@ def test_no_thinking_env_when_defaults( cmd = mock_run.call_args[0][0] env = mock_run.call_args[1]["env"] assert "--effort" not in cmd - assert "CLAUDE_CODE_DISABLE_THINKING" not in env - assert "CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING" not in env + assert CLAUDE_ENV_DISABLE_THINKING not in env + assert CLAUDE_ENV_DISABLE_ADAPTIVE_THINKING not in env def test_effort_none_not_appended(self, runner: ClaudeRunner, agent_config: AgentConfig): mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="{}", stderr="") @@ -1788,4 +1794,4 @@ def test_disable_thinking_false_not_set(self, runner: ClaudeRunner, agent_config disable_thinking=False, ) env = mock_run.call_args[1]["env"] - assert "CLAUDE_CODE_DISABLE_THINKING" not in env + assert CLAUDE_ENV_DISABLE_THINKING not in env diff --git a/uv.lock b/uv.lock index f8dbb71..dc6c6f4 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = "==3.14.*" [[package]] name = "askcc" -version = "0.2.3" +version = "0.2.4" source = { editable = "." } [package.dev-dependencies] From 35cee983de35febb2c0acb4b7f77937dc715d5d0 Mon Sep 17 00:00:00 2001 From: monkut Date: Thu, 16 Apr 2026 14:37:36 +0900 Subject: [PATCH 4/6] :sparkles: Default ASKCC_CLAUDE_EFFORT_LEVEL to 'max', convert to StrEnum - Add EffortLevel StrEnum replacing VALID_EFFORT_LEVELS tuple - Default effort level to EffortLevel.MAX when unset - Use DEFAULT_EFFORT_LEVEL constant in tests instead of raw strings --- askcc/cli.py | 4 ++-- askcc/settings.py | 27 +++++++++++++++++++-------- tests/test_askcc.py | 15 ++++++++------- 3 files changed, 29 insertions(+), 17 deletions(-) diff --git a/askcc/cli.py b/askcc/cli.py index dfc9c0a..087b44e 100644 --- a/askcc/cli.py +++ b/askcc/cli.py @@ -26,7 +26,7 @@ write_prompt_content, ) from .runners import DEFAULT_RUNNER, RUNNER_REGISTRY, get_runner -from .settings import VALID_EFFORT_LEVELS, configure_logging +from .settings import EffortLevel, configure_logging logger = logging.getLogger(__name__) @@ -107,7 +107,7 @@ def main() -> None: # noqa: PLR0912, PLR0915, C901 ) parser.add_argument( "--effort", - choices=VALID_EFFORT_LEVELS, + choices=EffortLevel, default=settings.ASKCC_CLAUDE_EFFORT_LEVEL, help=f"Claude thinking effort level (default: {settings.ASKCC_CLAUDE_EFFORT_LEVEL}). " "Env: ASKCC_CLAUDE_EFFORT_LEVEL.", diff --git a/askcc/settings.py b/askcc/settings.py index a6a309c..7d0b6fb 100644 --- a/askcc/settings.py +++ b/askcc/settings.py @@ -1,3 +1,4 @@ +import enum import logging import os import sys @@ -31,25 +32,35 @@ REVIEW_STATUS_OPTIONS: tuple[str, ...] = ("in-internal-review", "in-review") # -- Claude thinking/reasoning controls -- -VALID_EFFORT_LEVELS: tuple[str, ...] = ("low", "medium", "high", "max") -def _resolve_effort_level() -> str | None: +class EffortLevel(enum.StrEnum): + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + MAX = "max" + + +DEFAULT_EFFORT_LEVEL = EffortLevel.MAX + + +def _resolve_effort_level() -> EffortLevel: """Resolve ASKCC_CLAUDE_EFFORT_LEVEL, warning on invalid values.""" raw = os.getenv("ASKCC_CLAUDE_EFFORT_LEVEL") or None if raw is None: - return None - if raw not in VALID_EFFORT_LEVELS: + return DEFAULT_EFFORT_LEVEL + try: + return EffortLevel(raw) + except ValueError: logger.warning( "Invalid ASKCC_CLAUDE_EFFORT_LEVEL=%r (valid: %s). Ignoring.", raw, - ", ".join(VALID_EFFORT_LEVELS), + ", ".join(EffortLevel), ) - return None - return raw + return DEFAULT_EFFORT_LEVEL -ASKCC_CLAUDE_EFFORT_LEVEL: str | None = _resolve_effort_level() +ASKCC_CLAUDE_EFFORT_LEVEL: EffortLevel = _resolve_effort_level() DEFAULT_MAX_THINKING_TOKENS = 21000 # ~5% of Max5 plan daily token budget (~422K tokens/day) diff --git a/tests/test_askcc.py b/tests/test_askcc.py index c88a737..2834299 100644 --- a/tests/test_askcc.py +++ b/tests/test_askcc.py @@ -48,6 +48,7 @@ CLAUDE_ENV_DISABLE_ADAPTIVE_THINKING, CLAUDE_ENV_DISABLE_THINKING, CLAUDE_ENV_MAX_THINKING_TOKENS, + DEFAULT_EFFORT_LEVEL, DEFAULT_MAX_THINKING_TOKENS, _resolve_effort_level, ) @@ -1562,15 +1563,15 @@ def test_effort_level_from_env(self, monkeypatch: pytest.MonkeyPatch): result = os.getenv("ASKCC_CLAUDE_EFFORT_LEVEL") or None assert result == "high" - def test_effort_level_empty_string_is_none(self, monkeypatch: pytest.MonkeyPatch): + def test_effort_level_empty_string_uses_default(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.setenv("ASKCC_CLAUDE_EFFORT_LEVEL", "") - result = os.getenv("ASKCC_CLAUDE_EFFORT_LEVEL") or None - assert result is None + result = _resolve_effort_level() + assert result == DEFAULT_EFFORT_LEVEL - def test_effort_level_unset_is_none(self, monkeypatch: pytest.MonkeyPatch): + def test_effort_level_unset_uses_default(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.delenv("ASKCC_CLAUDE_EFFORT_LEVEL", raising=False) - result = os.getenv("ASKCC_CLAUDE_EFFORT_LEVEL") or None - assert result is None + result = _resolve_effort_level() + assert result == DEFAULT_EFFORT_LEVEL def test_invalid_effort_level_logged_and_ignored( self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture @@ -1578,7 +1579,7 @@ def test_invalid_effort_level_logged_and_ignored( monkeypatch.setenv("ASKCC_CLAUDE_EFFORT_LEVEL", "turbo") with caplog.at_level("WARNING", logger="askcc.settings"): result = _resolve_effort_level() - assert result is None + assert result == DEFAULT_EFFORT_LEVEL assert "Invalid ASKCC_CLAUDE_EFFORT_LEVEL" in caplog.text def test_max_thinking_tokens_from_env(self, monkeypatch: pytest.MonkeyPatch): From 46f85f7259d5e2174a002a2a46ffdaa831c96b12 Mon Sep 17 00:00:00 2001 From: monkut Date: Thu, 16 Apr 2026 14:43:33 +0900 Subject: [PATCH 5/6] :recycle: Rename EffortLevel to VALID_EFFORT_LEVELS, use constants in tests Keep original VALID_EFFORT_LEVELS name as StrEnum class. Use DEFAULT_EFFORT_LEVEL constant in test assertions. --- askcc/cli.py | 4 ++-- askcc/settings.py | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/askcc/cli.py b/askcc/cli.py index 087b44e..dfc9c0a 100644 --- a/askcc/cli.py +++ b/askcc/cli.py @@ -26,7 +26,7 @@ write_prompt_content, ) from .runners import DEFAULT_RUNNER, RUNNER_REGISTRY, get_runner -from .settings import EffortLevel, configure_logging +from .settings import VALID_EFFORT_LEVELS, configure_logging logger = logging.getLogger(__name__) @@ -107,7 +107,7 @@ def main() -> None: # noqa: PLR0912, PLR0915, C901 ) parser.add_argument( "--effort", - choices=EffortLevel, + choices=VALID_EFFORT_LEVELS, default=settings.ASKCC_CLAUDE_EFFORT_LEVEL, help=f"Claude thinking effort level (default: {settings.ASKCC_CLAUDE_EFFORT_LEVEL}). " "Env: ASKCC_CLAUDE_EFFORT_LEVEL.", diff --git a/askcc/settings.py b/askcc/settings.py index 7d0b6fb..cb2ebf7 100644 --- a/askcc/settings.py +++ b/askcc/settings.py @@ -34,33 +34,33 @@ # -- Claude thinking/reasoning controls -- -class EffortLevel(enum.StrEnum): +class VALID_EFFORT_LEVELS(enum.StrEnum): # noqa: N801 LOW = "low" MEDIUM = "medium" HIGH = "high" MAX = "max" -DEFAULT_EFFORT_LEVEL = EffortLevel.MAX +DEFAULT_EFFORT_LEVEL = VALID_EFFORT_LEVELS.MAX -def _resolve_effort_level() -> EffortLevel: +def _resolve_effort_level() -> VALID_EFFORT_LEVELS: """Resolve ASKCC_CLAUDE_EFFORT_LEVEL, warning on invalid values.""" raw = os.getenv("ASKCC_CLAUDE_EFFORT_LEVEL") or None if raw is None: return DEFAULT_EFFORT_LEVEL try: - return EffortLevel(raw) + return VALID_EFFORT_LEVELS(raw) except ValueError: logger.warning( "Invalid ASKCC_CLAUDE_EFFORT_LEVEL=%r (valid: %s). Ignoring.", raw, - ", ".join(EffortLevel), + ", ".join(VALID_EFFORT_LEVELS), ) return DEFAULT_EFFORT_LEVEL -ASKCC_CLAUDE_EFFORT_LEVEL: EffortLevel = _resolve_effort_level() +ASKCC_CLAUDE_EFFORT_LEVEL: VALID_EFFORT_LEVELS = _resolve_effort_level() DEFAULT_MAX_THINKING_TOKENS = 21000 # ~5% of Max5 plan daily token budget (~422K tokens/day) From 41fe723b7850f5e3c8f73bdaf46397e2c9555089 Mon Sep 17 00:00:00 2001 From: monkut Date: Thu, 16 Apr 2026 14:48:00 +0900 Subject: [PATCH 6/6] :memo: Add comment explaining VALID_EFFORT_LEVELS placement --- askcc/settings.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/askcc/settings.py b/askcc/settings.py index cb2ebf7..2629f7c 100644 --- a/askcc/settings.py +++ b/askcc/settings.py @@ -32,6 +32,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. class VALID_EFFORT_LEVELS(enum.StrEnum): # noqa: N801