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.
- {gac-3.10.11 → gac-3.12.0}/PKG-INFO +1 -2
- {gac-3.10.11 → gac-3.12.0}/pyproject.toml +1 -2
- {gac-3.10.11 → gac-3.12.0}/src/gac/__version__.py +1 -1
- {gac-3.10.11 → gac-3.12.0}/src/gac/ai_utils.py +5 -39
- {gac-3.10.11 → gac-3.12.0}/src/gac/cli.py +2 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/config.py +0 -2
- {gac-3.10.11 → gac-3.12.0}/src/gac/constants/defaults.py +0 -2
- {gac-3.10.11 → gac-3.12.0}/src/gac/grouped_commit_workflow.py +18 -2
- {gac-3.10.11 → gac-3.12.0}/src/gac/model_cli.py +10 -10
- gac-3.12.0/src/gac/prompt_cli.py +266 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/providers/base.py +8 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/providers/claude_code.py +1 -1
- {gac-3.10.11 → gac-3.12.0}/.gitignore +0 -0
- {gac-3.10.11 → gac-3.12.0}/LICENSE +0 -0
- {gac-3.10.11 → gac-3.12.0}/README.md +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/__init__.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/ai.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/auth_cli.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/commit_executor.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/config_cli.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/constants/__init__.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/constants/commit.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/constants/file_patterns.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/constants/languages.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/diff_cli.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/errors.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/git.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/git_state_validator.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/init_cli.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/interactive_mode.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/language_cli.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/main.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/model_identifier.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/oauth/__init__.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/oauth/claude_code.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/oauth/qwen_oauth.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/oauth/token_store.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/oauth_retry.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/postprocess.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/preprocess.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/prompt.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/prompt_builder.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/providers/README.md +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/providers/__init__.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/providers/anthropic.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/providers/azure_openai.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/providers/cerebras.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/providers/chutes.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/providers/custom_anthropic.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/providers/custom_openai.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/providers/deepseek.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/providers/error_handler.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/providers/fireworks.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/providers/gemini.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/providers/groq.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/providers/kimi_coding.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/providers/lmstudio.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/providers/minimax.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/providers/mistral.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/providers/moonshot.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/providers/ollama.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/providers/openai.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/providers/openrouter.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/providers/protocol.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/providers/qwen.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/providers/registry.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/providers/replicate.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/providers/streamlake.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/providers/synthetic.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/providers/together.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/providers/zai.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/py.typed +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/security.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/templates/__init__.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/templates/question_generation.txt +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/templates/system_prompt.txt +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/templates/user_prompt.txt +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/utils.py +0 -0
- {gac-3.10.11 → gac-3.12.0}/src/gac/workflow_context.py +0 -0
- {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.
|
|
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'
|
|
@@ -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
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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", [])
|
|
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.
|
|
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", "
|
|
93
|
-
("Gemini", "gemini-
|
|
94
|
-
("Groq", "
|
|
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", "
|
|
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.
|
|
107
|
-
("Together AI", "openai/gpt-oss-
|
|
108
|
-
("Z.AI", "glm-
|
|
109
|
-
("Z.AI Coding", "glm-4.
|
|
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
|
|
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
|
|
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
|