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
7 changes: 7 additions & 0 deletions docs/en/skill.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ 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`, `language=python3.12`, `command=python main.py`). If the
directory has no `main.py`, a placeholder entrypoint is injected so the package
is deployable. 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`、`language=python3.12`、`command=python main.py`)。若目录缺少 `main.py`,会注入占位入口文件以保证可部署。代码包不接受 `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
211 changes: 205 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,198 @@ 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 inject a placeholder main.py when missing."""
buf = io.BytesIO()
has_main_py = False
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)
if arcname == "main.py":
has_main_py = True
zf.write(full_path, arcname)
return base64.b64encode(buf.getvalue()).decode("ascii")
if not has_main_py:
zf.writestr(
"main.py",
"def handler(event, context):\n"
" return {'status': 'ok'}\n\n"
"if __name__ == '__main__':\n"
" print('skill package placeholder')\n",
)
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里新增了对 oss2 的运行时依赖,但 agentrun.spec 仍然把 oss2 放在 EXCLUDES 里。CI 的 smoke 只做 make build,不会执行 ar skill create,所以 standalone 二进制会构建成功,但运行到这里时会报 ModuleNotFoundError: No module named 'oss2'。需要把 oss2 从 PyInstaller excludes 中移除(必要时加一个打包后二进制执行 skill create/导入的 smoke),否则发布出来的 dist/agentrun 无法创建 skill。


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)
bucket.put_object(object_name, zip_data)
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,6 +307,7 @@ 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--from-file 现在会拒绝 zipFile/zip_file,但这个分支也不会调用 _upload_skill_archive_to_fc_temp_bucket,也不会补 create_method / artifact_type / code_configuration。也就是说用户即使传了必需的 --code-dir,最终也可能提交一个没有代码包位置和 CODE_PACKAGE 元数据的 payload(现有 from-file 测试就是这种形态),和文档里“目录会被上传并注册为代码包工具”的行为不一致,真实 API 也会缺少必填的 createMethod。建议要么把 --from-file 当作覆盖项但仍然上传 --code-dir 生成 codeConfiguration,要么明确要求 from-file 自带 CODE_PACKAGE + ossBucketName/ossObjectName 并补对应集成测试。

payload.setdefault("tool_type", "SKILL")
inp = models.CreateToolInputV2(**payload)
else:
Expand All @@ -131,14 +320,24 @@ 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,
language="python3.12",
command=["python", "main.py"],
)

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
26 changes: 22 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 Down Expand Up @@ -175,7 +193,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 +216,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