gac 3.10.11__tar.gz → 3.12.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. {gac-3.10.11 → gac-3.12.0}/PKG-INFO +1 -2
  2. {gac-3.10.11 → gac-3.12.0}/pyproject.toml +1 -2
  3. {gac-3.10.11 → gac-3.12.0}/src/gac/__version__.py +1 -1
  4. {gac-3.10.11 → gac-3.12.0}/src/gac/ai_utils.py +5 -39
  5. {gac-3.10.11 → gac-3.12.0}/src/gac/cli.py +2 -0
  6. {gac-3.10.11 → gac-3.12.0}/src/gac/config.py +0 -2
  7. {gac-3.10.11 → gac-3.12.0}/src/gac/constants/defaults.py +0 -2
  8. {gac-3.10.11 → gac-3.12.0}/src/gac/grouped_commit_workflow.py +18 -2
  9. {gac-3.10.11 → gac-3.12.0}/src/gac/model_cli.py +10 -10
  10. gac-3.12.0/src/gac/prompt_cli.py +266 -0
  11. {gac-3.10.11 → gac-3.12.0}/src/gac/providers/base.py +8 -0
  12. {gac-3.10.11 → gac-3.12.0}/src/gac/providers/claude_code.py +1 -1
  13. {gac-3.10.11 → gac-3.12.0}/.gitignore +0 -0
  14. {gac-3.10.11 → gac-3.12.0}/LICENSE +0 -0
  15. {gac-3.10.11 → gac-3.12.0}/README.md +0 -0
  16. {gac-3.10.11 → gac-3.12.0}/src/gac/__init__.py +0 -0
  17. {gac-3.10.11 → gac-3.12.0}/src/gac/ai.py +0 -0
  18. {gac-3.10.11 → gac-3.12.0}/src/gac/auth_cli.py +0 -0
  19. {gac-3.10.11 → gac-3.12.0}/src/gac/commit_executor.py +0 -0
  20. {gac-3.10.11 → gac-3.12.0}/src/gac/config_cli.py +0 -0
  21. {gac-3.10.11 → gac-3.12.0}/src/gac/constants/__init__.py +0 -0
  22. {gac-3.10.11 → gac-3.12.0}/src/gac/constants/commit.py +0 -0
  23. {gac-3.10.11 → gac-3.12.0}/src/gac/constants/file_patterns.py +0 -0
  24. {gac-3.10.11 → gac-3.12.0}/src/gac/constants/languages.py +0 -0
  25. {gac-3.10.11 → gac-3.12.0}/src/gac/diff_cli.py +0 -0
  26. {gac-3.10.11 → gac-3.12.0}/src/gac/errors.py +0 -0
  27. {gac-3.10.11 → gac-3.12.0}/src/gac/git.py +0 -0
  28. {gac-3.10.11 → gac-3.12.0}/src/gac/git_state_validator.py +0 -0
  29. {gac-3.10.11 → gac-3.12.0}/src/gac/init_cli.py +0 -0
  30. {gac-3.10.11 → gac-3.12.0}/src/gac/interactive_mode.py +0 -0
  31. {gac-3.10.11 → gac-3.12.0}/src/gac/language_cli.py +0 -0
  32. {gac-3.10.11 → gac-3.12.0}/src/gac/main.py +0 -0
  33. {gac-3.10.11 → gac-3.12.0}/src/gac/model_identifier.py +0 -0
  34. {gac-3.10.11 → gac-3.12.0}/src/gac/oauth/__init__.py +0 -0
  35. {gac-3.10.11 → gac-3.12.0}/src/gac/oauth/claude_code.py +0 -0
  36. {gac-3.10.11 → gac-3.12.0}/src/gac/oauth/qwen_oauth.py +0 -0
  37. {gac-3.10.11 → gac-3.12.0}/src/gac/oauth/token_store.py +0 -0
  38. {gac-3.10.11 → gac-3.12.0}/src/gac/oauth_retry.py +0 -0
  39. {gac-3.10.11 → gac-3.12.0}/src/gac/postprocess.py +0 -0
  40. {gac-3.10.11 → gac-3.12.0}/src/gac/preprocess.py +0 -0
  41. {gac-3.10.11 → gac-3.12.0}/src/gac/prompt.py +0 -0
  42. {gac-3.10.11 → gac-3.12.0}/src/gac/prompt_builder.py +0 -0
  43. {gac-3.10.11 → gac-3.12.0}/src/gac/providers/README.md +0 -0
  44. {gac-3.10.11 → gac-3.12.0}/src/gac/providers/__init__.py +0 -0
  45. {gac-3.10.11 → gac-3.12.0}/src/gac/providers/anthropic.py +0 -0
  46. {gac-3.10.11 → gac-3.12.0}/src/gac/providers/azure_openai.py +0 -0
  47. {gac-3.10.11 → gac-3.12.0}/src/gac/providers/cerebras.py +0 -0
  48. {gac-3.10.11 → gac-3.12.0}/src/gac/providers/chutes.py +0 -0
  49. {gac-3.10.11 → gac-3.12.0}/src/gac/providers/custom_anthropic.py +0 -0
  50. {gac-3.10.11 → gac-3.12.0}/src/gac/providers/custom_openai.py +0 -0
  51. {gac-3.10.11 → gac-3.12.0}/src/gac/providers/deepseek.py +0 -0
  52. {gac-3.10.11 → gac-3.12.0}/src/gac/providers/error_handler.py +0 -0
  53. {gac-3.10.11 → gac-3.12.0}/src/gac/providers/fireworks.py +0 -0
  54. {gac-3.10.11 → gac-3.12.0}/src/gac/providers/gemini.py +0 -0
  55. {gac-3.10.11 → gac-3.12.0}/src/gac/providers/groq.py +0 -0
  56. {gac-3.10.11 → gac-3.12.0}/src/gac/providers/kimi_coding.py +0 -0
  57. {gac-3.10.11 → gac-3.12.0}/src/gac/providers/lmstudio.py +0 -0
  58. {gac-3.10.11 → gac-3.12.0}/src/gac/providers/minimax.py +0 -0
  59. {gac-3.10.11 → gac-3.12.0}/src/gac/providers/mistral.py +0 -0
  60. {gac-3.10.11 → gac-3.12.0}/src/gac/providers/moonshot.py +0 -0
  61. {gac-3.10.11 → gac-3.12.0}/src/gac/providers/ollama.py +0 -0
  62. {gac-3.10.11 → gac-3.12.0}/src/gac/providers/openai.py +0 -0
  63. {gac-3.10.11 → gac-3.12.0}/src/gac/providers/openrouter.py +0 -0
  64. {gac-3.10.11 → gac-3.12.0}/src/gac/providers/protocol.py +0 -0
  65. {gac-3.10.11 → gac-3.12.0}/src/gac/providers/qwen.py +0 -0
  66. {gac-3.10.11 → gac-3.12.0}/src/gac/providers/registry.py +0 -0
  67. {gac-3.10.11 → gac-3.12.0}/src/gac/providers/replicate.py +0 -0
  68. {gac-3.10.11 → gac-3.12.0}/src/gac/providers/streamlake.py +0 -0
  69. {gac-3.10.11 → gac-3.12.0}/src/gac/providers/synthetic.py +0 -0
  70. {gac-3.10.11 → gac-3.12.0}/src/gac/providers/together.py +0 -0
  71. {gac-3.10.11 → gac-3.12.0}/src/gac/providers/zai.py +0 -0
  72. {gac-3.10.11 → gac-3.12.0}/src/gac/py.typed +0 -0
  73. {gac-3.10.11 → gac-3.12.0}/src/gac/security.py +0 -0
  74. {gac-3.10.11 → gac-3.12.0}/src/gac/templates/__init__.py +0 -0
  75. {gac-3.10.11 → gac-3.12.0}/src/gac/templates/question_generation.txt +0 -0
  76. {gac-3.10.11 → gac-3.12.0}/src/gac/templates/system_prompt.txt +0 -0
  77. {gac-3.10.11 → gac-3.12.0}/src/gac/templates/user_prompt.txt +0 -0
  78. {gac-3.10.11 → gac-3.12.0}/src/gac/utils.py +0 -0
  79. {gac-3.10.11 → gac-3.12.0}/src/gac/workflow_context.py +0 -0
  80. {gac-3.10.11 → gac-3.12.0}/src/gac/workflow_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gac
