morphcloud 0.1.111__tar.gz → 0.1.113__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 (166) hide show
  1. {morphcloud-0.1.111 → morphcloud-0.1.113}/.gitignore +8 -0
  2. {morphcloud-0.1.111 → morphcloud-0.1.113}/PKG-INFO +99 -4
  3. {morphcloud-0.1.111 → morphcloud-0.1.113}/README.md +98 -3
  4. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/api.py +233 -3
  5. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/cli.py +270 -8
  6. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/config.py +12 -0
  7. morphcloud-0.1.113/morphcloud/plugins/__init__.py +1 -0
  8. morphcloud-0.1.113/morphcloud/plugins/cli.py +1164 -0
  9. morphcloud-0.1.113/morphcloud/plugins/installer.py +569 -0
  10. morphcloud-0.1.113/morphcloud/simple_index/__init__.py +6 -0
  11. morphcloud-0.1.113/morphcloud/simple_index/client.py +415 -0
  12. {morphcloud-0.1.111 → morphcloud-0.1.113}/pyproject.toml +1 -1
  13. morphcloud-0.1.113/tests/integration/test_managed_ssh_keys.py +146 -0
  14. morphcloud-0.1.113/tests/unit/test_cli_managed_ssh_keys.py +249 -0
  15. morphcloud-0.1.113/tests/unit/test_cli_plugins.py +1764 -0
  16. morphcloud-0.1.113/tests/unit/test_managed_ssh_keys_api.py +233 -0
  17. morphcloud-0.1.113/tests/unit/test_plugin_installer.py +431 -0
  18. {morphcloud-0.1.111 → morphcloud-0.1.113}/tests/unit/test_profiles.py +8 -0
  19. morphcloud-0.1.113/tests/unit/test_simple_index_client.py +392 -0
  20. {morphcloud-0.1.111 → morphcloud-0.1.113}/.github/workflows/publish.yaml +0 -0
  21. {morphcloud-0.1.111 → morphcloud-0.1.113}/LICENSE +0 -0
  22. {morphcloud-0.1.111 → morphcloud-0.1.113}/Makefile +0 -0
  23. {morphcloud-0.1.111 → morphcloud-0.1.113}/conf.py +0 -0
  24. {morphcloud-0.1.111 → morphcloud-0.1.113}/examples/browser_example.py +0 -0
  25. {morphcloud-0.1.111 → morphcloud-0.1.113}/examples/devbox_example.py +0 -0
  26. {morphcloud-0.1.111 → morphcloud-0.1.113}/index.rst +0 -0
  27. {morphcloud-0.1.111 → morphcloud-0.1.113}/make.bat +0 -0
  28. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/__init__.py +0 -0
  29. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/_asyncify.py +0 -0
  30. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/_bash_interpreter.py +0 -0
  31. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/_http_transport.py +0 -0
  32. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/_llm.py +0 -0
  33. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/_scramble.py +0 -0
  34. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/_ssh.py +0 -0
  35. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/_utils.py +0 -0
  36. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/cli_helpers.py +0 -0
  37. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/__init__.py +0 -0
  38. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/cli.py +0 -0
  39. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/client.py +0 -0
  40. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/config.py +0 -0
  41. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/__init__.py +0 -0
  42. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/admin/__init__.py +0 -0
  43. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/admin/client.py +0 -0
  44. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/admin/raw_client.py +0 -0
  45. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/aliases/__init__.py +0 -0
  46. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/aliases/client.py +0 -0
  47. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/aliases/raw_client.py +0 -0
  48. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/client.py +0 -0
  49. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/core/__init__.py +0 -0
  50. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/core/api_error.py +0 -0
  51. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/core/client_wrapper.py +0 -0
  52. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/core/datetime_utils.py +0 -0
  53. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/core/file.py +0 -0
  54. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/core/force_multipart.py +0 -0
  55. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/core/http_client.py +0 -0
  56. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/core/http_response.py +0 -0
  57. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/core/jsonable_encoder.py +0 -0
  58. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/core/pydantic_utilities.py +0 -0
  59. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/core/query_encoder.py +0 -0
  60. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/core/remove_none_from_dict.py +0 -0
  61. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/core/request_options.py +0 -0
  62. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/core/serialization.py +0 -0
  63. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/devboxes_actions/__init__.py +0 -0
  64. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/devboxes_actions/client.py +0 -0
  65. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/devboxes_actions/raw_client.py +0 -0
  66. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/devboxes_core/__init__.py +0 -0
  67. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/devboxes_core/client.py +0 -0
  68. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/devboxes_core/raw_client.py +0 -0
  69. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/devboxes_lifecycle/__init__.py +0 -0
  70. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/devboxes_lifecycle/client.py +0 -0
  71. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/devboxes_lifecycle/raw_client.py +0 -0
  72. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/errors/__init__.py +0 -0
  73. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/errors/unprocessable_entity_error.py +0 -0
  74. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/templates/__init__.py +0 -0
  75. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/templates/client.py +0 -0
  76. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/templates/raw_client.py +0 -0
  77. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/tmux/__init__.py +0 -0
  78. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/tmux/client.py +0 -0
  79. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/tmux/raw_client.py +0 -0
  80. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/__init__.py +0 -0
  81. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/alias_response.py +0 -0
  82. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/branch_response.py +0 -0
  83. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/delete_response.py +0 -0
  84. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/devbox_detail_list_response.py +0 -0
  85. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/devbox_list_response.py +0 -0
  86. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/devbox_refs.py +0 -0
  87. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/devbox_response.py +0 -0
  88. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/devbox_spec.py +0 -0
  89. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/devbox_ssh_credentials_response.py +0 -0
  90. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/devbox_summary_response.py +0 -0
  91. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/http_service.py +0 -0
  92. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/http_service_auth_mode.py +0 -0
  93. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/http_validation_error.py +0 -0
  94. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/networking.py +0 -0
  95. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/secret_value_response.py +0 -0
  96. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/snapshot_response.py +0 -0
  97. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/success_response.py +0 -0
  98. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/template_cache_request.py +0 -0
  99. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/template_cache_start_response.py +0 -0
  100. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/template_detail.py +0 -0
  101. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/template_list_response.py +0 -0
  102. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/template_share_response.py +0 -0
  103. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/template_status.py +0 -0
  104. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/template_step_model.py +0 -0
  105. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/template_step_status.py +0 -0
  106. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/template_summary.py +0 -0
  107. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/timeline_event.py +0 -0
  108. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/tmux_debug_info_response.py +0 -0
  109. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/tmux_install_response.py +0 -0
  110. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/tmux_new_session_response.py +0 -0
  111. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/tmux_send_keys_response.py +0 -0
  112. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/tmux_session.py +0 -0
  113. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/tmux_sessions_list.py +0 -0
  114. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/tmux_snapshot_response.py +0 -0
  115. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/tmux_target.py +0 -0
  116. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/ttl.py +0 -0
  117. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/upload_response.py +0 -0
  118. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/validation_error.py +0 -0
  119. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/validation_error_loc_item.py +0 -0
  120. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/wake_on.py +0 -0
  121. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/uploads/__init__.py +0 -0
  122. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/uploads/client.py +0 -0
  123. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/uploads/raw_client.py +0 -0
  124. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/user_secrets/__init__.py +0 -0
  125. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/user_secrets/client.py +0 -0
  126. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/gen/user_secrets/raw_client.py +0 -0
  127. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/openapi.json +0 -0
  128. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/template_local_runner.py +0 -0
  129. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/template_runner.py +0 -0
  130. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/devbox/terminals.py +0 -0
  131. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/experimental/__init__.py +0 -0
  132. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/experimental/browser.py +0 -0
  133. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/py.typed +0 -0
  134. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/volumes/__init__.py +0 -0
  135. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/volumes/cli.py +0 -0
  136. {morphcloud-0.1.111 → morphcloud-0.1.113}/morphcloud/volumes/client.py +0 -0
  137. {morphcloud-0.1.111 → morphcloud-0.1.113}/requirements.txt +0 -0
  138. {morphcloud-0.1.111 → morphcloud-0.1.113}/scripts/abuild_smoke.py +0 -0
  139. {morphcloud-0.1.111 → morphcloud-0.1.113}/scripts/increment_version.py +0 -0
  140. {morphcloud-0.1.111 → morphcloud-0.1.113}/tests/integration/__init__.py +0 -0
  141. {morphcloud-0.1.111 → morphcloud-0.1.113}/tests/integration/conftest.py +0 -0
  142. {morphcloud-0.1.111 → morphcloud-0.1.113}/tests/integration/pytest.ini +0 -0
  143. {morphcloud-0.1.111 → morphcloud-0.1.113}/tests/integration/test_as_container.py +0 -0
  144. {morphcloud-0.1.111 → morphcloud-0.1.113}/tests/integration/test_branching.py +0 -0
  145. {morphcloud-0.1.111 → morphcloud-0.1.113}/tests/integration/test_command_execution.py +0 -0
  146. {morphcloud-0.1.111 → morphcloud-0.1.113}/tests/integration/test_file_operations.py +0 -0
  147. {morphcloud-0.1.111 → morphcloud-0.1.113}/tests/integration/test_from_tag_multiple.py +0 -0
  148. {morphcloud-0.1.111 → morphcloud-0.1.113}/tests/integration/test_http_service.py +0 -0
  149. {morphcloud-0.1.111 → morphcloud-0.1.113}/tests/integration/test_metadata.py +0 -0
  150. {morphcloud-0.1.111 → morphcloud-0.1.113}/tests/integration/test_simple.py +0 -0
  151. {morphcloud-0.1.111 → morphcloud-0.1.113}/tests/integration/test_snapshot_operations.py +0 -0
  152. {morphcloud-0.1.111 → morphcloud-0.1.113}/tests/integration/test_ssh_key_rotation.py +0 -0
  153. {morphcloud-0.1.111 → morphcloud-0.1.113}/tests/integration/test_ttl.py +0 -0
  154. {morphcloud-0.1.111 → morphcloud-0.1.113}/tests/integration/test_user.py +0 -0
  155. {morphcloud-0.1.111 → morphcloud-0.1.113}/tests/unit/test_cli_devbox.py +0 -0
  156. {morphcloud-0.1.111 → morphcloud-0.1.113}/tests/unit/test_cli_devbox_terminal.py +0 -0
  157. {morphcloud-0.1.111 → morphcloud-0.1.113}/tests/unit/test_cli_secrets.py +0 -0
  158. {morphcloud-0.1.111 → morphcloud-0.1.113}/tests/unit/test_cli_snapshots.py +0 -0
  159. {morphcloud-0.1.111 → morphcloud-0.1.113}/tests/unit/test_cli_volumes.py +0 -0
  160. {morphcloud-0.1.111 → morphcloud-0.1.113}/tests/unit/test_devbox_terminals.py +0 -0
  161. {morphcloud-0.1.111 → morphcloud-0.1.113}/tests/unit/test_http_transport_sharding.py +0 -0
  162. {morphcloud-0.1.111 → morphcloud-0.1.113}/tests/unit/test_snapshot_ttl.py +0 -0
  163. {morphcloud-0.1.111 → morphcloud-0.1.113}/tests/unit/test_ssh_connect_retry.py +0 -0
  164. {morphcloud-0.1.111 → morphcloud-0.1.113}/tests/unit/test_template_local_runner.py +0 -0
  165. {morphcloud-0.1.111 → morphcloud-0.1.113}/tests/unit/test_template_runner.py +0 -0
  166. {morphcloud-0.1.111 → morphcloud-0.1.113}/tests/unit/test_volumes_client.py +0 -0
