cloudsmith-cli 1.20.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.
Files changed (130) hide show
  1. cloudsmith_cli/__init__.py +10 -0
  2. cloudsmith_cli/__main__.py +8 -0
  3. cloudsmith_cli/cli/__init__.py +1 -0
  4. cloudsmith_cli/cli/command.py +160 -0
  5. cloudsmith_cli/cli/commands/__init__.py +33 -0
  6. cloudsmith_cli/cli/commands/auth.py +173 -0
  7. cloudsmith_cli/cli/commands/check.py +129 -0
  8. cloudsmith_cli/cli/commands/copy.py +98 -0
  9. cloudsmith_cli/cli/commands/credential_helper/__init__.py +39 -0
  10. cloudsmith_cli/cli/commands/credential_helper/docker.py +66 -0
  11. cloudsmith_cli/cli/commands/credential_helper/manage.py +299 -0
  12. cloudsmith_cli/cli/commands/delete.py +67 -0
  13. cloudsmith_cli/cli/commands/dependencies.py +108 -0
  14. cloudsmith_cli/cli/commands/docs.py +16 -0
  15. cloudsmith_cli/cli/commands/download.py +620 -0
  16. cloudsmith_cli/cli/commands/entitlements.py +819 -0
  17. cloudsmith_cli/cli/commands/help_.py +12 -0
  18. cloudsmith_cli/cli/commands/list_.py +317 -0
  19. cloudsmith_cli/cli/commands/login.py +99 -0
  20. cloudsmith_cli/cli/commands/logout.py +151 -0
  21. cloudsmith_cli/cli/commands/main.py +73 -0
  22. cloudsmith_cli/cli/commands/mcp.py +523 -0
  23. cloudsmith_cli/cli/commands/metadata.py +503 -0
  24. cloudsmith_cli/cli/commands/metrics/__init__.py +2 -0
  25. cloudsmith_cli/cli/commands/metrics/command.py +20 -0
  26. cloudsmith_cli/cli/commands/metrics/entitlements.py +148 -0
  27. cloudsmith_cli/cli/commands/metrics/packages.py +134 -0
  28. cloudsmith_cli/cli/commands/move.py +111 -0
  29. cloudsmith_cli/cli/commands/policy/__init__.py +3 -0
  30. cloudsmith_cli/cli/commands/policy/command.py +20 -0
  31. cloudsmith_cli/cli/commands/policy/deny.py +248 -0
  32. cloudsmith_cli/cli/commands/policy/license.py +335 -0
  33. cloudsmith_cli/cli/commands/policy/vulnerability.py +322 -0
  34. cloudsmith_cli/cli/commands/push.py +1323 -0
  35. cloudsmith_cli/cli/commands/quarantine.py +148 -0
  36. cloudsmith_cli/cli/commands/quota/__init__.py +2 -0
  37. cloudsmith_cli/cli/commands/quota/command.py +20 -0
  38. cloudsmith_cli/cli/commands/quota/history.py +122 -0
  39. cloudsmith_cli/cli/commands/quota/quota.py +107 -0
  40. cloudsmith_cli/cli/commands/repos.py +330 -0
  41. cloudsmith_cli/cli/commands/resync.py +90 -0
  42. cloudsmith_cli/cli/commands/status.py +92 -0
  43. cloudsmith_cli/cli/commands/tags.py +375 -0
  44. cloudsmith_cli/cli/commands/tokens.py +318 -0
  45. cloudsmith_cli/cli/commands/upstream.py +479 -0
  46. cloudsmith_cli/cli/commands/vulnerabilities.py +141 -0
  47. cloudsmith_cli/cli/commands/whoami.py +188 -0
  48. cloudsmith_cli/cli/config.py +635 -0
  49. cloudsmith_cli/cli/decorators.py +624 -0
  50. cloudsmith_cli/cli/exceptions.py +215 -0
  51. cloudsmith_cli/cli/metadata_common.py +146 -0
  52. cloudsmith_cli/cli/saml.py +109 -0
  53. cloudsmith_cli/cli/table.py +59 -0
  54. cloudsmith_cli/cli/types.py +14 -0
  55. cloudsmith_cli/cli/utils.py +267 -0
  56. cloudsmith_cli/cli/validators.py +378 -0
  57. cloudsmith_cli/cli/webserver.py +263 -0
  58. cloudsmith_cli/core/__init__.py +1 -0
  59. cloudsmith_cli/core/api/__init__.py +1 -0
  60. cloudsmith_cli/core/api/distros.py +31 -0
  61. cloudsmith_cli/core/api/entitlements.py +130 -0
  62. cloudsmith_cli/core/api/exceptions.py +57 -0
  63. cloudsmith_cli/core/api/files.py +131 -0
  64. cloudsmith_cli/core/api/init.py +109 -0
  65. cloudsmith_cli/core/api/metadata.py +217 -0
  66. cloudsmith_cli/core/api/metrics.py +78 -0
  67. cloudsmith_cli/core/api/orgs.py +201 -0
  68. cloudsmith_cli/core/api/packages.py +309 -0
  69. cloudsmith_cli/core/api/quota.py +64 -0
  70. cloudsmith_cli/core/api/rates.py +28 -0
  71. cloudsmith_cli/core/api/repos.py +81 -0
  72. cloudsmith_cli/core/api/status.py +27 -0
  73. cloudsmith_cli/core/api/upstreams.py +72 -0
  74. cloudsmith_cli/core/api/user.py +109 -0
  75. cloudsmith_cli/core/api/version.py +15 -0
  76. cloudsmith_cli/core/api/vulnerabilities.py +230 -0
  77. cloudsmith_cli/core/cache_utils.py +160 -0
  78. cloudsmith_cli/core/config.py +140 -0
  79. cloudsmith_cli/core/credentials/__init__.py +0 -0
  80. cloudsmith_cli/core/credentials/chain.py +69 -0
  81. cloudsmith_cli/core/credentials/models.py +44 -0
  82. cloudsmith_cli/core/credentials/oidc/__init__.py +6 -0
  83. cloudsmith_cli/core/credentials/oidc/cache.py +220 -0
  84. cloudsmith_cli/core/credentials/oidc/detectors/__init__.py +122 -0
  85. cloudsmith_cli/core/credentials/oidc/detectors/aws.py +85 -0
  86. cloudsmith_cli/core/credentials/oidc/detectors/azure_devops.py +70 -0
  87. cloudsmith_cli/core/credentials/oidc/detectors/base.py +26 -0
  88. cloudsmith_cli/core/credentials/oidc/detectors/bitbucket_pipelines.py +35 -0
  89. cloudsmith_cli/core/credentials/oidc/detectors/circleci.py +40 -0
  90. cloudsmith_cli/core/credentials/oidc/detectors/generic.py +42 -0
  91. cloudsmith_cli/core/credentials/oidc/detectors/github_actions.py +64 -0
  92. cloudsmith_cli/core/credentials/oidc/detectors/gitlab_ci.py +49 -0
  93. cloudsmith_cli/core/credentials/oidc/exchange.py +87 -0
  94. cloudsmith_cli/core/credentials/provider.py +17 -0
  95. cloudsmith_cli/core/credentials/providers/__init__.py +15 -0
  96. cloudsmith_cli/core/credentials/providers/cli_flag.py +24 -0
  97. cloudsmith_cli/core/credentials/providers/credentials_file.py +24 -0
  98. cloudsmith_cli/core/credentials/providers/env_var.py +24 -0
  99. cloudsmith_cli/core/credentials/providers/keyring_provider.py +59 -0
  100. cloudsmith_cli/core/credentials/providers/oidc_provider.py +115 -0
  101. cloudsmith_cli/core/download.py +594 -0
  102. cloudsmith_cli/core/keyring.py +171 -0
  103. cloudsmith_cli/core/mcp/__init__.py +0 -0
  104. cloudsmith_cli/core/mcp/data.py +17 -0
  105. cloudsmith_cli/core/mcp/server.py +786 -0
  106. cloudsmith_cli/core/pagination.py +131 -0
  107. cloudsmith_cli/core/ratelimits.py +88 -0
  108. cloudsmith_cli/core/rest.py +255 -0
  109. cloudsmith_cli/core/utils.py +95 -0
  110. cloudsmith_cli/core/version.py +20 -0
  111. cloudsmith_cli/credential_helpers/__init__.py +7 -0
  112. cloudsmith_cli/credential_helpers/backends.py +41 -0
  113. cloudsmith_cli/credential_helpers/common.py +111 -0
  114. cloudsmith_cli/credential_helpers/custom_domains.py +281 -0
  115. cloudsmith_cli/credential_helpers/docker/__init__.py +4 -0
  116. cloudsmith_cli/credential_helpers/docker/installer.py +349 -0
  117. cloudsmith_cli/credential_helpers/docker/runtime.py +117 -0
  118. cloudsmith_cli/credential_helpers/launchers.py +175 -0
  119. cloudsmith_cli/data/VERSION +1 -0
  120. cloudsmith_cli/data/config.ini +23 -0
  121. cloudsmith_cli/data/credentials.ini +14 -0
  122. cloudsmith_cli/templates/__init__.py +3 -0
  123. cloudsmith_cli/templates/auth_error.html +45 -0
  124. cloudsmith_cli/templates/auth_success.html +37 -0
  125. cloudsmith_cli-1.20.0.dist-info/METADATA +610 -0
  126. cloudsmith_cli-1.20.0.dist-info/RECORD +130 -0
  127. cloudsmith_cli-1.20.0.dist-info/WHEEL +5 -0
  128. cloudsmith_cli-1.20.0.dist-info/entry_points.txt +2 -0
  129. cloudsmith_cli-1.20.0.dist-info/licenses/LICENSE +201 -0
  130. cloudsmith_cli-1.20.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,523 @@