3
- Version: 3.10.11
3
+ Version: 3.12.0
4
4
  Summary: LLM-powered Git commit message generator with multi-provider support
5
5
  Project-URL: Homepage, https://github.com/cellwebb/gac
6
6
  Project-URL: Documentation, https://github.com/cellwebb/gac#readme
@@ -29,7 +29,6 @@ Requires-Dist: pydantic>=2.12.0
29
29
  Requires-Dist: python-dotenv>=1.1.1
30
30
  Requires-Dist: questionary
31
31
  Requires-Dist: rich>=14.1.0
32
- Requires-Dist: tiktoken>=0.12.0
33
32
  Provides-Extra: dev
34
33
  Requires-Dist: build; extra == 'dev'
35
34
  Requires-Dist: codecov; extra == 'dev'
@@ -29,8 +29,7 @@ dependencies = [
29
29
  "httpx>=0.28.0",
30
30
  "httpcore>=1.0.9", # Required for Python 3.14 compatibility
31
31
 
32
- # Token counting (OpenAI models)
33
- "tiktoken>=0.12.0",
32
+
34
33
 
35
34
  # Core functionality
36
35
  "pydantic>=2.12.0",
@@ -1,3 +1,3 @@
1
1
  """Version information for gac package."""
2
2
 
3
- __version__ = "3.10.11"
3
+ __version__ = "3.12.0"
@@ -8,14 +8,11 @@ import logging
8
8
  import os
9
9
  import time
10
10
  from collections.abc import Callable
11
- from functools import lru_cache
12
11
  from typing import Any, cast
13
12
 
14
- import tiktoken
15
13
  from rich.console import Console
16
14
  from rich.status import Status
17
15
 
18
- from gac.constants import EnvDefaults, Utility
19
16
  from gac.errors import AIError
20
17
  from gac.oauth import QwenOAuthProvider, refresh_token_if_expired
21
18
  from gac.oauth.token_store import TokenStore
@@ -25,29 +22,16 @@ logger = logging.getLogger(__name__)
25
22
  console = Console()
26
23
 
27
24
 
28
- @lru_cache(maxsize=1)
29
- def _should_skip_tiktoken_counting() -> bool:
30
- """Return True when token counting should avoid tiktoken calls entirely."""
31
- value = os.getenv("GAC_NO_TIKTOKEN", str(EnvDefaults.NO_TIKTOKEN))
32
- return value.lower() in ("true", "1", "yes", "on")
33
-
34
-
35
25
  def count_tokens(content: str | list[dict[str, str]] | dict[str, Any], model: str) -> int:
36
- """Count tokens in content using the model's tokenizer."""
26
+ """Count tokens in content using character-based estimation (1 token per 3.4 characters)."""
37
27
  text = extract_text_content(content)
38
28
  if not text:
39
29
  return 0
40
30
 
41
- if _should_skip_tiktoken_counting():
42
- return len(text) // 4
43
-
44
- try:
45
- encoding = get_encoding(model)
46
- return len(encoding.encode(text))
47
- except (KeyError, UnicodeError, ValueError) as e:
48
- logger.error(f"Error counting tokens: {e}")
49
- # Fallback to rough estimation (4 chars per token on average)
50
- return len(text) // 4
31
+ # Use simple character-based estimation: 1 token per 3.4 characters (rounded)
32
+ result = round(len(text) / 3.4)
33
+ # Ensure at least 1 token for non-empty text
34
+ return result if result > 0 else 1
51
35
 
52
36
 
53
37
  def extract_text_content(content: str | list[dict[str, str]] | dict[str, Any]) -> str:
@@ -61,24 +45,6 @@ def extract_text_content(content: str | list[dict[str, str]] | dict[str, Any]) -
61
45
  return ""
62
46
 
63
47
 
64
- @lru_cache(maxsize=1)
65
- def get_encoding(model: str) -> tiktoken.Encoding:
66
- """Get the appropriate encoding for a given model."""
67
- provider, model_name = model.split(":", 1) if ":" in model else (None, model)
68
-
69
- if provider != "openai":
70
- return tiktoken.get_encoding(Utility.DEFAULT_ENCODING)
71
-
72
- try:
73
- return tiktoken.encoding_for_model(model_name)
74
- except KeyError:
75
- # Fall back to default encoding if model not found
76
- return tiktoken.get_encoding(Utility.DEFAULT_ENCODING)
77
- except (OSError, ConnectionError):
78
- # If there are any network/SSL issues, fall back to default encoding
79
- return tiktoken.get_encoding(Utility.DEFAULT_ENCODING)
80
-
81
-
82
48
  def generate_with_retries(
83
49
  provider_funcs: dict[str, Callable[..., str]],
84
50
  model: str,
@@ -24,6 +24,7 @@ from gac.init_cli import init as init_cli
24
24
  from gac.language_cli import language as language_cli
25
25
  from gac.main import main
26
26
  from gac.model_cli import model as model_cli
27
+ from gac.prompt_cli import prompt as prompt_cli
27
28
  from gac.utils import setup_logging
28
29
  from gac.workflow_context import CLIOptions
29
30
 
@@ -206,6 +207,7 @@ cli.add_command(diff_cli)
206
207
  cli.add_command(init_cli)
207
208
  cli.add_command(language_cli)
208
209
  cli.add_command(model_cli)
210
+ cli.add_command(prompt_cli)
209
211
 
210
212
 
211
213
  @click.command(context_settings=language_cli.context_settings)
@@ -27,7 +27,6 @@ class GACConfig(TypedDict, total=False):
27
27
  warning_limit_tokens: int
28
28
  always_include_scope: bool
29
29
  skip_secret_scan: bool
30
- no_tiktoken: bool
31
30
  no_verify_ssl: bool
32
31
  verbose: bool
33
32
  system_prompt_path: str | None
@@ -110,7 +109,6 @@ def load_config() -> GACConfig:
110
109
  in ("true", "1", "yes", "on"),
111
110
  "skip_secret_scan": os.getenv("GAC_SKIP_SECRET_SCAN", str(EnvDefaults.SKIP_SECRET_SCAN)).lower()
112
111
  in ("true", "1", "yes", "on"),
113
- "no_tiktoken": os.getenv("GAC_NO_TIKTOKEN", str(EnvDefaults.NO_TIKTOKEN)).lower() in ("true", "1", "yes", "on"),
114
112
  "no_verify_ssl": os.getenv("GAC_NO_VERIFY_SSL", str(EnvDefaults.NO_VERIFY_SSL)).lower()
115
113
  in ("true", "1", "yes", "on"),
116
114
  "verbose": os.getenv("GAC_VERBOSE", str(EnvDefaults.VERBOSE)).lower() in ("true", "1", "yes", "on"),
@@ -13,7 +13,6 @@ class EnvDefaults:
13
13
  ALWAYS_INCLUDE_SCOPE: bool = False
14
14
  SKIP_SECRET_SCAN: bool = False
15
15
  VERBOSE: bool = False
16
- NO_TIKTOKEN: bool = False
17
16
  NO_VERIFY_SSL: bool = False # Skip SSL certificate verification (for corporate proxies)
18
17
  HOOK_TIMEOUT: int = 120 # Timeout for pre-commit and lefthook hooks in seconds
19
18
 
@@ -34,7 +33,6 @@ class Logging:
34
33
  class Utility:
35
34
  """General utility constants."""
36
35
 
37
- DEFAULT_ENCODING: str = "cl100k_base" # llm encoding
38
36
  DEFAULT_DIFF_TOKEN_LIMIT: int = 15000 # Maximum tokens for diff processing
39
37
  MAX_WORKERS: int = os.cpu_count() or 4 # Maximum number of parallel workers
40
38
  MAX_DISPLAYED_SECRET_LENGTH: int = 50 # Maximum length for displaying secrets
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env python3
2
2
  """Grouped commit workflow handling for gac."""
3
+ # mypy: warn-unreachable=false
3
4
 
4
5
  import json
5
6
  import logging
@@ -41,10 +42,23 @@ class GroupedCommitWorkflow:
41
42
  self, staged: set[str], grouped_result: dict[str, Any]
42
43
  ) -> tuple[bool, str, str]:
43
44
  """Validate that grouped commits cover all staged files correctly."""
44
- commits = grouped_result.get("commits", []) if isinstance(grouped_result, dict) else []
45
+ # Handle edge cases that should be caught elsewhere
46
+ if not isinstance(grouped_result, dict):
47
+ return True, "", ""
48
+
49
+ commits = grouped_result.get("commits", [])
50
+ # Handle empty commits case (defensive - unreachable in normal flow)
51
+ if not commits: # pragma: no cover # type: ignore[unreachable]
52
+ return True, "", "" # Empty commits is valid (will be caught elsewhere)
53
+
54
+ # Check if any commit has invalid structure - these should be caught in JSON validation
55
+ for commit in commits:
56
+ if not isinstance(commit, dict) or "files" not in commit:
57
+ return True, "", "" # Invalid structure - let JSON validation handle it
58
+
45
59
  all_files: list[str] = []
46
60
  for commit in commits:
47
- files = commit.get("files", []) if isinstance(commit, dict) else []
61
+ files = commit.get("files", [])
48
62
  all_files.extend([str(p) for p in files])
49
63
 
50
64
  counts = Counter(all_files)
@@ -84,11 +98,13 @@ class GroupedCommitWorkflow:
84
98
  conversation_messages.append({"role": "user", "content": feedback_message})
85
99
  if attempts >= content_retry_budget:
86
100
  logger.error(error_message)
101
+ logger.error("Raw model output:")
87
102
  console.print(f"\n[red]{error_message}[/red]")
88
103
  console.print("\n[yellow]Raw model output:[/yellow]")
89
104
  console.print(Panel(raw_response, title="Model Output", border_style="yellow"))
90
105
  return True
91
106
  if not quiet:
107
+ logger.info(f"Retry {attempts} of {content_retry_budget - 1}: {retry_context}")
92
108
  console.print(f"[yellow]Retry {attempts} of {content_retry_budget - 1}: {retry_context}[/yellow]")
93
109
  return False
94
110
 
@@ -84,18 +84,18 @@ def _configure_model(existing_env: dict[str, str]) -> bool:
84
84
  ("Anthropic", "claude-haiku-4-5"),
85
85
  ("Azure OpenAI", "gpt-5-mini"),
86
86
  ("Cerebras", "zai-glm-4.6"),
87
- ("Chutes", "zai-org/GLM-4.6-FP8"),
87
+ ("Chutes", "zai-org/GLM-4.7-TEE"),
88
88
  ("Claude Code (OAuth)", "claude-sonnet-4-5"),
89
89
  ("Custom (Anthropic)", ""),
90
90
  ("Custom (OpenAI)", ""),
91
91
  ("DeepSeek", "deepseek-chat"),
92
- ("Fireworks", "accounts/fireworks/models/gpt-oss-20b"),
93
- ("Gemini", "gemini-2.5-flash"),
94
- ("Groq", "meta-llama/llama-4-maverick-17b-128e-instruct"),
92
+ ("Fireworks", "fireworks/glm-4p7"),
93
+ ("Gemini", "gemini-3-flash-preview"),
94
+ ("Groq", "openai/gpt-oss-120b"),
95
95
  ("Kimi for Coding", "kimi-for-coding"),
96
96
  ("LM Studio", "gemma3"),
97
- ("MiniMax.io", "MiniMax-M2"),
98
- ("Mistral", "mistral-small-latest"),
97
+ ("MiniMax.io", "MiniMax-M2.1"),
98
+ ("Mistral", "devstral-2512"),
99
99
  ("Moonshot AI", "kimi-k2-thinking-turbo"),
100
100
  ("Ollama", "gemma3"),
101
101
  ("OpenAI", "gpt-5-mini"),
@@ -103,10 +103,10 @@ def _configure_model(existing_env: dict[str, str]) -> bool:
103
103
  ("Qwen.ai (OAuth)", "qwen3-coder-plus"),
104
104
  ("Replicate", "openai/gpt-oss-120b"),
105
105
  ("Streamlake", ""),
106
- ("Synthetic.new", "hf:zai-org/GLM-4.6"),
107
- ("Together AI", "openai/gpt-oss-20B"),
108
- ("Z.AI", "glm-4.5-air"),
109
- ("Z.AI Coding", "glm-4.6"),
106
+ ("Synthetic.new", "hf:zai-org/GLM-4.7"),
107
+ ("Together AI", "openai/gpt-oss-120B"),
108
+ ("Z.AI", "glm-47"),
109
+ ("Z.AI Coding", "glm-4.7"),
110
110
  ]
