diffsage 1.3.0__tar.gz → 1.4.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 (107) hide show
  1. {diffsage-1.3.0/src/diffsage.egg-info → diffsage-1.4.0}/PKG-INFO +47 -6
  2. {diffsage-1.3.0 → diffsage-1.4.0}/README.md +45 -4
  3. {diffsage-1.3.0 → diffsage-1.4.0}/pyproject.toml +23 -3
  4. diffsage-1.4.0/src/diffsage/cli.py +79 -0
  5. diffsage-1.4.0/src/diffsage/commands/ask.py +32 -0
  6. diffsage-1.4.0/src/diffsage/commands/auth.py +147 -0
  7. diffsage-1.4.0/src/diffsage/commands/commit.py +142 -0
  8. diffsage-1.4.0/src/diffsage/commands/config.py +159 -0
  9. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/commands/doctor.py +2 -0
  10. diffsage-1.4.0/src/diffsage/commands/error_handler.py +52 -0
  11. diffsage-1.4.0/src/diffsage/commands/pr.py +150 -0
  12. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/config/defaults.py +1 -0
  13. diffsage-1.4.0/src/diffsage/config/file_loader.py +52 -0
  14. diffsage-1.4.0/src/diffsage/config/loader.py +24 -0
  15. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/config/paths.py +22 -9
  16. diffsage-1.4.0/src/diffsage/config/resolver.py +156 -0
  17. diffsage-1.4.0/src/diffsage/config/schema.py +92 -0
  18. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/config/settings.py +1 -0
  19. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/exceptions/__init__.py +15 -1
  20. diffsage-1.4.0/src/diffsage/exceptions/base.py +83 -0
  21. diffsage-1.4.0/src/diffsage/exceptions/credentials.py +24 -0
  22. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/exceptions/git.py +14 -0
  23. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/git/client.py +61 -81
  24. diffsage-1.4.0/src/diffsage/models/commit_message.py +26 -0
  25. diffsage-1.4.0/src/diffsage/models/config.py +42 -0
  26. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/models/doctor.py +1 -0
  27. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/models/git.py +4 -12
  28. diffsage-1.4.0/src/diffsage/parsers/commit_message_parser.py +93 -0
  29. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/services/ai_service.py +3 -10
  30. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/services/commit_service.py +15 -1
  31. diffsage-1.4.0/src/diffsage/services/config_service.py +134 -0
  32. diffsage-1.4.0/src/diffsage/services/credentials_service.py +120 -0
  33. diffsage-1.4.0/src/diffsage/services/diff_sanitizer.py +256 -0
  34. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/services/doctor_service.py +12 -3
  35. diffsage-1.4.0/src/diffsage/services/editor_service.py +87 -0
  36. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/services/git_service.py +0 -1
  37. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/services/github_service.py +17 -6
  38. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/services/prompt_service.py +24 -5
  39. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/services/pull_request_service.py +17 -0
  40. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/storage/config_repository.py +13 -9
  41. diffsage-1.4.0/src/diffsage/storage/credentials_repository.py +176 -0
  42. diffsage-1.4.0/src/diffsage/storage/files.py +52 -0
  43. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/ui/commit_view.py +3 -0
  44. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/ui/config_view.py +25 -2
  45. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/ui/doctor_view.py +7 -1
  46. {diffsage-1.3.0 → diffsage-1.4.0/src/diffsage.egg-info}/PKG-INFO +47 -6
  47. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage.egg-info/SOURCES.txt +6 -1
  48. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage.egg-info/requires.txt +1 -1
  49. diffsage-1.4.0/tests/test_cli.py +21 -0
  50. diffsage-1.4.0/tests/test_harness.py +128 -0
  51. diffsage-1.3.0/src/diffsage/cli.py +0 -44
  52. diffsage-1.3.0/src/diffsage/commands/ask.py +0 -68
  53. diffsage-1.3.0/src/diffsage/commands/auth.py +0 -153
  54. diffsage-1.3.0/src/diffsage/commands/commit.py +0 -142
  55. diffsage-1.3.0/src/diffsage/commands/config.py +0 -216
  56. diffsage-1.3.0/src/diffsage/commands/pr.py +0 -220
  57. diffsage-1.3.0/src/diffsage/config/file_loader.py +0 -21
  58. diffsage-1.3.0/src/diffsage/config/loader.py +0 -8
  59. diffsage-1.3.0/src/diffsage/config/resolver.py +0 -118
  60. diffsage-1.3.0/src/diffsage/config/schema.py +0 -53
  61. diffsage-1.3.0/src/diffsage/exceptions/base.py +0 -44
  62. diffsage-1.3.0/src/diffsage/exceptions/credentials.py +0 -8
  63. diffsage-1.3.0/src/diffsage/models/commit_message.py +0 -11
  64. diffsage-1.3.0/src/diffsage/models/config.py +0 -16
  65. diffsage-1.3.0/src/diffsage/parsers/commit_message_parser.py +0 -43
  66. diffsage-1.3.0/src/diffsage/services/config_service.py +0 -109
  67. diffsage-1.3.0/src/diffsage/services/credentials_service.py +0 -61
  68. diffsage-1.3.0/src/diffsage/services/editor_service.py +0 -75
  69. diffsage-1.3.0/src/diffsage/storage/credentials_repository.py +0 -121
  70. {diffsage-1.3.0 → diffsage-1.4.0}/LICENSE +0 -0
  71. {diffsage-1.3.0 → diffsage-1.4.0}/setup.cfg +0 -0
  72. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/__init__.py +0 -0
  73. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/__main__.py +0 -0
  74. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/commands/__init__.py +0 -0
  75. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/config/__init__.py +0 -0
  76. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/config/scope.py +0 -0
  77. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/exceptions/github.py +0 -0
  78. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/exceptions/provider.py +0 -0
  79. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/git/__init__.py +0 -0
  80. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/github/__init__.py +0 -0
  81. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/github/client.py +0 -0
  82. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/logging/__init__.py +0 -0
  83. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/logging/formatter.py +0 -0
  84. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/logging/logger.py +0 -0
  85. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/models/__init__.py +0 -0
  86. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/models/credentials.py +0 -0
  87. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/models/provider.py +0 -0
  88. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/models/pull_request.py +0 -0
  89. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/models/pull_request_analysis.py +0 -0
  90. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/parsers/pull_request_parser.py +0 -0
  91. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/prompts/__init__.py +0 -0
  92. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/providers/__init__.py +0 -0
  93. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/providers/base.py +0 -0
  94. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/providers/factory.py +0 -0
  95. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/providers/gemini_provider.py +0 -0
  96. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/services/__init__.py +0 -0
  97. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/services/pull_request_analysis_service.py +0 -0
  98. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/storage/__init__.py +0 -0
  99. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/storage/base.py +0 -0
  100. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/ui/__init__.py +0 -0
  101. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/ui/ask_view.py +0 -0
  102. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/ui/auth_view.py +0 -0
  103. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/ui/base.py +0 -0
  104. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage/ui/pull_request_view.py +0 -0
  105. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage.egg-info/dependency_links.txt +0 -0
  106. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage.egg-info/entry_points.txt +0 -0
  107. {diffsage-1.3.0 → diffsage-1.4.0}/src/diffsage.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: diffsage
