morphcloud 0.1.112__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.112 → morphcloud-0.1.113}/PKG-INFO +37 -4
  2. {morphcloud-0.1.112 → morphcloud-0.1.113}/README.md +36 -3
  3. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/api.py +214 -2
  4. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/cli.py +256 -4
  5. {morphcloud-0.1.112 → morphcloud-0.1.113}/pyproject.toml +1 -1
  6. morphcloud-0.1.113/tests/integration/test_managed_ssh_keys.py +146 -0
  7. morphcloud-0.1.113/tests/unit/test_cli_managed_ssh_keys.py +249 -0
  8. morphcloud-0.1.113/tests/unit/test_managed_ssh_keys_api.py +233 -0
  9. {morphcloud-0.1.112 → morphcloud-0.1.113}/.github/workflows/publish.yaml +0 -0
  10. {morphcloud-0.1.112 → morphcloud-0.1.113}/.gitignore +0 -0
  11. {morphcloud-0.1.112 → morphcloud-0.1.113}/LICENSE +0 -0
  12. {morphcloud-0.1.112 → morphcloud-0.1.113}/Makefile +0 -0
  13. {morphcloud-0.1.112 → morphcloud-0.1.113}/conf.py +0 -0
  14. {morphcloud-0.1.112 → morphcloud-0.1.113}/examples/browser_example.py +0 -0
  15. {morphcloud-0.1.112 → morphcloud-0.1.113}/examples/devbox_example.py +0 -0
  16. {morphcloud-0.1.112 → morphcloud-0.1.113}/index.rst +0 -0
  17. {morphcloud-0.1.112 → morphcloud-0.1.113}/make.bat +0 -0
  18. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/__init__.py +0 -0
  19. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/_asyncify.py +0 -0
  20. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/_bash_interpreter.py +0 -0
  21. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/_http_transport.py +0 -0
  22. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/_llm.py +0 -0
  23. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/_scramble.py +0 -0
  24. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/_ssh.py +0 -0
  25. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/_utils.py +0 -0
  26. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/cli_helpers.py +0 -0
  27. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/config.py +0 -0
  28. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/__init__.py +0 -0
  29. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/cli.py +0 -0
  30. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/client.py +0 -0
  31. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/config.py +0 -0
  32. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/__init__.py +0 -0
  33. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/admin/__init__.py +0 -0
  34. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/admin/client.py +0 -0
  35. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/admin/raw_client.py +0 -0
  36. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/aliases/__init__.py +0 -0
  37. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/aliases/client.py +0 -0
  38. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/aliases/raw_client.py +0 -0
  39. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/client.py +0 -0
  40. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/core/__init__.py +0 -0
  41. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/core/api_error.py +0 -0
  42. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/core/client_wrapper.py +0 -0
  43. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/core/datetime_utils.py +0 -0
  44. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/core/file.py +0 -0
  45. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/core/force_multipart.py +0 -0
  46. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/core/http_client.py +0 -0
  47. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/core/http_response.py +0 -0
  48. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/core/jsonable_encoder.py +0 -0
  49. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/core/pydantic_utilities.py +0 -0
  50. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/core/query_encoder.py +0 -0
  51. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/core/remove_none_from_dict.py +0 -0
  52. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/core/request_options.py +0 -0
  53. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/core/serialization.py +0 -0
  54. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/devboxes_actions/__init__.py +0 -0
  55. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/devboxes_actions/client.py +0 -0
  56. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/devboxes_actions/raw_client.py +0 -0
  57. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/devboxes_core/__init__.py +0 -0
  58. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/devboxes_core/client.py +0 -0
  59. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/devboxes_core/raw_client.py +0 -0
  60. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/devboxes_lifecycle/__init__.py +0 -0
  61. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/devboxes_lifecycle/client.py +0 -0
  62. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/devboxes_lifecycle/raw_client.py +0 -0
  63. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/errors/__init__.py +0 -0
  64. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/errors/unprocessable_entity_error.py +0 -0
  65. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/templates/__init__.py +0 -0
  66. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/templates/client.py +0 -0
  67. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/templates/raw_client.py +0 -0
  68. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/tmux/__init__.py +0 -0
  69. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/tmux/client.py +0 -0
  70. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/tmux/raw_client.py +0 -0
  71. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/__init__.py +0 -0
  72. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/alias_response.py +0 -0
  73. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/branch_response.py +0 -0
  74. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/delete_response.py +0 -0
  75. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/devbox_detail_list_response.py +0 -0
  76. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/devbox_list_response.py +0 -0
  77. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/devbox_refs.py +0 -0
  78. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/devbox_response.py +0 -0
  79. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/devbox_spec.py +0 -0
  80. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/devbox_ssh_credentials_response.py +0 -0
  81. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/devbox_summary_response.py +0 -0
  82. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/http_service.py +0 -0
  83. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/http_service_auth_mode.py +0 -0
  84. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/http_validation_error.py +0 -0
  85. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/networking.py +0 -0
  86. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/secret_value_response.py +0 -0
  87. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/snapshot_response.py +0 -0
  88. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/success_response.py +0 -0
  89. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/template_cache_request.py +0 -0
  90. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/template_cache_start_response.py +0 -0
  91. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/template_detail.py +0 -0
  92. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/template_list_response.py +0 -0
  93. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/template_share_response.py +0 -0
  94. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/template_status.py +0 -0
  95. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/template_step_model.py +0 -0
  96. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/template_step_status.py +0 -0
  97. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/template_summary.py +0 -0
  98. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/timeline_event.py +0 -0
  99. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/tmux_debug_info_response.py +0 -0
  100. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/tmux_install_response.py +0 -0
  101. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/tmux_new_session_response.py +0 -0
  102. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/tmux_send_keys_response.py +0 -0
  103. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/tmux_session.py +0 -0
  104. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/tmux_sessions_list.py +0 -0
  105. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/tmux_snapshot_response.py +0 -0
  106. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/tmux_target.py +0 -0
  107. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/ttl.py +0 -0
  108. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/upload_response.py +0 -0
  109. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/validation_error.py +0 -0
  110. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/validation_error_loc_item.py +0 -0
  111. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/types/wake_on.py +0 -0
  112. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/uploads/__init__.py +0 -0
  113. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/uploads/client.py +0 -0
  114. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/uploads/raw_client.py +0 -0
  115. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/user_secrets/__init__.py +0 -0
  116. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/user_secrets/client.py +0 -0
  117. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/gen/user_secrets/raw_client.py +0 -0
  118. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/openapi.json +0 -0
  119. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/template_local_runner.py +0 -0
  120. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/template_runner.py +0 -0
  121. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/devbox/terminals.py +0 -0
  122. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/experimental/__init__.py +0 -0
  123. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/experimental/browser.py +0 -0
  124. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/plugins/__init__.py +0 -0
  125. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/plugins/cli.py +0 -0
  126. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/plugins/installer.py +0 -0
  127. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/py.typed +0 -0
  128. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/simple_index/__init__.py +0 -0
  129. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/simple_index/client.py +0 -0
  130. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/volumes/__init__.py +0 -0
  131. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/volumes/cli.py +0 -0
  132. {morphcloud-0.1.112 → morphcloud-0.1.113}/morphcloud/volumes/client.py +0 -0
  133. {morphcloud-0.1.112 → morphcloud-0.1.113}/requirements.txt +0 -0
  134. {morphcloud-0.1.112 → morphcloud-0.1.113}/scripts/abuild_smoke.py +0 -0
  135. {morphcloud-0.1.112 → morphcloud-0.1.113}/scripts/increment_version.py +0 -0
  136. {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/integration/__init__.py +0 -0
  137. {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/integration/conftest.py +0 -0
  138. {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/integration/pytest.ini +0 -0
  139. {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/integration/test_as_container.py +0 -0
  140. {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/integration/test_branching.py +0 -0
  141. {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/integration/test_command_execution.py +0 -0
  142. {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/integration/test_file_operations.py +0 -0
  143. {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/integration/test_from_tag_multiple.py +0 -0
  144. {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/integration/test_http_service.py +0 -0
  145. {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/integration/test_metadata.py +0 -0
  146. {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/integration/test_simple.py +0 -0
  147. {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/integration/test_snapshot_operations.py +0 -0
  148. {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/integration/test_ssh_key_rotation.py +0 -0
  149. {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/integration/test_ttl.py +0 -0
  150. {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/integration/test_user.py +0 -0
  151. {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/unit/test_cli_devbox.py +0 -0
  152. {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/unit/test_cli_devbox_terminal.py +0 -0
  153. {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/unit/test_cli_plugins.py +0 -0
  154. {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/unit/test_cli_secrets.py +0 -0
  155. {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/unit/test_cli_snapshots.py +0 -0
  156. {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/unit/test_cli_volumes.py +0 -0
  157. {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/unit/test_devbox_terminals.py +0 -0
  158. {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/unit/test_http_transport_sharding.py +0 -0
  159. {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/unit/test_plugin_installer.py +0 -0
  160. {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/unit/test_profiles.py +0 -0
  161. {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/unit/test_simple_index_client.py +0 -0
  162. {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/unit/test_snapshot_ttl.py +0 -0
  163. {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/unit/test_ssh_connect_retry.py +0 -0
  164. {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/unit/test_template_local_runner.py +0 -0
  165. {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/unit/test_template_runner.py +0 -0
  166. {morphcloud-0.1.112 → morphcloud-0.1.113}/tests/unit/test_volumes_client.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: morphcloud
3
- Version: 0.1.112
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
@@ -720,16 +720,49 @@ morphcloud user api-key create
720
720
  # Delete an API key
721
721
  morphcloud user api-key delete <api_key_id>
722
722
 
723
- # Get your SSH public key
724
- 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
732
+
733
+ # Revoke only that key across your current organization memberships
734
+ morphcloud user ssh-key revoke <ssh_key_id>
725
735
 
726
- # Set/update your SSH public key
736
+ # Legacy singular compatibility commands remain available
737
+ morphcloud user ssh-key get
727
738
  morphcloud user ssh-key set --public-key "ssh-rsa AAAA..."
728
739
 
729
740
  # View usage (3-hour lookback by default; supports 30m, 3h, 7d, etc.)
730
741
  morphcloud user usage --interval 3h
731
742
  ```
732
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
+
733
766
  ## Advanced Features
734
767
 
735
768
  ### Profiles
@@ -689,16 +689,49 @@ morphcloud user api-key create
689
689
  # Delete an API key
690
690
  morphcloud user api-key delete <api_key_id>
691
691
 
692
- # Get your SSH public key
693
- 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
701
+
702
+ # Revoke only that key across your current organization memberships
703
+ morphcloud user ssh-key revoke <ssh_key_id>
694
704
 
695
- # Set/update your SSH public key
705
+ # Legacy singular compatibility commands remain available
706
+ morphcloud user ssh-key get
696
707
  morphcloud user ssh-key set --public-key "ssh-rsa AAAA..."
697
708
 
698
709
  # View usage (3-hour lookback by default; supports 30m, 3h, 7d, etc.)
699
710
  morphcloud user usage --interval 3h
700
711
  ```
701
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
+
702
735
  ## Advanced Features
703
736
 
704
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
@@ -52,10 +52,29 @@ class ApiError(Exception):
52
52
  def __init__(self, message: str, status_code: int, response_body: str):
53
53
  self.status_code = status_code
54
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
55
68
  super().__init__(
56
69
  f"{message}\nStatus Code: {status_code}\nResponse Body: {response_body}"
57
70
  )
58
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
+
59
78
 
60
79
  class ApiClient(httpx.Client):
61
80
  def raise_for_status(self, response: httpx.Response) -> None:
@@ -441,28 +460,146 @@ class UserAPI(BaseAPI):
441
460
  await self._client._async_http_client.delete(f"/user/secret/{secret_name}")
442
461
 
443
462
  # ────────────────────────────────
444
- # 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
445
566
  # ────────────────────────────────
446
567
  def get_ssh_key(self) -> "UserSSHKey":
568
+ """Get the legacy singular key through its compatibility endpoint."""
447
569
  response = self._client._http_client.get("/user/ssh-key")
448
570
  return UserSSHKey.model_validate(response.json())
449
571
 
450
572
  async def aget_ssh_key(self) -> "UserSSHKey":
573
+ """Asynchronously get the legacy singular compatibility key."""
451
574
  response = await self._client._async_http_client.get("/user/ssh-key")
452
575
  return UserSSHKey.model_validate(response.json())
453
576
 
454
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
+ """
455
583
  response = self._client._http_client.put(
456
584
  "/user/ssh-key", json={"public_key": public_key}
457
585
  )
458
586
  return UserSSHKey.model_validate(response.json())
459
587
 
460
588
  async def aupdate_ssh_key(self, public_key: str) -> "UserSSHKey":
589
+ """Asynchronously set the legacy singular compatibility key."""
461
590
  response = await self._client._async_http_client.put(
462
591
  "/user/ssh-key", json={"public_key": public_key}
463
592
  )
464
593
  return UserSSHKey.model_validate(response.json())
465
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
+
466
603
  # ────────────────────────────────
467
604
  # Usage
468
605
  # ────────────────────────────────
@@ -535,8 +672,83 @@ class CreateSecretRequest(BaseModel):
535
672
 
536
673
 
537
674
  class UserSSHKey(BaseModel):
675
+ """Legacy singular user SSH-key response."""
676
+
677
+ public_key: str
678
+ created: int
679
+
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
538
694
  public_key: str
695
+ fingerprint: str
696
+ algorithm: str
539
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)
540
752
 
541
753
 
542
754
  class UserInstanceUsage(BaseModel):
@@ -4,6 +4,7 @@ import datetime
4
4
  import importlib.metadata
5
5
  import json
6
6
  import os
7
+ import pathlib
7
8
  import shlex
8
9
  import sys
9
10
  import termios
@@ -861,20 +862,153 @@ def user_secret_delete(name):
861
862
  cli.add_command(user_secret, name="secrets")
862
863
 
863
864
 
864
- # ── SSH Key ─────────────────────────────────────────────────
865
+ # ── Managed SSH public keys ─────────────────────────────────
865
866
 
866
867
 
867
868
  @user.group(cls=AliasedGroup, name="ssh-key")
868
869
  def user_ssh_key():
869
- """Manage user SSH public key."""
870
+ """Manage user-owned SSH public keys used for direct SSH."""
870
871
  pass
871
872
 
872
873
 
874
+ def _read_ssh_public_key(
875
+ public_key: typing.Optional[str], public_key_file: typing.Optional[str]
876
+ ) -> str:
877
+ if public_key is not None and public_key_file is not None:
878
+ raise click.ClickException(
879
+ "Use either --public-key or --public-key-file, not both."
880
+ )
881
+ if public_key_file is not None:
882
+ path = pathlib.Path(public_key_file)
883
+ if path.suffix.lower() != ".pub":
884
+ raise click.ClickException(
885
+ "--public-key-file must point to a public .pub file, never a private key."
886
+ )
887
+ try:
888
+ public_key = path.read_text(encoding="utf-8")
889
+ except (OSError, UnicodeError) as exc:
890
+ raise click.ClickException(
891
+ f"Could not read public-key file: {exc}"
892
+ ) from exc
893
+ if public_key is None:
894
+ raise click.ClickException(
895
+ "Provide the public key with --public-key or --public-key-file."
896
+ )
897
+ value = public_key.strip()
898
+ if not value:
899
+ raise click.ClickException("SSH public key cannot be empty.")
900
+ if "PRIVATE KEY" in value:
901
+ raise click.ClickException(
902
+ "Private keys are not accepted. Provide only the public .pub value."
903
+ )
904
+ if "\n" in value or "\r" in value:
905
+ raise click.ClickException("Provide exactly one SSH public key.")
906
+ return value
907
+
908
+
909
+ def _parse_ssh_expiry(
910
+ ctx: click.Context,
911
+ param: typing.Optional[click.Parameter],
912
+ value: typing.Optional[str],
913
+ ) -> typing.Optional[int]:
914
+ if value is None:
915
+ return None
916
+ try:
917
+ return int(value)
918
+ except ValueError:
919
+ pass
920
+ try:
921
+ parsed = datetime.datetime.fromisoformat(value.replace("Z", "+00:00"))
922
+ except ValueError as exc:
923
+ raise click.BadParameter(
924
+ "must be Unix seconds or an ISO 8601 datetime", ctx=ctx, param=param
925
+ ) from exc
926
+ if parsed.tzinfo is None:
927
+ parsed = parsed.replace(tzinfo=datetime.timezone.utc)
928
+ return int(parsed.timestamp())
929
+
930
+
931
+ @user_ssh_key.command(name="list", aliases=["ls"])
932
+ @click.option("--json", "json_mode", is_flag=True, default=False)
933
+ def user_ssh_key_list(json_mode):
934
+ """List managed SSH public keys."""
935
+ client = get_client()
936
+ try:
937
+ keys = client.user.list_ssh_keys()
938
+ if json_mode:
939
+ click.echo(
940
+ json.dumps([key.model_dump(mode="json") for key in keys], indent=2)
941
+ )
942
+ else:
943
+ headers = [
944
+ "ID",
945
+ "Name",
946
+ "Algorithm",
947
+ "Fingerprint",
948
+ "Status",
949
+ "Created",
950
+ "Last Used",
951
+ "Expires",
952
+ ]
953
+ rows = [
954
+ [
955
+ key.id,
956
+ key.name,
957
+ key.algorithm,
958
+ key.fingerprint,
959
+ key.status.value,
960
+ unix_timestamp_to_datetime(key.created),
961
+ unix_timestamp_to_datetime(key.last_used),
962
+ unix_timestamp_to_datetime(key.expires),
963
+ ]
964
+ for key in keys
965
+ ]
966
+ print_docker_style_table(headers, rows)
967
+ except Exception as e:
968
+ handle_api_error(e)
969
+
970
+
873
971
  @user_ssh_key.command(name="get")
972
+ @click.argument("ssh_key_id", required=False)
874
973
  @click.option("--json", "json_mode", is_flag=True, default=False)
875
- def user_ssh_key_get(json_mode):
974
+ def user_ssh_key_get(ssh_key_id, json_mode):
975
+ """Get a managed key by ID, or the legacy singular key when ID is omitted."""
876
976
  client = get_client()
877
977
  try:
978
+ if ssh_key_id is not None:
979
+ info = client.user.get_managed_ssh_key(ssh_key_id)
980
+ if json_mode:
981
+ click.echo(format_json(info))
982
+ else:
983
+ headers = [
984
+ "ID",
985
+ "Name",
986
+ "Algorithm",
987
+ "Fingerprint",
988
+ "Status",
989
+ "Created",
990
+ "Updated",
991
+ "Last Used",
992
+ "Expires",
993
+ "Revoked",
994
+ ]
995
+ rows = [
996
+ [
997
+ info.id,
998
+ info.name,
999
+ info.algorithm,
1000
+ info.fingerprint,
1001
+ info.status.value,
1002
+ unix_timestamp_to_datetime(info.created),
1003
+ unix_timestamp_to_datetime(info.updated),
1004
+ unix_timestamp_to_datetime(info.last_used),
1005
+ unix_timestamp_to_datetime(info.expires),
1006
+ unix_timestamp_to_datetime(info.revoked),
1007
+ ]
1008
+ ]
1009
+ print_docker_style_table(headers, rows)
1010
+ return
1011
+
878
1012
  info = client.user.get_ssh_key()
879
1013
  if json_mode:
880
1014
  click.echo(format_json(info))
@@ -886,6 +1020,120 @@ def user_ssh_key_get(json_mode):
886
1020
  handle_api_error(e)
887
1021
 
888
1022
 
1023
+ @user_ssh_key.command(name="add", aliases=["create"])
1024
+ @click.option("--name", required=True, help="A unique, recognizable device name.")
1025
+ @click.option("--public-key", default=None, help="One OpenSSH public-key value.")
1026
+ @click.option(
1027
+ "--public-key-file",
1028
+ type=click.Path(exists=True, dir_okay=False, readable=True),
1029
+ default=None,
1030
+ help="Read the public key from a .pub file.",
1031
+ )
1032
+ @click.option(
1033
+ "--expires",
1034
+ callback=_parse_ssh_expiry,
1035
+ default=None,
1036
+ help="Optional expiry as Unix seconds or ISO 8601 (naive values are UTC).",
1037
+ )
1038
+ @click.option("--json", "json_mode", is_flag=True, default=False)
1039
+ def user_ssh_key_add(name, public_key, public_key_file, expires, json_mode):
1040
+ """Add a managed SSH public key.
1041
+
1042
+ A user-owned key authorizes direct SSH through all current organization
1043
+ memberships. Morph never asks for or stores the corresponding private key.
1044
+ """
1045
+ key_value = _read_ssh_public_key(public_key, public_key_file)
1046
+ client = get_client()
1047
+ try:
1048
+ with Spinner(
1049
+ text="Adding SSH public key...",
1050
+ success_text="SSH public key added",
1051
+ success_emoji="🔐",
1052
+ ):
1053
+ info = client.user.add_ssh_key(
1054
+ name=name, public_key=key_value, expires=expires
1055
+ )
1056
+ if json_mode:
1057
+ click.echo(format_json(info))
1058
+ else:
1059
+ click.secho("SSH public key added.", fg="green")
1060
+ click.echo(f"ID: {info.id}")
1061
+ click.echo(f"Name: {info.name}")
1062
+ click.echo(f"Fingerprint: {info.fingerprint}")
1063
+ except Exception as e:
1064
+ handle_api_error(e)
1065
+
1066
+
1067
+ @user_ssh_key.command(name="edit")
1068
+ @click.argument("ssh_key_id")
1069
+ @click.option("--name", default=None, help="New recognizable device name.")
1070
+ @click.option(
1071
+ "--expires",
1072
+ callback=_parse_ssh_expiry,
1073
+ default=None,
1074
+ help="New expiry as Unix seconds or ISO 8601.",
1075
+ )
1076
+ @click.option(
1077
+ "--clear-expiry",
1078
+ is_flag=True,
1079
+ default=False,
1080
+ help="Remove the key's existing expiry.",
1081
+ )
1082
+ @click.option("--json", "json_mode", is_flag=True, default=False)
1083
+ def user_ssh_key_edit(ssh_key_id, name, expires, clear_expiry, json_mode):
1084
+ """Edit a managed key's name or expiry; public material is immutable."""
1085
+ if name is None and expires is None and not clear_expiry:
1086
+ raise click.ClickException(
1087
+ "Provide at least one of --name, --expires, or --clear-expiry."
1088
+ )
1089
+ if expires is not None and clear_expiry:
1090
+ raise click.ClickException("Use either --expires or --clear-expiry, not both.")
1091
+
1092
+ client = get_client()
1093
+ try:
1094
+ with Spinner(
1095
+ text="Editing SSH public key...",
1096
+ success_text="SSH public key edited",
1097
+ success_emoji="🔐",
1098
+ ):
1099
+ info = client.user.edit_ssh_key(
1100
+ ssh_key_id, name=name, expires=expires, clear_expiry=clear_expiry
1101
+ )
1102
+ if json_mode:
1103
+ click.echo(format_json(info))
1104
+ else:
1105
+ click.secho("SSH public key edited.", fg="green")
1106
+ click.echo(f"ID: {info.id}")
1107
+ click.echo(f"Name: {info.name}")
1108
+ click.echo(f"Expires: {unix_timestamp_to_datetime(info.expires)}")
1109
+ except Exception as e:
1110
+ handle_api_error(e)
1111
+
1112
+
1113
+ @user_ssh_key.command(name="revoke", aliases=["rm"])
1114
+ @click.argument("ssh_key_id")
1115
+ @click.option("--yes", is_flag=True, help="Skip the confirmation prompt.")
1116
+ def user_ssh_key_revoke(ssh_key_id, yes):
1117
+ """Revoke one managed key across all current organization memberships."""
1118
+ if not yes:
1119
+ click.confirm(
1120
+ "Revoke this key for direct SSH across all current organization "
1121
+ "memberships? Browser terminal and instance credentials are separate.",
1122
+ abort=True,
1123
+ )
1124
+ client = get_client()
1125
+ try:
1126
+ with Spinner(
1127
+ text="Revoking SSH public key...",
1128
+ success_text="SSH public key revoked",
1129
+ success_emoji="🗑",
1130
+ ):
1131
+ client.user.revoke_ssh_key(ssh_key_id)
1132
+ click.secho("SSH public key revoked.", fg="green")
1133
+ except Exception as e:
1134
+ handle_api_error(e)
1135
+
1136
+
889
1137
  @user_ssh_key.command(name="set", aliases=["update"])
890
1138
  @click.option(
891
1139
  "--public-key",
@@ -894,6 +1142,10 @@ def user_ssh_key_get(json_mode):
894
1142
  )
895
1143
  @click.option("--json", "json_mode", is_flag=True, default=False)
896
1144
  def user_ssh_key_set(public_key, json_mode):
1145
+ """Set the legacy singular compatibility key.
1146
+
1147
+ Prefer ``add`` for safe overlap rotation with managed keys.
1148
+ """
897
1149
  client = get_client()
898
1150
  try:
899
1151
  with Spinner(
@@ -901,7 +1153,7 @@ def user_ssh_key_set(public_key, json_mode):
901
1153
  success_text="SSH key updated",
902
1154
  success_emoji="🔐",
903
1155
  ):
904
- info = client.user.update_ssh_key(public_key)
1156
+ info = client.user.set_ssh_key(public_key)
905
1157
  if json_mode:
906
1158
  click.echo(format_json(info))
907
1159
  else: