mirror of
https://github.com/Skyvern-AI/skyvern.git
synced 2026-04-28 03:30:10 +00:00
🔄 synced local 'skyvern/' with remote 'skyvern/'
This commit is contained in:
parent
3d9a0d1b69
commit
3e0e131776
8 changed files with 284 additions and 9 deletions
|
|
@ -30,6 +30,12 @@ register_lazy_command("block", "skyvern.cli.block", "block_app", "Inspect and va
|
|||
register_lazy_command(
|
||||
"credential", "skyvern.cli.credential", "credential_app", "MCP-parity credential commands (list/get/delete)."
|
||||
)
|
||||
register_lazy_command(
|
||||
"config",
|
||||
"skyvern.cli.config_command",
|
||||
"config_app",
|
||||
"Read and update organization settings (max_steps_per_run, webhook URL, retries, artifact URL expiry).",
|
||||
)
|
||||
register_lazy_command("workflow", "skyvern.cli.workflow", "workflow_app", "Workflow management commands.")
|
||||
register_lazy_command("tasks", "skyvern.cli.tasks", "tasks_app", "Task management commands.")
|
||||
register_lazy_command(
|
||||
|
|
|
|||
105
skyvern/cli/config_command.py
Normal file
105
skyvern/cli/config_command.py
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
"""Organization config CLI: ``skyvern config show | get | set``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import typer
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from skyvern.config import settings
|
||||
from skyvern.forge.sdk.schemas.organizations import Organization, OrganizationUpdate
|
||||
from skyvern.utils.env_paths import resolve_backend_env_path
|
||||
|
||||
from .commands._output import run_tool
|
||||
from .mcp_tools.org import skyvern_org_get as tool_org_get
|
||||
from .mcp_tools.org import skyvern_org_update as tool_org_update
|
||||
|
||||
_SETTABLE_KEYS: frozenset[str] = frozenset(OrganizationUpdate.model_fields)
|
||||
# ``clear_*`` keys are write-only verbs (reset to NULL); not surfaced by ``get``.
|
||||
_READABLE_KEYS: frozenset[str] = frozenset(
|
||||
{name for name in Organization.model_fields if not name.startswith("clear_")}
|
||||
)
|
||||
|
||||
|
||||
config_app = typer.Typer(
|
||||
help="Read and update organization settings (max_steps_per_run, webhook URL, retries, artifact URL expiry).",
|
||||
no_args_is_help=True,
|
||||
)
|
||||
|
||||
|
||||
@config_app.callback()
|
||||
def config_callback(
|
||||
api_key: str | None = typer.Option(
|
||||
None,
|
||||
"--api-key",
|
||||
envvar="SKYVERN_API_KEY",
|
||||
help="Skyvern API key.",
|
||||
),
|
||||
) -> None:
|
||||
"""Load env and apply the optional API key override."""
|
||||
load_dotenv(resolve_backend_env_path())
|
||||
if api_key:
|
||||
settings.SKYVERN_API_KEY = api_key
|
||||
|
||||
|
||||
@config_app.command("show")
|
||||
def config_show(
|
||||
json_output: bool = typer.Option(False, "--json", help="Output as JSON."),
|
||||
) -> None:
|
||||
"""Show all current organization settings."""
|
||||
|
||||
async def _run() -> dict[str, Any]:
|
||||
return await tool_org_get()
|
||||
|
||||
run_tool(
|
||||
_run,
|
||||
json_output=json_output,
|
||||
hint_on_exception="Check your API key and Skyvern connection.",
|
||||
action="skyvern_org_get",
|
||||
)
|
||||
|
||||
|
||||
@config_app.command("get")
|
||||
def config_get(
|
||||
key: str = typer.Argument(..., help=f"Setting key. One of: {', '.join(sorted(_READABLE_KEYS))}."),
|
||||
json_output: bool = typer.Option(False, "--json", help="Output as JSON."),
|
||||
) -> None:
|
||||
"""Get a single setting value."""
|
||||
if key not in _READABLE_KEYS:
|
||||
raise typer.BadParameter(f"Unknown key: {key!r}. Allowed: {', '.join(sorted(_READABLE_KEYS))}")
|
||||
|
||||
async def _run() -> dict[str, Any]:
|
||||
result = await tool_org_get()
|
||||
if not result.get("ok"):
|
||||
return result
|
||||
full = result.get("data") or {}
|
||||
return {**result, "data": {key: full.get(key)}}
|
||||
|
||||
run_tool(
|
||||
_run,
|
||||
json_output=json_output,
|
||||
hint_on_exception="Check your API key and Skyvern connection.",
|
||||
action="skyvern_org_get",
|
||||
)
|
||||
|
||||
|
||||
@config_app.command("set")
|
||||
def config_set(
|
||||
key: str = typer.Argument(..., help=f"Setting key. One of: {', '.join(sorted(_SETTABLE_KEYS))}."),
|
||||
value: str = typer.Argument(..., help="New value (Pydantic coerces strings to ints/bools)."),
|
||||
json_output: bool = typer.Option(False, "--json", help="Output as JSON."),
|
||||
) -> None:
|
||||
"""Update a single organization setting."""
|
||||
if key not in _SETTABLE_KEYS:
|
||||
raise typer.BadParameter(f"Unknown key: {key!r}. Allowed: {', '.join(sorted(_SETTABLE_KEYS))}")
|
||||
|
||||
async def _run() -> dict[str, Any]:
|
||||
return await tool_org_update(updates={key: value})
|
||||
|
||||
run_tool(
|
||||
_run,
|
||||
json_output=json_output,
|
||||
hint_on_exception="Check your API key and Skyvern connection.",
|
||||
action="skyvern_org_update",
|
||||
)
|
||||
|
|
@ -65,6 +65,10 @@ from .inspection import (
|
|||
skyvern_network_route,
|
||||
skyvern_network_unroute,
|
||||
)
|
||||
from .org import (
|
||||
skyvern_org_get,
|
||||
skyvern_org_update,
|
||||
)
|
||||
from .prompts import build_workflow, debug_automation, extract_data, qa_test
|
||||
from .response import size_capped
|
||||
from .scripts import (
|
||||
|
|
@ -347,6 +351,10 @@ mcp.tool(tags={"storage"}, annotations=_dest("Clear Local Storage"))(skyvern_cle
|
|||
mcp.tool(tags={"block_discovery"}, annotations=_ro("Get Workflow Block Schema"))(skyvern_block_schema)
|
||||
mcp.tool(tags={"block_discovery"}, annotations=_ro("Validate Workflow Block"))(skyvern_block_validate)
|
||||
|
||||
# -- Organization settings (no browser needed) --
|
||||
mcp.tool(tags={"settings"}, annotations=_ro("Get Organization Settings"))(skyvern_org_get)
|
||||
mcp.tool(tags={"settings"}, annotations=_mut("Update Organization Settings"))(skyvern_org_update)
|
||||
|
||||
# -- Credential lookup (no browser needed) --
|
||||
mcp.tool(tags={"credential"}, annotations=_ro("List Credentials"))(skyvern_credential_list)
|
||||
mcp.tool(tags={"credential"}, annotations=_ro("Get Credential"))(skyvern_credential_get)
|
||||
|
|
@ -448,6 +456,9 @@ __all__ = [
|
|||
# Block discovery + validation
|
||||
"skyvern_block_schema",
|
||||
"skyvern_block_validate",
|
||||
# Organization settings
|
||||
"skyvern_org_get",
|
||||
"skyvern_org_update",
|
||||
# Credential lookup
|
||||
"skyvern_credential_list",
|
||||
"skyvern_credential_get",
|
||||
|
|
|
|||
|
|
@ -17,17 +17,34 @@ async def raw_http_get(path: str, params: dict[str, Any] | None = None) -> Any:
|
|||
|
||||
Raises NotFoundError on 404, RuntimeError on other HTTP errors.
|
||||
"""
|
||||
return await _raw_http_request("GET", path, params=params)
|
||||
|
||||
|
||||
async def raw_http_put(path: str, json_body: dict[str, Any] | None = None) -> Any:
|
||||
"""PUT request to Skyvern API for endpoints without SDK methods.
|
||||
|
||||
Raises NotFoundError on 404, RuntimeError on other HTTP errors.
|
||||
"""
|
||||
return await _raw_http_request("PUT", path, json_body=json_body)
|
||||
|
||||
|
||||
async def _raw_http_request(
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
params: dict[str, Any] | None = None,
|
||||
json_body: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
from ._session import get_skyvern
|
||||
|
||||
skyvern = get_skyvern()
|
||||
# Temporary workaround: these MCP routes do not have public Fern SDK methods yet,
|
||||
# so we reach through the generated client's private wrapper. Revisit if the SDK
|
||||
# is regenerated or adds first-class methods for these endpoints.
|
||||
response = await skyvern._client_wrapper.httpx_client.request(
|
||||
path,
|
||||
method="GET",
|
||||
params=params or {},
|
||||
)
|
||||
kwargs: dict[str, Any] = {"method": method, "params": params or {}}
|
||||
if json_body is not None:
|
||||
kwargs["json"] = json_body
|
||||
response = await skyvern._client_wrapper.httpx_client.request(path, **kwargs)
|
||||
if response.status_code == 404:
|
||||
raise NotFoundError(body={"detail": f"Not found: {path}"})
|
||||
if response.status_code >= 400:
|
||||
|
|
@ -54,5 +71,6 @@ __all__ = [
|
|||
"make_error",
|
||||
"make_result",
|
||||
"raw_http_get",
|
||||
"raw_http_put",
|
||||
"save_artifact",
|
||||
]
|
||||
|
|
|
|||
112
skyvern/cli/mcp_tools/org.py
Normal file
112
skyvern/cli/mcp_tools/org.py
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
"""Skyvern MCP organization-settings tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated, Any
|
||||
|
||||
from pydantic import Field, ValidationError
|
||||
|
||||
from skyvern.forge.sdk.schemas.organizations import OrganizationUpdate
|
||||
|
||||
from ._common import ErrorCode, Timer, make_error, make_result, raw_http_get, raw_http_put
|
||||
|
||||
_UPDATE_FIELDS: frozenset[str] = frozenset(OrganizationUpdate.model_fields)
|
||||
|
||||
|
||||
async def skyvern_org_get() -> dict[str, Any]:
|
||||
"""Get the caller's organization settings.
|
||||
|
||||
Use this to discover valid keys before calling skyvern_org_update.
|
||||
"""
|
||||
with Timer() as timer:
|
||||
try:
|
||||
data = await raw_http_get("api/v1/organizations/me")
|
||||
timer.mark("http")
|
||||
except Exception as e:
|
||||
return make_result(
|
||||
"skyvern_org_get",
|
||||
ok=False,
|
||||
timing_ms=timer.timing_ms,
|
||||
error=make_error(ErrorCode.API_ERROR, str(e), "Check your API key and Skyvern connection"),
|
||||
)
|
||||
|
||||
if not isinstance(data, dict) or not data.get("organization_id"):
|
||||
return make_result(
|
||||
"skyvern_org_get",
|
||||
ok=False,
|
||||
timing_ms=timer.timing_ms,
|
||||
error=make_error(ErrorCode.API_ERROR, "Unexpected response from /organizations/me", str(data)[:200]),
|
||||
)
|
||||
|
||||
return make_result("skyvern_org_get", data=data, timing_ms=timer.timing_ms)
|
||||
|
||||
|
||||
async def skyvern_org_update(
|
||||
updates: Annotated[
|
||||
dict[str, Any],
|
||||
Field(
|
||||
description=(
|
||||
"Partial settings dict. Allowed keys: "
|
||||
"max_steps_per_run (int >= 1), "
|
||||
"max_retries_per_step (int >= 0), "
|
||||
"webhook_callback_url (string), "
|
||||
"artifact_url_expiry_seconds (int 3600-604800), "
|
||||
"clear_artifact_url_expiry_seconds (bool — resets the expiry override to the global default)."
|
||||
)
|
||||
),
|
||||
],
|
||||
) -> dict[str, Any]:
|
||||
"""Update organization settings. Pass only the fields you want to change."""
|
||||
if not updates:
|
||||
return make_result(
|
||||
"skyvern_org_update",
|
||||
ok=False,
|
||||
error=make_error(ErrorCode.INVALID_INPUT, "updates dict is empty", "Pass at least one settable key"),
|
||||
)
|
||||
|
||||
none_keys = sorted(k for k, v in updates.items() if v is None)
|
||||
if none_keys:
|
||||
return make_result(
|
||||
"skyvern_org_update",
|
||||
ok=False,
|
||||
error=make_error(
|
||||
ErrorCode.INVALID_INPUT,
|
||||
f"None is not a valid value for: {', '.join(none_keys)}",
|
||||
"Omit keys you don't want to change instead of passing None",
|
||||
),
|
||||
)
|
||||
|
||||
unknown = sorted(set(updates) - set(_UPDATE_FIELDS))
|
||||
if unknown:
|
||||
return make_result(
|
||||
"skyvern_org_update",
|
||||
ok=False,
|
||||
error=make_error(
|
||||
ErrorCode.INVALID_INPUT,
|
||||
f"Unknown settings keys: {', '.join(unknown)}",
|
||||
f"Allowed keys: {', '.join(_UPDATE_FIELDS)}",
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
validated = OrganizationUpdate.model_validate(updates).model_dump(exclude_unset=True)
|
||||
except ValidationError as e:
|
||||
return make_result(
|
||||
"skyvern_org_update",
|
||||
ok=False,
|
||||
error=make_error(ErrorCode.INVALID_INPUT, str(e), "Check field types and ranges"),
|
||||
)
|
||||
|
||||
with Timer() as timer:
|
||||
try:
|
||||
data = await raw_http_put("api/v1/organizations", json_body=validated)
|
||||
timer.mark("http")
|
||||
except Exception as e:
|
||||
return make_result(
|
||||
"skyvern_org_update",
|
||||
ok=False,
|
||||
timing_ms=timer.timing_ms,
|
||||
error=make_error(ErrorCode.API_ERROR, str(e), "Verify field types and ranges"),
|
||||
)
|
||||
|
||||
return make_result("skyvern_org_update", data=data, timing_ms=timer.timing_ms)
|
||||
|
|
@ -165,11 +165,12 @@ class OrganizationsRepository(BaseRepository):
|
|||
raise NotFoundError
|
||||
if organization_name:
|
||||
organization.organization_name = organization_name
|
||||
if webhook_callback_url:
|
||||
# ``is not None`` (not truthy): "" clears the webhook, 0 disables retries.
|
||||
if webhook_callback_url is not None:
|
||||
organization.webhook_callback_url = webhook_callback_url
|
||||
if max_steps_per_run:
|
||||
if max_steps_per_run is not None:
|
||||
organization.max_steps_per_run = max_steps_per_run
|
||||
if max_retries_per_step:
|
||||
if max_retries_per_step is not None:
|
||||
organization.max_retries_per_step = max_retries_per_step
|
||||
# ``clear_*`` decouples "don't update" (None) from "explicitly clear":
|
||||
# callers pass ``clear_artifact_url_expiry_seconds=True`` to reset
|
||||
|
|
|
|||
|
|
@ -3272,6 +3272,8 @@ async def update_organization(
|
|||
return await app.DATABASE.organizations.update_organization(
|
||||
current_org.organization_id,
|
||||
max_steps_per_run=org_update.max_steps_per_run,
|
||||
max_retries_per_step=org_update.max_retries_per_step,
|
||||
webhook_callback_url=org_update.webhook_callback_url,
|
||||
artifact_url_expiry_seconds=org_update.artifact_url_expiry_seconds,
|
||||
clear_artifact_url_expiry_seconds=org_update.clear_artifact_url_expiry_seconds,
|
||||
)
|
||||
|
|
@ -3294,6 +3296,23 @@ async def get_organizations(
|
|||
return GetOrganizationsResponse(organizations=[current_org])
|
||||
|
||||
|
||||
@legacy_base_router.get(
|
||||
"/organizations/me",
|
||||
tags=["server"],
|
||||
openapi_extra={
|
||||
"x-fern-sdk-method-name": "get_current_organization",
|
||||
},
|
||||
)
|
||||
@legacy_base_router.get(
|
||||
"/organizations/me/",
|
||||
include_in_schema=False,
|
||||
)
|
||||
async def get_current_organization(
|
||||
current_org: Organization = Depends(org_auth_service.get_current_org),
|
||||
) -> Organization:
|
||||
return current_org
|
||||
|
||||
|
||||
@legacy_base_router.get(
|
||||
"/organizations/{organization_id}/apikeys/",
|
||||
tags=["server"],
|
||||
|
|
|
|||
|
|
@ -176,7 +176,10 @@ class GetOrganizationAPIKeysResponse(BaseModel):
|
|||
|
||||
|
||||
class OrganizationUpdate(BaseModel):
|
||||
max_steps_per_run: int | None = None
|
||||
max_steps_per_run: int | None = Field(default=None, ge=1)
|
||||
# 0 is a valid "disable retries" value — see ForgeAgent.execute_step.
|
||||
max_retries_per_step: int | None = Field(default=None, ge=0)
|
||||
webhook_callback_url: str | None = None
|
||||
artifact_url_expiry_seconds: int | None = Field(
|
||||
None,
|
||||
description=(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue