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.

Files changed (84) hide show
  1. mcp_ticketer/__version__.py +3 -3
  2. mcp_ticketer/adapters/__init__.py +2 -0
  3. mcp_ticketer/adapters/aitrackdown.py +263 -14
  4. mcp_ticketer/adapters/asana/__init__.py +15 -0
  5. mcp_ticketer/adapters/asana/adapter.py +1308 -0
  6. mcp_ticketer/adapters/asana/client.py +292 -0
  7. mcp_ticketer/adapters/asana/mappers.py +334 -0
  8. mcp_ticketer/adapters/asana/types.py +146 -0
  9. mcp_ticketer/adapters/github.py +326 -109
  10. mcp_ticketer/adapters/hybrid.py +11 -11
  11. mcp_ticketer/adapters/jira.py +271 -25
  12. mcp_ticketer/adapters/linear/adapter.py +693 -39
  13. mcp_ticketer/adapters/linear/client.py +61 -9
  14. mcp_ticketer/adapters/linear/mappers.py +9 -3
  15. mcp_ticketer/adapters/linear/queries.py +9 -7
  16. mcp_ticketer/cache/memory.py +9 -8
  17. mcp_ticketer/cli/adapter_diagnostics.py +1 -1
  18. mcp_ticketer/cli/auggie_configure.py +104 -15
  19. mcp_ticketer/cli/codex_configure.py +188 -32
  20. mcp_ticketer/cli/configure.py +37 -48
  21. mcp_ticketer/cli/diagnostics.py +20 -18
  22. mcp_ticketer/cli/discover.py +292 -26
  23. mcp_ticketer/cli/gemini_configure.py +107 -26
  24. mcp_ticketer/cli/instruction_commands.py +429 -0
  25. mcp_ticketer/cli/linear_commands.py +105 -22
  26. mcp_ticketer/cli/main.py +1830 -435
  27. mcp_ticketer/cli/mcp_configure.py +296 -89
  28. mcp_ticketer/cli/migrate_config.py +12 -8
  29. mcp_ticketer/cli/platform_commands.py +123 -0
  30. mcp_ticketer/cli/platform_detection.py +412 -0
  31. mcp_ticketer/cli/python_detection.py +126 -0
  32. mcp_ticketer/cli/queue_commands.py +15 -15
  33. mcp_ticketer/cli/simple_health.py +1 -1
  34. mcp_ticketer/cli/ticket_commands.py +773 -0
  35. mcp_ticketer/cli/update_checker.py +313 -0
  36. mcp_ticketer/cli/utils.py +67 -62
  37. mcp_ticketer/core/__init__.py +14 -1
  38. mcp_ticketer/core/adapter.py +84 -15
  39. mcp_ticketer/core/config.py +44 -39
  40. mcp_ticketer/core/env_discovery.py +42 -12
  41. mcp_ticketer/core/env_loader.py +15 -14
  42. mcp_ticketer/core/exceptions.py +3 -3
  43. mcp_ticketer/core/http_client.py +26 -26
  44. mcp_ticketer/core/instructions.py +405 -0
  45. mcp_ticketer/core/mappers.py +11 -11
  46. mcp_ticketer/core/models.py +50 -20
  47. mcp_ticketer/core/onepassword_secrets.py +379 -0
  48. mcp_ticketer/core/project_config.py +57 -35
  49. mcp_ticketer/core/registry.py +3 -3
  50. mcp_ticketer/defaults/ticket_instructions.md +644 -0
  51. mcp_ticketer/mcp/__init__.py +29 -1
  52. mcp_ticketer/mcp/__main__.py +60 -0
  53. mcp_ticketer/mcp/server/__init__.py +25 -0
  54. mcp_ticketer/mcp/server/__main__.py +60 -0
  55. mcp_ticketer/mcp/{dto.py → server/dto.py} +32 -32
  56. mcp_ticketer/mcp/{server.py → server/main.py} +127 -74
  57. mcp_ticketer/mcp/{response_builder.py → server/response_builder.py} +2 -2
  58. mcp_ticketer/mcp/server/server_sdk.py +93 -0
  59. mcp_ticketer/mcp/server/tools/__init__.py +47 -0
  60. mcp_ticketer/mcp/server/tools/attachment_tools.py +226 -0
  61. mcp_ticketer/mcp/server/tools/bulk_tools.py +273 -0
  62. mcp_ticketer/mcp/server/tools/comment_tools.py +90 -0
  63. mcp_ticketer/mcp/server/tools/config_tools.py +381 -0
  64. mcp_ticketer/mcp/server/tools/hierarchy_tools.py +532 -0
  65. mcp_ticketer/mcp/server/tools/instruction_tools.py +293 -0
  66. mcp_ticketer/mcp/server/tools/pr_tools.py +154 -0
  67. mcp_ticketer/mcp/server/tools/search_tools.py +206 -0
  68. mcp_ticketer/mcp/server/tools/ticket_tools.py +430 -0
  69. mcp_ticketer/mcp/server/tools/user_ticket_tools.py +382 -0
  70. mcp_ticketer/queue/__init__.py +1 -0
  71. mcp_ticketer/queue/health_monitor.py +5 -4
  72. mcp_ticketer/queue/manager.py +15 -51
  73. mcp_ticketer/queue/queue.py +19 -19
  74. mcp_ticketer/queue/run_worker.py +1 -1
  75. mcp_ticketer/queue/ticket_registry.py +14 -14
  76. mcp_ticketer/queue/worker.py +16 -14
  77. {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/METADATA +168 -32
  78. mcp_ticketer-0.12.0.dist-info/RECORD +91 -0
  79. mcp_ticketer-0.3.5.dist-info/RECORD +0 -62
  80. /mcp_ticketer/mcp/{constants.py → server/constants.py} +0 -0
  81. {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/WHEEL +0 -0
  82. {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/entry_points.txt +0 -0
  83. {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/licenses/LICENSE +0 -0
  84. {mcp_ticketer-0.3.5.dist-info → mcp_ticketer-0.12.0.dist-info}/top_level.txt +0 -0
@@ -25,7 +25,7 @@ Example:
25
25
 
26
26
  from datetime import datetime
27
27
  from enum import Enum
28
- from typing import Any, Optional
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: Optional[str] = Field(None, description="Unique identifier")
197
+ id: str | None = Field(None, description="Unique identifier")
198
198
  title: str = Field(..., min_length=1, description="Ticket title")
199
- description: Optional[str] = Field(None, description="Detailed 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: Optional[datetime] = Field(None, description="Creation timestamp")
204
- updated_at: Optional[datetime] = Field(None, description="Last update timestamp")
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: Optional[str] = Field(None, description="Parent issue ID (for tasks)")
274
- parent_epic: Optional[str] = Field(
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: Optional[str] = Field(None, description="Assigned user")
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: Optional[float] = Field(None, description="Time estimate")
283
- actual_hours: Optional[float] = Field(None, description="Actual time spent")
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) -> Optional[str]:
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: Optional[str]) -> None:
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: Optional[str] = Field(None, description="Comment ID")
348
+ id: str | None = Field(None, description="Comment ID")
349
349
  ticket_id: str = Field(..., description="Parent ticket ID")
350
- author: Optional[str] = Field(None, description="Comment author")
350
+ author: str | None = Field(None, description="Comment author")
351
351
  content: str = Field(..., min_length=1, description="Comment text")
352
- created_at: Optional[datetime] = Field(None, description="Creation timestamp")
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: Optional[str] = Field(None, description="Text search query")
362
- state: Optional[TicketState] = Field(None, description="Filter by state")
363
- priority: Optional[Priority] = Field(None, description="Filter by priority")
364
- tags: Optional[list[str]] = Field(None, description="Filter by tags")
365
- assignee: Optional[str] = Field(None, description="Filter by 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