@@ -19,6 +19,7 @@ wheels/
19
19
  *.egg-info/
20
20
  .installed.cfg
21
21
  *.egg
22
+ .ruff_cache/
22
23
 
23
24
  # Poetry
24
25
  poetry.lock
@@ -60,3 +61,10 @@ _build/
60
61
 
61
62
  # uv
62
63
  uv.lock
64
+
65
+ #clanker
66
+ AGENTS.md
67
+ CLAUDE.md
68
+ PLAN.md
69
+ .codex/
70
+ .agents/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: morphcloud
3
- Version: 0.1.111
3
+ Version: 0.1.113
4
4
  Summary: A Python SDK and CLI tool for creating, managing, and interacting with Morph Cloud VMs.
5
5
  Author-email: Morph Labs <jobs@morph.so>
6
6
  License: Apache-2.0
@@ -216,6 +216,68 @@ morphcloud instance copy inst_123:/remote/file.log ./local_dir/
216
216
  morphcloud instance copy -r ./local_dir inst_123:/remote/dir
217
217
  ```
218
218
 
219
+ ### Plugins
220
+
221
+ Plugins are Python packages that register extra `morphcloud` commands through
222
+ the `morphcloud.cli_plugins` entry-point group. Catalog and package operations
223
+ use your normal `MORPH_API_KEY`. A catalog entry can install from the private
224
+ simple-index package name or from an explicit `artifact_url`.
225
+
226
+ ```bash
227
+ # Inspect plugin configuration and installed commands
228
+ morphcloud plugin doctor
229
+
230
+ # List or inspect plugins visible to your organization
231
+ morphcloud plugin list
232
+ morphcloud plugin show intro
233
+
234
+ # Validate install resolution without changing the environment
235
+ morphcloud plugin install intro --dry-run
236
+
237
+ # Install or update a plugin into the current Python environment
238
+ morphcloud plugin install intro
239
+ morphcloud plugin upgrade intro
240
+
241
+ # Run the command exposed by the installed plugin
242
+ morphcloud intro --help
243
+ ```
244
+
245
+ Common commands:
246
+
247
+ | Command | Purpose |
248
+ | --- | --- |
249
+ | `morphcloud plugin list [query]` | List visible enabled plugins. |
250
+ | `morphcloud plugin search <query>` | Search visible plugins by name, package, artifact, or command. |
251
+ | `morphcloud plugin show <name>` / `info` | Show one catalog entry. |
252
+ | `morphcloud plugin install <name>` | Install into the active Python environment. |
253
+ | `morphcloud plugin upgrade <name>` / `update` | Upgrade an installed plugin. |
254
+ | `morphcloud plugin uninstall <name>` | Remove the plugin package. |
255
+ | `morphcloud plugin register <name>` | Upsert a catalog entry from flags or `--manifest`. |
256
+ | `morphcloud plugin publish <name> --wheel dist/pkg-version-py3-none-any.whl` | Upload a wheel to simple-index, then register the catalog entry. |
257
+ | `morphcloud plugin enable/disable/delete <name>` | Manage catalog lifecycle. |
258
+ | `morphcloud plugin doctor` | Show auth, index, catalog, and installed entry-point status. |
259
+
260
+ Management commands accept `--visibility global|org` and `--organization-id`.
261
+ Org-scoped entries require `--organization-id`; global entries must omit it.
262
+
263
+ For local or preview simple-index services, set:
264
+
265
+ ```bash
266
+ export MORPH_SIMPLE_INDEX_BASE_URL="http://127.0.0.1:8912"
267
+ ```
268
+
269
+ The installer uses the authenticated simple-index URL as the primary package
270
+ index for package-backed plugins and keeps PyPI as an extra index for public
271
+ dependencies. Credentials are redacted from CLI output, including direct
272
+ artifact URLs. Direct `artifact_url` installs are allowed only for URLs without
273
+ embedded credentials, query strings, or fragments; use package-backed simple-index
274
+ publishing for private or signed artifacts so secrets are not passed to pip as
275
+ process arguments.
276
+
277
+ For stricter private-package installs, set `MORPH_PLUGIN_DISABLE_EXTRA_INDEX=1`
278
+ to omit the public extra index. To use a controlled mirror instead of PyPI, set
279
+ `MORPH_PLUGIN_EXTRA_INDEX_URL`.
280
+
219
281
  ### Volumes
220
282
 
221
283
  Morph Volumes exposes an S3-compatible object store. Bucket lifecycle and object operations use your normal `MORPH_API_KEY`.
@@ -658,16 +720,49 @@ morphcloud user api-key create
658
720
  # Delete an API key
659
721
  morphcloud user api-key delete <api_key_id>
660
722
 
661
- # Get your SSH public key
662
- morphcloud user ssh-key get
723
+ # List named SSH public keys (fingerprints, expiry, and status)
724
+ morphcloud user ssh-key list
725
+
726
+ # Add a user-owned public key; never provide the private key
727
+ morphcloud user ssh-key add --name "Work laptop" --public-key-file ~/.ssh/id_ed25519.pub
728
+
729
+ # Inspect and edit one managed key by its stable ID
730
+ morphcloud user ssh-key get <ssh_key_id>
731
+ morphcloud user ssh-key edit <ssh_key_id> --expires 2030-01-01T00:00:00Z
663
732
 
664
- # Set/update your SSH public key
733
+ # Revoke only that key across your current organization memberships
734
+ morphcloud user ssh-key revoke <ssh_key_id>
735
+
736
+ # Legacy singular compatibility commands remain available
737
+ morphcloud user ssh-key get
665
738
  morphcloud user ssh-key set --public-key "ssh-rsa AAAA..."
666
739
 
667
740
  # View usage (3-hour lookback by default; supports 30m, 3h, 7d, etc.)
668
741
  morphcloud user usage --interval 3h
669
742
  ```
