
Byted Volcengine Tosutil
- 6 installs
- 411 repo stars
- Updated August 4, 2026
- bytedance/agentkit-samples
byted-volcengine-tosutil is a Claude skill that generates, validates, and diagnoses tosutil CLI commands for managing Volcengine object storage TOS.
About
This skill turns intent into safe, executable, and verifiable tosutil commands for Volcengine object storage TOS. It defaults to preview-only, requires explicit flags to run and to confirm destructive deletes, and redacts credentials in output. It also plans bucket and object operations, computes commands for upload, download, copy, and delete, and diagnoses errors such as HTTP 403 and connection failures.
- Generates safe, preview-first tosutil commands
- Plans bucket and object operations with structured JSON output
- Diagnoses tosutil errors like HTTP 403 and connectivity failures
Byted Volcengine Tosutil by the numbers
- 6 all-time installs (skills.sh)
- Ranked #870 of 1,039 Cloud & Infrastructure skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
byted-volcengine-tosutil capabilities & compatibility
Requires a Volcengine TOS account with AK/SK or an STS token.
- Use cases
- devops
- Platforms
- macOS · Windows · Linux
- Runs
- Runs locally
- Pricing
- Bring your own API key
What byted-volcengine-tosutil says it does
把“要对 TOS 做什么操作”的需求,转换成**默认只预览**的 `tosutil` 命令,并在需要执行时输出**结构化 JSON 结果 + 可复现证据 + 可诊断建议**。
核心命令包括 `ls`、`mkdir`、`du`、`mb`、`cp`、`setmeta`、`stat`、`rm`、`share`、`set-acl`、`mount`。
npx skills add https://github.com/bytedance/agentkit-samples --skill byted-volcengine-tosutilAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 6 |
|---|---|
| repo stars | ★ 411 |
| Last updated | August 4, 2026 |
| Repository | bytedance/agentkit-samples ↗ |
What it does
Generate and validate tosutil CLI commands to manage Volcengine object storage buckets and objects.
Who is it for?
Generating safe tosutil commands and diagnosing TOS object-storage errors.
When should I use this skill?
Use when a user mentions tosutil, TOS bucket/object management, batch upload/download, or related troubleshooting.
By the numbers
- 11 core commands
- 3 auth modes: permanent key, STS, anonymous
- supports Windows, Linux, macOS
Files
火山引擎 tosutil Skill
这个 Skill 面向火山引擎对象存储 TOS 的 tosutil 命令行工具,负责把用户意图转换成安全、可执行、可校验的 tosutil 操作流程。
目标一句话
把“要对 TOS 做什么操作”的需求,转换成默认只预览的 tosutil 命令,并在需要执行时输出结构化 JSON 结果 + 可复现证据 + 可诊断建议。
输入与输出
- 输入:目标命令(如
ls/cp/rm/du/setmeta)+ 最少必要参数(如tos://地址、本地路径、递归开关)+ 可选公共参数(endpoint/region/credentials/conf)。 - 输出:统一 JSON 协议(
ok/code/message/data/ts),包含preview.shell(脱敏后的可复现命令)、执行摘要、失败时的advice.code与next_actions。
默认行为(降低用户成本 + 安全)
- 默认只生成命令预览,不执行(需要显式
--run才会执行)。 - 破坏性命令(例如
rm)默认不执行:必须显式--yes(或 legacy 模式下--assume-yes)才会真正运行。 - 输出默认脱敏:不会回显 AK/SK/Token。
何时使用
当用户要求以下任一场景时调用本 Skill:
- 使用
tosutil初始化或更新 TOS 配置 - 创建桶、列举桶/对象、查询对象属性
- 上传、下载、复制、批量删除对象
- 计算对象容量、设置对象元数据
- 分析
Http status [403]、连通性失败、命令参数错误等问题 - 为
share、set-acl、mount、probe、netdig等高级命令生成执行方案
文档事实基线
基于 tosutil 文档体系,可确认以下事实:
tosutil是访问和管理火山引擎对象存储 TOS 的命令行工具,适合本地与 TOS 之间的批量数据处理、脚本集成和中小数据迁移。- 核心命令包括
ls、mkdir、du、mb、cp、setmeta、stat、rm、share、set-acl、mount。 - 辅助命令包括
config、help、probe、netdig、hash、fcp、clear、version、ping、connect、traceroute、curl。 - 初始化配置支持永久密钥、STS 临时密钥和匿名访问三种方式。
- 初始化时应使用 TOS 协议域名,而不是 S3 协议域名。
rm默认存在二次确认;递归和批量删除必须显式评估风险。du在百万级对象下可能耗时较长,优先按目录拆分计算。
下载与安装
tosutil 支持 Windows、Linux 和 macOS。使用本 Skill 前,建议先根据当前操作系统与芯片架构下载对应版本,并完成执行权限设置。
官方下载建议
- Linux amd64:支持直接下载二进制并执行
- macOS amd64(Intel):支持直接下载二进制并执行
- macOS arm64(Apple M 系列芯片):支持直接下载二进制并执行
- Windows 64bit:下载
tosutil.exe - 官方同时提供对应的
sha256校验文件,建议下载后做完整性校验 - 当前
tosutil最新版本主要适用于 Windows、macOS 和 Linux amd 系统
安装命令
Linux:
wget https://m645b3e1bb36e-mrap.mrap.accesspoint.tos-global.volces.com/linux/amd64/tosutil
chmod a+x tosutil
sudo mv tosutil /usr/local/binmacOS Intel:
wget https://m645b3e1bb36e-mrap.mrap.accesspoint.tos-global.volces.com/darwin/amd64/tosutil
chmod a+x tosutil
sudo mv tosutil /usr/local/binmacOS Apple Silicon:
wget https://m645b3e1bb36e-mrap.mrap.accesspoint.tos-global.volces.com/darwin/arm64/tosutil
chmod a+x tosutil
sudo mv tosutil /usr/local/binWindows:
wget https://m645b3e1bb36e-mrap.mrap.accesspoint.tos-global.volces.com/windows/tosutil -O tosutil.exe安装注意事项
- macOS 默认可能拦截未验证开发者应用;如果首次执行
tosutil时被系统阻止,需要在系统安全设置中放行 - Linux / macOS 下载后通常需要执行
chmod a+x tosutil - 如果希望在任意目录直接执行
tosutil,建议将二进制移动到已加入PATH的目录,例如/usr/local/bin - 如果二进制没有加入
PATH,请使用绝对路径调用,例如/absolute/path/to/tosutil version - 本 Skill 在未加入
PATH的场景下,建议通过--tosutil-binary <absolute-path>显式指定二进制位置,避免找不到工具
安装后校验
如果已加入 PATH:
tosutil version
tosutil config
tosutil ls如果未加入 PATH:
/absolute/path/to/tosutil version
/absolute/path/to/tosutil config
/absolute/path/to/tosutil ls结合本 Skill 的建议:
- 先用
version验证二进制是否可执行 - 再用
config确认配置文件路径 - 最后用
ls验证凭证、地域和网络连通性
如果 tosutil 已加入 PATH,可这样调用本 Skill:
python3 .trae/skills/byted-volcengine-tosutil/scripts/main.py \
ls \
--preflight如果 tosutil 未加入 PATH,可这样调用本 Skill:
python3 .trae/skills/byted-volcengine-tosutil/scripts/main.py \
ls \
--tosutil-binary /absolute/path/to/tosutil \
--preflight工作原则
- 优先确认目标是“读操作”还是“写操作/删操作”。
- 优先生成最小可行命令,避免一次性拼接过多危险参数。
- 对批量上传、下载、复制任务,先确认并发和分片阈值,再执行。
- 对删除类任务,默认给出预检查步骤和回滚提示,不直接跳过确认。
- 遇到高级命令且参数未完全确认时,优先结合
tosutil help <command>校验,不臆造参数。
标准流程
1. 识别场景
将用户请求归类到以下场景之一:
- 初始化配置:
config - 桶操作:
ls、mb、stat - 对象传输:
cp - 对象删除:
rm - 对象元数据:
setmeta - 容量统计:
du - 故障诊断:
version、ls、help、probe、netdig、ping、connect、traceroute
2. 预检查
执行或建议以下检查:
tosutil version
tosutil config
tosutil ls检查重点:
- 工具是否已安装且可执行
Endpoint是否为 TOS 协议域名Region与目标桶地域是否一致AK/SK或STS Token是否存在且权限足够- 返回结果中是否出现
Bucket number is:、Http status [403]、A connection attempt failed
3. 参数归一化
在生成命令前,统一整理以下参数:
- 资源地址:本地路径、
tos://bucket、tos://bucket/prefix - 凭证模式:永久密钥、STS、匿名访问
- 公共参数:
-e、-re、-i、-k、-t、-conf - 桶类型:
-bt=fns|hns - 批量任务并发:
-j - 分片并发或分片任务控制:
-p、-threshold、-ps - 结果输出目录:
-o
4. 命令生成
根据资源类型自动选择命令模式:
- 本地 -> TOS:上传
- TOS -> 本地:下载
- TOS -> TOS:对象复制
- 单对象:单任务模式
- 目录或前缀:递归模式
-r - 大文件:根据阈值切换分片任务
5. 输出校验
解析执行结果中的以下信号:
- 成功标志:
successfully、Bucket number is:、Succeed count is:、Task id is: - 权限问题:
Http status [403] - 网络问题:
A connection attempt failed - 参数问题:命令帮助输出、必选参数缺失、路径格式错误
- 清理建议:断点续传失败时考虑
clear
命令映射
初始化配置
永久密钥(推荐):
tosutil config -i <ak> -k <sk> -e <endpoint> -re <region>endpoint和region 可以参考“附录:地域及访问域名”,优先使用内网endpoint,若内网endpoint不可用则使用公网网endpoint
STS:
tosutil config -i <ak> -k <sk> -t <token> -e <endpoint> -re <region>匿名访问:
tosutil config -i= -k= -t= -e <endpoint> -re <region>桶与对象常见命令
tosutil ls
tosutil mb tos://bucketname
tosutil cp /local/file.txt tos://bucketname/file.txt
tosutil cp tos://bucketname/file.txt /local/file.txt
tosutil rm tos://bucketname/file.txt
tosutil du tos://bucketname
tosutil setmeta tos://bucketname/object.png -meta aaa:bbb#ccc:ddd安全策略
- 删除对象前先判断是否为单对象、目录前缀、桶级删除。
- 对
rm -r、rm -f、批量元数据更新、批量复制等操作,先输出影响范围说明。 - 如用户只要求“生成命令”,默认不直接执行。
- 如需执行高风险命令,先建议列举目标对象或做
dryRun风格校验;若命令本身不支持dryRun,先做只读检查。
集成实现建议
本 Skill 的实现以“本地 CLI 封装层”而不是“直接调用 TOS HTTP API”为主,因为文档主体提供的是 tosutil 命令接口。
脚本入口(推荐子命令模式)
命令预览(不执行):
python3 .trae/skills/byted-volcengine-tosutil/scripts/main.py ls --cloud-url tos://bucketname执行并返回结构化结果:
python3 .trae/skills/byted-volcengine-tosutil/scripts/main.py ls --cloud-url tos://bucketname --preflight --run高风险删除(必须显式确认):
python3 .trae/skills/byted-volcengine-tosutil/scripts/main.py rm --cloud-url tos://bucketname/prefix/ --recursive --run --yes兼容旧入口(legacy):
python3 .trae/skills/byted-volcengine-tosutil/scripts/main.py --command ls --cloud-url tos://bucketname推荐目录结构:
.trae/skills/byted-volcengine-tosutil/
├── SKILL.md
├── references/
│ └── doc-survey.md
└── scripts/
├── main.py
├── models.py
├── result_handler.py
└── tosutil_service.py推荐核心类:
CommonOptions/CredentialMode:封装永久密钥、STS、匿名模式和公共参数TosResource:封装本地路径与tos://资源的解析结果CommandSpec:封装命令名、参数列表、风险级别、是否破坏性操作TosutilRunner:统一执行tosutil命令并收集退出码、标准输出、标准错误result_handler:统一负责结果解析、错误映射与结构化输出字段整理tosutil_service:统一负责命令构建、预检查、执行、脱敏和公共校验逻辑
关键规则
配置规则
config写入的是本机配置文件,默认位于用户目录下的.tosutilconfig-conf只能指向已有配置文件路径,不负责自动创建新文件- 优先建议使用显式
region + endpoint,避免跨地域误操作
传输规则
cp需要根据源和目标地址类型自动推断是上传、下载还是云上复制- 批量任务的实际并发需综合
-j与分片并发参数评估,避免盲目调大 - 大文件和批量任务优先显式设置阈值、并发和输出目录,便于排错
统计规则
du在海量对象场景下不要默认全桶递归扫描- 如桶开启版本控制,可按需加入版本相关参数并拆分统计范围
元数据规则
setmeta支持单对象和批量前缀模式- 批量模式必须明确
-r,并限制最大并发-j
故障诊断提示
权限问题
- 如果结果出现
Http status [403],优先检查 AK/SK、STS 是否失效,以及目标桶/对象权限
网络问题
- 如果结果出现
A connection attempt failed,优先检查网络、代理、防火墙和Endpoint
参数问题
- 如果高级命令参数不确定,先执行
tosutil help <command>
清理问题
- 断点续传或异常中断后,可考虑
clear清理记录文件并尽力做云端清理
最佳实践
- 首次接管环境时,先执行
version、config、ls三连检查 - 初始化必须使用 TOS 协议域名
- 匿名访问只适用于公开读或公共写场景
- 百万级对象统计按目录拆分
- 批量任务先保守设置并发,再逐步调优
- 对高风险命令保留二次确认和影响面说明
交互模板
用户想上传文件
1. 确认本地路径、目标桶、目标对象名 2. 判断是否需要递归上传 3. 检查配置与连通性 4. 生成 cp 命令 5. 返回成功判据与失败排查点
用户想删除目录
1. 先确认目标前缀 2. 先建议 ls 验证影响范围 3. 再生成 rm -r 4. 如用户确认强制删除,再考虑 -f
用户想排查无法访问
1. 检查 version 2. 检查 config 3. 执行 ls 4. 根据 403、连接失败、参数错误做分类诊断
测试要求
- 用文档示例作为命令构造测试样本
- 用示例输出作为解析器快照样本
- 对
403、网络失败、空配置、匿名访问、递归删除等场景做单元测试 - 对
cp的上传/下载/云上复制三种方向分别做集成测试 - 对
rm、setmeta、du的批量参数做边界测试
限制说明
- 本 Skill 优先覆盖文档中最核心的
config、ls、mb、cp、rm、du、setmeta、stat、help、version share、set-acl、mount、probe、netdig、curl等高级命令采用扩展适配模式- 如果某个高级命令的参数未在当前引用资料中完整展开,必须先通过
help或补充文档确认后再执行
<br />
附录:地域及访问域名
- 地域(Region):表示 TOS 的数据中心所在物理位置。
- 访问域名(Endpoint):表示 TOS 对外服务的访问域名。
| Region 中文名称 | Region ID | Endpoint (内网/外网) | S3 Endpoint (内网/外网) |
|---|---|---|---|
| 华北2(北京) | cn-beijing | 内网: tos-cn-beijing.ivolces.com外网: tos-cn-beijing.volces.com | 内网: tos-s3-cn-beijing.ivolces.com外网: tos-s3-cn-beijing.volces.com |
| 华南1(广州) | cn-guangzhou | 内网: tos-cn-guangzhou.ivolces.com外网: tos-cn-guangzhou.volces.com | 内网: tos-s3-cn-guangzhou.ivolces.com外网: tos-s3-cn-guangzhou.volces.com |
| 华东2(上海) | cn-shanghai | 内网: tos-cn-shanghai.ivolces.com外网: tos-cn-shanghai.volces.com | 内网: tos-s3-cn-shanghai.ivolces.com外网: tos-s3-cn-shanghai.volces.com |
| 中国香港 | cn-hongkong | 内网: tos-cn-hongkong.ivolces.com外网: tos-cn-hongkong.volces.com | 内网: tos-s3-cn-hongkong.ivolces.com外网: tos-s3-cn-hongkong.volces.com |
| 亚太东南(柔佛) | ap-southeast-1 | 内网: tos-ap-southeast-1.ivolces.com外网: tos-ap-southeast-1.volces.com | 内网: tos-s3-ap-southeast-1.ivolces.com外网: tos-s3-ap-southeast-1.volces.com |
| 亚太东南(雅加达) | ap-southeast-3 | 内网: tos-ap-southeast-3.ivolces.com外网: tos-ap-southeast-3.volces.com | 内网: tos-s3-ap-southeast-3.ivolces.com外网: tos-s3-ap-southeast-3.volces.com |
tosutil 文档调研摘要
已纳入方案的文档主题
本 Skill 方案围绕 tosutil 文档入口所公开的命令体系整理,重点吸收了以下类型的信息:
- 概述页
tosutil的定位- 命令分类
- 常见业务场景
- 快速入门
- 初始化配置
- 永久密钥、STS、匿名访问三种初始化方式
version与ls的验证方法mb、cp、rm的基础示例- 命令页
confighelpdusetmetacprm- 配置说明与示例摘要
- 并发与分片阈值
- 输出目录和配置路径
已确认的技术事实
tosutil是火山引擎对象存储 TOS 的命令行工具。- 核心命令覆盖桶管理、对象传输、元数据设置、统计和删除。
config写入配置文件,默认路径在用户目录下的.tosutilconfig。- 配置时应使用 TOS 协议域名,而不是 S3 协议域名。
- 匿名访问通过将
-i、-k、-t置空实现。 ls的成功输出中可见Bucket number is:。rm存在二次确认机制,强制删除依赖-f。du在对象很多时应按目录拆分,避免一次性全量扫描。setmeta的批量模式需要-r。cp支持上传、下载和云上复制,并支持并发与分片参数。
方案边界
为避免超出已确认事实,当前实现方案采取以下边界控制:
- 对
config、help、version、ls、mb、cp、rm、du、setmeta、stat做优先建模。 - 对
share、set-acl、mount、probe、netdig、curl保留扩展接口,但在参数未二次确认前不做硬编码承诺。 - 所有高级命令在真实执行前,先建议配合
tosutil help <command>做参数校验。
实现影响
这些文档结论直接决定了 Skill 的实现方式:
- 核心不是 HTTP 请求签名,而是本地 CLI 编排与结果解析。
- 风险控制点集中在配置、批量任务、递归删除和大文件传输。
- 测试重点应放在命令构造、输出解析和错误分类,而不是 SDK mock。
from __future__ import annotations
import argparse
import json
import sys
import time
from models import CommonOptions, CredentialMode, OperationRequest
from result_handler import parse_execution_result
from tosutil_service import (
TosutilRunner,
ValidationError,
build_command,
redact_argv,
run_preflight,
shell_join,
)
def _add_common_args(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--endpoint", help="TOS Endpoint(必须是 TOS 协议域名)")
parser.add_argument("--region", help="Region,例如 cn-beijing")
parser.add_argument("--access-key", help="Access Key(不会在输出中回显)")
parser.add_argument("--secret-key", help="Secret Key(不会在输出中回显)")
parser.add_argument("--security-token", help="STS Token(不会在输出中回显)")
parser.add_argument("--conf-path", help="配置文件路径(-conf)")
parser.add_argument("--bucket-type", help="桶类型,fns 或 hns")
parser.add_argument("--output-dir", help="结果输出目录(-o)")
parser.add_argument("--tosutil-binary", default="tosutil", help="tosutil 可执行文件路径")
parser.add_argument(
"--credential-mode",
choices=[mode.value for mode in CredentialMode],
default=CredentialMode.PERMANENT.value,
help="凭证模式:permanent/sts/anonymous",
)
parser.add_argument("--timeout", type=int, default=300, help="单次执行超时秒数(默认 300)")
parser.add_argument("--retries", type=int, default=0, help="网络/超时错误重试次数(默认 0)")
parser.add_argument("--retry-backoff", type=float, default=0.8, help="重试退避基准秒数(默认 0.8)")
def _add_execution_args(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--run", action="store_true", help="是否真的执行(默认只预览命令)")
parser.add_argument("--preflight", action="store_true", help="执行前进行版本/配置预检查")
parser.add_argument("--connectivity", action="store_true", help="预检查时额外执行 ls 检查连通性")
parser.add_argument("--yes", action="store_true", help="对破坏性操作跳过确认(高风险)")
def _build_subcommand_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="byted-volcengine-tosutil:tosutil 命令生成/执行/诊断器")
sub = parser.add_subparsers(dest="subcommand", required=True)
common_parent = argparse.ArgumentParser(add_help=False)
_add_common_args(common_parent)
execution_parent = argparse.ArgumentParser(add_help=False)
_add_execution_args(execution_parent)
parents = [common_parent, execution_parent]
sub.add_parser("version", help="查看 tosutil 版本", parents=parents)
p_help = sub.add_parser("help", help="查看 tosutil 帮助", parents=parents)
p_help.add_argument("--help-command", help="具体命令名,例如 ls/cp/rm")
sub.add_parser("config", help="查看或更新配置(默认仅查看配置路径)", parents=parents)
p_ls = sub.add_parser("ls", help="列举桶或对象", parents=parents)
p_ls.add_argument("--cloud-url", help="可选:tos://bucket 或 tos://bucket/prefix")
p_mb = sub.add_parser("mb", help="创建桶", parents=parents)
p_mb.add_argument("--cloud-url", required=True, help="tos://bucket")
p_cp = sub.add_parser("cp", help="上传/下载/云上复制对象", parents=parents)
p_cp.add_argument("--source", required=True, help="源路径:本地路径或 tos://")
p_cp.add_argument("--target", required=True, help="目标路径:本地路径或 tos://")
p_cp.add_argument("--recursive", action="store_true", help="递归复制目录/前缀")
p_cp.add_argument("--jobs", type=int, help="批量任务并发数(-j)")
p_cp.add_argument("--part-concurrency", type=int, help="分片任务并发数(-p)")
p_cp.add_argument("--threshold", help="分片阈值(-threshold)")
p_cp.add_argument("--part-size", help="分片大小(-ps)")
p_rm = sub.add_parser("rm", help="删除桶/对象/前缀(破坏性)", parents=parents)
p_rm.add_argument("--cloud-url", required=True, help="tos://bucket 或 tos://bucket/prefix")
p_rm.add_argument("--recursive", action="store_true", help="递归删除(-r)")
p_rm.add_argument("--force", action="store_true", help="强制删除(-f,跳过交互确认)")
p_rm.add_argument("--jobs", type=int, help="批量删除并发数(-j)")
p_du = sub.add_parser("du", help="统计对象/分片大小和数量", parents=parents)
p_du.add_argument("--cloud-url", required=True, help="tos://bucket 或 tos://bucket/prefix")
p_du.add_argument("--directory-mode", action="store_true", help="目录模式(-d)")
p_du.add_argument("--include-versions", action="store_true", help="包含历史版本(-v)")
p_du.add_argument("--include-multipart", action="store_true", help="包含分片任务(-m)")
p_setmeta = sub.add_parser("setmeta", help="设置对象元数据", parents=parents)
p_setmeta.add_argument("--cloud-url", required=True, help="tos://bucket/key 或 tos://bucket/prefix")
p_setmeta.add_argument("--recursive", action="store_true", help="批量前缀模式(-r)")
p_setmeta.add_argument("--jobs", type=int, help="批量并发(-j)")
p_setmeta.add_argument("--meta", help="自定义元数据(-meta,格式 aaa:bbb#ccc:ddd)")
p_setmeta.add_argument("--content-type", help="Content-Type(-contentType)")
p_setmeta.add_argument("--expires", help="Expires(-expires,格式 YYYYMMDDHHmmSS)")
p_stat = sub.add_parser("stat", help="查询桶/对象属性", parents=parents)
p_stat.add_argument("--cloud-url", required=True, help="tos://bucket 或 tos://bucket/key")
return parser
def _build_legacy_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="byted-volcengine-tosutil(legacy 模式)")
_add_common_args(parser)
parser.add_argument("--command", required=True, help="tosutil 命令名,例如 ls/cp/rm")
parser.add_argument("--source", help="源路径,本地路径或 tos:// URI")
parser.add_argument("--target", help="目标路径,本地路径或 tos:// URI")
parser.add_argument("--cloud-url", help="单资源命令使用的 tos:// URI")
parser.add_argument("--recursive", action="store_true", help="是否递归执行")
parser.add_argument("--force", action="store_true", help="是否强制执行")
parser.add_argument("--jobs", type=int, help="批量任务并发数")
parser.add_argument("--part-concurrency", type=int, help="分片任务并发数")
parser.add_argument("--threshold", help="分片阈值")
parser.add_argument("--part-size", help="分片大小")
parser.add_argument("--meta", help="自定义元数据")
parser.add_argument("--content-type", help="Content-Type")
parser.add_argument("--expires", help="Expires 时间")
parser.add_argument("--directory-mode", action="store_true", help="du 目录模式")
parser.add_argument("--include-versions", action="store_true", help="是否统计版本数据")
parser.add_argument("--include-multipart", action="store_true", help="是否统计分片上传任务")
parser.add_argument("--help-command", help="help 子命令名称")
parser.add_argument("--assume-yes", action="store_true", help="跳过删除类命令确认")
parser.add_argument("--run", action="store_true", help="是否真的执行命令")
parser.add_argument("--preflight", action="store_true", help="执行前进行版本和配置预检查")
parser.add_argument("--connectivity", action="store_true", help="预检查时额外执行 ls 检查连通性")
return parser
def main() -> int:
started_at = time.time()
argv = sys.argv[1:]
use_legacy = "--command" in argv or any(item.startswith("--command=") for item in argv)
parser = _build_legacy_parser() if use_legacy else _build_subcommand_parser()
args = parser.parse_args()
common_options = CommonOptions(
endpoint=args.endpoint,
region=args.region,
access_key=args.access_key,
secret_key=args.secret_key,
security_token=args.security_token,
conf_path=args.conf_path,
bucket_type=args.bucket_type,
output_dir=args.output_dir,
tosutil_binary=args.tosutil_binary,
credential_mode=CredentialMode(args.credential_mode),
timeout_seconds=args.timeout,
retries=args.retries,
retry_backoff_seconds=args.retry_backoff,
)
request = _build_request_from_args(args, common_options, legacy=use_legacy)
try:
if args.preflight:
report = run_preflight(common_options, check_connectivity=args.connectivity)
_emit_json(
ok=report.ok,
code="OK" if report.ok else "E_PREFLIGHT",
message="预检查通过" if report.ok else "预检查失败",
data={"preflight": _serialize_preflight(report)},
started_at=started_at,
)
if not report.ok:
return 2
spec = build_command(request)
except ValidationError as exc:
_emit_json(
ok=False,
code="E_VALIDATION",
message=str(exc),
data={"input": _safe_input_summary(request)},
started_at=started_at,
)
return 2
safe_argv = redact_argv(spec.argv)
preview = {
"command": spec.command,
"summary": spec.summary,
"risk_level": spec.risk_level.value,
"requires_confirmation": spec.requires_confirmation,
"argv": safe_argv,
"shell": shell_join(safe_argv),
"hints": spec.hints,
}
if not args.run:
_emit_json(ok=True, code="OK", message="已生成命令预览", data={"preview": preview}, started_at=started_at)
return 0
assume_yes = getattr(args, "yes", False) or getattr(args, "assume_yes", False)
if spec.requires_confirmation and not assume_yes:
_emit_json(
ok=False,
code="E_CONFIRM_REQUIRED",
message="该操作为高风险/破坏性操作,默认不执行。请确认影响范围后重试。",
data={
"preview": preview,
"next_actions": [
"先用 `ls` 验证影响范围。",
"确认无误后,重新执行并加 `--yes`(或 legacy 模式下加 `--assume-yes`)。",
],
},
started_at=started_at,
)
return 3
runner = TosutilRunner(
timeout_seconds=common_options.timeout_seconds,
retries=common_options.retries,
retry_backoff_seconds=common_options.retry_backoff_seconds,
)
result = runner.run(spec)
parsed = parse_execution_result(spec, result)
_emit_json(
ok=parsed.success,
code="OK" if parsed.success else parsed.advice.code if parsed.advice else "E_RUNTIME",
message=parsed.summary,
data={
"preview": preview,
"result": _safe_result_payload(parsed),
"evidence": {"exit_code": result.exit_code, "duration_ms": result.duration_ms},
},
started_at=started_at,
)
return 0 if parsed.success else 1
def _serialize_preflight(report: object) -> dict[str, object]:
checks = getattr(report, "checks", [])
return {
"ok": getattr(report, "ok", False),
"checks": [
{
"name": check.name,
"success": check.success,
"details": check.details,
"hints": check.hints,
}
for check in checks
],
}
def _build_request_from_args(args: argparse.Namespace, common: CommonOptions, *, legacy: bool) -> OperationRequest:
if legacy:
return OperationRequest(
command=args.command,
common_options=common,
source=args.source,
target=args.target,
cloud_url=args.cloud_url,
recursive=args.recursive,
force=args.force,
jobs=args.jobs,
part_concurrency=args.part_concurrency,
threshold=args.threshold,
part_size=args.part_size,
meta=args.meta,
content_type=args.content_type,
expires=args.expires,
directory_mode=args.directory_mode,
include_versions=args.include_versions,
include_multipart=args.include_multipart,
help_command=args.help_command,
assume_yes=args.assume_yes,
)
return OperationRequest(
command=args.subcommand,
common_options=common,
source=getattr(args, "source", None),
target=getattr(args, "target", None),
cloud_url=getattr(args, "cloud_url", None),
recursive=getattr(args, "recursive", False),
force=getattr(args, "force", False),
jobs=getattr(args, "jobs", None),
part_concurrency=getattr(args, "part_concurrency", None),
threshold=getattr(args, "threshold", None),
part_size=getattr(args, "part_size", None),
meta=getattr(args, "meta", None),
content_type=getattr(args, "content_type", None),
expires=getattr(args, "expires", None),
directory_mode=getattr(args, "directory_mode", False),
include_versions=getattr(args, "include_versions", False),
include_multipart=getattr(args, "include_multipart", False),
help_command=getattr(args, "help_command", None),
assume_yes=False,
)
def _emit_json(*, ok: bool, code: str, message: str, data: dict[str, object], started_at: float) -> None:
print(json.dumps({"ok": ok, "code": code, "message": message, "data": data, "ts": int(started_at)}, ensure_ascii=False, indent=2))
def _safe_input_summary(request: OperationRequest) -> dict[str, object]:
return {
"command": request.command,
"source": request.source,
"target": request.target,
"cloud_url": request.cloud_url,
"recursive": request.recursive,
"force": request.force,
}
def _truncate(text: str | bytes, max_chars: int = 4000) -> dict[str, object]:
if isinstance(text, bytes):
text = text.decode("utf-8", errors="replace")
if len(text) <= max_chars:
return {"text": text, "truncated": False, "max_chars": max_chars}
return {"text": text[:max_chars], "truncated": True, "max_chars": max_chars}
def _safe_result_payload(parsed: object) -> dict[str, object]:
parsed_dict = getattr(parsed, "to_dict")()
stdout = parsed_dict.pop("raw_stdout", "")
stderr = parsed_dict.pop("raw_stderr", "")
parsed_dict["stdout"] = _truncate(stdout)
parsed_dict["stderr"] = _truncate(stderr)
return parsed_dict
if __name__ == "__main__":
sys.exit(main())
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from enum import Enum
from typing import Any
class CredentialMode(str, Enum):
PERMANENT = "permanent"
STS = "sts"
ANONYMOUS = "anonymous"
class ResourceKind(str, Enum):
LOCAL_FILE = "local_file"
LOCAL_DIR = "local_dir"
TOS_URI = "tos_uri"
UNKNOWN = "unknown"
class TransferDirection(str, Enum):
UPLOAD = "upload"
DOWNLOAD = "download"
CLOUD_COPY = "cloud_copy"
UNKNOWN = "unknown"
class RiskLevel(str, Enum):
READONLY = "readonly"
WRITE = "write"
DESTRUCTIVE = "destructive"
@dataclass
class CommonOptions:
endpoint: str | None = None
region: str | None = None
access_key: str | None = None
secret_key: str | None = None
security_token: str | None = None
conf_path: str | None = None
bucket_type: str | None = None
output_dir: str | None = None
tosutil_binary: str = "tosutil"
credential_mode: CredentialMode = CredentialMode.PERMANENT
timeout_seconds: int = 300
retries: int = 0
retry_backoff_seconds: float = 0.8
def to_safe_dict(self) -> dict[str, Any]:
data = asdict(self)
for key in ("access_key", "secret_key", "security_token"):
if data.get(key):
data[key] = "***"
return data
@dataclass
class TosResource:
raw: str
kind: ResourceKind
exists: bool = False
@property
def is_tos(self) -> bool:
return self.kind == ResourceKind.TOS_URI
@dataclass
class OperationRequest:
command: str
common_options: CommonOptions = field(default_factory=CommonOptions)
source: str | None = None
target: str | None = None
cloud_url: str | None = None
recursive: bool = False
force: bool = False
jobs: int | None = None
part_concurrency: int | None = None
threshold: str | None = None
part_size: str | None = None
meta: str | None = None
content_type: str | None = None
expires: str | None = None
directory_mode: bool = False
include_versions: bool = False
include_multipart: bool = False
help_command: str | None = None
extra_args: list[str] = field(default_factory=list)
assume_yes: bool = False
@dataclass
class CommandSpec:
command: str
argv: list[str]
risk_level: RiskLevel
summary: str
requires_confirmation: bool = False
hints: list[str] = field(default_factory=list)
def shell_command(self) -> str:
return " ".join(self.argv)
@dataclass
class ExecutionResult:
exit_code: int
stdout: str | bytes
stderr: str | bytes
duration_ms: int
argv: list[str]
timed_out: bool = False
@property
def combined_output(self) -> str:
return "\n".join(
_coerce_text(part)
for part in (self.stdout, self.stderr)
if part
)
def _coerce_text(value: str | bytes) -> str:
if isinstance(value, bytes):
# 某些超时或底层调用场景仍可能返回 bytes,这里统一兼容处理。
return value.decode("utf-8", errors="replace")
return value
@dataclass
class ErrorAdvice:
code: str
category: str
message: str
probable_causes: list[str] = field(default_factory=list)
next_actions: list[str] = field(default_factory=list)
@dataclass
class ParsedResult:
success: bool
command: str
summary: str
request_id: str | None = None
task_id: str | None = None
success_count: int | None = None
failed_count: int | None = None
bucket_count: int | None = None
hints: list[str] = field(default_factory=list)
advice: ErrorAdvice | None = None
raw_stdout: str = ""
raw_stderr: str = ""
def to_dict(self) -> dict[str, Any]:
data = asdict(self)
if self.advice is None:
data.pop("advice", None)
return data
@dataclass
class PreflightCheck:
name: str
success: bool
details: str
hints: list[str] = field(default_factory=list)
@dataclass
class PreflightReport:
ok: bool
checks: list[PreflightCheck] = field(default_factory=list)
def add(self, check: PreflightCheck) -> None:
self.checks.append(check)
if not check.success:
self.ok = False
from __future__ import annotations
import re
from models import CommandSpec, ErrorAdvice, ExecutionResult, ParsedResult
BUCKET_COUNT_RE = re.compile(r"Bucket number is:\s*(\d+)", re.IGNORECASE)
SUCCESS_COUNT_RE = re.compile(r"Succeed count is:\s*(\d+)", re.IGNORECASE)
FAILED_COUNT_RE = re.compile(r"Failed count is:\s*(\d+)", re.IGNORECASE)
TASK_ID_RE = re.compile(r"Task id is:\s*([A-Za-z0-9-]+)", re.IGNORECASE)
REQUEST_ID_RE = re.compile(r"request id(?:\s*\[|\s+)([A-Za-z0-9-]+)", re.IGNORECASE)
def map_error(command: str, result: ExecutionResult) -> ErrorAdvice | None:
output = result.combined_output.lower()
if "http status [403]" in output:
return ErrorAdvice(
code="E_PERMISSION_403",
category="permission_error",
message="命令执行失败,疑似访问凭证无效或无权限访问目标资源。",
probable_causes=["AK/SK 配置错误。", "STS Token 已过期。", "目标桶或对象未授予当前身份访问权限。"],
next_actions=["执行 `tosutil config` 检查当前配置。", "执行 `tosutil ls` 验证基础权限。", "确认目标桶策略、ACL 或 RAM 权限设置。"],
)
if "a connection attempt failed" in output:
return ErrorAdvice(
code="E_NETWORK_CONNECT",
category="network_error",
message="命令执行失败,当前环境无法连通 TOS Endpoint。",
probable_causes=["网络异常或 DNS 解析失败。", "企业代理、防火墙或安全组限制。", "Endpoint 配置错误。"],
next_actions=["确认 Endpoint 是否为正确的 TOS 协议域名。", "检查本机网络、代理与 VPN 配置。", "先执行 `tosutil ls` 或网络诊断相关命令定位连通性问题。"],
)
if "config file" in output and "not exist" in output:
return ErrorAdvice(
code="E_CONFIG_NOT_FOUND",
category="config_error",
message="命令执行失败,配置文件不存在或无法读取。",
probable_causes=["传入的 `-conf` 路径不存在。", "当前用户没有权限访问配置文件。"],
next_actions=["检查 `-conf` 参数路径是否正确。", "若需要默认配置,可先执行 `tosutil config` 初始化。"],
)
if "unknown flag" in output or "invalid argument" in output:
return ErrorAdvice(
code="E_INVALID_ARGUMENT",
category="argument_error",
message="命令执行失败,参数不合法或当前命令不支持该参数。",
probable_causes=["命令拼装错误。", "tosutil 版本与参数能力不匹配。"],
next_actions=["执行 `tosutil help <command>` 查看当前版本支持的参数。", "减少可选参数,先验证最小命令是否可执行。"],
)
if result.timed_out:
return ErrorAdvice(
code="E_TIMEOUT",
category="timeout_error",
message="命令执行超时。",
probable_causes=["批量任务过大。", "网络波动导致任务执行缓慢。"],
next_actions=["缩小操作范围后重试。", "针对 `cp`、`du` 等批量任务调整并发与扫描范围。"],
)
if result.exit_code != 0:
return ErrorAdvice(
code="E_RUNTIME",
category="runtime_error",
message=f"`{command}` 执行失败,请结合原始输出进一步排查。",
probable_causes=["命令参数与资源状态不匹配。", "环境、权限或网络存在异常。"],
next_actions=["查看 stdout/stderr 原始输出。", "先退回最小只读命令验证环境,例如 `version` 或 `ls`。"],
)
return None
def parse_execution_result(spec: CommandSpec, result: ExecutionResult) -> ParsedResult:
output = result.combined_output
bucket_count = _search_int(BUCKET_COUNT_RE, output)
success_count = _search_int(SUCCESS_COUNT_RE, output)
failed_count = _search_int(FAILED_COUNT_RE, output)
task_id = _search_text(TASK_ID_RE, output)
request_id = _search_text(REQUEST_ID_RE, output)
success = result.exit_code == 0 and not result.timed_out
advice = None if success else map_error(spec.command, result)
hints = list(spec.hints)
if bucket_count is not None:
hints.append(f"当前列举结果包含 {bucket_count} 个桶。")
if success_count is not None or failed_count is not None:
hints.append(f"批量任务统计:成功 {success_count or 0},失败 {failed_count or 0}。")
if task_id:
hints.append(f"任务标识:{task_id}")
return ParsedResult(
success=success,
command=spec.command,
summary=_build_summary(spec.command, success, bucket_count, success_count, failed_count),
request_id=request_id,
task_id=task_id,
success_count=success_count,
failed_count=failed_count,
bucket_count=bucket_count,
hints=hints,
advice=advice,
raw_stdout=result.stdout,
raw_stderr=result.stderr,
)
def _search_int(pattern: re.Pattern[str], text: str) -> int | None:
match = pattern.search(text)
if not match:
return None
return int(match.group(1))
def _search_text(pattern: re.Pattern[str], text: str) -> str | None:
match = pattern.search(text)
if not match:
return None
return match.group(1)
def _build_summary(
command: str,
success: bool,
bucket_count: int | None,
success_count: int | None,
failed_count: int | None,
) -> str:
if not success:
return f"`{command}` 执行失败。"
if command == "ls" and bucket_count is not None:
return f"`ls` 执行成功,共列举到 {bucket_count} 个桶。"
if success_count is not None or failed_count is not None:
return f"`{command}` 执行完成,成功 {success_count or 0} 个,失败 {failed_count or 0} 个。"
return f"`{command}` 执行成功。"
from __future__ import annotations
"""
集中放置 tosutil skill 的“服务层”逻辑:
- 命令构建
- 资源识别和公共参数校验
- 子进程执行与重试
- 预检查
- 输出脱敏与命令串拼接
"""
import os
import re
import shlex
import subprocess
import time
from pathlib import Path
from typing import Callable
from models import (
CommandSpec,
CommonOptions,
ExecutionResult,
OperationRequest,
PreflightCheck,
PreflightReport,
ResourceKind,
RiskLevel,
TosResource,
TransferDirection,
)
TOS_URI_PREFIX = "tos://"
_S3_ENDPOINT_HINT_RE = re.compile(r"(^|\.)(s3|tos-s3)(\.|$)", re.IGNORECASE)
_RETRYABLE_NETWORK_SNIPPET = "a connection attempt failed"
class ValidationError(ValueError):
pass
# ----------------------------
# Resource detection
# ----------------------------
def is_tos_uri(value: str | None) -> bool:
return bool(value) and value.startswith(TOS_URI_PREFIX)
def detect_resource(path_or_uri: str | None) -> TosResource:
if not path_or_uri:
return TosResource(raw="", kind=ResourceKind.UNKNOWN, exists=False)
if is_tos_uri(path_or_uri):
return TosResource(raw=path_or_uri, kind=ResourceKind.TOS_URI, exists=True)
path = Path(path_or_uri).expanduser()
if path.exists() and path.is_dir():
return TosResource(raw=path_or_uri, kind=ResourceKind.LOCAL_DIR, exists=True)
if path.exists() and path.is_file():
return TosResource(raw=path_or_uri, kind=ResourceKind.LOCAL_FILE, exists=True)
if path_or_uri.endswith(("/", os.sep)):
return TosResource(raw=path_or_uri, kind=ResourceKind.LOCAL_DIR, exists=False)
return TosResource(raw=path_or_uri, kind=ResourceKind.LOCAL_FILE, exists=False)
# ----------------------------
# Common validation & CLI helpers
# ----------------------------
def validate_tos_endpoint(endpoint: str | None) -> None:
if endpoint and _S3_ENDPOINT_HINT_RE.search(endpoint):
raise ValidationError("检测到疑似 S3 协议域名,请改用 TOS 协议 Endpoint。")
def validate_common_options(options: CommonOptions) -> list[str]:
hints: list[str] = []
validate_tos_endpoint(options.endpoint)
if options.conf_path and not Path(options.conf_path).expanduser().exists():
raise ValidationError("`-conf` 指向的配置文件不存在。")
mode = options.credential_mode.value
if mode == "anonymous":
if options.access_key or options.secret_key or options.security_token:
raise ValidationError("匿名访问模式下,不应再传入 AK/SK/Token。")
hints.append("当前为匿名访问模式,仅适用于公共读或公共写场景。")
return hints
if mode == "sts" and not options.security_token:
raise ValidationError("STS 模式缺少 security token。")
return hints
def normalize_command_name(command: str) -> str:
normalized = command.strip().lower()
if not normalized:
raise ValidationError("命令名不能为空。")
return normalized
def ensure_required(value: str | None, field_name: str) -> str:
if value:
return value
raise ValidationError(f"缺少必填参数:{field_name}")
def append_flag(argv: list[str], flag: str, enabled: bool) -> None:
if enabled:
argv.append(flag)
def append_option(argv: list[str], flag: str, value: str | int | None) -> None:
if value is None or value == "":
return
argv.append(f"{flag}={value}")
def shell_join(argv: list[str]) -> str:
join_fn = getattr(shlex, "join", None)
if join_fn:
return join_fn(argv)
return " ".join(shlex.quote(arg) for arg in argv)
def redact_argv(argv: list[str]) -> list[str]:
return [mask_sensitive_text(item) for item in argv]
def mask_sensitive_text(value: str) -> str:
masked = value
for pattern, replacement in (
(r"(-i=)(\S+)", r"\1***"),
(r"(-k=)(\S+)", r"\1***"),
(r"(-t=)(\S+)", r"\1***"),
):
masked = re.sub(pattern, replacement, masked)
return masked
# ----------------------------
# Command building (public)
# ----------------------------
def build_command(request: OperationRequest) -> CommandSpec:
command = normalize_command_name(request.command)
hints = validate_common_options(request.common_options)
builders: dict[str, Callable[[OperationRequest, list[str]], CommandSpec]] = {
"version": _build_version,
"help": _build_help,
"config": _build_config,
"ls": _build_ls,
"mb": _build_mb,
"cp": _build_cp,
"rm": _build_rm,
"du": _build_du,
"setmeta": _build_setmeta,
"stat": _build_stat,
}
builder = builders.get(command)
if not builder:
raise ValidationError(f"暂未实现命令:{command}")
return builder(request, hints)
def _build_version(request: OperationRequest, hints: list[str]) -> CommandSpec:
return CommandSpec(
command="version",
argv=[request.common_options.tosutil_binary, "version"],
risk_level=RiskLevel.READONLY,
summary="查看 tosutil 版本信息",
hints=hints,
)
def _build_help(request: OperationRequest, hints: list[str]) -> CommandSpec:
argv = [request.common_options.tosutil_binary, "help"]
if request.help_command:
argv.append(request.help_command)
_append_common_options(argv, request.common_options)
return CommandSpec(command="help", argv=argv, risk_level=RiskLevel.READONLY, summary="查看 tosutil 帮助文档", hints=hints)
def _build_config(request: OperationRequest, hints: list[str]) -> CommandSpec:
argv = [request.common_options.tosutil_binary, "config"]
_append_common_options(argv, request.common_options, include_output=False)
return CommandSpec(command="config", argv=argv, risk_level=RiskLevel.WRITE, summary="初始化或更新 tosutil 配置", hints=hints)
def _build_ls(request: OperationRequest, hints: list[str]) -> CommandSpec:
argv = [request.common_options.tosutil_binary, "ls"]
if request.cloud_url:
argv.append(request.cloud_url)
_append_common_options(argv, request.common_options)
return CommandSpec(command="ls", argv=argv, risk_level=RiskLevel.READONLY, summary="列举桶或对象", hints=hints)
def _build_mb(request: OperationRequest, hints: list[str]) -> CommandSpec:
cloud_url = ensure_required(request.cloud_url, "cloud_url")
_ensure_tos_resource(cloud_url, "mb")
argv = [request.common_options.tosutil_binary, "mb", cloud_url]
_append_common_options(argv, request.common_options, include_output=False)
return CommandSpec(command="mb", argv=argv, risk_level=RiskLevel.WRITE, summary="创建新桶", hints=hints)
def _build_cp(request: OperationRequest, hints: list[str]) -> CommandSpec:
source = ensure_required(request.source, "source")
target = ensure_required(request.target, "target")
src_resource = detect_resource(source)
dst_resource = detect_resource(target)
direction = _detect_transfer_direction(src_resource, dst_resource)
if direction == TransferDirection.UNKNOWN:
raise ValidationError("无法识别 `cp` 的传输方向,请检查源和目标路径。")
argv = [request.common_options.tosutil_binary, "cp", source, target]
append_flag(argv, "-r", request.recursive or src_resource.kind == ResourceKind.LOCAL_DIR)
append_option(argv, "-j", request.jobs)
append_option(argv, "-p", request.part_concurrency)
append_option(argv, "-threshold", request.threshold)
append_option(argv, "-ps", request.part_size)
_append_common_options(argv, request.common_options)
summary_by_direction = {
TransferDirection.UPLOAD: "上传本地文件或目录到 TOS",
TransferDirection.DOWNLOAD: "从 TOS 下载对象到本地",
TransferDirection.CLOUD_COPY: "在 TOS 内部复制对象",
}
return CommandSpec(
command="cp",
argv=argv,
risk_level=RiskLevel.WRITE,
summary=summary_by_direction[direction],
hints=hints + _build_cp_hints(direction, request),
)
def _build_rm(request: OperationRequest, hints: list[str]) -> CommandSpec:
cloud_url = ensure_required(request.cloud_url, "cloud_url")
_ensure_tos_resource(cloud_url, "rm")
argv = [request.common_options.tosutil_binary, "rm", cloud_url]
append_flag(argv, "-r", request.recursive)
append_flag(argv, "-f", request.force or request.assume_yes)
append_option(argv, "-j", request.jobs)
_append_common_options(argv, request.common_options)
return CommandSpec(
command="rm",
argv=argv,
risk_level=RiskLevel.DESTRUCTIVE,
summary="删除桶、对象或对象前缀",
requires_confirmation=not (request.force or request.assume_yes),
hints=hints + _build_rm_hints(request),
)
def _build_du(request: OperationRequest, hints: list[str]) -> CommandSpec:
cloud_url = ensure_required(request.cloud_url, "cloud_url")
_ensure_tos_resource(cloud_url, "du")
argv = [request.common_options.tosutil_binary, "du", cloud_url]
append_flag(argv, "-d", request.directory_mode)
append_flag(argv, "-v", request.include_versions)
append_flag(argv, "-m", request.include_multipart)
_append_common_options(argv, request.common_options, include_output=False)
if not request.directory_mode:
hints.append("如果对象规模较大,建议按目录拆分统计,避免一次性全量扫描。")
return CommandSpec(command="du", argv=argv, risk_level=RiskLevel.READONLY, summary="统计对象与分片大小和数量", hints=hints)
def _build_setmeta(request: OperationRequest, hints: list[str]) -> CommandSpec:
cloud_url = ensure_required(request.cloud_url, "cloud_url")
_ensure_tos_resource(cloud_url, "setmeta")
argv = [request.common_options.tosutil_binary, "setmeta", cloud_url]
append_flag(argv, "-r", request.recursive)
append_option(argv, "-j", request.jobs)
append_option(argv, "-meta", request.meta)
append_option(argv, "-contentType", request.content_type)
append_option(argv, "-expires", request.expires)
_append_common_options(argv, request.common_options)
if request.recursive and request.jobs is None:
hints.append("批量设置元数据时建议显式设置 `-j`,避免默认并发不可控。")
return CommandSpec(command="setmeta", argv=argv, risk_level=RiskLevel.WRITE, summary="设置对象元数据", hints=hints)
def _build_stat(request: OperationRequest, hints: list[str]) -> CommandSpec:
cloud_url = ensure_required(request.cloud_url, "cloud_url")
_ensure_tos_resource(cloud_url, "stat")
argv = [request.common_options.tosutil_binary, "stat", cloud_url]
_append_common_options(argv, request.common_options, include_output=False)
return CommandSpec(command="stat", argv=argv, risk_level=RiskLevel.READONLY, summary="查询桶或对象属性", hints=hints)
def _append_common_options(argv: list[str], options: CommonOptions, *, include_output: bool = True) -> None:
append_option(argv, "-e", options.endpoint)
append_option(argv, "-re", options.region)
if options.credential_mode.value == "anonymous":
argv.extend(["-i=", "-k=", "-t="])
else:
append_option(argv, "-i", options.access_key)
append_option(argv, "-k", options.secret_key)
append_option(argv, "-t", options.security_token)
append_option(argv, "-conf", options.conf_path)
append_option(argv, "-bt", options.bucket_type)
if include_output:
append_option(argv, "-o", options.output_dir)
def _detect_transfer_direction(source: TosResource, target: TosResource) -> TransferDirection:
if source.is_tos and target.is_tos:
return TransferDirection.CLOUD_COPY
if source.is_tos and target.kind in (ResourceKind.LOCAL_DIR, ResourceKind.LOCAL_FILE):
return TransferDirection.DOWNLOAD
if target.is_tos and source.kind in (ResourceKind.LOCAL_DIR, ResourceKind.LOCAL_FILE):
return TransferDirection.UPLOAD
return TransferDirection.UNKNOWN
def _ensure_tos_resource(cloud_url: str, command: str) -> None:
resource = detect_resource(cloud_url)
if resource.kind != ResourceKind.TOS_URI:
raise ValidationError(f"`{command}` 需要传入 `tos://` 资源地址。")
def _build_cp_hints(direction: TransferDirection, request: OperationRequest) -> list[str]:
hints: list[str] = []
if direction == TransferDirection.UPLOAD:
hints.append("上传大文件时,建议结合 `-threshold`、`-p` 调整分片策略。")
if direction == TransferDirection.DOWNLOAD:
hints.append("下载目录或前缀时,请确认目标本地路径具备写权限。")
if request.jobs and request.jobs > 50:
hints.append("当前并发较高,建议根据机器资源和带宽情况评估是否下调。")
return hints
def _build_rm_hints(request: OperationRequest) -> list[str]:
hints = ["删除前建议先用 `ls` 检查影响范围。"]
if request.recursive:
hints.append("当前为递归删除,请确认前缀范围是否准确。")
if request.force:
hints.append("当前使用强制删除,命令将跳过交互确认。")
return hints
# ----------------------------
# Runner
# ----------------------------
class TosutilRunner:
def __init__(self, *, timeout_seconds: int = 300, retries: int = 0, retry_backoff_seconds: float = 0.8) -> None:
self.timeout_seconds = timeout_seconds
self.retries = max(0, retries)
self.retry_backoff_seconds = max(0.0, retry_backoff_seconds)
def run(self, spec: CommandSpec) -> ExecutionResult:
last_result: ExecutionResult | None = None
attempts = 1 + self.retries
for attempt in range(1, attempts + 1):
result = self._run_once(spec)
last_result = result
if result.exit_code == 0 and not result.timed_out:
return result
if attempt >= attempts or not self._is_retryable(result):
return result
# 指数退避:瞬时网络抖动下更稳定。
time.sleep(self.retry_backoff_seconds * (2 ** (attempt - 1)))
return last_result or self._run_once(spec)
def _run_once(self, spec: CommandSpec) -> ExecutionResult:
started_at = time.perf_counter()
try:
completed = subprocess.run(
spec.argv,
capture_output=True,
text=True,
check=False,
timeout=self.timeout_seconds,
)
return ExecutionResult(
exit_code=completed.returncode,
stdout=completed.stdout,
stderr=completed.stderr,
duration_ms=int((time.perf_counter() - started_at) * 1000),
argv=spec.argv,
timed_out=False,
)
except subprocess.TimeoutExpired as exc:
return ExecutionResult(
exit_code=124,
stdout=exc.stdout or "",
stderr=exc.stderr or "",
duration_ms=int((time.perf_counter() - started_at) * 1000),
argv=spec.argv,
timed_out=True,
)
def _is_retryable(self, result: ExecutionResult) -> bool:
if result.timed_out:
return True
return _RETRYABLE_NETWORK_SNIPPET in result.combined_output.lower()
# ----------------------------
# Preflight
# ----------------------------
def run_preflight(common_options: CommonOptions, *, check_connectivity: bool = False) -> PreflightReport:
runner = TosutilRunner(timeout_seconds=min(common_options.timeout_seconds, 30), retries=0)
report = PreflightReport(ok=True)
binary_check = _check_binary(common_options.tosutil_binary)
report.add(binary_check)
if not binary_check.success:
return report
report.add(_check_conf_path(common_options))
report.add(_run_preflight_command(runner, OperationRequest(command="version", common_options=common_options)))
if check_connectivity:
report.add(_run_preflight_command(runner, OperationRequest(command="ls", common_options=common_options)))
return report
def _run_preflight_command(runner: TosutilRunner, request: OperationRequest) -> PreflightCheck:
spec = build_command(request)
result = runner.run(spec)
success = result.exit_code == 0 and not result.timed_out
details = f"`{request.command}` 检查通过" if success else f"`{request.command}` 检查失败"
hints: list[str] = []
snippet = _compact_output(result.combined_output)
if snippet and not success:
hints.append(f"输出摘要:{snippet}")
return PreflightCheck(name=request.command, success=success, details=details, hints=hints)
def _compact_output(text: str, max_chars: int = 200) -> str:
cleaned = " ".join(text.split())
if not cleaned:
return ""
if len(cleaned) <= max_chars:
return cleaned
return cleaned[:max_chars] + "..."
def _check_binary(binary_path: str) -> PreflightCheck:
path = Path(binary_path).expanduser()
if path.exists() and path.is_file() and os.access(str(path), os.X_OK):
return PreflightCheck(name="binary", success=True, details=f"已找到可执行文件:{path}")
return PreflightCheck(
name="binary",
success=False,
details=f"未找到 tosutil 可执行文件:{binary_path}",
hints=["请确认 `tosutil` 已下载并具备执行权限,例如 `chmod +x tosutil`。"],
)
def _check_conf_path(common_options: CommonOptions) -> PreflightCheck:
if not common_options.conf_path:
return PreflightCheck(name="config_path", success=True, details="未显式指定 `-conf`,将使用默认配置文件路径。")
conf_path = Path(common_options.conf_path).expanduser()
if conf_path.exists():
return PreflightCheck(name="config_path", success=True, details=f"配置文件存在:{conf_path}")
return PreflightCheck(
name="config_path",
success=False,
details=f"配置文件不存在:{conf_path}",
hints=["请先创建配置文件,或移除 `-conf` 使用默认配置路径。"],
)
Related skills
FAQ
Does it execute commands by default?
No. It defaults to generating command previews and only executes when --run is passed; destructive commands like rm also need explicit --yes.
Which OSes does tosutil support?
Windows, Linux, and macOS, including Intel and Apple Silicon macs.