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.
- cloudsmith_cli/__init__.py +10 -0
- cloudsmith_cli/__main__.py +8 -0
- cloudsmith_cli/cli/__init__.py +1 -0
- cloudsmith_cli/cli/command.py +160 -0
- cloudsmith_cli/cli/commands/__init__.py +33 -0
- cloudsmith_cli/cli/commands/auth.py +173 -0
- cloudsmith_cli/cli/commands/check.py +129 -0
- cloudsmith_cli/cli/commands/copy.py +98 -0
- cloudsmith_cli/cli/commands/credential_helper/__init__.py +39 -0
- cloudsmith_cli/cli/commands/credential_helper/docker.py +66 -0
- cloudsmith_cli/cli/commands/credential_helper/manage.py +299 -0
- cloudsmith_cli/cli/commands/delete.py +67 -0
- cloudsmith_cli/cli/commands/dependencies.py +108 -0
- cloudsmith_cli/cli/commands/docs.py +16 -0
- cloudsmith_cli/cli/commands/download.py +620 -0
- cloudsmith_cli/cli/commands/entitlements.py +819 -0
- cloudsmith_cli/cli/commands/help_.py +12 -0
- cloudsmith_cli/cli/commands/list_.py +317 -0
- cloudsmith_cli/cli/commands/login.py +99 -0
- cloudsmith_cli/cli/commands/logout.py +151 -0
- cloudsmith_cli/cli/commands/main.py +73 -0
- cloudsmith_cli/cli/commands/mcp.py +523 -0
- cloudsmith_cli/cli/commands/metadata.py +503 -0
- cloudsmith_cli/cli/commands/metrics/__init__.py +2 -0
- cloudsmith_cli/cli/commands/metrics/command.py +20 -0
- cloudsmith_cli/cli/commands/metrics/entitlements.py +148 -0
- cloudsmith_cli/cli/commands/metrics/packages.py +134 -0
- cloudsmith_cli/cli/commands/move.py +111 -0
- cloudsmith_cli/cli/commands/policy/__init__.py +3 -0
- cloudsmith_cli/cli/commands/policy/command.py +20 -0
- cloudsmith_cli/cli/commands/policy/deny.py +248 -0
- cloudsmith_cli/cli/commands/policy/license.py +335 -0
- cloudsmith_cli/cli/commands/policy/vulnerability.py +322 -0
- cloudsmith_cli/cli/commands/push.py +1323 -0
- cloudsmith_cli/cli/commands/quarantine.py +148 -0
- cloudsmith_cli/cli/commands/quota/__init__.py +2 -0
- cloudsmith_cli/cli/commands/quota/command.py +20 -0
- cloudsmith_cli/cli/commands/quota/history.py +122 -0
- cloudsmith_cli/cli/commands/quota/quota.py +107 -0
- cloudsmith_cli/cli/commands/repos.py +330 -0
- cloudsmith_cli/cli/commands/resync.py +90 -0
- cloudsmith_cli/cli/commands/status.py +92 -0
- cloudsmith_cli/cli/commands/tags.py +375 -0
- cloudsmith_cli/cli/commands/tokens.py +318 -0
- cloudsmith_cli/cli/commands/upstream.py +479 -0
- cloudsmith_cli/cli/commands/vulnerabilities.py +141 -0
- cloudsmith_cli/cli/commands/whoami.py +188 -0
- cloudsmith_cli/cli/config.py +635 -0
- cloudsmith_cli/cli/decorators.py +624 -0
- cloudsmith_cli/cli/exceptions.py +215 -0
- cloudsmith_cli/cli/metadata_common.py +146 -0
- cloudsmith_cli/cli/saml.py +109 -0
- cloudsmith_cli/cli/table.py +59 -0
- cloudsmith_cli/cli/types.py +14 -0
- cloudsmith_cli/cli/utils.py +267 -0
- cloudsmith_cli/cli/validators.py +378 -0
- cloudsmith_cli/cli/webserver.py +263 -0
- cloudsmith_cli/core/__init__.py +1 -0
- cloudsmith_cli/core/api/__init__.py +1 -0
- cloudsmith_cli/core/api/distros.py +31 -0
- cloudsmith_cli/core/api/entitlements.py +130 -0
- cloudsmith_cli/core/api/exceptions.py +57 -0
- cloudsmith_cli/core/api/files.py +131 -0
- cloudsmith_cli/core/api/init.py +109 -0
- cloudsmith_cli/core/api/metadata.py +217 -0
- cloudsmith_cli/core/api/metrics.py +78 -0
- cloudsmith_cli/core/api/orgs.py +201 -0
- cloudsmith_cli/core/api/packages.py +309 -0
- cloudsmith_cli/core/api/quota.py +64 -0
- cloudsmith_cli/core/api/rates.py +28 -0
- cloudsmith_cli/core/api/repos.py +81 -0
- cloudsmith_cli/core/api/status.py +27 -0
- cloudsmith_cli/core/api/upstreams.py +72 -0
- cloudsmith_cli/core/api/user.py +109 -0
- cloudsmith_cli/core/api/version.py +15 -0
- cloudsmith_cli/core/api/vulnerabilities.py +230 -0
- cloudsmith_cli/core/cache_utils.py +160 -0
- cloudsmith_cli/core/config.py +140 -0
- cloudsmith_cli/core/credentials/__init__.py +0 -0
- cloudsmith_cli/core/credentials/chain.py +69 -0
- cloudsmith_cli/core/credentials/models.py +44 -0
- cloudsmith_cli/core/credentials/oidc/__init__.py +6 -0
- cloudsmith_cli/core/credentials/oidc/cache.py +220 -0
- cloudsmith_cli/core/credentials/oidc/detectors/__init__.py +122 -0
- cloudsmith_cli/core/credentials/oidc/detectors/aws.py +85 -0
- cloudsmith_cli/core/credentials/oidc/detectors/azure_devops.py +70 -0
- cloudsmith_cli/core/credentials/oidc/detectors/base.py +26 -0
- cloudsmith_cli/core/credentials/oidc/detectors/bitbucket_pipelines.py +35 -0
- cloudsmith_cli/core/credentials/oidc/detectors/circleci.py +40 -0
- cloudsmith_cli/core/credentials/oidc/detectors/generic.py +42 -0
- cloudsmith_cli/core/credentials/oidc/detectors/github_actions.py +64 -0
- cloudsmith_cli/core/credentials/oidc/detectors/gitlab_ci.py +49 -0
- cloudsmith_cli/core/credentials/oidc/exchange.py +87 -0
- cloudsmith_cli/core/credentials/provider.py +17 -0
- cloudsmith_cli/core/credentials/providers/__init__.py +15 -0
- cloudsmith_cli/core/credentials/providers/cli_flag.py +24 -0
- cloudsmith_cli/core/credentials/providers/credentials_file.py +24 -0
- cloudsmith_cli/core/credentials/providers/env_var.py +24 -0
- cloudsmith_cli/core/credentials/providers/keyring_provider.py +59 -0
- cloudsmith_cli/core/credentials/providers/oidc_provider.py +115 -0
- cloudsmith_cli/core/download.py +594 -0
- cloudsmith_cli/core/keyring.py +171 -0
- cloudsmith_cli/core/mcp/__init__.py +0 -0
- cloudsmith_cli/core/mcp/data.py +17 -0
- cloudsmith_cli/core/mcp/server.py +786 -0
- cloudsmith_cli/core/pagination.py +131 -0
- cloudsmith_cli/core/ratelimits.py +88 -0
- cloudsmith_cli/core/rest.py +255 -0
- cloudsmith_cli/core/utils.py +95 -0
- cloudsmith_cli/core/version.py +20 -0
- cloudsmith_cli/credential_helpers/__init__.py +7 -0
- cloudsmith_cli/credential_helpers/backends.py +41 -0
- cloudsmith_cli/credential_helpers/common.py +111 -0
- cloudsmith_cli/credential_helpers/custom_domains.py +281 -0
- cloudsmith_cli/credential_helpers/docker/__init__.py +4 -0
- cloudsmith_cli/credential_helpers/docker/installer.py +349 -0
- cloudsmith_cli/credential_helpers/docker/runtime.py +117 -0
- cloudsmith_cli/credential_helpers/launchers.py +175 -0
- cloudsmith_cli/data/VERSION +1 -0
- cloudsmith_cli/data/config.ini +23 -0
- cloudsmith_cli/data/credentials.ini +14 -0
- cloudsmith_cli/templates/__init__.py +3 -0
- cloudsmith_cli/templates/auth_error.html +45 -0
- cloudsmith_cli/templates/auth_success.html +37 -0
- cloudsmith_cli-1.20.0.dist-info/METADATA +610 -0
- cloudsmith_cli-1.20.0.dist-info/RECORD +130 -0
- cloudsmith_cli-1.20.0.dist-info/WHEEL +5 -0
- cloudsmith_cli-1.20.0.dist-info/entry_points.txt +2 -0
- cloudsmith_cli-1.20.0.dist-info/licenses/LICENSE +201 -0
- 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
|
+
)
|