670
743
 
744
+ The same managed-key lifecycle is typed in the SDK, with async methods using the
745
+ usual `a` prefix (`alist_ssh_keys`, `aadd_ssh_key`, and so on):
746
+
747
+ ```python
748
+ from pathlib import Path
749
+
750
+ from morphcloud.api import MorphCloudClient
751
+
752
+ client = MorphCloudClient()
753
+ key = client.user.add_ssh_key(
754
+ name="Work laptop",
755
+ public_key=Path("~/.ssh/id_ed25519.pub").expanduser().read_text().strip(),
756
+ )
757
+ key = client.user.get_managed_ssh_key(key.id)
758
+ key = client.user.edit_ssh_key(key.id, name="Work laptop (2026)")
759
+ client.user.revoke_ssh_key(key.id)
760
+ ```
761
+
762
+ Managed account public keys are separate from Morph-generated instance
763
+ credentials and browser terminal access. A managed key is owned by one user and
764
+ authorizes direct SSH through that user's current organization memberships.
765
+
671
766
  ## Advanced Features
672
767
 
673
768
  ### Profiles
@@ -185,6 +185,68 @@ morphcloud instance copy inst_123:/remote/file.log ./local_dir/
185
185
  morphcloud instance copy -r ./local_dir inst_123:/remote/dir
186
186
  ```
187
187
 
188
+ ### Plugins
189
+
190
+ Plugins are Python packages that register extra `morphcloud` commands through
191
+ the `morphcloud.cli_plugins` entry-point group. Catalog and package operations
192
+ use your normal `MORPH_API_KEY`. A catalog entry can install from the private
193
+ simple-index package name or from an explicit `artifact_url`.
194
+
195
+ ```bash
196
+ # Inspect plugin configuration and installed commands
197
+ morphcloud plugin doctor
198
+
199
+ # List or inspect plugins visible to your organization
200
+ morphcloud plugin list
201
+ morphcloud plugin show intro
202
+
203
+ # Validate install resolution without changing the environment
204
+ morphcloud plugin install intro --dry-run
205
+
206
+ # Install or update a plugin into the current Python environment
207
+ morphcloud plugin install intro
208
+ morphcloud plugin upgrade intro
209
+
210
+ # Run the command exposed by the installed plugin
211
+ morphcloud intro --help
212
+ ```
213
+
214
+ Common commands:
215
+
216
+ | Command | Purpose |
217
+ | --- | --- |
218
+ | `morphcloud plugin list [query]` | List visible enabled plugins. |
219
+ | `morphcloud plugin search <query>` | Search visible plugins by name, package, artifact, or command. |
220
+ | `morphcloud plugin show <name>` / `info` | Show one catalog entry. |
221
+ | `morphcloud plugin install <name>` | Install into the active Python environment. |
222
+ | `morphcloud plugin upgrade <name>` / `update` | Upgrade an installed plugin. |
223
+ | `morphcloud plugin uninstall <name>` | Remove the plugin package. |
224
+ | `morphcloud plugin register <name>` | Upsert a catalog entry from flags or `--manifest`. |
225
+ | `morphcloud plugin publish <name> --wheel dist/pkg-version-py3-none-any.whl` | Upload a wheel to simple-index, then register the catalog entry. |
226
+ | `morphcloud plugin enable/disable/delete <name>` | Manage catalog lifecycle. |
227
+ | `morphcloud plugin doctor` | Show auth, index, catalog, and installed entry-point status. |
228
+
229
+ Management commands accept `--visibility global|org` and `--organization-id`.
230
+ Org-scoped entries require `--organization-id`; global entries must omit it.
231
+
232
+ For local or preview simple-index services, set:
233
+
234
+ ```bash
235
+ export MORPH_SIMPLE_INDEX_BASE_URL="http://127.0.0.1:8912"
236
+ ```
237
+
238
+ The installer uses the authenticated simple-index URL as the primary package
239
+ index for package-backed plugins and keeps PyPI as an extra index for public
240
+ dependencies. Credentials are redacted from CLI output, including direct
241
+ artifact URLs. Direct `artifact_url` installs are allowed only for URLs without
242
+ embedded credentials, query strings, or fragments; use package-backed simple-index
243
+ publishing for private or signed artifacts so secrets are not passed to pip as
244
+ process arguments.
245
+
246
+ For stricter private-package installs, set `MORPH_PLUGIN_DISABLE_EXTRA_INDEX=1`
247
+ to omit the public extra index. To use a controlled mirror instead of PyPI, set
248
+ `MORPH_PLUGIN_EXTRA_INDEX_URL`.
249
+
188
250
  ### Volumes
189
251
 
190
252
  Morph Volumes exposes an S3-compatible object store. Bucket lifecycle and object operations use your normal `MORPH_API_KEY`.
@@ -627,16 +689,49 @@ morphcloud user api-key create
627
689
  # Delete an API key
628
690
  morphcloud user api-key delete <api_key_id>
629
691
 
630
- # Get your SSH public key
631
- morphcloud user ssh-key get
692
+ # List named SSH public keys (fingerprints, expiry, and status)
693
+ morphcloud user ssh-key list
694
+
695
+ # Add a user-owned public key; never provide the private key
696
+ morphcloud user ssh-key add --name "Work laptop" --public-key-file ~/.ssh/id_ed25519.pub
697
+
698
+ # Inspect and edit one managed key by its stable ID
699
+ morphcloud user ssh-key get <ssh_key_id>
700
+ morphcloud user ssh-key edit <ssh_key_id> --expires 2030-01-01T00:00:00Z
632
701
 
633
- # Set/update your SSH public key
702
+ # Revoke only that key across your current organization memberships
703
+ morphcloud user ssh-key revoke <ssh_key_id>
704
+
705
+ # Legacy singular compatibility commands remain available
706
+ morphcloud user ssh-key get
634
707
  morphcloud user ssh-key set --public-key "ssh-rsa AAAA..."
635
708
 
636
709
  # View usage (3-hour lookback by default; supports 30m, 3h, 7d, etc.)
637
710
  morphcloud user usage --interval 3h
638
711
  ```
