mcp-ticketer 0.1.21__py3-none-any.whl → 0.1.23__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 (42) hide show
  1. mcp_ticketer/__init__.py +7 -7
  2. mcp_ticketer/__version__.py +4 -2
  3. mcp_ticketer/adapters/__init__.py +4 -4
  4. mcp_ticketer/adapters/aitrackdown.py +66 -49
  5. mcp_ticketer/adapters/github.py +192 -125
  6. mcp_ticketer/adapters/hybrid.py +99 -53
  7. mcp_ticketer/adapters/jira.py +161 -151
  8. mcp_ticketer/adapters/linear.py +396 -246
  9. mcp_ticketer/cache/__init__.py +1 -1
  10. mcp_ticketer/cache/memory.py +15 -16
  11. mcp_ticketer/cli/__init__.py +1 -1
  12. mcp_ticketer/cli/configure.py +69 -93
  13. mcp_ticketer/cli/discover.py +43 -35
  14. mcp_ticketer/cli/main.py +283 -298
  15. mcp_ticketer/cli/mcp_configure.py +39 -15
  16. mcp_ticketer/cli/migrate_config.py +11 -13
  17. mcp_ticketer/cli/queue_commands.py +21 -58
  18. mcp_ticketer/cli/utils.py +121 -66
  19. mcp_ticketer/core/__init__.py +2 -2
  20. mcp_ticketer/core/adapter.py +46 -39
  21. mcp_ticketer/core/config.py +128 -92
  22. mcp_ticketer/core/env_discovery.py +69 -37
  23. mcp_ticketer/core/http_client.py +57 -40
  24. mcp_ticketer/core/mappers.py +98 -54
  25. mcp_ticketer/core/models.py +38 -24
  26. mcp_ticketer/core/project_config.py +145 -80
  27. mcp_ticketer/core/registry.py +16 -16
  28. mcp_ticketer/mcp/__init__.py +1 -1
  29. mcp_ticketer/mcp/server.py +199 -145
  30. mcp_ticketer/queue/__init__.py +2 -2
  31. mcp_ticketer/queue/__main__.py +1 -1
  32. mcp_ticketer/queue/manager.py +30 -26
  33. mcp_ticketer/queue/queue.py +147 -85
  34. mcp_ticketer/queue/run_worker.py +2 -3
  35. mcp_ticketer/queue/worker.py +55 -40
  36. {mcp_ticketer-0.1.21.dist-info → mcp_ticketer-0.1.23.dist-info}/METADATA +1 -1
  37. mcp_ticketer-0.1.23.dist-info/RECORD +42 -0
  38. mcp_ticketer-0.1.21.dist-info/RECORD +0 -42
  39. {mcp_ticketer-0.1.21.dist-info → mcp_ticketer-0.1.23.dist-info}/WHEEL +0 -0
  40. {mcp_ticketer-0.1.21.dist-info → mcp_ticketer-0.1.23.dist-info}/entry_points.txt +0 -0
  41. {mcp_ticketer-0.1.21.dist-info → mcp_ticketer-0.1.23.dist-info}/licenses/LICENSE +0 -0
  42. {mcp_ticketer-0.1.21.dist-info → mcp_ticketer-0.1.23.dist-info}/top_level.txt +0 -0
mcp_ticketer/cli/main.py CHANGED
@@ -3,25 +3,23 @@
3
3
  import asyncio
4
4
  import json
5
5
  import os
6
- from pathlib import Path
7
- from typing import Optional, List
8
6
  from enum import Enum
7
+ from pathlib import Path
8
+ from typing import Optional
9
9
 
10
10
  import typer
11
+ from dotenv import load_dotenv
11
12
  from rich.console import Console
12
13
  from rich.table import Table
13
- from rich import print as rprint
14
- from dotenv import load_dotenv
15
14
 
16
- from ..core import Task, TicketState, Priority, AdapterRegistry
15
+ from ..__version__ import __version__
16
+ from ..core import AdapterRegistry, Priority, TicketState
17
17
  from ..core.models import SearchQuery
18
- from ..adapters import AITrackdownAdapter
19
18
  from ..queue import Queue, QueueStatus, WorkerManager
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
19
+ from .configure import configure_wizard, set_adapter_config, show_current_config
24
20
  from .discover import app as discover_app
21
+ from .migrate_config import migrate_config_command
22
+ from .queue_commands import app as queue_app
25
23
 
26
24
  # Load environment variables from .env files
27
25
  # Priority: .env.local (highest) > .env (base)
@@ -58,21 +56,20 @@ def main_callback(
58
56
  "-v",
59
57
  callback=version_callback,
60
58
  is_eager=True,
61
- help="Show version and exit"
59
+ help="Show version and exit",
62
60
  ),
63
61
  ):
64
- """
65
- MCP Ticketer - Universal ticket management interface.
66
- """
62
+ """MCP Ticketer - Universal ticket management interface."""
67
63
  pass
68
64
 
69
65
 
70
- # Configuration file management
71
- CONFIG_FILE = Path.home() / ".mcp-ticketer" / "config.json"
66
+ # Configuration file management - PROJECT-LOCAL ONLY
67
+ CONFIG_FILE = Path.cwd() / ".mcp-ticketer" / "config.json"
72
68
 
73
69
 
74
70
  class AdapterType(str, Enum):
75
71
  """Available adapter types."""
72
+
76
73
  AITRACKDOWN = "aitrackdown"
77
74
  LINEAR = "linear"
78
75
  JIRA = "jira"
@@ -80,48 +77,82 @@ class AdapterType(str, Enum):
80
77
 
81
78
 
82
79
  def load_config(project_dir: Optional[Path] = None) -> dict:
83
- """Load configuration from file.
80
+ """Load configuration from project-local config file ONLY.
81
+
82
+ SECURITY: This method ONLY reads from the current project directory
83
+ to prevent configuration leakage across projects. It will NEVER read
84
+ from user home directory or system-wide locations.
84
85
 
85
86
  Args:
86
87
  project_dir: Optional project directory to load config from
87
88
 
88
89
  Resolution order:
89
90
  1. Project-specific config (.mcp-ticketer/config.json in project_dir or cwd)
90
- 2. Global config (~/.mcp-ticketer/config.json)
91
+ 2. Default to aitrackdown adapter
91
92
 
92
93
  Returns:
93
- Configuration dictionary
94
+ Configuration dictionary with adapter and config keys.
95
+ Defaults to aitrackdown if no local config exists.
96
+
94
97
  """
98
+ import logging
99
+
100
+ logger = logging.getLogger(__name__)
101
+
95
102
  # Use provided project_dir or current working directory
96
103
  base_dir = project_dir or Path.cwd()
97
104
 
98
- # Check project-specific config first
105
+ # ONLY check project-specific config in project directory
99
106
  project_config = base_dir / ".mcp-ticketer" / "config.json"
100
107
  if project_config.exists():
108
+ # Validate that config file is actually in project directory
101
109
  try:
102
- with open(project_config, "r") as f:
103
- return json.load(f)
104
- except (json.JSONDecodeError, IOError) as e:
105
- console.print(f"[yellow]Warning: Could not load project config: {e}[/yellow]")
106
- # Fall through to global config
107
-
108
- # Fall back to global config
109
- if CONFIG_FILE.exists():
110
- try:
111
- with open(CONFIG_FILE, "r") as f:
112
- return json.load(f)
113
- except (json.JSONDecodeError, IOError) as e:
114
- console.print(f"[yellow]Warning: Could not load global config: {e}[/yellow]")
110
+ if not project_config.resolve().is_relative_to(base_dir.resolve()):
111
+ logger.error(
112
+ f"Security violation: Config file {project_config} "
113
+ "is not within project directory"
114
+ )
115
+ raise ValueError(
116
+ f"Security violation: Config file {project_config} "
117
+ "is not within project directory"
118
+ )
119
+ except (ValueError, RuntimeError):
120
+ # is_relative_to may raise ValueError in some cases
121
+ pass
115
122
 
116
- # Default fallback
123
+ try:
124
+ with open(project_config) as f:
125
+ config = json.load(f)
126
+ logger.info(
127
+ f"Loaded configuration from project-local: {project_config}"
128
+ )
129
+ return config
130
+ except (OSError, json.JSONDecodeError) as e:
131
+ logger.warning(f"Could not load project config: {e}, using defaults")
132
+ console.print(
133
+ f"[yellow]Warning: Could not load project config: {e}[/yellow]"
134
+ )
135
+
136
+ # Default to aitrackdown with local base path
137
+ logger.info("No project-local config found, defaulting to aitrackdown adapter")
117
138
  return {"adapter": "aitrackdown", "config": {"base_path": ".aitrackdown"}}
118
139
 
119
140
 
120
141
  def save_config(config: dict) -> None:
121
- """Save configuration to file."""
122
- CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
123
- with open(CONFIG_FILE, "w") as f:
142
+ """Save configuration to project-local config file ONLY.
143
+
144
+ SECURITY: This method ONLY saves to the current project directory
145
+ to prevent configuration leakage across projects.
146
+ """
147
+ import logging
148
+
149
+ logger = logging.getLogger(__name__)
150
+
151
+ project_config = Path.cwd() / ".mcp-ticketer" / "config.json"
152
+ project_config.parent.mkdir(parents=True, exist_ok=True)
153
+ with open(project_config, "w") as f:
124
154
  json.dump(config, f, indent=2)
155
+ logger.info(f"Saved configuration to project-local: {project_config}")
125
156
 
126
157
 
127
158
  def merge_config(updates: dict) -> dict:
@@ -132,6 +163,7 @@ def merge_config(updates: dict) -> dict:
132
163
 
133
164
  Returns:
134
165
  Updated configuration
166
+
135
167
  """
136
168
  config = load_config()
137
169
 
@@ -151,12 +183,15 @@ def merge_config(updates: dict) -> dict:
151
183
  return config
152
184
 
153
185
 
154
- def get_adapter(override_adapter: Optional[str] = None, override_config: Optional[dict] = None):
186
+ def get_adapter(
187
+ override_adapter: Optional[str] = None, override_config: Optional[dict] = None
188
+ ):
155
189
  """Get configured adapter instance.
156
190
 
157
191
  Args:
158
192
  override_adapter: Override the default adapter type
159
193
  override_config: Override configuration for the adapter
194
+
160
195
  """
161
196
  config = load_config()
162
197
 
@@ -182,6 +217,7 @@ def get_adapter(override_adapter: Optional[str] = None, override_config: Optiona
182
217
 
183
218
  # Add environment variables for authentication
184
219
  import os
220
+
185
221
  if adapter_type == "linear":
186
222
  if not adapter_config.get("api_key"):
187
223
  adapter_config["api_key"] = os.getenv("LINEAR_API_KEY")
@@ -203,64 +239,48 @@ def init(
203
239
  None,
204
240
  "--adapter",
205
241
  "-a",
206
- help="Adapter type to use (auto-detected from .env if not specified)"
242
+ help="Adapter type to use (auto-detected from .env if not specified)",
207
243
  ),
208
244
  project_path: Optional[str] = typer.Option(
209
- None,
210
- "--path",
211
- help="Project path (default: current directory)"
245
+ None, "--path", help="Project path (default: current directory)"
212
246
  ),
213
247
  global_config: bool = typer.Option(
214
248
  False,
215
249
  "--global",
216
250
  "-g",
217
- help="Save to global config instead of project-specific"
251
+ help="Save to global config instead of project-specific",
218
252
  ),
219
253
  base_path: Optional[str] = typer.Option(
220
254
  None,
221
255
  "--base-path",
222
256
  "-p",
223
- help="Base path for ticket storage (AITrackdown only)"
257
+ help="Base path for ticket storage (AITrackdown only)",
224
258
  ),
225
259
  api_key: Optional[str] = typer.Option(
226
- None,
227
- "--api-key",
228
- help="API key for Linear or API token for JIRA"
260
+ None, "--api-key", help="API key for Linear or API token for JIRA"
229
261
  ),
230
262
  team_id: Optional[str] = typer.Option(
231
- None,
232
- "--team-id",
233
- help="Linear team ID (required for Linear adapter)"
263
+ None, "--team-id", help="Linear team ID (required for Linear adapter)"
234
264
  ),
235
265
  jira_server: Optional[str] = typer.Option(
236
266
  None,
237
267
  "--jira-server",
238
- help="JIRA server URL (e.g., https://company.atlassian.net)"
268
+ help="JIRA server URL (e.g., https://company.atlassian.net)",
239
269
  ),
240
270
  jira_email: Optional[str] = typer.Option(
241
- None,
242
- "--jira-email",
243
- help="JIRA user email for authentication"
271
+ None, "--jira-email", help="JIRA user email for authentication"
244
272
  ),
245
273
  jira_project: Optional[str] = typer.Option(
246
- None,
247
- "--jira-project",
248
- help="Default JIRA project key"
274
+ None, "--jira-project", help="Default JIRA project key"
249
275
  ),
250
276
  github_owner: Optional[str] = typer.Option(
251
- None,
252
- "--github-owner",
253
- help="GitHub repository owner"
277
+ None, "--github-owner", help="GitHub repository owner"
254
278
  ),
255
279
  github_repo: Optional[str] = typer.Option(
256
- None,
257
- "--github-repo",
258
- help="GitHub repository name"
280
+ None, "--github-repo", help="GitHub repository name"
259
281
  ),
260
282
  github_token: Optional[str] = typer.Option(
261
- None,
262
- "--github-token",
263
- help="GitHub Personal Access Token"
283
+ None, "--github-token", help="GitHub Personal Access Token"
264
284
  ),
265
285
  ) -> None:
266
286
  """Initialize mcp-ticketer for the current project.
@@ -280,10 +300,12 @@ def init(
280
300
 
281
301
  # Save globally (not recommended)
282
302
  mcp-ticketer init --global
303
+
283
304
  """
284
305
  from pathlib import Path
285
- from ..core.project_config import ConfigResolver
306
+
286
307
  from ..core.env_discovery import discover_config
308
+ from ..core.project_config import ConfigResolver
287
309
 
288
310
  # Determine project path
289
311
  proj_path = Path(project_path) if project_path else Path.cwd()
@@ -295,7 +317,7 @@ def init(
295
317
  if config_path.exists():
296
318
  if not typer.confirm(
297
319
  f"Configuration already exists at {config_path}. Overwrite?",
298
- default=False
320
+ default=False,
299
321
  ):
300
322
  console.print("[yellow]Initialization cancelled.[/yellow]")
301
323
  raise typer.Exit(0)
@@ -305,30 +327,37 @@ def init(
305
327
  adapter_type = adapter
306
328
 
307
329
  if not adapter_type:
308
- console.print("[cyan]🔍 Auto-discovering configuration from .env files...[/cyan]")
330
+ console.print(
331
+ "[cyan]🔍 Auto-discovering configuration from .env files...[/cyan]"
332
+ )
309
333
  discovered = discover_config(proj_path)
310
334
 
311
335
  if discovered and discovered.adapters:
312
336
  primary = discovered.get_primary_adapter()
313
337
  if primary:
314
338
  adapter_type = primary.adapter_type
315
- console.print(f"[green]✓ Detected {adapter_type} adapter from environment files[/green]")
339
+ console.print(
340
+ f"[green]✓ Detected {adapter_type} adapter from environment files[/green]"
341
+ )
316
342
 
317
343
  # Show what was discovered
318
- console.print(f"\n[dim]Configuration found in: {primary.found_in}[/dim]")
344
+ console.print(
345
+ f"\n[dim]Configuration found in: {primary.found_in}[/dim]"
346
+ )
319
347
  console.print(f"[dim]Confidence: {primary.confidence:.0%}[/dim]")
320
348
  else:
321
349
  adapter_type = "aitrackdown" # Fallback
322
- console.print("[yellow]⚠ No credentials found, defaulting to aitrackdown[/yellow]")
350
+ console.print(
351
+ "[yellow]⚠ No credentials found, defaulting to aitrackdown[/yellow]"
352
+ )
323
353
  else:
324
354
  adapter_type = "aitrackdown" # Fallback
325
- console.print("[yellow]⚠ No .env files found, defaulting to aitrackdown[/yellow]")
355
+ console.print(
356
+ "[yellow]⚠ No .env files found, defaulting to aitrackdown[/yellow]"
357
+ )
326
358
 
327
359
  # 2. Create configuration based on adapter type
328
- config = {
329
- "default_adapter": adapter_type,
330
- "adapters": {}
331
- }
360
+ config = {"default_adapter": adapter_type, "adapters": {}}
332
361
 
333
362
  # 3. If discovered and matches adapter_type, use discovered config
334
363
  if discovered and adapter_type != "aitrackdown":
@@ -355,7 +384,9 @@ def init(
355
384
  linear_config["api_key"] = linear_api_key
356
385
  elif not discovered:
357
386
  console.print("[yellow]Warning:[/yellow] No Linear API key provided.")
358
- console.print("Set LINEAR_API_KEY environment variable or use --api-key option")
387
+ console.print(
388
+ "Set LINEAR_API_KEY environment variable or use --api-key option"
389
+ )
359
390
 
360
391
  if linear_config:
361
392
  config["adapters"]["linear"] = linear_config
@@ -370,7 +401,9 @@ def init(
370
401
 
371
402
  if not server:
372
403
  console.print("[red]Error:[/red] JIRA server URL is required")
373
- console.print("Use --jira-server or set JIRA_SERVER environment variable")
404
+ console.print(
405
+ "Use --jira-server or set JIRA_SERVER environment variable"
406
+ )
374
407
  raise typer.Exit(1)
375
408
 
376
409
  if not email:
@@ -380,15 +413,15 @@ def init(
380
413
 
381
414
  if not token:
382
415
  console.print("[red]Error:[/red] JIRA API token is required")
383
- console.print("Use --api-key or set JIRA_API_TOKEN environment variable")
384
- console.print("[dim]Generate token at: https://id.atlassian.com/manage/api-tokens[/dim]")
416
+ console.print(
417
+ "Use --api-key or set JIRA_API_TOKEN environment variable"
418
+ )
419
+ console.print(
420
+ "[dim]Generate token at: https://id.atlassian.com/manage/api-tokens[/dim]"
421
+ )
385
422
  raise typer.Exit(1)
386
423
 
387
- jira_config = {
388
- "server": server,
389
- "email": email,
390
- "api_token": token
391
- }
424
+ jira_config = {"server": server, "email": email, "api_token": token}
392
425
 
393
426
  if project:
394
427
  jira_config["project_key"] = project
@@ -404,25 +437,37 @@ def init(
404
437
 
405
438
  if not owner:
406
439
  console.print("[red]Error:[/red] GitHub repository owner is required")
407
- console.print("Use --github-owner or set GITHUB_OWNER environment variable")
440
+ console.print(
441
+ "Use --github-owner or set GITHUB_OWNER environment variable"
442
+ )
408
443
  raise typer.Exit(1)
409
444
 
410
445
  if not repo:
411
446
  console.print("[red]Error:[/red] GitHub repository name is required")
412
- console.print("Use --github-repo or set GITHUB_REPO environment variable")
447
+ console.print(
448
+ "Use --github-repo or set GITHUB_REPO environment variable"
449
+ )
413
450
  raise typer.Exit(1)
414
451
 
415
452
  if not token:
416
- console.print("[red]Error:[/red] GitHub Personal Access Token is required")
417
- console.print("Use --github-token or set GITHUB_TOKEN environment variable")
418
- console.print("[dim]Create token at: https://github.com/settings/tokens/new[/dim]")
419
- console.print("[dim]Required scopes: repo (for private repos) or public_repo (for public repos)[/dim]")
453
+ console.print(
454
+ "[red]Error:[/red] GitHub Personal Access Token is required"
455
+ )
456
+ console.print(
457
+ "Use --github-token or set GITHUB_TOKEN environment variable"
458
+ )
459
+ console.print(
460
+ "[dim]Create token at: https://github.com/settings/tokens/new[/dim]"
461
+ )
462
+ console.print(
463
+ "[dim]Required scopes: repo (for private repos) or public_repo (for public repos)[/dim]"
464
+ )
420
465
  raise typer.Exit(1)
421
466
 
422
467
  config["adapters"]["github"] = {
423
468
  "owner": owner,
424
469
  "repo": repo,
425
- "token": token
470
+ "token": token,
426
471
  }
427
472
 
428
473
  # 5. Save to appropriate location
@@ -432,7 +477,7 @@ def init(
432
477
  config_file_path = resolver.GLOBAL_CONFIG_PATH
433
478
  config_file_path.parent.mkdir(parents=True, exist_ok=True)
434
479
 
435
- with open(config_file_path, 'w') as f:
480
+ with open(config_file_path, "w") as f:
436
481
  json.dump(config, f, indent=2)
437
482
 
438
483
  console.print(f"[green]✓ Initialized with {adapter_type} adapter[/green]")
@@ -442,7 +487,7 @@ def init(
442
487
  config_file_path = proj_path / ".mcp-ticketer" / "config.json"
443
488
  config_file_path.parent.mkdir(parents=True, exist_ok=True)
444
489
 
445
- with open(config_file_path, 'w') as f:
490
+ with open(config_file_path, "w") as f:
446
491
  json.dump(config, f, indent=2)
447
492
 
448
493
  console.print(f"[green]✓ Initialized with {adapter_type} adapter[/green]")
@@ -453,12 +498,12 @@ def init(
453
498
  if gitignore_path.exists():
454
499
  gitignore_content = gitignore_path.read_text()
455
500
  if ".mcp-ticketer" not in gitignore_content:
456
- with open(gitignore_path, 'a') as f:
501
+ with open(gitignore_path, "a") as f:
457
502
  f.write("\n# MCP Ticketer\n.mcp-ticketer/\n")
458
503
  console.print("[dim]✓ Added .mcp-ticketer/ to .gitignore[/dim]")
459
504
  else:
460
505
  # Create .gitignore if it doesn't exist
461
- with open(gitignore_path, 'w') as f:
506
+ with open(gitignore_path, "w") as f:
462
507
  f.write("# MCP Ticketer\n.mcp-ticketer/\n")
463
508
  console.print("[dim]✓ Created .gitignore with .mcp-ticketer/[/dim]")
464
509
 
@@ -469,64 +514,48 @@ def install(
469
514
  None,
470
515
  "--adapter",
471
516
  "-a",
472
- help="Adapter type to use (auto-detected from .env if not specified)"
517
+ help="Adapter type to use (auto-detected from .env if not specified)",
473
518
  ),
474
519
  project_path: Optional[str] = typer.Option(
475
- None,
476
- "--path",
477
- help="Project path (default: current directory)"
520
+ None, "--path", help="Project path (default: current directory)"
478
521
  ),
479
522
  global_config: bool = typer.Option(
480
523
  False,
481
524
  "--global",
482
525
  "-g",
483
- help="Save to global config instead of project-specific"
526
+ help="Save to global config instead of project-specific",
484
527
  ),
485
528
  base_path: Optional[str] = typer.Option(
486
529
  None,
487
530
  "--base-path",
488
531
  "-p",
489
- help="Base path for ticket storage (AITrackdown only)"
532
+ help="Base path for ticket storage (AITrackdown only)",
490
533
  ),
491
534
  api_key: Optional[str] = typer.Option(
492
- None,
493
- "--api-key",
494
- help="API key for Linear or API token for JIRA"
535
+ None, "--api-key", help="API key for Linear or API token for JIRA"
495
536
  ),
496
537
  team_id: Optional[str] = typer.Option(
497
- None,
498
- "--team-id",
499
- help="Linear team ID (required for Linear adapter)"
538
+ None, "--team-id", help="Linear team ID (required for Linear adapter)"
500
539
  ),
501
540
  jira_server: Optional[str] = typer.Option(
502
541
  None,
503
542
  "--jira-server",
504
- help="JIRA server URL (e.g., https://company.atlassian.net)"
543
+ help="JIRA server URL (e.g., https://company.atlassian.net)",
505
544
  ),
506
545
  jira_email: Optional[str] = typer.Option(
507
- None,
508
- "--jira-email",
509
- help="JIRA user email for authentication"
546
+ None, "--jira-email", help="JIRA user email for authentication"
510
547
  ),
511
548
  jira_project: Optional[str] = typer.Option(
512
- None,
513
- "--jira-project",
514
- help="Default JIRA project key"
549
+ None, "--jira-project", help="Default JIRA project key"
515
550
  ),
516
551
  github_owner: Optional[str] = typer.Option(
517
- None,
518
- "--github-owner",
519
- help="GitHub repository owner"
552
+ None, "--github-owner", help="GitHub repository owner"
520
553
  ),
521
554
  github_repo: Optional[str] = typer.Option(
522
- None,
523
- "--github-repo",
524
- help="GitHub repository name"
555
+ None, "--github-repo", help="GitHub repository name"
525
556
  ),
526
557
  github_token: Optional[str] = typer.Option(
527
- None,
528
- "--github-token",
529
- help="GitHub Personal Access Token"
558
+ None, "--github-token", help="GitHub Personal Access Token"
530
559
  ),
531
560
  ) -> None:
532
561
  """Initialize mcp-ticketer for the current project (alias for init).
@@ -547,6 +576,7 @@ def install(
547
576
 
548
577
  # Save globally (not recommended)
549
578
  mcp-ticketer install --global
579
+
550
580
  """
551
581
  # Call init with all parameters
552
582
  init(
@@ -568,45 +598,20 @@ def install(
568
598
  @app.command("set")
569
599
  def set_config(
570
600
  adapter: Optional[AdapterType] = typer.Option(
571
- None,
572
- "--adapter",
573
- "-a",
574
- help="Set default adapter"
601
+ None, "--adapter", "-a", help="Set default adapter"
575
602
  ),
576
603
  team_key: Optional[str] = typer.Option(
577
- None,
578
- "--team-key",
579
- help="Linear team key (e.g., BTA)"
580
- ),
581
- team_id: Optional[str] = typer.Option(
582
- None,
583
- "--team-id",
584
- help="Linear team ID"
604
+ None, "--team-key", help="Linear team key (e.g., BTA)"
585
605
  ),
606
+ team_id: Optional[str] = typer.Option(None, "--team-id", help="Linear team ID"),
586
607
  owner: Optional[str] = typer.Option(
587
- None,
588
- "--owner",
589
- help="GitHub repository owner"
590
- ),
591
- repo: Optional[str] = typer.Option(
592
- None,
593
- "--repo",
594
- help="GitHub repository name"
595
- ),
596
- server: Optional[str] = typer.Option(
597
- None,
598
- "--server",
599
- help="JIRA server URL"
600
- ),
601
- project: Optional[str] = typer.Option(
602
- None,
603
- "--project",
604
- help="JIRA project key"
608
+ None, "--owner", help="GitHub repository owner"
605
609
  ),
610
+ repo: Optional[str] = typer.Option(None, "--repo", help="GitHub repository name"),
611
+ server: Optional[str] = typer.Option(None, "--server", help="JIRA server URL"),
612
+ project: Optional[str] = typer.Option(None, "--project", help="JIRA project key"),
606
613
  base_path: Optional[str] = typer.Option(
607
- None,
608
- "--base-path",
609
- help="AITrackdown base path"
614
+ None, "--base-path", help="AITrackdown base path"
610
615
  ),
611
616
  ) -> None:
612
617
  """Set default adapter and adapter-specific configuration.
@@ -617,7 +622,9 @@ def set_config(
617
622
  # Show current configuration
618
623
  config = load_config()
619
624
  console.print("[bold]Current Configuration:[/bold]")
620
- console.print(f"Default adapter: [cyan]{config.get('default_adapter', 'aitrackdown')}[/cyan]")
625
+ console.print(
626
+ f"Default adapter: [cyan]{config.get('default_adapter', 'aitrackdown')}[/cyan]"
627
+ )
621
628
 
622
629
  adapters_config = config.get("adapters", {})
623
630
  if adapters_config:
@@ -626,7 +633,11 @@ def set_config(
626
633
  console.print(f"\n[cyan]{adapter_name}:[/cyan]")
627
634
  for key, value in adapter_config.items():
628
635
  # Don't display sensitive values like tokens
629
- if "token" in key.lower() or "key" in key.lower() and "team" not in key.lower():
636
+ if (
637
+ "token" in key.lower()
638
+ or "key" in key.lower()
639
+ and "team" not in key.lower()
640
+ ):
630
641
  value = "***" if value else "not set"
631
642
  console.print(f" {key}: {value}")
632
643
  return
@@ -649,7 +660,7 @@ def set_config(
649
660
  if team_id:
650
661
  linear_config["team_id"] = team_id
651
662
  adapter_configs["linear"] = linear_config
652
- console.print(f"[green]✓[/green] Linear settings updated")
663
+ console.print("[green]✓[/green] Linear settings updated")
653
664
 
654
665
  # GitHub configuration
655
666
  if owner or repo:
@@ -659,7 +670,7 @@ def set_config(
659
670
  if repo:
660
671
  github_config["repo"] = repo
661
672
  adapter_configs["github"] = github_config
662
- console.print(f"[green]✓[/green] GitHub settings updated")
673
+ console.print("[green]✓[/green] GitHub settings updated")
663
674
 
664
675
  # JIRA configuration
665
676
  if server or project:
@@ -669,12 +680,12 @@ def set_config(
669
680
  if project:
670
681
  jira_config["project_key"] = project
671
682
  adapter_configs["jira"] = jira_config
672
- console.print(f"[green]✓[/green] JIRA settings updated")
683
+ console.print("[green]✓[/green] JIRA settings updated")
673
684
 
674
685
  # AITrackdown configuration
675
686
  if base_path:
676
687
  adapter_configs["aitrackdown"] = {"base_path": base_path}
677
- console.print(f"[green]✓[/green] AITrackdown settings updated")
688
+ console.print("[green]✓[/green] AITrackdown settings updated")
678
689
 
679
690
  if adapter_configs:
680
691
  updates["adapters"] = adapter_configs
@@ -688,36 +699,22 @@ def set_config(
688
699
 
689
700
  @app.command("configure")
690
701
  def configure_command(
691
- show: bool = typer.Option(
692
- False,
693
- "--show",
694
- help="Show current configuration"
695
- ),
702
+ show: bool = typer.Option(False, "--show", help="Show current configuration"),
696
703
  adapter: Optional[str] = typer.Option(
697
- None,
698
- "--adapter",
699
- help="Set default adapter type"
700
- ),
701
- api_key: Optional[str] = typer.Option(
702
- None,
703
- "--api-key",
704
- help="Set API key/token"
704
+ None, "--adapter", help="Set default adapter type"
705
705
  ),
706
+ api_key: Optional[str] = typer.Option(None, "--api-key", help="Set API key/token"),
706
707
  project_id: Optional[str] = typer.Option(
707
- None,
708
- "--project-id",
709
- help="Set project ID"
708
+ None, "--project-id", help="Set project ID"
710
709
  ),
711
710
  team_id: Optional[str] = typer.Option(
712
- None,
713
- "--team-id",
714
- help="Set team ID (Linear)"
711
+ None, "--team-id", help="Set team ID (Linear)"
715
712
  ),
716
713
  global_scope: bool = typer.Option(
717
714
  False,
718
715
  "--global",
719
716
  "-g",
720
- help="Save to global config instead of project-specific"
717
+ help="Save to global config instead of project-specific",
721
718
  ),
722
719
  ) -> None:
723
720
  """Configure MCP Ticketer integration.
@@ -738,7 +735,7 @@ def configure_command(
738
735
  api_key=api_key,
739
736
  project_id=project_id,
740
737
  team_id=team_id,
741
- global_scope=global_scope
738
+ global_scope=global_scope,
742
739
  )
743
740
  return
744
741
 
@@ -749,9 +746,7 @@ def configure_command(
749
746
  @app.command("migrate-config")
750
747
  def migrate_config(
751
748
  dry_run: bool = typer.Option(
752
- False,
753
- "--dry-run",
754
- help="Show what would be done without making changes"
749
+ False, "--dry-run", help="Show what would be done without making changes"
755
750
  ),
756
751
  ) -> None:
757
752
  """Migrate configuration from old format to new format.
@@ -785,50 +780,42 @@ def status_command():
785
780
  # Show worker status
786
781
  worker_status = manager.get_status()
787
782
  if worker_status["running"]:
788
- console.print(f"\n[green]● Worker is running[/green] (PID: {worker_status.get('pid')})")
783
+ console.print(
784
+ f"\n[green]● Worker is running[/green] (PID: {worker_status.get('pid')})"
785
+ )
789
786
  else:
790
787
  console.print("\n[red]○ Worker is not running[/red]")
791
788
  if pending > 0:
792
- console.print("[yellow]Note: There are pending items. Start worker with 'mcp-ticketer worker start'[/yellow]")
789
+ console.print(
790
+ "[yellow]Note: There are pending items. Start worker with 'mcp-ticketer worker start'[/yellow]"
791
+ )
793
792
 
794
793
 
795
794
  @app.command()
796
795
  def create(
797
796
  title: str = typer.Argument(..., help="Ticket title"),
798
797
  description: Optional[str] = typer.Option(
799
- None,
800
- "--description",
801
- "-d",
802
- help="Ticket description"
798
+ None, "--description", "-d", help="Ticket description"
803
799
  ),
804
800
  priority: Priority = typer.Option(
805
- Priority.MEDIUM,
806
- "--priority",
807
- "-p",
808
- help="Priority level"
801
+ Priority.MEDIUM, "--priority", "-p", help="Priority level"
809
802
  ),
810
- tags: Optional[List[str]] = typer.Option(
811
- None,
812
- "--tag",
813
- "-t",
814
- help="Tags (can be specified multiple times)"
803
+ tags: Optional[list[str]] = typer.Option(
804
+ None, "--tag", "-t", help="Tags (can be specified multiple times)"
815
805
  ),
816
806
  assignee: Optional[str] = typer.Option(
817
- None,
818
- "--assignee",
819
- "-a",
820
- help="Assignee username"
807
+ None, "--assignee", "-a", help="Assignee username"
821
808
  ),
822
809
  adapter: Optional[AdapterType] = typer.Option(
823
- None,
824
- "--adapter",
825
- help="Override default adapter"
810
+ None, "--adapter", help="Override default adapter"
826
811
  ),
827
812
  ) -> None:
828
813
  """Create a new ticket."""
829
814
  # Get the adapter name
830
815
  config = load_config()
831
- adapter_name = adapter.value if adapter else config.get("default_adapter", "aitrackdown")
816
+ adapter_name = (
817
+ adapter.value if adapter else config.get("default_adapter", "aitrackdown")
818
+ )
832
819
 
833
820
  # Create task data
834
821
  task_data = {
@@ -842,9 +829,7 @@ def create(
842
829
  # Add to queue
843
830
  queue = Queue()
844
831
  queue_id = queue.add(
845
- ticket_data=task_data,
846
- adapter=adapter_name,
847
- operation="create"
832
+ ticket_data=task_data, adapter=adapter_name, operation="create"
848
833
  )
849
834
 
850
835
  console.print(f"[green]✓[/green] Queued ticket creation: {queue_id}")
@@ -861,32 +846,22 @@ def create(
861
846
  @app.command("list")
862
847
  def list_tickets(
863
848
  state: Optional[TicketState] = typer.Option(
864
- None,
865
- "--state",
866
- "-s",
867
- help="Filter by state"
849
+ None, "--state", "-s", help="Filter by state"
868
850
  ),
869
851
  priority: Optional[Priority] = typer.Option(
870
- None,
871
- "--priority",
872
- "-p",
873
- help="Filter by priority"
874
- ),
875
- limit: int = typer.Option(
876
- 10,
877
- "--limit",
878
- "-l",
879
- help="Maximum number of tickets"
852
+ None, "--priority", "-p", help="Filter by priority"
880
853
  ),
854
+ limit: int = typer.Option(10, "--limit", "-l", help="Maximum number of tickets"),
881
855
  adapter: Optional[AdapterType] = typer.Option(
882
- None,
883
- "--adapter",
884
- help="Override default adapter"
856
+ None, "--adapter", help="Override default adapter"
885
857
  ),
886
858
  ) -> None:
887
859
  """List tickets with optional filters."""
860
+
888
861
  async def _list():
889
- adapter_instance = get_adapter(override_adapter=adapter.value if adapter else None)
862
+ adapter_instance = get_adapter(
863
+ override_adapter=adapter.value if adapter else None
864
+ )
890
865
  filters = {}
891
866
  if state:
892
867
  filters["state"] = state
@@ -923,21 +898,17 @@ def list_tickets(
923
898
  @app.command()
924
899
  def show(
925
900
  ticket_id: str = typer.Argument(..., help="Ticket ID"),
926
- comments: bool = typer.Option(
927
- False,
928
- "--comments",
929
- "-c",
930
- help="Show comments"
931
- ),
901
+ comments: bool = typer.Option(False, "--comments", "-c", help="Show comments"),
932
902
  adapter: Optional[AdapterType] = typer.Option(
933
- None,
934
- "--adapter",
935
- help="Override default adapter"
903
+ None, "--adapter", help="Override default adapter"
936
904
  ),
937
905
  ) -> None:
938
906
  """Show detailed ticket information."""
907
+
939
908
  async def _show():
940
- adapter_instance = get_adapter(override_adapter=adapter.value if adapter else None)
909
+ adapter_instance = get_adapter(
910
+ override_adapter=adapter.value if adapter else None
911
+ )
941
912
  ticket = await adapter_instance.read(ticket_id)
942
913
  ticket_comments = None
943
914
  if comments and ticket:
@@ -957,7 +928,7 @@ def show(
957
928
  console.print(f"Priority: [yellow]{ticket.priority}[/yellow]")
958
929
 
959
930
  if ticket.description:
960
- console.print(f"\n[dim]Description:[/dim]")
931
+ console.print("\n[dim]Description:[/dim]")
961
932
  console.print(ticket.description)
962
933
 
963
934
  if ticket.tags:
@@ -979,27 +950,16 @@ def update(
979
950
  ticket_id: str = typer.Argument(..., help="Ticket ID"),
980
951
  title: Optional[str] = typer.Option(None, "--title", help="New title"),
981
952
  description: Optional[str] = typer.Option(
982
- None,
983
- "--description",
984
- "-d",
985
- help="New description"
953
+ None, "--description", "-d", help="New description"
986
954
  ),
987
955
  priority: Optional[Priority] = typer.Option(
988
- None,
989
- "--priority",
990
- "-p",
991
- help="New priority"
956
+ None, "--priority", "-p", help="New priority"
992
957
  ),
993
958
  assignee: Optional[str] = typer.Option(
994
- None,
995
- "--assignee",
996
- "-a",
997
- help="New assignee"
959
+ None, "--assignee", "-a", help="New assignee"
998
960
  ),
999
961
  adapter: Optional[AdapterType] = typer.Option(
1000
- None,
1001
- "--adapter",
1002
- help="Override default adapter"
962
+ None, "--adapter", help="Override default adapter"
1003
963
  ),
1004
964
  ) -> None:
1005
965
  """Update ticket fields."""
@@ -1009,7 +969,9 @@ def update(
1009
969
  if description:
1010
970
  updates["description"] = description
1011
971
  if priority:
1012
- updates["priority"] = priority.value if isinstance(priority, Priority) else priority
972
+ updates["priority"] = (
973
+ priority.value if isinstance(priority, Priority) else priority
974
+ )
1013
975
  if assignee:
1014
976
  updates["assignee"] = assignee
1015
977
 
@@ -1019,18 +981,16 @@ def update(
1019
981
 
1020
982
  # Get the adapter name
1021
983
  config = load_config()
1022
- adapter_name = adapter.value if adapter else config.get("default_adapter", "aitrackdown")
984
+ adapter_name = (
985
+ adapter.value if adapter else config.get("default_adapter", "aitrackdown")
986
+ )
1023
987
 
1024
988
  # Add ticket_id to updates
1025
989
  updates["ticket_id"] = ticket_id
1026
990
 
1027
991
  # Add to queue
1028
992
  queue = Queue()
1029
- queue_id = queue.add(
1030
- ticket_data=updates,
1031
- adapter=adapter_name,
1032
- operation="update"
1033
- )
993
+ queue_id = queue.add(ticket_data=updates, adapter=adapter_name, operation="update")
1034
994
 
1035
995
  console.print(f"[green]✓[/green] Queued ticket update: {queue_id}")
1036
996
  for key, value in updates.items():
@@ -1047,31 +1007,60 @@ def update(
1047
1007
  @app.command()
1048
1008
  def transition(
1049
1009
  ticket_id: str = typer.Argument(..., help="Ticket ID"),
1050
- state: TicketState = typer.Argument(..., help="Target state"),
1010
+ state_positional: Optional[TicketState] = typer.Argument(
1011
+ None, help="Target state (positional - deprecated, use --state instead)"
1012
+ ),
1013
+ state: Optional[TicketState] = typer.Option(
1014
+ None, "--state", "-s", help="Target state (recommended)"
1015
+ ),
1051
1016
  adapter: Optional[AdapterType] = typer.Option(
1052
- None,
1053
- "--adapter",
1054
- help="Override default adapter"
1017
+ None, "--adapter", help="Override default adapter"
1055
1018
  ),
1056
1019
  ) -> None:
1057
- """Change ticket state with validation."""
1020
+ """Change ticket state with validation.
1021
+
1022
+ Examples:
1023
+ # Recommended syntax with flag:
1024
+ mcp-ticketer transition BTA-215 --state done
1025
+ mcp-ticketer transition BTA-215 -s in_progress
1026
+
1027
+ # Legacy positional syntax (still supported):
1028
+ mcp-ticketer transition BTA-215 done
1029
+
1030
+ """
1031
+ # Determine which state to use (prefer flag over positional)
1032
+ target_state = state if state is not None else state_positional
1033
+
1034
+ if target_state is None:
1035
+ console.print("[red]Error: State is required[/red]")
1036
+ console.print(
1037
+ "Use either:\n"
1038
+ " - Flag syntax (recommended): mcp-ticketer transition TICKET-ID --state STATE\n"
1039
+ " - Positional syntax: mcp-ticketer transition TICKET-ID STATE"
1040
+ )
1041
+ raise typer.Exit(1)
1042
+
1058
1043
  # Get the adapter name
1059
1044
  config = load_config()
1060
- adapter_name = adapter.value if adapter else config.get("default_adapter", "aitrackdown")
1045
+ adapter_name = (
1046
+ adapter.value if adapter else config.get("default_adapter", "aitrackdown")
1047
+ )
1061
1048
 
1062
1049
  # Add to queue
1063
1050
  queue = Queue()
1064
1051
  queue_id = queue.add(
1065
1052
  ticket_data={
1066
1053
  "ticket_id": ticket_id,
1067
- "state": state.value if hasattr(state, 'value') else state
1054
+ "state": (
1055
+ target_state.value if hasattr(target_state, "value") else target_state
1056
+ ),
1068
1057
  },
1069
1058
  adapter=adapter_name,
1070
- operation="transition"
1059
+ operation="transition",
1071
1060
  )
1072
1061
 
1073
1062
  console.print(f"[green]✓[/green] Queued state transition: {queue_id}")
1074
- console.print(f" Ticket: {ticket_id} → {state}")
1063
+ console.print(f" Ticket: {ticket_id} → {target_state}")
1075
1064
  console.print("[dim]Use 'mcp-ticketer status {queue_id}' to check progress[/dim]")
1076
1065
 
1077
1066
  # Start worker if needed
@@ -1088,14 +1077,15 @@ def search(
1088
1077
  assignee: Optional[str] = typer.Option(None, "--assignee", "-a"),
1089
1078
  limit: int = typer.Option(10, "--limit", "-l"),
1090
1079
  adapter: Optional[AdapterType] = typer.Option(
1091
- None,
1092
- "--adapter",
1093
- help="Override default adapter"
1080
+ None, "--adapter", help="Override default adapter"
1094
1081
  ),
1095
1082
  ) -> None:
1096
1083
  """Search tickets with advanced query."""
1084
+
1097
1085
  async def _search():
1098
- adapter_instance = get_adapter(override_adapter=adapter.value if adapter else None)
1086
+ adapter_instance = get_adapter(
1087
+ override_adapter=adapter.value if adapter else None
1088
+ )
1099
1089
  search_query = SearchQuery(
1100
1090
  query=query,
1101
1091
  state=state,
@@ -1130,9 +1120,7 @@ app.add_typer(discover_app, name="discover")
1130
1120
 
1131
1121
 
1132
1122
  @app.command()
1133
- def check(
1134
- queue_id: str = typer.Argument(..., help="Queue ID to check")
1135
- ):
1123
+ def check(queue_id: str = typer.Argument(..., help="Queue ID to check")):
1136
1124
  """Check status of a queued operation."""
1137
1125
  queue = Queue()
1138
1126
  item = queue.get_item(queue_id)
@@ -1165,7 +1153,7 @@ def check(
1165
1153
  if item.error_message:
1166
1154
  console.print(f"\n[red]Error:[/red] {item.error_message}")
1167
1155
  elif item.result:
1168
- console.print(f"\n[green]Result:[/green]")
1156
+ console.print("\n[green]Result:[/green]")
1169
1157
  for key, value in item.result.items():
1170
1158
  console.print(f" {key}: {value}")
1171
1159
 
@@ -1176,15 +1164,10 @@ def check(
1176
1164
  @app.command()
1177
1165
  def serve(
1178
1166
  adapter: Optional[AdapterType] = typer.Option(
1179
- None,
1180
- "--adapter",
1181
- "-a",
1182
- help="Override default adapter type"
1167
+ None, "--adapter", "-a", help="Override default adapter type"
1183
1168
  ),
1184
1169
  base_path: Optional[str] = typer.Option(
1185
- None,
1186
- "--base-path",
1187
- help="Base path for AITrackdown adapter"
1170
+ None, "--base-path", help="Base path for AITrackdown adapter"
1188
1171
  ),
1189
1172
  ):
1190
1173
  """Start MCP server for JSON-RPC communication over stdio.
@@ -1206,7 +1189,9 @@ def serve(
1206
1189
  config = load_config()
1207
1190
 
1208
1191
  # Determine adapter type
1209
- adapter_type = adapter.value if adapter else config.get("default_adapter", "aitrackdown")
1192
+ adapter_type = (
1193
+ adapter.value if adapter else config.get("default_adapter", "aitrackdown")
1194
+ )
1210
1195
 
1211
1196
  # Get adapter configuration
1212
1197
  adapters_config = config.get("adapters", {})
@@ -1223,11 +1208,14 @@ def serve(
1223
1208
  # MCP server uses stdio for JSON-RPC, so we can't print to stdout
1224
1209
  # Only print to stderr to avoid interfering with the protocol
1225
1210
  import sys
1211
+
1226
1212
  if sys.stderr.isatty():
1227
1213
  # Only print if stderr is a terminal (not redirected)
1228
1214
  console.file = sys.stderr
1229
1215
  console.print(f"[green]Starting MCP server[/green] with {adapter_type} adapter")
1230
- console.print("[dim]Server running on stdio. Send JSON-RPC requests via stdin.[/dim]")
1216
+ console.print(
1217
+ "[dim]Server running on stdio. Send JSON-RPC requests via stdin.[/dim]"
1218
+ )
1231
1219
 
1232
1220
  # Create and run server
1233
1221
  try:
@@ -1237,7 +1225,7 @@ def serve(
1237
1225
  # Also send this to stderr
1238
1226
  if sys.stderr.isatty():
1239
1227
  console.print("\n[yellow]Server stopped by user[/yellow]")
1240
- if 'server' in locals():
1228
+ if "server" in locals():
1241
1229
  asyncio.run(server.stop())
1242
1230
  except Exception as e:
1243
1231
  # Log error to stderr
@@ -1251,13 +1239,10 @@ def mcp(
1251
1239
  False,
1252
1240
  "--global",
1253
1241
  "-g",
1254
- help="Configure Claude Desktop instead of project-level"
1242
+ help="Configure Claude Desktop instead of project-level",
1255
1243
  ),
1256
1244
  force: bool = typer.Option(
1257
- False,
1258
- "--force",
1259
- "-f",
1260
- help="Overwrite existing configuration"
1245
+ False, "--force", "-f", help="Overwrite existing configuration"
1261
1246
  ),
1262
1247
  ):
1263
1248
  """Configure Claude Code to use mcp-ticketer MCP server.
@@ -1283,4 +1268,4 @@ def main():
1283
1268
 
1284
1269
 
1285
1270
  if __name__ == "__main__":
1286
- main()
1271
+ main()