gac 3.10.3__py3-none-any.whl

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.

Potentially problematic release.


This version of gac might be problematic. Click here for more details.

Files changed (67) hide show
  1. gac/__init__.py +15 -0
  2. gac/__version__.py +3 -0
  3. gac/ai.py +109 -0
  4. gac/ai_utils.py +246 -0
  5. gac/auth_cli.py +214 -0
  6. gac/cli.py +218 -0
  7. gac/commit_executor.py +62 -0
  8. gac/config.py +125 -0
  9. gac/config_cli.py +95 -0
  10. gac/constants.py +328 -0
  11. gac/diff_cli.py +159 -0
  12. gac/errors.py +231 -0
  13. gac/git.py +372 -0
  14. gac/git_state_validator.py +184 -0
  15. gac/grouped_commit_workflow.py +423 -0
  16. gac/init_cli.py +70 -0
  17. gac/interactive_mode.py +182 -0
  18. gac/language_cli.py +377 -0
  19. gac/main.py +476 -0
  20. gac/model_cli.py +430 -0
  21. gac/oauth/__init__.py +27 -0
  22. gac/oauth/claude_code.py +464 -0
  23. gac/oauth/qwen_oauth.py +327 -0
  24. gac/oauth/token_store.py +81 -0
  25. gac/preprocess.py +511 -0
  26. gac/prompt.py +878 -0
  27. gac/prompt_builder.py +88 -0
  28. gac/providers/README.md +437 -0
  29. gac/providers/__init__.py +80 -0
  30. gac/providers/anthropic.py +17 -0
  31. gac/providers/azure_openai.py +57 -0
  32. gac/providers/base.py +329 -0
  33. gac/providers/cerebras.py +15 -0
  34. gac/providers/chutes.py +25 -0
  35. gac/providers/claude_code.py +79 -0
  36. gac/providers/custom_anthropic.py +103 -0
  37. gac/providers/custom_openai.py +44 -0
  38. gac/providers/deepseek.py +15 -0
  39. gac/providers/error_handler.py +139 -0
  40. gac/providers/fireworks.py +15 -0
  41. gac/providers/gemini.py +90 -0
  42. gac/providers/groq.py +15 -0
  43. gac/providers/kimi_coding.py +27 -0
  44. gac/providers/lmstudio.py +80 -0
  45. gac/providers/minimax.py +15 -0
  46. gac/providers/mistral.py +15 -0
  47. gac/providers/moonshot.py +15 -0
  48. gac/providers/ollama.py +73 -0
  49. gac/providers/openai.py +32 -0
  50. gac/providers/openrouter.py +21 -0
  51. gac/providers/protocol.py +71 -0
  52. gac/providers/qwen.py +64 -0
  53. gac/providers/registry.py +58 -0
  54. gac/providers/replicate.py +156 -0
  55. gac/providers/streamlake.py +31 -0
  56. gac/providers/synthetic.py +40 -0
  57. gac/providers/together.py +15 -0
  58. gac/providers/zai.py +31 -0
  59. gac/py.typed +0 -0
  60. gac/security.py +293 -0
  61. gac/utils.py +401 -0
  62. gac/workflow_utils.py +217 -0
  63. gac-3.10.3.dist-info/METADATA +283 -0
  64. gac-3.10.3.dist-info/RECORD +67 -0
  65. gac-3.10.3.dist-info/WHEEL +4 -0
  66. gac-3.10.3.dist-info/entry_points.txt +2 -0
  67. gac-3.10.3.dist-info/licenses/LICENSE +16 -0