111
111
  provider_names = [p[0] for p in providers]
112
112
  provider = questionary.select(
@@ -0,0 +1,266 @@
1
+ """CLI for managing custom system prompts."""
2
+
3
+ import logging
4
+ import os
5
+ from pathlib import Path
6
+
7
+ import click
8
+ from rich.console import Console
9
+ from rich.panel import Panel
10
+
11
+ logger = logging.getLogger(__name__)
12
+ console = Console()
13
+
14
+ GAC_CONFIG_DIR = Path.home() / ".config" / "gac"
15
+ CUSTOM_PROMPT_FILE = GAC_CONFIG_DIR / "custom_system_prompt.txt"
16
+
17
+
18
+ def get_active_custom_prompt() -> tuple[str | None, str | None]:
19
+ """Return (content, source) for active custom prompt, or (None, None) if none.
20
+
21
+ Returns:
22
+ Tuple of (content, source) where:
23
+ - content: The custom prompt text, or None if using default
24
+ - source: Human-readable description of where the prompt came from, or None if using default
25
+ """
26
+ # Check GAC_SYSTEM_PROMPT_PATH env var first (highest precedence)
27
+ env_path = os.getenv("GAC_SYSTEM_PROMPT_PATH")
28
+ if env_path:
29
+ env_file = Path(env_path)
30
+ if env_file.exists():
31
+ try:
32
+ content = env_file.read_text(encoding="utf-8")
33
+ return content, f"GAC_SYSTEM_PROMPT_PATH={env_path}"
34
+ except OSError:
35
+ pass
36
+
37
+ # Check stored custom prompt file
38
+ if CUSTOM_PROMPT_FILE.exists():
39
+ try:
40
+ content = CUSTOM_PROMPT_FILE.read_text(encoding="utf-8")
41
+ return content, str(CUSTOM_PROMPT_FILE)
42
+ except OSError:
43
+ pass
44
+
45
+ # No custom prompt configured
46
+ return None, None
47
+
48
+
49
+ @click.group()
50
+ def prompt() -> None:
51
+ """Manage custom system prompts."""
52
+ pass
53
+
54
+
55
+ @prompt.command()
56
+ def show() -> None:
57
+ """Show the active custom system prompt."""
58
+ from gac.prompt import _load_default_system_template
59
+
60
+ content, source = get_active_custom_prompt()
61
+
62
+ if content is None:
63
+ console.print("[dim]No custom prompt configured. Showing default:[/dim]\n")
64
+ default_template = _load_default_system_template()
65
+ console.print(Panel(default_template.strip(), title="Default System Prompt", border_style="green"))
66
+ return
67
+
68
+ # Determine title based on source
69
+ if source and source.startswith("GAC_SYSTEM_PROMPT_PATH="):
70
+ title = f"Custom System Prompt (from {source})"
71
+ else:
72
+ title = f"Custom System Prompt ({source})"
73
+
74
+ console.print(Panel(content.strip(), title=title, border_style="green"))
75
+
76
+
77
+ def _edit_text_interactive(initial_text: str) -> str | None:
78
+ """Edit text interactively using prompt_toolkit.
79
+
80
+ Returns edited text, or None if cancelled.
81
+ """
82
+ from prompt_toolkit import Application
83
+ from prompt_toolkit.buffer import Buffer
84
+ from prompt_toolkit.document import Document
85
+ from prompt_toolkit.enums import EditingMode
86
+ from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent
87
+ from prompt_toolkit.layout import HSplit, Layout, Window
88
+ from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
89
+ from prompt_toolkit.layout.margins import ScrollbarMargin
90
+ from prompt_toolkit.styles import Style
91
+
92
+ try:
93
+ import shutil
94
+
95
+ console.print("\n[bold]Edit your custom system prompt:[/bold]")
96
+ console.print("[dim]Esc+Enter or Ctrl+S to save | Ctrl+C to cancel[/dim]\n")
97
+
98
+ # Create buffer for text editing
99
+ text_buffer = Buffer(
100
+ document=Document(text=initial_text, cursor_position=0),
101
+ multiline=True,
102
+ enable_history_search=False,
103
+ )
104
+
105
+ # Track state
106
+ cancelled = {"value": False}
107
+ submitted = {"value": False}
108
+
109
+ # Get terminal size and calculate appropriate height
110
+ term_size = shutil.get_terminal_size((80, 24))
111
+ # Reserve 6 lines for header, hint bar, and margins
112
+ available_height = max(5, term_size.lines - 6)
113
+ content_height = initial_text.count("\n") + 3
114
+ editor_height = min(available_height, max(5, content_height))
115
+
116
+ # Create text editor window - adapt to terminal size
117
+ text_window = Window(
118
+ content=BufferControl(buffer=text_buffer, focus_on_click=True),
119
+ height=editor_height,
120
+ wrap_lines=True,
121
+ right_margins=[ScrollbarMargin()],
122
+ )
123
+
124
+ # Create hint window
125
+ hint_window = Window(
126
+ content=FormattedTextControl(text=[("class:hint", " Esc+Enter or Ctrl+S to save | Ctrl+C to cancel ")]),
127
+ height=1,
128
+ dont_extend_height=True,
129
+ )
130
+
131
+ # Create layout
132
+ root_container = HSplit([text_window, hint_window])
133
+ layout = Layout(root_container, focused_element=text_window)
134
+
135
+ # Create key bindings
136
+ kb = KeyBindings()
137
+
138
+ @kb.add("c-s")
139
+ def _(event: KeyPressEvent) -> None:
140
+ submitted["value"] = True
141
+ event.app.exit()
142
+
143
+ @kb.add("c-c")
144
+ def _(event: KeyPressEvent) -> None:
145
+ cancelled["value"] = True
146
+ event.app.exit()
147
+
148
+ @kb.add("escape", "enter")
149
+ def _(event: KeyPressEvent) -> None:
150
+ submitted["value"] = True
151
+ event.app.exit()
152
+
153
+ # Create and run application
154
+ custom_style = Style.from_dict({"hint": "#888888"})
155
+
156
+ app: Application[None] = Application(
157
+ layout=layout,
158
+ key_bindings=kb,
159
+ full_screen=False,
160
+ mouse_support=False,
161
+ editing_mode=EditingMode.VI,
162
+ style=custom_style,
163
+ )
164
+
165
+ app.run()
166
+
167
+ if cancelled["value"]:
168
+ return None
169
+
170
+ if submitted["value"]:
171
+ return text_buffer.text.strip()
172
+
173
+ return None
174
+
175
+ except (EOFError, KeyboardInterrupt):
176
+ return None
177
+ except Exception as e:
178
+ logger.error(f"Error during interactive editing: {e}")
179
+ console.print(f"[red]Failed to open editor: {e}[/red]")
180
+ return None
181
+
182
+
183
+ def _get_prompt_file_to_edit() -> tuple[Path, str]:
184
+ """Get the file path to edit and its current content.
185
+
186
+ Returns the env var path if set, otherwise the default stored path.
187
+ """
188
+ env_path = os.getenv("GAC_SYSTEM_PROMPT_PATH")
189
+ if env_path:
190
+ target_file = Path(env_path)
191
+ content = ""
192
+ if target_file.exists():
193
+ try:
194
+ content = target_file.read_text(encoding="utf-8")
195
+ except OSError:
196
+ pass
197
+ return target_file, content
198
+
199
+ # Default to stored config file
200
+ content = ""
201
+ if CUSTOM_PROMPT_FILE.exists():
202
+ try:
203
+ content = CUSTOM_PROMPT_FILE.read_text(encoding="utf-8")
204
+ except OSError:
205
+ pass
206
+ return CUSTOM_PROMPT_FILE, content
207
+
208
+
209
+ @prompt.command()
210
+ @click.option("--edit", "-e", is_flag=True, help="Edit prompt interactively in terminal")
211
+ @click.option("--file", "file_path", type=click.Path(exists=True), help="Copy prompt from file")
212
+ def set(edit: bool, file_path: str | None) -> None:
213
+ """Set custom system prompt via interactive editor or file."""
214
+ # Require exactly one of --edit or --file
215
+ if edit and file_path:
216
+ console.print("[red]Error: --edit and --file are mutually exclusive[/red]")
217
+ raise click.Abort()
218
+
219
+ if not edit and not file_path:
220
+ console.print("[red]Error: either --edit or --file must be specified[/red]")
221
+ raise click.Abort()
222
+
223
+ if edit:
224
+ # Get the target file and its current content
225
+ target_file, initial_content = _get_prompt_file_to_edit()
226
+
227
+ # Create parent directory if needed
228
+ target_file.parent.mkdir(parents=True, exist_ok=True)
229
+
230
+ # Open interactive editor
231
+ result = _edit_text_interactive(initial_content)
232
+
233
+ if result is None:
234
+ console.print("\n[yellow]Edit cancelled, no changes made.[/yellow]")
235
+ return
236
+
237
+ if not result:
238
+ console.print("\n[yellow]Empty prompt not saved.[/yellow]")
239
+ return
240
+
241
+ # Save result
242
+ target_file.write_text(result, encoding="utf-8")
243
+ console.print(f"\n[green]Custom prompt saved to {target_file}[/green]")
244
+
245
+ elif file_path:
246
+ # Copy file content
247
+ source_file = Path(file_path)
248
+ try:
249
+ content = source_file.read_text(encoding="utf-8")
250
+ # Create parent directory if needed
251
+ CUSTOM_PROMPT_FILE.parent.mkdir(parents=True, exist_ok=True)
252
+ CUSTOM_PROMPT_FILE.write_text(content, encoding="utf-8")
253
+ console.print(f"Custom prompt copied from {file_path} to {CUSTOM_PROMPT_FILE}")
254
+ except OSError as e:
255
+ console.print(f"[red]Error reading file {file_path}: {e}[/red]")
256
+ raise click.Abort() from e
257
+
258
+
259
+ @prompt.command()
260
+ def clear() -> None:
261
+ """Clear custom system prompt (revert to default)."""
262
+ if CUSTOM_PROMPT_FILE.exists():
263
+ CUSTOM_PROMPT_FILE.unlink()
264
+ console.print(f"Custom prompt deleted: {CUSTOM_PROMPT_FILE}")
265
+ else:
266
+ console.print("No custom prompt file to delete.")
@@ -249,6 +249,14 @@ class AnthropicCompatibleProvider(BaseConfiguredProvider):
249
249
  headers["anthropic-version"] = "2023-06-01"
250
250
  return headers
251
251
 
252
+ def _get_api_url(self, model: str | None = None) -> str:
253
+ """Get Anthropic API URL with /messages endpoint."""
254
+ if self.config.base_url.endswith("messages"):
255
+ return self.config.base_url
256
+ if self.config.base_url.endswith("/"):
257
+ return f"{self.config.base_url}messages"
258
+ return f"{self.config.base_url}/messages"
259
+
252
260
  def _build_request_body(
253
261
  self, messages: list[dict[str, Any]], temperature: float, max_tokens: int, model: str, **kwargs: Any
254
262
  ) -> dict[str, Any]:
@@ -17,7 +17,7 @@ class ClaudeCodeProvider(AnthropicCompatibleProvider):
17
17
  config = ProviderConfig(
18
18
  name="Claude Code",
19
19
  api_key_env="CLAUDE_CODE_ACCESS_TOKEN",
20
- base_url="https://api.anthropic.com/v1/messages",
20
+ base_url="https://api.anthropic.com/v1",
21
21
  )
22
22
 
23
23
  def _get_api_key(self) -> str:
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes