gnosys-strata 1.1.4__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.
@@ -0,0 +1,233 @@
1
+ """
2
+ Field-based weighted search engine
3
+
4
+ A simpler alternative to BM25 that focuses on field-level matching
5
+ with explicit weights and avoids document length bias.
6
+
7
+ ## Algorithm Design
8
+
9
+ This search engine implements a three-layer scoring system to prevent score explosion
10
+ and ensure relevant results:
11
+
12
+ ### Layer 1: Match Quality Scoring
13
+ For each token-field match, we assign a base score based on match quality:
14
+ - Exact match: weight × 3.0 (e.g., field value "projects" == query "projects")
15
+ - Word boundary match: weight × 2.0 (e.g., "list projects" matches query "projects" as complete word)
16
+ - Partial match: weight × 1.0 (e.g., "project_list" contains query "project")
17
+
18
+ ### Layer 2: Intra-field Token Decay (Harmonic Series)
19
+ When multiple query tokens match within the same field, we apply diminishing returns:
20
+ - 1st token: 100% of score
21
+ - 2nd token: 50% of score (1/2)
22
+ - 3rd token: 33% of score (1/3)
23
+ - 4th token: 25% of score (1/4)
24
+
25
+ This prevents long descriptions from accumulating excessive scores by matching many tokens.
26
+
27
+ ### Layer 3: Per-field Logarithmic Dampening
28
+ After calculating field scores, we apply logarithmic dampening based on field type:
29
+ - Description fields (description, param_desc): log(1 + score) × 5 (stronger dampening)
30
+ - Identifier fields (service, operation, tag, path, etc.): log(1 + score) × 10 (lighter dampening)
31
+
32
+ This prevents any single field from dominating the final score, especially verbose fields.
33
+
34
+ ### Final Score Calculation
35
+ - Sum all dampened field scores
36
+ - Add diversity bonus: sqrt(matched_field_types) × 3
37
+ (rewards matching across multiple field types)
38
+
39
+ ## Problem Scenarios This Solves
40
+
41
+ ### Scenario 1: Keyword Repetition
42
+ Query: "projects"
43
+ Without dampening:
44
+ - Endpoint A: service="projects"(90) + tag="projects"(90) + path="/projects"(60) +
45
+ description="manage projects"(20) = 260 points
46
+ - Endpoint B: service="users"(0) + operation="get_user_projects"(60) = 60 points
47
+
48
+ With our algorithm:
49
+ - Endpoint A: log(91)×10 + log(91)×10 + log(61)×10 + log(21)×5 = 45.5+45.5+40.2+15.2 = 146.4
50
+ - Endpoint B: log(61)×10 = 40.2
51
+
52
+ Still favors A but with reasonable margin, not 4x difference.
53
+
54
+ ### Scenario 2: Long Description Domination
55
+ Query: "create user project pipeline"
56
+ Without dampening:
57
+ - Endpoint A: description contains all 4 words = 20×4 = 80 points
58
+ - Endpoint B: operation="create_pipeline" = 30×2 = 60 points
59
+
60
+ With our algorithm:
61
+ - Endpoint A: (20 + 20/2 + 20/3 + 20/4) = 41.7 → log(42.7)×5 = 18.6 points
62
+ - Endpoint B: 30×2 = 60 → log(61)×10 = 40.2 points
63
+
64
+ Now B correctly ranks higher as it's more specific.
65
+
66
+ ### Scenario 3: Exact Service Name Match
67
+ Query: "projects"
68
+ - Service name exactly "projects": 30×3=90 → log(91)×10 = 45.5 points
69
+ This ensures exact matches still get high scores despite dampening.
70
+
71
+ ## Weight Configuration
72
+
73
+ Weights should be configured based on field importance:
74
+ - High (30): service, operation, tag, path - core identifiers
75
+ - Medium (20): summary, description - contextual information
76
+ - Low (5): method, param - auxiliary information
77
+ - Minimal (1-2): param_desc, body_field - verbose/detailed fields
78
+
79
+ The weights are passed during document indexing, allowing different OpenAPI
80
+ implementations to customize based on their documentation structure.
81
+ """
82
+
83
+ import math
84
+ import re
85
+ from typing import List, Tuple
86
+
87
+
88
+ class FieldSearchEngine:
89
+ """
90
+ Simple field-based search engine with weighted scoring
91
+ Compatible with BM25SearchEngine interface
92
+ """
93
+
94
+ def __init__(self, **kwargs):
95
+ """Initialize the search engine (kwargs for compatibility with BM25SearchEngine)"""
96
+ self.documents = []
97
+ self.corpus_metadata = None
98
+
99
+ def build_index(self, documents: List[Tuple[List[Tuple[str, str, int]], str]]):
100
+ """
101
+ Build index from documents
102
+
103
+ Args:
104
+ documents: List of (fields, doc_id) tuples
105
+ fields: List of (field_key, field_value, weight) tuples
106
+ weight is used as field priority
107
+ doc_id: Document identifier string
108
+ """
109
+ self.documents = []
110
+ self.corpus_metadata = []
111
+
112
+ for fields, doc_id in documents:
113
+ # Store document with structured fields and their weights
114
+ doc_fields = {}
115
+ field_weights = {}
116
+
117
+ for field_key, field_value, weight in fields:
118
+ if field_value:
119
+ # Group values by field type
120
+ if field_key not in doc_fields:
121
+ doc_fields[field_key] = []
122
+ field_weights[field_key] = weight
123
+ doc_fields[field_key].append(field_value.lower())
124
+
125
+ # Use the highest weight if multiple values for same field
126
+ if weight > field_weights.get(field_key, 0):
127
+ field_weights[field_key] = weight
128
+
129
+ self.documents.append(
130
+ {"id": doc_id, "fields": doc_fields, "weights": field_weights}
131
+ )
132
+ self.corpus_metadata.append(doc_id)
133
+
134
+ def search(self, query: str, top_k: int = 10) -> List[Tuple[float, str]]:
135
+ """
136
+ Search documents with field-weighted scoring and logarithmic dampening
137
+
138
+ Args:
139
+ query: Search query string
140
+ top_k: Number of top results to return
141
+
142
+ Returns:
143
+ List of (score, doc_id) tuples sorted by score descending
144
+ """
145
+ if not self.documents:
146
+ return []
147
+
148
+ # Tokenize query into words
149
+ query_tokens = query.lower().split()
150
+
151
+ results = []
152
+
153
+ for doc in self.documents:
154
+ # Track scores by field type to apply per-field dampening
155
+ field_scores = {}
156
+ matched_field_types = set()
157
+
158
+ # Check each field type
159
+ for field_type, field_values in doc["fields"].items():
160
+ # Get weight from document's field weights
161
+ field_weight = doc["weights"].get(field_type, 1.0)
162
+
163
+ # Track tokens matched in this field
164
+ field_token_scores = []
165
+ matched_tokens = set()
166
+
167
+ # For each query token
168
+ for token in query_tokens:
169
+ # Check if token appears in any value of this field
170
+ best_match_score = 0
171
+
172
+ for value in field_values:
173
+ if (
174
+ self._match_token(token, value)
175
+ and token not in matched_tokens
176
+ ):
177
+ # Calculate match quality
178
+ match_score = 0
179
+
180
+ # Exact match gets highest score
181
+ if value == token:
182
+ match_score = 3.0
183
+ # Word boundary match (complete word)
184
+ elif re.search(r"\b" + re.escape(token) + r"\b", value):
185
+ match_score = 2.0
186
+ # Partial match gets base score
187
+ else:
188
+ match_score = 1.0
189
+
190
+ best_match_score = max(best_match_score, match_score)
191
+
192
+ if best_match_score > 0:
193
+ matched_tokens.add(token)
194
+ field_token_scores.append(field_weight * best_match_score)
195
+ matched_field_types.add(field_type)
196
+
197
+ # Apply diminishing returns for multiple tokens in same field
198
+ if field_token_scores:
199
+ # Sort scores in descending order
200
+ field_token_scores.sort(reverse=True)
201
+
202
+ # Apply decay: 1st token 100%, 2nd 50%, 3rd 33%, etc.
203
+ field_total = 0
204
+ for i, token_score in enumerate(field_token_scores):
205
+ field_total += token_score / (i + 1)
206
+
207
+ # Apply logarithmic dampening per field to prevent single field domination
208
+ # This prevents description or other verbose fields from dominating
209
+ if field_type in ["description", "param_desc"]:
210
+ # Stronger dampening for description fields
211
+ field_scores[field_type] = math.log(1 + field_total) * 5
212
+ else:
213
+ # Lighter dampening for identifier fields
214
+ field_scores[field_type] = math.log(1 + field_total) * 10
215
+
216
+ # Calculate final score
217
+ if field_scores:
218
+ # Sum all field scores (already dampened per field)
219
+ total_score = sum(field_scores.values())
220
+
221
+ # Add diversity bonus for matching multiple field types
222
+ diversity_bonus = math.sqrt(len(matched_field_types)) * 3
223
+
224
+ final_score = total_score + diversity_bonus
225
+ results.append((final_score, doc["id"]))
226
+
227
+ # Sort by score descending and return top k
228
+ results.sort(key=lambda x: x[0], reverse=True)
229
+ return results[:top_k]
230
+
231
+ def _match_token(self, token: str, text: str) -> bool:
232
+ """Check if token matches in text"""
233
+ return token in text
@@ -0,0 +1,202 @@
1
+ """
2
+ Shared search utility for both single_server and strata_server.
3
+ Provides type-safe interfaces for searching through MCP tools
4
+ Uses a unified generic approach to reduce code duplication.
5
+ """
6
+
7
+ from typing import Any, Dict, List, Optional
8
+
9
+ from mcp import types
10
+
11
+ from strata.utils.bm25_search import BM25SearchEngine
12
+
13
+
14
+ class UniversalToolSearcher:
15
+ """
16
+ Universal searcher that handles all tool types
17
+ using a single unified approach based on function names.
18
+ """
19
+
20
+ def __init__(self, mixed_tools_map: Dict[str, List[Any]]):
21
+ """
22
+ Initialize universal searcher with mixed tool types.
23
+
24
+ Args:
25
+ mixed_tools_map: Dictionary mapping categories to tools.
26
+ Tools can be either types.Tool objects or dict objects.
27
+ """
28
+ self.tools_map = mixed_tools_map
29
+ self.search_engine = self._build_index()
30
+
31
+ def _get_tool_name(self, tool: Any) -> Optional[str]:
32
+ """Extract name from any tool type."""
33
+ if isinstance(tool, types.Tool):
34
+ return tool.name if tool.name else None
35
+ elif isinstance(tool, dict):
36
+ return tool.get("name")
37
+ return None
38
+
39
+ def _get_tool_field(self, tool: Any, field_name: str, default: Any = None) -> Any:
40
+ """Extract field value from any tool type."""
41
+ if isinstance(tool, types.Tool):
42
+ return getattr(tool, field_name, default)
43
+ elif isinstance(tool, dict):
44
+ return tool.get(field_name, default)
45
+ return default
46
+
47
+ def _build_index(self) -> BM25SearchEngine:
48
+ """Build unified search index from all tools."""
49
+ documents = []
50
+
51
+ for category_name, tools in self.tools_map.items():
52
+ for tool in tools:
53
+ # Get tool name (function name)
54
+ tool_name = self._get_tool_name(tool)
55
+ if not tool_name:
56
+ continue
57
+
58
+ # Build weighted fields
59
+ fields = []
60
+
61
+ # Core identifiers - highest weight
62
+ fields.append(("category", category_name.lower(), 30))
63
+ fields.append(("operation", tool_name.lower(), 30))
64
+
65
+ # Title if available
66
+ title = self._get_tool_field(tool, "title", "")
67
+ if title:
68
+ fields.append(("title", str(title).lower(), 30))
69
+
70
+ # Description/Summary - highest weight
71
+ description = self._get_tool_field(tool, "description", "")
72
+ if description:
73
+ fields.append(("description", str(description).lower(), 30))
74
+
75
+ summary = self._get_tool_field(tool, "summary", "")
76
+ if summary:
77
+ fields.append(("summary", str(summary).lower(), 30))
78
+
79
+ tags = self._get_tool_field(tool, "tags", [])
80
+ if isinstance(tags, list):
81
+ for tag in tags:
82
+ if tag:
83
+ fields.append(("tag", str(tag).lower(), 30))
84
+
85
+ path = self._get_tool_field(tool, "path", "")
86
+ if path:
87
+ fields.append(("path", str(path).lower(), 30))
88
+
89
+ method = self._get_tool_field(tool, "method", "")
90
+ if method:
91
+ fields.append(("method", str(method).lower(), 15))
92
+
93
+ for param_type in ["path_params", "query_params"]:
94
+ params = self._get_tool_field(tool, param_type, {})
95
+ for param_name, param_info in params.items():
96
+ fields.append(
97
+ (f"{param_type}/{param_name}", param_name.lower(), 15)
98
+ )
99
+ if isinstance(param_info, dict):
100
+ param_desc = param_info.get("description", "")
101
+ if param_desc:
102
+ fields.append(
103
+ (
104
+ f"{param_type}/{param_name}_desc",
105
+ param_desc.lower(),
106
+ 15,
107
+ )
108
+ )
109
+
110
+ # Body schema fields
111
+ body_schema = self._get_tool_field(tool, "body_schema", {})
112
+ for param_name, param_info in body_schema.get("properties", {}).items():
113
+ fields.append((f"body_schema/{param_name}", param_name.lower(), 15))
114
+ if isinstance(param_info, dict):
115
+ param_desc = param_info.get("description", "")
116
+ if param_desc:
117
+ fields.append(
118
+ (
119
+ f"body_schema/{param_name}_desc",
120
+ param_desc.lower(),
121
+ 15,
122
+ )
123
+ )
124
+
125
+ response_schema = self._get_tool_field(tool, "response_schema", {})
126
+ for param_name, param_info in response_schema.get(
127
+ "properties", {}
128
+ ).items():
129
+ fields.append(
130
+ (f"response_schema/{param_name}", param_name.lower(), 5)
131
+ )
132
+ if isinstance(param_info, dict):
133
+ param_desc = param_info.get("description", "")
134
+ if param_desc:
135
+ fields.append(
136
+ (
137
+ f"response_schema/{param_name}_desc",
138
+ param_desc.lower(),
139
+ 5,
140
+ )
141
+ )
142
+
143
+ # Create document ID
144
+ doc_id = f"{category_name}::{tool_name}"
145
+ if fields:
146
+ documents.append((fields, doc_id))
147
+
148
+ # Build search index
149
+ search_engine = BM25SearchEngine()
150
+ search_engine.build_index(documents)
151
+ return search_engine
152
+
153
+ def search(self, query: str, max_results: int = 10) -> List[Dict[str, Any]]:
154
+ """
155
+ Search through all tools.
156
+
157
+ Args:
158
+ query: Search query string
159
+ max_results: Maximum number of results to return
160
+
161
+ Returns:
162
+ List of search results with tool information
163
+ """
164
+ if self.search_engine is None:
165
+ return []
166
+
167
+ # Perform search
168
+ search_results = self.search_engine.search(query.lower(), top_k=max_results)
169
+
170
+ # Build results
171
+ results = []
172
+
173
+ for score, doc_id in search_results:
174
+ # Parse doc_id
175
+ if "::" not in doc_id:
176
+ continue
177
+
178
+ category_name, tool_name = doc_id.split("::", 1)
179
+
180
+ # Find the tool
181
+ if category_name in self.tools_map:
182
+ for tool in self.tools_map[category_name]:
183
+ if self._get_tool_name(tool) == tool_name:
184
+ result = {
185
+ "name": tool_name,
186
+ "description": self._get_tool_field(
187
+ tool, "description", ""
188
+ ),
189
+ "category_name": category_name,
190
+ # "relevance_score": score,
191
+ }
192
+
193
+ # Add optional fields if they exist
194
+ for field in ["title", "summary"]:
195
+ value = self._get_tool_field(tool, field)
196
+ if value:
197
+ result[field] = value
198
+
199
+ results.append(result)
200
+ break
201
+
202
+ return results
@@ -0,0 +1,269 @@
1
+ """Tool integration utilities for adding Strata to various IDEs and editors."""
2
+
3
+ import json
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+
8
+
9
+ def update_json_recursively(data: dict, keys: list, value) -> dict:
10
+ """Recursively update a nested dictionary, creating keys as needed.
11
+
12
+ Args:
13
+ data: The dictionary to update
14
+ keys: List of keys representing the path to update
15
+ value: The value to set at the path
16
+
17
+ Returns:
18
+ The updated dictionary
19
+ """
20
+ if not keys:
21
+ return data
22
+
23
+ if len(keys) == 1:
24
+ # Base case: set the value
25
+ key = keys[0]
26
+ if isinstance(data.get(key), dict) and isinstance(value, dict):
27
+ # Merge dictionaries if both are dict type
28
+ data[key] = {**data.get(key, {}), **value}
29
+ else:
30
+ data[key] = value
31
+ return data
32
+
33
+ # Recursive case: ensure intermediate keys exist
34
+ key = keys[0]
35
+ if key not in data:
36
+ data[key] = {}
37
+ elif not isinstance(data[key], dict):
38
+ # If the existing value is not a dict, replace it with dict
39
+ data[key] = {}
40
+
41
+ data[key] = update_json_recursively(data[key], keys[1:], value)
42
+ return data
43
+
44
+
45
+ def ensure_json_config(config_path: Path) -> dict:
46
+ """Ensure JSON configuration file exists and return its content.
47
+
48
+ Args:
49
+ config_path: Path to the configuration file
50
+
51
+ Returns:
52
+ The configuration dictionary
53
+ """
54
+ # Create directory if it doesn't exist
55
+ config_path.parent.mkdir(parents=True, exist_ok=True)
56
+
57
+ # Load existing config or create empty one
58
+ if config_path.exists():
59
+ try:
60
+ with open(config_path, "r", encoding="utf-8") as f:
61
+ return json.load(f)
62
+ except (json.JSONDecodeError, IOError) as e:
63
+ print(
64
+ f"Warning: Could not read existing config {config_path}: {e}",
65
+ file=sys.stderr,
66
+ )
67
+ print("Creating new configuration file", file=sys.stderr)
68
+
69
+ return {}
70
+
71
+
72
+ def save_json_config(config_path: Path, config: dict) -> None:
73
+ """Save JSON configuration to file.
74
+
75
+ Args:
76
+ config_path: Path to save the configuration
77
+ config: Configuration dictionary to save
78
+ """
79
+ config_path.parent.mkdir(parents=True, exist_ok=True)
80
+ with open(config_path, "w", encoding="utf-8") as f:
81
+ json.dump(config, f, indent=2, ensure_ascii=False)
82
+
83
+
84
+ def check_cli_available(target: str) -> bool:
85
+ """Check if a CLI tool is available.
86
+
87
+ Args:
88
+ target: Name of the CLI tool to check
89
+
90
+ Returns:
91
+ True if the CLI tool is available, False otherwise
92
+ """
93
+ try:
94
+ if target == "vscode":
95
+ # Check for VSCode CLI (code command)
96
+ result = subprocess.run(
97
+ ["code", "--version"], capture_output=True, text=True, timeout=5
98
+ )
99
+ else:
100
+ result = subprocess.run(
101
+ [target, "--version"], capture_output=True, text=True, timeout=5
102
+ )
103
+
104
+ return result.returncode == 0
105
+ except (subprocess.SubprocessError, FileNotFoundError):
106
+ return False
107
+
108
+
109
+ def add_strata_to_cursor(scope: str = "user") -> int:
110
+ """Add Strata to Cursor MCP configuration.
111
+
112
+ Args:
113
+ scope: Configuration scope (user, project, or local)
114
+
115
+ Returns:
116
+ 0 on success, 1 on error
117
+ """
118
+ try:
119
+ # Determine config path based on scope
120
+ if scope == "user":
121
+ # User scope: ~/.cursor/mcp.json
122
+ cursor_config_path = Path.home() / ".cursor" / "mcp.json"
123
+ elif scope in ["project", "local"]:
124
+ # Project scope: .cursor/mcp.json in current directory
125
+ cursor_config_path = Path.cwd() / ".cursor" / "mcp.json"
126
+ else:
127
+ print(
128
+ f"Error: Unsupported scope '{scope}' for Cursor. Supported: user, project, local",
129
+ file=sys.stderr,
130
+ )
131
+ return 1
132
+
133
+ print(
134
+ f"Adding Strata to Cursor with scope '{scope}' at {cursor_config_path}..."
135
+ )
136
+
137
+ # Load or create cursor configuration
138
+ cursor_config = ensure_json_config(cursor_config_path)
139
+
140
+ # Create Strata server configuration for Cursor
141
+ strata_server_config = {"command": "strata", "args": []}
142
+
143
+ # Update configuration using recursive update
144
+ cursor_config = update_json_recursively(
145
+ cursor_config, ["mcpServers", "strata"], strata_server_config
146
+ )
147
+
148
+ # Save updated configuration
149
+ save_json_config(cursor_config_path, cursor_config)
150
+ print("✓ Successfully added Strata to Cursor MCP configuration")
151
+ return 0
152
+
153
+ except (IOError, OSError) as e:
154
+ print(f"Error handling Cursor configuration: {e}", file=sys.stderr)
155
+ return 1
156
+
157
+
158
+ def add_strata_to_vscode() -> int:
159
+ """Add Strata to VSCode MCP configuration.
160
+
161
+ Returns:
162
+ 0 on success, 1 on error
163
+ """
164
+ try:
165
+ # VSCode uses JSON format: code --add-mcp '{"name":"strata","command":"strata"}'
166
+ mcp_config = {"name": "strata", "command": "strata"}
167
+ mcp_json = json.dumps(mcp_config)
168
+
169
+ print("Adding Strata to VSCode...")
170
+ cmd = ["code", "--add-mcp", mcp_json]
171
+ result = subprocess.run(cmd, capture_output=True, text=True)
172
+
173
+ if result.returncode == 0:
174
+ print("✓ Successfully added Strata to VSCode MCP configuration")
175
+ if result.stdout.strip():
176
+ print(result.stdout)
177
+ return 0
178
+ else:
179
+ print(
180
+ f"Error adding Strata to VSCode: {result.stderr.strip()}",
181
+ file=sys.stderr,
182
+ )
183
+ return result.returncode
184
+
185
+ except subprocess.SubprocessError as e:
186
+ print(f"Error running VSCode command: {e}", file=sys.stderr)
187
+ return 1
188
+
189
+
190
+ def add_strata_to_claude_or_gemini(target: str, scope: str = "user") -> int:
191
+ """Add Strata to Claude or Gemini MCP configuration.
192
+
193
+ Args:
194
+ target: Target CLI tool (claude or gemini)
195
+ scope: Configuration scope
196
+
197
+ Returns:
198
+ 0 on success, 1 on error
199
+ """
200
+ try:
201
+ # Claude and Gemini use the original format
202
+ cmd = [target, "mcp", "add"]
203
+ cmd.extend(["--scope", scope])
204
+ cmd.extend(["strata", "strata"])
205
+
206
+ print(f"Adding Strata to {target} with scope '{scope}'...")
207
+ result = subprocess.run(cmd, capture_output=True, text=True)
208
+
209
+ if result.returncode == 0:
210
+ print(f"✓ Successfully added Strata to {target} MCP configuration")
211
+ if result.stdout.strip():
212
+ print(result.stdout)
213
+ return 0
214
+ else:
215
+ print(
216
+ f"Error adding Strata to {target}: {result.stderr.strip()}",
217
+ file=sys.stderr,
218
+ )
219
+ return result.returncode
220
+
221
+ except subprocess.SubprocessError as e:
222
+ print(f"Error running {target} command: {e}", file=sys.stderr)
223
+ return 1
224
+
225
+
226
+ def add_strata_to_tool(target: str, scope: str = "user") -> int:
227
+ """Add Strata MCP server to specified tool configuration.
228
+
229
+ Args:
230
+ target: Target tool (claude, gemini, vscode, cursor)
231
+ scope: Configuration scope
232
+
233
+ Returns:
234
+ 0 on success, 1 on error
235
+ """
236
+ target = target.lower()
237
+
238
+ # Validate target
239
+ if target not in ["claude", "gemini", "vscode", "cursor"]:
240
+ print(
241
+ f"Error: Unsupported target '{target}'. Supported targets: claude, gemini, vscode, cursor",
242
+ file=sys.stderr,
243
+ )
244
+ return 1
245
+
246
+ # VSCode doesn't support scope parameter
247
+ if target == "vscode" and scope != "user":
248
+ print(
249
+ "Warning: VSCode doesn't support scope parameter, using default behavior",
250
+ file=sys.stderr,
251
+ )
252
+
253
+ # Check if the target CLI is available (skip for cursor as we handle files directly)
254
+ if target != "cursor":
255
+ if not check_cli_available(target):
256
+ cli_name = "code" if target == "vscode" else target
257
+ print(
258
+ f"Error: {cli_name} CLI not found. Please install {cli_name} CLI first.",
259
+ file=sys.stderr,
260
+ )
261
+ return 1
262
+
263
+ # Handle each target
264
+ if target == "cursor":
265
+ return add_strata_to_cursor(scope)
266
+ elif target == "vscode":
267
+ return add_strata_to_vscode()
268
+ else: # claude or gemini
269
+ return add_strata_to_claude_or_gemini(target, scope)