gac/cli.py ADDED
@@ -0,0 +1,218 @@
1
+ # flake8: noqa: E304
2
+
3
+ """CLI entry point for gac.
4
+
5
+ Defines the Click-based command-line interface and delegates execution to the main workflow.
6
+ """
7
+
8
+ import logging
9
+ import os
10
+ import sys
11
+ from typing import Any
12
+
13
+ import click
14
+ from rich.console import Console
15
+
16
+ from gac import __version__
17
+ from gac.auth_cli import auth as auth_cli
18
+ from gac.config import GACConfig, load_config
19
+ from gac.config_cli import config as config_cli
20
+ from gac.constants import Languages, Logging
21
+ from gac.diff_cli import diff as diff_cli
22
+ from gac.errors import handle_error
23
+ from gac.init_cli import init as init_cli
24
+ from gac.language_cli import language as language_cli
25
+ from gac.main import main
26
+ from gac.model_cli import model as model_cli
27
+ from gac.utils import setup_logging
28
+
29
+ config: GACConfig = load_config()
30
+ logger = logging.getLogger(__name__)
31
+ console = Console()
32
+
33
+
34
+ @click.group(invoke_without_command=True, context_settings={"ignore_unknown_options": True})
35
+ # Git workflow options
36
+ @click.option("--add-all", "-a", is_flag=True, help="Stage all changes before committing")
37
+ @click.option("--group", "-g", is_flag=True, help="Group changes into multiple logical commits")
38
+ @click.option(
39
+ "--interactive", "-i", is_flag=True, help="Ask interactive questions to gather more context for the commit message"
40
+ )
41
+ @click.option("--push", "-p", is_flag=True, help="Push changes to remote after committing")
42
+ @click.option("--dry-run", is_flag=True, help="Dry run the commit workflow")
43
+ @click.option("--message-only", is_flag=True, help="Output only the generated commit message without committing")
44
+ @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt")
45
+ # Commit message options
46
+ @click.option("--one-liner", "-o", is_flag=True, help="Generate a single-line commit message")
47
+ @click.option("--show-prompt", is_flag=True, help="Show the prompt sent to the LLM")
48
+ @click.option(
49
+ "--scope",
50
+ "-s",
51
+ is_flag=True,
52
+ default=False,
53
+ help="Infer an appropriate scope for the commit message",
54
+ )
55
+ @click.option("--hint", "-h", default="", help="Additional context to include in the prompt")
56
+ # Model options
57
+ @click.option("--model", "-m", help="Override the default model (format: 'provider:model_name')")
58
+ @click.option(
59
+ "--language", "-l", help="Override the language for commit messages (e.g., 'Spanish', 'es', 'zh-CN', 'ja')"
60
+ )
61
+ # Output options
62
+ @click.option("--quiet", "-q", is_flag=True, help="Suppress non-error output")
63
+ @click.option(
64
+ "--verbose",
65
+ "-v",
66
+ is_flag=True,
67
+ help="Generate detailed commit messages with motivation, architecture, and impact sections",
68
+ )
69
+ @click.option(
70
+ "--log-level",
71
+ default=config["log_level"],
72
+ type=click.Choice(Logging.LEVELS, case_sensitive=False),
73
+ help=f"Set log level (default: {config['log_level']})",
74
+ )
75
+ # Advanced options
76
+ @click.option("--no-verify", is_flag=True, help="Skip pre-commit and lefthook hooks when committing")
77
+ @click.option("--skip-secret-scan", is_flag=True, help="Skip security scan for secrets in staged changes")
78
+ @click.option(
79
+ "--no-verify-ssl",
80
+ is_flag=True,
81
+ help="Skip SSL certificate verification (useful for corporate proxies)",
82
+ )
83
+ @click.option(
84
+ "--hook-timeout",
85
+ type=int,
86
+ default=0,
87
+ help="Timeout for pre-commit and lefthook hooks in seconds (0 to use configuration)",
88
+ )
89
+ # Other options
90
+ @click.option("--version", is_flag=True, help="Show the version of the Git Auto Commit (gac) tool")
91
+ @click.pass_context
92
+ def cli(
93
+ ctx: click.Context,
94
+ add_all: bool = False,
95
+ group: bool = False,
96
+ interactive: bool = False,
97
+ log_level: str = str(config["log_level"]),
98
+ one_liner: bool = False,
99
+ push: bool = False,
100
+ show_prompt: bool = False,
101
+ scope: bool = False,
102
+ quiet: bool = False,
103
+ yes: bool = False,
104
+ hint: str = "",
105
+ model: str | None = None,
106
+ language: str | None = None,
107
+ version: bool = False,
108
+ dry_run: bool = False,
109
+ message_only: bool = False,
110
+ verbose: bool = False,
111
+ no_verify: bool = False,
112
+ skip_secret_scan: bool = False,
113
+ no_verify_ssl: bool = False,
114
+ hook_timeout: int = 0,
115
+ ) -> None:
116
+ """Git Auto Commit - Generate commit messages with AI."""
117
+ if ctx.invoked_subcommand is None:
118
+ if version:
119
+ print(f"Git Auto Commit (gac) version: {__version__}")
120
+ sys.exit(0)
121
+ effective_log_level = log_level
122
+ if quiet:
123
+ effective_log_level = "ERROR"
124
+ setup_logging(effective_log_level)
125
+ logger.info("Starting gac")
126
+
127
+ # Set SSL verification environment variable if flag is used or config is set
128
+ if no_verify_ssl or config.get("no_verify_ssl", False):
129
+ os.environ["GAC_NO_VERIFY_SSL"] = "true"
130
+ logger.info("SSL certificate verification disabled")
131
+
132
+ # Validate incompatible flag combinations
133
+ if message_only and group:
134
+ console.print("[red]Error: --message-only and --group options are mutually exclusive[/red]")
135
+ console.print("[yellow]--message-only is for generating a single commit message for external use[/yellow]")
136
+ console.print("[yellow]--group is for organizing multiple commits within the current workflow[/yellow]")
137
+ sys.exit(1)
138
+
139
+ # Determine if we should infer scope based on -s flag or always_include_scope setting
140
+ infer_scope = bool(scope or config.get("always_include_scope", False))
141
+
142
+ # Determine if verbose mode should be enabled based on -v flag or verbose config setting
143
+ use_verbose = bool(verbose or config.get("verbose", False))
144
+
145
+ # Resolve language code to full name if provided
146
+ resolved_language = Languages.resolve_code(language) if language else None
147
+
148
+ try:
149
+ main(
150
+ stage_all=add_all,
151
+ group=group,
152
+ interactive=interactive,
153
+ model=model,
154
+ hint=hint,
155
+ one_liner=one_liner,
156
+ show_prompt=show_prompt,
157
+ infer_scope=bool(infer_scope),
158
+ require_confirmation=not yes,
159
+ push=push,
160
+ quiet=quiet,
161
+ dry_run=dry_run,
162
+ message_only=message_only,
163
+ verbose=use_verbose,
164
+ no_verify=no_verify,
165
+ skip_secret_scan=skip_secret_scan or bool(config.get("skip_secret_scan", False)),
166
+ language=resolved_language,
167
+ hook_timeout=hook_timeout if hook_timeout > 0 else int(config.get("hook_timeout", 120) or 120),
168
+ )
169
+ except Exception as e:
170
+ handle_error(e, exit_program=True)
171
+ else:
172
+ # Determine if we should infer scope based on -s flag or always_include_scope setting
173
+ infer_scope = bool(scope or config.get("always_include_scope", False))
174
+
175
+ ctx.obj = {
176
+ "add_all": add_all,
177
+ "group": group,
178
+ "interactive": interactive,
179
+ "log_level": log_level,
180
+ "one_liner": one_liner,
181
+ "push": push,
182
+ "show_prompt": show_prompt,
183
+ "scope": infer_scope,
184
+ "quiet": quiet,
185
+ "yes": yes,
186
+ "hint": hint,
187
+ "model": model,
188
+ "language": language,
189
+ "version": version,
190
+ "dry_run": dry_run,
191
+ "message_only": message_only,
192
+ "verbose": verbose,
193
+ "no_verify": no_verify,
194
+ "skip_secret_scan": skip_secret_scan,
195
+ "no_verify_ssl": no_verify_ssl,
196
+ "hook_timeout": hook_timeout,
197
+ }
198
+
199
+
200
+ cli.add_command(auth_cli)
201
+ cli.add_command(config_cli)
202
+ cli.add_command(diff_cli)
203
+ cli.add_command(init_cli)
204
+ cli.add_command(language_cli)
205
+ cli.add_command(model_cli)
206
+
207
+
208
+ @click.command(context_settings=language_cli.context_settings)
209
+ @click.pass_context
210
+ def lang(ctx: Any) -> None:
211
+ """Set the language for commit messages interactively. (Alias for 'language')"""
212
+ ctx.forward(language_cli)
213
+
214
+
215
+ cli.add_command(lang) # Add the lang alias
216
+
217
+ if __name__ == "__main__":
218
+ cli()
gac/commit_executor.py ADDED
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env python3
2
+ """Commit execution logic for gac."""
3
+
4
+ import logging
5
+ import sys
6
+
7
+ from rich.console import Console
8
+
9
+ from gac.errors import GitError
10
+ from gac.git import get_staged_files, push_changes
11
+ from gac.workflow_utils import execute_commit
12
+
13
+ logger = logging.getLogger(__name__)
14
+ console = Console()
15
+
16
+
17
+ class CommitExecutor:
18
+ """Handles commit creation and related operations."""
19
+
20
+ def __init__(self, dry_run: bool = False, quiet: bool = False, no_verify: bool = False, hook_timeout: int = 120):
21
+ self.dry_run = dry_run
22
+ self.quiet = quiet
23
+ self.no_verify = no_verify
24
+ self.hook_timeout = hook_timeout
25
+
26
+ def create_commit(self, commit_message: str) -> None:
27
+ """Create a single commit with the given message."""
28
+ if self.dry_run:
29
+ console.print("[yellow]Dry run: Commit message generated but not applied[/yellow]")
30
+ console.print("Would commit with message:")
31
+ from rich.panel import Panel
32
+
33
+ console.print(Panel(commit_message, title="Commit Message", border_style="cyan"))
34
+ staged_files = get_staged_files(existing_only=False)
35
+ console.print(f"Would commit {len(staged_files)} files")
36
+ logger.info(f"Would commit {len(staged_files)} files")
37
+ else:
38
+ execute_commit(commit_message, self.no_verify, self.hook_timeout)
39
+
40
+ def push_to_remote(self) -> None:
41
+ """Push changes to remote repository."""
42
+ try:
43
+ if self.dry_run:
44
+ staged_files = get_staged_files(existing_only=False)
45
+ logger.info("Dry run: Would push changes")
46
+ logger.info(f"Would push {len(staged_files)} files")
47
+ console.print("[yellow]Dry run: Would push changes[/yellow]")
48
+ console.print(f"Would push {len(staged_files)} files")
49
+ sys.exit(0)
50
+
51
+ if push_changes():
52
+ logger.info("Changes pushed successfully")
53
+ if not self.quiet:
54
+ console.print("[green]Changes pushed successfully[/green]")
55
+ else:
56
+ console.print(
57
+ "[red]Failed to push changes. Check your remote configuration and network connection.[/red]"
58
+ )
59
+ sys.exit(1)
60
+ except (GitError, OSError) as e:
61
+ console.print(f"[red]Error pushing changes: {e}[/red]")
62
+ sys.exit(1)
gac/config.py ADDED
@@ -0,0 +1,125 @@
1
+ """Configuration loading for gac.
2
+
3
+ Handles environment variable and .gac.env file precedence for application settings.
4
+ """
5
+
6
+ import os
7
+ from pathlib import Path
8
+ from typing import TypedDict
9
+
10
+ from dotenv import load_dotenv
11
+
12
+ from gac.constants import EnvDefaults, Logging
13
+ from gac.errors import ConfigError
14
+
15
+
16
+ class GACConfig(TypedDict, total=False):
17
+ """TypedDict for GAC configuration values.
18
+
19
+ Fields that can be None or omitted are marked with total=False.
20
+ """
21
+
22
+ model: str | None
23
+ temperature: float
24
+ max_output_tokens: int
25
+ max_retries: int
26
+ log_level: str
27
+ warning_limit_tokens: int
28
+ always_include_scope: bool
29
+ skip_secret_scan: bool
30
+ no_tiktoken: bool
31
+ no_verify_ssl: bool
32
+ verbose: bool
33
+ system_prompt_path: str | None
34
+ language: str | None
35
+ translate_prefixes: bool
36
+ rtl_confirmed: bool
37
+ hook_timeout: int
38
+
39
+
40
+ def validate_config(config: GACConfig) -> None:
41
+ """Validate configuration values at load time.
42
+
43
+ Args:
44
+ config: Configuration dictionary to validate
45
+
46
+ Raises:
47
+ ConfigError: If any configuration value is invalid
48
+ """
49
+ # Validate temperature (0.0 to 2.0)
50
+ if config.get("temperature") is not None:
51
+ temp = config["temperature"]
52
+ if not isinstance(temp, (int, float)):
53
+ raise ConfigError(f"temperature must be a number, got {type(temp).__name__}")
54
+ if not 0.0 <= temp <= 2.0:
55
+ raise ConfigError(f"temperature must be between 0.0 and 2.0, got {temp}")
56
+
57
+ # Validate max_output_tokens (1 to 100000)
58
+ if config.get("max_output_tokens") is not None:
59
+ tokens = config["max_output_tokens"]
60
+ if not isinstance(tokens, int):
61
+ raise ConfigError(f"max_output_tokens must be an integer, got {type(tokens).__name__}")
62
+ if tokens < 1 or tokens > 100000:
63
+ raise ConfigError(f"max_output_tokens must be between 1 and 100000, got {tokens}")
64
+
65
+ # Validate max_retries (1 to 10)
66
+ if config.get("max_retries") is not None:
67
+ retries = config["max_retries"]
68
+ if not isinstance(retries, int):
69
+ raise ConfigError(f"max_retries must be an integer, got {type(retries).__name__}")
70
+ if retries < 1 or retries > 10:
71
+ raise ConfigError(f"max_retries must be between 1 and 10, got {retries}")
72
+
73
+ # Validate warning_limit_tokens (must be positive)
74
+ if config.get("warning_limit_tokens") is not None:
75
+ warning_limit = config["warning_limit_tokens"]
76
+ if not isinstance(warning_limit, int):
77
+ raise ConfigError(f"warning_limit_tokens must be an integer, got {type(warning_limit).__name__}")
78
+ if warning_limit < 1:
79
+ raise ConfigError(f"warning_limit_tokens must be positive, got {warning_limit}")
80
+
81
+ # Validate hook_timeout (must be positive)
82
+ if config.get("hook_timeout") is not None:
83
+ hook_timeout = config["hook_timeout"]
84
+ if not isinstance(hook_timeout, int):
85
+ raise ConfigError(f"hook_timeout must be an integer, got {type(hook_timeout).__name__}")
86
+ if hook_timeout < 1:
87
+ raise ConfigError(f"hook_timeout must be positive, got {hook_timeout}")
88
+
89
+
90
+ def load_config() -> GACConfig:
91
+ """Load configuration from $HOME/.gac.env, then ./.gac.env, then environment variables."""
92
+ user_config = Path.home() / ".gac.env"
93
+ if user_config.exists():
94
+ load_dotenv(user_config)
95
+
96
+ # Check for .gac.env in project directory
97
+ project_gac_env = Path(".gac.env")
98
+
99
+ if project_gac_env.exists():
100
+ load_dotenv(project_gac_env, override=True)
101
+
102
+ config: GACConfig = {
103
+ "model": os.getenv("GAC_MODEL"),
104
+ "temperature": float(os.getenv("GAC_TEMPERATURE", EnvDefaults.TEMPERATURE)),
105
+ "max_output_tokens": int(os.getenv("GAC_MAX_OUTPUT_TOKENS", EnvDefaults.MAX_OUTPUT_TOKENS)),
106
+ "max_retries": int(os.getenv("GAC_RETRIES", EnvDefaults.MAX_RETRIES)),
107
+ "log_level": os.getenv("GAC_LOG_LEVEL", Logging.DEFAULT_LEVEL),
108
+ "warning_limit_tokens": int(os.getenv("GAC_WARNING_LIMIT_TOKENS", EnvDefaults.WARNING_LIMIT_TOKENS)),
109
+ "always_include_scope": os.getenv("GAC_ALWAYS_INCLUDE_SCOPE", str(EnvDefaults.ALWAYS_INCLUDE_SCOPE)).lower()
110
+ in ("true", "1", "yes", "on"),
111
+ "skip_secret_scan": os.getenv("GAC_SKIP_SECRET_SCAN", str(EnvDefaults.SKIP_SECRET_SCAN)).lower()
112
+ in ("true", "1", "yes", "on"),
113
+ "no_tiktoken": os.getenv("GAC_NO_TIKTOKEN", str(EnvDefaults.NO_TIKTOKEN)).lower() in ("true", "1", "yes", "on"),
114
+ "no_verify_ssl": os.getenv("GAC_NO_VERIFY_SSL", str(EnvDefaults.NO_VERIFY_SSL)).lower()
115
+ in ("true", "1", "yes", "on"),
116
+ "verbose": os.getenv("GAC_VERBOSE", str(EnvDefaults.VERBOSE)).lower() in ("true", "1", "yes", "on"),
117
+ "system_prompt_path": os.getenv("GAC_SYSTEM_PROMPT_PATH"),
118
+ "language": os.getenv("GAC_LANGUAGE"),
119
+ "translate_prefixes": os.getenv("GAC_TRANSLATE_PREFIXES", "false").lower() in ("true", "1", "yes", "on"),
120
+ "rtl_confirmed": os.getenv("GAC_RTL_CONFIRMED", "false").lower() in ("true", "1", "yes", "on"),
121
+ "hook_timeout": int(os.getenv("GAC_HOOK_TIMEOUT", EnvDefaults.HOOK_TIMEOUT)),
122
+ }
123
+
124
+ validate_config(config)
125
+ return config
gac/config_cli.py ADDED
@@ -0,0 +1,95 @@
1
+ """CLI for managing gac configuration in $HOME/.gac.env."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ import click
7
+ from dotenv import load_dotenv, set_key
8
+
9
+ GAC_ENV_PATH = Path.home() / ".gac.env"
10
+
11
+
12
+ @click.group()
13
+ def config() -> None:
14
+ """Manage gac configuration."""
15
+ pass
16
+
17
+
18
+ @config.command()
19
+ def show() -> None:
20
+ """Show all current config values."""
21
+ from dotenv import dotenv_values
22
+
23
+ project_env_path = Path(".gac.env")
24
+ user_exists = GAC_ENV_PATH.exists()
25
+ project_exists = project_env_path.exists()
26
+
27
+ if not user_exists and not project_exists:
28
+ click.echo("No $HOME/.gac.env found.")
29
+ click.echo("No project-level .gac.env found.")
30
+ return
31
+
32
+ if user_exists:
33
+ click.echo(f"User config ({GAC_ENV_PATH}):")
34
+ user_config = dotenv_values(str(GAC_ENV_PATH))
35
+ for key, value in sorted(user_config.items()):
36
+ if value is not None:
37
+ if any(sensitive in key.lower() for sensitive in ["key", "token", "secret"]):
38
+ display_value = "***hidden***"
39
+ else:
40
+ display_value = value
41
+ click.echo(f" {key}={display_value}")
42
+ else:
43
+ click.echo("No $HOME/.gac.env found.")
44
+
45
+ if project_exists:
46
+ if user_exists:
47
+ click.echo("")
48
+ click.echo("Project config (./.gac.env):")
49
+ project_config = dotenv_values(str(project_env_path))
50
+ for key, value in sorted(project_config.items()):
51
+ if value is not None:
52
+ if any(sensitive in key.lower() for sensitive in ["key", "token", "secret"]):
53
+ display_value = "***hidden***"
54
+ else:
55
+ display_value = value
56
+ click.echo(f" {key}={display_value}")
57
+ click.echo("")
58
+ click.echo("Note: Project-level .gac.env overrides $HOME/.gac.env values for any duplicated variables.")
59
+ else:
60
+ click.echo("No project-level .gac.env found.")
61
+
62
+
63
+ @config.command()
64
+ @click.argument("key")
65
+ @click.argument("value")
66
+ def set(key: str, value: str) -> None:
67
+ """Set a config KEY to VALUE in $HOME/.gac.env."""
68
+ GAC_ENV_PATH.touch(exist_ok=True)
69
+ set_key(str(GAC_ENV_PATH), key, value)
70
+ click.echo(f"Set {key} in $HOME/.gac.env")
71
+
72
+
73
+ @config.command()
74
+ @click.argument("key")
75
+ def get(key: str) -> None:
76
+ """Get a config value by KEY."""
77
+ load_dotenv(GAC_ENV_PATH, override=True)
78
+ value = os.getenv(key)
79
+ if value is None:
80
+ click.echo(f"{key} not set.")
81
+ else:
82
+ click.echo(value)
83
+
84
+
85
+ @config.command()
86
+ @click.argument("key")
87
+ def unset(key: str) -> None:
88
+ """Remove a config KEY from $HOME/.gac.env."""
89
+ if not GAC_ENV_PATH.exists():
90
+ click.echo("No $HOME/.gac.env found.")
91
+ return
92
+ lines = GAC_ENV_PATH.read_text().splitlines()
93
+ new_lines = [line for line in lines if not line.strip().startswith(f"{key}=")]
94
+ GAC_ENV_PATH.write_text("\n".join(new_lines) + "\n")
95
+ click.echo(f"Unset {key} in $HOME/.gac.env")