cloudinary-cli 1.15.0__tar.gz → 1.16.0__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 (99) hide show
  1. {cloudinary_cli-1.15.0/cloudinary_cli.egg-info → cloudinary_cli-1.16.0}/PKG-INFO +25 -2
  2. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/README.md +23 -0
  3. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/cloudinary_cli/auth/__init__.py +3 -31
  4. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/cloudinary_cli/auth/session.py +6 -7
  5. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/cloudinary_cli/cli_group.py +3 -1
  6. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/cloudinary_cli/core/__init__.py +2 -0
  7. cloudinary_cli-1.16.0/cloudinary_cli/core/agent.py +203 -0
  8. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/cloudinary_cli/core/auth.py +3 -0
  9. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/cloudinary_cli/core/config.py +8 -5
  10. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/cloudinary_cli/defaults.py +35 -1
  11. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/cloudinary_cli/utils/api_utils.py +14 -2
  12. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/cloudinary_cli/utils/config_listing.py +14 -6
  13. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/cloudinary_cli/utils/config_resolver.py +42 -20
  14. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/cloudinary_cli/utils/config_utils.py +90 -1
  15. cloudinary_cli-1.16.0/cloudinary_cli/utils/url_utils.py +29 -0
  16. cloudinary_cli-1.16.0/cloudinary_cli/version.py +1 -0
  17. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0/cloudinary_cli.egg-info}/PKG-INFO +25 -2
  18. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/cloudinary_cli.egg-info/SOURCES.txt +3 -0
  19. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/cloudinary_cli.egg-info/requires.txt +1 -1
  20. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/requirements.txt +1 -1
  21. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/test/test_auth_session.py +2 -2
  22. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/test/test_cli.py +1 -0
  23. cloudinary_cli-1.16.0/test/test_cli_agent.py +402 -0
  24. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/test/test_cli_config.py +9 -5
  25. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/test/test_cli_config_oauth.py +37 -34
  26. cloudinary_cli-1.16.0/test/test_default_config.py +213 -0
  27. cloudinary_cli-1.15.0/cloudinary_cli/version.py +0 -1
  28. cloudinary_cli-1.15.0/test/test_default_config.py +0 -57
  29. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/LICENSE +0 -0
  30. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/MANIFEST.in +0 -0
  31. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/cloudinary_cli/__init__.py +0 -0
  32. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/cloudinary_cli/auth/callback_page.py +0 -0
  33. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/cloudinary_cli/auth/flow.py +0 -0
  34. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/cloudinary_cli/auth/loopback_server.py +0 -0
  35. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/cloudinary_cli/auth/oauth_config.py +0 -0
  36. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/cloudinary_cli/auth/refresh.py +0 -0
  37. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/cloudinary_cli/cli.py +0 -0
  38. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/cloudinary_cli/core/admin.py +0 -0
  39. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/cloudinary_cli/core/overrides.py +0 -0
  40. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/cloudinary_cli/core/provisioning.py +0 -0
  41. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/cloudinary_cli/core/search.py +0 -0
  42. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/cloudinary_cli/core/uploader.py +0 -0
  43. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/cloudinary_cli/core/utils.py +0 -0
  44. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/cloudinary_cli/modules/__init__.py +0 -0
  45. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/cloudinary_cli/modules/clone.py +0 -0
  46. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/cloudinary_cli/modules/make.py +0 -0
  47. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/cloudinary_cli/modules/migrate.py +0 -0
  48. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/cloudinary_cli/modules/regen_derived.py +0 -0
  49. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/cloudinary_cli/modules/sync.py +0 -0
  50. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/cloudinary_cli/modules/upload_dir.py +0 -0
  51. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/cloudinary_cli/samples/__init__.py +0 -0
  52. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/cloudinary_cli/templates/html/media_library_widget +0 -0
  53. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/cloudinary_cli/templates/html/product_gallery +0 -0
  54. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/cloudinary_cli/templates/html/upload_widget +0 -0
  55. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/cloudinary_cli/templates/html/video_player +0 -0
  56. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/cloudinary_cli/templates/node/upload +0 -0
  57. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/cloudinary_cli/templates/python/base +0 -0
  58. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/cloudinary_cli/templates/python/explicit +0 -0
  59. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/cloudinary_cli/templates/python/find_all_empty_folders +0 -0
  60. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/cloudinary_cli/templates/python/upload +0 -0
  61. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/cloudinary_cli/templates/ruby/upload +0 -0
  62. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/cloudinary_cli/utils/__init__.py +0 -0
  63. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/cloudinary_cli/utils/file_utils.py +0 -0
  64. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/cloudinary_cli/utils/json_utils.py +0 -0
  65. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/cloudinary_cli/utils/search_utils.py +0 -0
  66. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/cloudinary_cli/utils/utils.py +0 -0
  67. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/cloudinary_cli.egg-info/dependency_links.txt +0 -0
  68. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/cloudinary_cli.egg-info/entry_points.txt +0 -0
  69. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/cloudinary_cli.egg-info/not-zip-safe +0 -0
  70. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/cloudinary_cli.egg-info/top_level.txt +0 -0
  71. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/setup.cfg +0 -0
  72. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/setup.py +0 -0
  73. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/test/__init__.py +0 -0
  74. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/test/conftest.py +0 -0
  75. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/test/helper_test.py +0 -0
  76. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/test/oauth_helpers.py +0 -0
  77. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/test/test_auth_flow.py +0 -0
  78. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/test/test_auth_loopback.py +0 -0
  79. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/test/test_auth_region.py +0 -0
  80. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/test/test_cli_api.py +0 -0
  81. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/test/test_cli_samples.py +0 -0
  82. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/test/test_cli_search_api.py +0 -0
  83. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/test/test_cli_url.py +0 -0
  84. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/test/test_cli_utils.py +0 -0
  85. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/test/test_config_cache.py +0 -0
  86. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/test/test_config_concurrency.py +0 -0
  87. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/test/test_config_permissions.py +0 -0
  88. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/test/test_file_utils.py +0 -0
  89. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/test/test_json_utils.py +0 -0
  90. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/test/test_modules/__init__.py +0 -0
  91. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/test/test_modules/test_cli_clone.py +0 -0
  92. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/test/test_modules/test_cli_make.py +0 -0
  93. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/test/test_modules/test_cli_sync.py +0 -0
  94. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/test/test_modules/test_cli_upload_dir.py +0 -0
  95. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/test/test_oauth_multiprocess.py +0 -0
  96. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/test/test_oauth_retry.py +0 -0
  97. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/test/test_oauth_token_seam.py +0 -0
  98. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/test/test_search_utils.py +0 -0
  99. {cloudinary_cli-1.15.0 → cloudinary_cli-1.16.0}/test/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: cloudinary-cli
3
- Version: 1.15.0
3
+ Version: 1.16.0
4
4
  Summary: A command line interface for Cloudinary with full API support
5
5
  Home-page: https://github.com/cloudinary/cloudinary-cli
6
6
  Author: Cloudinary, Brian Luk
@@ -13,7 +13,7 @@ Classifier: Operating System :: OS Independent
13
13
  Requires-Python: >=3.8.0
14
14
  Description-Content-Type: text/markdown
15
15
  License-File: LICENSE
16
- Requires-Dist: cloudinary>=1.44.4
16
+ Requires-Dist: cloudinary>=1.45.0
17
17
  Requires-Dist: pygments
18
18
  Requires-Dist: jinja2
19
19
  Requires-Dist: click
@@ -168,6 +168,7 @@ cld logout # Revokes and removes a saved OAuth login.
168
168
  cld search --help # Shows usage for the Search API.
169
169
  cld admin # Lists Admin API methods.
170
170
  cld uploader # Lists Upload API methods.
171
+ cld agent signup # For AI agents: creates a Cloudinary account on behalf of a human.
171
172
  ```
172
173
 
173
174
  ## Docker Usage
@@ -341,6 +342,28 @@ cld [cli options] migrate [command options] upload_mapping file
341
342
 
342
343
  For details, see the [Cloudinary CLI documentation](https://cloudinary.com/documentation/cloudinary_cli#migrate).
343
344
 
345
+ ### `agent signup`
346
+
347
+ **For AI agents only.** Creates a Free-plan Cloudinary account on behalf of a human. No existing configuration is required to run it. A verification email is sent to the address, and the returned credentials are **inert until the human completes that verification**. The new product environment is saved as a named configuration (named after the cloud) so it is ready to use once activated.
348
+
349
+ ```
350
+ cld agent signup [command options] <email> <agent_framework> <agent_llm_model> <agent_goal>
351
+ ```
352
+
353
+ Example:
354
+
355
+ ```
356
+ cld agent signup you@example.com claude-code claude-fable-5 "test the agent account flow"
357
+ ```
358
+
359
+ Options:
360
+
361
+ * `--name <name>` — name for the saved configuration (default: the returned cloud name).
362
+ * `--set-default` — set the saved configuration as the default.
363
+ * `--no-save` — show the credentials but do not save them as a configuration.
364
+ * `--sdk-framework <name>` — the Cloudinary SDK framework the agent intends to use.
365
+ * `--json` — output the full raw JSON response (the agent contract) instead of the human-readable summary.
366
+
344
367
  ## Additional configurations
345
368
 
346
369
  A configuration is a reference to a specified Cloudinary account or cloud name via its environment variable. You set the default configuration during setup and installation. Using different configurations allows you to access different Cloudinary cloud names, such as sub-accounts of your main Cloudinary account, or any additional Cloudinary accounts you may have.
@@ -142,6 +142,7 @@ cld logout # Revokes and removes a saved OAuth login.
142
142
  cld search --help # Shows usage for the Search API.
143
143
  cld admin # Lists Admin API methods.
144
144
  cld uploader # Lists Upload API methods.
145
+ cld agent signup # For AI agents: creates a Cloudinary account on behalf of a human.
145
146
  ```
146
147
 
147
148
  ## Docker Usage
@@ -315,6 +316,28 @@ cld [cli options] migrate [command options] upload_mapping file
315
316
 
316
317
  For details, see the [Cloudinary CLI documentation](https://cloudinary.com/documentation/cloudinary_cli#migrate).
317
318
 
319
+ ### `agent signup`
320
+
321
+ **For AI agents only.** Creates a Free-plan Cloudinary account on behalf of a human. No existing configuration is required to run it. A verification email is sent to the address, and the returned credentials are **inert until the human completes that verification**. The new product environment is saved as a named configuration (named after the cloud) so it is ready to use once activated.
322
+
323
+ ```
324
+ cld agent signup [command options] <email> <agent_framework> <agent_llm_model> <agent_goal>
325
+ ```
326
+
327
+ Example:
328
+
329
+ ```
330
+ cld agent signup you@example.com claude-code claude-fable-5 "test the agent account flow"
331
+ ```
332
+
333
+ Options:
334
+
335
+ * `--name <name>` — name for the saved configuration (default: the returned cloud name).
336
+ * `--set-default` — set the saved configuration as the default.
337
+ * `--no-save` — show the credentials but do not save them as a configuration.
338
+ * `--sdk-framework <name>` — the Cloudinary SDK framework the agent intends to use.
339
+ * `--json` — output the full raw JSON response (the agent contract) instead of the human-readable summary.
340
+
318
341
  ## Additional configurations
319
342
 
320
343
  A configuration is a reference to a specified Cloudinary account or cloud name via its environment variable. You set the default configuration during setup and installation. Using different configurations allows you to access different Cloudinary cloud names, such as sub-accounts of your main Cloudinary account, or any additional Cloudinary accounts you may have.
@@ -23,13 +23,9 @@ from cloudinary_cli.auth.refresh import (
23
23
  from cloudinary_cli.defaults import logger, normalize_region, DEFAULT_REGION, CLOUDINARY_REGION
24
24
  from cloudinary_cli.utils.config_utils import (
25
25
  load_config,
26
- update_config,
27
26
  remove_config_keys,
28
- user_config_names,
29
- get_default_config_name,
30
- set_default_config,
27
+ save_named_config,
31
28
  is_reserved_config_name,
32
- is_env_configured,
33
29
  )
34
30
  from cloudinary_cli.utils.utils import log_exception, is_interactive
35
31
 
@@ -62,32 +58,8 @@ def login(region=None, name=None, set_default=False):
62
58
  raise RuntimeError("Login token did not include a cloud name; cannot save this login.")
63
59
  config_name = name or _derive_config_name(session.cloud_name, region)
64
60
 
65
- was_default = get_default_config_name() == config_name # before we touch the config
66
- update_config({config_name: to_cloudinary_url(session)})
67
-
68
- if was_default:
69
- return config_name, "already"
70
- if set_default or _should_auto_default(config_name):
71
- set_default_config(config_name)
72
- return config_name, "made"
73
- return config_name, "no"
74
-
75
-
76
- def _should_auto_default(name):
77
- """
78
- True when the just-saved login should become the default without an explicit flag: it is the
79
- only saved config, the environment configures nothing, and no default is already stored.
80
-
81
- A stored default outranks the environment, so auto-defaulting is suppressed when an env config
82
- is present: a single `cld login` must not silently override a user's CLOUDINARY_URL. They can
83
- still opt in with `--set-default`.
84
- """
85
- cfg = load_config()
86
- return (
87
- user_config_names(cfg) == [name]
88
- and not is_env_configured()
89
- and not get_default_config_name()
90
- )
61
+ default_status = save_named_config(config_name, to_cloudinary_url(session), set_default=set_default)
62
+ return config_name, default_status
91
63
 
92
64
 
93
65
  def logout(name):
@@ -4,13 +4,14 @@ dataclass is the in-memory form, `to_cloudinary_url`/`from_cloudinary_url` the p
4
4
  import base64
5
5
  import json
6
6
  import time
7
- import urllib.parse
8
7
  from dataclasses import dataclass
9
8
 
10
9
  from cloudinary_cli.defaults import (
11
10
  OAUTH_EXPIRY_SKEW_SECONDS,
12
11
  api_host_for_region,
13
12
  )
13
+ from cloudinary_cli.utils.url_utils import url_params, url_host
14
+ from cloudinary_cli.utils.config_utils import build_config_url
14
15
 
15
16
  # Query-string keys that carry the OAuth session inside a cloudinary:// URL.
16
17
  _OAUTH_MARKER = "oauth_token"
@@ -71,15 +72,14 @@ def to_cloudinary_url(session):
71
72
  "issuer": session.issuer or "",
72
73
  "upload_prefix": api_host_for_region(session.region),
73
74
  }
74
- return f"cloudinary://{session.cloud_name}?{urllib.parse.urlencode(params)}"
75
+ return build_config_url(session.cloud_name, params)
75
76
 
76
77
 
77
78
  def from_cloudinary_url(url):
78
79
  """Parse an OAuth cloudinary:// URL back into a Session."""
79
- parsed = urllib.parse.urlparse(url)
80
- q = {k: v[0] for k, v in urllib.parse.parse_qs(parsed.query).items()}
80
+ q = url_params(url)
81
81
  return Session(
82
- cloud_name=parsed.hostname,
82
+ cloud_name=url_host(url),
83
83
  access_token=q.get("oauth_token"),
84
84
  refresh_token=q.get("refresh_token") or None,
85
85
  issued_at=int(q.get("issued_at", 0) or 0),
@@ -92,8 +92,7 @@ def from_cloudinary_url(url):
92
92
  def is_oauth_url(url):
93
93
  if not isinstance(url, str):
94
94
  return False
95
- query = urllib.parse.urlparse(url).query
96
- return _OAUTH_MARKER in urllib.parse.parse_qs(query)
95
+ return _OAUTH_MARKER in url_params(url)
97
96
 
98
97
 
99
98
  def _decode_jwt_payload(access_token):
@@ -28,7 +28,9 @@ CONTEXT_SETTINGS = dict(max_content_width=shutil.get_terminal_size()[0], termina
28
28
  @click_log.simple_verbosity_option(logger)
29
29
  @click.pass_context
30
30
  def cli(ctx, config, config_saved):
31
- resolve_cli_config(config, config_saved)
31
+ subcommand = cli.get_command(ctx, ctx.invoked_subcommand) if ctx.invoked_subcommand else None
32
+ warn_if_unconfigured = not getattr(subcommand, "config_optional", False)
33
+ resolve_cli_config(config, config_saved, warn_if_unconfigured=warn_if_unconfigured)
32
34
 
33
35
  if ctx.invoked_subcommand is None:
34
36
  click.echo(ctx.get_help())
@@ -1,6 +1,7 @@
1
1
  import click
2
2
 
3
3
  from cloudinary_cli.core.admin import admin
4
+ from cloudinary_cli.core.agent import agent_group
4
5
  from cloudinary_cli.core.auth import login, logout
5
6
  from cloudinary_cli.core.config import config_command
6
7
  from cloudinary_cli.core.search import search, search_folders
@@ -13,6 +14,7 @@ setattr(click.Group, "resolve_command", resolve_command)
13
14
 
14
15
  commands = [
15
16
  config_command,
17
+ agent_group,
16
18
  login,
17
19
  logout,
18
20
  search,
@@ -0,0 +1,203 @@
1
+ import json
2
+ import re
3
+
4
+ import cloudinary.provisioning
5
+ from click import group, argument, option, echo, style, BadParameter, ClickException
6
+ from cloudinary.exceptions import Error as CloudinaryError, RateLimited
7
+
8
+ from cloudinary_cli.defaults import logger, ACCOUNT_EMAIL_PARAM
9
+ from cloudinary_cli.utils.api_utils import call_api
10
+ from cloudinary_cli.utils.json_utils import print_json
11
+ from cloudinary_cli.utils.config_utils import (
12
+ save_named_config,
13
+ is_reserved_config_name,
14
+ config_name_for_email,
15
+ build_config_url,
16
+ user_config_names,
17
+ config_optional,
18
+ )
19
+
20
+
21
+ @config_optional
22
+ @group("agent", help="Commands for AI agents acting on behalf of a human.")
23
+ def agent_group():
24
+ pass
25
+
26
+
27
+ @agent_group.command("signup",
28
+ short_help="Create a Cloudinary account on behalf of a human (for AI agents only).",
29
+ help="""\b
30
+ For AI agents only: create a Free-plan Cloudinary account on behalf of a human.
31
+ A verification email is sent to the address; the credentials are inert until the human verifies it.
32
+ If you are a human, or the human prefers to sign up themselves, use https://cloudinary.com/users/register_free instead.
33
+ The returned product environment is saved as a named configuration (use --no-save to skip).
34
+ Format: cld agent signup <email> <agent_framework> <agent_llm_model> <agent_goal>
35
+ \te.g. cld agent signup you@example.com claude-code claude-fable-5 "test the agent account flow"
36
+ """)
37
+ @argument("email")
38
+ @argument("agent_framework")
39
+ @argument("agent_llm_model")
40
+ @argument("agent_goal")
41
+ @option("--sdk-framework", "sdk_framework", help="The Cloudinary SDK framework the agent intends to use.")
42
+ @option("--name", help="Name for the saved configuration (default: the returned cloud name).")
43
+ @option("--set-default", "set_default", is_flag=True, help="Set the saved configuration as the default.")
44
+ @option("--no-save", "no_save", is_flag=True, help="Do not save the returned credentials as a configuration.")
45
+ @option("--json", "as_json", is_flag=True,
46
+ help="Output the full raw JSON response (agent contract) instead of the human summary.")
47
+ def signup(email, agent_framework, agent_llm_model, agent_goal, sdk_framework, name, set_default, no_save,
48
+ as_json):
49
+ if name and is_reserved_config_name(name):
50
+ raise BadParameter(f"'{name}' is a reserved configuration name.")
51
+ if not email or not email.strip():
52
+ raise BadParameter("email must not be empty.")
53
+
54
+ existing = config_name_for_email(email)
55
+ if existing:
56
+ raise ClickException(_already_have_config_message(email, existing))
57
+
58
+ try:
59
+ result = call_api(cloudinary.provisioning.create_agent_account, email, agent_framework,
60
+ agent_llm_model, agent_goal, sdk_framework=sdk_framework)
61
+ except RateLimited as e:
62
+ raise ClickException(
63
+ f"Rate limited while creating the account: {e}. This endpoint is limited per IP address; "
64
+ f"wait a bit and try again.")
65
+ except CloudinaryError as e:
66
+ raise ClickException(_signup_error_message(email, e))
67
+
68
+ # Show the freshly-minted credentials BEFORE saving, so a save failure can never lose them.
69
+ if as_json:
70
+ print_json(result)
71
+ else:
72
+ _print_signup_summary(result)
73
+
74
+ if not no_save:
75
+ save_agent_config(result, email, name=name, set_default=set_default)
76
+
77
+ logger.info("Note: the account's credentials are inert until the emailed verification is completed.")
78
+
79
+
80
+ def _already_have_config_message(email, name):
81
+ return (f"You already signed up with {email} (saved as '{name}'). "
82
+ f"Use it with `cld -C {name} <command>`. "
83
+ f"If it's not activated yet, complete the verification email; or run `cld login` if you use OAuth.")
84
+
85
+
86
+ def _account_exists_message(email):
87
+ return (f"An account already exists for {email}, but no configuration is saved on this machine. "
88
+ f"If it's already verified, add its CLOUDINARY_URL with `cld config -n <name> <url>` "
89
+ f"(or `cld login` if you use OAuth). If you just created it, complete the verification email first.")
90
+
91
+
92
+ def _signup_error_message(email, error):
93
+ text = str(error)
94
+ if "has already been taken" in text or "409" in text:
95
+ return _account_exists_message(email)
96
+
97
+ detail = _parse_error_detail(text)
98
+ return f"Signup failed: {detail}." if detail else f"Signup failed: {text}."
99
+
100
+
101
+ def _parse_error_detail(text):
102
+ """Best-effort human message from a provisioning error string like
103
+ 'Error 400 - {"email":["is invalid"]}' or '... {"error":{"message":"boom"}}'. Returns None on any
104
+ failure so the caller falls back to the raw text."""
105
+ match = re.search(r"\{.*\}", text)
106
+ if not match:
107
+ return None
108
+ try:
109
+ payload = json.loads(match.group(0))
110
+ except ValueError:
111
+ return None
112
+ if not isinstance(payload, dict) or not payload:
113
+ return None
114
+
115
+ error = payload.get("error")
116
+ if isinstance(error, dict) and error.get("message"):
117
+ return str(error["message"])
118
+
119
+ parts = []
120
+ for field, msgs in payload.items():
121
+ msgs = msgs if isinstance(msgs, list) else [msgs]
122
+ parts.append(f"{field} {', '.join(str(m) for m in msgs)}")
123
+ return "; ".join(parts) or None
124
+
125
+
126
+ # Top-level and product-environment response keys the summary renders explicitly (or deliberately
127
+ # omits, e.g. secrets folded into CLOUDINARY_URL). Any key NOT listed here is surfaced generically
128
+ # by _extra_rows so future fields the server adds are never silently dropped.
129
+ _KNOWN_TOP_LEVEL_KEYS = {"email", "plan_name", "product_environments", "guidance"}
130
+ _KNOWN_ENV_KEYS = {"cloud_name", "api_key", "api_secret", "api_environment_variable", "external_id"}
131
+
132
+
133
+ def _print_signup_summary(result):
134
+ environment = (result.get("product_environments") or [{}])[0]
135
+ rows = [
136
+ ("Email", result.get("email", "")),
137
+ ("Plan", result.get("plan_name", "")),
138
+ ("Cloud name", environment.get("cloud_name", "")),
139
+ ("API key", environment.get("api_key", "")),
140
+ ("CLOUDINARY_URL", _config_url_from_environment(environment)),
141
+ ]
142
+ rows += _extra_rows(result, _KNOWN_TOP_LEVEL_KEYS)
143
+ rows += _extra_rows(environment, _KNOWN_ENV_KEYS)
144
+ rows = [(label, value) for label, value in rows if value]
145
+
146
+ echo(style("Cloudinary account created.", fg="green"))
147
+ if rows:
148
+ width = max(len(label) for label, _ in rows) + 1
149
+ template = "{0:" + str(width) + "} {1}"
150
+ echo("\n".join(template.format(f"{label}:", value) for label, value in rows))
151
+
152
+ guidance = result.get("guidance")
153
+ if guidance:
154
+ echo(f"\n{guidance}")
155
+
156
+
157
+ def _extra_rows(data, known_keys):
158
+ """(label, value) rows for scalar keys not already rendered, so response fields the server adds
159
+ in the future surface instead of being dropped. Skips nested dict/list values (shown elsewhere
160
+ or via --json) and empties."""
161
+ rows = []
162
+ for key, value in data.items():
163
+ if key in known_keys or isinstance(value, (dict, list)) or value in (None, ""):
164
+ continue
165
+ rows.append((key.replace("_", " ").capitalize(), value))
166
+ return rows
167
+
168
+
169
+ def save_agent_config(result, email, name=None, set_default=False):
170
+ environment = (result.get("product_environments") or [{}])[0]
171
+ config_name = name or environment.get("cloud_name")
172
+ stored_url = _config_url_from_environment(environment, email=email)
173
+ if not stored_url or not config_name:
174
+ logger.warning("Could not save the configuration automatically (missing credentials in the response). "
175
+ "Add it manually with `cld config -n <name> <CLOUDINARY_URL>`.")
176
+ return
177
+
178
+ if name and name in user_config_names():
179
+ logger.warning(f"Overwriting existing config '{name}'.")
180
+
181
+ try:
182
+ default_status = save_named_config(config_name, stored_url, set_default=set_default)
183
+ except Exception as e:
184
+ logger.warning(f"Could not save the configuration '{config_name}': {e}. "
185
+ f"Add it manually with `cld config -n {config_name} {_config_url_from_environment(environment)}`.")
186
+ return
187
+
188
+ logger.info(f"Config '{config_name}' saved!")
189
+ logger.info(f"Example usage: cld -C {config_name} <command>")
190
+ if default_status == "made":
191
+ logger.info(f"Default set to '{config_name}'. Run `cld <command>` to use it, "
192
+ f"or `cld -C {config_name} <command>` to select it explicitly.")
193
+
194
+
195
+ def _config_url_from_environment(environment, email=None):
196
+ """Build a validated cloudinary:// config URL from a product-environment's credential fields,
197
+ optionally carrying the account email. Returns "" when the response lacks the credentials."""
198
+ params = {ACCOUNT_EMAIL_PARAM: email.strip().lower()} if email and email.strip() else None
199
+ try:
200
+ return build_config_url(environment["cloud_name"], params=params,
201
+ api_key=environment["api_key"], api_secret=environment["api_secret"])
202
+ except (KeyError, ValueError):
203
+ return ""
@@ -2,9 +2,11 @@ from click import command, argument, option, echo
2
2
 
3
3
  from cloudinary_cli.auth import login as run_login, logout as run_logout, list_oauth_login_names
4
4
  from cloudinary_cli.defaults import logger
5
+ from cloudinary_cli.utils.config_utils import config_optional
5
6
  from cloudinary_cli.utils.utils import log_exception, prompt_user
6
7
 
7
8
 
9
+ @config_optional
8
10
  @command("login", help="Log in to Cloudinary via OAuth (opens a browser). The session is saved "
9
11
  "as a named configuration you can select with `-C`.")
10
12
  @argument("name", required=False)
@@ -34,6 +36,7 @@ def login(name, region, set_default):
34
36
  return True
35
37
 
36
38
 
39
+ @config_optional
37
40
  @command("logout", help="Log out: revoke a saved OAuth login's token and remove its configuration. "
38
41
  "Run without a name to choose from the saved logins.")
39
42
  @argument("name", required=False)
@@ -1,11 +1,11 @@
1
1
  import cloudinary
2
2
  from click import command, option, echo, BadParameter, UsageError
3
3
 
4
- from cloudinary_cli.defaults import logger, DEFAULT_CONFIG_KEY
4
+ from cloudinary_cli.defaults import logger, DEFAULT_CONFIG_KEY, NO_CONFIG_MESSAGE
5
5
  from cloudinary_cli.utils.config_utils import (
6
6
  load_config,
7
7
  verify_cloudinary_url,
8
- update_config,
8
+ save_named_config,
9
9
  remove_config_keys,
10
10
  show_cloudinary_config,
11
11
  is_valid_cloudinary_config,
@@ -15,6 +15,7 @@ from cloudinary_cli.utils.config_utils import (
15
15
  clear_default_config,
16
16
  is_reserved_config_name,
17
17
  config_type,
18
+ config_optional,
18
19
  )
19
20
  from cloudinary_cli.utils.utils import ConfigurationError
20
21
  from cloudinary_cli.utils.json_utils import print_json
@@ -30,6 +31,7 @@ from cloudinary_cli.utils.config_listing import (
30
31
  )
31
32
 
32
33
 
34
+ @config_optional
33
35
  @command("config", help="Display the current configuration, and manage additional configurations.")
34
36
  @option("-n", "--new", help="""\b Create and name a configuration from a Cloudinary account environment variable.
35
37
  e.g. cld config -n <NAME> <CLOUDINARY_URL>""", nargs=2)
@@ -80,13 +82,12 @@ def config_command(new, ls, as_json, show, rm, from_url, default, set_default, u
80
82
 
81
83
  config_name = config_name or cloudinary.config().cloud_name
82
84
 
83
- update_config({config_name: cloudinary_url})
85
+ default_status = save_named_config(config_name, cloudinary_url, set_default=set_default)
84
86
 
85
87
  logger.info("Config '{}' saved!".format(config_name))
86
88
  logger.info("Example usage: cld -C {} <command>".format(config_name))
87
89
 
88
- if set_default:
89
- set_default_config(config_name)
90
+ if default_status == "made":
90
91
  logger.info(f"Default set to '{config_name}'. Run `cld <command>` to use it, "
91
92
  f"or `cld -C {config_name} <command>` to select it explicitly.")
92
93
  elif default:
@@ -110,6 +111,8 @@ def config_command(new, ls, as_json, show, rm, from_url, default, set_default, u
110
111
  rows = list_configs()
111
112
  if as_json:
112
113
  print_json(rows)
114
+ elif not rows:
115
+ echo(NO_CONFIG_MESSAGE)
113
116
  else:
114
117
  echo(render_config_table(rows))
115
118
  elif show:
@@ -28,6 +28,40 @@ CLOUDINARY_CLI_CONFIG_FILE = abspath(path_join(CLOUDINARY_HOME, 'config.json'))
28
28
  # names are rejected as user config names, so this can't collide with a saved config.
29
29
  DEFAULT_CONFIG_KEY = "__default__"
30
30
 
31
+ # Query param carried inside a saved cloudinary:// URL recording the email the account was created
32
+ # for (via `cld agent signup`). Stripped before display and before reaching the SDK.
33
+ ACCOUNT_EMAIL_PARAM = "account_email"
34
+
35
+ # Guidance shown when no configuration is available (the group callback for account-consuming
36
+ # commands, and the empty `config -ls`). Printed verbatim to stderr, without the logger's
37
+ # "warning:" prefix, so the copy-pasteable command lines stay clean.
38
+ NO_CONFIG_MESSAGE = (
39
+ "No Cloudinary configuration found.\n"
40
+ " - Log in with OAuth: cld login\n"
41
+ " - Add an API-key config: cld config -n <name> "
42
+ "cloudinary://<api_key>:<api_secret>@<cloud_name> --set-default\n"
43
+ " - Set an existing config\n"
44
+ " as the default: cld config -d <name>\n"
45
+ " - AI agents only, create\n"
46
+ " an account for a human: cld agent signup <email> <framework> <model> <goal>"
47
+ )
48
+
49
+ # Shown when saved configs exist but none is active (no default set, no environment config, and no
50
+ # -c/-C on the command line). The account is there; the CLI just doesn't know which one to use.
51
+ NO_DEFAULT_CONFIG_MESSAGE = (
52
+ "No default Cloudinary configuration is set. Select one per command with `-C <name>`, "
53
+ "or set a default with `cld config -d <name>`.\n"
54
+ "List your saved configurations with `cld config -ls`."
55
+ )
56
+
57
+ # Shown when an explicitly selected config (-c URL or -C saved name) has a cloud name but no
58
+ # credentials (api_key/api_secret or an OAuth token). The user picked a config on purpose, so the
59
+ # generic "no config found" guidance would be misleading; the config is just incomplete.
60
+ INCOMPLETE_CONFIG_MESSAGE = (
61
+ "The selected configuration is incomplete: it has a cloud name but no credentials "
62
+ "(api_key/api_secret or an OAuth token). Operations that need authentication will fail."
63
+ )
64
+
31
65
  # OAuth configuration for `cld login`. The region string derives both the API and
32
66
  # OAuth hosts; an unknown region simply fails to resolve.
33
67
  DEFAULT_REGION = 'api'
@@ -82,7 +116,7 @@ OAUTH_REDIRECT_PORT = int(os.environ.get('CLOUDINARY_OAUTH_REDIRECT_PORT', OAUTH
82
116
  OAUTH_CALLBACK_PATH = '/callback'
83
117
 
84
118
  OAUTH_CALLBACK_TIMEOUT_SECONDS = 300
85
- OAUTH_EXPIRY_SKEW_SECONDS = 280
119
+ OAUTH_EXPIRY_SKEW_SECONDS = 30
86
120
  OAUTH_HTTP_TIMEOUT_SECONDS = 30
87
121
 
88
122
  TEMPLATE_FOLDER_NAME = 'templates'
@@ -9,7 +9,7 @@ from cloudinary.exceptions import AuthorizationRequired
9
9
  from cloudinary.utils import cloudinary_url
10
10
 
11
11
  from cloudinary_cli.defaults import logger
12
- from cloudinary_cli.utils.config_utils import is_valid_cloudinary_config
12
+ from cloudinary_cli.utils.config_utils import is_valid_cloudinary_config, user_config_names
13
13
  from cloudinary_cli.utils.file_utils import (normalize_file_extension, posix_rel_path, get_destination_folder,
14
14
  populate_duplicate_name)
15
15
  from cloudinary_cli.utils.json_utils import print_json, write_json_to_file
@@ -35,6 +35,15 @@ DESTRUCTIVE_BULK_API_METHODS = {
35
35
  # (NConfig.max_resource_count_for_delete). Used purely for clearer prompt wording.
36
36
  MAX_DESTRUCTIVE_BULK_PER_CALL = 1000
37
37
 
38
+ # Public, unauthenticated API methods that must run without a Cloudinary configuration.
39
+ PUBLIC_API_METHODS = {
40
+ "provisioning": {"create_agent_account"},
41
+ }
42
+
43
+
44
+ def is_public_api_method(api_name, method_name):
45
+ return method_name in PUBLIC_API_METHODS.get(api_name, set())
46
+
38
47
 
39
48
  def is_destructive_bulk_api_method(method_name):
40
49
  return method_name in DESTRUCTIVE_BULK_API_METHODS
@@ -368,7 +377,10 @@ def handle_api_command(
368
377
  if not confirm_destructive_bulk_api_method(api_name, func.__name__, force):
369
378
  return False
370
379
 
371
- if not is_valid_cloudinary_config():
380
+ if not is_public_api_method(api_name, func.__name__) and not is_valid_cloudinary_config():
381
+ if user_config_names():
382
+ raise ConfigurationError("No default Cloudinary configuration is set. "
383
+ "Select one with `-C <name>` or set a default with `cld config -d <name>`.")
372
384
  raise ConfigurationError("No Cloudinary configuration found.")
373
385
 
374
386
  try:
@@ -12,6 +12,7 @@ from cloudinary_cli.utils.config_utils import (
12
12
  config_type,
13
13
  cloudinary_config_details,
14
14
  is_env_configured,
15
+ email_from_url,
15
16
  )
16
17
  from cloudinary_cli.utils.config_resolver import (
17
18
  active_config_name,
@@ -31,6 +32,8 @@ def config_type_label(config_obj):
31
32
 
32
33
  _TABLE_COLUMNS = [("name", "NAME"), ("cloud_name", "CLOUD"), ("type", "TYPE"),
33
34
  ("default", "DEFAULT"), ("active", "ACTIVE")]
35
+ # EMAIL is appended dynamically (see render_config_table) only when at least one row carries one.
36
+ _EMAIL_COLUMN = ("email", "EMAIL")
34
37
 
35
38
 
36
39
  def list_configs():
@@ -45,8 +48,8 @@ def list_configs():
45
48
  rows.append(_url_row()) # an inline -c URL: not a saved config, but it is what's active now
46
49
  if is_env_configured():
47
50
  rows.append(_env_row(env_active=active_config_is_env()))
48
- rows += [
49
- {
51
+ for name in user_config_names(cfg):
52
+ row = {
50
53
  "name": name,
51
54
  "cloud_name": cloud_name_from_url(cfg[name]),
52
55
  "type": config_type(cfg[name]),
@@ -54,8 +57,10 @@ def list_configs():
54
57
  "default": name == default,
55
58
  "active": name == active_name,
56
59
  }
57
- for name in user_config_names(cfg)
58
- ]
60
+ email = email_from_url(cfg[name])
61
+ if email: # only surfaced when the config records an account email (e.g. from `agent signup`)
62
+ row["email"] = email
63
+ rows.append(row)
59
64
  return rows
60
65
 
61
66
 
@@ -88,8 +93,11 @@ def active_config_meta(config_obj):
88
93
 
89
94
 
90
95
  def render_config_table(rows):
91
- headers = [title for _, title in _TABLE_COLUMNS]
92
- cells = [[_cell(row, key) for key, _ in _TABLE_COLUMNS] for row in rows]
96
+ columns = list(_TABLE_COLUMNS)
97
+ if any(row.get("email") for row in rows): # add EMAIL only when some config records one
98
+ columns.append(_EMAIL_COLUMN)
99
+ headers = [title for _, title in columns]
100
+ cells = [[_cell(row, key) for key, _ in columns] for row in rows]
93
101
  widths = [max(len(headers[i]), *(len(r[i]) for r in cells)) if cells else len(headers[i])
94
102
  for i in range(len(headers))]
95
103
  line = lambda values: " ".join(v.ljust(widths[i]) for i, v in enumerate(values)).rstrip()