3
- Version: 1.3.0
3
+ Version: 1.4.0
4
4
  Summary: AI-powered Git workflow assistant
5
5
  Author: Gunitha Rathnayake
6
6
  License: MIT
@@ -26,7 +26,6 @@ Requires-Dist: platformdirs>=4.0.0
26
26
  Requires-Dist: tomlkit<1,>=0.13
27
27
  Requires-Dist: httpx<0.29,>=0.28
28
28
  Requires-Dist: PyYAML<7,>=6.0
29
- Requires-Dist: python-dotenv<2,>=1.1
30
29
  Requires-Dist: google-genai<2,>=1
31
30
  Provides-Extra: dev
32
31
  Requires-Dist: pytest<10,>=9; extra == "dev"
@@ -35,6 +34,7 @@ Requires-Dist: ruff<0.13,>=0.12; extra == "dev"
35
34
  Requires-Dist: pre-commit<5,>=4; extra == "dev"
36
35
  Requires-Dist: build<2,>=1; extra == "dev"
37
36
  Requires-Dist: pip-audit<3,>=2; extra == "dev"
37
+ Requires-Dist: mypy<2,>=1.18; extra == "dev"
38
38
  Dynamic: license-file
39
39
 
40
40
  # DiffSage
@@ -234,7 +234,7 @@ pipx install diffsage
234
234
  A specific version can be installed with:
235
235
 
236
236
  ```bash
237
- pipx install diffsage==1.2.0
237
+ pipx install diffsage==1.4.0
238
238
  ```
239
239
 
240
240
  Release builds and PyPI publishing are automated through GitHub Actions.
@@ -270,12 +270,45 @@ diffsage config unset provider
270
270
 
271
271
  ---
272
272
 
273
+ # What Is Sent to the AI Provider
274
+
275
+ `diffsage commit` and `diffsage pr` send your Git diff to the configured AI provider. Before
276
+ anything is sent, DiffSage:
277
+
278
+ - omits the contents of files that usually hold secrets (`.env`, `.env.*`, `*.pem`, `*.key`,
279
+ SSH keys, `.npmrc`, ...); `.env.example`-style templates are sent with their values redacted
280
+ - omits lockfile contents (`package-lock.json`, `poetry.lock`, `uv.lock`, ...)
281
+ - replaces values that look like credentials with `[REDACTED]`: Google, GitHub, AWS, Slack,
282
+ OpenAI and Anthropic keys, private key blocks, and `key = "value"` style secret assignments
283
+ - limits the diff to 100,000 characters, keeping whole files and listing any that are left out
284
+
285
+ Whenever something is removed, DiffSage prints a `!` notice, so nothing is hidden silently.
286
+ This is a safety net, not a guarantee: review what you stage, and keep secrets out of
287
+ your repository.
288
+
289
+ ---
290
+
273
291
  # Quick Start
274
292
 
275
- Set up your provider credential:
293
+ Set up your provider credential. DiffSage asks for the API key at a hidden prompt, so it
294
+ never appears on screen, in your shell history or in the process list:
295
+
296
+ ```bash
297
+ diffsage auth set gemini
298
+ ```
299
+
300
+ In scripts, pipe the key in instead of typing it:
301
+
302
+ ```bash
303
+ printf '%s\n' "$GEMINI_API_KEY" | diffsage auth set gemini
304
+ ```
305
+
306
+ In CI or containers you can skip the credentials file entirely and provide the key through
307
+ the `DIFFSAGE_API_KEY` environment variable. When it is set, it is used instead of any
308
+ stored credential, and `diffsage auth list` says so:
276
309
 
277
310
  ```bash
278
- diffsage auth set gemini YOUR_API_KEY
311
+ DIFFSAGE_API_KEY="$GEMINI_API_KEY" diffsage pr
279
312
  ```
280
313
 
281
314
  Verify your environment:
@@ -349,7 +382,15 @@ Display PR URL
349
382
  Named credential profiles are also supported:
350
383
 
351
384
  ```bash
352
- diffsage auth set gemini YOUR_API_KEY --name paid
385
+ diffsage auth set gemini --name paid
386
+ ```
387
+
388
+ Choose which profile AI commands use with the `credential_profile` setting, per repository
389
+ or globally, or for a single run with `DIFFSAGE_CREDENTIAL_PROFILE`:
390
+
391
+ ```bash
392
+ diffsage config set credential_profile paid --local
393
+ DIFFSAGE_CREDENTIAL_PROFILE=paid diffsage pr
353
394
  ```
354
395
 
355
396
  View configured credentials:
@@ -195,7 +195,7 @@ pipx install diffsage
195
195
  A specific version can be installed with:
196
196
 
197
197
  ```bash
198
- pipx install diffsage==1.2.0
198
+ pipx install diffsage==1.4.0
199
199
  ```
200
200
 
201
201
  Release builds and PyPI publishing are automated through GitHub Actions.
@@ -231,12 +231,45 @@ diffsage config unset provider
231
231
 
232
232
  ---
233
233
 
234
+ # What Is Sent to the AI Provider
235
+
236
+ `diffsage commit` and `diffsage pr` send your Git diff to the configured AI provider. Before
237
+ anything is sent, DiffSage:
238
+
239
+ - omits the contents of files that usually hold secrets (`.env`, `.env.*`, `*.pem`, `*.key`,
240
+ SSH keys, `.npmrc`, ...); `.env.example`-style templates are sent with their values redacted
241
+ - omits lockfile contents (`package-lock.json`, `poetry.lock`, `uv.lock`, ...)
242
+ - replaces values that look like credentials with `[REDACTED]`: Google, GitHub, AWS, Slack,
243
+ OpenAI and Anthropic keys, private key blocks, and `key = "value"` style secret assignments
244
+ - limits the diff to 100,000 characters, keeping whole files and listing any that are left out
245
+
246
+ Whenever something is removed, DiffSage prints a `!` notice, so nothing is hidden silently.
247
+ This is a safety net, not a guarantee: review what you stage, and keep secrets out of
248
+ your repository.
249
+
250
+ ---
251
+
234
252
  # Quick Start
235
253
 
236
- Set up your provider credential:
254
+ Set up your provider credential. DiffSage asks for the API key at a hidden prompt, so it
255
+ never appears on screen, in your shell history or in the process list:
256
+
257
+ ```bash
258
+ diffsage auth set gemini
259
+ ```
260
+
261
+ In scripts, pipe the key in instead of typing it:
262
+
263
+ ```bash
264
+ printf '%s\n' "$GEMINI_API_KEY" | diffsage auth set gemini
265
+ ```
266
+
267
+ In CI or containers you can skip the credentials file entirely and provide the key through
268
+ the `DIFFSAGE_API_KEY` environment variable. When it is set, it is used instead of any
269
+ stored credential, and `diffsage auth list` says so:
237
270
 
238
271
  ```bash
239
- diffsage auth set gemini YOUR_API_KEY
272
+ DIFFSAGE_API_KEY="$GEMINI_API_KEY" diffsage pr
240
273
  ```
241
274
 
242
275
  Verify your environment:
@@ -310,7 +343,15 @@ Display PR URL
310
343
  Named credential profiles are also supported:
311
344
 
312
345
  ```bash
313
- diffsage auth set gemini YOUR_API_KEY --name paid
346
+ diffsage auth set gemini --name paid
347
+ ```
348
+
349
+ Choose which profile AI commands use with the `credential_profile` setting, per repository
350
+ or globally, or for a single run with `DIFFSAGE_CREDENTIAL_PROFILE`:
351
+
352
+ ```bash
353
+ diffsage config set credential_profile paid --local
354
+ DIFFSAGE_CREDENTIAL_PROFILE=paid diffsage pr
314
355
  ```
315
356
 
316
357
  View configured credentials:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "diffsage"
7
- version = "1.3.0"
7
+ version = "1.4.0"
8
8
  description = "AI-powered Git workflow assistant"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12,<3.13"
@@ -41,7 +41,6 @@ dependencies = [
41
41
  "tomlkit>=0.13,<1",
42
42
  "httpx>=0.28,<0.29",
43
43
  "PyYAML>=6.0,<7",
44
- "python-dotenv>=1.1,<2",
45
44
  "google-genai>=1,<2",
46
45
  ]
47
46
 
@@ -53,6 +52,7 @@ dev = [
53
52
  "pre-commit>=4,<5",
54
53
  "build>=1,<2",
55
54
  "pip-audit>=2,<3",
55
+ "mypy>=1.18,<2",
56
56
  ]
57
57
 
58
58
  [project.scripts]
@@ -80,4 +80,24 @@ line-length = 100
80
80
  target-version = "py312"
81
81
 
82
82
  [tool.ruff.lint]
83
- select = ["E", "F", "I", "UP", "B"]
83
+ select = ["E", "F", "I", "UP", "B", "ARG"]
84
+
85
+ [tool.mypy]
86
+ python_version = "3.12"
87
+ files = ["src"]
88
+ warn_unused_configs = true
89
+ warn_redundant_casts = true
90
+ warn_unused_ignores = true
91
+
92
+ # Temporary baseline: these modules have known type errors that are fixed by
93
+ # later remediation branches. Each branch removes its modules from this list.
94
+ [[tool.mypy.overrides]]
95
+ module = [
96
+ # feature/ai-provider-hardening
97
+ "diffsage.providers.gemini_provider",
98
+ "diffsage.services.ai_service",
99
+ # chore/cleanup
100
+ "diffsage.ui.auth_view",
101
+ "diffsage.ui.config_view",
102
+ ]
103
+ ignore_errors = true
@@ -0,0 +1,79 @@
1
+ import logging
2
+ from importlib.metadata import version
3
+
4
+ import typer
5
+
6
+ from diffsage.commands.ask import ask
7
+ from diffsage.commands.auth import app as auth_app
8
+ from diffsage.commands.commit import commit
9
+ from diffsage.commands.config import app as config_app
10
+ from diffsage.commands.doctor import doctor
11
+ from diffsage.commands.error_handler import handle_command_errors
12
+ from diffsage.commands.pr import pr
13
+ from diffsage.config.loader import load_settings
14
+ from diffsage.exceptions import ConfigError
15
+ from diffsage.logging.logger import configure_logging
16
+
17
+ app = typer.Typer(help="DiffSage: AI-aware Git workflow toolkit")
18
+
19
+ # Commands that must run even when the configuration is invalid, so it can be
20
+ # repaired (`config`) or diagnosed (`doctor`).
21
+ CONFIG_TOLERANT_COMMANDS = {"config", "doctor"}
22
+
23
+
24
+ def version_callback(value: bool) -> None:
25
+ if value:
26
+ typer.echo(version("diffsage"))
27
+ raise typer.Exit()
28
+
29
+
30
+ @app.callback()
31
+ @handle_command_errors("diffsage")
32
+ def main(
33
+ ctx: typer.Context,
34
+ _version: bool = typer.Option(
35
+ False,
36
+ "--version",
37
+ "-v",
38
+ callback=version_callback,
39
+ is_eager=True,
40
+ help="Show the Diffsage version.",
41
+ ),
42
+ ) -> None:
43
+ """
44
+ Initialize DiffSage.
45
+ """
46
+ try:
47
+ settings = load_settings()
48
+ except ConfigError:
49
+ # Start the log file at the default level so the error handler's warning is
50
+ # written there, instead of to stderr through Python's last-resort handler.
51
+ configure_logging()
52
+
53
+ if ctx.invoked_subcommand in CONFIG_TOLERANT_COMMANDS:
54
+ return
55
+
56
+ raise
57
+
58
+ configure_logging(
59
+ getattr(
60
+ logging,
61
+ settings.log_level.upper(),
62
+ logging.INFO,
63
+ )
64
+ )
65
+
66
+
67
+ app.command()(doctor)
68
+ app.command()(ask)
69
+ app.command()(commit)
70
+ app.command()(pr)
71
+
72
+ app.add_typer(
73
+ config_app,
74
+ name="config",
75
+ )
76
+ app.add_typer(
77
+ auth_app,
78
+ name="auth",
79
+ )
@@ -0,0 +1,32 @@
1
+ from diffsage.commands.error_handler import handle_command_errors
2
+ from diffsage.config.loader import load_settings
3
+ from diffsage.config.paths import get_credentials_path
4
+ from diffsage.logging.logger import get_logger
5
+ from diffsage.services.ai_service import AIService
6
+ from diffsage.services.credentials_service import CredentialService
7
+ from diffsage.storage.credentials_repository import CredentialsRepository
8
+ from diffsage.ui.ask_view import AskView
9
+
10
+ logger = get_logger(__name__)
11
+
12
+
13
+ @handle_command_errors("ask")
14
+ def ask(prompt: str) -> None:
15
+ """Ask the configured AI provider a question"""
16
+
17
+ logger.info("Running ask command")
18
+ view = AskView()
19
+
20
+ settings = load_settings()
21
+
22
+ credential_path = get_credentials_path()
23
+ repository = CredentialsRepository(credential_path)
24
+ credential_service = CredentialService(repository)
25
+
26
+ service = AIService(
27
+ settings,
28
+ credential_service,
29
+ )
30
+
31
+ response = service.ask(prompt)
32
+ view.show_response(response)
@@ -0,0 +1,147 @@
1
+ import sys
2
+
3
+ import typer
4
+
5
+ from diffsage.commands.error_handler import handle_command_errors
6
+ from diffsage.config.paths import get_credentials_path
7
+ from diffsage.exceptions import CredentialNotFoundError, InvalidCredentialError
8
+ from diffsage.logging.logger import get_logger
9
+ from diffsage.services.credentials_service import API_KEY_ENVIRONMENT_VARIABLE, CredentialService
10
+ from diffsage.storage.credentials_repository import CredentialsRepository
11
+ from diffsage.ui.auth_view import AuthView
12
+
13
+ logger = get_logger(__name__)
14
+
15
+ app = typer.Typer(
16
+ help="DiffSage credential management",
17
+ invoke_without_command=False,
18
+ )
19
+
20
+
21
+ def _stdin_is_terminal() -> bool:
22
+ return sys.stdin.isatty()
23
+
24
+
25
+ def _read_api_key(provider: str) -> str:
26
+ """Ask for the key at a hidden prompt, or read it from piped input."""
27
+
28
+ if _stdin_is_terminal():
29
+ return typer.prompt(f"API key for {provider}", hide_input=True)
30
+
31
+ # Piped input (scripts, CI): nothing to hide on screen, so no prompt. Using the
32
+ # hidden prompt here would print getpass's "input may be echoed" warning.
33
+ api_key = sys.stdin.readline().strip()
34
+
35
+ if not api_key:
36
+ raise InvalidCredentialError("No API key was received on standard input.")
37
+
38
+ return api_key
39
+
40
+
41
+ def _warn_if_environment_key_is_set(view: AuthView) -> None:
42
+ if CredentialService.environment_api_key() is not None:
43
+ view.show_warning(
44
+ f"{API_KEY_ENVIRONMENT_VARIABLE} is set, so AI commands use it instead of "
45
+ "stored credentials."
46
+ )
47
+
48
+
49
+ def _credential_service() -> CredentialService:
50
+ path = get_credentials_path()
51
+ repository = CredentialsRepository(path)
52
+ return CredentialService(repository)
53
+
54
+
55
+ @app.command("set")
56
+ @handle_command_errors("auth set")
57
+ def set_credential(
58
+ provider: str,
59
+ name: str = typer.Option(
60
+ "default",
61
+ "--name",
62
+ help="Credential profile name.",
63
+ ),
64
+ ) -> None:
65
+ """Store a DiffSage provider credential.
66
+
67
+ The API key is entered at a hidden prompt, never as an argument.
68
+
69
+ For scripts, pipe it in: printf '%s\\n' "$KEY" | diffsage auth set gemini
70
+ """
71
+
72
+ view = AuthView()
73
+
74
+ api_key = _read_api_key(provider)
75
+
76
+ _credential_service().set_credential(
77
+ provider,
78
+ api_key,
79
+ name,
80
+ )
81
+
82
+ view.show_success("Credential saved.")
83
+
84
+
85
+ @app.command("get")
86
+ @handle_command_errors("auth get")
87
+ def get_credential(
88
+ provider: str,
89
+ name: str = typer.Option(
90
+ "default",
91
+ "--name",
92
+ help="Credential profile name.",
93
+ ),
94
+ ) -> None:
95
+ """Get a DiffSage provider credential."""
96
+
97
+ view = AuthView()
98
+
99
+ credential = _credential_service().get_credential(
100
+ provider,
101
+ name,
102
+ )
103
+
104
+ _warn_if_environment_key_is_set(view)
105
+
106
+ if credential is None:
107
+ raise CredentialNotFoundError(provider, name)
108
+
109
+ view.show_credential(credential)
110
+
111
+
112
+ @app.command("list")
113
+ @handle_command_errors("auth list")
114
+ def list_credentials() -> None:
115
+ """List configured DiffSage credentials."""
116
+
117
+ view = AuthView()
118
+
119
+ credentials = _credential_service().list_credentials()
120
+
121
+ _warn_if_environment_key_is_set(view)
122
+ view.show_credentials(credentials)
123
+
124
+
125
+ @app.command("unset")
126
+ @handle_command_errors("auth unset")
127
+ def unset_credential(
128
+ provider: str,
129
+ name: str = typer.Option(
130
+ "default",
131
+ "--name",
132
+ help="Credential profile name.",
133
+ ),
134
+ ) -> None:
135
+ """Unset DiffSage provider credential."""
136
+
137
+ view = AuthView()
138
+
139
+ deleted = _credential_service().delete_credential(
140
+ provider,
141
+ name,
142
+ )
143
+
144
+ if not deleted:
145
+ raise CredentialNotFoundError(provider, name)
146
+
147
+ view.show_success("Credential removed.")
@@ -0,0 +1,142 @@
1
+ from collections.abc import Callable
2
+
3
+ from diffsage.commands.error_handler import handle_command_errors
4
+ from diffsage.config.loader import load_settings
5
+ from diffsage.config.paths import get_credentials_path
6
+ from diffsage.exceptions import InvalidCommitMessageError
7
+ from diffsage.git.client import GitClient
8
+ from diffsage.logging.logger import get_logger
9
+ from diffsage.parsers.commit_message_parser import CommitMessageParser
10
+ from diffsage.services.ai_service import AIService
11
+ from diffsage.services.commit_service import CommitService
12
+ from diffsage.services.credentials_service import CredentialService
13
+ from diffsage.services.editor_service import EditorService
14
+ from diffsage.services.git_service import GitService
15
+ from diffsage.services.prompt_service import PromptService
16
+ from diffsage.storage.credentials_repository import CredentialsRepository
17
+ from diffsage.ui.commit_view import CommitView
18
+
19
+ logger = get_logger(__name__)
20
+
21
+
22
+ def generate_message(
23
+ commit_service: CommitService,
24
+ on_attempt: Callable[[int, int], None] | None = None,
25
+ on_notice: Callable[[str], None] | None = None,
26
+ ) -> str:
27
+ return commit_service.generate_commit_message(
28
+ on_attempt=on_attempt,
29
+ on_notice=on_notice,
30
+ )
31
+
32
+
33
+ @handle_command_errors("commit")
34
+ def commit() -> None:
35
+ """Generate a conventional commit message"""
36
+
37
+ git_client = GitClient()
38
+ view = CommitView()
39
+ editor = EditorService()
40
+
41
+ settings = load_settings()
42
+
43
+ credential_path = get_credentials_path()
44
+ credential_repository = CredentialsRepository(credential_path)
45
+ credential_service = CredentialService(credential_repository)
46
+
47
+ git_service = GitService(git_client)
48
+ ai_service = AIService(
49
+ settings,
50
+ credential_service,
51
+ )
52
+ prompt_service = PromptService()
53
+
54
+ commit_service = CommitService(
55
+ git_client,
56
+ git_service,
57
+ prompt_service,
58
+ ai_service,
59
+ )
60
+
61
+ with view.generating() as status:
62
+ raw_message = generate_message(
63
+ commit_service,
64
+ on_attempt=lambda attempt, total: status.update(
65
+ f"Generating commit message... Attempt {attempt}/{total}"
66
+ ),
67
+ on_notice=view.show_warning,
68
+ )
69
+ commit_message = CommitMessageParser.parse(raw_message)
70
+
71
+ view.show_generated()
72
+ view.show_commit(commit_message)
73
+
74
+ # Text of an edit that did not parse; the next E reopens it so nothing typed is lost.
75
+ pending_edit: str | None = None
76
+
77
+ while True:
78
+ choice = view.prompt_action()
79
+
80
+ if choice in ("", "y"):
81
+ logger.info("User selected commit.")
82
+ # Commit the cleaned message: no code fences or preamble from the AI reply.
83
+ message_text = commit_message.to_text()
84
+ git_client.commit(message_text)
85
+ view.show_success(message_text)
86
+ break
87
+
88
+ if choice == "e":
89
+ logger.info("User selected edit.")
90
+ edited = editor.edit(pending_edit or commit_message.to_text())
91
+
92
+ if edited is None:
93
+ view.show_warning("Edit cancelled. The message below is unchanged.")
94
+ view.show_commit(commit_message)
95
+ continue
96
+
97
+ try:
98
+ commit_message = CommitMessageParser.parse(edited)
99
+ except InvalidCommitMessageError as error:
100
+ pending_edit = edited
101
+ view.show_error(error.message)
102
+ view.show_warning(
103
+ "Your edit was kept. Press E to continue editing it. The message below "
104
+ "is unchanged, and Y commits it."
105
+ )
106
+ else:
107
+ pending_edit = None
108
+
109
+ view.show_commit(commit_message)
110
+ continue
111
+
112
+ if choice == "r":
113
+ logger.info("User selected regenerate.")
114
+ with view.generating() as status:
115
+ raw_message = generate_message(
116
+ commit_service,
117
+ on_attempt=lambda attempt, total: status.update(
118
+ f"Generating commit message... Attempt {attempt}/{total}"
119
+ ),
120
+ on_notice=view.show_warning,
121
+ )
122
+
123
+ try:
124
+ commit_message = CommitMessageParser.parse(raw_message)
125
+ except InvalidCommitMessageError as error:
126
+ view.show_error(error.message)
127
+ view.show_warning(
128
+ "The new suggestion could not be used. The message below is unchanged."
129
+ )
130
+ else:
131
+ pending_edit = None
132
+ view.show_generated()
133
+
134
+ view.show_commit(commit_message)
135
+ continue
136
+
137
+ if choice == "n":
138
+ logger.info("User cancelled commit.")
139
+ view.show_cancelled()
140
+ break
141
+
142
+ view.show_invalid_option()