From 74726d3a1636524d4a178faab1de9067cf2c5b58 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 03:16:45 +0000 Subject: [PATCH 1/5] feat: add sync-skills command for ai tool skill dirs Signed-off-by: GitHub Co-authored-by: OhYee <13498329+OhYee@users.noreply.github.com> --- README.md | 1 + README_zh.md | 1 + docs/en/index.md | 1 + docs/en/sync-skills.md | 45 +++ docs/zh/index.md | 1 + docs/zh/sync-skills.md | 44 +++ src/agentrun_cli/commands/sync_skills_cmd.py | 314 +++++++++++++++++++ src/agentrun_cli/main.py | 2 + tests/integration/test_sync_skills_cmd.py | 64 ++++ tests/unit/test_sync_skills_cmd.py | 166 ++++++++++ 10 files changed, 639 insertions(+) create mode 100644 docs/en/sync-skills.md create mode 100644 docs/zh/sync-skills.md create mode 100644 src/agentrun_cli/commands/sync_skills_cmd.py create mode 100644 tests/integration/test_sync_skills_cmd.py create mode 100644 tests/unit/test_sync_skills_cmd.py diff --git a/README.md b/README.md index 52c779e..18d0582 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,7 @@ Multi-document YAMLs (`---` separated) let you deploy many agents in one call. | `sandbox` | `sb` | Sandboxes + files, processes, contexts, templates, browser | [en](./docs/en/sandbox.md) · [zh](./docs/zh/sandbox.md) | | `tool` | | MCP and FunctionCall tools | [en](./docs/en/tool.md) · [zh](./docs/zh/tool.md) | | `skill` | | Platform skill packages + local execution | [en](./docs/en/skill.md) · [zh](./docs/zh/skill.md) | +| `sync-skills` | | Sync platform skills to Claude Code/Codex local skills directories | [en](./docs/en/sync-skills.md) · [zh](./docs/zh/sync-skills.md) | | `super-agent` | `sa` | Quickstart / CRUD / declarative / conversation | [en](./docs/en/super-agent.md) · [zh](./docs/zh/super-agent.md) | ## Documentation diff --git a/README_zh.md b/README_zh.md index d70b48f..c383c75 100644 --- a/README_zh.md +++ b/README_zh.md @@ -181,6 +181,7 @@ ar sa invoke my-helper -m "帮我规划今天的事情" --text-only | `sandbox` | `sb` | 沙箱 + 文件、进程、上下文、模板、浏览器 | [en](./docs/en/sandbox.md) · [zh](./docs/zh/sandbox.md) | | `tool` | | MCP 与 FunctionCall 工具 | [en](./docs/en/tool.md) · [zh](./docs/zh/tool.md) | | `skill` | | 平台技能包 + 本地执行 | [en](./docs/en/skill.md) · [zh](./docs/zh/skill.md) | +| `sync-skills` | | 将平台 skills 同步到 Claude Code/Codex 本地技能目录 | [en](./docs/en/sync-skills.md) · [zh](./docs/zh/sync-skills.md) | | `super-agent` | `sa` | 一键拉起 / CRUD / 声明式 / 会话管理 | [en](./docs/en/super-agent.md) · [zh](./docs/zh/super-agent.md) | ## 文档 diff --git a/docs/en/index.md b/docs/en/index.md index f447eeb..3cab21e 100644 --- a/docs/en/index.md +++ b/docs/en/index.md @@ -197,4 +197,5 @@ Errors are written to stderr as JSON: | `sandbox` | `sb` | Sandboxes plus file, process, context, template and browser sub-groups | [sandbox.md](./sandbox.md) | | `tool` | | MCP and FunctionCall tools + sub-tool invocation | [tool.md](./tool.md) | | `skill` | | Platform skill packages + local scan/load/exec | [skill.md](./skill.md) | +| `sync-skills` | | Sync platform skills to Claude Code/Codex skill directories | [sync-skills.md](./sync-skills.md) | | `super-agent` | `sa` | Quickstart REPL, declarative deploy, CRUD, conversations | [super-agent.md](./super-agent.md) | diff --git a/docs/en/sync-skills.md b/docs/en/sync-skills.md new file mode 100644 index 0000000..88de965 --- /dev/null +++ b/docs/en/sync-skills.md @@ -0,0 +1,45 @@ +**English** | [简体中文](../zh/sync-skills.md) + +# ar sync-skills + +Sync platform Skills to local AI tool skill directories (Claude Code or Codex). + +``` +ar sync-skills [tool] [scope] [options] +``` + +## Options + +| Flag | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `--claude-code` | flag | yes* | | Sync to Claude Code skill directory. | +| `--codex` | flag | yes* | | Sync to Codex skill directory. | +| `--user` | flag | yes** | | Sync to user-level directory. | +| `--project` | flag | yes** | | Sync to project-level directory. | +| `--workspace` | multi | no | all | Workspace filter, repeatable. | +| `--delete-unmanaged` | flag | no | false | Delete local skills outside selected workspace scope (with confirmation). | +| `-y`, `--yes` | flag | no | false | Skip confirmation prompts. | + +\* Exactly one of `--claude-code` or `--codex` is required. +\** Exactly one of `--user` or `--project` is required. + +## Behavior + +- By default, all platform skills are selected (unless `--workspace` is provided). +- Before downloading/updating skills, the CLI asks for confirmation. +- Sync checks local metadata and only downloads skills that are missing or outdated. +- When `--delete-unmanaged` is enabled, local skill directories not in the selected + managed scope can be removed after confirmation. + +## Examples + +```bash +# Sync skills from workspace abc + def into user-level Claude Code skills +ar sync-skills --claude-code --user --workspace abc --workspace def + +# Sync skills from workspace abc into project-level Codex skills +ar sync-skills --codex --project --workspace abc + +# Sync all skills and also remove unmanaged local skills +ar sync-skills --claude-code --project --delete-unmanaged +``` diff --git a/docs/zh/index.md b/docs/zh/index.md index 9aec785..02cdcb5 100644 --- a/docs/zh/index.md +++ b/docs/zh/index.md @@ -193,4 +193,5 @@ ar sandbox exec "$SANDBOX" --code "print('hello')" | `sandbox` | `sb` | 沙箱以及 file / process / context / template / browser 子组 | [sandbox.md](./sandbox.md) | | `tool` | | MCP 与 FunctionCall 工具 + 子工具调用 | [tool.md](./tool.md) | | `skill` | | 平台侧技能包 + 本地 scan / load / exec | [skill.md](./skill.md) | +| `sync-skills` | | 把平台 skills 同步到 Claude Code/Codex 本地技能目录 | [sync-skills.md](./sync-skills.md) | | `super-agent` | `sa` | 一键拉起 REPL、声明式部署、CRUD、会话管理 | [super-agent.md](./super-agent.md) | diff --git a/docs/zh/sync-skills.md b/docs/zh/sync-skills.md new file mode 100644 index 0000000..a5a1abd --- /dev/null +++ b/docs/zh/sync-skills.md @@ -0,0 +1,44 @@ +[English](../en/sync-skills.md) | **简体中文** + +# ar sync-skills + +把平台上的 Skill 同步到本地 AI 工具技能目录(Claude Code 或 Codex)。 + +``` +ar sync-skills [tool] [scope] [options] +``` + +## 参数 + +| Flag | 类型 | 必填 | 默认 | 说明 | +|------|------|------|------|------| +| `--claude-code` | flag | 是* | | 同步到 Claude Code 技能目录。 | +| `--codex` | flag | 是* | | 同步到 Codex 技能目录。 | +| `--user` | flag | 是** | | 同步到用户级目录。 | +| `--project` | flag | 是** | | 同步到项目级目录。 | +| `--workspace` | multi | 否 | 全部 | 工作空间过滤,可重复。 | +| `--delete-unmanaged` | flag | 否 | false | 删除不在所选管控范围内的本地技能目录(需确认)。 | +| `-y`、`--yes` | flag | 否 | false | 跳过确认提示。 | + +\* `--claude-code` 与 `--codex` 二选一。 +\** `--user` 与 `--project` 二选一。 + +## 行为说明 + +- 默认同步全部平台 Skill(除非传入 `--workspace`)。 +- 在下载/更新 Skill 前会先进行用户确认。 +- 同步会检查本地元数据,仅下载缺失或有更新的 Skill。 +- 开启 `--delete-unmanaged` 时,会在确认后删除不在当前管控范围内的本地 Skill 目录。 + +## 示例 + +```bash +# 将 workspace abc + def 的 skills 同步到用户级 Claude Code 目录 +ar sync-skills --claude-code --user --workspace abc --workspace def + +# 将 workspace abc 的 skills 同步到项目级 Codex 目录 +ar sync-skills --codex --project --workspace abc + +# 同步全部 skills,并删除不在管控范围内的本地 skills +ar sync-skills --claude-code --project --delete-unmanaged +``` diff --git a/src/agentrun_cli/commands/sync_skills_cmd.py b/src/agentrun_cli/commands/sync_skills_cmd.py new file mode 100644 index 0000000..ad9e462 --- /dev/null +++ b/src/agentrun_cli/commands/sync_skills_cmd.py @@ -0,0 +1,314 @@ +"""``ar sync-skills`` — sync platform skills to local AI tool directories.""" + +import json +import os +import shutil + +import click + +from agentrun_cli._utils.config import build_sdk_config +from agentrun_cli._utils.error import handle_errors +from agentrun_cli._utils.inner_client import get_agentrun_client +from agentrun_cli._utils.output import format_output + +_META_FILE_NAME = ".agentrun-sync-skills.json" + + +def _ctx_cfg(ctx): + return (ctx.obj or {}).get("profile"), (ctx.obj or {}).get("region") + + +def _extract_skill_name(skill) -> str: + return getattr(skill, "tool_name", None) or getattr(skill, "name", None) or "" + + +def _extract_updated_at(skill): + return ( + getattr(skill, "updated_at", None) + or getattr(skill, "last_updated_at", None) + or getattr(skill, "last_modified_time", None) + or getattr(skill, "updated_time", None) + ) + + +def _extract_workspace_values(skill) -> set[str]: + keys = ("name", "workspace_name", "workspace", "id", "workspace_id") + values = set() + raw = ( + getattr(skill, "workspace_names", None) + or getattr(skill, "workspaces", None) + or getattr(skill, "workspace_name", None) + or getattr(skill, "workspace", None) + ) + if raw is None: + return values + if isinstance(raw, str): + return {raw} + if isinstance(raw, (list, tuple, set)): + for item in raw: + if isinstance(item, str): + values.add(item) + elif isinstance(item, dict): + for key in keys: + v = item.get(key) + if v: + values.add(str(v)) + else: + for key in keys: + v = getattr(item, key, None) + if v: + values.add(str(v)) + return values + if isinstance(raw, dict): + for key in keys: + v = raw.get(key) + if v: + values.add(str(v)) + return values + for key in keys: + v = getattr(raw, key, None) + if v: + values.add(str(v)) + return values + + +def _resolve_target_dir(ai_tool: str, scope: str) -> str: + if ai_tool == "claude-code": + root = ( + os.path.expanduser("~/.claude") + if scope == "user" + else os.path.abspath(".claude") + ) + else: + root = ( + os.path.expanduser("~/.codex") + if scope == "user" + else os.path.abspath(".codex") + ) + return os.path.join(root, "skills") + + +def _meta_file_path(target_dir: str) -> str: + return os.path.join(target_dir, _META_FILE_NAME) + + +def _load_sync_meta(target_dir: str) -> dict: + path = _meta_file_path(target_dir) + if not os.path.isfile(path): + return {} + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + return data if isinstance(data, dict) else {} + + +def _save_sync_meta(target_dir: str, data: dict): + path = _meta_file_path(target_dir) + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2, sort_keys=True) + + +def _list_local_skill_dirs(target_dir: str) -> set[str]: + if not os.path.isdir(target_dir): + return set() + result = set() + for entry in os.listdir(target_dir): + if entry.startswith("."): + continue + full = os.path.join(target_dir, entry) + if os.path.isdir(full): + result.add(entry) + return result + + +def _list_platform_skills(profile, region): + from alibabacloud_agentrun20250910 import models + + client, headers, runtime = get_agentrun_client(profile, region) + + page_number = 1 + page_size = 100 + all_items = [] + while True: + req = models.ListToolsRequest( + tool_type="SKILL", + page_number=page_number, + page_size=page_size, + ) + resp = client.list_tools_with_options(req, headers, runtime) + data = getattr(resp.body, "data", None) + page_items = (getattr(data, "items", None) or []) if data else [] + if not page_items: + break + all_items.extend(page_items) + if len(page_items) < page_size: + break + page_number += 1 + return all_items + + +@click.command( + "sync-skills", + help="Sync platform skills to Claude Code or Codex skill directories.", +) +@click.option( + "--claude-code", + "use_claude_code", + is_flag=True, + default=False, + help="Sync to Claude Code skill directory.", +) +@click.option( + "--codex", + "use_codex", + is_flag=True, + default=False, + help="Sync to Codex skill directory.", +) +@click.option( + "--user", + "user_scope", + is_flag=True, + default=False, + help="Use user-level skill directory.", +) +@click.option( + "--project", + "project_scope", + is_flag=True, + default=False, + help="Use project-level skill directory.", +) +@click.option( + "--workspace", + "workspaces", + multiple=True, + help="Workspace filter (repeatable). Defaults to all workspaces.", +) +@click.option( + "--delete-unmanaged", + is_flag=True, + default=False, + help=( + "Delete local skills not in selected workspace scope " + "(with confirmation)." + ), +) +@click.option( + "-y", + "--yes", + "auto_confirm", + is_flag=True, + default=False, + help="Skip confirmation prompts.", +) +@click.pass_context +@handle_errors +def sync_skills( + ctx, + use_claude_code, + use_codex, + user_scope, + project_scope, + workspaces, + delete_unmanaged, + auto_confirm, +): + """Sync platform skills to local AI tool skill directories.""" + if use_claude_code == use_codex: + raise click.UsageError("Exactly one of --claude-code or --codex is required.") + if user_scope == project_scope: + raise click.UsageError("Exactly one of --user or --project is required.") + + ai_tool = "claude-code" if use_claude_code else "codex" + scope = "user" if user_scope else "project" + target_dir = _resolve_target_dir(ai_tool, scope) + os.makedirs(target_dir, exist_ok=True) + + profile, region = _ctx_cfg(ctx) + cfg = build_sdk_config(profile_name=profile, region=region) + all_skills = _list_platform_skills(profile, region) + requested_workspaces = set(workspaces or []) + + selected_skills = [] + for skill in all_skills: + if requested_workspaces: + if not (_extract_workspace_values(skill) & requested_workspaces): + continue + name = _extract_skill_name(skill) + if name: + selected_skills.append(skill) + + selected_names = {_extract_skill_name(s) for s in selected_skills} + sync_meta = _load_sync_meta(target_dir) + + to_sync = [] + skipped = [] + for skill in selected_skills: + name = _extract_skill_name(skill) + remote_updated_at = _extract_updated_at(skill) + local_exists = os.path.isdir(os.path.join(target_dir, name)) + meta_updated_at = (sync_meta.get(name) or {}).get("updated_at") + if local_exists and meta_updated_at == remote_updated_at: + skipped.append(name) + continue + to_sync.append((name, remote_updated_at, local_exists)) + + if to_sync and not auto_confirm: + click.confirm( + f"Will sync {len(to_sync)} skill(s) to {target_dir}. Continue?", + default=True, + abort=True, + ) + + downloaded = [] + updated = [] + from agentrun.tool import Tool + + for name, remote_updated_at, local_exists in to_sync: + tool = Tool.get_by_name(name, config=cfg) + path = tool.download_skill(target_dir=target_dir, config=cfg) + sync_meta[name] = {"updated_at": remote_updated_at} + record = {"skill_name": name, "path": path} + if local_exists: + updated.append(record) + else: + downloaded.append(record) + + removed = [] + if delete_unmanaged: + local_skills = _list_local_skill_dirs(target_dir) + unmanaged = sorted(local_skills - selected_names) + if unmanaged: + if not auto_confirm: + click.confirm( + ( + "Will delete " + f"{len(unmanaged)} unmanaged local skill(s) " + f"from {target_dir}. Continue?" + ), + default=False, + abort=True, + ) + for name in unmanaged: + full = os.path.join(target_dir, name) + shutil.rmtree(full) + sync_meta.pop(name, None) + removed.append(name) + + _save_sync_meta(target_dir, sync_meta) + + format_output( + ctx, + { + "ai_tool": ai_tool, + "scope": scope, + "target_dir": target_dir, + "workspaces": sorted(requested_workspaces), + "platform_skill_total": len(all_skills), + "managed_skill_total": len(selected_skills), + "downloaded": downloaded, + "updated": updated, + "skipped": sorted(skipped), + "removed": removed, + }, + ) diff --git a/src/agentrun_cli/main.py b/src/agentrun_cli/main.py index 1cf460c..51a602e 100644 --- a/src/agentrun_cli/main.py +++ b/src/agentrun_cli/main.py @@ -22,6 +22,7 @@ from agentrun_cli.commands.sandbox import sandbox_group from agentrun_cli.commands.skill_cmd import skill_group from agentrun_cli.commands.super_agent import super_agent_group +from agentrun_cli.commands.sync_skills_cmd import sync_skills from agentrun_cli.commands.tool_cmd import tool_group @@ -106,6 +107,7 @@ def cli(ctx: click.Context, profile, region, output, debug): cli._aliases["sb"] = "sandbox" cli.add_command(tool_group) cli.add_command(skill_group) +cli.add_command(sync_skills) cli.add_command(super_agent_group) cli._aliases["sa"] = "super-agent" diff --git a/tests/integration/test_sync_skills_cmd.py b/tests/integration/test_sync_skills_cmd.py new file mode 100644 index 0000000..c0a7411 --- /dev/null +++ b/tests/integration/test_sync_skills_cmd.py @@ -0,0 +1,64 @@ +"""Integration tests for top-level ``ar sync-skills`` command.""" + +import json +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from click.testing import CliRunner + +from agentrun_cli.main import cli + + +def _mock_models_module(): + mod = MagicMock() + mod.ListToolsRequest = MagicMock(side_effect=lambda **kw: SimpleNamespace(**kw)) + return mod + + +class TestSyncSkillsCommand: + + @patch( + "agentrun_cli.commands.sync_skills_cmd.build_sdk_config", + return_value=MagicMock(), + ) + @patch("agentrun.tool.Tool.get_by_name") + @patch("agentrun_cli.commands.sync_skills_cmd.get_agentrun_client") + def test_sync_success(self, mock_client_fn, mock_get_tool, _mock_cfg): + items = [SimpleNamespace(tool_name="skill-a", updated_at="2026-01-01")] + client = MagicMock() + client.list_tools_with_options.return_value = SimpleNamespace( + body=SimpleNamespace(data=SimpleNamespace(items=items)) + ) + mock_client_fn.return_value = (client, {}, MagicMock()) + + tool_obj = MagicMock() + tool_obj.download_skill.return_value = "/tmp/skills/skill-a" + mock_get_tool.return_value = tool_obj + + mock_models = _mock_models_module() + with patch.dict( + "sys.modules", + { + "alibabacloud_agentrun20250910": MagicMock(), + "alibabacloud_agentrun20250910.models": mock_models, + }, + ): + runner = CliRunner() + result = runner.invoke( + cli, + ["sync-skills", "--claude-code", "--project", "-y"], + ) + + assert result.exit_code == 0, result.output + out = json.loads(result.output) + assert out["ai_tool"] == "claude-code" + assert out["managed_skill_total"] == 1 + + def test_sync_usage_error(self): + runner = CliRunner() + result = runner.invoke( + cli, + ["sync-skills", "--claude-code", "--user", "--project"], + ) + assert result.exit_code != 0 + assert "--user or --project" in result.output diff --git a/tests/unit/test_sync_skills_cmd.py b/tests/unit/test_sync_skills_cmd.py new file mode 100644 index 0000000..4743461 --- /dev/null +++ b/tests/unit/test_sync_skills_cmd.py @@ -0,0 +1,166 @@ +"""Unit tests for ``agentrun_cli.commands.sync_skills_cmd``.""" + +import json +import os +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from click.testing import CliRunner + +from agentrun_cli.commands.sync_skills_cmd import sync_skills + + +def _mock_models_module(): + mod = MagicMock() + mod.ListToolsRequest = MagicMock(side_effect=lambda **kw: SimpleNamespace(**kw)) + return mod + + +def _patch_client_with_items(items): + client = MagicMock() + client.list_tools_with_options.return_value = SimpleNamespace( + body=SimpleNamespace(data=SimpleNamespace(items=items)) + ) + return patch( + "agentrun_cli.commands.sync_skills_cmd.get_agentrun_client", + return_value=(client, {}, MagicMock()), + ) + + +def _patch_tool_download(): + def _make_tool(name, **_kwargs): + obj = MagicMock() + obj.download_skill.return_value = f"/fake/skills/{name}" + return obj + + return patch("agentrun.tool.Tool.get_by_name", side_effect=_make_tool) + + +class TestSyncSkillsValidation: + + def test_requires_exactly_one_tool(self): + runner = CliRunner() + result = runner.invoke(sync_skills, ["--user"]) + assert result.exit_code != 0 + assert "--claude-code or --codex" in result.output + + def test_requires_exactly_one_scope(self): + runner = CliRunner() + result = runner.invoke(sync_skills, ["--claude-code"]) + assert result.exit_code != 0 + assert "--user or --project" in result.output + + +class TestSyncSkillsCommand: + + @patch( + "agentrun_cli.commands.sync_skills_cmd.build_sdk_config", + return_value=MagicMock(), + ) + def test_sync_all_with_confirmation(self, _mock_cfg): + items = [ + SimpleNamespace(tool_name="skill-a", updated_at="2026-01-01"), + SimpleNamespace(tool_name="skill-b", updated_at="2026-01-02"), + ] + mock_models = _mock_models_module() + + with _patch_client_with_items(items), _patch_tool_download(), patch.dict( + "sys.modules", + { + "alibabacloud_agentrun20250910": MagicMock(), + "alibabacloud_agentrun20250910.models": mock_models, + }, + ): + runner = CliRunner() + with runner.isolated_filesystem(): + result = runner.invoke( + sync_skills, + ["--claude-code", "--user"], + input="y\n", + env={"HOME": os.getcwd()}, + ) + + assert result.exit_code == 0, result.output + payload = result.output[result.output.find("{") :] + out = json.loads(payload) + assert out["managed_skill_total"] == 2 + assert len(out["downloaded"]) == 2 + + @patch( + "agentrun_cli.commands.sync_skills_cmd.build_sdk_config", + return_value=MagicMock(), + ) + def test_workspace_filter_and_skip_up_to_date(self, _mock_cfg): + items = [ + SimpleNamespace( + tool_name="skill-a", + updated_at="2026-01-01", + workspace_name="abc", + ), + SimpleNamespace( + tool_name="skill-b", + updated_at="2026-01-02", + workspace_name="def", + ), + ] + mock_models = _mock_models_module() + + with _patch_client_with_items(items), _patch_tool_download(), patch.dict( + "sys.modules", + { + "alibabacloud_agentrun20250910": MagicMock(), + "alibabacloud_agentrun20250910.models": mock_models, + }, + ): + runner = CliRunner() + with runner.isolated_filesystem(): + os.makedirs(".claude/skills/skill-a", exist_ok=True) + with open( + ".claude/skills/.agentrun-sync-skills.json", + "w", + encoding="utf-8", + ) as f: + json.dump({"skill-a": {"updated_at": "2026-01-01"}}, f) + + result = runner.invoke( + sync_skills, + ["--claude-code", "--project", "--workspace", "abc", "-y"], + ) + + assert result.exit_code == 0, result.output + out = json.loads(result.output) + assert out["managed_skill_total"] == 1 + assert out["downloaded"] == [] + assert out["updated"] == [] + assert out["skipped"] == ["skill-a"] + + @patch( + "agentrun_cli.commands.sync_skills_cmd.build_sdk_config", + return_value=MagicMock(), + ) + def test_delete_unmanaged(self, _mock_cfg): + items = [SimpleNamespace(tool_name="skill-a", updated_at="2026-01-01")] + mock_models = _mock_models_module() + + with _patch_client_with_items(items), _patch_tool_download(), patch.dict( + "sys.modules", + { + "alibabacloud_agentrun20250910": MagicMock(), + "alibabacloud_agentrun20250910.models": mock_models, + }, + ): + runner = CliRunner() + with runner.isolated_filesystem(): + os.makedirs(".codex/skills/skill-a", exist_ok=True) + os.makedirs(".codex/skills/skill-old", exist_ok=True) + + result = runner.invoke( + sync_skills, + ["--codex", "--project", "--delete-unmanaged", "-y"], + ) + + assert not os.path.exists(".codex/skills/skill-old") + + assert result.exit_code == 0, result.output + out = json.loads(result.output) + assert out["removed"] == ["skill-old"] From 9fc4a3482c134b54b189cf5ba45c81e35fe76e60 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 03:17:50 +0000 Subject: [PATCH 2/5] test: tighten sync-skills option validation coverage Signed-off-by: GitHub Co-authored-by: OhYee <13498329+OhYee@users.noreply.github.com> --- src/agentrun_cli/commands/sync_skills_cmd.py | 8 +++++--- tests/unit/test_sync_skills_cmd.py | 12 ++++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/agentrun_cli/commands/sync_skills_cmd.py b/src/agentrun_cli/commands/sync_skills_cmd.py index ad9e462..380d042 100644 --- a/src/agentrun_cli/commands/sync_skills_cmd.py +++ b/src/agentrun_cli/commands/sync_skills_cmd.py @@ -215,9 +215,11 @@ def sync_skills( ): """Sync platform skills to local AI tool skill directories.""" if use_claude_code == use_codex: - raise click.UsageError("Exactly one of --claude-code or --codex is required.") + raise click.UsageError( + "You must specify exactly one of --claude-code or --codex." + ) if user_scope == project_scope: - raise click.UsageError("Exactly one of --user or --project is required.") + raise click.UsageError("You must specify exactly one of --user or --project.") ai_tool = "claude-code" if use_claude_code else "codex" scope = "user" if user_scope else "project" @@ -225,7 +227,6 @@ def sync_skills( os.makedirs(target_dir, exist_ok=True) profile, region = _ctx_cfg(ctx) - cfg = build_sdk_config(profile_name=profile, region=region) all_skills = _list_platform_skills(profile, region) requested_workspaces = set(workspaces or []) @@ -262,6 +263,7 @@ def sync_skills( downloaded = [] updated = [] + cfg = build_sdk_config(profile_name=profile, region=region) from agentrun.tool import Tool for name, remote_updated_at, local_exists in to_sync: diff --git a/tests/unit/test_sync_skills_cmd.py b/tests/unit/test_sync_skills_cmd.py index 4743461..428d7f3 100644 --- a/tests/unit/test_sync_skills_cmd.py +++ b/tests/unit/test_sync_skills_cmd.py @@ -44,12 +44,24 @@ def test_requires_exactly_one_tool(self): assert result.exit_code != 0 assert "--claude-code or --codex" in result.output + def test_rejects_both_tool_flags(self): + runner = CliRunner() + result = runner.invoke(sync_skills, ["--claude-code", "--codex", "--user"]) + assert result.exit_code != 0 + assert "--claude-code or --codex" in result.output + def test_requires_exactly_one_scope(self): runner = CliRunner() result = runner.invoke(sync_skills, ["--claude-code"]) assert result.exit_code != 0 assert "--user or --project" in result.output + def test_rejects_both_scope_flags(self): + runner = CliRunner() + result = runner.invoke(sync_skills, ["--claude-code", "--user", "--project"]) + assert result.exit_code != 0 + assert "--user or --project" in result.output + class TestSyncSkillsCommand: From 582c66e6b49fa3ff1eab7409cc69ad416798fdc0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 04:14:58 +0000 Subject: [PATCH 3/5] feat: refactor sync-skills to use --tool choice and add github-copilot/cursor/qoder Signed-off-by: GitHub Co-authored-by: OhYee <13498329+OhYee@users.noreply.github.com> --- docs/en/sync-skills.md | 42 ++++--- docs/zh/sync-skills.md | 42 ++++--- src/agentrun_cli/commands/sync_skills_cmd.py | 65 +++++------ tests/integration/test_sync_skills_cmd.py | 22 +++- tests/unit/test_sync_skills_cmd.py | 110 +++++++++++++++++-- 5 files changed, 207 insertions(+), 74 deletions(-) diff --git a/docs/en/sync-skills.md b/docs/en/sync-skills.md index 88de965..242b4c5 100644 --- a/docs/en/sync-skills.md +++ b/docs/en/sync-skills.md @@ -2,44 +2,58 @@ # ar sync-skills -Sync platform Skills to local AI tool skill directories (Claude Code or Codex). +Sync platform Skills to a local AI tool skill directory. ``` -ar sync-skills [tool] [scope] [options] +ar sync-skills --tool (--user | --project) [options] ``` ## Options | Flag | Type | Required | Default | Description | |------|------|----------|---------|-------------| -| `--claude-code` | flag | yes* | | Sync to Claude Code skill directory. | -| `--codex` | flag | yes* | | Sync to Codex skill directory. | -| `--user` | flag | yes** | | Sync to user-level directory. | -| `--project` | flag | yes** | | Sync to project-level directory. | +| `--tool` | choice | yes | | Target AI tool. See choices below. | +| `--user` | flag | yes* | | Sync to user-level directory. | +| `--project` | flag | yes* | | Sync to project-level directory. | | `--workspace` | multi | no | all | Workspace filter, repeatable. | | `--delete-unmanaged` | flag | no | false | Delete local skills outside selected workspace scope (with confirmation). | | `-y`, `--yes` | flag | no | false | Skip confirmation prompts. | -\* Exactly one of `--claude-code` or `--codex` is required. -\** Exactly one of `--user` or `--project` is required. +\* Exactly one of `--user` or `--project` is required. + +### `--tool` choices + +| Choice | User-level path | Project-level path | +|--------|----------------|--------------------| +| `claude-code` | `~/.claude/skills` | `.claude/skills` | +| `codex` | `~/.codex/skills` | `.codex/skills` | +| `github-copilot` | `~/.github/copilot/skills` | `.github/copilot/skills` | +| `cursor` | `~/.cursor/skills` | `.cursor/skills` | +| `qoder` | `~/.qoder/skills` | `.qoder/skills` | ## Behavior - By default, all platform skills are selected (unless `--workspace` is provided). -- Before downloading/updating skills, the CLI asks for confirmation. +- Before downloading/updating skills, the CLI asks for confirmation (skip with `-y`). - Sync checks local metadata and only downloads skills that are missing or outdated. - When `--delete-unmanaged` is enabled, local skill directories not in the selected - managed scope can be removed after confirmation. + managed scope can be removed after a separate confirmation. ## Examples ```bash # Sync skills from workspace abc + def into user-level Claude Code skills -ar sync-skills --claude-code --user --workspace abc --workspace def +ar sync-skills --tool claude-code --user --workspace abc --workspace def # Sync skills from workspace abc into project-level Codex skills -ar sync-skills --codex --project --workspace abc +ar sync-skills --tool codex --project --workspace abc + +# Sync all skills to project-level Cursor directory without prompts +ar sync-skills --tool cursor --project -y + +# Sync to GitHub Copilot user-level directory and remove unmanaged local skills +ar sync-skills --tool github-copilot --user --delete-unmanaged -# Sync all skills and also remove unmanaged local skills -ar sync-skills --claude-code --project --delete-unmanaged +# Sync to Qoder project-level directory +ar sync-skills --tool qoder --project --workspace my-workspace ``` diff --git a/docs/zh/sync-skills.md b/docs/zh/sync-skills.md index a5a1abd..d4aca99 100644 --- a/docs/zh/sync-skills.md +++ b/docs/zh/sync-skills.md @@ -2,43 +2,57 @@ # ar sync-skills -把平台上的 Skill 同步到本地 AI 工具技能目录(Claude Code 或 Codex)。 +把平台上的 Skill 同步到本地 AI 工具技能目录。 ``` -ar sync-skills [tool] [scope] [options] +ar sync-skills --tool (--user | --project) [options] ``` ## 参数 | Flag | 类型 | 必填 | 默认 | 说明 | |------|------|------|------|------| -| `--claude-code` | flag | 是* | | 同步到 Claude Code 技能目录。 | -| `--codex` | flag | 是* | | 同步到 Codex 技能目录。 | -| `--user` | flag | 是** | | 同步到用户级目录。 | -| `--project` | flag | 是** | | 同步到项目级目录。 | +| `--tool` | choice | 是 | | 目标 AI 工具,见下表。 | +| `--user` | flag | 是* | | 同步到用户级目录。 | +| `--project` | flag | 是* | | 同步到项目级目录。 | | `--workspace` | multi | 否 | 全部 | 工作空间过滤,可重复。 | | `--delete-unmanaged` | flag | 否 | false | 删除不在所选管控范围内的本地技能目录(需确认)。 | | `-y`、`--yes` | flag | 否 | false | 跳过确认提示。 | -\* `--claude-code` 与 `--codex` 二选一。 -\** `--user` 与 `--project` 二选一。 +\* `--user` 与 `--project` 二选一。 + +### `--tool` 可选值 + +| 选项 | 用户级路径 | 项目级路径 | +|------|-----------|-----------| +| `claude-code` | `~/.claude/skills` | `.claude/skills` | +| `codex` | `~/.codex/skills` | `.codex/skills` | +| `github-copilot` | `~/.github/copilot/skills` | `.github/copilot/skills` | +| `cursor` | `~/.cursor/skills` | `.cursor/skills` | +| `qoder` | `~/.qoder/skills` | `.qoder/skills` | ## 行为说明 - 默认同步全部平台 Skill(除非传入 `--workspace`)。 -- 在下载/更新 Skill 前会先进行用户确认。 +- 在下载/更新 Skill 前会先进行用户确认(传 `-y` 可跳过)。 - 同步会检查本地元数据,仅下载缺失或有更新的 Skill。 -- 开启 `--delete-unmanaged` 时,会在确认后删除不在当前管控范围内的本地 Skill 目录。 +- 开启 `--delete-unmanaged` 时,会在再次确认后删除不在当前管控范围内的本地 Skill 目录。 ## 示例 ```bash # 将 workspace abc + def 的 skills 同步到用户级 Claude Code 目录 -ar sync-skills --claude-code --user --workspace abc --workspace def +ar sync-skills --tool claude-code --user --workspace abc --workspace def # 将 workspace abc 的 skills 同步到项目级 Codex 目录 -ar sync-skills --codex --project --workspace abc +ar sync-skills --tool codex --project --workspace abc + +# 跳过确认,将全部 skills 同步到项目级 Cursor 目录 +ar sync-skills --tool cursor --project -y + +# 同步到 GitHub Copilot 用户级目录,并删除不在管控范围的本地 skills +ar sync-skills --tool github-copilot --user --delete-unmanaged -# 同步全部 skills,并删除不在管控范围内的本地 skills -ar sync-skills --claude-code --project --delete-unmanaged +# 同步到 Qoder 项目级目录 +ar sync-skills --tool qoder --project --workspace my-workspace ``` diff --git a/src/agentrun_cli/commands/sync_skills_cmd.py b/src/agentrun_cli/commands/sync_skills_cmd.py index 380d042..c8a2183 100644 --- a/src/agentrun_cli/commands/sync_skills_cmd.py +++ b/src/agentrun_cli/commands/sync_skills_cmd.py @@ -13,6 +13,14 @@ _META_FILE_NAME = ".agentrun-sync-skills.json" +_TOOL_CHOICES = ( + "claude-code", + "codex", + "github-copilot", + "cursor", + "qoder", +) + def _ctx_cfg(ctx): return (ctx.obj or {}).get("profile"), (ctx.obj or {}).get("region") @@ -72,19 +80,23 @@ def _extract_workspace_values(skill) -> set[str]: return values +_TOOL_ROOTS: dict[str, tuple[str, str]] = { + # (user_root, project_subdir_name) + "claude-code": ("~/.claude", ".claude"), + "codex": ("~/.codex", ".codex"), + "github-copilot": ("~/.github/copilot", ".github/copilot"), + "cursor": ("~/.cursor", ".cursor"), + "qoder": ("~/.qoder", ".qoder"), +} + + def _resolve_target_dir(ai_tool: str, scope: str) -> str: - if ai_tool == "claude-code": - root = ( - os.path.expanduser("~/.claude") - if scope == "user" - else os.path.abspath(".claude") - ) - else: - root = ( - os.path.expanduser("~/.codex") - if scope == "user" - else os.path.abspath(".codex") - ) + user_root, project_dir = _TOOL_ROOTS[ai_tool] + root = ( + os.path.expanduser(user_root) + if scope == "user" + else os.path.abspath(project_dir) + ) return os.path.join(root, "skills") @@ -148,21 +160,16 @@ def _list_platform_skills(profile, region): @click.command( "sync-skills", - help="Sync platform skills to Claude Code or Codex skill directories.", -) -@click.option( - "--claude-code", - "use_claude_code", - is_flag=True, - default=False, - help="Sync to Claude Code skill directory.", + help="Sync platform skills to a local AI tool skill directory.", ) @click.option( - "--codex", - "use_codex", - is_flag=True, - default=False, - help="Sync to Codex skill directory.", + "--tool", + "ai_tool", + type=click.Choice(_TOOL_CHOICES), + required=True, + help=( + "Target AI tool: claude-code, codex, github-copilot, cursor, qoder." + ), ) @click.option( "--user", @@ -205,8 +212,7 @@ def _list_platform_skills(profile, region): @handle_errors def sync_skills( ctx, - use_claude_code, - use_codex, + ai_tool, user_scope, project_scope, workspaces, @@ -214,14 +220,9 @@ def sync_skills( auto_confirm, ): """Sync platform skills to local AI tool skill directories.""" - if use_claude_code == use_codex: - raise click.UsageError( - "You must specify exactly one of --claude-code or --codex." - ) if user_scope == project_scope: raise click.UsageError("You must specify exactly one of --user or --project.") - ai_tool = "claude-code" if use_claude_code else "codex" scope = "user" if user_scope else "project" target_dir = _resolve_target_dir(ai_tool, scope) os.makedirs(target_dir, exist_ok=True) diff --git a/tests/integration/test_sync_skills_cmd.py b/tests/integration/test_sync_skills_cmd.py index c0a7411..ea3fa9a 100644 --- a/tests/integration/test_sync_skills_cmd.py +++ b/tests/integration/test_sync_skills_cmd.py @@ -46,7 +46,7 @@ def test_sync_success(self, mock_client_fn, mock_get_tool, _mock_cfg): runner = CliRunner() result = runner.invoke( cli, - ["sync-skills", "--claude-code", "--project", "-y"], + ["sync-skills", "--tool", "claude-code", "--project", "-y"], ) assert result.exit_code == 0, result.output @@ -54,11 +54,27 @@ def test_sync_success(self, mock_client_fn, mock_get_tool, _mock_cfg): assert out["ai_tool"] == "claude-code" assert out["managed_skill_total"] == 1 - def test_sync_usage_error(self): + def test_sync_usage_error_missing_scope(self): runner = CliRunner() result = runner.invoke( cli, - ["sync-skills", "--claude-code", "--user", "--project"], + ["sync-skills", "--tool", "cursor"], ) assert result.exit_code != 0 assert "--user or --project" in result.output + + def test_sync_usage_error_both_scopes(self): + runner = CliRunner() + result = runner.invoke( + cli, + ["sync-skills", "--tool", "claude-code", "--user", "--project"], + ) + assert result.exit_code != 0 + assert "--user or --project" in result.output + + def test_sync_help_lists_all_tools(self): + runner = CliRunner() + result = runner.invoke(cli, ["sync-skills", "--help"]) + assert result.exit_code == 0 + for tool in ("claude-code", "codex", "github-copilot", "cursor", "qoder"): + assert tool in result.output diff --git a/tests/unit/test_sync_skills_cmd.py b/tests/unit/test_sync_skills_cmd.py index 428d7f3..c9f1fdb 100644 --- a/tests/unit/test_sync_skills_cmd.py +++ b/tests/unit/test_sync_skills_cmd.py @@ -38,27 +38,28 @@ def _make_tool(name, **_kwargs): class TestSyncSkillsValidation: - def test_requires_exactly_one_tool(self): + def test_requires_tool_flag(self): runner = CliRunner() result = runner.invoke(sync_skills, ["--user"]) assert result.exit_code != 0 - assert "--claude-code or --codex" in result.output + assert "--tool" in result.output - def test_rejects_both_tool_flags(self): + def test_rejects_invalid_tool_name(self): runner = CliRunner() - result = runner.invoke(sync_skills, ["--claude-code", "--codex", "--user"]) + result = runner.invoke(sync_skills, ["--tool", "unknown-tool", "--user"]) assert result.exit_code != 0 - assert "--claude-code or --codex" in result.output def test_requires_exactly_one_scope(self): runner = CliRunner() - result = runner.invoke(sync_skills, ["--claude-code"]) + result = runner.invoke(sync_skills, ["--tool", "claude-code"]) assert result.exit_code != 0 assert "--user or --project" in result.output def test_rejects_both_scope_flags(self): runner = CliRunner() - result = runner.invoke(sync_skills, ["--claude-code", "--user", "--project"]) + result = runner.invoke( + sync_skills, ["--tool", "claude-code", "--user", "--project"] + ) assert result.exit_code != 0 assert "--user or --project" in result.output @@ -87,13 +88,13 @@ def test_sync_all_with_confirmation(self, _mock_cfg): with runner.isolated_filesystem(): result = runner.invoke( sync_skills, - ["--claude-code", "--user"], + ["--tool", "claude-code", "--user"], input="y\n", env={"HOME": os.getcwd()}, ) assert result.exit_code == 0, result.output - payload = result.output[result.output.find("{") :] + payload = result.output[result.output.find("{"):] out = json.loads(payload) assert out["managed_skill_total"] == 2 assert len(out["downloaded"]) == 2 @@ -136,7 +137,12 @@ def test_workspace_filter_and_skip_up_to_date(self, _mock_cfg): result = runner.invoke( sync_skills, - ["--claude-code", "--project", "--workspace", "abc", "-y"], + [ + "--tool", "claude-code", + "--project", + "--workspace", "abc", + "-y", + ], ) assert result.exit_code == 0, result.output @@ -168,7 +174,7 @@ def test_delete_unmanaged(self, _mock_cfg): result = runner.invoke( sync_skills, - ["--codex", "--project", "--delete-unmanaged", "-y"], + ["--tool", "codex", "--project", "--delete-unmanaged", "-y"], ) assert not os.path.exists(".codex/skills/skill-old") @@ -176,3 +182,85 @@ def test_delete_unmanaged(self, _mock_cfg): assert result.exit_code == 0, result.output out = json.loads(result.output) assert out["removed"] == ["skill-old"] + + @patch( + "agentrun_cli.commands.sync_skills_cmd.build_sdk_config", + return_value=MagicMock(), + ) + def test_cursor_project_scope(self, _mock_cfg): + items = [SimpleNamespace(tool_name="skill-x", updated_at="2026-02-01")] + mock_models = _mock_models_module() + + with _patch_client_with_items(items), _patch_tool_download(), patch.dict( + "sys.modules", + { + "alibabacloud_agentrun20250910": MagicMock(), + "alibabacloud_agentrun20250910.models": mock_models, + }, + ): + runner = CliRunner() + with runner.isolated_filesystem(): + result = runner.invoke( + sync_skills, + ["--tool", "cursor", "--project", "-y"], + ) + + assert result.exit_code == 0, result.output + out = json.loads(result.output) + assert out["ai_tool"] == "cursor" + assert ".cursor/skills" in out["target_dir"] + + @patch( + "agentrun_cli.commands.sync_skills_cmd.build_sdk_config", + return_value=MagicMock(), + ) + def test_github_copilot_user_scope(self, _mock_cfg): + items = [SimpleNamespace(tool_name="skill-y", updated_at="2026-03-01")] + mock_models = _mock_models_module() + + with _patch_client_with_items(items), _patch_tool_download(), patch.dict( + "sys.modules", + { + "alibabacloud_agentrun20250910": MagicMock(), + "alibabacloud_agentrun20250910.models": mock_models, + }, + ): + runner = CliRunner() + with runner.isolated_filesystem(): + result = runner.invoke( + sync_skills, + ["--tool", "github-copilot", "--user", "-y"], + env={"HOME": os.getcwd()}, + ) + + assert result.exit_code == 0, result.output + out = json.loads(result.output) + assert out["ai_tool"] == "github-copilot" + assert "github" in out["target_dir"] + + @patch( + "agentrun_cli.commands.sync_skills_cmd.build_sdk_config", + return_value=MagicMock(), + ) + def test_qoder_project_scope(self, _mock_cfg): + items = [SimpleNamespace(tool_name="skill-z", updated_at="2026-04-01")] + mock_models = _mock_models_module() + + with _patch_client_with_items(items), _patch_tool_download(), patch.dict( + "sys.modules", + { + "alibabacloud_agentrun20250910": MagicMock(), + "alibabacloud_agentrun20250910.models": mock_models, + }, + ): + runner = CliRunner() + with runner.isolated_filesystem(): + result = runner.invoke( + sync_skills, + ["--tool", "qoder", "--project", "-y"], + ) + + assert result.exit_code == 0, result.output + out = json.loads(result.output) + assert out["ai_tool"] == "qoder" + assert ".qoder/skills" in out["target_dir"] From 3ca3b08e49ef58b638625ecc2750b069a409969c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 04:16:08 +0000 Subject: [PATCH 4/5] docs: update sync-skills description to list all supported tools Signed-off-by: GitHub Co-authored-by: OhYee <13498329+OhYee@users.noreply.github.com> --- README.md | 2 +- README_zh.md | 2 +- docs/en/index.md | 2 +- docs/zh/index.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 18d0582..df87257 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,7 @@ Multi-document YAMLs (`---` separated) let you deploy many agents in one call. | `sandbox` | `sb` | Sandboxes + files, processes, contexts, templates, browser | [en](./docs/en/sandbox.md) · [zh](./docs/zh/sandbox.md) | | `tool` | | MCP and FunctionCall tools | [en](./docs/en/tool.md) · [zh](./docs/zh/tool.md) | | `skill` | | Platform skill packages + local execution | [en](./docs/en/skill.md) · [zh](./docs/zh/skill.md) | -| `sync-skills` | | Sync platform skills to Claude Code/Codex local skills directories | [en](./docs/en/sync-skills.md) · [zh](./docs/zh/sync-skills.md) | +| `sync-skills` | | Sync platform skills to AI tool local skill directories (Claude Code, Codex, GitHub Copilot, Cursor, Qoder) | [en](./docs/en/sync-skills.md) · [zh](./docs/zh/sync-skills.md) | | `super-agent` | `sa` | Quickstart / CRUD / declarative / conversation | [en](./docs/en/super-agent.md) · [zh](./docs/zh/super-agent.md) | ## Documentation diff --git a/README_zh.md b/README_zh.md index c383c75..42586ae 100644 --- a/README_zh.md +++ b/README_zh.md @@ -181,7 +181,7 @@ ar sa invoke my-helper -m "帮我规划今天的事情" --text-only | `sandbox` | `sb` | 沙箱 + 文件、进程、上下文、模板、浏览器 | [en](./docs/en/sandbox.md) · [zh](./docs/zh/sandbox.md) | | `tool` | | MCP 与 FunctionCall 工具 | [en](./docs/en/tool.md) · [zh](./docs/zh/tool.md) | | `skill` | | 平台技能包 + 本地执行 | [en](./docs/en/skill.md) · [zh](./docs/zh/skill.md) | -| `sync-skills` | | 将平台 skills 同步到 Claude Code/Codex 本地技能目录 | [en](./docs/en/sync-skills.md) · [zh](./docs/zh/sync-skills.md) | +| `sync-skills` | | 将平台 skills 同步到 AI 工具本地技能目录(Claude Code、Codex、GitHub Copilot、Cursor、Qoder) | [en](./docs/en/sync-skills.md) · [zh](./docs/zh/sync-skills.md) | | `super-agent` | `sa` | 一键拉起 / CRUD / 声明式 / 会话管理 | [en](./docs/en/super-agent.md) · [zh](./docs/zh/super-agent.md) | ## 文档 diff --git a/docs/en/index.md b/docs/en/index.md index 3cab21e..2bd88c0 100644 --- a/docs/en/index.md +++ b/docs/en/index.md @@ -197,5 +197,5 @@ Errors are written to stderr as JSON: | `sandbox` | `sb` | Sandboxes plus file, process, context, template and browser sub-groups | [sandbox.md](./sandbox.md) | | `tool` | | MCP and FunctionCall tools + sub-tool invocation | [tool.md](./tool.md) | | `skill` | | Platform skill packages + local scan/load/exec | [skill.md](./skill.md) | -| `sync-skills` | | Sync platform skills to Claude Code/Codex skill directories | [sync-skills.md](./sync-skills.md) | +| `sync-skills` | | Sync platform skills to AI tool local skill directories (Claude Code, Codex, GitHub Copilot, Cursor, Qoder) | [sync-skills.md](./sync-skills.md) | | `super-agent` | `sa` | Quickstart REPL, declarative deploy, CRUD, conversations | [super-agent.md](./super-agent.md) | diff --git a/docs/zh/index.md b/docs/zh/index.md index 02cdcb5..6149a21 100644 --- a/docs/zh/index.md +++ b/docs/zh/index.md @@ -193,5 +193,5 @@ ar sandbox exec "$SANDBOX" --code "print('hello')" | `sandbox` | `sb` | 沙箱以及 file / process / context / template / browser 子组 | [sandbox.md](./sandbox.md) | | `tool` | | MCP 与 FunctionCall 工具 + 子工具调用 | [tool.md](./tool.md) | | `skill` | | 平台侧技能包 + 本地 scan / load / exec | [skill.md](./skill.md) | -| `sync-skills` | | 把平台 skills 同步到 Claude Code/Codex 本地技能目录 | [sync-skills.md](./sync-skills.md) | +| `sync-skills` | | 把平台 skills 同步到 AI 工具本地技能目录(Claude Code、Codex、GitHub Copilot、Cursor、Qoder) | [sync-skills.md](./sync-skills.md) | | `super-agent` | `sa` | 一键拉起 REPL、声明式部署、CRUD、会话管理 | [super-agent.md](./super-agent.md) | From 6459e9ba27b7bbe91a3d511ee7cb60699cff211d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 12:23:25 +0000 Subject: [PATCH 5/5] refactor: move sync-skills to ar skill sync subcommand Signed-off-by: GitHub Co-authored-by: OhYee <13498329+OhYee@users.noreply.github.com> --- README.md | 3 +- README_zh.md | 3 +- docs/en/index.md | 3 +- docs/en/skill.md | 65 ++++++++++++++++++++ docs/en/sync-skills.md | 59 ------------------ docs/zh/index.md | 3 +- docs/zh/skill.md | 63 +++++++++++++++++++ docs/zh/sync-skills.md | 58 ----------------- src/agentrun_cli/commands/skill_cmd.py | 1 + src/agentrun_cli/commands/sync_skills_cmd.py | 9 +-- src/agentrun_cli/main.py | 5 +- tests/integration/test_sync_skills_cmd.py | 12 ++-- tests/unit/test_sync_skills_cmd.py | 24 ++++---- 13 files changed, 159 insertions(+), 149 deletions(-) delete mode 100644 docs/en/sync-skills.md delete mode 100644 docs/zh/sync-skills.md diff --git a/README.md b/README.md index df87257..98117cb 100644 --- a/README.md +++ b/README.md @@ -184,8 +184,7 @@ Multi-document YAMLs (`---` separated) let you deploy many agents in one call. | `model` | | Register external LLM providers as ModelServices | [en](./docs/en/model.md) · [zh](./docs/zh/model.md) | | `sandbox` | `sb` | Sandboxes + files, processes, contexts, templates, browser | [en](./docs/en/sandbox.md) · [zh](./docs/zh/sandbox.md) | | `tool` | | MCP and FunctionCall tools | [en](./docs/en/tool.md) · [zh](./docs/zh/tool.md) | -| `skill` | | Platform skill packages + local execution | [en](./docs/en/skill.md) · [zh](./docs/zh/skill.md) | -| `sync-skills` | | Sync platform skills to AI tool local skill directories (Claude Code, Codex, GitHub Copilot, Cursor, Qoder) | [en](./docs/en/sync-skills.md) · [zh](./docs/zh/sync-skills.md) | +| `skill` | | Platform skill packages + local execution + bulk sync to AI tool directories | [en](./docs/en/skill.md) · [zh](./docs/zh/skill.md) | | `super-agent` | `sa` | Quickstart / CRUD / declarative / conversation | [en](./docs/en/super-agent.md) · [zh](./docs/zh/super-agent.md) | ## Documentation diff --git a/README_zh.md b/README_zh.md index 42586ae..615185d 100644 --- a/README_zh.md +++ b/README_zh.md @@ -180,8 +180,7 @@ ar sa invoke my-helper -m "帮我规划今天的事情" --text-only | `model` | | 接入外部 LLM Provider 成为 ModelService | [en](./docs/en/model.md) · [zh](./docs/zh/model.md) | | `sandbox` | `sb` | 沙箱 + 文件、进程、上下文、模板、浏览器 | [en](./docs/en/sandbox.md) · [zh](./docs/zh/sandbox.md) | | `tool` | | MCP 与 FunctionCall 工具 | [en](./docs/en/tool.md) · [zh](./docs/zh/tool.md) | -| `skill` | | 平台技能包 + 本地执行 | [en](./docs/en/skill.md) · [zh](./docs/zh/skill.md) | -| `sync-skills` | | 将平台 skills 同步到 AI 工具本地技能目录(Claude Code、Codex、GitHub Copilot、Cursor、Qoder) | [en](./docs/en/sync-skills.md) · [zh](./docs/zh/sync-skills.md) | +| `skill` | | 平台技能包 + 本地执行 + 批量同步到 AI 工具目录 | [en](./docs/en/skill.md) · [zh](./docs/zh/skill.md) | | `super-agent` | `sa` | 一键拉起 / CRUD / 声明式 / 会话管理 | [en](./docs/en/super-agent.md) · [zh](./docs/zh/super-agent.md) | ## 文档 diff --git a/docs/en/index.md b/docs/en/index.md index 2bd88c0..b0bd1c2 100644 --- a/docs/en/index.md +++ b/docs/en/index.md @@ -196,6 +196,5 @@ Errors are written to stderr as JSON: | `model` | | ModelService registration (Tongyi, OpenAI, DeepSeek, …) | [model.md](./model.md) | | `sandbox` | `sb` | Sandboxes plus file, process, context, template and browser sub-groups | [sandbox.md](./sandbox.md) | | `tool` | | MCP and FunctionCall tools + sub-tool invocation | [tool.md](./tool.md) | -| `skill` | | Platform skill packages + local scan/load/exec | [skill.md](./skill.md) | -| `sync-skills` | | Sync platform skills to AI tool local skill directories (Claude Code, Codex, GitHub Copilot, Cursor, Qoder) | [sync-skills.md](./sync-skills.md) | +| `skill` | | Platform skill packages + local scan/load/exec + bulk sync to AI tool directories | [skill.md](./skill.md) | | `super-agent` | `sa` | Quickstart REPL, declarative deploy, CRUD, conversations | [super-agent.md](./super-agent.md) | diff --git a/docs/en/skill.md b/docs/en/skill.md index 62ffcbe..dca687d 100644 --- a/docs/en/skill.md +++ b/docs/en/skill.md @@ -27,6 +27,10 @@ Data plane (local): - [read-file](#read-file) - [exec](#exec) +Sync (bulk download to AI tool directories): + +- [sync](#sync) + --- ## create @@ -230,3 +234,64 @@ ar skill exec --name --command [--dir ] [--timeout ] ar skill exec --name web-scraper --command "python scraper.py --url https://example.com" ar skill exec --name my-skill --command "pytest tests/" --timeout 600 ``` + +--- + +## sync + +Sync platform Skills to a local AI tool skill directory (bulk download with +change-detection and optional cleanup). + +``` +ar skill sync --tool (--user | --project) [options] +``` + +### Options + +| Flag | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `--tool` | choice | yes | | Target AI tool. See choices below. | +| `--user` | flag | yes* | | Sync to user-level directory. | +| `--project` | flag | yes* | | Sync to project-level directory. | +| `--workspace` | multi | no | all | Workspace filter, repeatable. | +| `--delete-unmanaged` | flag | no | false | Delete local skills outside selected workspace scope (with confirmation). | +| `-y`, `--yes` | flag | no | false | Skip confirmation prompts. | + +\* Exactly one of `--user` or `--project` is required. + +### `--tool` choices + +| Choice | User-level path | Project-level path | +|--------|----------------|--------------------| +| `claude-code` | `~/.claude/skills` | `.claude/skills` | +| `codex` | `~/.codex/skills` | `.codex/skills` | +| `github-copilot` | `~/.github/copilot/skills` | `.github/copilot/skills` | +| `cursor` | `~/.cursor/skills` | `.cursor/skills` | +| `qoder` | `~/.qoder/skills` | `.qoder/skills` | + +### Behavior + +- By default, all platform skills are selected (unless `--workspace` is provided). +- Before downloading/updating, the CLI asks for confirmation (skip with `-y`). +- Sync checks local metadata and only downloads skills that are missing or outdated. +- When `--delete-unmanaged` is enabled, local skill directories not in the selected + managed scope can be removed after a separate confirmation. + +### Examples + +```bash +# Sync skills from workspace abc + def into user-level Claude Code directory +ar skill sync --tool claude-code --user --workspace abc --workspace def + +# Sync skills from workspace abc into project-level Codex directory +ar skill sync --tool codex --project --workspace abc + +# Sync all skills to project-level Cursor directory without prompts +ar skill sync --tool cursor --project -y + +# Sync to GitHub Copilot user-level directory and remove unmanaged local skills +ar skill sync --tool github-copilot --user --delete-unmanaged + +# Sync to Qoder project-level directory +ar skill sync --tool qoder --project --workspace my-workspace +``` diff --git a/docs/en/sync-skills.md b/docs/en/sync-skills.md deleted file mode 100644 index 242b4c5..0000000 --- a/docs/en/sync-skills.md +++ /dev/null @@ -1,59 +0,0 @@ -**English** | [简体中文](../zh/sync-skills.md) - -# ar sync-skills - -Sync platform Skills to a local AI tool skill directory. - -``` -ar sync-skills --tool (--user | --project) [options] -``` - -## Options - -| Flag | Type | Required | Default | Description | -|------|------|----------|---------|-------------| -| `--tool` | choice | yes | | Target AI tool. See choices below. | -| `--user` | flag | yes* | | Sync to user-level directory. | -| `--project` | flag | yes* | | Sync to project-level directory. | -| `--workspace` | multi | no | all | Workspace filter, repeatable. | -| `--delete-unmanaged` | flag | no | false | Delete local skills outside selected workspace scope (with confirmation). | -| `-y`, `--yes` | flag | no | false | Skip confirmation prompts. | - -\* Exactly one of `--user` or `--project` is required. - -### `--tool` choices - -| Choice | User-level path | Project-level path | -|--------|----------------|--------------------| -| `claude-code` | `~/.claude/skills` | `.claude/skills` | -| `codex` | `~/.codex/skills` | `.codex/skills` | -| `github-copilot` | `~/.github/copilot/skills` | `.github/copilot/skills` | -| `cursor` | `~/.cursor/skills` | `.cursor/skills` | -| `qoder` | `~/.qoder/skills` | `.qoder/skills` | - -## Behavior - -- By default, all platform skills are selected (unless `--workspace` is provided). -- Before downloading/updating skills, the CLI asks for confirmation (skip with `-y`). -- Sync checks local metadata and only downloads skills that are missing or outdated. -- When `--delete-unmanaged` is enabled, local skill directories not in the selected - managed scope can be removed after a separate confirmation. - -## Examples - -```bash -# Sync skills from workspace abc + def into user-level Claude Code skills -ar sync-skills --tool claude-code --user --workspace abc --workspace def - -# Sync skills from workspace abc into project-level Codex skills -ar sync-skills --tool codex --project --workspace abc - -# Sync all skills to project-level Cursor directory without prompts -ar sync-skills --tool cursor --project -y - -# Sync to GitHub Copilot user-level directory and remove unmanaged local skills -ar sync-skills --tool github-copilot --user --delete-unmanaged - -# Sync to Qoder project-level directory -ar sync-skills --tool qoder --project --workspace my-workspace -``` diff --git a/docs/zh/index.md b/docs/zh/index.md index 6149a21..4287b9f 100644 --- a/docs/zh/index.md +++ b/docs/zh/index.md @@ -192,6 +192,5 @@ ar sandbox exec "$SANDBOX" --code "print('hello')" | `model` | | 注册 ModelService(通义、OpenAI、DeepSeek……) | [model.md](./model.md) | | `sandbox` | `sb` | 沙箱以及 file / process / context / template / browser 子组 | [sandbox.md](./sandbox.md) | | `tool` | | MCP 与 FunctionCall 工具 + 子工具调用 | [tool.md](./tool.md) | -| `skill` | | 平台侧技能包 + 本地 scan / load / exec | [skill.md](./skill.md) | -| `sync-skills` | | 把平台 skills 同步到 AI 工具本地技能目录(Claude Code、Codex、GitHub Copilot、Cursor、Qoder) | [sync-skills.md](./sync-skills.md) | +| `skill` | | 平台侧技能包 + 本地 scan / load / exec + 批量同步到 AI 工具目录 | [skill.md](./skill.md) | | `super-agent` | `sa` | 一键拉起 REPL、声明式部署、CRUD、会话管理 | [super-agent.md](./super-agent.md) | diff --git a/docs/zh/skill.md b/docs/zh/skill.md index 01e9106..b42921e 100644 --- a/docs/zh/skill.md +++ b/docs/zh/skill.md @@ -26,6 +26,10 @@ - [read-file](#read-file) - [exec](#exec) +同步(批量下载到 AI 工具目录): + +- [sync](#sync) + --- ## create @@ -228,3 +232,62 @@ ar skill exec --name --command [--dir ] [--timeout ] ar skill exec --name web-scraper --command "python scraper.py --url https://example.com" ar skill exec --name my-skill --command "pytest tests/" --timeout 600 ``` + +--- + +## sync + +把平台 Skills 批量同步到本地 AI 工具技能目录(增量下载,支持可选清理)。 + +``` +ar skill sync --tool (--user | --project) [options] +``` + +### 参数 + +| Flag | 类型 | 必填 | 默认 | 说明 | +|------|------|------|------|------| +| `--tool` | choice | 是 | | 目标 AI 工具,见下表。 | +| `--user` | flag | 是* | | 同步到用户级目录。 | +| `--project` | flag | 是* | | 同步到项目级目录。 | +| `--workspace` | multi | 否 | 全部 | 工作空间过滤,可重复。 | +| `--delete-unmanaged` | flag | 否 | false | 删除不在所选管控范围内的本地技能目录(需确认)。 | +| `-y`、`--yes` | flag | 否 | false | 跳过确认提示。 | + +\* `--user` 与 `--project` 二选一。 + +### `--tool` 可选值 + +| 选项 | 用户级路径 | 项目级路径 | +|------|-----------|-----------| +| `claude-code` | `~/.claude/skills` | `.claude/skills` | +| `codex` | `~/.codex/skills` | `.codex/skills` | +| `github-copilot` | `~/.github/copilot/skills` | `.github/copilot/skills` | +| `cursor` | `~/.cursor/skills` | `.cursor/skills` | +| `qoder` | `~/.qoder/skills` | `.qoder/skills` | + +### 行为说明 + +- 默认同步全部平台 Skill(除非传入 `--workspace`)。 +- 在下载/更新前会先进行用户确认(传 `-y` 可跳过)。 +- 同步会检查本地元数据,仅下载缺失或有更新的 Skill。 +- 开启 `--delete-unmanaged` 时,会在再次确认后删除不在当前管控范围内的本地 Skill 目录。 + +### 示例 + +```bash +# 将 workspace abc + def 的 skills 同步到用户级 Claude Code 目录 +ar skill sync --tool claude-code --user --workspace abc --workspace def + +# 将 workspace abc 的 skills 同步到项目级 Codex 目录 +ar skill sync --tool codex --project --workspace abc + +# 跳过确认,将全部 skills 同步到项目级 Cursor 目录 +ar skill sync --tool cursor --project -y + +# 同步到 GitHub Copilot 用户级目录,并删除不在管控范围的本地 skills +ar skill sync --tool github-copilot --user --delete-unmanaged + +# 同步到 Qoder 项目级目录 +ar skill sync --tool qoder --project --workspace my-workspace +``` diff --git a/docs/zh/sync-skills.md b/docs/zh/sync-skills.md deleted file mode 100644 index d4aca99..0000000 --- a/docs/zh/sync-skills.md +++ /dev/null @@ -1,58 +0,0 @@ -[English](../en/sync-skills.md) | **简体中文** - -# ar sync-skills - -把平台上的 Skill 同步到本地 AI 工具技能目录。 - -``` -ar sync-skills --tool (--user | --project) [options] -``` - -## 参数 - -| Flag | 类型 | 必填 | 默认 | 说明 | -|------|------|------|------|------| -| `--tool` | choice | 是 | | 目标 AI 工具,见下表。 | -| `--user` | flag | 是* | | 同步到用户级目录。 | -| `--project` | flag | 是* | | 同步到项目级目录。 | -| `--workspace` | multi | 否 | 全部 | 工作空间过滤,可重复。 | -| `--delete-unmanaged` | flag | 否 | false | 删除不在所选管控范围内的本地技能目录(需确认)。 | -| `-y`、`--yes` | flag | 否 | false | 跳过确认提示。 | - -\* `--user` 与 `--project` 二选一。 - -### `--tool` 可选值 - -| 选项 | 用户级路径 | 项目级路径 | -|------|-----------|-----------| -| `claude-code` | `~/.claude/skills` | `.claude/skills` | -| `codex` | `~/.codex/skills` | `.codex/skills` | -| `github-copilot` | `~/.github/copilot/skills` | `.github/copilot/skills` | -| `cursor` | `~/.cursor/skills` | `.cursor/skills` | -| `qoder` | `~/.qoder/skills` | `.qoder/skills` | - -## 行为说明 - -- 默认同步全部平台 Skill(除非传入 `--workspace`)。 -- 在下载/更新 Skill 前会先进行用户确认(传 `-y` 可跳过)。 -- 同步会检查本地元数据,仅下载缺失或有更新的 Skill。 -- 开启 `--delete-unmanaged` 时,会在再次确认后删除不在当前管控范围内的本地 Skill 目录。 - -## 示例 - -```bash -# 将 workspace abc + def 的 skills 同步到用户级 Claude Code 目录 -ar sync-skills --tool claude-code --user --workspace abc --workspace def - -# 将 workspace abc 的 skills 同步到项目级 Codex 目录 -ar sync-skills --tool codex --project --workspace abc - -# 跳过确认,将全部 skills 同步到项目级 Cursor 目录 -ar sync-skills --tool cursor --project -y - -# 同步到 GitHub Copilot 用户级目录,并删除不在管控范围的本地 skills -ar sync-skills --tool github-copilot --user --delete-unmanaged - -# 同步到 Qoder 项目级目录 -ar sync-skills --tool qoder --project --workspace my-workspace -``` diff --git a/src/agentrun_cli/commands/skill_cmd.py b/src/agentrun_cli/commands/skill_cmd.py index 5f663c3..57b0159 100644 --- a/src/agentrun_cli/commands/skill_cmd.py +++ b/src/agentrun_cli/commands/skill_cmd.py @@ -15,6 +15,7 @@ ar skill read-file --name web-scraper --path scraper.py ar skill exec --name web-scraper --command "python scraper.py" ar skill delete --name web-scraper + ar skill sync --tool claude-code --user --workspace my-workspace """ import base64 diff --git a/src/agentrun_cli/commands/sync_skills_cmd.py b/src/agentrun_cli/commands/sync_skills_cmd.py index c8a2183..9e7ec93 100644 --- a/src/agentrun_cli/commands/sync_skills_cmd.py +++ b/src/agentrun_cli/commands/sync_skills_cmd.py @@ -1,4 +1,4 @@ -"""``ar sync-skills`` — sync platform skills to local AI tool directories.""" +"""``ar skill sync`` — sync platform skills to local AI tool directories.""" import json import os @@ -10,6 +10,7 @@ from agentrun_cli._utils.error import handle_errors from agentrun_cli._utils.inner_client import get_agentrun_client from agentrun_cli._utils.output import format_output +from agentrun_cli.commands.skill_cmd import skill_group _META_FILE_NAME = ".agentrun-sync-skills.json" @@ -158,8 +159,8 @@ def _list_platform_skills(profile, region): return all_items -@click.command( - "sync-skills", +@skill_group.command( + "sync", help="Sync platform skills to a local AI tool skill directory.", ) @click.option( @@ -210,7 +211,7 @@ def _list_platform_skills(profile, region): ) @click.pass_context @handle_errors -def sync_skills( +def skill_sync( ctx, ai_tool, user_scope, diff --git a/src/agentrun_cli/main.py b/src/agentrun_cli/main.py index 51a602e..d3d8acb 100644 --- a/src/agentrun_cli/main.py +++ b/src/agentrun_cli/main.py @@ -22,7 +22,9 @@ from agentrun_cli.commands.sandbox import sandbox_group from agentrun_cli.commands.skill_cmd import skill_group from agentrun_cli.commands.super_agent import super_agent_group -from agentrun_cli.commands.sync_skills_cmd import sync_skills +from agentrun_cli.commands.sync_skills_cmd import ( + skill_sync, # noqa: F401 – registers ar skill sync +) from agentrun_cli.commands.tool_cmd import tool_group @@ -107,7 +109,6 @@ def cli(ctx: click.Context, profile, region, output, debug): cli._aliases["sb"] = "sandbox" cli.add_command(tool_group) cli.add_command(skill_group) -cli.add_command(sync_skills) cli.add_command(super_agent_group) cli._aliases["sa"] = "super-agent" diff --git a/tests/integration/test_sync_skills_cmd.py b/tests/integration/test_sync_skills_cmd.py index ea3fa9a..de34459 100644 --- a/tests/integration/test_sync_skills_cmd.py +++ b/tests/integration/test_sync_skills_cmd.py @@ -1,4 +1,4 @@ -"""Integration tests for top-level ``ar sync-skills`` command.""" +"""Integration tests for ``ar skill sync`` command.""" import json from types import SimpleNamespace @@ -15,7 +15,7 @@ def _mock_models_module(): return mod -class TestSyncSkillsCommand: +class TestSkillSyncCommand: @patch( "agentrun_cli.commands.sync_skills_cmd.build_sdk_config", @@ -46,7 +46,7 @@ def test_sync_success(self, mock_client_fn, mock_get_tool, _mock_cfg): runner = CliRunner() result = runner.invoke( cli, - ["sync-skills", "--tool", "claude-code", "--project", "-y"], + ["skill", "sync", "--tool", "claude-code", "--project", "-y"], ) assert result.exit_code == 0, result.output @@ -58,7 +58,7 @@ def test_sync_usage_error_missing_scope(self): runner = CliRunner() result = runner.invoke( cli, - ["sync-skills", "--tool", "cursor"], + ["skill", "sync", "--tool", "cursor"], ) assert result.exit_code != 0 assert "--user or --project" in result.output @@ -67,14 +67,14 @@ def test_sync_usage_error_both_scopes(self): runner = CliRunner() result = runner.invoke( cli, - ["sync-skills", "--tool", "claude-code", "--user", "--project"], + ["skill", "sync", "--tool", "claude-code", "--user", "--project"], ) assert result.exit_code != 0 assert "--user or --project" in result.output def test_sync_help_lists_all_tools(self): runner = CliRunner() - result = runner.invoke(cli, ["sync-skills", "--help"]) + result = runner.invoke(cli, ["skill", "sync", "--help"]) assert result.exit_code == 0 for tool in ("claude-code", "codex", "github-copilot", "cursor", "qoder"): assert tool in result.output diff --git a/tests/unit/test_sync_skills_cmd.py b/tests/unit/test_sync_skills_cmd.py index c9f1fdb..cdfd8dc 100644 --- a/tests/unit/test_sync_skills_cmd.py +++ b/tests/unit/test_sync_skills_cmd.py @@ -1,4 +1,4 @@ -"""Unit tests for ``agentrun_cli.commands.sync_skills_cmd``.""" +"""Unit tests for ``agentrun_cli.commands.sync_skills_cmd`` (ar skill sync).""" import json import os @@ -7,7 +7,7 @@ from click.testing import CliRunner -from agentrun_cli.commands.sync_skills_cmd import sync_skills +from agentrun_cli.commands.sync_skills_cmd import skill_sync def _mock_models_module(): @@ -40,25 +40,25 @@ class TestSyncSkillsValidation: def test_requires_tool_flag(self): runner = CliRunner() - result = runner.invoke(sync_skills, ["--user"]) + result = runner.invoke(skill_sync, ["--user"]) assert result.exit_code != 0 assert "--tool" in result.output def test_rejects_invalid_tool_name(self): runner = CliRunner() - result = runner.invoke(sync_skills, ["--tool", "unknown-tool", "--user"]) + result = runner.invoke(skill_sync, ["--tool", "unknown-tool", "--user"]) assert result.exit_code != 0 def test_requires_exactly_one_scope(self): runner = CliRunner() - result = runner.invoke(sync_skills, ["--tool", "claude-code"]) + result = runner.invoke(skill_sync, ["--tool", "claude-code"]) assert result.exit_code != 0 assert "--user or --project" in result.output def test_rejects_both_scope_flags(self): runner = CliRunner() result = runner.invoke( - sync_skills, ["--tool", "claude-code", "--user", "--project"] + skill_sync, ["--tool", "claude-code", "--user", "--project"] ) assert result.exit_code != 0 assert "--user or --project" in result.output @@ -87,7 +87,7 @@ def test_sync_all_with_confirmation(self, _mock_cfg): runner = CliRunner() with runner.isolated_filesystem(): result = runner.invoke( - sync_skills, + skill_sync, ["--tool", "claude-code", "--user"], input="y\n", env={"HOME": os.getcwd()}, @@ -136,7 +136,7 @@ def test_workspace_filter_and_skip_up_to_date(self, _mock_cfg): json.dump({"skill-a": {"updated_at": "2026-01-01"}}, f) result = runner.invoke( - sync_skills, + skill_sync, [ "--tool", "claude-code", "--project", @@ -173,7 +173,7 @@ def test_delete_unmanaged(self, _mock_cfg): os.makedirs(".codex/skills/skill-old", exist_ok=True) result = runner.invoke( - sync_skills, + skill_sync, ["--tool", "codex", "--project", "--delete-unmanaged", "-y"], ) @@ -201,7 +201,7 @@ def test_cursor_project_scope(self, _mock_cfg): runner = CliRunner() with runner.isolated_filesystem(): result = runner.invoke( - sync_skills, + skill_sync, ["--tool", "cursor", "--project", "-y"], ) @@ -228,7 +228,7 @@ def test_github_copilot_user_scope(self, _mock_cfg): runner = CliRunner() with runner.isolated_filesystem(): result = runner.invoke( - sync_skills, + skill_sync, ["--tool", "github-copilot", "--user", "-y"], env={"HOME": os.getcwd()}, ) @@ -256,7 +256,7 @@ def test_qoder_project_scope(self, _mock_cfg): runner = CliRunner() with runner.isolated_filesystem(): result = runner.invoke( - sync_skills, + skill_sync, ["--tool", "qoder", "--project", "-y"], )