1
+ """Main command/entrypoint."""
2
+
3
+ import json
4
+ import os
5
+ import shutil
6
+ import sys
7
+ import tempfile
8
+ from pathlib import Path
9
+
10
+ import click
11
+ import json5
12
+
13
+ from ...core.mcp import server
14
+ from ...core.mcp.data import OpenAPITool
15
+ from .. import command, decorators, utils
16
+ from .main import main
17
+
18
+ SUPPORTED_MCP_CLIENTS = {
19
+ "claude": "Claude Desktop",
20
+ "claude-code": "Claude Code",
21
+ "cursor": "Cursor IDE",
22
+ "vscode": "VS Code",
23
+ "gemini-cli": "Gemini CLI",
24
+ }
25
+
26
+
27
+ @main.group(cls=command.AliasGroup, name="mcp")
28
+ @decorators.common_cli_config_options
29
+ @decorators.common_cli_output_options
30
+ @decorators.common_api_auth_options
31
+ @decorators.initialise_api
32
+ @click.pass_context
33
+ def mcp_(ctx, opts): # pylint: disable=unused-argument
34
+ """
35
+ Start the Cloudsmith MCP Server
36
+
37
+ See the help for subcommands for more information on each.
38
+ """
39
+
40
+
41
+ @mcp_.command(name="start")
42
+ @decorators.initialise_api
43
+ @decorators.initialise_mcp
44
+ @click.pass_context
45
+ def start(ctx, opts, mcp_server: server.DynamicMCPServer):
46
+ """
47
+ Start the MCP Server
48
+ """
49
+ mcp_server.run()
50
+
51
+
52
+ @mcp_.command(name="list_tools")
53
+ @decorators.common_cli_config_options
54
+ @decorators.common_cli_output_options
55
+ @decorators.common_api_auth_options
56
+ @decorators.initialise_api
57
+ @decorators.initialise_mcp
58
+ @click.pass_context
59
+ def list_tools(ctx, opts, mcp_server: server.DynamicMCPServer):
60
+ """
61
+ List available tools that will be exposed to the MCP Client
62
+ """
63
+ use_stderr = utils.should_use_stderr(opts)
64
+
65
+ if not use_stderr:
66
+ click.echo("Getting list of tools ... ", nl=False, err=use_stderr)
67
+
68
+ with utils.maybe_spinner(opts):
69
+ tools = mcp_server.list_tools()
70
+
71
+ if not use_stderr:
72
+ click.secho("OK", fg="green", err=use_stderr)
73
+
74
+ tools_data = [
75
+ {"name": name, "description": spec.description} for name, spec in tools.items()
76
+ ]
77
+
78
+ if utils.maybe_print_as_json(opts, tools_data):
79
+ return
80
+
81
+ print_tools(tools)
82
+
83
+
84
+ @mcp_.command(name="list_groups")
85
+ @decorators.common_cli_config_options
86
+ @decorators.common_cli_output_options
87
+ @decorators.common_api_auth_options
88
+ @decorators.initialise_api
89
+ @decorators.initialise_mcp
90
+ @click.pass_context
91
+ def list_groups(ctx, opts, mcp_server: server.DynamicMCPServer):
92
+ """
93
+ List available tool groups and the tools they contain
94
+ """
95
+ use_stderr = utils.should_use_stderr(opts)
96
+
97
+ if not use_stderr:
98
+ click.echo("Getting list of tool groups ... ", nl=False, err=use_stderr)
99
+
100
+ with utils.maybe_spinner(opts):
101
+ groups = mcp_server.list_groups()
102
+
103
+ if not use_stderr:
104
+ click.secho("OK", fg="green", err=use_stderr)
105
+
106
+ groups_data = [{"name": name, "tools": tools} for name, tools in groups.items()]
107
+
108
+ if utils.maybe_print_as_json(opts, groups_data):
109
+ return
110
+
111
+ print_groups(groups)
112
+
113
+
114
+ def print_tools(tool_list: dict[str, OpenAPITool]):
115
+ """Print tools as a table or output in another format."""
116
+
117
+ headers = [
118
+ "Name",
119
+ "Description",
120
+ ]
121
+
122
+ rows = []
123
+ for tool_name, tools_spec in tool_list.items():
124
+ rows.append(
125
+ [
126
+ click.style(tool_name, fg="cyan"),
127
+ click.style(tools_spec.description, fg="yellow"),
128
+ ]
129
+ )
130
+
131
+ if tool_list:
132
+ click.echo()
133
+ utils.pretty_print_table(headers, rows)
134
+
135
+ click.echo()
136
+
137
+ num_results = len(tool_list)
138
+ list_suffix = "tool%s visible" % ("s" if num_results != 1 else "")
139
+ utils.pretty_print_list_info(num_results=num_results, suffix=list_suffix)
140
+
141
+
142
+ def print_groups(group_list: dict[str, list[str]]):
143
+ """Print tool groups as a table or output in another format."""
144
+
145
+ headers = [
146
+ "Group Name",
147
+ "Tool Count",
148
+ "Sample Tools",
149
+ ]
150
+
151
+ rows = []
152
+ for group_name, tools in group_list.items():
153
+ # Show first 3 tools as samples
154
+ sample_tools = ", ".join(tools[:3])
155
+ if len(tools) > 3:
156
+ sample_tools += f", ... (+{len(tools) - 3} more)"
157
+
158
+ rows.append(
159
+ [
160
+ click.style(group_name, fg="cyan"),
161
+ click.style(str(len(tools)), fg="yellow"),
162
+ click.style(sample_tools, fg="white"),
163
+ ]
164
+ )
165
+
166
+ if group_list:
167
+ click.echo()
168
+ utils.pretty_print_table(headers, rows)
169
+
170
+ click.echo()
171
+
172
+ num_results = len(group_list)
173
+ list_suffix = "group%s visible" % ("s" if num_results != 1 else "")
174
+ utils.pretty_print_list_info(num_results=num_results, suffix=list_suffix)
175
+
176
+
177
+ @mcp_.command(name="configure")
178
+ @decorators.common_cli_config_options
179
+ @decorators.common_cli_output_options
180
+ @decorators.common_api_auth_options
181
+ @click.option(
182
+ "--client",
183
+ type=click.Choice(list(SUPPORTED_MCP_CLIENTS.keys()), case_sensitive=False),
184
+ help=f"MCP client to configure ({', '.join(SUPPORTED_MCP_CLIENTS.keys())}). If not specified, will attempt to detect and configure all.",
185
+ )
186
+ @click.option(
187
+ "--global/--local",
188
+ "is_global",
189
+ default=True,
190
+ help="Configure globally (default) or in current project directory (local)",
191
+ )
192
+ @decorators.initialise_api
193
+ @click.pass_context
194
+ def configure(ctx, opts, client, is_global): # pylint: disable=unused-argument
195
+ """
196
+ Configure the Cloudsmith MCP server for supported clients.
197
+
198
+ This command automatically adds the Cloudsmith MCP server configuration
199
+ to the specified client's configuration file. Supported clients are:
200
+ - Claude Desktop
201
+ - Claude Code
202
+ - Cursor IDE
203
+ - VS Code (GitHub Copilot)
204
+ - Gemini CLI
205
+
206
+ For Claude Code, --global edits ~/.claude.json (user scope) and
207
+ --local writes ./.mcp.json (project scope, intended to be committed).
208
+
209
+ Examples:\n
210
+ cloudsmith mcp configure --client claude\n
211
+ cloudsmith mcp configure --client claude-code\n
212
+ cloudsmith mcp configure --client cursor --local\n
213
+ cloudsmith mcp configure --client gemini-cli\n
214
+ cloudsmith mcp configure # Auto-detect and configure all
215
+ """
216
+
217
+ use_stderr = utils.should_use_stderr(opts)
218
+
219
+ # Get the profile from context
220
+ profile = ctx.meta.get("profile")
221
+
222
+ # Determine the best command to run the MCP server
223
+ server_config = _get_server_config(profile)
224
+
225
+ clients_to_configure = []
226
+ if client:
227
+ clients_to_configure = [client.lower()]
228
+ else:
229
+ # Auto-detect available clients
230
+ clients_to_configure = detect_available_clients()
231
+
232
+ if not clients_to_configure:
233
+ if not use_stderr:
234
+ click.echo(click.style("No supported MCP clients detected.", fg="yellow"))
235
+ click.echo("\nSupported clients:")
236
+ for display_name in SUPPORTED_MCP_CLIENTS.values():
237
+ click.echo(f" - {display_name}")
238
+
239
+ utils.maybe_print_as_json(opts, [])
240
+ return
241
+
242
+ results = []
243
+ success_count = 0
244
+ for client_name in clients_to_configure:
245
+ try:
246
+ if configure_client(client_name, server_config, is_global, profile):
247
+ if not use_stderr:
248
+ click.echo(
249
+ click.style(f"✓ Configured {client_name.title()}", fg="green")
250
+ )
251
+ success_count += 1
252
+ results.append({"client": client_name, "success": True})
253
+ else:
254
+ if not use_stderr:
255
+ click.echo(
256
+ click.style(
257
+ f"✗ Failed to configure {client_name.title()}", fg="red"
258
+ )
259
+ )
260
+ results.append(
261
+ {
262
+ "client": client_name,
263
+ "success": False,
264
+ "error": "Configuration failed",
265
+ }
266
+ )
267
+ except (OSError, ValueError) as e:
268
+ if not use_stderr:
269
+ click.echo(
270
+ click.style(
271
+ f"✗ Error configuring {client_name.title()}: {str(e)}", fg="red"
272
+ )
273
+ )
274
+ results.append({"client": client_name, "success": False, "error": str(e)})
275
+
276
+ if utils.maybe_print_as_json(opts, results):
277
+ return
278
+
279
+ if success_count > 0:
280
+ click.echo(
281
+ click.style(
282
+ f"\n✓ Successfully configured {success_count} client(s)", fg="green"
283
+ )
284
+ )
285
+ click.echo(
286
+ "\nNote: You may need to restart the client application for changes to take effect."
287
+ )
288
+ else:
289
+ click.echo(click.style("\n✗ No clients were configured successfully", fg="red"))
290
+
291
+
292
+ def _get_server_config(profile=None):
293
+ """Determine the first available command configuration to run the MCP server."""
294
+ is_frozen = getattr(sys, "frozen", False)
295
+ in_venv = hasattr(sys, "real_prefix") or (
296
+ hasattr(sys, "base_prefix") and sys.base_prefix != sys.prefix
297
+ )
298
+
299
+ # Build the base args
300
+ base_args = []
301
+ if profile:
302
+ base_args.extend(["-P", profile])
303
+
304
+ if is_frozen:
305
+ return {"command": sys.executable, "args": base_args + ["mcp", "start"]}
306
+
307
+ # In a venv, always use python -m to ensure we use the venv's packages
308
+ if in_venv:
309
+ return {
310
+ "command": sys.executable,
311
+ "args": ["-m", "cloudsmith_cli"] + base_args + ["mcp", "start"],
312
+ }
313
+
314
+ # Otherwise, try to find cloudsmith in PATH, fall back to python -m
315
+ cloudsmith_cmd = shutil.which("cloudsmith")
316
+ if cloudsmith_cmd:
317
+ return {"command": cloudsmith_cmd, "args": base_args + ["mcp", "start"]}
318
+
319
+ return {
320
+ "command": sys.executable,
321
+ "args": ["-m", "cloudsmith_cli"] + base_args + ["mcp", "start"],
322
+ }
323
+
324
+
325
+ def detect_available_clients():
326
+ """Detect which MCP clients are available on the system."""
327
+ available = []
328
+ home = Path.home()
329
+
330
+ for client in SUPPORTED_MCP_CLIENTS:
331
+ config = get_config_path(client, is_global=True)
332
+ if not config:
333
+ continue
334
+ # Parent-dir existence is the usual "app installed" marker, but a
335
+ # parent of $HOME (Claude Code) tells us nothing; require the file
336
+ # itself in that case.
337
+ if config.exists() or (config.parent.exists() and config.parent != home):
338
+ available.append(client)
339
+
340
+ return available
341
+
342
+
343
+ def get_config_path(client_name, is_global=True):
344
+ """Get the configuration file path for a given client."""
345
+ home = Path.home()
346
+ appdata = os.getenv("APPDATA", "")
347
+
348
+ # Configuration paths by client, platform, and scope
349
+ config_paths = {
350
+ "claude": {
351
+ "darwin": home
352
+ / "Library"
353
+ / "Application Support"
354
+ / "Claude"
355
+ / "claude_desktop_config.json",
356
+ "win32": (
357
+ Path(appdata) / "Claude" / "claude_desktop_config.json"
358
+ if appdata
359
+ else None
360
+ ),
361
+ "linux": home / ".config" / "Claude" / "claude_desktop_config.json",
362
+ },
363
+ "claude-code": {
364
+ "global": home / ".claude.json",
365
+ "local": Path.cwd() / ".mcp.json",
366
+ },
367
+ "cursor": {
368
+ "global": home / ".cursor" / "mcp.json",
369
+ "local": Path.cwd() / ".cursor" / "mcp.json",
370
+ },
371
+ "vscode": {
372
+ "darwin": home
373
+ / "Library"
374
+ / "Application Support"
375
+ / "Code"
376
+ / "User"
377
+ / "settings.json",
378
+ "win32": (
379
+ Path(appdata) / "Code" / "User" / "settings.json" if appdata else None
380
+ ),
381
+ "linux": home / ".config" / "Code" / "User" / "settings.json",
382
+ "local": Path.cwd() / ".vscode" / "settings.json",
383
+ },
384
+ "gemini-cli": {
385
+ "global": home / ".gemini" / "settings.json",
386
+ "local": Path.cwd() / ".gemini" / "settings.json",
387
+ },
388
+ }
389
+
390
+ client_config = config_paths.get(client_name, {})
391
+
392
+ # For scope-keyed (not platform-keyed) clients, look up by global/local.
393
+ if client_name in ("claude-code", "cursor", "gemini-cli"):
394
+ scope = "global" if is_global else "local"
395
+ return client_config.get(scope)
396
+
397
+ # For VS Code local config
398
+ if client_name == "vscode" and not is_global:
399
+ return client_config.get("local")
400
+
401
+ # For platform-specific configs (Claude and VS Code global)
402
+ platform = sys.platform if sys.platform in ("darwin", "win32") else "linux"
403
+ return client_config.get(platform)
404
+
405
+
406
+ def configure_client(client_name, server_config, is_global=True, profile=None):
407
+ """Configure a specific MCP client with the Cloudsmith server."""
408
+ server_name = f"cloudsmith-{profile}" if profile else "cloudsmith"
409
+
410
+ if client_name == "claude-code":
411
+ return _configure_claude_code(server_name, server_config, is_global)
412
+
413
+ config_path = get_config_path(client_name, is_global)
414
+ if not config_path:
415
+ return False
416
+
417
+ key = "chat.mcp.servers" if client_name == "vscode" else "mcpServers"
418
+
419
+ def mutate(config):
420
+ config.setdefault(key, {})[server_name] = server_config
421
+
422
+ _safe_update_json(config_path, mutate)
423
+ return True
424
+
425
+
426
+ def _configure_claude_code(server_name, server_config, is_global):
427
+ """Register the Cloudsmith MCP server with Claude Code.
428
+
429
+ Why direct edit (not `claude mcp add-json`): avoids requiring the Claude
430
+ Code CLI on PATH. _safe_update_json handles the race with a running
431
+ Claude Code session writing to ~/.claude.json.
432
+ """
433
+ path = get_config_path("claude-code", is_global=is_global)
434
+ if is_global and not path.exists():
435
+ raise ValueError(
436
+ f"{path} not found. Launch Claude Code at least once, "
437
+ "then re-run this command."
438
+ )
439
+
440
+ def mutate(config):
441
+ config.setdefault("mcpServers", {})[server_name] = server_config
442
+
443
+ _safe_update_json(path, mutate)
444
+ return True
445
+
446
+
447
+ def _atomic_write_json(path: Path, data) -> None:
448
+ """Write JSON to ``path`` atomically via a tempfile + os.replace.
449
+
450
+ Preserves the destination's existing file mode when present.
451
+
452
+ Follows symlinks before writing so dotfile-managed configs (a symlinked
453
+ ~/.claude.json, settings.json, etc.) update through to the real file
454
+ rather than getting the link replaced.
455
+ """
456
+ if path.is_symlink():
457
+ path = Path(os.path.realpath(path))
458
+
459
+ path.parent.mkdir(parents=True, exist_ok=True)
460
+ existing_mode = path.stat().st_mode & 0o777 if path.exists() else None
461
+
462
+ tmp = tempfile.NamedTemporaryFile(
463
+ mode="w",
464
+ dir=path.parent,
465
+ prefix=f".{path.name}.",
466
+ suffix=".tmp",
467
+ delete=False,
468
+ )
469
+ tmp_path = Path(tmp.name)
470
+ try:
471
+ with tmp as f:
472
+ # json5 is used for reading; we write standard JSON, which drops
473
+ # any user comments (currently only relevant for VS Code's JSONC).
474
+ json.dump(data, f, indent=2)
475
+ f.flush()
476
+ os.fsync(f.fileno())
477
+ if existing_mode is not None:
478
+ os.chmod(tmp_path, existing_mode)
479
+ os.replace(tmp_path, path)
480
+ except BaseException:
481
+ try:
482
+ tmp_path.unlink()
483
+ except FileNotFoundError:
484
+ pass
485
+ raise
486
+
487
+
488
+ def _safe_update_json(path: Path, mutate, *, max_retries: int = 3) -> None:
489
+ """Read JSON at ``path``, apply ``mutate(dict)``, atomic-write back.
490
+
491
+ Retries on mtime change between read and replace -- guards against a
492
+ concurrent writer (e.g. a running Claude Code session updating
493
+ ~/.claude.json, or VS Code editing settings.json) clobbering deltas.
494
+ """
495
+ for _ in range(max_retries):
496
+ if path.exists():
497
+ mtime_before = path.stat().st_mtime_ns
498
+ with open(path) as f:
499
+ content = f.read()
500
+ try:
501
+ config = json5.loads(content)
502
+ except (json.JSONDecodeError, ValueError) as e:
503
+ raise ValueError(
504
+ f"Cannot parse config file '{path}': {e}. "
505
+ "Please fix the JSON syntax or remove the file to "
506
+ "create a new one."
507
+ ) from e
508
+ else:
509
+ mtime_before = None
510
+ config = {}
511
+
512
+ mutate(config)
513
+
514
+ if mtime_before is not None and path.stat().st_mtime_ns != mtime_before:
515
+ continue
516
+
517
+ _atomic_write_json(path, config)
518
+ return
519
+
520
+ raise ValueError(
521
+ f"Could not safely update {path}: another process keeps modifying "
522
+ "it. Close the consuming app and retry."
523
+ )