mlspace-plugin 0.3.0__tar.gz → 0.3.2__tar.gz

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 (133) hide show
  1. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/INSTALL.md +25 -4
  2. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/INSTALL_AGENT.md +11 -2
  3. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/PKG-INFO +1 -1
  4. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/RELEASE.md +5 -5
  5. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/plugin/.claude-plugin/plugin.json +1 -1
  6. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/plugin/.codex-plugin/plugin.json +1 -1
  7. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/plugin/.mcp.json +1 -1
  8. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/plugin/mcp.json +1 -1
  9. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/plugin/plugin.json +1 -1
  10. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/pyproject.toml +1 -1
  11. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/src/mlspace_mcp/__init__.py +2 -1
  12. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/src/mlspace_mcp/client_setup.py +56 -7
  13. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/src/mlspace_mcp/init_cli.py +15 -6
  14. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/src/mlspace_mcp/server.py +5 -1
  15. mlspace_plugin-0.3.2/src/mlspace_mcp/setup_conflicts.py +105 -0
  16. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/src/mlspace_mcp/setup_verification.py +15 -4
  17. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/tests/test_client_setup.py +1 -1
  18. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/tests/test_onboarding_init.py +57 -1
  19. mlspace_plugin-0.3.2/tests/test_opencode_journey.py +249 -0
  20. mlspace_plugin-0.3.2/tests/test_setup_conflicts.py +202 -0
  21. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/tests/test_setup_native_journey.py +2 -1
  22. mlspace_plugin-0.3.2/tests/test_user_agent.py +59 -0
  23. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/.agents/plugins/marketplace.json +0 -0
  24. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/.claude-plugin/marketplace.json +0 -0
  25. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/.env.example +0 -0
  26. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/.gitignore +0 -0
  27. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/AGENTS.md +0 -0
  28. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/CONTRIBUTING.md +0 -0
  29. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/LICENSE +0 -0
  30. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/README.md +0 -0
  31. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/plugin/README.md +0 -0
  32. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/plugin/skills/mlspace-call-deployed-model-predict/SKILL.md +0 -0
  33. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/plugin/skills/mlspace-deploy-inference-service/SKILL.md +0 -0
  34. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/plugin/skills/mlspace-diagnose-data-transfer/SKILL.md +0 -0
  35. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/plugin/skills/mlspace-diagnose-failed-training-job/SKILL.md +0 -0
  36. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/plugin/skills/mlspace-diagnose-job-launch-rejection/SKILL.md +0 -0
  37. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/plugin/skills/mlspace-diagnose-unhealthy-inference/SKILL.md +0 -0
  38. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/plugin/skills/mlspace-discover-launchable-resources/SKILL.md +0 -0
  39. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/plugin/skills/mlspace-launch-training-job/SKILL.md +0 -0
  40. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/plugin/skills/mlspace-list-running-jobs/SKILL.md +0 -0
  41. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/plugin/skills/mlspace-resume-notebook-and-set-autoshutdown/SKILL.md +0 -0
  42. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/plugin/skills/mlspace-stage-s3-to-nfs/SKILL.md +0 -0
  43. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/plugin/skills/mlspace-transfer-compute-node/SKILL.md +0 -0
  44. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/scripts/build_plugin.py +0 -0
  45. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/scripts/demo_multi_workspace.py +0 -0
  46. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/scripts/gen_body_schemas.py +0 -0
  47. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/scripts/gen_plugin.py +0 -0
  48. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/scripts/mcp_call.py +0 -0
  49. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/scripts/serve_stdio.sh +0 -0
  50. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/scripts/serve_stdio_dryrun.sh +0 -0
  51. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/src/mlspace_mcp/__main__.py +0 -0
  52. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/src/mlspace_mcp/_status.py +0 -0
  53. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/src/mlspace_mcp/auth.py +0 -0
  54. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/src/mlspace_mcp/bodyspec.py +0 -0
  55. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/src/mlspace_mcp/client.py +0 -0
  56. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/src/mlspace_mcp/config.py +0 -0
  57. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/src/mlspace_mcp/errors.py +0 -0
  58. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/src/mlspace_mcp/formatting.py +0 -0
  59. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/src/mlspace_mcp/job_hooks.py +0 -0
  60. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/src/mlspace_mcp/jsonc.py +0 -0
  61. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/src/mlspace_mcp/native_plugins.py +0 -0
  62. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/src/mlspace_mcp/overview.py +0 -0
  63. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/src/mlspace_mcp/registry.py +0 -0
  64. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/src/mlspace_mcp/resource_refs.py +0 -0
  65. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/src/mlspace_mcp/setup_ui.py +0 -0
  66. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/src/mlspace_mcp/spec/body_schemas.json +0 -0
  67. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/src/mlspace_mcp/spec/openapi.json +0 -0
  68. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/src/mlspace_mcp/tls.py +0 -0
  69. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/src/mlspace_mcp/tools/__init__.py +0 -0
  70. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/src/mlspace_mcp/tools/allocations.py +0 -0
  71. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/src/mlspace_mcp/tools/async_inference.py +0 -0
  72. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/src/mlspace_mcp/tools/build_image.py +0 -0
  73. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/src/mlspace_mcp/tools/dalle.py +0 -0
  74. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/src/mlspace_mcp/tools/data_transfer.py +0 -0
  75. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/src/mlspace_mcp/tools/docker_registry.py +0 -0
  76. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/src/mlspace_mcp/tools/inference.py +0 -0
  77. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/src/mlspace_mcp/tools/jobs.py +0 -0
  78. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/src/mlspace_mcp/tools/notebooks.py +0 -0
  79. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/src/mlspace_mcp/tools/queues.py +0 -0
  80. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/src/mlspace_mcp/tools/resources.py +0 -0
  81. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/src/mlspace_mcp/tools/tensorboards.py +0 -0
  82. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/src/mlspace_mcp/tools/workspaces.py +0 -0
  83. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/src/mlspace_mcp/workspace_context.py +0 -0
  84. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/tests/conftest.py +0 -0
  85. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/tests/specutil.py +0 -0
  86. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/tests/test_allocations.py +0 -0
  87. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/tests/test_artifact_sync.py +0 -0
  88. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/tests/test_async_inference.py +0 -0
  89. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/tests/test_auth.py +0 -0
  90. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/tests/test_autofill.py +0 -0
  91. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/tests/test_bodyspec.py +0 -0
  92. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/tests/test_build_image.py +0 -0
  93. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/tests/test_client.py +0 -0
  94. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/tests/test_configs_shrink.py +0 -0
  95. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/tests/test_credential_pointer.py +0 -0
  96. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/tests/test_dalle.py +0 -0
  97. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/tests/test_data_transfer.py +0 -0
  98. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/tests/test_docker_registry.py +0 -0
  99. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/tests/test_drift.py +0 -0
  100. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/tests/test_dry_run.py +0 -0
  101. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/tests/test_dry_run_fidelity.py +0 -0
  102. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/tests/test_env_file.py +0 -0
  103. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/tests/test_errors.py +0 -0
  104. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/tests/test_false_success.py +0 -0
  105. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/tests/test_formatting.py +0 -0
  106. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/tests/test_init_cli.py +0 -0
  107. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/tests/test_integration.py +0 -0
  108. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/tests/test_job_status.py +0 -0
  109. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/tests/test_jobs.py +0 -0
  110. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/tests/test_log_normalisation.py +0 -0
  111. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/tests/test_mcp_error_contract.py +0 -0
  112. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/tests/test_native_cli_integration.py +0 -0
  113. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/tests/test_native_plugins.py +0 -0
  114. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/tests/test_notebooks.py +0 -0
  115. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/tests/test_onboarding_tls.py +0 -0
  116. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/tests/test_overview.py +0 -0
  117. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/tests/test_plugin_bundle.py +0 -0
  118. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/tests/test_plugin_cli.py +0 -0
  119. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/tests/test_plugin_release.py +0 -0
  120. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/tests/test_preflight.py +0 -0
  121. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/tests/test_queue_scope.py +0 -0
  122. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/tests/test_queues.py +0 -0
  123. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/tests/test_registry.py +0 -0
  124. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/tests/test_resource_refs.py +0 -0
  125. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/tests/test_resources.py +0 -0
  126. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/tests/test_route_confusion.py +0 -0
  127. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/tests/test_setup_ui.py +0 -0
  128. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/tests/test_setup_verification.py +0 -0
  129. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/tests/test_skills.py +0 -0
  130. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/tests/test_tensorboards.py +0 -0
  131. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/tests/test_workspace_context.py +0 -0
  132. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/tests/test_workspaces.py +0 -0
  133. {mlspace_plugin-0.3.0 → mlspace_plugin-0.3.2}/tests/test_wrapper_hardening.py +0 -0
@@ -21,7 +21,7 @@ mlspace-plugin setup
21
21
  ```
22
22
 
23
23
  Если пакет уже установлен: `uv tool install --force .`.
24
- При установке из wheel: `uv tool install /путь/к/mlspace_plugin-0.3.0-py3-none-any.whl`.
24
+ При установке из wheel: `uv tool install /путь/к/mlspace_plugin-0.3.2-py3-none-any.whl`.
25
25
  Не используйте editable-установку из `/tmp` для постоянной работы.
26
26
  Далее команды показаны для PyPI. После установки исходников или wheel через
27
27
  `uv tool install` заменяйте `uvx mlspace-plugin@latest` на `mlspace-plugin`,
@@ -84,8 +84,27 @@ uvx mlspace-plugin@latest setup --force --client-path opencode=/полный/п
84
84
  `--force` повторяет настройку с сохранёнными ключами на прежнем endpoint.
85
85
  `--replace-credentials` запрашивает новые. При смене endpoint ключи запрашиваются
86
86
  заново, если источник явно не выбран. `--client` можно повторять.
87
- Существующие посторонние MCP, комментарии JSONC и изменённые пользователем скиллы
88
- не перезаписываются молча; конфликт показывает путь для разбора.
87
+ Посторонние MCP, комментарии JSONC и дополнительные пользовательские скиллы сохраняются.
88
+ Если в OpenCode уже есть другое подключение `mcp.mlspace` или отличающиеся скиллы
89
+ с именами из пакета, мастер **до ввода ключей** покажет все эти конфликты вместе.
90
+ Для подключения выберите `r`, чтобы сохранить резервную копию конфига и заменить
91
+ только `mcp.mlspace`. Для каждого конфликтующего скилла доступны:
92
+
93
+ - `k` — оставить свою версию активной и исключить её из обновлений плагина.
94
+ Этот выбор сохраняется для последующих запусков setup. Проверка сообщит,
95
+ что пользовательская версия не проверялась на совпадение с релизом.
96
+ - `r` — сохранить весь каталог скилла, включая вспомогательные файлы, в резервной
97
+ копии и установить пакетный `SKILL.md`.
98
+ - Enter — отменить настройку без замены конфликтующих файлов.
99
+
100
+ Резервные копии находятся в `backups/setup-*` рядом с `install-state.json`
101
+ в каталоге настроек MLSpace, **вне каталогов скиллов**; мастер печатает точный путь.
102
+ Они создаются перед заменой после проверки credentials. Если резервное копирование
103
+ не удалось, конфликтующие файлы не заменяются. Отличие от пакета само по себе
104
+ не означает ручное редактирование: это может быть файл от старой установки.
105
+ `--force` не разрешает замену конфликтов. В `--non-interactive` мастер перечисляет
106
+ их и останавливается; для выбора запустите ту же команду в своём терминале без этого флага.
107
+
89
108
  Уже установленный `mlspace@mlspace` из этого репозитория переиспользуется.
90
109
  Если имя marketplace занято другим источником или есть отдельная прямая регистрация
91
110
  MLSpace в Claude/Codex, мастер остановится до ввода ключей. Разберите указанное
@@ -126,7 +145,9 @@ uvx mlspace-plugin@latest setup --force
126
145
 
127
146
  Claude Code и Codex обновляют нативный плагин через свой менеджер; MCP и скиллы
128
147
  приходят из одного релиза. OpenCode обновляет команду MCP и свои скиллы.
129
- Изменённые вручную конфиги и скиллы OpenCode вызывают конфликт, а не перезапись.
148
+ Для отличающихся конфигов и скиллов OpenCode мастер предлагает выбор, описанный выше.
149
+ Дополнительные скиллы с отдельными именами, в том числе `mlspace-*`, не мешают
150
+ установке и проверке. Скиллы, оставленные через `k`, не обновляются плагином.
130
151
  Кеш нативного плагина принадлежит менеджеру клиента: свои правки храните отдельно.
131
152
 
132
153
  Для постоянной установки сначала `uv tool upgrade mlspace-plugin`, затем
@@ -30,8 +30,17 @@
30
30
  Попросите открыть новую сессию клиента (при необходимости перезапустить).
31
31
  После появления MCP выполните read-only запрос списка выбранных workspace.
32
32
  До этого не заявляйте, что клиент уже загрузил MCP и скиллы.
33
- 6. Если возник конфликт с существующим плагином или изменённым скиллом,
34
- разберите названный несекретный путь. Не удаляйте чужую интеграцию автоматически.
33
+ 6. При существующей настройке объясните выбор до отправки человека в терминал:
34
+ мастер покажет конфликты OpenCode одним списком перед вводом ключей.
35
+ `k` оставляет пользовательский скилл активным и исключает из обновлений;
36
+ `r` сохраняет резервную копию и заменяет конфликтующий файл; Enter отменяет.
37
+ Новые скиллы пользователя с отдельными именами сохраняются автоматически.
38
+ Не удаляйте каталоги `mlspace-*` и не считайте отличие от пакета доказательством
39
+ ручного изменения. Не заменяйте выбор человека флагом `--force` — он этого не делает.
40
+ В неинтерактивном режиме конфликты перечисляются без изменений: предложите человеку
41
+ запустить ту же команду без `--non-interactive`, сохранив endpoint, путь и выбор клиентов.
42
+ Для конфликта нативного плагина Claude/Codex разберите указанный несекретный путь;
43
+ не удаляйте чужую интеграцию автоматически.
35
44
  При частичном успехе сообщите, какие этапы завершены, и используйте команду `Retry`
36
45
  из ошибки: она сохраняет выбор незавершённых подключений. Для автоматизации
37
46
  добавьте прежние `--non-interactive` и `--workspace`.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: mlspace-plugin
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: Cloud.ru MLSpace plugin: MCP server, agent skills and setup
5
5
  Project-URL: Repository, https://github.com/noesskeetit/mlspace-plugin
6
6
  Project-URL: Issues, https://github.com/noesskeetit/mlspace-plugin/issues
@@ -7,11 +7,11 @@
7
7
  `uv lock` и `uv run python scripts/gen_plugin.py`. Обновите примеры имён wheel/ZIP
8
8
  в инструкциях, если версия присутствует в имени файла.
9
9
  2. Выполните проверки из `CONTRIBUTING.md` на Linux и macOS.
10
- 3. Соберите пакет (в командах ниже замените 0.3.0 на выпускаемую версию):
10
+ 3. Соберите пакет (в командах ниже замените 0.3.2 на выпускаемую версию):
11
11
 
12
12
  ```sh
13
- uv build --out-dir dist/release-0.3.0
14
- uv run python scripts/build_plugin.py --output dist/plugin-release-0.3.0
13
+ uv build --out-dir dist/release-0.3.2
14
+ uv run python scripts/build_plugin.py --output dist/plugin-release-0.3.2
15
15
  ```
16
16
 
17
17
  4. Просмотрите содержимое wheel, sdist и ZIP. В них не должно быть credentials,
@@ -23,11 +23,11 @@ uv run python scripts/build_plugin.py --output dist/plugin-release-0.3.0
23
23
  `UV_PUBLISH_TOKEN` средствами вашего хранилища секретов, не аргументом команды:
24
24
 
25
25
  ```sh
26
- uv publish --trusted-publishing never dist/release-0.3.0/mlspace_plugin-0.3.0-py3-none-any.whl dist/release-0.3.0/mlspace_plugin-0.3.0.tar.gz
26
+ uv publish --trusted-publishing never dist/release-0.3.2/mlspace_plugin-0.3.2-py3-none-any.whl dist/release-0.3.2/mlspace_plugin-0.3.2.tar.gz
27
27
  ```
28
28
 
29
29
  6. Проверьте версию и SHA256 обоих файлов через PyPI JSON API; проверьте установку
30
- `uvx --from mlspace-plugin==0.3.0 mlspace-plugin --version` из нового кеша.
30
+ `uvx --from mlspace-plugin==0.3.2 mlspace-plugin --version` из нового кеша.
31
31
  7. Только после проверки точной версии в PyPI обновите рабочую ветку GitHub
32
32
  marketplace и создайте тег релиза. До этого проверяйте изменения на отдельной
33
33
  release-ветке или локальном Git marketplace: main не должен ссылаться на
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mlspace",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Cloud.ru MLSpace: training jobs, inference, notebooks, data transfer, registry and compute catalog — an MCP server plus skills.",
5
5
  "author": {
6
6
  "name": "Cloud.ru"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mlspace",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Cloud.ru MLSpace: training jobs, inference, notebooks, data transfer, registry and compute catalog — an MCP server plus skills.",
5
5
  "author": {
6
6
  "name": "Cloud.ru"
@@ -5,7 +5,7 @@
5
5
  "command": "uvx",
6
6
  "args": [
7
7
  "--from",
8
- "mlspace-plugin==0.3.0",
8
+ "mlspace-plugin==0.3.2",
9
9
  "mlspace-plugin",
10
10
  "--transport",
11
11
  "stdio"
@@ -6,7 +6,7 @@
6
6
  "command": "uvx",
7
7
  "args": [
8
8
  "--from",
9
- "mlspace-plugin==0.3.0",
9
+ "mlspace-plugin==0.3.2",
10
10
  "mlspace-plugin",
11
11
  "--transport",
12
12
  "stdio"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "mlspace",
4
- "version": "0.3.0",
4
+ "version": "0.3.2",
5
5
  "description": "Cloud.ru MLSpace: training jobs, inference, notebooks, data transfer, registry and compute catalog — an MCP server plus skills.",
6
6
  "author": {
7
7
  "name": "Cloud.ru"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mlspace-plugin"
3
- version = "0.3.0"
3
+ version = "0.3.2"
4
4
  description = "Cloud.ru MLSpace plugin: MCP server, agent skills and setup"
5
5
  keywords = ["mlspace", "mcp", "agent-skills", "cloudru"]
6
6
  classifiers = [
@@ -1,3 +1,4 @@
1
1
  """mlspace_mcp — MCP server for the Cloud.ru MLSpace public API v2."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.3.2"
4
+ USER_AGENT = f"mlspace-plugin/{__version__}"
@@ -18,6 +18,7 @@ from .config import credential_pointer_file
18
18
  from .jsonc import loads as load_jsonc
19
19
  from .jsonc import set_member as set_jsonc_member
20
20
  from .native_plugins import inspect_native, install_native
21
+ from .setup_conflicts import Conflict, Resolutions, backup_replacements, conflict_message
21
22
 
22
23
  CLIENTS = {'claude-code': 'claude', 'codex': 'codex', 'opencode': 'opencode'}
23
24
 
@@ -142,13 +143,36 @@ def managed_clients(overrides: dict[str, str] | None = None) -> dict[str, str]:
142
143
  return result
143
144
 
144
145
 
145
- def prepare_clients(clients: dict[str, str], env_file: Path, command: list[str]) -> dict[str, Any]:
146
+ def prepare_clients(clients: dict[str, str], env_file: Path, command: list[str], *,
147
+ collect_conflicts: bool = False, resolutions: Resolutions | None = None) -> dict[str, Any]:
146
148
  """Check client capabilities and conflicts before setup saves credentials."""
147
149
  state_path = install_state_file()
148
150
  state = json.loads(state_path.read_text()) if state_path.exists() else {}
149
151
  clients = managed_clients(clients) | clients
150
152
  sources = _skill_sources()
151
153
  writes: dict[Path, str] = {}
154
+ conflicts: list[Conflict] = []
155
+ replacements: list[Conflict] = []
156
+ overrides = set(state.get('skill_overrides', []))
157
+ decisions = resolutions or {}
158
+ examined: set[str] = set()
159
+
160
+ def resolve(path: Path, kind: str) -> str:
161
+ item = Conflict.inspect(path, kind)
162
+ decision = decisions.get(str(path))
163
+ if decision is None:
164
+ conflicts.append(item)
165
+ return ''
166
+ action, approved = decision
167
+ examined.add(str(path))
168
+ if item.fingerprint != approved:
169
+ raise ValueError(f'File changed after your choice; run setup again: {path}')
170
+ if action == 'replace':
171
+ replacements.append(item)
172
+ elif action != 'keep' or kind != 'skill':
173
+ raise ValueError(f'Invalid conflict choice for {path}')
174
+ return action
175
+
152
176
  registrations: list[tuple[str, str, Path, dict[str, Any] | None, Any]] = []
153
177
  for client, executable in clients.items():
154
178
  if client not in CLIENTS:
@@ -179,27 +203,47 @@ def prepare_clients(clients: dict[str, str], env_file: Path, command: list[str])
179
203
  comparable = dict(current)
180
204
  comparable.pop('type', None) if client != 'opencode' else None
181
205
  if comparable != entry and _hash(json.dumps(current, sort_keys=True)) != previous:
182
- raise ValueError(f'Existing mlspace entry differs in {config}; it was not overwritten.')
206
+ resolve(config, 'connection')
183
207
  registrations.append((client, executable, config, entry, current))
184
208
  skills_root = Path(os.environ.get('XDG_CONFIG_HOME', str(Path.home() / '.config'))) / 'opencode/skills'
185
209
  for name, content in sources.items():
186
210
  target = skills_root / name / 'SKILL.md'
211
+ # A kept override belongs to the user, including their subsequent edits.
212
+ if str(target) in overrides and target.is_file():
213
+ state.get('skills', {}).pop(str(target), None)
214
+ continue
215
+ overrides.discard(str(target))
187
216
  if target.is_symlink() or target.parent.is_symlink():
188
217
  if target.is_file() and target.read_text() == content:
189
218
  continue
190
219
  raise ValueError(f'Existing skill symlink differs: {target}')
191
220
  if target.exists() and target.read_text() != content:
192
221
  if state.get('skills', {}).get(str(target)) != _hash(target.read_text()):
193
- raise ValueError(f'Existing skill has local changes: {target}')
222
+ if resolve(target, 'skill') == 'keep':
223
+ overrides.add(str(target))
224
+ state.get('skills', {}).pop(str(target), None)
225
+ continue
194
226
  writes[target] = content
227
+ if set(decisions) != examined:
228
+ raise ValueError('Conflicting files changed after your choice; run setup again.')
229
+ if conflicts and not collect_conflicts:
230
+ raise ValueError(conflict_message(conflicts))
231
+ state['skill_overrides'] = sorted(overrides)
195
232
  return {'state_path': state_path, 'state': state, 'writes': writes,
196
- 'registrations': registrations}
233
+ 'registrations': registrations, 'conflicts': conflicts, 'replacements': replacements}
197
234
 
198
235
 
199
- def install_clients(clients: dict[str, str], env_file: Path, command: list[str]) -> list[str]:
236
+ def install_clients(clients: dict[str, str], env_file: Path, command: list[str], *,
237
+ resolutions: Resolutions | None = None) -> list[str]:
200
238
  # Revalidate after human input: files may have changed since the preflight.
201
- plan = prepare_clients(clients, env_file, command)
239
+ plan = prepare_clients(clients, env_file, command, resolutions=resolutions)
202
240
  state, state_path = plan['state'], plan['state_path']
241
+ try:
242
+ backup_replacements(plan['replacements'], state_path.parent / 'backups')
243
+ except OSError as exc:
244
+ raise ValueError(f'Could not back up conflicting files in {state_path.parent / "backups"}. '
245
+ 'Originals were not replaced. Check permissions and free space, then retry setup.') from exc
246
+ replacements = {item.path: item for item in plan['replacements']}
203
247
  retry = ['uvx', 'mlspace-plugin@latest', 'setup', '--force']
204
248
  for client, executable, *_ in plan['registrations']:
205
249
  retry.extend(['--client-path', f'{client}={executable}'])
@@ -211,6 +255,8 @@ def install_clients(clients: dict[str, str], env_file: Path, command: list[str])
211
255
  text = config.read_text() if config.exists() else '{}\n'
212
256
  updated = set_jsonc_member(text, ['mcp', 'mlspace'], entry)
213
257
  if updated != text:
258
+ if config in replacements:
259
+ replacements[config].verify_unchanged()
214
260
  atomic_write(config, updated)
215
261
  saved = _read_config(config).get('mcp', {}).get('mlspace')
216
262
  if saved != entry:
@@ -236,11 +282,14 @@ def install_clients(clients: dict[str, str], env_file: Path, command: list[str])
236
282
  try:
237
283
  for target, content in plan['writes'].items():
238
284
  if not target.exists() or target.read_text() != content:
285
+ if target in replacements:
286
+ replacements[target].verify_unchanged()
239
287
  atomic_write(target, content, 0o644)
240
288
  state.setdefault('skills', {})[str(target)] = _hash(content)
241
289
  atomic_write(state_path, json.dumps(state, indent=2))
242
290
  except (OSError, ValueError) as exc:
291
+ reason = str(exc) if type(exc) is ValueError else 'Check permissions in the skills directory.'
243
292
  raise ValueError(f'MCP registered: {", ".join(installed)}. Skill installation incomplete; '
244
- 'check permissions in the skills directory and rerun setup --force. '
293
+ f'{reason} Rerun setup --force. '
245
294
  'Verified credentials remain saved.') from exc
246
295
  return installed
@@ -15,6 +15,7 @@ from urllib.parse import urlsplit
15
15
 
16
16
  import httpx
17
17
 
18
+ from . import USER_AGENT
18
19
  from .client_setup import (
19
20
  CLIENTS,
20
21
  atomic_write,
@@ -33,6 +34,7 @@ from .config import (
33
34
  read_dotenv_file,
34
35
  user_config_file,
35
36
  )
37
+ from .setup_conflicts import Resolutions, choose_resolutions
36
38
  from .setup_ui import choose_workspaces, display, masked_prompt
37
39
  from .setup_verification import verify_client
38
40
  from .tls import safe_error as _safe_error
@@ -64,7 +66,9 @@ def _fetch_workspaces(key_id: str, key_secret: str, *, base_url: str,
64
66
  from .client import MLSpaceClient
65
67
 
66
68
  async def fetch() -> Any:
67
- async with httpx.AsyncClient(timeout=30, verify=tls_context(ca_file)) as http:
69
+ async with httpx.AsyncClient(
70
+ timeout=30, verify=tls_context(ca_file), headers={"User-Agent": USER_AGENT},
71
+ ) as http:
68
72
  tokens = TokenManager(http, base_url=base_url, client_id=key_id, client_secret=key_secret)
69
73
  client = MLSpaceClient(http, tokens, base_url=base_url, api_key='', workspace_id='')
70
74
  return await client.request('GET', '/public/v2/workspaces/v3/')
@@ -147,7 +151,10 @@ def _verify(values: dict[str, str], *, base_url: str) -> str | None:
147
151
  workspaces=json.loads(values.get("WORKSPACES", "[]")),
148
152
  namespace="",
149
153
  )
150
- async with httpx.AsyncClient(timeout=30, verify=tls_context(values.get("CA_FILE", ""))) as http:
154
+ async with httpx.AsyncClient(
155
+ timeout=30, verify=tls_context(values.get("CA_FILE", "")),
156
+ headers={"User-Agent": USER_AGENT},
157
+ ) as http:
151
158
  tokens = TokenManager(
152
159
  http, base_url=base_url, client_id=values["CLIENT_ID"],
153
160
  client_secret=values["CLIENT_SECRET"],
@@ -251,7 +258,7 @@ def run_init(path: Path | None = None, *, force: bool = False, verify: bool = Tr
251
258
  except (ValueError, OSError, httpx.HTTPError) as exc:
252
259
  # Only our own ValueErrors carry displayable text. Third-party errors may echo keys.
253
260
  message = str(exc) if type(exc) is ValueError else _safe_error(exc)
254
- print('Setup failed: ' + display(message), file=sys.stderr)
261
+ print('Setup failed: ' + '\n'.join(display(line) for line in message.splitlines()), file=sys.stderr)
255
262
  return 1
256
263
 
257
264
 
@@ -284,8 +291,10 @@ def _run_init(path: Path | None, **options: Any) -> int:
284
291
  selected_clients = {} if options['config_only'] or listing else _clients(
285
292
  options['clients'], options['client_paths'], non_interactive)
286
293
  command = server_command() if selected_clients and 'opencode' in selected_clients | managed_clients(selected_clients) else []
294
+ resolutions: Resolutions = {}
287
295
  if selected_clients:
288
- plan = prepare_clients(selected_clients, target, command)
296
+ plan = prepare_clients(selected_clients, target, command, collect_conflicts=True)
297
+ resolutions = choose_resolutions(plan.get('conflicts', []), non_interactive=non_interactive)
289
298
  print('MCP and skills will be kept together for: ' +
290
299
  ', '.join(row[0] for row in plan['registrations']), file=sys.stderr)
291
300
  if not listing:
@@ -385,11 +394,11 @@ def _run_init(path: Path | None, **options: Any) -> int:
385
394
  if selected_clients:
386
395
  print('[4/4] Connecting MCP and installing skills…', file=sys.stderr)
387
396
  try:
388
- install_clients(selected_clients, target, command)
397
+ install_clients(selected_clients, target, command, resolutions=resolutions)
389
398
  for client, executable, *_ in plan['registrations']:
390
399
  print(f' {client}: installed; checking skills and MCP process…', file=sys.stderr)
391
400
  verify_client(client, executable, target, command)
392
- print(f' {client}: skills and MCP process verified.', file=sys.stderr)
401
+ print(f' {client}: packaged skills checked (kept user overrides excluded); MCP process verified.', file=sys.stderr)
393
402
  except (OSError, ValueError) as exc:
394
403
  reason = str(exc) if type(exc) is ValueError else _safe_error(exc)
395
404
  raise ValueError('Credentials saved; client setup is incomplete. ' + reason) from exc
@@ -14,6 +14,7 @@ from mcp.server.fastmcp import FastMCP
14
14
  from mcp.server.transport_security import TransportSecuritySettings
15
15
  from mcp.types import ToolAnnotations
16
16
 
17
+ from . import USER_AGENT
17
18
  from .auth import TokenManager
18
19
  from .config import Settings
19
20
  from .overview import register_overviews
@@ -149,7 +150,10 @@ def build_server(settings: Settings) -> FastMCP:
149
150
 
150
151
  @asynccontextmanager
151
152
  async def lifespan(_server: FastMCP):
152
- async with httpx.AsyncClient(timeout=settings.timeout, verify=tls_context(settings.ca_file)) as http:
153
+ async with httpx.AsyncClient(
154
+ timeout=settings.timeout, verify=tls_context(settings.ca_file),
155
+ headers={"User-Agent": USER_AGENT},
156
+ ) as http:
153
157
  tokens = TokenManager(
154
158
  http,
155
159
  settings.base_url,
@@ -0,0 +1,105 @@
1
+ """Explicit choices and recoverable backups for existing OpenCode files."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ import shutil
7
+ import sys
8
+ import tempfile
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ from .setup_ui import display
13
+
14
+ Resolutions = dict[str, tuple[str, str]]
15
+
16
+
17
+ def fingerprint(path: Path) -> str:
18
+ """Include supporting skill files; never follow symlinks while inspecting them."""
19
+ rows = []
20
+ paths = [path, *sorted(path.rglob('*'))] if path.is_dir() and not path.is_symlink() else [path]
21
+ for item in paths:
22
+ name = str(item.relative_to(path))
23
+ if item.is_symlink():
24
+ value = 'link:' + str(item.readlink())
25
+ elif item.is_file():
26
+ value = hashlib.sha256(item.read_bytes()).hexdigest()
27
+ elif item.is_dir():
28
+ value = 'directory'
29
+ else:
30
+ raise ValueError(f'Unsupported or missing setup file: {item}')
31
+ rows.append((name, value))
32
+ return hashlib.sha256(json.dumps(rows).encode()).hexdigest()
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Conflict:
37
+ path: Path
38
+ kind: str
39
+ fingerprint: str
40
+
41
+ @property
42
+ def source(self) -> Path:
43
+ return self.path.parent if self.kind == 'skill' else self.path
44
+
45
+ def verify_unchanged(self) -> None:
46
+ if fingerprint(self.source) != self.fingerprint:
47
+ raise ValueError(f'File changed after your choice; run setup again: {self.path}')
48
+
49
+ @classmethod
50
+ def inspect(cls, path: Path, kind: str) -> Conflict:
51
+ source = path.parent if kind == 'skill' else path
52
+ return cls(path, kind, fingerprint(source))
53
+
54
+
55
+ def conflict_message(conflicts: list[Conflict]) -> str:
56
+ lines = ['Existing MLSpace files differ; nothing was overwritten:']
57
+ lines.extend(f' {item.kind}: {item.path}' for item in conflicts)
58
+ lines.append('Run setup in your terminal to keep custom skills or back up and replace conflicting files. '
59
+ '--force does not approve replacing them.')
60
+ return '\n'.join(lines)
61
+
62
+
63
+ def choose_resolutions(conflicts: list[Conflict], *, non_interactive: bool) -> Resolutions:
64
+ if not conflicts:
65
+ return {}
66
+ if non_interactive:
67
+ raise ValueError(conflict_message(conflicts))
68
+ for line in conflict_message(conflicts).splitlines():
69
+ print(display(line), file=sys.stderr)
70
+ print('Replacement saves a backup outside the skills directory. '
71
+ 'Other connections and additional user skills are preserved.', file=sys.stderr)
72
+ choices = {}
73
+ for item in conflicts:
74
+ print(display(str(item.path)), file=sys.stderr)
75
+ prompt = ('[k] Keep my skill (exclude from plugin updates), [r] Back up and replace, '
76
+ '[Enter] Cancel: ' if item.kind == 'skill' else
77
+ '[r] Back up config and replace only mcp.mlspace, [Enter] Cancel: ')
78
+ while True:
79
+ choice = input(prompt).strip().lower()
80
+ if not choice:
81
+ raise ValueError('Setup cancelled; no conflicting files were changed.')
82
+ if choice == 'r' or (choice == 'k' and item.kind == 'skill'):
83
+ choices[str(item.path)] = ('keep' if choice == 'k' else 'replace', item.fingerprint)
84
+ break
85
+ print('Choose one of the displayed options or press Enter to cancel.', file=sys.stderr)
86
+ return choices
87
+
88
+
89
+ def backup_replacements(conflicts: list[Conflict], root: Path) -> Path | None:
90
+ if not conflicts:
91
+ return None
92
+ root.mkdir(parents=True, exist_ok=True, mode=0o700)
93
+ backup = Path(tempfile.mkdtemp(prefix='setup-', dir=root))
94
+ print(f'Backup directory: {display(str(backup))}', file=sys.stderr)
95
+ for item in conflicts:
96
+ item.verify_unchanged()
97
+ destination = backup / 'skills' / item.path.parent.name if item.kind == 'skill' else backup / item.path.name
98
+ if item.kind == 'skill':
99
+ shutil.copytree(item.source, destination, symlinks=True)
100
+ else:
101
+ shutil.copy2(item.source, destination)
102
+ destination.chmod(0o600)
103
+ if fingerprint(destination) != item.fingerprint or fingerprint(item.source) != item.fingerprint:
104
+ raise ValueError(f'File changed during backup; originals were not replaced: {item.path}')
105
+ return backup
@@ -5,6 +5,7 @@ import asyncio
5
5
  import json
6
6
  import logging
7
7
  import os
8
+ import sys
8
9
  from pathlib import Path
9
10
  from typing import Any
10
11
 
@@ -13,10 +14,11 @@ from mcp import ClientSession, StdioServerParameters
13
14
  from mcp.client.stdio import stdio_client
14
15
 
15
16
  from . import __version__
16
- from .client_setup import _config, _read_config, _skill_sources
17
+ from .client_setup import _config, _read_config, _skill_sources, install_state_file
17
18
  from .config import Settings, read_dotenv_file
18
19
  from .native_plugins import inspect_native
19
20
  from .server import build_server
21
+ from .setup_ui import display
20
22
  from .workspace_context import environment_id, normalized_endpoint, selected_workspaces
21
23
 
22
24
 
@@ -73,11 +75,15 @@ def verify_runtime(command: list[str], credentials: Path, *, direct: bool = Fals
73
75
 
74
76
  def verify_client(client: str, executable: str, credentials: Path, command: list[str]) -> None:
75
77
  sources = _skill_sources()
78
+ overrides: set[str] = set()
76
79
  if client == 'opencode':
77
80
  config = _read_config(_config(client)).get('mcp', {}).get('mlspace', {})
78
81
  if config.get('command') != command or config.get('enabled') is not True:
79
82
  raise ValueError('opencode: installed MCP configuration differs; retry setup.')
80
83
  root = Path(os.environ.get('XDG_CONFIG_HOME', str(Path.home() / '.config'))) / 'opencode/skills'
84
+ state_path = install_state_file()
85
+ if state_path.exists():
86
+ overrides = set(json.loads(state_path.read_text()).get('skill_overrides', []))
81
87
  cwd = None
82
88
  else:
83
89
  status = inspect_native(client, executable)
@@ -89,7 +95,12 @@ def verify_client(client: str, executable: str, credentials: Path, command: list
89
95
  command = ['uvx', '--from', f'mlspace-plugin=={__version__}', 'mlspace-plugin', '--transport', 'stdio']
90
96
  if config != {'type': 'stdio', 'command': command[0], 'args': command[1:]}:
91
97
  raise ValueError(f'{client}: installed runtime command differs from this release.')
92
- installed = {p.parent.name: p.read_text() for p in root.glob('mlspace-*/SKILL.md')}
93
- if installed != sources:
94
- raise ValueError(f'{client}: installed skills differ from this release; retry setup.')
98
+ for name, content in sources.items():
99
+ path = root / name / 'SKILL.md'
100
+ if not path.is_file():
101
+ raise ValueError(f'{client}: installed skills missing: {path}; retry setup.')
102
+ if client == 'opencode' and str(path) in overrides:
103
+ print(f' User skill preserved (not verified against release): {display(str(path))}', file=sys.stderr)
104
+ elif path.read_text() != content:
105
+ raise ValueError(f'{client}: installed skills differ from this release: {path}; retry setup.')
95
106
  verify_runtime(command, credentials, direct=client == 'opencode', cwd=cwd)
@@ -239,7 +239,7 @@ def test_modified_previously_connected_client_blocks_shared_update(tmp_path, mon
239
239
  client_setup.install_clients({'opencode': '/usr/bin/true'}, tmp_path / '.env', ['runtime-v1'])
240
240
  config = tmp_path / '.config/opencode/opencode.json'
241
241
  config.write_text(config.read_text().replace('runtime-v1', 'user-edited'))
242
- with pytest.raises(ValueError, match='was not overwritten'):
242
+ with pytest.raises(ValueError, match='nothing was overwritten'):
243
243
  client_setup.prepare_clients({'codex': '/usr/bin/true'}, tmp_path / '.env', ['runtime-v2'])
244
244
 
245
245
 
@@ -10,6 +10,62 @@ from mlspace_mcp.init_cli import run_init
10
10
  BASE = 'https://setup.example'
11
11
 
12
12
 
13
+ def conflicting_opencode(tmp_path, monkeypatch):
14
+ import sys
15
+
16
+ from mlspace_mcp import init_cli
17
+ monkeypatch.setattr(sys.stdin, 'isatty', lambda: True)
18
+ monkeypatch.setattr(init_cli, 'detect_clients', lambda: {'opencode': '/usr/bin/true'})
19
+ monkeypatch.setattr(init_cli, 'server_command', lambda: [sys.executable, '-m', 'mlspace_mcp', '--transport', 'stdio'])
20
+ root = tmp_path / '.config/opencode'
21
+ skill = root / 'skills/mlspace-list-running-jobs/SKILL.md'
22
+ skill.parent.mkdir(parents=True)
23
+ skill.write_text('custom workflow')
24
+ config = root / 'opencode.jsonc'
25
+ config.write_text('{"mcp":{"mlspace":{"command":["old-wrapper"]}}}')
26
+ return skill, config
27
+
28
+
29
+ @respx.mock
30
+ def test_interactive_setup_resolves_wrapper_and_keeps_custom_skill(tmp_path, monkeypatch, capsys):
31
+ routes()
32
+ skill, config = conflicting_opencode(tmp_path, monkeypatch)
33
+ answers = iter(['r', 'k'])
34
+ monkeypatch.setattr('builtins.input', lambda *a: next(answers))
35
+ target = tmp_path / 'new.env'
36
+ assert run_init(target, base_url=BASE, from_env=True, clients=['opencode'], workspace_ids=['ws2']) == 0
37
+ assert skill.read_text() == 'custom workflow'
38
+ assert 'old-wrapper' not in config.read_text()
39
+ assert read_dotenv_file(target)['MLSPACE_WORKSPACE_ID'] == 'ws2'
40
+ output = capsys.readouterr().err
41
+ assert str(config) in output and str(skill) in output
42
+ assert output.index(str(skill)) < output.index('[1/4]')
43
+ assert 'not verified against release' in output
44
+
45
+
46
+ def test_cancel_conflicts_preserves_configuration_and_credentials(tmp_path, monkeypatch, capsys):
47
+ skill, config = conflicting_opencode(tmp_path, monkeypatch)
48
+ before = config.read_bytes()
49
+ monkeypatch.setattr('builtins.input', lambda *a: '')
50
+ target = tmp_path / 'new.env'
51
+ assert run_init(target, base_url=BASE, from_env=True, clients=['opencode'], workspace_ids=['ws2']) == 1
52
+ assert not target.exists()
53
+ assert config.read_bytes() == before and skill.read_text() == 'custom workflow'
54
+ assert 'cancelled' in capsys.readouterr().err.lower()
55
+
56
+
57
+ def test_noninteractive_conflicts_list_all_paths_without_prompt_even_with_force(tmp_path, monkeypatch, capsys):
58
+ skill, config = conflicting_opencode(tmp_path, monkeypatch)
59
+ monkeypatch.setattr('builtins.input', lambda *a: pytest.fail('unexpected prompt'))
60
+ target = tmp_path / 'existing.env'
61
+ target.write_text('MLSPACE_CLIENT_SECRET=old-secret\n')
62
+ assert run_init(target, base_url=BASE, from_env=True, clients=['opencode'], workspace_ids=['ws2'],
63
+ non_interactive=True, force=True) == 1
64
+ output = capsys.readouterr().err
65
+ assert str(skill) in output and str(config) in output
66
+ assert target.read_text() == 'MLSPACE_CLIENT_SECRET=old-secret\n'
67
+
68
+
13
69
  @pytest.fixture(autouse=True)
14
70
  def isolate(monkeypatch, tmp_path):
15
71
  import os
@@ -163,7 +219,7 @@ def test_client_install_failure_keeps_verified_credentials_for_retry(tmp_path, m
163
219
  target.write_text(original)
164
220
  monkeypatch.setattr('mlspace_mcp.init_cli._clients', lambda *a: {'opencode': '/bin/true'})
165
221
  monkeypatch.setattr('mlspace_mcp.init_cli.server_command', lambda: ['/bin/echo'])
166
- monkeypatch.setattr('mlspace_mcp.init_cli.prepare_clients', lambda *a: {'registrations': [('opencode',)]})
222
+ monkeypatch.setattr('mlspace_mcp.init_cli.prepare_clients', lambda *a, **kw: {'registrations': [('opencode',)]})
167
223
  def fail(*a, **kw):
168
224
  raise ValueError('Client installation failed')
169
225
  monkeypatch.setattr('mlspace_mcp.init_cli.install_clients', fail)