mcp-ticketer 2.0.1__py3-none-any.whl → 2.2.13__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 mcp-ticketer might be problematic. Click here for more details.

Files changed (73) hide show
  1. mcp_ticketer/__version__.py +1 -1
  2. mcp_ticketer/_version_scm.py +1 -0
  3. mcp_ticketer/adapters/aitrackdown.py +122 -0
  4. mcp_ticketer/adapters/asana/adapter.py +121 -0
  5. mcp_ticketer/adapters/github/__init__.py +26 -0
  6. mcp_ticketer/adapters/{github.py → github/adapter.py} +1506 -365
  7. mcp_ticketer/adapters/github/client.py +335 -0
  8. mcp_ticketer/adapters/github/mappers.py +797 -0
  9. mcp_ticketer/adapters/github/queries.py +692 -0
  10. mcp_ticketer/adapters/github/types.py +460 -0
  11. mcp_ticketer/adapters/jira/__init__.py +35 -0
  12. mcp_ticketer/adapters/{jira.py → jira/adapter.py} +250 -678
  13. mcp_ticketer/adapters/jira/client.py +271 -0
  14. mcp_ticketer/adapters/jira/mappers.py +246 -0
  15. mcp_ticketer/adapters/jira/queries.py +216 -0
  16. mcp_ticketer/adapters/jira/types.py +304 -0
  17. mcp_ticketer/adapters/linear/adapter.py +1000 -92
  18. mcp_ticketer/adapters/linear/client.py +91 -1
  19. mcp_ticketer/adapters/linear/mappers.py +107 -0
  20. mcp_ticketer/adapters/linear/queries.py +112 -2
  21. mcp_ticketer/adapters/linear/types.py +50 -10
  22. mcp_ticketer/cli/configure.py +524 -89
  23. mcp_ticketer/cli/install_mcp_server.py +418 -0
  24. mcp_ticketer/cli/main.py +10 -0
  25. mcp_ticketer/cli/mcp_configure.py +177 -49
  26. mcp_ticketer/cli/platform_installer.py +9 -0
  27. mcp_ticketer/cli/setup_command.py +157 -1
  28. mcp_ticketer/cli/ticket_commands.py +443 -81
  29. mcp_ticketer/cli/utils.py +113 -0
  30. mcp_ticketer/core/__init__.py +28 -0
  31. mcp_ticketer/core/adapter.py +367 -1
  32. mcp_ticketer/core/milestone_manager.py +252 -0
  33. mcp_ticketer/core/models.py +345 -0
  34. mcp_ticketer/core/project_utils.py +281 -0
  35. mcp_ticketer/core/project_validator.py +376 -0
  36. mcp_ticketer/core/session_state.py +6 -1
  37. mcp_ticketer/core/state_matcher.py +36 -3
  38. mcp_ticketer/mcp/server/__main__.py +2 -1
  39. mcp_ticketer/mcp/server/routing.py +68 -0
  40. mcp_ticketer/mcp/server/tools/__init__.py +7 -4
  41. mcp_ticketer/mcp/server/tools/attachment_tools.py +3 -1
  42. mcp_ticketer/mcp/server/tools/config_tools.py +233 -35
  43. mcp_ticketer/mcp/server/tools/milestone_tools.py +338 -0
  44. mcp_ticketer/mcp/server/tools/search_tools.py +30 -1
  45. mcp_ticketer/mcp/server/tools/ticket_tools.py +37 -1
  46. mcp_ticketer/queue/queue.py +68 -0
  47. {mcp_ticketer-2.0.1.dist-info → mcp_ticketer-2.2.13.dist-info}/METADATA +33 -3
  48. {mcp_ticketer-2.0.1.dist-info → mcp_ticketer-2.2.13.dist-info}/RECORD +72 -36
  49. mcp_ticketer-2.2.13.dist-info/top_level.txt +2 -0
  50. py_mcp_installer/examples/phase3_demo.py +178 -0
  51. py_mcp_installer/scripts/manage_version.py +54 -0
  52. py_mcp_installer/setup.py +6 -0
  53. py_mcp_installer/src/py_mcp_installer/__init__.py +153 -0
  54. py_mcp_installer/src/py_mcp_installer/command_builder.py +445 -0
  55. py_mcp_installer/src/py_mcp_installer/config_manager.py +541 -0
  56. py_mcp_installer/src/py_mcp_installer/exceptions.py +243 -0
  57. py_mcp_installer/src/py_mcp_installer/installation_strategy.py +617 -0
  58. py_mcp_installer/src/py_mcp_installer/installer.py +656 -0
  59. py_mcp_installer/src/py_mcp_installer/mcp_inspector.py +750 -0
  60. py_mcp_installer/src/py_mcp_installer/platform_detector.py +451 -0
  61. py_mcp_installer/src/py_mcp_installer/platforms/__init__.py +26 -0
  62. py_mcp_installer/src/py_mcp_installer/platforms/claude_code.py +225 -0
  63. py_mcp_installer/src/py_mcp_installer/platforms/codex.py +181 -0
  64. py_mcp_installer/src/py_mcp_installer/platforms/cursor.py +191 -0
  65. py_mcp_installer/src/py_mcp_installer/types.py +222 -0
  66. py_mcp_installer/src/py_mcp_installer/utils.py +463 -0
  67. py_mcp_installer/tests/__init__.py +0 -0
  68. py_mcp_installer/tests/platforms/__init__.py +0 -0
  69. py_mcp_installer/tests/test_platform_detector.py +17 -0
  70. mcp_ticketer-2.0.1.dist-info/top_level.txt +0 -1
  71. {mcp_ticketer-2.0.1.dist-info → mcp_ticketer-2.2.13.dist-info}/WHEEL +0 -0
  72. {mcp_ticketer-2.0.1.dist-info → mcp_ticketer-2.2.13.dist-info}/entry_points.txt +0 -0
  73. {mcp_ticketer-2.0.1.dist-info → mcp_ticketer-2.2.13.dist-info}/licenses/LICENSE +0 -0
@@ -1,9 +1,12 @@
1
1
  """Interactive configuration wizard for MCP Ticketer."""
2
2
 
3
+ import json
3
4
  import os
4
5
  from collections.abc import Callable
6
+ from pathlib import Path
5
7
  from typing import Any
6
8
 
9
+ import httpx
7
10
  import typer
8
11
  from rich.console import Console
9
12
  from rich.panel import Panel
@@ -23,6 +26,205 @@ from ..core.project_config import (
23
26
  console = Console()
24
27
 
25
28
 
29
+ def _load_existing_adapter_config(adapter_type: str) -> dict[str, Any] | None:
30
+ """Load existing adapter configuration from project config if available.
31
+
32
+ Args:
33
+ ----
34
+ adapter_type: Type of adapter (linear, jira, github, aitrackdown)
35
+
36
+ Returns:
37
+ -------
38
+ Dictionary with existing configuration if available, None otherwise
39
+
40
+ """
41
+ config_path = Path.cwd() / ".mcp-ticketer" / "config.json"
42
+ if not config_path.exists():
43
+ return None
44
+
45
+ try:
46
+ with open(config_path) as f:
47
+ config = json.load(f)
48
+ return config.get("adapters", {}).get(adapter_type)
49
+ except (json.JSONDecodeError, OSError) as e:
50
+ console.print(f"[yellow]Warning: Could not load existing config: {e}[/yellow]")
51
+ return None
52
+
53
+
54
+ def _mask_sensitive_value(value: str, show_chars: int = 8) -> str:
55
+ """Mask sensitive value for display.
56
+
57
+ Args:
58
+ ----
59
+ value: The sensitive value to mask
60
+ show_chars: Number of characters to show at start (default: 8)
61
+
62
+ Returns:
63
+ -------
64
+ Masked string like "ghp_1234****" or "****" for short values
65
+
66
+ """
67
+ if not value:
68
+ return "****"
69
+ if len(value) <= show_chars:
70
+ return "****"
71
+ return f"{value[:show_chars]}****"
72
+
73
+
74
+ def _validate_api_credentials(
75
+ adapter_type: str,
76
+ credentials: dict[str, str],
77
+ max_retries: int = 3,
78
+ ) -> bool:
79
+ """Validate API credentials with real API calls.
80
+
81
+ Args:
82
+ ----
83
+ adapter_type: Type of adapter (linear, github, jira)
84
+ credentials: Dictionary with adapter-specific credentials
85
+ max_retries: Maximum retry attempts on validation failure
86
+
87
+ Returns:
88
+ -------
89
+ True if validation succeeds
90
+
91
+ Raises:
92
+ ------
93
+ typer.Exit: If user gives up after max retries
94
+
95
+ """
96
+ for attempt in range(1, max_retries + 1):
97
+ try:
98
+ if adapter_type == "linear":
99
+ # Test Linear API with viewer query
100
+ response = httpx.post(
101
+ "https://api.linear.app/graphql",
102
+ headers={"Authorization": credentials["api_key"]},
103
+ json={"query": "{ viewer { id name email } }"},
104
+ timeout=10.0,
105
+ )
106
+
107
+ if response.status_code == 200:
108
+ data = response.json()
109
+ if "data" in data and "viewer" in data["data"]:
110
+ viewer = data["data"]["viewer"]
111
+ console.print(
112
+ f"[green]✓ Linear API key verified (user: {viewer.get('name', viewer.get('email', 'Unknown'))})[/green]"
113
+ )
114
+ return True
115
+ else:
116
+ errors = data.get("errors", [])
117
+ if errors:
118
+ error_msg = errors[0].get("message", "Unknown error")
119
+ raise ValueError(f"API returned error: {error_msg}")
120
+ raise ValueError("Invalid API response format")
121
+ else:
122
+ raise ValueError(
123
+ f"API returned status {response.status_code}: {response.text}"
124
+ )
125
+
126
+ elif adapter_type == "github":
127
+ # Test GitHub API with user endpoint
128
+ response = httpx.get(
129
+ "https://api.github.com/user",
130
+ headers={
131
+ "Authorization": f"Bearer {credentials['token']}",
132
+ "Accept": "application/vnd.github+json",
133
+ "X-GitHub-Api-Version": "2022-11-28",
134
+ },
135
+ timeout=10.0,
136
+ )
137
+
138
+ if response.status_code == 200:
139
+ user_data = response.json()
140
+ login = user_data.get("login", "Unknown")
141
+ console.print(
142
+ f"[green]✓ GitHub token verified (user: {login})[/green]"
143
+ )
144
+ return True
145
+ elif response.status_code == 401:
146
+ raise ValueError("Invalid token or token has expired")
147
+ elif response.status_code == 403:
148
+ raise ValueError(
149
+ "Token lacks required permissions (need 'repo' scope)"
150
+ )
151
+ else:
152
+ raise ValueError(
153
+ f"GitHub API returned {response.status_code}: {response.text}"
154
+ )
155
+
156
+ elif adapter_type == "jira":
157
+ # Test JIRA API with myself endpoint
158
+ response = httpx.get(
159
+ f"{credentials['server'].rstrip('/')}/rest/api/2/myself",
160
+ auth=(credentials["email"], credentials["api_token"]),
161
+ headers={"Accept": "application/json"},
162
+ timeout=10.0,
163
+ )
164
+
165
+ if response.status_code == 200:
166
+ user_data = response.json()
167
+ name = user_data.get("displayName", credentials["email"])
168
+ console.print(
169
+ f"[green]✓ JIRA credentials verified (user: {name})[/green]"
170
+ )
171
+ return True
172
+ elif response.status_code == 401:
173
+ raise ValueError("Invalid credentials or API token has expired")
174
+ elif response.status_code == 404:
175
+ raise ValueError(
176
+ f"Invalid JIRA server URL: {credentials['server']}"
177
+ )
178
+ else:
179
+ raise ValueError(
180
+ f"JIRA API returned {response.status_code}: {response.text}"
181
+ )
182
+
183
+ else:
184
+ # Unknown adapter type, skip validation
185
+ console.print(
186
+ f"[yellow]Warning: No validation available for adapter '{adapter_type}'[/yellow]"
187
+ )
188
+ return True
189
+
190
+ except httpx.TimeoutException:
191
+ console.print(
192
+ f"[red]✗ API validation timed out (attempt {attempt}/{max_retries})[/red]"
193
+ )
194
+ except httpx.NetworkError as e:
195
+ console.print(
196
+ f"[red]✗ Network error during validation: {e} (attempt {attempt}/{max_retries})[/red]"
197
+ )
198
+ except ValueError as e:
199
+ console.print(
200
+ f"[red]✗ API validation failed: {e} (attempt {attempt}/{max_retries})[/red]"
201
+ )
202
+ except Exception as e:
203
+ console.print(
204
+ f"[red]✗ Unexpected error during validation: {e} (attempt {attempt}/{max_retries})[/red]"
205
+ )
206
+
207
+ # Ask user if they want to retry
208
+ if attempt < max_retries:
209
+ retry = Confirm.ask("Re-enter credentials and try again?", default=True)
210
+ if not retry:
211
+ console.print(
212
+ "[yellow]Skipping validation. Configuration saved but may not work.[/yellow]"
213
+ )
214
+ return True # Allow saving unvalidated config
215
+ else:
216
+ console.print("[red]Max retries exceeded[/red]")
217
+ final_choice = Confirm.ask(
218
+ "Save configuration anyway? (it may not work)", default=False
219
+ )
220
+ if final_choice:
221
+ console.print("[yellow]Configuration saved without validation[/yellow]")
222
+ return True
223
+ raise typer.Exit(1) from None
224
+
225
+ return False
226
+
227
+
26
228
  def _retry_setting(
27
229
  setting_name: str,
28
230
  prompt_func: Callable[[], Any],
@@ -207,6 +409,10 @@ def _configure_linear(
207
409
  if interactive:
208
410
  console.print("\n[bold cyan]Linear Configuration[/bold cyan]")
209
411
 
412
+ # Load existing configuration if available
413
+ if existing_config is None and interactive:
414
+ existing_config = _load_existing_adapter_config("linear")
415
+
210
416
  # Check if we have existing config
211
417
  has_existing = (
212
418
  existing_config is not None and existing_config.get("adapter") == "linear"
@@ -244,7 +450,7 @@ def _configure_linear(
244
450
  "[yellow]Enter new values or press Enter to keep current[/yellow]"
245
451
  )
246
452
 
247
- # API Key (programmatic mode: use provided value or env, interactive: prompt)
453
+ # API Key with validation loop
248
454
  current_key = config_dict.get("api_key", "") if has_existing else ""
249
455
  final_api_key = api_key or os.getenv("LINEAR_API_KEY") or ""
250
456
 
@@ -256,18 +462,40 @@ def _configure_linear(
256
462
  if use_env:
257
463
  current_key = final_api_key
258
464
 
259
- def prompt_api_key() -> str:
260
- if current_key:
261
- api_key_prompt = f"Linear API Key [current: {'*' * 8}...]"
262
- return Prompt.ask(api_key_prompt, password=True, default=current_key)
263
- return Prompt.ask("Linear API Key", password=True)
465
+ # Validation loop for API key
466
+ api_key_validated = False
467
+ while not api_key_validated:
264
468
 
265
- def validate_api_key(key: str) -> tuple[bool, str | None]:
266
- if not key or len(key) < 10:
267
- return False, "API key must be at least 10 characters"
268
- return True, None
469
+ def prompt_api_key() -> str:
470
+ if current_key:
471
+ masked = _mask_sensitive_value(current_key)
472
+ api_key_prompt = f"Linear API Key [current: {masked}]"
473
+ return Prompt.ask(
474
+ api_key_prompt, password=True, default=current_key
475
+ )
476
+ return Prompt.ask("Linear API Key", password=True)
477
+
478
+ def validate_api_key(key: str) -> tuple[bool, str | None]:
479
+ if not key or len(key) < 10:
480
+ return False, "API key must be at least 10 characters"
481
+ return True, None
482
+
483
+ final_api_key = _retry_setting("API Key", prompt_api_key, validate_api_key)
484
+
485
+ # Validate API key with real API call
486
+ try:
487
+ api_key_validated = _validate_api_credentials(
488
+ "linear", {"api_key": final_api_key}
489
+ )
490
+ except typer.Exit:
491
+ # User cancelled, propagate the exit
492
+ raise
493
+ except Exception as e:
494
+ console.print(f"[red]Validation error: {e}[/red]")
495
+ retry = Confirm.ask("Re-enter API key?", default=True)
496
+ if not retry:
497
+ raise typer.Exit(1) from None
269
498
 
270
- final_api_key = _retry_setting("API Key", prompt_api_key, validate_api_key)
271
499
  elif not final_api_key:
272
500
  raise ValueError(
273
501
  "Linear API key is required (provide api_key parameter or set LINEAR_API_KEY environment variable)"
@@ -513,44 +741,112 @@ def _configure_jira(
513
741
  if interactive:
514
742
  console.print("\n[bold]Configure JIRA Integration:[/bold]")
515
743
 
516
- # Server URL (programmatic mode: use provided value or env, interactive: prompt)
744
+ # Load existing configuration if available
745
+ existing_config = _load_existing_adapter_config("jira") if interactive else None
746
+ has_existing = existing_config is not None
747
+
748
+ # Server URL (with existing value as default)
749
+ existing_server = existing_config.get("server", "") if has_existing else ""
517
750
  final_server = server or os.getenv("JIRA_SERVER") or ""
518
- if interactive and not final_server:
519
- final_server = Prompt.ask(
520
- "JIRA Server URL (e.g., https://company.atlassian.net)"
521
- )
751
+
752
+ if interactive:
753
+ if not final_server and existing_server:
754
+ final_server = Prompt.ask(
755
+ f"JIRA Server URL [current: {existing_server}]",
756
+ default=existing_server,
757
+ )
758
+ elif not final_server:
759
+ final_server = Prompt.ask(
760
+ "JIRA Server URL (e.g., https://company.atlassian.net)"
761
+ )
522
762
  elif not interactive and not final_server:
523
763
  raise ValueError(
524
764
  "JIRA server URL is required (provide server parameter or set JIRA_SERVER environment variable)"
525
765
  )
526
766
 
527
- # Email (programmatic mode: use provided value or env, interactive: prompt)
767
+ # Email (with existing value as default)
768
+ existing_email = existing_config.get("email", "") if has_existing else ""
528
769
  final_email = email or os.getenv("JIRA_EMAIL") or ""
529
- if interactive and not final_email:
530
- final_email = Prompt.ask("JIRA User Email")
770
+
771
+ if interactive:
772
+ if not final_email and existing_email:
773
+ final_email = Prompt.ask(
774
+ f"JIRA User Email [current: {existing_email}]",
775
+ default=existing_email,
776
+ )
777
+ elif not final_email:
778
+ final_email = Prompt.ask("JIRA User Email")
531
779
  elif not interactive and not final_email:
532
780
  raise ValueError(
533
781
  "JIRA email is required (provide email parameter or set JIRA_EMAIL environment variable)"
534
782
  )
535
783
 
536
- # API Token (programmatic mode: use provided value or env, interactive: prompt)
784
+ # API Token with validation loop
785
+ existing_token = existing_config.get("api_token", "") if has_existing else ""
537
786
  final_api_token = api_token or os.getenv("JIRA_API_TOKEN") or ""
538
- if interactive and not final_api_token:
539
- console.print(
540
- "[dim]Generate token at: https://id.atlassian.com/manage/api-tokens[/dim]"
541
- )
542
- final_api_token = Prompt.ask("JIRA API Token", password=True)
787
+
788
+ if interactive:
789
+ # Validation loop for JIRA credentials
790
+ jira_validated = False
791
+ while not jira_validated:
792
+ if not final_api_token and existing_token:
793
+ masked = _mask_sensitive_value(existing_token)
794
+ console.print(
795
+ "[dim]Generate token at: https://id.atlassian.com/manage/api-tokens[/dim]"
796
+ )
797
+ final_api_token = Prompt.ask(
798
+ f"JIRA API Token [current: {masked}]",
799
+ password=True,
800
+ default=existing_token,
801
+ )
802
+ elif not final_api_token:
803
+ console.print(
804
+ "[dim]Generate token at: https://id.atlassian.com/manage/api-tokens[/dim]"
805
+ )
806
+ final_api_token = Prompt.ask("JIRA API Token", password=True)
807
+
808
+ # Validate JIRA credentials with real API call
809
+ try:
810
+ jira_validated = _validate_api_credentials(
811
+ "jira",
812
+ {
813
+ "server": final_server,
814
+ "email": final_email,
815
+ "api_token": final_api_token,
816
+ },
817
+ )
818
+ except typer.Exit:
819
+ # User cancelled, propagate the exit
820
+ raise
821
+ except Exception as e:
822
+ console.print(f"[red]Validation error: {e}[/red]")
823
+ retry = Confirm.ask("Re-enter credentials?", default=True)
824
+ if not retry:
825
+ raise typer.Exit(1) from None
826
+ # Reset to prompt again
827
+ final_api_token = ""
828
+
543
829
  elif not interactive and not final_api_token:
544
830
  raise ValueError(
545
831
  "JIRA API token is required (provide api_token parameter or set JIRA_API_TOKEN environment variable)"
546
832
  )
547
833
 
548
- # Project Key (optional)
834
+ # Project Key (optional, with existing value as default)
835
+ existing_project_key = (
836
+ existing_config.get("project_key", "") if has_existing else ""
837
+ )
549
838
  final_project_key = project_key or os.getenv("JIRA_PROJECT_KEY") or ""
550
- if interactive and not final_project_key:
551
- final_project_key = Prompt.ask(
552
- "Default Project Key (optional, e.g., PROJ)", default=""
553
- )
839
+
840
+ if interactive:
841
+ if not final_project_key and existing_project_key:
842
+ final_project_key = Prompt.ask(
843
+ f"Default Project Key (optional, e.g., PROJ) [current: {existing_project_key}]",
844
+ default=existing_project_key,
845
+ )
846
+ elif not final_project_key:
847
+ final_project_key = Prompt.ask(
848
+ "Default Project Key (optional, e.g., PROJ)", default=""
849
+ )
554
850
 
555
851
  config_dict = {
556
852
  "adapter": AdapterType.JIRA.value,
@@ -580,11 +876,18 @@ def _configure_jira(
580
876
  console.print("Configure default values for ticket creation:")
581
877
 
582
878
  # Default user/assignee
583
- user_input = Prompt.ask(
584
- "Default assignee/user (optional, JIRA username or email)",
585
- default="",
586
- show_default=False,
587
- )
879
+ existing_user = existing_config.get("user_email", "") if has_existing else ""
880
+ if existing_user:
881
+ user_input = Prompt.ask(
882
+ f"Default assignee/user (optional, JIRA username or email) [current: {existing_user}]",
883
+ default=existing_user,
884
+ )
885
+ else:
886
+ user_input = Prompt.ask(
887
+ "Default assignee/user (optional, JIRA username or email)",
888
+ default="",
889
+ show_default=False,
890
+ )
588
891
  if user_input:
589
892
  default_values["default_user"] = user_input
590
893
  console.print(
@@ -592,11 +895,18 @@ def _configure_jira(
592
895
  )
593
896
 
594
897
  # Default epic/project
595
- epic_input = Prompt.ask(
596
- "Default epic/project ID (optional, e.g., 'PROJ-123')",
597
- default="",
598
- show_default=False,
599
- )
898
+ existing_epic = existing_config.get("default_epic", "") if has_existing else ""
899
+ if existing_epic:
900
+ epic_input = Prompt.ask(
901
+ f"Default epic/project ID (optional, e.g., 'PROJ-123') [current: {existing_epic}]",
902
+ default=existing_epic,
903
+ )
904
+ else:
905
+ epic_input = Prompt.ask(
906
+ "Default epic/project ID (optional, e.g., 'PROJ-123')",
907
+ default="",
908
+ show_default=False,
909
+ )
600
910
  if epic_input:
601
911
  default_values["default_epic"] = epic_input
602
912
  default_values["default_project"] = epic_input # Compatibility
@@ -605,11 +915,19 @@ def _configure_jira(
605
915
  )
606
916
 
607
917
  # Default tags
608
- tags_input = Prompt.ask(
609
- "Default tags/labels (optional, comma-separated, e.g., 'bug,urgent')",
610
- default="",
611
- show_default=False,
612
- )
918
+ existing_tags = existing_config.get("default_tags", []) if has_existing else []
919
+ existing_tags_str = ", ".join(existing_tags) if existing_tags else ""
920
+ if existing_tags_str:
921
+ tags_input = Prompt.ask(
922
+ f"Default tags/labels (optional, comma-separated, e.g., 'bug,urgent') [current: {existing_tags_str}]",
923
+ default=existing_tags_str,
924
+ )
925
+ else:
926
+ tags_input = Prompt.ask(
927
+ "Default tags/labels (optional, comma-separated, e.g., 'bug,urgent')",
928
+ default="",
929
+ show_default=False,
930
+ )
613
931
  if tags_input:
614
932
  tags_list = [t.strip() for t in tags_input.split(",") if t.strip()]
615
933
  if tags_list:
@@ -650,30 +968,74 @@ def _configure_github(
650
968
  if interactive:
651
969
  console.print("\n[bold]Configure GitHub Integration:[/bold]")
652
970
 
653
- # Token (programmatic mode: use provided value or env, interactive: prompt)
971
+ # Load existing configuration if available
972
+ existing_config = _load_existing_adapter_config("github") if interactive else None
973
+ has_existing = existing_config is not None
974
+
975
+ # Token with validation loop
976
+ existing_token = existing_config.get("token", "") if has_existing else ""
654
977
  final_token = token or os.getenv("GITHUB_TOKEN") or ""
978
+
655
979
  if interactive:
656
- if final_token:
657
- console.print("[dim]Found GITHUB_TOKEN in environment[/dim]")
658
- use_env = Confirm.ask("Use this token?", default=True)
659
- if not use_env:
980
+ github_validated = False
981
+ while not github_validated:
982
+ if final_token and not existing_token:
983
+ console.print("[dim]Found GITHUB_TOKEN in environment[/dim]")
984
+ use_env = Confirm.ask("Use this token?", default=True)
985
+ if not use_env:
986
+ final_token = ""
987
+
988
+ if not final_token:
989
+ if existing_token:
990
+ # Show masked existing token as default
991
+ masked = _mask_sensitive_value(existing_token)
992
+ console.print(
993
+ "[dim]Create token at: https://github.com/settings/tokens/new[/dim]"
994
+ )
995
+ console.print(
996
+ "[dim]Required scopes: repo (or public_repo for public repos)[/dim]"
997
+ )
998
+ final_token = Prompt.ask(
999
+ f"GitHub Personal Access Token [current: {masked}]",
1000
+ password=True,
1001
+ default=existing_token,
1002
+ )
1003
+ else:
1004
+ console.print(
1005
+ "[dim]Create token at: https://github.com/settings/tokens/new[/dim]"
1006
+ )
1007
+ console.print(
1008
+ "[dim]Required scopes: repo (or public_repo for public repos)[/dim]"
1009
+ )
1010
+ final_token = Prompt.ask(
1011
+ "GitHub Personal Access Token", password=True
1012
+ )
1013
+
1014
+ # Validate GitHub token with real API call
1015
+ try:
1016
+ github_validated = _validate_api_credentials(
1017
+ "github", {"token": final_token}
1018
+ )
1019
+ except typer.Exit:
1020
+ # User cancelled, propagate the exit
1021
+ raise
1022
+ except Exception as e:
1023
+ console.print(f"[red]Validation error: {e}[/red]")
1024
+ retry = Confirm.ask("Re-enter token?", default=True)
1025
+ if not retry:
1026
+ raise typer.Exit(1) from None
1027
+ # Reset to prompt again
660
1028
  final_token = ""
661
1029
 
662
- if not final_token:
663
- console.print(
664
- "[dim]Create token at: https://github.com/settings/tokens/new[/dim]"
665
- )
666
- console.print(
667
- "[dim]Required scopes: repo (or public_repo for public repos)[/dim]"
668
- )
669
- final_token = Prompt.ask("GitHub Personal Access Token", password=True)
670
1030
  elif not final_token:
671
1031
  raise ValueError(
672
1032
  "GitHub token is required (provide token parameter or set GITHUB_TOKEN environment variable)"
673
1033
  )
674
1034
 
675
1035
  # Repository URL/Owner/Repo - Prioritize repo_url, fallback to owner/repo
676
- # Programmatic mode: use repo_url or GITHUB_REPO_URL env, fallback to owner/repo
1036
+ existing_owner = existing_config.get("owner", "") if has_existing else ""
1037
+ existing_repo = existing_config.get("repo", "") if has_existing else ""
1038
+
677
1039
  final_owner = ""
678
1040
  final_repo = ""
679
1041
 
@@ -705,13 +1067,24 @@ def _configure_github(
705
1067
  "[dim]Enter your GitHub repository URL (e.g., https://github.com/owner/repo)[/dim]"
706
1068
  )
707
1069
 
1070
+ # Show existing as default if available
1071
+ if existing_owner and existing_repo:
1072
+ existing_url = f"https://github.com/{existing_owner}/{existing_repo}"
1073
+ console.print(f"[dim]Current repository: {existing_url}[/dim]")
1074
+
708
1075
  # Keep prompting until we get a valid URL
709
1076
  while not final_owner or not final_repo:
710
1077
  from ..core.url_parser import parse_github_repo_url
711
1078
 
1079
+ default_prompt = (
1080
+ f"https://github.com/{existing_owner}/{existing_repo}"
1081
+ if existing_owner and existing_repo
1082
+ else "https://github.com/"
1083
+ )
1084
+
712
1085
  url_prompt = Prompt.ask(
713
1086
  "GitHub Repository URL",
714
- default="https://github.com/",
1087
+ default=default_prompt,
715
1088
  )
716
1089
 
717
1090
  parsed_owner, parsed_repo, error = parse_github_repo_url(url_prompt)
@@ -774,11 +1147,18 @@ def _configure_github(
774
1147
  console.print("Configure default values for ticket creation:")
775
1148
 
776
1149
  # Default user/assignee
777
- user_input = Prompt.ask(
778
- "Default assignee/user (optional, GitHub username)",
779
- default="",
780
- show_default=False,
781
- )
1150
+ existing_user = existing_config.get("user_email", "") if has_existing else ""
1151
+ if existing_user:
1152
+ user_input = Prompt.ask(
1153
+ f"Default assignee/user (optional, GitHub username) [current: {existing_user}]",
1154
+ default=existing_user,
1155
+ )
1156
+ else:
1157
+ user_input = Prompt.ask(
1158
+ "Default assignee/user (optional, GitHub username)",
1159
+ default="",
1160
+ show_default=False,
1161
+ )
782
1162
  if user_input:
783
1163
  default_values["default_user"] = user_input
784
1164
  console.print(
@@ -786,11 +1166,18 @@ def _configure_github(
786
1166
  )
787
1167
 
788
1168
  # Default epic/project (milestone for GitHub)
789
- epic_input = Prompt.ask(
790
- "Default milestone/project (optional, e.g., 'v1.0' or milestone number)",
791
- default="",
792
- show_default=False,
793
- )
1169
+ existing_epic = existing_config.get("default_epic", "") if has_existing else ""
1170
+ if existing_epic:
1171
+ epic_input = Prompt.ask(
1172
+ f"Default milestone/project (optional, e.g., 'v1.0' or milestone number) [current: {existing_epic}]",
1173
+ default=existing_epic,
1174
+ )
1175
+ else:
1176
+ epic_input = Prompt.ask(
1177
+ "Default milestone/project (optional, e.g., 'v1.0' or milestone number)",
1178
+ default="",
1179
+ show_default=False,
1180
+ )
794
1181
  if epic_input:
795
1182
  default_values["default_epic"] = epic_input
796
1183
  default_values["default_project"] = epic_input # Compatibility
@@ -799,11 +1186,19 @@ def _configure_github(
799
1186
  )
800
1187
 
801
1188
  # Default tags (labels for GitHub)
802
- tags_input = Prompt.ask(
803
- "Default labels (optional, comma-separated, e.g., 'bug,enhancement')",
804
- default="",
805
- show_default=False,
806
- )
1189
+ existing_tags = existing_config.get("default_tags", []) if has_existing else []
1190
+ existing_tags_str = ", ".join(existing_tags) if existing_tags else ""
1191
+ if existing_tags_str:
1192
+ tags_input = Prompt.ask(
1193
+ f"Default labels (optional, comma-separated, e.g., 'bug,enhancement') [current: {existing_tags_str}]",
1194
+ default=existing_tags_str,
1195
+ )
1196
+ else:
1197
+ tags_input = Prompt.ask(
1198
+ "Default labels (optional, comma-separated, e.g., 'bug,enhancement')",
1199
+ default="",
1200
+ show_default=False,
1201
+ )
807
1202
  if tags_input:
808
1203
  tags_list = [t.strip() for t in tags_input.split(",") if t.strip()]
809
1204
  if tags_list:
@@ -838,12 +1233,28 @@ def _configure_aitrackdown(
838
1233
  if interactive:
839
1234
  console.print("\n[bold]Configure AITrackdown (File-based):[/bold]")
840
1235
 
841
- # Base path (programmatic mode: use provided value or default, interactive: prompt)
842
- final_base_path = base_path or ".aitrackdown"
1236
+ # Load existing configuration if available
1237
+ existing_config = (
1238
+ _load_existing_adapter_config("aitrackdown") if interactive else None
1239
+ )
1240
+ has_existing = existing_config is not None
1241
+
1242
+ # Base path with existing value as default
1243
+ existing_base_path = (
1244
+ existing_config.get("base_path", ".aitrackdown") if has_existing else ""
1245
+ )
1246
+ final_base_path = base_path or existing_base_path or ".aitrackdown"
1247
+
843
1248
  if interactive:
844
- final_base_path = Prompt.ask(
845
- "Base path for ticket storage", default=".aitrackdown"
846
- )
1249
+ if existing_base_path:
1250
+ final_base_path = Prompt.ask(
1251
+ f"Base path for ticket storage [current: {existing_base_path}]",
1252
+ default=existing_base_path,
1253
+ )
1254
+ else:
1255
+ final_base_path = Prompt.ask(
1256
+ "Base path for ticket storage", default=".aitrackdown"
1257
+ )
847
1258
 
848
1259
  config_dict = {
849
1260
  "adapter": AdapterType.AITRACKDOWN.value,
@@ -860,9 +1271,16 @@ def _configure_aitrackdown(
860
1271
  console.print("Configure default values for ticket creation:")
861
1272
 
862
1273
  # Default user/assignee
863
- user_input = Prompt.ask(
864
- "Default assignee/user (optional)", default="", show_default=False
865
- )
1274
+ existing_user = existing_config.get("user_email", "") if has_existing else ""
1275
+ if existing_user:
1276
+ user_input = Prompt.ask(
1277
+ f"Default assignee/user (optional) [current: {existing_user}]",
1278
+ default=existing_user,
1279
+ )
1280
+ else:
1281
+ user_input = Prompt.ask(
1282
+ "Default assignee/user (optional)", default="", show_default=False
1283
+ )
866
1284
  if user_input:
867
1285
  default_values["default_user"] = user_input
868
1286
  console.print(
@@ -870,9 +1288,16 @@ def _configure_aitrackdown(
870
1288
  )
871
1289
 
872
1290
  # Default epic/project
873
- epic_input = Prompt.ask(
874
- "Default epic/project ID (optional)", default="", show_default=False
875
- )
1291
+ existing_epic = existing_config.get("default_epic", "") if has_existing else ""
1292
+ if existing_epic:
1293
+ epic_input = Prompt.ask(
1294
+ f"Default epic/project ID (optional) [current: {existing_epic}]",
1295
+ default=existing_epic,
1296
+ )
1297
+ else:
1298
+ epic_input = Prompt.ask(
1299
+ "Default epic/project ID (optional)", default="", show_default=False
1300
+ )
876
1301
  if epic_input:
877
1302
  default_values["default_epic"] = epic_input
878
1303
  default_values["default_project"] = epic_input # Compatibility
@@ -881,9 +1306,19 @@ def _configure_aitrackdown(
881
1306
  )
882
1307
 
883
1308
  # Default tags
884
- tags_input = Prompt.ask(
885
- "Default tags (optional, comma-separated)", default="", show_default=False
886
- )
1309
+ existing_tags = existing_config.get("default_tags", []) if has_existing else []
1310
+ existing_tags_str = ", ".join(existing_tags) if existing_tags else ""
1311
+ if existing_tags_str:
1312
+ tags_input = Prompt.ask(
1313
+ f"Default tags (optional, comma-separated) [current: {existing_tags_str}]",
1314
+ default=existing_tags_str,
1315
+ )
1316
+ else:
1317
+ tags_input = Prompt.ask(
1318
+ "Default tags (optional, comma-separated)",
1319
+ default="",
1320
+ show_default=False,
1321
+ )
887
1322
  if tags_input:
888
1323
  tags_list = [t.strip() for t in tags_input.split(",") if t.strip()]
889
1324
  if tags_list: