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
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ jobs:
shell: bash
run: |
./dist/agentrun${{ matrix.ext }} --version
./dist/agentrun${{ matrix.ext }} skill create --help

# --- Package (Unix) -----------------------------------------------
- name: Package tar.gz (Unix)
Expand Down
3 changes: 0 additions & 3 deletions agentrun.spec
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,10 @@ EXCLUDES = [
'pytz',
'pygments',
'sqlalchemy',
'Crypto',
'pycryptodome',
'rich',
'markdown_it',
'mysql',
'MySQLdb',
'oss2',
'posthog',
'jinja2',
'qdrant_client',
Expand Down
5 changes: 5 additions & 0 deletions docs/en/skill.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ Data plane (local):

Upload a local skill directory to the platform.

The directory is zipped and uploaded to an FC temporary OSS bucket, then
registered as a code-package tool (`createMethod=CODE_PACKAGE`,
`artifactType=Code`). Inline code base64 fields such as `zipFile` and
`zip_file` are not accepted, including in `--from-file` payloads.

```
ar skill create --name <name> --code-dir <dir> [options]
```
Expand Down
2 changes: 2 additions & 0 deletions docs/zh/skill.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@

把本地 Skill 目录打包上传到平台。

目录会被打包并上传到 FC 临时 OSS bucket,然后注册为代码包工具(`createMethod=CODE_PACKAGE`、`artifactType=Code`)。代码包不接受 `zipFile` / `zip_file` 等 inline base64 字段,`--from-file` 也会拒绝这些字段。

```
ar skill create --name <name> --code-dir <dir> [options]
```
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ classifiers = [

dependencies = [
"agentrun-sdk[core]>=0.0.37",
"oss2>=2.19.1",
"pyyaml>=6.0",
"questionary>=2.0",
]
Expand Down
205 changes: 199 additions & 6 deletions src/agentrun_cli/commands/skill_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,16 @@
"""

import base64
import email.utils
import hashlib
import hmac
import io
import json
import os
import urllib.error
import urllib.request
import zipfile
from dataclasses import dataclass

import click

Expand Down Expand Up @@ -58,16 +64,192 @@ def _serialize_tool(t) -> dict:
}


def _zip_directory(dir_path: str) -> str:
"""ZIP a directory and return base64-encoded content."""
def _zip_skill_directory_bytes(dir_path: str) -> bytes:
"""Package a Skill directory and return raw ZIP bytes."""
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
for root, _dirs, files in os.walk(dir_path):
for fname in files:
full_path = os.path.join(root, fname)
arcname = os.path.relpath(full_path, dir_path)
zf.write(full_path, arcname)
return base64.b64encode(buf.getvalue()).decode("ascii")
return buf.getvalue()


def _reject_inline_code_fields(value: object) -> None:
"""Reject legacy inline code fields in user-provided payloads."""
if isinstance(value, dict):
for key, nested in value.items():
if key in {"zipFile", "zip_file"}:
raise click.UsageError(
"Inline code base64 fields are not supported for Skill "
"creation; use --code-dir so the CLI uploads the package "
"to FC TempBucket OSS."
)
_reject_inline_code_fields(nested)
elif isinstance(value, list):
for nested in value:
_reject_inline_code_fields(nested)


@dataclass(frozen=True)
class _CodePackageLocation:
oss_bucket_name: str
oss_object_name: str


def _upload_skill_archive_to_fc_temp_bucket(
zip_data: bytes,
*,
profile: str | None,
region: str | None,
) -> _CodePackageLocation:
"""Upload a Skill ZIP to FC TempBucket OSS and return its code location."""
import oss2
Comment thread
117503445 marked this conversation as resolved.

cfg = build_sdk_config(profile_name=profile, region=region)
ak = cfg.get_access_key_id()
sk = cfg.get_access_key_secret()
token = cfg.get_security_token()
try:
account_id = cfg.get_account_id()
except ValueError as exc:
raise click.ClickException(
"Creating a Skill requires access_key_id, access_key_secret, "
"account_id, and region."
) from exc
region_id = cfg.get_region_id()
if not ak or not sk or not account_id or not region_id:
raise click.ClickException(
"Creating a Skill requires access_key_id, access_key_secret, "
"account_id, and region."
)

payload = _get_fc_temp_bucket_token(ak, sk, token, account_id, region_id)
oss_region = _required_temp_bucket_field(payload, "ossRegion")
oss_bucket = _required_temp_bucket_field(payload, "ossBucket")
object_name = _temp_bucket_object_name(
account_id, _required_temp_bucket_field(payload, "objectName")
)
credentials = payload.get("credentials") or payload.get("Credentials") or {}
temp_ak = _required_temp_bucket_field(credentials, "accessKeyId")
temp_sk = _required_temp_bucket_field(credentials, "accessKeySecret")
temp_token = _required_temp_bucket_field(credentials, "securityToken")

auth = oss2.StsAuth(temp_ak, temp_sk, temp_token)
bucket = oss2.Bucket(auth, _oss_endpoint_from_region(oss_region), oss_bucket)
try:
bucket.put_object(object_name, zip_data)
except Exception as exc:
raise click.ClickException(
f"Failed to upload skill archive to FC TempBucket OSS: {exc}"
) from exc
return _CodePackageLocation(oss_bucket, object_name)


def _get_fc_temp_bucket_token(
access_key_id: str,
access_key_secret: str,
security_token: str | None,
account_id: str,
region_id: str,
) -> dict:
"""Call the FC 2016 API to fetch temporary OSS credentials."""
host = f"{account_id}.{region_id}.fc.aliyuncs.com"
path = "/2016-08-15/tempBucketToken"
date = email.utils.formatdate(usegmt=True)
headers = {
"Host": host,
"Accept": "application/json",
"Date": date,
"User-Agent": "agentrun-cli",
"X-Fc-Account-Id": account_id,
}
if security_token:
headers["X-Fc-Security-Token"] = security_token
headers["Authorization"] = _fc_authorization(
access_key_id, access_key_secret, "GET", headers, path
)
request = urllib.request.Request(
f"https://{host}{path}", headers=headers, method="GET"
)
try:
with urllib.request.urlopen(request, timeout=60) as response: # noqa: S310
raw = response.read()
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
message = f"Failed to get FC TempBucket token: {detail}"
raise click.ClickException(message) from exc
except urllib.error.URLError as exc:
message = f"Failed to get FC TempBucket token: {exc.reason}"
raise click.ClickException(message) from exc
try:
return json.loads(raw.decode("utf-8"))
except json.JSONDecodeError as exc:
preview = raw[:200].decode("utf-8", errors="replace")
message = f"Failed to parse FC TempBucket token response: {preview}"
raise click.ClickException(message) from exc


def _fc_authorization(
access_key_id: str,
access_key_secret: str,
method: str,
headers: dict[str, str],
resource: str,
) -> str:
"""Build the Authorization header for FC 2016 API requests."""
lower_headers = {key.lower(): value for key, value in headers.items()}
fc_headers = ""
for key in sorted(k for k in lower_headers if k.startswith("x-fc-")):
fc_headers += f"{key}:{lower_headers[key]}\n"
string_to_sign = "\n".join(
[
method,
lower_headers.get("content-md5", ""),
lower_headers.get("content-type", ""),
lower_headers.get("date", ""),
f"{fc_headers}{resource}",
]
)
digest = hmac.new(
access_key_secret.encode("utf-8"),
string_to_sign.encode("utf-8"),
hashlib.sha256,
).digest()
signature = base64.b64encode(digest).decode("ascii")
return f"FC {access_key_id}:{signature}"


def _required_temp_bucket_field(data: dict, key: str) -> str:
"""Read a required FC TempBucket field, accepting PascalCase variants."""
value = data.get(key)
if value is None:
value = data.get(key[:1].upper() + key[1:])
if not isinstance(value, str) or not value.strip():
raise click.ClickException(f"FC TempBucket response is missing '{key}'")
return value.strip()


def _temp_bucket_object_name(account_id: str, object_name: str) -> str:
"""Build the final FC TempBucket object name."""
account_id = account_id.strip().strip("/")
object_name = object_name.strip().lstrip("/")
if not account_id:
return object_name
return f"{account_id}/{object_name}"


def _oss_endpoint_from_region(oss_region: str) -> str:
"""Build an OSS endpoint from the FC-returned OSS region."""
value = oss_region.strip()
if value.startswith(("http://", "https://")):
return value
if "." in value:
return f"https://{value}"
if value.startswith("oss-"):
return f"https://{value}.aliyuncs.com"
return f"https://oss-{value}.aliyuncs.com"


def _load_json_option(raw: str | None) -> dict | None:
Expand Down Expand Up @@ -119,7 +301,10 @@ def skill_create(ctx, skill_name, code_dir, description, credential_name, from_f

if from_file:
payload = _load_json_option(from_file)
_reject_inline_code_fields(payload)
Comment thread
117503445 marked this conversation as resolved.
payload.setdefault("tool_type", "SKILL")
payload.setdefault("create_method", "CODE_PACKAGE")
payload.setdefault("artifact_type", "Code")
inp = models.CreateToolInputV2(**payload)
else:
# Validate code-dir
Expand All @@ -131,14 +316,22 @@ def skill_create(ctx, skill_name, code_dir, description, credential_name, from_f
if not description:
description = _extract_description(skill_md)

# ZIP and base64 encode
zip_b64 = _zip_directory(code_dir)
location = _upload_skill_archive_to_fc_temp_bucket(
_zip_skill_directory_bytes(code_dir),
profile=profile,
region=region,
)

code_cfg = models.CodeConfiguration(zip_file=zip_b64)
code_cfg = models.CodeConfiguration(
oss_bucket_name=location.oss_bucket_name,
oss_object_name=location.oss_object_name,
)

inp = models.CreateToolInputV2(
tool_name=skill_name,
tool_type="SKILL",
create_method="CODE_PACKAGE",
artifact_type="Code",
description=description,
code_configuration=code_cfg,
credential_name=credential_name,
Expand Down
29 changes: 25 additions & 4 deletions tests/integration/test_skill_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from click.testing import CliRunner

from agentrun_cli.commands.skill_cmd import _CodePackageLocation
from agentrun_cli.main import cli

# ---------------------------------------------------------------------------
Expand All @@ -24,6 +25,13 @@ def _mock_agentrun_models():
return mod


def _mock_agentrun_package(models_mod):
"""Build mock alibabacloud_agentrun20250910 package."""
pkg = MagicMock()
pkg.models = models_mod
return pkg


def _make_tool_obj(**overrides):
defaults = {
"tool_id": "t-xxx",
Expand Down Expand Up @@ -89,10 +97,14 @@ def test_create_skill(self):

with (
_patch_inner_client(client),
patch(
"agentrun_cli.commands.skill_cmd._upload_skill_archive_to_fc_temp_bucket",
return_value=_CodePackageLocation("bucket", "149/object.zip"),
) as mock_upload,
patch.dict(
"sys.modules",
{
"alibabacloud_agentrun20250910": MagicMock(),
"alibabacloud_agentrun20250910": _mock_agentrun_package(mock_mod),
"alibabacloud_agentrun20250910.models": mock_mod,
},
),
Expand All @@ -118,6 +130,12 @@ def test_create_skill(self):
assert result.exit_code == 0, result.output
out = json.loads(result.output)
assert out["tool_name"] == "new-skill"
mock_upload.assert_called_once()
body = mock_mod.CreateToolRequest.call_args.kwargs["body"]
assert body.create_method == "CODE_PACKAGE"
assert body.artifact_type == "Code"
assert body.code_configuration.oss_bucket_name == "bucket"
assert body.code_configuration.oss_object_name == "149/object.zip"

def test_create_from_file(self):
mock_mod = _mock_agentrun_models()
Expand All @@ -132,7 +150,7 @@ def test_create_from_file(self):
patch.dict(
"sys.modules",
{
"alibabacloud_agentrun20250910": MagicMock(),
"alibabacloud_agentrun20250910": _mock_agentrun_package(mock_mod),
"alibabacloud_agentrun20250910.models": mock_mod,
},
),
Expand All @@ -158,6 +176,9 @@ def test_create_from_file(self):
],
)
assert result.exit_code == 0, result.output
body = mock_mod.CreateToolRequest.call_args.kwargs["body"]
assert body.create_method == "CODE_PACKAGE"
assert body.artifact_type == "Code"


class TestSkillList:
Expand All @@ -175,7 +196,7 @@ def test_list_skills(self):
patch.dict(
"sys.modules",
{
"alibabacloud_agentrun20250910": MagicMock(),
"alibabacloud_agentrun20250910": _mock_agentrun_package(mock_mod),
"alibabacloud_agentrun20250910.models": mock_mod,
},
),
Expand All @@ -198,7 +219,7 @@ def test_list_with_pagination(self):
patch.dict(
"sys.modules",
{
"alibabacloud_agentrun20250910": MagicMock(),
"alibabacloud_agentrun20250910": _mock_agentrun_package(mock_mod),
"alibabacloud_agentrun20250910.models": mock_mod,
},
),
Expand Down
Loading
Loading