mcp-ticketer 0.1.8__py3-none-any.whl → 0.1.12__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.

@@ -0,0 +1,532 @@
1
+ """Interactive configuration wizard for MCP Ticketer."""
2
+
3
+ import os
4
+ import sys
5
+ from typing import Optional, Dict, Any
6
+ from pathlib import Path
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.prompt import Prompt, Confirm
11
+ from rich.panel import Panel
12
+ from rich.table import Table
13
+
14
+ from ..core.project_config import (
15
+ ConfigResolver,
16
+ TicketerConfig,
17
+ AdapterConfig,
18
+ ProjectConfig,
19
+ HybridConfig,
20
+ AdapterType,
21
+ SyncStrategy,
22
+ ConfigValidator
23
+ )
24
+
25
+ console = Console()
26
+
27
+
28
+ def configure_wizard() -> None:
29
+ """Run interactive configuration wizard."""
30
+ console.print(Panel.fit(
31
+ "[bold cyan]MCP-Ticketer Configuration Wizard[/bold cyan]\n"
32
+ "Configure your ticketing system integration",
33
+ border_style="cyan"
34
+ ))
35
+
36
+ # Step 1: Choose integration mode
37
+ console.print("\n[bold]Step 1: Integration Mode[/bold]")
38
+ console.print("1. Single Adapter (recommended for most projects)")
39
+ console.print("2. Hybrid Mode (sync across multiple platforms)")
40
+
41
+ mode = Prompt.ask(
42
+ "Select mode",
43
+ choices=["1", "2"],
44
+ default="1"
45
+ )
46
+
47
+ if mode == "1":
48
+ config = _configure_single_adapter()
49
+ else:
50
+ config = _configure_hybrid_mode()
51
+
52
+ # Step 2: Choose where to save
53
+ console.print("\n[bold]Step 2: Configuration Scope[/bold]")
54
+ console.print("1. Global (all projects): ~/.mcp-ticketer/config.json")
55
+ console.print("2. Project-specific: .mcp-ticketer/config.json in project root")
56
+
57
+ scope = Prompt.ask(
58
+ "Save configuration as",
59
+ choices=["1", "2"],
60
+ default="2"
61
+ )
62
+
63
+ resolver = ConfigResolver()
64
+
65
+ if scope == "1":
66
+ # Save global
67
+ resolver.save_global_config(config)
68
+ console.print(f"\n[green]✓[/green] Configuration saved globally to {resolver.GLOBAL_CONFIG_PATH}")
69
+ else:
70
+ # Save project-specific
71
+ resolver.save_project_config(config)
72
+ config_path = resolver.project_path / resolver.PROJECT_CONFIG_SUBPATH
73
+ console.print(f"\n[green]✓[/green] Configuration saved to {config_path}")
74
+
75
+ # Show usage instructions
76
+ console.print("\n[bold]Usage:[/bold]")
77
+ console.print(" CLI: [cyan]mcp-ticketer create \"Task title\"[/cyan]")
78
+ console.print(" MCP: Configure Claude Desktop to use this adapter")
79
+ console.print("\nRun [cyan]mcp-ticketer configure --show[/cyan] to view your configuration")
80
+
81
+
82
+ def _configure_single_adapter() -> TicketerConfig:
83
+ """Configure a single adapter."""
84
+ console.print("\n[bold]Select Ticketing System:[/bold]")
85
+ console.print("1. Linear (Modern project management)")
86
+ console.print("2. JIRA (Enterprise issue tracking)")
87
+ console.print("3. GitHub Issues (Code-integrated tracking)")
88
+ console.print("4. Internal/AITrackdown (File-based, no API)")
89
+
90
+ adapter_choice = Prompt.ask(
91
+ "Select system",
92
+ choices=["1", "2", "3", "4"],
93
+ default="1"
94
+ )
95
+
96
+ adapter_type_map = {
97
+ "1": AdapterType.LINEAR,
98
+ "2": AdapterType.JIRA,
99
+ "3": AdapterType.GITHUB,
100
+ "4": AdapterType.AITRACKDOWN,
101
+ }
102
+
103
+ adapter_type = adapter_type_map[adapter_choice]
104
+
105
+ # Configure the selected adapter
106
+ if adapter_type == AdapterType.LINEAR:
107
+ adapter_config = _configure_linear()
108
+ elif adapter_type == AdapterType.JIRA:
109
+ adapter_config = _configure_jira()
110
+ elif adapter_type == AdapterType.GITHUB:
111
+ adapter_config = _configure_github()
112
+ else:
113
+ adapter_config = _configure_aitrackdown()
114
+
115
+ # Create config
116
+ config = TicketerConfig(
117
+ default_adapter=adapter_type.value,
118
+ adapters={adapter_type.value: adapter_config}
119
+ )
120
+
121
+ return config
122
+
123
+
124
+ def _configure_linear() -> AdapterConfig:
125
+ """Configure Linear adapter."""
126
+ console.print("\n[bold]Configure Linear Integration:[/bold]")
127
+
128
+ # API Key
129
+ api_key = os.getenv("LINEAR_API_KEY") or ""
130
+ if api_key:
131
+ console.print(f"[dim]Found LINEAR_API_KEY in environment[/dim]")
132
+ use_env = Confirm.ask("Use this API key?", default=True)
133
+ if not use_env:
134
+ api_key = ""
135
+
136
+ if not api_key:
137
+ api_key = Prompt.ask(
138
+ "Linear API Key",
139
+ password=True
140
+ )
141
+
142
+ # Team ID
143
+ team_id = Prompt.ask(
144
+ "Team ID (optional, e.g., team-abc)",
145
+ default=""
146
+ )
147
+
148
+ # Team Key
149
+ team_key = Prompt.ask(
150
+ "Team Key (optional, e.g., ENG)",
151
+ default=""
152
+ )
153
+
154
+ # Project ID
155
+ project_id = Prompt.ask(
156
+ "Project ID (optional)",
157
+ default=""
158
+ )
159
+
160
+ config_dict = {
161
+ "adapter": AdapterType.LINEAR.value,
162
+ "api_key": api_key,
163
+ }
164
+
165
+ if team_id:
166
+ config_dict["team_id"] = team_id
167
+ if team_key:
168
+ config_dict["team_key"] = team_key
169
+ if project_id:
170
+ config_dict["project_id"] = project_id
171
+
172
+ # Validate
173
+ is_valid, error = ConfigValidator.validate_linear_config(config_dict)
174
+ if not is_valid:
175
+ console.print(f"[red]Configuration error: {error}[/red]")
176
+ raise typer.Exit(1)
177
+
178
+ return AdapterConfig.from_dict(config_dict)
179
+
180
+
181
+ def _configure_jira() -> AdapterConfig:
182
+ """Configure JIRA adapter."""
183
+ console.print("\n[bold]Configure JIRA Integration:[/bold]")
184
+
185
+ # Server URL
186
+ server = os.getenv("JIRA_SERVER") or ""
187
+ if not server:
188
+ server = Prompt.ask(
189
+ "JIRA Server URL (e.g., https://company.atlassian.net)"
190
+ )
191
+
192
+ # Email
193
+ email = os.getenv("JIRA_EMAIL") or ""
194
+ if not email:
195
+ email = Prompt.ask("JIRA User Email")
196
+
197
+ # API Token
198
+ api_token = os.getenv("JIRA_API_TOKEN") or ""
199
+ if not api_token:
200
+ console.print("[dim]Generate token at: https://id.atlassian.com/manage/api-tokens[/dim]")
201
+ api_token = Prompt.ask(
202
+ "JIRA API Token",
203
+ password=True
204
+ )
205
+
206
+ # Project Key
207
+ project_key = Prompt.ask(
208
+ "Default Project Key (optional, e.g., PROJ)",
209
+ default=""
210
+ )
211
+
212
+ config_dict = {
213
+ "adapter": AdapterType.JIRA.value,
214
+ "server": server.rstrip('/'),
215
+ "email": email,
216
+ "api_token": api_token,
217
+ }
218
+
219
+ if project_key:
220
+ config_dict["project_key"] = project_key
221
+
222
+ # Validate
223
+ is_valid, error = ConfigValidator.validate_jira_config(config_dict)
224
+ if not is_valid:
225
+ console.print(f"[red]Configuration error: {error}[/red]")
226
+ raise typer.Exit(1)
227
+
228
+ return AdapterConfig.from_dict(config_dict)
229
+
230
+
231
+ def _configure_github() -> AdapterConfig:
232
+ """Configure GitHub adapter."""
233
+ console.print("\n[bold]Configure GitHub Integration:[/bold]")
234
+
235
+ # Token
236
+ token = os.getenv("GITHUB_TOKEN") or ""
237
+ if token:
238
+ console.print(f"[dim]Found GITHUB_TOKEN in environment[/dim]")
239
+ use_env = Confirm.ask("Use this token?", default=True)
240
+ if not use_env:
241
+ token = ""
242
+
243
+ if not token:
244
+ console.print("[dim]Create token at: https://github.com/settings/tokens/new[/dim]")
245
+ console.print("[dim]Required scopes: repo (or public_repo for public repos)[/dim]")
246
+ token = Prompt.ask(
247
+ "GitHub Personal Access Token",
248
+ password=True
249
+ )
250
+
251
+ # Repository Owner
252
+ owner = os.getenv("GITHUB_OWNER") or ""
253
+ if not owner:
254
+ owner = Prompt.ask("Repository Owner (username or org)")
255
+
256
+ # Repository Name
257
+ repo = os.getenv("GITHUB_REPO") or ""
258
+ if not repo:
259
+ repo = Prompt.ask("Repository Name")
260
+
261
+ config_dict = {
262
+ "adapter": AdapterType.GITHUB.value,
263
+ "token": token,
264
+ "owner": owner,
265
+ "repo": repo,
266
+ "project_id": f"{owner}/{repo}", # Convenience field
267
+ }
268
+
269
+ # Validate
270
+ is_valid, error = ConfigValidator.validate_github_config(config_dict)
271
+ if not is_valid:
272
+ console.print(f"[red]Configuration error: {error}[/red]")
273
+ raise typer.Exit(1)
274
+
275
+ return AdapterConfig.from_dict(config_dict)
276
+
277
+
278
+ def _configure_aitrackdown() -> AdapterConfig:
279
+ """Configure AITrackdown adapter."""
280
+ console.print("\n[bold]Configure AITrackdown (File-based):[/bold]")
281
+
282
+ base_path = Prompt.ask(
283
+ "Base path for ticket storage",
284
+ default=".aitrackdown"
285
+ )
286
+
287
+ config_dict = {
288
+ "adapter": AdapterType.AITRACKDOWN.value,
289
+ "base_path": base_path,
290
+ }
291
+
292
+ return AdapterConfig.from_dict(config_dict)
293
+
294
+
295
+ def _configure_hybrid_mode() -> TicketerConfig:
296
+ """Configure hybrid mode with multiple adapters."""
297
+ console.print("\n[bold]Hybrid Mode Configuration[/bold]")
298
+ console.print("Sync tickets across multiple platforms")
299
+
300
+ # Select adapters
301
+ console.print("\n[bold]Select adapters to sync (comma-separated):[/bold]")
302
+ console.print("1. Linear")
303
+ console.print("2. JIRA")
304
+ console.print("3. GitHub")
305
+ console.print("4. AITrackdown")
306
+
307
+ selections = Prompt.ask(
308
+ "Select adapters (e.g., 1,3 for Linear and GitHub)",
309
+ default="1,3"
310
+ )
311
+
312
+ adapter_choices = [s.strip() for s in selections.split(",")]
313
+
314
+ adapter_type_map = {
315
+ "1": AdapterType.LINEAR,
316
+ "2": AdapterType.JIRA,
317
+ "3": AdapterType.GITHUB,
318
+ "4": AdapterType.AITRACKDOWN,
319
+ }
320
+
321
+ selected_adapters = [adapter_type_map[c] for c in adapter_choices if c in adapter_type_map]
322
+
323
+ if len(selected_adapters) < 2:
324
+ console.print("[red]Hybrid mode requires at least 2 adapters[/red]")
325
+ raise typer.Exit(1)
326
+
327
+ # Configure each adapter
328
+ adapters = {}
329
+ for adapter_type in selected_adapters:
330
+ console.print(f"\n[cyan]Configuring {adapter_type.value}...[/cyan]")
331
+
332
+ if adapter_type == AdapterType.LINEAR:
333
+ adapter_config = _configure_linear()
334
+ elif adapter_type == AdapterType.JIRA:
335
+ adapter_config = _configure_jira()
336
+ elif adapter_type == AdapterType.GITHUB:
337
+ adapter_config = _configure_github()
338
+ else:
339
+ adapter_config = _configure_aitrackdown()
340
+
341
+ adapters[adapter_type.value] = adapter_config
342
+
343
+ # Select primary adapter
344
+ console.print("\n[bold]Select primary adapter (source of truth):[/bold]")
345
+ for idx, adapter_type in enumerate(selected_adapters, 1):
346
+ console.print(f"{idx}. {adapter_type.value}")
347
+
348
+ primary_idx = int(Prompt.ask(
349
+ "Primary adapter",
350
+ choices=[str(i) for i in range(1, len(selected_adapters) + 1)],
351
+ default="1"
352
+ ))
353
+
354
+ primary_adapter = selected_adapters[primary_idx - 1].value
355
+
356
+ # Select sync strategy
357
+ console.print("\n[bold]Select sync strategy:[/bold]")
358
+ console.print("1. Primary Source (one-way: primary → others)")
359
+ console.print("2. Bidirectional (two-way sync)")
360
+ console.print("3. Mirror (clone tickets across all)")
361
+
362
+ strategy_choice = Prompt.ask(
363
+ "Sync strategy",
364
+ choices=["1", "2", "3"],
365
+ default="1"
366
+ )
367
+
368
+ strategy_map = {
369
+ "1": SyncStrategy.PRIMARY_SOURCE,
370
+ "2": SyncStrategy.BIDIRECTIONAL,
371
+ "3": SyncStrategy.MIRROR,
372
+ }
373
+
374
+ sync_strategy = strategy_map[strategy_choice]
375
+
376
+ # Create hybrid config
377
+ hybrid_config = HybridConfig(
378
+ enabled=True,
379
+ adapters=[a.value for a in selected_adapters],
380
+ primary_adapter=primary_adapter,
381
+ sync_strategy=sync_strategy
382
+ )
383
+
384
+ # Create full config
385
+ config = TicketerConfig(
386
+ default_adapter=primary_adapter,
387
+ adapters=adapters,
388
+ hybrid_mode=hybrid_config
389
+ )
390
+
391
+ return config
392
+
393
+
394
+ def show_current_config() -> None:
395
+ """Show current configuration."""
396
+ resolver = ConfigResolver()
397
+
398
+ # Try to load configs
399
+ global_config = resolver.load_global_config()
400
+ project_config = resolver.load_project_config()
401
+
402
+ console.print("[bold]Current Configuration:[/bold]\n")
403
+
404
+ # Global config
405
+ if resolver.GLOBAL_CONFIG_PATH.exists():
406
+ console.print(f"[cyan]Global:[/cyan] {resolver.GLOBAL_CONFIG_PATH}")
407
+ console.print(f" Default adapter: {global_config.default_adapter}")
408
+
409
+ if global_config.adapters:
410
+ table = Table(title="Global Adapters")
411
+ table.add_column("Adapter", style="cyan")
412
+ table.add_column("Configured", style="green")
413
+
414
+ for name, config in global_config.adapters.items():
415
+ configured = "✓" if config.enabled else "✗"
416
+ table.add_row(name, configured)
417
+
418
+ console.print(table)
419
+ else:
420
+ console.print("[yellow]No global configuration found[/yellow]")
421
+
422
+ # Project config
423
+ console.print()
424
+ project_config_path = resolver.project_path / resolver.PROJECT_CONFIG_SUBPATH
425
+ if project_config_path.exists():
426
+ console.print(f"[cyan]Project:[/cyan] {project_config_path}")
427
+ if project_config:
428
+ console.print(f" Default adapter: {project_config.default_adapter}")
429
+
430
+ if project_config.adapters:
431
+ table = Table(title="Project Adapters")
432
+ table.add_column("Adapter", style="cyan")
433
+ table.add_column("Configured", style="green")
434
+
435
+ for name, config in project_config.adapters.items():
436
+ configured = "✓" if config.enabled else "✗"
437
+ table.add_row(name, configured)
438
+
439
+ console.print(table)
440
+
441
+ if project_config.hybrid_mode and project_config.hybrid_mode.enabled:
442
+ console.print("\n[bold]Hybrid Mode:[/bold] Enabled")
443
+ console.print(f" Adapters: {', '.join(project_config.hybrid_mode.adapters)}")
444
+ console.print(f" Primary: {project_config.hybrid_mode.primary_adapter}")
445
+ console.print(f" Strategy: {project_config.hybrid_mode.sync_strategy.value}")
446
+ else:
447
+ console.print("[yellow]No project-specific configuration found[/yellow]")
448
+
449
+ # Show resolved config for current project
450
+ console.print("\n[bold]Resolved Configuration (for current project):[/bold]")
451
+ resolved = resolver.resolve_adapter_config()
452
+
453
+ table = Table()
454
+ table.add_column("Key", style="cyan")
455
+ table.add_column("Value", style="white")
456
+
457
+ for key, value in resolved.items():
458
+ # Hide sensitive values
459
+ if any(s in key.lower() for s in ["token", "key", "password"]) and value:
460
+ value = "***"
461
+ table.add_row(key, str(value))
462
+
463
+ console.print(table)
464
+
465
+
466
+ def set_adapter_config(
467
+ adapter: Optional[str] = None,
468
+ api_key: Optional[str] = None,
469
+ project_id: Optional[str] = None,
470
+ team_id: Optional[str] = None,
471
+ global_scope: bool = False,
472
+ **kwargs
473
+ ) -> None:
474
+ """Set specific adapter configuration values.
475
+
476
+ Args:
477
+ adapter: Adapter type to set as default
478
+ api_key: API key/token
479
+ project_id: Project ID
480
+ team_id: Team ID (Linear)
481
+ global_scope: Save to global config instead of project
482
+ **kwargs: Additional adapter-specific options
483
+ """
484
+ resolver = ConfigResolver()
485
+
486
+ # Load appropriate config
487
+ if global_scope:
488
+ config = resolver.load_global_config()
489
+ else:
490
+ config = resolver.load_project_config() or TicketerConfig()
491
+
492
+ # Update default adapter
493
+ if adapter:
494
+ config.default_adapter = adapter
495
+ console.print(f"[green]✓[/green] Default adapter set to: {adapter}")
496
+
497
+ # Update adapter-specific settings
498
+ updates = {}
499
+ if api_key:
500
+ updates["api_key"] = api_key
501
+ if project_id:
502
+ updates["project_id"] = project_id
503
+ if team_id:
504
+ updates["team_id"] = team_id
505
+
506
+ updates.update(kwargs)
507
+
508
+ if updates:
509
+ target_adapter = adapter or config.default_adapter
510
+
511
+ # Get or create adapter config
512
+ if target_adapter not in config.adapters:
513
+ config.adapters[target_adapter] = AdapterConfig(
514
+ adapter=target_adapter,
515
+ **updates
516
+ )
517
+ else:
518
+ # Update existing
519
+ existing = config.adapters[target_adapter].to_dict()
520
+ existing.update(updates)
521
+ config.adapters[target_adapter] = AdapterConfig.from_dict(existing)
522
+
523
+ console.print(f"[green]✓[/green] Updated {target_adapter} configuration")
524
+
525
+ # Save config
526
+ if global_scope:
527
+ resolver.save_global_config(config)
528
+ console.print(f"[dim]Saved to {resolver.GLOBAL_CONFIG_PATH}[/dim]")
529
+ else:
530
+ resolver.save_project_config(config)
531
+ config_path = resolver.project_path / resolver.PROJECT_CONFIG_SUBPATH
532
+ console.print(f"[dim]Saved to {config_path}[/dim]")
mcp_ticketer/cli/main.py CHANGED
@@ -18,6 +18,9 @@ from ..core.models import SearchQuery
18
18
  from ..adapters import AITrackdownAdapter
19
19
  from ..queue import Queue, QueueStatus, WorkerManager
20
20
  from .queue_commands import app as queue_app
21
+ from ..__version__ import __version__
22
+ from .configure import configure_wizard, show_current_config, set_adapter_config
23
+ from .migrate_config import migrate_config_command
21
24
 
22
25
  # Load environment variables
23
26
  load_dotenv()
@@ -29,6 +32,31 @@ app = typer.Typer(
29
32
  )
30
33
  console = Console()
31
34
 
35
+
36
+ def version_callback(value: bool):
37
+ """Print version and exit."""
38
+ if value:
39
+ console.print(f"mcp-ticketer version {__version__}")
40
+ raise typer.Exit()
41
+
42
+
43
+ @app.callback()
44
+ def main_callback(
45
+ version: bool = typer.Option(
46
+ None,
47
+ "--version",
48
+ "-v",
49
+ callback=version_callback,
50
+ is_eager=True,
51
+ help="Show version and exit"
52
+ ),
53
+ ):
54
+ """
55
+ MCP Ticketer - Universal ticket management interface.
56
+ """
57
+ pass
58
+
59
+
32
60
  # Configuration file management
33
61
  CONFIG_FILE = Path.home() / ".mcp-ticketer" / "config.json"
34
62
 
@@ -398,6 +426,85 @@ def set_config(
398
426
  console.print(f"[dim]Configuration saved to {CONFIG_FILE}[/dim]")
399
427
 
400
428
 
429
+ @app.command("configure")
430
+ def configure_command(
431
+ show: bool = typer.Option(
432
+ False,
433
+ "--show",
434
+ help="Show current configuration"
435
+ ),
436
+ adapter: Optional[str] = typer.Option(
437
+ None,
438
+ "--adapter",
439
+ help="Set default adapter type"
440
+ ),
441
+ api_key: Optional[str] = typer.Option(
442
+ None,
443
+ "--api-key",
444
+ help="Set API key/token"
445
+ ),
446
+ project_id: Optional[str] = typer.Option(
447
+ None,
448
+ "--project-id",
449
+ help="Set project ID"
450
+ ),
451
+ team_id: Optional[str] = typer.Option(
452
+ None,
453
+ "--team-id",
454
+ help="Set team ID (Linear)"
455
+ ),
456
+ global_scope: bool = typer.Option(
457
+ False,
458
+ "--global",
459
+ "-g",
460
+ help="Save to global config instead of project-specific"
461
+ ),
462
+ ) -> None:
463
+ """Configure MCP Ticketer integration.
464
+
465
+ Run without arguments to launch interactive wizard.
466
+ Use --show to display current configuration.
467
+ Use options to set specific values directly.
468
+ """
469
+ # Show configuration
470
+ if show:
471
+ show_current_config()
472
+ return
473
+
474
+ # Direct configuration
475
+ if any([adapter, api_key, project_id, team_id]):
476
+ set_adapter_config(
477
+ adapter=adapter,
478
+ api_key=api_key,
479
+ project_id=project_id,
480
+ team_id=team_id,
481
+ global_scope=global_scope
482
+ )
483
+ return
484
+
485
+ # Run interactive wizard
486
+ configure_wizard()
487
+
488
+
489
+ @app.command("migrate-config")
490
+ def migrate_config(
491
+ dry_run: bool = typer.Option(
492
+ False,
493
+ "--dry-run",
494
+ help="Show what would be done without making changes"
495
+ ),
496
+ ) -> None:
497
+ """Migrate configuration from old format to new format.
498
+
499
+ This command will:
500
+ 1. Detect old configuration format
501
+ 2. Convert to new schema
502
+ 3. Backup old config
503
+ 4. Apply new config
504
+ """
505
+ migrate_config_command(dry_run=dry_run)
506
+
507
+
401
508
  @app.command("status")
402
509
  def status_command():
403
510
  """Show queue and worker status."""