Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ cld logout # Revokes and removes a saved OAuth login.
cld search --help # Shows usage for the Search API.
cld admin # Lists Admin API methods.
cld uploader # Lists Upload API methods.
cld agent signup # For AI agents: creates a Cloudinary account on behalf of a human.
```

## Docker Usage
Expand Down Expand Up @@ -315,6 +316,28 @@ cld [cli options] migrate [command options] upload_mapping file

For details, see the [Cloudinary CLI documentation](https://cloudinary.com/documentation/cloudinary_cli#migrate).

### `agent signup`

**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.

```
cld agent signup [command options] <email> <agent_framework> <agent_llm_model> <agent_goal>
```

Example:

```
cld agent signup you@example.com claude-code claude-fable-5 "test the agent account flow"
```

Options:

* `--name <name>` — name for the saved configuration (default: the returned cloud name).
* `--set-default` — set the saved configuration as the default.
* `--no-save` — show the credentials but do not save them as a configuration.
* `--sdk-framework <name>` — the Cloudinary SDK framework the agent intends to use.
* `--json` — output the full raw JSON response (the agent contract) instead of the human-readable summary.

## Additional configurations

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.
Expand Down
34 changes: 3 additions & 31 deletions cloudinary_cli/auth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,9 @@
from cloudinary_cli.defaults import logger, normalize_region, DEFAULT_REGION, CLOUDINARY_REGION
from cloudinary_cli.utils.config_utils import (
load_config,
update_config,
remove_config_keys,
user_config_names,
get_default_config_name,
set_default_config,
save_named_config,
is_reserved_config_name,
is_env_configured,
)
from cloudinary_cli.utils.utils import log_exception, is_interactive

Expand Down Expand Up @@ -62,32 +58,8 @@ def login(region=None, name=None, set_default=False):
raise RuntimeError("Login token did not include a cloud name; cannot save this login.")
config_name = name or _derive_config_name(session.cloud_name, region)

was_default = get_default_config_name() == config_name # before we touch the config
update_config({config_name: to_cloudinary_url(session)})

if was_default:
return config_name, "already"
if set_default or _should_auto_default(config_name):
set_default_config(config_name)
return config_name, "made"
return config_name, "no"


def _should_auto_default(name):
"""
True when the just-saved login should become the default without an explicit flag: it is the
only saved config, the environment configures nothing, and no default is already stored.

A stored default outranks the environment, so auto-defaulting is suppressed when an env config
is present: a single `cld login` must not silently override a user's CLOUDINARY_URL. They can
still opt in with `--set-default`.
"""
cfg = load_config()
return (
user_config_names(cfg) == [name]
and not is_env_configured()
and not get_default_config_name()
)
default_status = save_named_config(config_name, to_cloudinary_url(session), set_default=set_default)
return config_name, default_status


def logout(name):
Expand Down
13 changes: 6 additions & 7 deletions cloudinary_cli/auth/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@
import base64
import json
import time
import urllib.parse
from dataclasses import dataclass

from cloudinary_cli.defaults import (
OAUTH_EXPIRY_SKEW_SECONDS,
api_host_for_region,
)
from cloudinary_cli.utils.url_utils import url_params, url_host
from cloudinary_cli.utils.config_utils import build_config_url

# Query-string keys that carry the OAuth session inside a cloudinary:// URL.
_OAUTH_MARKER = "oauth_token"
Expand Down Expand Up @@ -71,15 +72,14 @@ def to_cloudinary_url(session):
"issuer": session.issuer or "",
"upload_prefix": api_host_for_region(session.region),
}
return f"cloudinary://{session.cloud_name}?{urllib.parse.urlencode(params)}"
return build_config_url(session.cloud_name, params)


def from_cloudinary_url(url):
"""Parse an OAuth cloudinary:// URL back into a Session."""
parsed = urllib.parse.urlparse(url)
q = {k: v[0] for k, v in urllib.parse.parse_qs(parsed.query).items()}
q = url_params(url)
return Session(
cloud_name=parsed.hostname,
cloud_name=url_host(url),
access_token=q.get("oauth_token"),
refresh_token=q.get("refresh_token") or None,
issued_at=int(q.get("issued_at", 0) or 0),
Expand All @@ -92,8 +92,7 @@ def from_cloudinary_url(url):
def is_oauth_url(url):
if not isinstance(url, str):
return False
query = urllib.parse.urlparse(url).query
return _OAUTH_MARKER in urllib.parse.parse_qs(query)
return _OAUTH_MARKER in url_params(url)


def _decode_jwt_payload(access_token):
Expand Down
4 changes: 3 additions & 1 deletion cloudinary_cli/cli_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@
@click_log.simple_verbosity_option(logger)
@click.pass_context
def cli(ctx, config, config_saved):
resolve_cli_config(config, config_saved)
subcommand = cli.get_command(ctx, ctx.invoked_subcommand) if ctx.invoked_subcommand else None
warn_if_unconfigured = not getattr(subcommand, "config_optional", False)
resolve_cli_config(config, config_saved, warn_if_unconfigured=warn_if_unconfigured)

if ctx.invoked_subcommand is None:
click.echo(ctx.get_help())
Expand Down
2 changes: 2 additions & 0 deletions cloudinary_cli/core/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import click

from cloudinary_cli.core.admin import admin
from cloudinary_cli.core.agent import agent_group
from cloudinary_cli.core.auth import login, logout
from cloudinary_cli.core.config import config_command
from cloudinary_cli.core.search import search, search_folders
Expand All @@ -13,6 +14,7 @@

commands = [
config_command,
agent_group,
login,
logout,
search,
Expand Down
202 changes: 202 additions & 0 deletions cloudinary_cli/core/agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import json
import re

import cloudinary.provisioning
from click import group, argument, option, echo, style, BadParameter, ClickException
from cloudinary.exceptions import Error as CloudinaryError, RateLimited

from cloudinary_cli.defaults import logger, ACCOUNT_EMAIL_PARAM
from cloudinary_cli.utils.api_utils import call_api
from cloudinary_cli.utils.json_utils import print_json
from cloudinary_cli.utils.config_utils import (
save_named_config,
is_reserved_config_name,
config_name_for_email,
build_config_url,
user_config_names,
config_optional,
)


@config_optional
@group("agent", help="Commands for AI agents acting on behalf of a human.")
def agent_group():
pass


@agent_group.command("signup",
short_help="Create a Cloudinary account on behalf of a human (for AI agents only).",
help="""\b
For AI agents only: create a Free-plan Cloudinary account on behalf of a human.
A verification email is sent to the address; the credentials are inert until the human verifies it.
The returned product environment is saved as a named configuration (use --no-save to skip).
Format: cld agent signup <email> <agent_framework> <agent_llm_model> <agent_goal>
\te.g. cld agent signup you@example.com claude-code claude-fable-5 "test the agent account flow"
""")
@argument("email")
@argument("agent_framework")
@argument("agent_llm_model")
@argument("agent_goal")
@option("--sdk-framework", "sdk_framework", help="The Cloudinary SDK framework the agent intends to use.")
@option("--name", help="Name for the saved configuration (default: the returned cloud name).")
@option("--set-default", "set_default", is_flag=True, help="Set the saved configuration as the default.")
@option("--no-save", "no_save", is_flag=True, help="Do not save the returned credentials as a configuration.")
@option("--json", "as_json", is_flag=True,
help="Output the full raw JSON response (agent contract) instead of the human summary.")
def signup(email, agent_framework, agent_llm_model, agent_goal, sdk_framework, name, set_default, no_save,
as_json):
if name and is_reserved_config_name(name):
raise BadParameter(f"'{name}' is a reserved configuration name.")
if not email or not email.strip():
raise BadParameter("email must not be empty.")

existing = config_name_for_email(email)
if existing:
raise ClickException(_already_have_config_message(email, existing))

try:
result = call_api(cloudinary.provisioning.create_agent_account, email, agent_framework,
agent_llm_model, agent_goal, sdk_framework=sdk_framework)
except RateLimited as e:
raise ClickException(
f"Rate limited while creating the account: {e}. This endpoint is limited per IP address; "
f"wait a bit and try again.")
except CloudinaryError as e:
raise ClickException(_signup_error_message(email, e))

# Show the freshly-minted credentials BEFORE saving, so a save failure can never lose them.
if as_json:
print_json(result)
else:
_print_signup_summary(result)

if not no_save:
save_agent_config(result, email, name=name, set_default=set_default)

logger.info("Note: the account's credentials are inert until the emailed verification is completed.")


def _already_have_config_message(email, name):
return (f"You already signed up with {email} (saved as '{name}'). "
f"Use it with `cld -C {name} <command>`. "
f"If it's not activated yet, complete the verification email; or run `cld login` if you use OAuth.")


def _account_exists_message(email):
return (f"An account already exists for {email}, but no configuration is saved on this machine. "
f"If it's already verified, add its CLOUDINARY_URL with `cld config -n <name> <url>` "
f"(or `cld login` if you use OAuth). If you just created it, complete the verification email first.")


def _signup_error_message(email, error):
text = str(error)
if "has already been taken" in text or "409" in text:
return _account_exists_message(email)

detail = _parse_error_detail(text)
return f"Signup failed: {detail}." if detail else f"Signup failed: {text}."


def _parse_error_detail(text):
"""Best-effort human message from a provisioning error string like
'Error 400 - {"email":["is invalid"]}' or '... {"error":{"message":"boom"}}'. Returns None on any
failure so the caller falls back to the raw text."""
match = re.search(r"\{.*\}", text)
if not match:
return None
try:
payload = json.loads(match.group(0))
except ValueError:
return None
if not isinstance(payload, dict) or not payload:
return None

error = payload.get("error")
if isinstance(error, dict) and error.get("message"):
return str(error["message"])

parts = []
for field, msgs in payload.items():
msgs = msgs if isinstance(msgs, list) else [msgs]
parts.append(f"{field} {', '.join(str(m) for m in msgs)}")
return "; ".join(parts) or None


# Top-level and product-environment response keys the summary renders explicitly (or deliberately
# omits, e.g. secrets folded into CLOUDINARY_URL). Any key NOT listed here is surfaced generically
# by _extra_rows so future fields the server adds are never silently dropped.
_KNOWN_TOP_LEVEL_KEYS = {"email", "plan_name", "product_environments", "guidance"}
_KNOWN_ENV_KEYS = {"cloud_name", "api_key", "api_secret", "api_environment_variable", "external_id"}


def _print_signup_summary(result):
environment = (result.get("product_environments") or [{}])[0]
rows = [
("Email", result.get("email", "")),
("Plan", result.get("plan_name", "")),
("Cloud name", environment.get("cloud_name", "")),
("API key", environment.get("api_key", "")),
("CLOUDINARY_URL", _config_url_from_environment(environment)),
]
rows += _extra_rows(result, _KNOWN_TOP_LEVEL_KEYS)
rows += _extra_rows(environment, _KNOWN_ENV_KEYS)
rows = [(label, value) for label, value in rows if value]

echo(style("Cloudinary account created.", fg="green"))
if rows:
width = max(len(label) for label, _ in rows) + 1
template = "{0:" + str(width) + "} {1}"
echo("\n".join(template.format(f"{label}:", value) for label, value in rows))

guidance = result.get("guidance")
if guidance:
echo(f"\n{guidance}")


def _extra_rows(data, known_keys):
"""(label, value) rows for scalar keys not already rendered, so response fields the server adds
in the future surface instead of being dropped. Skips nested dict/list values (shown elsewhere
or via --json) and empties."""
rows = []
for key, value in data.items():
if key in known_keys or isinstance(value, (dict, list)) or value in (None, ""):
continue
rows.append((key.replace("_", " ").capitalize(), value))
return rows


def save_agent_config(result, email, name=None, set_default=False):
environment = (result.get("product_environments") or [{}])[0]
config_name = name or environment.get("cloud_name")
stored_url = _config_url_from_environment(environment, email=email)
if not stored_url or not config_name:
logger.warning("Could not save the configuration automatically (missing credentials in the response). "
"Add it manually with `cld config -n <name> <CLOUDINARY_URL>`.")
return

if name and name in user_config_names():
logger.warning(f"Overwriting existing config '{name}'.")

try:
default_status = save_named_config(config_name, stored_url, set_default=set_default)
except Exception as e:
logger.warning(f"Could not save the configuration '{config_name}': {e}. "
f"Add it manually with `cld config -n {config_name} {_config_url_from_environment(environment)}`.")
return

logger.info(f"Config '{config_name}' saved!")
logger.info(f"Example usage: cld -C {config_name} <command>")
if default_status == "made":
logger.info(f"Default set to '{config_name}'. Run `cld <command>` to use it, "
f"or `cld -C {config_name} <command>` to select it explicitly.")


def _config_url_from_environment(environment, email=None):
"""Build a validated cloudinary:// config URL from a product-environment's credential fields,
optionally carrying the account email. Returns "" when the response lacks the credentials."""
params = {ACCOUNT_EMAIL_PARAM: email.strip().lower()} if email and email.strip() else None
try:
return build_config_url(environment["cloud_name"], params=params,
api_key=environment["api_key"], api_secret=environment["api_secret"])
except (KeyError, ValueError):
return ""
3 changes: 3 additions & 0 deletions cloudinary_cli/core/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@

from cloudinary_cli.auth import login as run_login, logout as run_logout, list_oauth_login_names
from cloudinary_cli.defaults import logger
from cloudinary_cli.utils.config_utils import config_optional
from cloudinary_cli.utils.utils import log_exception, prompt_user


@config_optional
@command("login", help="Log in to Cloudinary via OAuth (opens a browser). The session is saved "
"as a named configuration you can select with `-C`.")
@argument("name", required=False)
Expand Down Expand Up @@ -34,6 +36,7 @@ def login(name, region, set_default):
return True


@config_optional
@command("logout", help="Log out: revoke a saved OAuth login's token and remove its configuration. "
"Run without a name to choose from the saved logins.")
@argument("name", required=False)
Expand Down
Loading
Loading