639
712
 
713
+ The same managed-key lifecycle is typed in the SDK, with async methods using the
714
+ usual `a` prefix (`alist_ssh_keys`, `aadd_ssh_key`, and so on):
715
+
716
+ ```python
717
+ from pathlib import Path
718
+
719
+ from morphcloud.api import MorphCloudClient
720
+
721
+ client = MorphCloudClient()
722
+ key = client.user.add_ssh_key(
723
+ name="Work laptop",
724
+ public_key=Path("~/.ssh/id_ed25519.pub").expanduser().read_text().strip(),
725
+ )
726
+ key = client.user.get_managed_ssh_key(key.id)
727
+ key = client.user.edit_ssh_key(key.id, name="Work laptop (2026)")
728
+ client.user.revoke_ssh_key(key.id)
729
+ ```
730
+
731
+ Managed account public keys are separate from Morph-generated instance
732
+ credentials and browser terminal access. A managed key is owned by one user and
733
+ authorizes direct SSH through that user's current organization memberships.
734
+
640
735
  ## Advanced Features
641
736
 
642
737
  ### Profiles
@@ -13,7 +13,7 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
13
13
  from functools import lru_cache
14
14
 
15
15
  import httpx
16
- from pydantic import BaseModel, Field, PrivateAttr
16
+ from pydantic import BaseModel, Field, PrivateAttr, model_validator
17
17
 
18
18
  # Import Rich for fancy printing
19
19
  from rich.console import Console
@@ -24,6 +24,7 @@ from morphcloud.config import resolve_settings
24
24
 
25
25
  if typing.TYPE_CHECKING:
26
26
  from morphcloud.devbox.client import AsyncDevboxClient, DevboxClient
27
+ from morphcloud.simple_index.client import SimpleIndexClient
27
28
  from morphcloud.volumes.client import VolumesClient
28
29
 
29
30
  # Global console instance
@@ -51,10 +52,29 @@ class ApiError(Exception):
51
52
  def __init__(self, message: str, status_code: int, response_body: str):
52
53
  self.status_code = status_code
53
54
  self.response_body = response_body
55
+ self.error_code: typing.Optional[str] = None
56
+ self.detail: typing.Optional[str] = None
57
+ try:
58
+ payload = json.loads(response_body)
59
+ if isinstance(payload, dict):
60
+ error_code = payload.get("error_code")
61
+ detail = payload.get("detail")
62
+ if isinstance(error_code, str):
63
+ self.error_code = error_code
64
+ if isinstance(detail, str):
65
+ self.detail = detail
66
+ except (TypeError, ValueError):
67
+ pass
54
68
  super().__init__(
55
69
  f"{message}\nStatus Code: {status_code}\nResponse Body: {response_body}"
56
70
  )
57
71
 
72
+ def as_managed_ssh_key_error(self) -> typing.Optional["ManagedSSHKeyError"]:
73
+ """Return a typed managed-key error when the response uses that contract."""
74
+ if self.error_code is None or self.detail is None:
75
+ return None
76
+ return ManagedSSHKeyError(error_code=self.error_code, detail=self.detail)
77
+
58
78
 
59
79
  class ApiClient(httpx.Client):
60
80
  def raise_for_status(self, response: httpx.Response) -> None:
@@ -167,6 +187,7 @@ class MorphCloudClient:
167
187
  volumes_service_api_key: typing.Optional[str] = None,
168
188
  admin_base_url: typing.Optional[str] = None,
169
189
  db_base_url: typing.Optional[str] = None,
190
+ simple_index_base_url: typing.Optional[str] = None,
170
191
  ssh_hostname: typing.Optional[str] = None,
171
192
  ssh_port: typing.Optional[int] = None,
172
193
  devbox_base_url: typing.Optional[str] = None,
@@ -182,6 +203,7 @@ class MorphCloudClient:
182
203
  "devbox_base_url": devbox_base_url,
183
204
  "admin_base_url": admin_base_url,
184
205
  "db_base_url": db_base_url,
206
+ "simple_index_base_url": simple_index_base_url,
185
207
  "ssh_hostname": ssh_hostname,
186
208
  "ssh_port": ssh_port,
187
209
  },
@@ -200,10 +222,12 @@ class MorphCloudClient:
200
222
  self.devbox_base_url = settings.devbox_base_url
201
223
  self.admin_base_url = settings.admin_base_url
202
224
  self.db_base_url = settings.db_base_url
225
+ self.simple_index_base_url = settings.simple_index_base_url
203
226
  self.profile = settings.profile
204
227
  self._devbox_client = None
205
228
  self._devbox_async_client = None
206
229
  self._volumes_client = None
230
+ self._simple_index_client = None
207
231
  if not self.api_key:
208
232
  raise ValueError(
209
233
  "API key must be provided or set in MORPH_API_KEY environment variable"
@@ -254,9 +278,10 @@ class MorphCloudClient:
254
278
  )
255
279
 
256
280
  self._load_sdk_plugins()
257
- # Ensure first-party devbox clients win even if an external plugin attempted to attach them.
281
+ # Ensure first-party clients win even if an external plugin attempted to attach them.
258
282
  self._devbox_client = None
259
283
  self._devbox_async_client = None
284
+ self._simple_index_client = None
260
285
 
261
286
  def _load_sdk_plugins(self):
262
287
  """Load SDK plugins from entry points."""
@@ -339,6 +364,18 @@ class MorphCloudClient:
339
364
  def volumes(self, value: "VolumesClient") -> None:
340
365
  self._volumes_client = value
341
366
 
367
+ @property
368
+ def simple_index(self) -> "SimpleIndexClient":
369
+ if self._simple_index_client is None:
370
+ from morphcloud.simple_index.client import SimpleIndexClient
371
+
372
+ self._simple_index_client = SimpleIndexClient(self)
373
+ return self._simple_index_client
374
+
375
+ @simple_index.setter
376
+ def simple_index(self, value: "SimpleIndexClient") -> None:
377
+ self._simple_index_client = value
378
+
342
379
 
343
380
  class BaseAPI:
344
381
  def __init__(self, client: MorphCloudClient):
@@ -423,28 +460,146 @@ class UserAPI(BaseAPI):
423
460
  await self._client._async_http_client.delete(f"/user/secret/{secret_name}")
424
461
 
425
462
  # ────────────────────────────────
426
- # SSH Key
463
+ # Managed SSH public keys
464
+ # ────────────────────────────────
465
+ def list_ssh_keys(self) -> typing.List["ManagedSSHKey"]:
466
+ """List all managed SSH public keys owned by the authenticated user."""
467
+ response = self._client._http_client.get("/user/ssh-keys")
468
+ return ManagedSSHKeyList.model_validate(response.json()).data
469
+
470
+ async def alist_ssh_keys(self) -> typing.List["ManagedSSHKey"]:
471
+ """Asynchronously list all managed SSH public keys owned by the user."""
472
+ response = await self._client._async_http_client.get("/user/ssh-keys")
473
+ return ManagedSSHKeyList.model_validate(response.json()).data
474
+
475
+ def get_managed_ssh_key(self, ssh_key_id: str) -> "ManagedSSHKey":
476
+ """Get one managed SSH public key by its stable ID."""
477
+ response = self._client._http_client.get(f"/user/ssh-keys/{ssh_key_id}")
478
+ return ManagedSSHKey.model_validate(response.json())
479
+
480
+ async def aget_managed_ssh_key(self, ssh_key_id: str) -> "ManagedSSHKey":
481
+ """Asynchronously get one managed SSH public key by its stable ID."""
482
+ response = await self._client._async_http_client.get(
483
+ f"/user/ssh-keys/{ssh_key_id}"
484
+ )
485
+ return ManagedSSHKey.model_validate(response.json())
486
+
487
+ def add_ssh_key(
488
+ self,
489
+ name: str,
490
+ public_key: str,
491
+ expires: typing.Optional[int] = None,
492
+ ) -> "ManagedSSHKey":
493
+ """Add a named SSH public key, optionally expiring at Unix seconds."""
494
+ request_body = ManagedSSHKeyCreateRequest(
495
+ name=name, public_key=public_key, expires=expires
496
+ )
497
+ response = self._client._http_client.post(
498
+ "/user/ssh-keys", json=request_body.model_dump(exclude_none=True)
499
+ )
500
+ return ManagedSSHKey.model_validate(response.json())
501
+
502
+ async def aadd_ssh_key(
503
+ self,
504
+ name: str,
505
+ public_key: str,
506
+ expires: typing.Optional[int] = None,
507
+ ) -> "ManagedSSHKey":
508
+ """Asynchronously add a named SSH public key."""
509
+ request_body = ManagedSSHKeyCreateRequest(
510
+ name=name, public_key=public_key, expires=expires
511
+ )
512
+ response = await self._client._async_http_client.post(
513
+ "/user/ssh-keys", json=request_body.model_dump(exclude_none=True)
514
+ )
515
+ return ManagedSSHKey.model_validate(response.json())
516
+
517
+ def edit_ssh_key(
518
+ self,
519
+ ssh_key_id: str,
520
+ *,
521
+ name: typing.Optional[str] = None,
522
+ expires: typing.Optional[int] = None,
523
+ clear_expiry: bool = False,
524
+ ) -> "ManagedSSHKey":
525
+ """Edit a key's name or expiry without changing its public material.
526
+
527
+ Pass ``clear_expiry=True`` to send an explicit JSON null for ``expires``.
528
+ """
529
+ request_body = _managed_ssh_key_patch_request(
530
+ name=name, expires=expires, clear_expiry=clear_expiry
531
+ )
532
+ response = self._client._http_client.patch(
533
+ f"/user/ssh-keys/{ssh_key_id}",
534
+ json=request_body.model_dump(exclude_unset=True),
535
+ )
536
+ return ManagedSSHKey.model_validate(response.json())
537
+
538
+ async def aedit_ssh_key(
539
+ self,
540
+ ssh_key_id: str,
541
+ *,
542
+ name: typing.Optional[str] = None,
543
+ expires: typing.Optional[int] = None,
544
+ clear_expiry: bool = False,
545
+ ) -> "ManagedSSHKey":
546
+ """Asynchronously edit a key's name or expiry."""
547
+ request_body = _managed_ssh_key_patch_request(
548
+ name=name, expires=expires, clear_expiry=clear_expiry
549
+ )
550
+ response = await self._client._async_http_client.patch(
551
+ f"/user/ssh-keys/{ssh_key_id}",
552
+ json=request_body.model_dump(exclude_unset=True),
553
+ )
554
+ return ManagedSSHKey.model_validate(response.json())
555
+
556
+ def revoke_ssh_key(self, ssh_key_id: str) -> None:
557
+ """Permanently revoke one managed SSH public key by stable ID."""
558
+ self._client._http_client.delete(f"/user/ssh-keys/{ssh_key_id}")
559
+
560
+ async def arevoke_ssh_key(self, ssh_key_id: str) -> None:
561
+ """Asynchronously revoke one managed SSH public key by stable ID."""
562
+ await self._client._async_http_client.delete(f"/user/ssh-keys/{ssh_key_id}")
563
+
564
+ # ────────────────────────────────
565
+ # Legacy singular SSH key compatibility
427
566
  # ────────────────────────────────
428
567
  def get_ssh_key(self) -> "UserSSHKey":
568
+ """Get the legacy singular key through its compatibility endpoint."""
429
569
  response = self._client._http_client.get("/user/ssh-key")
430
570
  return UserSSHKey.model_validate(response.json())
431
571
 
432
572
  async def aget_ssh_key(self) -> "UserSSHKey":
573
+ """Asynchronously get the legacy singular compatibility key."""
433
574
  response = await self._client._async_http_client.get("/user/ssh-key")
434
575
  return UserSSHKey.model_validate(response.json())
435
576
 
436
577
  def update_ssh_key(self, public_key: str) -> "UserSSHKey":
578
+ """Set the legacy singular compatibility key.
579
+
580
+ New integrations should use :meth:`add_ssh_key`, then revoke the old key
581
+ only after verifying the replacement.
582
+ """
437
583
  response = self._client._http_client.put(
438
584
  "/user/ssh-key", json={"public_key": public_key}
439
585
  )
440
586
  return UserSSHKey.model_validate(response.json())
441
587
 
442
588
  async def aupdate_ssh_key(self, public_key: str) -> "UserSSHKey":
589
+ """Asynchronously set the legacy singular compatibility key."""
443
590
  response = await self._client._async_http_client.put(
444
591
  "/user/ssh-key", json={"public_key": public_key}
445
592
  )
446
593
  return UserSSHKey.model_validate(response.json())
447
594
 
595
+ def set_ssh_key(self, public_key: str) -> "UserSSHKey":
596
+ """Compatibility alias for :meth:`update_ssh_key`."""
597
+ return self.update_ssh_key(public_key)
598
+
599
+ async def aset_ssh_key(self, public_key: str) -> "UserSSHKey":
600
+ """Async compatibility alias for :meth:`aupdate_ssh_key`."""
601
+ return await self.aupdate_ssh_key(public_key)
602
+
448
603
  # ────────────────────────────────
449
604
  # Usage
450
605
  # ────────────────────────────────
@@ -517,10 +672,85 @@ class CreateSecretRequest(BaseModel):
517
672
 
518
673
 
519
674
  class UserSSHKey(BaseModel):
675
+ """Legacy singular user SSH-key response."""
676
+
520
677
  public_key: str
521
678
  created: int
522
679
 
523
680
 
681
+ class ManagedSSHKeyStatus(StrEnum):
682
+ ACTIVE = "active"
683
+ EXPIRED = "expired"
684
+ REVOKED = "revoked"
685
+ MIGRATION_WARNING = "migration_warning"
686
+
687
+
688
+ class ManagedSSHKey(BaseModel):
689
+ """A user-owned SSH public key accepted through current org memberships."""
690
+
691
+ object: typing.Literal["user_ssh_key"] = "user_ssh_key"
692
+ id: str
693
+ name: str
694
+ public_key: str
695
+ fingerprint: str
696
+ algorithm: str
697
+ created: int
698
+ updated: int
699
+ expires: typing.Optional[int] = None
700
+ last_used: typing.Optional[int] = None
701
+ revoked: typing.Optional[int] = None
702
+ status: ManagedSSHKeyStatus
703
+ migrated_from_legacy: bool = False
704
+ migration_warning: bool = False
705
+
706
+
707
+ class ManagedSSHKeyList(BaseModel):
708
+ object: typing.Literal["list"] = "list"
709
+ data: typing.List[ManagedSSHKey]
710
+
711
+
712
+ class ManagedSSHKeyCreateRequest(BaseModel):
713
+ name: str
714
+ public_key: str
715
+ expires: typing.Optional[int] = None
716
+
717
+
718
+ class ManagedSSHKeyPatchRequest(BaseModel):
719
+ name: typing.Optional[str] = None
720
+ expires: typing.Optional[int] = None
721
+
722
+ @model_validator(mode="after")
723
+ def require_change(self) -> "ManagedSSHKeyPatchRequest":
724
+ if not self.model_fields_set:
725
+ raise ValueError("at least one of name or expires must be provided")
726
+ return self
727
+
728
+
729
+ class ManagedSSHKeyError(BaseModel):
730
+ """Stable machine-readable error returned by managed-key endpoints."""
731
+
732
+ detail: str
733
+ error_code: str
734
+
735
+
736
+ def _managed_ssh_key_patch_request(
737
+ *,
738
+ name: typing.Optional[str],
739
+ expires: typing.Optional[int],
740
+ clear_expiry: bool,
741
+ ) -> ManagedSSHKeyPatchRequest:
742
+ if clear_expiry and expires is not None:
743
+ raise ValueError("expires and clear_expiry cannot be used together")
744
+ values: typing.Dict[str, typing.Any] = {}
745
+ if name is not None:
746
+ values["name"] = name
747
+ if clear_expiry:
748
+ values["expires"] = None
749
+ elif expires is not None:
750
+ values["expires"] = expires
751
+ return ManagedSSHKeyPatchRequest(**values)
752
+
753
+
524
754
  class UserInstanceUsage(BaseModel):
525
755
  instance_cpu_time: int
526
756
  instance_memory_time: int