open-edison 0.1.34__py3-none-any.whl → 0.1.37__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,201 @@
1
+ import argparse
2
+ from pathlib import Path
3
+
4
+ from loguru import logger as log
5
+
6
+ from .exporters import ExportError, export_to_claude_code, export_to_cursor, export_to_vscode
7
+ from .paths import (
8
+ detect_cursor_config_path,
9
+ detect_vscode_config_path,
10
+ get_default_cursor_config_path,
11
+ get_default_vscode_config_path,
12
+ )
13
+
14
+
15
+ def _prompt_yes_no(message: str, *, default_no: bool = True) -> bool:
16
+ suffix = "[y/N]" if default_no else "[Y/n]"
17
+ while True:
18
+ resp = input(f"{message} {suffix} ").strip().lower()
19
+ if resp == "y" or resp == "yes":
20
+ return True
21
+ if resp == "n" or resp == "no":
22
+ return False
23
+ if resp == "" and default_no:
24
+ return False
25
+ if resp == "" and not default_no:
26
+ return True
27
+
28
+
29
+ def build_arg_parser() -> argparse.ArgumentParser:
30
+ p = argparse.ArgumentParser(
31
+ description="Export editor MCP config to use Open Edison (Cursor support)",
32
+ )
33
+ p.add_argument("--target", choices=["cursor", "vscode", "claude-code"], default="cursor")
34
+ p.add_argument("--dry-run", action="store_true", help="Show actions without writing")
35
+ p.add_argument("--force", action="store_true", help="Rewrite even if already configured")
36
+ p.add_argument(
37
+ "--yes",
38
+ action="store_true",
39
+ help="Automatic yes to prompts (create missing files without confirmation)",
40
+ )
41
+ p.add_argument("--url", default="http://localhost:3000/mcp/", help="MCP URL")
42
+ p.add_argument(
43
+ "--api-key",
44
+ default="dev-api-key-change-me",
45
+ help="API key for Authorization header",
46
+ )
47
+ p.add_argument("--name", default="open-edison", help="Name of the server entry")
48
+ return p
49
+
50
+
51
+ def _handle_cursor(args: argparse.Namespace) -> int:
52
+ detected = detect_cursor_config_path()
53
+ target_path: Path = detected if detected else get_default_cursor_config_path()
54
+
55
+ create_if_missing = False
56
+ if not target_path.exists():
57
+ if args.yes:
58
+ create_if_missing = True
59
+ else:
60
+ confirmed = _prompt_yes_no(
61
+ f"Cursor config not found at {target_path}. Create it?", default_no=False
62
+ )
63
+ if not confirmed:
64
+ log.info("Aborted: user declined to create missing file")
65
+ return 0
66
+ create_if_missing = True
67
+
68
+ try:
69
+ result = export_to_cursor(
70
+ url=args.url,
71
+ api_key=args.api_key,
72
+ server_name=args.name,
73
+ dry_run=args.dry_run,
74
+ force=args.force,
75
+ create_if_missing=create_if_missing,
76
+ )
77
+ except ExportError as e:
78
+ log.error(str(e))
79
+ return 1
80
+
81
+ if result.dry_run:
82
+ log.info("Dry-run complete. No changes written.")
83
+ return 0
84
+
85
+ if result.wrote_changes:
86
+ if result.backup_path is not None:
87
+ log.info("Backup created at {}", result.backup_path)
88
+ log.info("Updated {}", result.target_path)
89
+ else:
90
+ log.info("No changes were necessary.")
91
+ return 0
92
+
93
+
94
+ def _handle_vscode(args: argparse.Namespace) -> int:
95
+ detected = detect_vscode_config_path()
96
+ target_path: Path = detected if detected else get_default_vscode_config_path()
97
+
98
+ create_if_missing = False
99
+ if not target_path.exists():
100
+ if args.yes:
101
+ create_if_missing = True
102
+ else:
103
+ confirmed = _prompt_yes_no(
104
+ f"VS Code MCP config not found at {target_path}. Create it?", default_no=False
105
+ )
106
+ if not confirmed:
107
+ log.info("Aborted: user declined to create missing file")
108
+ return 0
109
+ create_if_missing = True
110
+
111
+ try:
112
+ result = export_to_vscode(
113
+ url=args.url,
114
+ api_key=args.api_key,
115
+ server_name=args.name,
116
+ dry_run=args.dry_run,
117
+ force=args.force,
118
+ create_if_missing=create_if_missing,
119
+ )
120
+ except ExportError as e:
121
+ log.error(str(e))
122
+ return 1
123
+
124
+ if result.dry_run:
125
+ log.info("Dry-run complete. No changes written.")
126
+ return 0
127
+
128
+ if result.wrote_changes:
129
+ if result.backup_path is not None:
130
+ log.info("Backup created at {}", result.backup_path)
131
+ log.info("Updated {}", result.target_path)
132
+ else:
133
+ log.info("No changes were necessary.")
134
+ return 0
135
+
136
+
137
+ def _handle_claude_code(args: argparse.Namespace) -> int:
138
+ from .paths import detect_claude_code_config_path, get_default_claude_code_config_path
139
+
140
+ detected = detect_claude_code_config_path()
141
+ target_path: Path = detected if detected else get_default_claude_code_config_path()
142
+
143
+ create_if_missing = False
144
+ if not target_path.exists():
145
+ if args.yes:
146
+ create_if_missing = True
147
+ else:
148
+ confirmed = _prompt_yes_no(
149
+ f"Claude Code config not found at {target_path}. Create it?", default_no=False
150
+ )
151
+ if not confirmed:
152
+ log.info("Aborted: user declined to create missing file")
153
+ return 0
154
+ create_if_missing = True
155
+
156
+ try:
157
+ result = export_to_claude_code(
158
+ url=args.url,
159
+ api_key=args.api_key,
160
+ server_name=args.name,
161
+ dry_run=args.dry_run,
162
+ force=args.force,
163
+ create_if_missing=create_if_missing,
164
+ )
165
+ except ExportError as e:
166
+ log.error(str(e))
167
+ return 1
168
+
169
+ if result.dry_run:
170
+ log.info("Dry-run complete. No changes written.")
171
+ return 0
172
+
173
+ if result.wrote_changes:
174
+ if result.backup_path is not None:
175
+ log.info("Backup created at {}", result.backup_path)
176
+ log.info("Updated {}", result.target_path)
177
+ else:
178
+ log.info("No changes were necessary.")
179
+ return 0
180
+
181
+
182
+ def run_cli(argv: list[str] | None = None) -> int:
183
+ parser = build_arg_parser()
184
+ args = parser.parse_args(argv)
185
+
186
+ if args.target == "cursor":
187
+ return _handle_cursor(args)
188
+ if args.target == "vscode":
189
+ return _handle_vscode(args)
190
+ if args.target == "claude-code":
191
+ return _handle_claude_code(args)
192
+ log.error("Unsupported target: {}", args.target)
193
+ return 2
194
+
195
+
196
+ def main() -> int:
197
+ return run_cli()
198
+
199
+
200
+ if __name__ == "__main__":
201
+ raise SystemExit(main())
@@ -0,0 +1,393 @@
1
+ import json
2
+ import os
3
+ import shutil
4
+ import tempfile
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from loguru import logger as log
11
+
12
+ from .paths import (
13
+ find_claude_code_user_all_candidates,
14
+ find_cursor_user_file,
15
+ find_vscode_user_mcp_file,
16
+ is_macos,
17
+ is_windows,
18
+ )
19
+
20
+
21
+ @dataclass
22
+ class ExportResult:
23
+ target_path: Path
24
+ backup_path: Path | None
25
+ wrote_changes: bool
26
+ dry_run: bool
27
+
28
+
29
+ class ExportError(Exception):
30
+ pass
31
+
32
+
33
+ def _timestamp() -> str:
34
+ return datetime.now().strftime("%Y%m%d-%H%M%S")
35
+
36
+
37
+ def _ensure_parent_dir(path: Path) -> None:
38
+ path.parent.mkdir(parents=True, exist_ok=True)
39
+
40
+
41
+ def _atomic_write_json(path: Path, data: dict[str, Any]) -> None:
42
+ _ensure_parent_dir(path)
43
+ tmp_fd, tmp_path = tempfile.mkstemp(prefix=path.name + ".", dir=str(path.parent))
44
+ try:
45
+ with os.fdopen(tmp_fd, "w", encoding="utf-8") as f:
46
+ json.dump(data, f, indent=2, ensure_ascii=False)
47
+ f.write("\n")
48
+ # Use replace to be atomic on POSIX
49
+ Path(tmp_path).replace(path)
50
+ finally:
51
+ try:
52
+ if Path(tmp_path).exists():
53
+ Path(tmp_path).unlink(missing_ok=True)
54
+ except Exception:
55
+ pass
56
+
57
+
58
+ def _read_json_or_error(path: Path) -> dict[str, Any]:
59
+ try:
60
+ with open(path, encoding="utf-8") as f:
61
+ data = json.load(f)
62
+ except Exception as e:
63
+ raise ExportError(f"Malformed JSON at {path}: {e}") from e
64
+ if not isinstance(data, dict):
65
+ raise ExportError(f"Expected top-level JSON object at {path}")
66
+ return data
67
+
68
+
69
+ def _require_supported_os() -> None:
70
+ if is_windows():
71
+ raise ExportError("Windows is not supported. Use macOS or Linux.")
72
+
73
+
74
+ def _resolve_cursor_target() -> Path:
75
+ existing = find_cursor_user_file()
76
+ return existing[0] if existing else (Path.home() / ".cursor" / "mcp.json").resolve()
77
+
78
+
79
+ def _resolve_vscode_target() -> Path:
80
+ existing = find_vscode_user_mcp_file()
81
+ if existing:
82
+ return existing[0]
83
+ if is_macos():
84
+ return (
85
+ Path.home() / "Library" / "Application Support" / "Code" / "User" / "mcp.json"
86
+ ).resolve()
87
+ return (Path.home() / ".config" / "Code" / "User" / "mcp.json").resolve()
88
+
89
+
90
+ def _resolve_claude_code_target() -> Path:
91
+ existing = find_claude_code_user_all_candidates()
92
+ if existing:
93
+ return existing[0]
94
+ return (Path.home() / ".claude.json").resolve()
95
+
96
+
97
+ def _validate_or_confirm_create(target_path: Path, *, create_if_missing: bool, label: str) -> None:
98
+ if target_path.exists():
99
+ _read_json_or_error(target_path)
100
+ return
101
+ if not create_if_missing:
102
+ raise ExportError(
103
+ f"{label} config not found at {target_path}. Refusing to create without confirmation."
104
+ )
105
+
106
+
107
+ def _skip_if_already_configured(
108
+ target_path: Path,
109
+ *,
110
+ url: str,
111
+ api_key: str,
112
+ name: str,
113
+ force: bool,
114
+ dry_run: bool,
115
+ label: str,
116
+ ) -> ExportResult | None:
117
+ if not target_path.exists():
118
+ return None
119
+ current = _read_json_or_error(target_path)
120
+ if _is_already_open_edison(current, url=url, api_key=api_key, name=name) and not force:
121
+ log.info(
122
+ "{} is already configured to use Open Edison. Skipping (use --force to rewrite).", label
123
+ )
124
+ return ExportResult(
125
+ target_path=target_path, backup_path=None, wrote_changes=False, dry_run=dry_run
126
+ )
127
+ return None
128
+
129
+
130
+ def _backup_if_exists(target_path: Path, *, dry_run: bool) -> Path | None:
131
+ if not target_path.exists():
132
+ return None
133
+ backup_path = target_path.with_name(target_path.name + f".bak-{_timestamp()}")
134
+ if dry_run:
135
+ log.info("[dry-run] Would back up {} -> {}", target_path, backup_path)
136
+ return backup_path
137
+ _ensure_parent_dir(backup_path)
138
+ shutil.copy2(target_path, backup_path)
139
+ log.info("Backed up {} -> {}", target_path, backup_path)
140
+ return backup_path
141
+
142
+
143
+ def _write_config(
144
+ target_path: Path,
145
+ *,
146
+ new_config: dict[str, Any],
147
+ backup_path: Path | None,
148
+ dry_run: bool,
149
+ label: str,
150
+ ) -> ExportResult:
151
+ if dry_run:
152
+ log.info("[dry-run] Would write minimal {} MCP config to {}", label, target_path)
153
+ log.debug("[dry-run] New JSON: {}", json.dumps(new_config, indent=2))
154
+ return ExportResult(
155
+ target_path=target_path, backup_path=backup_path, wrote_changes=False, dry_run=True
156
+ )
157
+ _atomic_write_json(target_path, new_config)
158
+ log.info("Wrote {} MCP config to {}", label, target_path)
159
+ return ExportResult(
160
+ target_path=target_path, backup_path=backup_path, wrote_changes=True, dry_run=False
161
+ )
162
+
163
+
164
+ def _build_open_edison_server(
165
+ *,
166
+ name: str,
167
+ url: str,
168
+ api_key: str,
169
+ ) -> dict[str, Any]:
170
+ return {
171
+ name: {
172
+ "command": "npx",
173
+ "args": [
174
+ "-y",
175
+ "mcp-remote",
176
+ url,
177
+ "--header",
178
+ f"Authorization: Bearer {api_key}",
179
+ "--transport",
180
+ "http-only",
181
+ "--allow-http",
182
+ ],
183
+ "enabled": True,
184
+ }
185
+ }
186
+
187
+
188
+ def _is_already_open_edison(
189
+ config_obj: dict[str, Any], *, url: str, api_key: str, name: str
190
+ ) -> bool:
191
+ servers_node = config_obj.get("mcpServers") or config_obj.get("servers")
192
+ if not isinstance(servers_node, dict):
193
+ return False
194
+ # Must be exactly one server
195
+ if len(servers_node) != 1:
196
+ return False
197
+ only_name, only_spec = next(iter(servers_node.items()))
198
+ if only_name != name or not isinstance(only_spec, dict):
199
+ return False
200
+ if only_spec.get("command") != "npx":
201
+ return False
202
+ args = only_spec.get("args")
203
+ if not isinstance(args, list):
204
+ return False
205
+ args_str = [str(a) for a in args]
206
+ expected_header = f"Authorization: Bearer {api_key}"
207
+ return (
208
+ url in args_str
209
+ and expected_header in args_str
210
+ and "mcp-remote" in args_str
211
+ and "--transport" in args_str
212
+ and "http-only" in args_str
213
+ )
214
+
215
+
216
+ def export_to_cursor(
217
+ *,
218
+ url: str = "http://localhost:3000/mcp/",
219
+ api_key: str = "dev-api-key-change-me",
220
+ server_name: str = "open-edison",
221
+ dry_run: bool = False,
222
+ force: bool = False,
223
+ create_if_missing: bool = False,
224
+ ) -> ExportResult:
225
+ """Export editor config for Cursor to point solely to Open Edison.
226
+
227
+ Behavior:
228
+ - Back up existing file if present.
229
+ - Abort on malformed JSON.
230
+ - If file does not exist, require create_if_missing=True or raise ExportError.
231
+ - Write a minimal mcpServers object with a single Open Edison server.
232
+ - Atomic writes.
233
+ """
234
+
235
+ _require_supported_os()
236
+ target_path = _resolve_cursor_target()
237
+
238
+ backup_path: Path | None = None
239
+
240
+ _validate_or_confirm_create(target_path, create_if_missing=create_if_missing, label="Cursor")
241
+
242
+ # Build the minimal config
243
+ new_config: dict[str, Any] = {
244
+ "mcpServers": _build_open_edison_server(name=server_name, url=url, api_key=api_key)
245
+ }
246
+
247
+ # If already configured exactly as desired and not forcing, no-op
248
+ maybe_skip = _skip_if_already_configured(
249
+ target_path,
250
+ url=url,
251
+ api_key=api_key,
252
+ name=server_name,
253
+ force=force,
254
+ dry_run=dry_run,
255
+ label="Cursor",
256
+ )
257
+ if maybe_skip is not None:
258
+ return maybe_skip
259
+
260
+ # Prepare backup if file exists
261
+ backup_path = _backup_if_exists(target_path, dry_run=dry_run)
262
+
263
+ # Write new config
264
+ return _write_config(
265
+ target_path,
266
+ new_config=new_config,
267
+ backup_path=backup_path,
268
+ dry_run=dry_run,
269
+ label="Cursor",
270
+ )
271
+
272
+
273
+ def export_to_vscode(
274
+ *,
275
+ url: str = "http://localhost:3000/mcp/",
276
+ api_key: str = "dev-api-key-change-me",
277
+ server_name: str = "open-edison",
278
+ dry_run: bool = False,
279
+ force: bool = False,
280
+ create_if_missing: bool = False,
281
+ ) -> ExportResult:
282
+ """Export editor config for VS Code to point solely to Open Edison.
283
+
284
+ Uses the user-level `mcp.json` path used by the importer.
285
+
286
+ Behavior mirrors Cursor export:
287
+ - Back up existing file if present.
288
+ - Abort on malformed JSON.
289
+ - If file does not exist, require create_if_missing=True or raise ExportError.
290
+ - Write a minimal mcpServers object with a single Open Edison server.
291
+ - Atomic writes.
292
+ """
293
+
294
+ _require_supported_os()
295
+ target_path = _resolve_vscode_target()
296
+
297
+ backup_path: Path | None = None
298
+
299
+ _validate_or_confirm_create(target_path, create_if_missing=create_if_missing, label="VS Code")
300
+
301
+ # Build the minimal config
302
+ new_config: dict[str, Any] = {
303
+ "mcpServers": _build_open_edison_server(name=server_name, url=url, api_key=api_key)
304
+ }
305
+
306
+ # If already configured exactly as desired and not forcing, no-op
307
+ maybe_skip = _skip_if_already_configured(
308
+ target_path,
309
+ url=url,
310
+ api_key=api_key,
311
+ name=server_name,
312
+ force=force,
313
+ dry_run=dry_run,
314
+ label="VS Code",
315
+ )
316
+ if maybe_skip is not None:
317
+ return maybe_skip
318
+
319
+ # Prepare backup if file exists
320
+ backup_path = _backup_if_exists(target_path, dry_run=dry_run)
321
+
322
+ # Write new config
323
+ return _write_config(
324
+ target_path,
325
+ new_config=new_config,
326
+ backup_path=backup_path,
327
+ dry_run=dry_run,
328
+ label="VS Code",
329
+ )
330
+
331
+
332
+ def _merge_preserving_non_mcp(
333
+ existing_obj: dict[str, Any], new_mcp: dict[str, Any]
334
+ ) -> dict[str, Any]:
335
+ merged = dict(existing_obj)
336
+ merged.pop("servers", None)
337
+ merged["mcpServers"] = new_mcp
338
+ return merged
339
+
340
+
341
+ def export_to_claude_code(
342
+ *,
343
+ url: str = "http://localhost:3000/mcp/",
344
+ api_key: str = "dev-api-key-change-me",
345
+ server_name: str = "open-edison",
346
+ dry_run: bool = False,
347
+ force: bool = False,
348
+ create_if_missing: bool = False,
349
+ ) -> ExportResult:
350
+ """Export for Claude Code.
351
+
352
+ - If target is a general settings file, preserve non-MCP keys and replace MCP.
353
+ - Otherwise, write minimal MCP-only object.
354
+ """
355
+
356
+ _require_supported_os()
357
+ target_path = _resolve_claude_code_target()
358
+
359
+ is_existing = target_path.exists()
360
+ if is_existing:
361
+ current = _read_json_or_error(target_path)
362
+ maybe_skip = _skip_if_already_configured(
363
+ target_path,
364
+ url=url,
365
+ api_key=api_key,
366
+ name=server_name,
367
+ force=force,
368
+ dry_run=dry_run,
369
+ label="Claude Code",
370
+ )
371
+ if maybe_skip is not None:
372
+ return maybe_skip
373
+ else:
374
+ if not create_if_missing:
375
+ raise ExportError(
376
+ f"Claude Code config not found at {target_path}. Refusing to create without confirmation."
377
+ )
378
+ current = {}
379
+
380
+ new_mcp = _build_open_edison_server(name=server_name, url=url, api_key=api_key)
381
+ if is_existing and isinstance(current, dict) and current:
382
+ new_config = _merge_preserving_non_mcp(current, new_mcp)
383
+ else:
384
+ new_config = {"mcpServers": new_mcp}
385
+
386
+ backup_path = _backup_if_exists(target_path, dry_run=dry_run)
387
+ return _write_config(
388
+ target_path,
389
+ new_config=new_config,
390
+ backup_path=backup_path,
391
+ dry_run=dry_run,
392
+ label="Claude Code",
393
+ )
@@ -0,0 +1,3 @@
1
+ from __future__ import annotations
2
+
3
+ # legacy helpers were removed to satisfy deadcode scanning
@@ -0,0 +1,63 @@
1
+ from collections.abc import Callable
2
+ from pathlib import Path
3
+
4
+ from loguru import logger as log
5
+
6
+ from src.config import MCPServerConfig
7
+
8
+ from .parsers import ImportErrorDetails, parse_mcp_like_json, safe_read_json
9
+ from .paths import (
10
+ find_claude_code_user_all_candidates,
11
+ find_claude_code_user_settings_file,
12
+ find_cursor_user_file,
13
+ find_vscode_user_mcp_file,
14
+ )
15
+
16
+
17
+ def import_from_cursor() -> list[MCPServerConfig]:
18
+ # Only support user-level Cursor config
19
+ files = find_cursor_user_file()
20
+ if not files:
21
+ raise ImportErrorDetails(
22
+ "Cursor MCP config not found (~/.cursor/mcp.json).",
23
+ Path.home() / ".cursor" / "mcp.json",
24
+ )
25
+ data = safe_read_json(files[0])
26
+ return parse_mcp_like_json(data, default_enabled=True)
27
+
28
+
29
+ def import_from_vscode() -> list[MCPServerConfig]:
30
+ files = find_vscode_user_mcp_file()
31
+ if not files:
32
+ raise ImportErrorDetails("VSCode mcp.json not found at User/mcp.json on macOS/Linux.")
33
+ log.info("VSCode MCP config detected at: {}", files[0])
34
+ data = safe_read_json(files[0])
35
+ return parse_mcp_like_json(data, default_enabled=True)
36
+
37
+
38
+ def import_from_claude_code() -> list[MCPServerConfig]:
39
+ # Prefer Claude Code's documented user-level locations if present
40
+ files = find_claude_code_user_all_candidates()
41
+ if not files:
42
+ # Back-compat: also check specific settings.json location
43
+ files = find_claude_code_user_settings_file()
44
+ for f in files:
45
+ try:
46
+ log.info("Claude Code config detected at: {}", f)
47
+ data = safe_read_json(f)
48
+ parsed = parse_mcp_like_json(data, default_enabled=True)
49
+ if parsed:
50
+ return parsed
51
+ except Exception as e:
52
+ log.warning("Failed reading Claude Code config {}: {}", f, e)
53
+
54
+ # No user-level Claude Code config found; return empty per user preference
55
+ log.info("Claude Code config not found; returning empty result (no user-level config found)")
56
+ return []
57
+
58
+
59
+ IMPORTERS: dict[str, Callable[..., list[MCPServerConfig]]] = {
60
+ "cursor": import_from_cursor,
61
+ "vscode": import_from_vscode,
62
+ "claude-code": import_from_claude_code,
63
+ }
@@ -0,0 +1,47 @@
1
+ from typing import Any
2
+
3
+ from loguru import logger as log
4
+
5
+ MCPServerConfigT = Any
6
+
7
+
8
+ class MergePolicy:
9
+ SKIP = "skip"
10
+ OVERWRITE = "overwrite"
11
+ RENAME = "rename"
12
+
13
+
14
+ def merge_servers(
15
+ existing: list[MCPServerConfigT],
16
+ imported: list[MCPServerConfigT],
17
+ policy: str,
18
+ ) -> list[MCPServerConfigT]:
19
+ name_to_index: dict[str, int] = {s.name: i for i, s in enumerate(existing)}
20
+ result: list[MCPServerConfigT] = list(existing)
21
+
22
+ for server in imported:
23
+ server.enabled = True
24
+ if server.name in name_to_index:
25
+ if policy == MergePolicy.SKIP:
26
+ log.info("Skipping duplicate server '{}' (policy=skip)", server.name)
27
+ continue
28
+ if policy == MergePolicy.OVERWRITE:
29
+ idx = name_to_index[server.name]
30
+ log.info("Overwriting server '{}' (policy=overwrite)", server.name)
31
+ result[idx] = server
32
+ continue
33
+ if policy == MergePolicy.RENAME:
34
+ suffix = "-imported"
35
+ base_name = server.name
36
+ counter = 1
37
+ new_name = f"{base_name}{suffix}"
38
+ while new_name in name_to_index:
39
+ counter += 1
40
+ new_name = f"{base_name}{suffix}-{counter}"
41
+ server.name = new_name
42
+ log.info(
43
+ "Renamed imported server from '{}' to '{}' (policy=rename)", base_name, new_name
44
+ )
45
+ name_to_index[server.name] = len(result)
46
+ result.append(server)
47
+ return result