mcp-ticketer 0.3.5__py3-none-any.whl → 0.12.0__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.
- mcp_ticketer/__version__.py +3 -3
- mcp_ticketer/adapters/__init__.py +2 -0
- mcp_ticketer/adapters/aitrackdown.py +263 -14
- mcp_ticketer/adapters/asana/__init__.py +15 -0
- mcp_ticketer/adapters/asana/adapter.py +1308 -0
- mcp_ticketer/adapters/asana/client.py +292 -0
- mcp_ticketer/adapters/asana/mappers.py +334 -0
- mcp_ticketer/adapters/asana/types.py +146 -0
- mcp_ticketer/adapters/github.py +326 -109
- mcp_ticketer/adapters/hybrid.py +11 -11
- mcp_ticketer/adapters/jira.py +271 -25
- mcp_ticketer/adapters/linear/adapter.py +693 -39
- mcp_ticketer/adapters/linear/client.py +61 -9
- mcp_ticketer/adapters/linear/mappers.py +9 -3
- mcp_ticketer/adapters/linear/queries.py +9 -7
- mcp_ticketer/cache/memory.py +9 -8
- mcp_ticketer/cli/adapter_diagnostics.py +1 -1
- mcp_ticketer/cli/auggie_configure.py +104 -15
- mcp_ticketer/cli/codex_configure.py +188 -32
- mcp_ticketer/cli/configure.py +37 -48
- mcp_ticketer/cli/diagnostics.py +20 -18
- mcp_ticketer/cli/discover.py +292 -26
- mcp_ticketer/cli/gemini_configure.py +107 -26
- mcp_ticketer/cli/instruction_commands.py +429 -0
- mcp_ticketer/cli/linear_commands.py +105 -22
- mcp_ticketer/cli/main.py +1830 -435
- mcp_ticketer/cli/mcp_configure.py +296 -89
- mcp_ticketer/cli/migrate_config.py +12 -8
- mcp_ticketer/cli/platform_commands.py +123 -0
- mcp_ticketer/cli/platform_detection.py +412 -0
- mcp_ticketer/cli/python_detection.py +126 -0
- mcp_ticketer/cli/queue_commands.py +15 -15
- mcp_ticketer/cli/simple_health.py +1 -1
- mcp_ticketer/cli/ticket_commands.py +773 -0
- mcp_ticketer/cli/update_checker.py +313 -0
- mcp_ticketer/cli/utils.py +67 -62
- mcp_ticketer/core/__init__.py +14 -1
- mcp_ticketer/core/adapter.py +84 -15
- mcp_ticketer/core/config.py +44 -39
- mcp_ticketer/core/env_discovery.py +42 -12
- mcp_ticketer/core/env_loader.py +15 -14
- mcp_ticketer/core/exceptions.py +3 -3
- mcp_ticketer/core/http_client.py +26 -26
- mcp_ticketer/core/instructions.py +405 -0
- mcp_ticketer/core/mappers.py +11 -11
- mcp_ticketer/core/models.py +50 -20
- mcp_ticketer/core/onepassword_secrets.py +379 -0
- mcp_ticketer/core/project_config.py +57 -35
- mcp_ticketer/core/registry.py +3 -3
- mcp_ticketer/defaults/ticket_instructions.md +644 -0
- mcp_ticketer/mcp/__init__.py +29 -1
- mcp_ticketer/mcp/__main__.py +60 -0
- mcp_ticketer/mcp/server/__init__.py +25 -0
- mcp_ticketer/mcp/server/__main__.py +60 -0
- mcp_ticketer/mcp/{dto.py → server/dto.py} +32 -32
- mcp_ticketer/mcp/{server.py → server/main.py} +127 -74
- mcp_ticketer/mcp/{response_builder.py → server/response_builder.py} +2 -2
- mcp_ticketer/mcp/server/server_sdk.py +93 -0
- mcp_ticketer/mcp/server/tools/__init__.py +47 -0
- mcp_ticketer/mcp/server/tools/attachment_tools.py +226 -0
- mcp_ticketer/mcp/server/tools/bulk_tools.py +273 -0
- mcp_ticketer/mcp/server/tools/comment_tools.py +90 -0
- mcp_ticketer/mcp/server/tools/config_tools.py +381 -0
- mcp_ticketer/mcp/server/tools/hierarchy_tools.py +532 -0
- mcp_ticketer/mcp/server/tools/instruction_tools.py +293 -0
- mcp_ticketer/mcp/server/tools/pr_tools.py +154 -0
- mcp_ticketer/mcp/server/tools/search_tools.py +206 -0
- mcp_ticketer/mcp/server/tools/ticket_tools.py +430 -0
- mcp_ticketer/mcp/server/tools/user_ticket_tools.py +382 -0
- mcp_ticketer/queue/__init__.py +1 -0
- mcp_ticketer/queue/health_monitor.py +5 -4
- mcp_ticketer/queue/manager.py +15 -51
- mcp_ticketer/queue/queue.py +19 -19
- mcp_ticketer/queue/run_worker.py +1 -1
- mcp_ticketer/queue/ticket_registry.py +14 -14
- mcp_ticketer/queue/worker.py +16 -14
- {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/METADATA +168 -32
- mcp_ticketer-0.12.0.dist-info/RECORD +91 -0
- mcp_ticketer-0.3.5.dist-info/RECORD +0 -62
- /mcp_ticketer/mcp/{constants.py → server/constants.py} +0 -0
- {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/WHEEL +0 -0
- {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/entry_points.txt +0 -0
- {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/licenses/LICENSE +0 -0
- {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/top_level.txt +0 -0
mcp_ticketer/core/models.py
CHANGED
|
@@ -25,7 +25,7 @@ Example:
|
|
|
25
25
|
|
|
26
26
|
from datetime import datetime
|
|
27
27
|
from enum import Enum
|
|
28
|
-
from typing import Any
|
|
28
|
+
from typing import Any
|
|
29
29
|
|
|
30
30
|
from pydantic import BaseModel, ConfigDict, Field
|
|
31
31
|
|
|
@@ -194,14 +194,14 @@ class BaseTicket(BaseModel):
|
|
|
194
194
|
|
|
195
195
|
model_config = ConfigDict(use_enum_values=True)
|
|
196
196
|
|
|
197
|
-
id:
|
|
197
|
+
id: str | None = Field(None, description="Unique identifier")
|
|
198
198
|
title: str = Field(..., min_length=1, description="Ticket title")
|
|
199
|
-
description:
|
|
199
|
+
description: str | None = Field(None, description="Detailed description")
|
|
200
200
|
state: TicketState = Field(TicketState.OPEN, description="Current state")
|
|
201
201
|
priority: Priority = Field(Priority.MEDIUM, description="Priority level")
|
|
202
202
|
tags: list[str] = Field(default_factory=list, description="Tags/labels")
|
|
203
|
-
created_at:
|
|
204
|
-
updated_at:
|
|
203
|
+
created_at: datetime | None = Field(None, description="Creation timestamp")
|
|
204
|
+
updated_at: datetime | None = Field(None, description="Last update timestamp")
|
|
205
205
|
|
|
206
206
|
# Metadata for field mapping to different systems
|
|
207
207
|
metadata: dict[str, Any] = Field(
|
|
@@ -270,20 +270,20 @@ class Task(BaseTicket):
|
|
|
270
270
|
ticket_type: TicketType = Field(
|
|
271
271
|
default=TicketType.ISSUE, description="Ticket type in hierarchy"
|
|
272
272
|
)
|
|
273
|
-
parent_issue:
|
|
274
|
-
parent_epic:
|
|
273
|
+
parent_issue: str | None = Field(None, description="Parent issue ID (for tasks)")
|
|
274
|
+
parent_epic: str | None = Field(
|
|
275
275
|
None,
|
|
276
276
|
description="Parent epic/project ID (for issues). Synonym: 'project'",
|
|
277
277
|
)
|
|
278
|
-
assignee:
|
|
278
|
+
assignee: str | None = Field(None, description="Assigned user")
|
|
279
279
|
children: list[str] = Field(default_factory=list, description="Child task IDs")
|
|
280
280
|
|
|
281
281
|
# Additional fields common across systems
|
|
282
|
-
estimated_hours:
|
|
283
|
-
actual_hours:
|
|
282
|
+
estimated_hours: float | None = Field(None, description="Time estimate")
|
|
283
|
+
actual_hours: float | None = Field(None, description="Actual time spent")
|
|
284
284
|
|
|
285
285
|
@property
|
|
286
|
-
def project(self) ->
|
|
286
|
+
def project(self) -> str | None:
|
|
287
287
|
"""Synonym for parent_epic.
|
|
288
288
|
|
|
289
289
|
Returns:
|
|
@@ -293,7 +293,7 @@ class Task(BaseTicket):
|
|
|
293
293
|
return self.parent_epic
|
|
294
294
|
|
|
295
295
|
@project.setter
|
|
296
|
-
def project(self, value:
|
|
296
|
+
def project(self, value: str | None) -> None:
|
|
297
297
|
"""Set parent_epic via project synonym.
|
|
298
298
|
|
|
299
299
|
Args:
|
|
@@ -345,23 +345,53 @@ class Comment(BaseModel):
|
|
|
345
345
|
|
|
346
346
|
model_config = ConfigDict(use_enum_values=True)
|
|
347
347
|
|
|
348
|
-
id:
|
|
348
|
+
id: str | None = Field(None, description="Comment ID")
|
|
349
349
|
ticket_id: str = Field(..., description="Parent ticket ID")
|
|
350
|
-
author:
|
|
350
|
+
author: str | None = Field(None, description="Comment author")
|
|
351
351
|
content: str = Field(..., min_length=1, description="Comment text")
|
|
352
|
-
created_at:
|
|
352
|
+
created_at: datetime | None = Field(None, description="Creation timestamp")
|
|
353
353
|
metadata: dict[str, Any] = Field(
|
|
354
354
|
default_factory=dict, description="System-specific metadata"
|
|
355
355
|
)
|
|
356
356
|
|
|
357
357
|
|
|
358
|
+
class Attachment(BaseModel):
|
|
359
|
+
"""File attachment metadata for tickets.
|
|
360
|
+
|
|
361
|
+
Represents a file attached to a ticket across all adapters.
|
|
362
|
+
Each adapter maps its native attachment format to this model.
|
|
363
|
+
"""
|
|
364
|
+
|
|
365
|
+
model_config = ConfigDict(use_enum_values=True)
|
|
366
|
+
|
|
367
|
+
id: str | None = Field(None, description="Attachment unique identifier")
|
|
368
|
+
ticket_id: str = Field(..., description="Parent ticket identifier")
|
|
369
|
+
filename: str = Field(..., description="Original filename")
|
|
370
|
+
url: str | None = Field(None, description="Download URL or file path")
|
|
371
|
+
content_type: str | None = Field(
|
|
372
|
+
None, description="MIME type (e.g., 'application/pdf', 'image/png')"
|
|
373
|
+
)
|
|
374
|
+
size_bytes: int | None = Field(None, description="File size in bytes")
|
|
375
|
+
created_at: datetime | None = Field(None, description="Upload timestamp")
|
|
376
|
+
created_by: str | None = Field(None, description="User who uploaded the attachment")
|
|
377
|
+
description: str | None = Field(None, description="Attachment description or notes")
|
|
378
|
+
metadata: dict[str, Any] = Field(
|
|
379
|
+
default_factory=dict, description="Adapter-specific attachment metadata"
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
def __str__(self) -> str:
|
|
383
|
+
"""Return string representation showing filename and size."""
|
|
384
|
+
size_str = f" ({self.size_bytes} bytes)" if self.size_bytes else ""
|
|
385
|
+
return f"Attachment({self.filename}{size_str})"
|
|
386
|
+
|
|
387
|
+
|
|
358
388
|
class SearchQuery(BaseModel):
|
|
359
389
|
"""Search query parameters."""
|
|
360
390
|
|
|
361
|
-
query:
|
|
362
|
-
state:
|
|
363
|
-
priority:
|
|
364
|
-
tags:
|
|
365
|
-
assignee:
|
|
391
|
+
query: str | None = Field(None, description="Text search query")
|
|
392
|
+
state: TicketState | None = Field(None, description="Filter by state")
|
|
393
|
+
priority: Priority | None = Field(None, description="Filter by priority")
|
|
394
|
+
tags: list[str] | None = Field(None, description="Filter by tags")
|
|
395
|
+
assignee: str | None = Field(None, description="Filter by assignee")
|
|
366
396
|
limit: int = Field(10, gt=0, le=100, description="Maximum results")
|
|
367
397
|
offset: int = Field(0, ge=0, description="Result offset for pagination")
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
"""1Password CLI integration for secure secret management.
|
|
2
|
+
|
|
3
|
+
This module provides automatic secret loading from 1Password using the op CLI,
|
|
4
|
+
supporting:
|
|
5
|
+
- Detection of op:// secret references in .env files
|
|
6
|
+
- Automatic resolution using `op run` or `op inject`
|
|
7
|
+
- Fallback to regular .env values if 1Password CLI is not available
|
|
8
|
+
- Support for .env.1password template files
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class OnePasswordConfig:
|
|
23
|
+
"""Configuration for 1Password integration."""
|
|
24
|
+
|
|
25
|
+
enabled: bool = True
|
|
26
|
+
vault: str | None = None # Default vault for secret references
|
|
27
|
+
service_account_token: str | None = None # For CI/CD environments
|
|
28
|
+
fallback_to_env: bool = True # Fall back to regular .env if op CLI unavailable
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class OnePasswordSecretsLoader:
|
|
32
|
+
"""Load secrets from 1Password using the op CLI.
|
|
33
|
+
|
|
34
|
+
This class provides methods to:
|
|
35
|
+
1. Check if 1Password CLI is installed and authenticated
|
|
36
|
+
2. Resolve op:// secret references in .env files
|
|
37
|
+
3. Load secrets into environment variables
|
|
38
|
+
4. Create .env templates with op:// references
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, config: OnePasswordConfig | None = None) -> None:
|
|
42
|
+
"""Initialize the 1Password secrets loader.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
config: Configuration for 1Password integration
|
|
46
|
+
|
|
47
|
+
"""
|
|
48
|
+
self.config = config or OnePasswordConfig()
|
|
49
|
+
self._op_available: bool | None = None
|
|
50
|
+
self._op_authenticated: bool | None = None
|
|
51
|
+
|
|
52
|
+
def is_op_available(self) -> bool:
|
|
53
|
+
"""Check if 1Password CLI is installed.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
True if op CLI is available, False otherwise
|
|
57
|
+
|
|
58
|
+
"""
|
|
59
|
+
if self._op_available is None:
|
|
60
|
+
self._op_available = shutil.which("op") is not None
|
|
61
|
+
if not self._op_available:
|
|
62
|
+
logger.debug("1Password CLI (op) not found in PATH")
|
|
63
|
+
return self._op_available
|
|
64
|
+
|
|
65
|
+
def is_authenticated(self) -> bool:
|
|
66
|
+
"""Check if user is authenticated with 1Password.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
True if authenticated, False otherwise
|
|
70
|
+
|
|
71
|
+
"""
|
|
72
|
+
if not self.is_op_available():
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
if self._op_authenticated is None:
|
|
76
|
+
try:
|
|
77
|
+
# Try to list accounts to check authentication
|
|
78
|
+
result = subprocess.run(
|
|
79
|
+
["op", "account", "list"],
|
|
80
|
+
capture_output=True,
|
|
81
|
+
text=True,
|
|
82
|
+
timeout=5,
|
|
83
|
+
check=False,
|
|
84
|
+
)
|
|
85
|
+
self._op_authenticated = result.returncode == 0
|
|
86
|
+
if not self._op_authenticated:
|
|
87
|
+
logger.debug(
|
|
88
|
+
"1Password CLI not authenticated. Run 'op signin' first."
|
|
89
|
+
)
|
|
90
|
+
except (subprocess.TimeoutExpired, FileNotFoundError) as e:
|
|
91
|
+
logger.debug(f"Error checking 1Password authentication: {e}")
|
|
92
|
+
self._op_authenticated = False
|
|
93
|
+
|
|
94
|
+
return self._op_authenticated
|
|
95
|
+
|
|
96
|
+
def load_secrets_from_env_file(
|
|
97
|
+
self, env_file: Path, output_dict: dict[str, str] | None = None
|
|
98
|
+
) -> dict[str, str]:
|
|
99
|
+
"""Load secrets from .env file, resolving 1Password references.
|
|
100
|
+
|
|
101
|
+
This method:
|
|
102
|
+
1. Checks if the .env file contains op:// references
|
|
103
|
+
2. If yes and op CLI is available, uses op inject to resolve them
|
|
104
|
+
3. If no op references or CLI unavailable, returns regular dotenv values
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
env_file: Path to .env file (may contain op:// references)
|
|
108
|
+
output_dict: Optional dict to update with loaded secrets
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
Dictionary of environment variables with secrets resolved
|
|
112
|
+
|
|
113
|
+
"""
|
|
114
|
+
if not env_file.exists():
|
|
115
|
+
logger.warning(f"Environment file not found: {env_file}")
|
|
116
|
+
return output_dict or {}
|
|
117
|
+
|
|
118
|
+
# Read the file to check for op:// references
|
|
119
|
+
content = env_file.read_text(encoding="utf-8")
|
|
120
|
+
has_op_references = "op://" in content
|
|
121
|
+
|
|
122
|
+
if has_op_references and self.is_authenticated():
|
|
123
|
+
# Use op inject to resolve references
|
|
124
|
+
return self._inject_secrets(env_file, output_dict)
|
|
125
|
+
else:
|
|
126
|
+
# Fall back to regular dotenv parsing
|
|
127
|
+
if has_op_references and not self.is_authenticated():
|
|
128
|
+
logger.warning(
|
|
129
|
+
f"File {env_file} contains 1Password references but op CLI "
|
|
130
|
+
"is not authenticated. Using fallback values."
|
|
131
|
+
)
|
|
132
|
+
return self._load_regular_env(env_file, output_dict)
|
|
133
|
+
|
|
134
|
+
def _inject_secrets(
|
|
135
|
+
self, env_file: Path, output_dict: dict[str, str] | None = None
|
|
136
|
+
) -> dict[str, str]:
|
|
137
|
+
"""Use op inject to resolve secret references in .env file.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
env_file: Path to .env file with op:// references
|
|
141
|
+
output_dict: Optional dict to update
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
Dictionary with resolved secrets
|
|
145
|
+
|
|
146
|
+
"""
|
|
147
|
+
try:
|
|
148
|
+
# Use op inject to resolve references
|
|
149
|
+
cmd = ["op", "inject", "--in-file", str(env_file)]
|
|
150
|
+
|
|
151
|
+
# Add service account token if provided
|
|
152
|
+
env = None
|
|
153
|
+
if self.config.service_account_token:
|
|
154
|
+
env = {"OP_SERVICE_ACCOUNT_TOKEN": self.config.service_account_token}
|
|
155
|
+
|
|
156
|
+
result = subprocess.run(
|
|
157
|
+
cmd,
|
|
158
|
+
capture_output=True,
|
|
159
|
+
text=True,
|
|
160
|
+
timeout=30,
|
|
161
|
+
check=True,
|
|
162
|
+
env=env,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
# Parse the injected output
|
|
166
|
+
secrets = self._parse_env_output(result.stdout)
|
|
167
|
+
|
|
168
|
+
if output_dict is not None:
|
|
169
|
+
output_dict.update(secrets)
|
|
170
|
+
return output_dict
|
|
171
|
+
return secrets
|
|
172
|
+
|
|
173
|
+
except subprocess.CalledProcessError as e:
|
|
174
|
+
logger.error(f"Error injecting 1Password secrets: {e.stderr}")
|
|
175
|
+
if self.config.fallback_to_env:
|
|
176
|
+
logger.info("Falling back to regular .env parsing")
|
|
177
|
+
return self._load_regular_env(env_file, output_dict)
|
|
178
|
+
raise
|
|
179
|
+
except subprocess.TimeoutExpired:
|
|
180
|
+
logger.error("Timeout while injecting 1Password secrets")
|
|
181
|
+
if self.config.fallback_to_env:
|
|
182
|
+
return self._load_regular_env(env_file, output_dict)
|
|
183
|
+
raise
|
|
184
|
+
|
|
185
|
+
def _load_regular_env(
|
|
186
|
+
self, env_file: Path, output_dict: dict[str, str] | None = None
|
|
187
|
+
) -> dict[str, str]:
|
|
188
|
+
"""Load environment variables without 1Password resolution.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
env_file: Path to .env file
|
|
192
|
+
output_dict: Optional dict to update
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
Dictionary of environment variables
|
|
196
|
+
|
|
197
|
+
"""
|
|
198
|
+
from dotenv import dotenv_values
|
|
199
|
+
|
|
200
|
+
values = dotenv_values(env_file)
|
|
201
|
+
|
|
202
|
+
if output_dict is not None:
|
|
203
|
+
output_dict.update(values)
|
|
204
|
+
return output_dict
|
|
205
|
+
return dict(values)
|
|
206
|
+
|
|
207
|
+
def _parse_env_output(self, output: str) -> dict[str, str]:
|
|
208
|
+
"""Parse environment variable output from op inject.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
output: String output from op inject
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
Dictionary of parsed environment variables
|
|
215
|
+
|
|
216
|
+
"""
|
|
217
|
+
env_vars = {}
|
|
218
|
+
for line in output.splitlines():
|
|
219
|
+
line = line.strip()
|
|
220
|
+
if not line or line.startswith("#"):
|
|
221
|
+
continue
|
|
222
|
+
|
|
223
|
+
# Split on first = only
|
|
224
|
+
if "=" in line:
|
|
225
|
+
key, value = line.split("=", 1)
|
|
226
|
+
key = key.strip()
|
|
227
|
+
value = value.strip()
|
|
228
|
+
|
|
229
|
+
# Remove quotes if present
|
|
230
|
+
if value.startswith('"') and value.endswith('"'):
|
|
231
|
+
value = value[1:-1]
|
|
232
|
+
elif value.startswith("'") and value.endswith("'"):
|
|
233
|
+
value = value[1:-1]
|
|
234
|
+
|
|
235
|
+
env_vars[key] = value
|
|
236
|
+
|
|
237
|
+
return env_vars
|
|
238
|
+
|
|
239
|
+
def create_template_file(
|
|
240
|
+
self,
|
|
241
|
+
output_path: Path,
|
|
242
|
+
adapter_type: str,
|
|
243
|
+
vault_name: str = "Development",
|
|
244
|
+
item_name: str | None = None,
|
|
245
|
+
) -> None:
|
|
246
|
+
"""Create a .env template file with 1Password secret references.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
output_path: Path where to create the template file
|
|
250
|
+
adapter_type: Type of adapter (linear, github, jira, aitrackdown)
|
|
251
|
+
vault_name: Name of 1Password vault to use
|
|
252
|
+
item_name: Name of 1Password item (defaults to adapter name)
|
|
253
|
+
|
|
254
|
+
"""
|
|
255
|
+
if item_name is None:
|
|
256
|
+
item_name = adapter_type.upper()
|
|
257
|
+
|
|
258
|
+
templates = {
|
|
259
|
+
"linear": f"""# Linear Configuration with 1Password
|
|
260
|
+
# This file contains secret references that will be resolved by 1Password CLI
|
|
261
|
+
# Run: op run --env-file=.env.1password -- mcp-ticketer discover
|
|
262
|
+
|
|
263
|
+
LINEAR_API_KEY="op://{vault_name}/{item_name}/api_key"
|
|
264
|
+
LINEAR_TEAM_ID="op://{vault_name}/{item_name}/team_id"
|
|
265
|
+
LINEAR_TEAM_KEY="op://{vault_name}/{item_name}/team_key"
|
|
266
|
+
LINEAR_PROJECT_ID="op://{vault_name}/{item_name}/project_id"
|
|
267
|
+
""",
|
|
268
|
+
"github": f"""# GitHub Configuration with 1Password
|
|
269
|
+
# This file contains secret references that will be resolved by 1Password CLI
|
|
270
|
+
# Run: op run --env-file=.env.1password -- mcp-ticketer discover
|
|
271
|
+
|
|
272
|
+
GITHUB_TOKEN="op://{vault_name}/{item_name}/token"
|
|
273
|
+
GITHUB_OWNER="op://{vault_name}/{item_name}/owner"
|
|
274
|
+
GITHUB_REPO="op://{vault_name}/{item_name}/repo"
|
|
275
|
+
""",
|
|
276
|
+
"jira": f"""# JIRA Configuration with 1Password
|
|
277
|
+
# This file contains secret references that will be resolved by 1Password CLI
|
|
278
|
+
# Run: op run --env-file=.env.1password -- mcp-ticketer discover
|
|
279
|
+
|
|
280
|
+
JIRA_SERVER="op://{vault_name}/{item_name}/server"
|
|
281
|
+
JIRA_EMAIL="op://{vault_name}/{item_name}/email"
|
|
282
|
+
JIRA_API_TOKEN="op://{vault_name}/{item_name}/api_token"
|
|
283
|
+
JIRA_PROJECT_KEY="op://{vault_name}/{item_name}/project_key"
|
|
284
|
+
""",
|
|
285
|
+
"aitrackdown": """# AITrackdown Configuration
|
|
286
|
+
# AITrackdown doesn't use API keys, but you can store the base path
|
|
287
|
+
|
|
288
|
+
AITRACKDOWN_PATH=".aitrackdown"
|
|
289
|
+
""",
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
template = templates.get(adapter_type.lower(), "")
|
|
293
|
+
if template:
|
|
294
|
+
output_path.write_text(template, encoding="utf-8")
|
|
295
|
+
logger.info(f"Created 1Password template file: {output_path}")
|
|
296
|
+
else:
|
|
297
|
+
logger.error(f"Unknown adapter type: {adapter_type}")
|
|
298
|
+
|
|
299
|
+
def run_with_secrets(
|
|
300
|
+
self, command: list[str], env_file: Path | None = None
|
|
301
|
+
) -> subprocess.CompletedProcess[str]:
|
|
302
|
+
"""Run a command with secrets loaded from 1Password.
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
command: Command and arguments to run
|
|
306
|
+
env_file: Optional .env file with secret references
|
|
307
|
+
|
|
308
|
+
Returns:
|
|
309
|
+
CompletedProcess result
|
|
310
|
+
|
|
311
|
+
"""
|
|
312
|
+
if not self.is_authenticated():
|
|
313
|
+
raise RuntimeError(
|
|
314
|
+
"1Password CLI not authenticated. Run 'op signin' first."
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
cmd = ["op", "run"]
|
|
318
|
+
|
|
319
|
+
if env_file:
|
|
320
|
+
cmd.extend(["--env-file", str(env_file)])
|
|
321
|
+
|
|
322
|
+
cmd.append("--")
|
|
323
|
+
cmd.extend(command)
|
|
324
|
+
|
|
325
|
+
return subprocess.run(cmd, capture_output=True, text=True, check=True)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def check_op_cli_status() -> dict[str, Any]:
|
|
329
|
+
"""Check the status of 1Password CLI installation and authentication.
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
Dictionary with status information
|
|
333
|
+
|
|
334
|
+
"""
|
|
335
|
+
loader = OnePasswordSecretsLoader()
|
|
336
|
+
|
|
337
|
+
status = {
|
|
338
|
+
"installed": loader.is_op_available(),
|
|
339
|
+
"authenticated": False,
|
|
340
|
+
"version": None,
|
|
341
|
+
"accounts": [],
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if status["installed"]:
|
|
345
|
+
# Get version
|
|
346
|
+
try:
|
|
347
|
+
result = subprocess.run(
|
|
348
|
+
["op", "--version"],
|
|
349
|
+
capture_output=True,
|
|
350
|
+
text=True,
|
|
351
|
+
timeout=5,
|
|
352
|
+
check=False,
|
|
353
|
+
)
|
|
354
|
+
if result.returncode == 0:
|
|
355
|
+
status["version"] = result.stdout.strip()
|
|
356
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
357
|
+
pass
|
|
358
|
+
|
|
359
|
+
# Check authentication
|
|
360
|
+
status["authenticated"] = loader.is_authenticated()
|
|
361
|
+
|
|
362
|
+
# Get accounts if authenticated
|
|
363
|
+
if status["authenticated"]:
|
|
364
|
+
try:
|
|
365
|
+
result = subprocess.run(
|
|
366
|
+
["op", "account", "list", "--format=json"],
|
|
367
|
+
capture_output=True,
|
|
368
|
+
text=True,
|
|
369
|
+
timeout=5,
|
|
370
|
+
check=False,
|
|
371
|
+
)
|
|
372
|
+
if result.returncode == 0:
|
|
373
|
+
import json
|
|
374
|
+
|
|
375
|
+
status["accounts"] = json.loads(result.stdout)
|
|
376
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, json.JSONDecodeError):
|
|
377
|
+
pass
|
|
378
|
+
|
|
379
|
+
return status
|