Files
.pouch/skiff/cli.py
T
2026-08-23 22:02:06 +08:00

1825 lines
63 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""skiff CLI 入口。"""
from __future__ import annotations
import argparse
import ctypes
import errno
import os
import re
import secrets
import stat
import subprocess
import sys
import tempfile
from datetime import datetime
from pathlib import Path
from skiff import __version__
from skiff.agents import flatten_agent_args, resolve_agent_args
from skiff.gitops import publish as git_publish
from skiff.paths import (
ALL_TARGETS,
DRAFTS_DIR,
CATALOG_CACHE_DIR,
CONFIG_FILE,
SKILLS_DIR,
SKILLS_HOME,
TEMPLATE_DIR,
agent_skill_dir,
ensure_skills_home,
)
from skiff.project import (
add_skill_to_manifest,
iter_manifest_skills,
load_manifest,
remove_skill_from_manifest,
resolve_manifest_skill,
)
from skiff.catalog import (
discover_catalog_skills,
catalog_checkout_path,
catalog_skill_path,
load_catalog,
catalog_repo,
save_catalog,
)
from skiff.selector import SkillChoice, select_skills
from skiff.skills import (
list_custom_skills,
list_builtin_skills,
builtin_skill_path,
read_skill_meta,
normalize_source,
resolve_skill_source,
split_skill_spec,
skill_description,
validate_skill_dir,
validate_skill_name,
)
from skiff.sources import (
discover_source_skills,
fetch_source,
load_sources,
save_sources,
source_checkout,
source_skills_root,
validate_source_name,
)
from skiff.yaml_io import safe_dump
from skiff.symlinks import check_link, copy_template, create_link, find_repo_root, remove_link
_RENAME_NOREPLACE = 1
def _encode_single_path_component(value: str) -> bytes:
encoded = os.fsencode(value)
if (
not encoded
or encoded in {b".", b".."}
or b"/" in encoded
or b"\0" in encoded
):
raise ValueError(f"必须是单一路径组件: {value!r}")
return encoded
def _print(msg: str = "") -> None:
print(msg, file=sys.stdout)
def _err(msg: str) -> None:
print(msg, file=sys.stderr)
def _project_root(explicit: str | None = None) -> Path:
if explicit:
return Path(explicit).resolve()
return find_repo_root() or Path.cwd()
def _render_template(
source: Path,
destination: Path,
values: dict[str, str],
) -> str:
content = source.read_text(encoding="utf-8")
for placeholder, value in values.items():
content = content.replace(placeholder, value)
destination.write_text(content, encoding="utf-8")
return content
def _open_or_create_directory_at(
parent_fd: int,
name: str,
) -> tuple[int, bool]:
if (
not name
or name in {".", ".."}
or Path(name).name != name
or "/" in name
or "\\" in name
or (os.altsep is not None and os.altsep in name)
):
raise SystemExit(f"初始化目录名必须是单个安全路径段: {name!r}")
created = False
try:
os.mkdir(name, dir_fd=parent_fd)
created = True
except FileExistsError:
pass
try:
directory_fd = os.open(
name,
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
dir_fd=parent_fd,
)
except OSError as exc:
raise SystemExit(
f"初始化路径必须是普通目录且不能是软链接: {name}: {exc}"
) from exc
return directory_fd, created
def _rename_directory_noreplace(
source_parent_fd: int,
source_name: str,
destination_parent_fd: int,
destination_name: str,
) -> None:
"""Atomically publish a directory without replacing an existing path."""
source = _encode_single_path_component(source_name)
destination = _encode_single_path_component(destination_name)
if source == destination:
raise ValueError("暂存目录名与目标目录名不能相同")
try:
renameat2 = ctypes.CDLL(None, use_errno=True).renameat2
except (AttributeError, OSError) as exc:
raise SystemExit(
"当前平台缺少原子 no-replace 目录发布能力,拒绝执行初始化"
) from exc
renameat2.argtypes = [
ctypes.c_int,
ctypes.c_char_p,
ctypes.c_int,
ctypes.c_char_p,
ctypes.c_uint,
]
renameat2.restype = ctypes.c_int
ctypes.set_errno(0)
result = renameat2(
source_parent_fd,
source,
destination_parent_fd,
destination,
_RENAME_NOREPLACE,
)
if result == 0:
return
error_number = ctypes.get_errno()
if error_number in {errno.EEXIST, errno.ENOTEMPTY}:
raise FileExistsError(
error_number,
os.strerror(error_number),
destination_name,
)
if error_number in {
errno.ENOSYS,
errno.EINVAL,
getattr(errno, "ENOTSUP", errno.EOPNOTSUPP),
errno.EOPNOTSUPP,
}:
raise SystemExit(
"当前文件系统不支持原子 no-replace 目录发布,拒绝执行初始化"
)
if error_number == 0:
raise RuntimeError("renameat2 失败但未设置 errno")
raise OSError(
error_number,
os.strerror(error_number),
f"{source_name} -> {destination_name}",
)
def _assert_open_directory_path(
directory_fd: int,
path: Path,
*,
phase: str,
label: str = "项目目录",
) -> None:
"""Fail if a named directory no longer resolves to the opened inode."""
opened = os.fstat(directory_fd)
try:
current = os.stat(path, follow_symlinks=False)
except OSError as exc:
raise SystemExit(f"{phase}{label}已移动或不可访问: {path}") from exc
if (
not stat.S_ISDIR(current.st_mode)
or (opened.st_dev, opened.st_ino) != (current.st_dev, current.st_ino)
):
raise SystemExit(f"{phase}{label}已被替换: {path}")
def _directory_entry_matches_open_fd(
parent_fd: int,
name: str,
opened_fd: int,
) -> bool:
try:
current = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
except OSError:
return False
opened = os.fstat(opened_fd)
return (
stat.S_ISDIR(current.st_mode)
and (current.st_dev, current.st_ino) == (opened.st_dev, opened.st_ino)
)
def _collect_skill_names(positional: list[str] | None, flagged: list[str] | None) -> list[str]:
names = list(positional or [])
if flagged:
names.extend(flagged)
if "*" in names:
return list_builtin_skills()
return names
def _ensure_source_fetched(name: str, source: str | None = None) -> None:
name, source = split_skill_spec(name, source)
catalog = load_catalog()
sources = load_sources()
if source in sources:
root = source_skills_root(source, sources[source])
if not root.is_dir():
_print(f"拉取 source: {source}")
fetch_source(source, sources[source])
return
catalog_name = (
source.split(":", 1)[1]
if source and source.startswith("catalog:")
else source
if source in catalog
else name
if name in catalog
else None
)
if catalog_name and (
source in (None, "catalog", catalog_name)
or source == f"catalog:{catalog_name}"
):
_ensure_catalog_fetched(catalog_name, catalog[catalog_name])
return
if source not in (None, "catalog"):
return
if name not in catalog:
return
_ensure_catalog_fetched(name, catalog[name])
def _ensure_catalog_fetched(name: str, entry: dict[str, object]) -> None:
path = catalog_skill_path(name, entry)
if (path / "SKILL.md").is_file() or discover_catalog_skills(name, entry):
return
repo = catalog_repo(entry)
ref = entry.get("ref", "main")
dest = catalog_checkout_path(name, entry)
dest.parent.mkdir(parents=True, exist_ok=True)
if dest.exists():
raise SystemExit(
f"外部仓库已存在但 skill 路径无效: {catalog_skill_path(name, entry)}"
)
_print(f"拉取外部 skill: {name}")
subprocess.run(
["git", "clone", "--depth", "1", "--branch", ref, "--", repo, str(dest)],
check=True,
)
if not discover_catalog_skills(name, entry):
raise SystemExit(
f"catalog 条目 {name!r} 的 path 中没有可安装的 SKILL.md: {path}"
)
def _catalog_skill_names(
name: str,
entry: dict[str, object] | None = None,
) -> list[str]:
entry = entry or load_catalog().get(name)
if not entry:
raise SystemExit(f"catalog 中不存在: {name}")
_ensure_catalog_fetched(name, entry)
names = list(discover_catalog_skills(name, entry))
for skill_name in names:
validate_skill_name(skill_name)
if not names:
raise SystemExit(f"catalog 条目 {name!r} 中没有可安装的 skill")
return names
def _expand_install_request(
spec: str,
explicit_source: str | None = None,
) -> list[tuple[str, str | None]]:
name, source = split_skill_spec(spec, explicit_source)
catalog = load_catalog()
sources = load_sources()
if source in sources:
return [(name, source)]
if source == "catalog" and name in catalog:
available = _catalog_skill_names(name, catalog[name])
root = catalog_skill_path(name, catalog[name])
if (root / "SKILL.md").is_file():
return [(name, "catalog")]
return [(skill_name, f"catalog:{name}") for skill_name in available]
if source in catalog:
available = _catalog_skill_names(source, catalog[source])
if name not in available:
raise SystemExit(
f"catalog collection {source!r} 中找不到 skill {name!r}"
)
return [(name, f"catalog:{source}")]
if source is None and name in catalog:
available = _catalog_skill_names(name, catalog[name])
root = catalog_skill_path(name, catalog[name])
if (root / "SKILL.md").is_file():
return [(name, f"catalog:{name}")]
return [(skill_name, f"catalog:{name}") for skill_name in available]
if source is None and name in sources:
_ensure_source_fetched(name, name)
available = discover_source_skills(name, sources[name])
if not available:
raise SystemExit(f"custom source {name!r} 中没有可安装的 skill")
return [(skill_name, name) for skill_name in available]
return [(name, source)]
def _manifest_source_details(resolved_source: str) -> tuple[str, dict[str, object]]:
return resolved_source, {}
def _install_skill(
name: str,
targets: list[str],
project_root: Path | None = None,
*,
source: str | None = None,
) -> str:
name, source = split_skill_spec(name, source)
_ensure_source_fetched(name, source)
skill_path, resolved_source = resolve_skill_source(name, source=source)
links = [
(target, agent_skill_dir(target, project_root=project_root) / name)
for target in targets
]
original: list[tuple[Path, str | None]] = []
try:
for _, link in links:
previous = str(link.readlink()) if link.is_symlink() else None
original.append((link, previous))
create_link(link, skill_path)
except Exception:
for link, previous in reversed(original):
if link.is_symlink():
link.unlink()
if previous is not None:
link.symlink_to(previous)
raise
for target, _ in links:
scope = "全局" if project_root is None else "项目"
_print(f"已安装 ({scope}/{target}): {resolved_source}/{name} -> {skill_path}")
return resolved_source
def _list_installed_names(project_root: Path | None, targets: list[str]) -> list[str]:
names: set[str] = set()
for target in targets:
skill_dir = agent_skill_dir(target, project_root=project_root)
if not skill_dir.is_dir():
continue
for entry in skill_dir.iterdir():
if entry.name.startswith("."):
continue
if entry.is_symlink() or entry.is_dir():
names.add(entry.name)
return sorted(names)
def _list_fully_installed_names(
project_root: Path | None,
targets: list[str],
) -> list[str]:
installed_by_target = [
set(_list_installed_names(project_root, [target]))
for target in targets
]
if not installed_by_target:
return []
return sorted(set.intersection(*installed_by_target))
def _is_fully_installed(
name: str,
expected: Path,
project_root: Path | None,
targets: list[str],
) -> bool:
return all(
check_link(
agent_skill_dir(target, project_root=project_root) / name,
expected,
).ok
for target in targets
)
def _global_installation_note(
name: str,
expected: Path,
targets: list[str],
) -> str:
installed: list[str] = []
conflicts: list[str] = []
for target in targets:
link = agent_skill_dir(target) / name
status = check_link(link, expected)
if status.ok:
installed.append(target)
elif link.exists() or link.is_symlink():
conflicts.append(target)
parts: list[str] = []
if installed:
parts.append(f"全局: {','.join(installed)}")
if conflicts:
parts.append(f"全局同名冲突: {','.join(conflicts)}")
return "".join(parts)
def _remove_skill(
name: str,
targets: list[str],
*,
project_root: Path | None,
) -> int:
removed = 0
for target in targets:
link = agent_skill_dir(target, project_root=project_root) / name
try:
if remove_link(link):
scope = "全局" if project_root is None else "项目"
_print(f"已移除 ({scope}/{target}): {name}")
removed += 1
except FileExistsError as exc:
_err(f"跳过 {link}: {exc}")
return removed
def cmd_list(args: argparse.Namespace) -> None:
ensure_skills_home()
source_filter = normalize_source(args.source)
builtin = list_builtin_skills()
catalog = load_catalog()
custom = (
{}
if source_filter in ("builtin", "catalog")
or (source_filter and source_filter.startswith("catalog:"))
else list_custom_skills(source_filter)
)
if source_filter in (None, "builtin"):
_print("内置 (builtin):")
for name in builtin:
_print(f" {name}")
if source_filter in (None, "catalog"):
_print("\n目录 (catalog):")
if not catalog:
_print(" (无)")
else:
for name, entry in catalog.items():
repo = entry.get("repo", "?")
_print(f" {name} ({repo})")
elif source_filter and source_filter.startswith("catalog:"):
provider = source_filter.split(":", 1)[1]
if provider not in catalog:
raise SystemExit(f"catalog 中不存在: {provider}")
_ensure_catalog_fetched(provider, catalog[provider])
_print(f"目录 (catalog:{provider}):")
for name in discover_catalog_skills(provider, catalog[provider]):
_print(f" {name}")
for source, names in custom.items():
_print(f"\n自定义 ({source}):")
if not names:
_print(" (无,或 source 尚未 fetch)")
for name in names:
_print(f" {name}")
def cmd_bootstrap(args: argparse.Namespace) -> None:
del args
ensure_skills_home()
project_skill = "skiff"
builtin_skill_path(project_skill)
_install_skill(project_skill, list(ALL_TARGETS), project_root=None)
_print("已全局安装 builtin skiff skill 到所有 agent")
def cmd_update(args: argparse.Namespace) -> None:
del args
ensure_skills_home()
_print(f"更新 skiff: {SKILLS_HOME}")
subprocess.run(["git", "-C", str(SKILLS_HOME), "pull"], check=True)
def _installed_links(
name: str,
targets: list[str],
project_root: Path | None = None,
*,
source: str | None = None,
) -> list[tuple[str, Path, Path]]:
skill_path, _ = resolve_skill_source(name, source=source)
rows: list[tuple[str, Path, Path]] = []
for target in targets:
link = agent_skill_dir(target, project_root=project_root) / name
rows.append((target, link, skill_path))
return rows
def cmd_status(args: argparse.Namespace) -> None:
ensure_skills_home()
targets = resolve_agent_args(flatten_agent_args(args.agents))
builtin = list_builtin_skills()
catalog = load_catalog()
custom = list_custom_skills()
entries = [("builtin", name) for name in builtin]
unfetched_catalog: list[str] = []
for package, entry in catalog.items():
discovered = discover_catalog_skills(package, entry)
if not discovered:
unfetched_catalog.append(package)
elif (catalog_skill_path(package, entry) / "SKILL.md").is_file():
entries.append((f"catalog:{package}", package))
else:
entries.extend(
(f"catalog:{package}", skill_name)
for skill_name in discovered
)
entries.extend((source, name) for source, names in custom.items() for name in names)
_print(f"skills 仓库: {SKILLS_HOME.resolve()}")
_print(f"agents: {', '.join(targets)}\n")
for package in unfetched_catalog:
_print(f"[catalog] {package}\n (未 fetch)\n")
for source, name in entries:
_print(f"[{source}] {name}")
try:
skill_path, _ = resolve_skill_source(name, source=source)
rows = [
(target, agent_skill_dir(target) / name, skill_path)
for target in targets
]
except SystemExit:
_print(" (未 fetch)")
continue
for target, link, expected in rows:
status = check_link(link, expected)
mark = "✓" if status.ok else "✗"
detail = "" if status.ok else f" — {status.issue}"
_print(f" {mark} {target}: {link}{detail}")
_print("")
def _print_available_skills() -> None:
ensure_skills_home()
builtin = list_builtin_skills()
if not builtin:
_print("~/.skills/skills/ 中没有自研 skill")
return
_print(f"来源: {SKILLS_DIR}\n")
for name in builtin:
desc = skill_description(name)
_print(f" {name}")
if desc:
for line in desc.splitlines():
_print(f" {line}")
_print("")
def cmd_add(args: argparse.Namespace) -> None:
ensure_skills_home()
if args.list_available:
if args.source:
cmd_list(argparse.Namespace(source=args.source))
else:
_print_available_skills()
_print("使用 skiff add <name> 安装,或 skiff add <name> -g 全局安装")
return
if args.all:
if args.source:
source = normalize_source(args.source)
if source == "builtin":
names = list_builtin_skills()
elif source == "catalog":
names = list(load_catalog())
else:
_ensure_source_fetched("", source)
names = list_custom_skills(source)[source]
else:
names = list_builtin_skills()
targets = resolve_agent_args(["*"])
else:
names = _collect_skill_names(args.skills, args.skills_flag)
if not names:
raise SystemExit("请指定 skill 名称,或使用 skiff add --list 查看可用 skill")
targets = resolve_agent_args(flatten_agent_args(args.agents))
project_root = None if args.global_scope else _project_root(args.project)
requests = [
request
for name in names
for request in _expand_install_request(name, args.source)
]
requests = list(dict.fromkeys(requests))
original_links: list[tuple[Path, str | None]] = []
for skill_name, source in requests:
validate_skill_name(skill_name)
_ensure_source_fetched(skill_name, source)
resolve_skill_source(skill_name, source=source)
for target in targets:
link = agent_skill_dir(target, project_root=project_root) / skill_name
if link.exists() and not link.is_symlink():
raise FileExistsError(f"已存在非软链路径: {link}")
original_links.append(
(link, str(link.readlink()) if link.is_symlink() else None)
)
manifest_path = project_root / ".skills.yaml" if project_root else None
manifest_before = (
manifest_path.read_bytes()
if manifest_path and manifest_path.is_file()
else None
)
try:
for skill_name, source in requests:
resolved_source = _install_skill(
skill_name,
targets,
project_root=project_root,
source=source,
)
if manifest_path is not None:
manifest_source, extra = _manifest_source_details(resolved_source)
add_skill_to_manifest(
manifest_path,
skill_name,
source=manifest_source,
extra=extra or None,
)
except Exception:
for link, previous in reversed(original_links):
if link.is_symlink():
link.unlink()
if previous is not None:
link.symlink_to(previous)
if manifest_path is not None:
if manifest_before is None:
if manifest_path.is_file():
manifest_path.unlink()
else:
manifest_path.write_bytes(manifest_before)
raise
def cmd_select(args: argparse.Namespace) -> None:
ensure_skills_home()
if not sys.stdin.isatty() or not sys.stdout.isatty():
raise SystemExit(
"`skiff select` 需要交互式终端;非交互环境请使用 `skiff add <name>...`"
)
targets = resolve_agent_args(flatten_agent_args(args.agents))
project_root = None if args.global_scope else _project_root(args.project)
catalog = load_catalog()
builtin_names = list_builtin_skills()
for name in [*builtin_names, *catalog]:
validate_skill_name(name)
def make_choice(
*,
name: str,
installed_name: str,
expected: Path,
kind: str,
description: str,
indent: int = 0,
) -> SkillChoice:
return SkillChoice(
name=name,
kind=kind,
description=description,
installed=_is_fully_installed(
installed_name,
expected,
project_root,
targets,
),
readonly_status=(
_global_installation_note(installed_name, expected, targets)
if project_root is not None
else ""
),
indent=indent,
)
choices = [
make_choice(
name=name,
installed_name=name,
expected=SKILLS_DIR / name,
kind="builtin",
description=skill_description(name) or "",
)
for name in builtin_names
]
choice_requests: dict[str, tuple[str, str]] = {
name: (name, "builtin") for name in builtin_names
}
def add_provider_choices(
provider: str,
*,
registration: str,
discovered: dict[str, Path],
description: str,
) -> None:
source = provider if registration == "custom" else f"catalog:{provider}"
if len(discovered) == 1 and provider in discovered:
expected = discovered[provider]
if provider in choice_requests:
_err(
f"警告: {registration} source 与已有 skill 同名,"
f"已忽略: {provider}"
)
return
choices.append(
make_choice(
name=provider,
installed_name=provider,
expected=expected,
kind=f"{registration}:{provider}",
description=description,
)
)
choice_requests[provider] = (provider, source)
return
child_names: list[str] = []
first_child = len(choices)
for skill_name, expected in discovered.items():
if (
skill_name in builtin_names
and expected.resolve() == (SKILLS_DIR / skill_name).resolve()
):
continue
choice_name = f"{provider}/{skill_name}"
choices.append(
make_choice(
name=choice_name,
installed_name=skill_name,
expected=expected,
kind=f"{registration}:{provider}",
description=read_skill_meta(expected).get("description", ""),
indent=1,
)
)
choice_requests[choice_name] = (skill_name, source)
child_names.append(choice_name)
if child_names:
choices.insert(
first_child,
SkillChoice(
name=provider,
kind=f"{registration} source",
description=description,
children=tuple(child_names),
),
)
for package, entry in catalog.items():
skill_names = _catalog_skill_names(package, entry)
root = catalog_skill_path(package, entry)
discovered = (
{package: root}
if (root / "SKILL.md").is_file()
else {skill_name: root / skill_name for skill_name in skill_names}
)
add_provider_choices(
package,
registration="catalog",
discovered=discovered,
description=str(entry.get("description", "")),
)
custom_sources = load_sources()
for provider, entry in custom_sources.items():
validate_skill_name(provider)
_ensure_source_fetched("", provider)
add_provider_choices(
provider,
registration="custom",
discovered=discover_source_skills(provider, entry),
description=str(entry.get("description", "")),
)
try:
scope_label = (
"全局"
if project_root is None
else f"项目 {project_root}(全局状态只读)"
)
selected = select_skills(choices, scope_label=scope_label)
except (RuntimeError, OSError) as exc:
raise SystemExit(str(exc)) from exc
if selected is None:
_print("已取消,未修改环境")
return
selected_installed_keys = {
choice.name for choice in choices if choice.installed
}
selected_outputs: dict[str, str] = {}
for key in selected:
skill_name, _ = choice_requests[key]
previous = selected_outputs.get(skill_name)
if previous and previous != key:
raise SystemExit(
f"选择冲突: {previous}{key} 都会安装为 {skill_name!r}"
)
selected_outputs[skill_name] = key
names = sorted(selected - selected_installed_keys)
failures: list[tuple[str, str]] = []
manifest_path = project_root / ".skills.yaml" if project_root else None
successful = set(selected & selected_installed_keys)
for name in names:
skill_name, source = choice_requests[name]
try:
_install_skill(
skill_name,
targets,
project_root=project_root,
source=source,
)
successful.add(name)
except (OSError, subprocess.CalledProcessError, SystemExit) as exc:
failures.append((name, str(exc)))
_err(f"✗ {name}: {exc}")
if manifest_path is not None:
entry_targets = targets if args.agents else None
for name in sorted(successful):
skill_name, source = choice_requests[name]
extra = {"targets": entry_targets} if entry_targets else None
add_skill_to_manifest(
manifest_path,
skill_name,
source=source,
extra=extra,
)
installed_count = len(names) - len(failures)
if not names:
_print("没有需要安装的新 skill")
else:
_print(f"安装完成: {installed_count} 成功,{len(failures)} 失败")
if failures:
raise SystemExit(1)
def cmd_remove(args: argparse.Namespace) -> None:
ensure_skills_home()
project_root = None if args.global_scope else _project_root(args.project)
targets = resolve_agent_args(flatten_agent_args(args.agents))
if args.all:
names = _list_installed_names(project_root, targets)
else:
names = _collect_skill_names(args.skills, args.skills_flag)
if not names:
raise SystemExit("请指定 skill 名称,或使用 skiff remove --all")
catalog = load_catalog()
sources = load_sources()
expanded: list[str] = []
for spec in names:
name, source = split_skill_spec(spec)
if source and source.startswith("catalog:"):
provider = source.split(":", 1)[1]
if provider not in catalog:
raise SystemExit(f"catalog 中不存在: {provider}")
if name not in discover_catalog_skills(provider, catalog[provider]):
raise SystemExit(
f"catalog source {provider!r} 中找不到 skill {name!r}"
)
expanded.append(name)
elif source is None and name in catalog:
discovered = discover_catalog_skills(name, catalog[name])
root = catalog_skill_path(name, catalog[name])
expanded.extend(
[name]
if (root / "SKILL.md").is_file()
else list(discovered)
)
elif source is None and name in sources:
expanded.extend(discover_source_skills(name, sources[name]))
else:
expanded.append(name)
total = 0
for name in dict.fromkeys(expanded):
total += _remove_skill(name, targets, project_root=project_root)
if project_root is not None:
remove_skill_from_manifest(project_root / ".skills.yaml", name)
if total == 0:
_print("没有移除任何 skill")
def cmd_publish(args: argparse.Namespace) -> None:
ensure_skills_home()
paths = args.paths or ["."]
git_publish(
paths=paths,
message=args.message,
push=args.push,
no_commit=args.no_commit,
)
def cmd_catalog_add(args: argparse.Namespace) -> None:
ensure_skills_home()
validate_skill_name(args.name)
catalog = load_catalog()
if args.name in catalog:
raise SystemExit(f"catalog 中已存在: {args.name}")
catalog[args.name] = {
"repo": args.repo,
"ref": args.ref,
"path": args.path,
}
save_catalog(catalog)
_print(f"已添加 catalog 条目: {args.name}")
def cmd_fetch(args: argparse.Namespace) -> None:
ensure_skills_home()
catalog = load_catalog()
if args.name not in catalog:
raise SystemExit(f"catalog 中不存在: {args.name}")
entry = catalog[args.name]
repo = catalog_repo(entry)
ref = entry.get("ref", "main")
dest = catalog_checkout_path(args.name, entry)
CATALOG_CACHE_DIR.mkdir(parents=True, exist_ok=True)
if dest.exists():
_print(f"更新: {dest}")
subprocess.run(["git", "-C", str(dest), "fetch", "--all", "--tags"], check=True)
subprocess.run(["git", "-C", str(dest), "checkout", ref], check=True)
subprocess.run(["git", "-C", str(dest), "pull", "--ff-only"], check=True)
else:
_print(f"克隆: {repo} -> {dest}")
subprocess.run(
["git", "clone", "--depth", "1", "--branch", ref, "--", repo, str(dest)],
check=True,
)
def cmd_source_add(args: argparse.Namespace) -> None:
validate_source_name(args.name)
sources = load_sources()
if args.name in sources:
raise SystemExit(f"source 已存在: {args.name}")
if bool(args.repo) == bool(args.local):
raise SystemExit("必须且只能指定 Git repo 或 --local")
entry: dict[str, str] = {"skills_path": args.skills_path}
if args.local:
local = Path(args.local).expanduser().resolve()
if not local.is_dir():
raise SystemExit(f"本地仓库不存在: {local}")
entry["local_path"] = str(local)
else:
entry.update({"repo": args.repo, "ref": args.ref})
if args.checkout:
entry["checkout"] = str(Path(args.checkout).expanduser().resolve())
# 注册前先校验路径不能逃出仓库。
source_skills_root(args.name, entry)
if not args.local and not args.no_fetch:
_print(f"克隆 source: {args.name}")
fetch_source(args.name, entry)
sources[args.name] = entry
save_sources(sources)
_print(f"已添加 source: {args.name} ({CONFIG_FILE})")
def cmd_source_list(args: argparse.Namespace) -> None:
del args
sources = load_sources()
if not sources:
_print("未配置 custom source")
return
for name, entry in sources.items():
kind = "local" if entry.get("local_path") else "git"
_print(
f"{name} [{kind}] checkout={source_checkout(name, entry)} "
f"skills={source_skills_root(name, entry)}"
)
def cmd_source_show(args: argparse.Namespace) -> None:
sources = load_sources()
if args.name not in sources:
raise SystemExit(f"未配置 source: {args.name}")
entry = sources[args.name]
_print(f"name: {args.name}")
for key, value in entry.items():
_print(f"{key}: {value}")
_print(f"checkout: {source_checkout(args.name, entry)}")
_print(f"skills_root: {source_skills_root(args.name, entry)}")
def cmd_source_fetch(args: argparse.Namespace) -> None:
sources = load_sources()
names = list(sources) if args.all else [args.name]
if not names or names == [None]:
raise SystemExit("请指定 source 名称,或使用 --all")
for name in names:
if name not in sources:
raise SystemExit(f"未配置 source: {name}")
_print(f"更新 source: {name}")
fetch_source(name, sources[name])
def cmd_source_remove(args: argparse.Namespace) -> None:
sources = load_sources()
if args.name not in sources:
raise SystemExit(f"未配置 source: {args.name}")
entry = sources[args.name]
if args.delete_checkout and entry.get("local_path"):
raise SystemExit("不会删除 --local 指定的仓库")
checkout = source_checkout(args.name, entry)
if args.delete_checkout and (
checkout in (Path("/"), Path.home().resolve()) or not (checkout / ".git").is_dir()
):
raise SystemExit(f"拒绝删除不安全或非 Git checkout: {checkout}")
sources.pop(args.name)
save_sources(sources)
_print(f"已移除 source 配置: {args.name}")
if args.delete_checkout:
import shutil
if checkout.is_dir():
shutil.rmtree(checkout)
_print(f"已删除 checkout(不可恢复): {checkout}")
def cmd_sync(args: argparse.Namespace) -> None:
ensure_skills_home()
root = _project_root(args.project)
manifest_path = root / ".skills.yaml"
if not manifest_path.is_file():
raise SystemExit(f"未找到 {manifest_path}")
_, data = load_manifest(manifest_path)
targets = resolve_agent_args(flatten_agent_args(args.agents))
manifest_targets = data.get("targets")
if manifest_targets:
targets = [t for t in targets if t in manifest_targets]
for entry in iter_manifest_skills(data):
name = entry["name"]
source = entry.get("source")
_ensure_source_fetched(name, source)
skill_path, _ = resolve_manifest_skill(entry)
skill_targets = targets
if entry.get("targets"):
skill_targets = [target for target in targets if target in entry["targets"]]
for target in skill_targets:
link = agent_skill_dir(target, project_root=root) / name
create_link(link, skill_path)
_print(f"已同步: {link} -> {skill_path}")
def cmd_create(args: argparse.Namespace) -> None:
ensure_skills_home()
validate_skill_name(args.name)
if not TEMPLATE_DIR.is_dir():
raise SystemExit(f"模板目录不存在: {TEMPLATE_DIR}")
dst = DRAFTS_DIR / args.name
copy_template(TEMPLATE_DIR, dst)
skill_md = dst / "SKILL.md"
content = skill_md.read_text(encoding="utf-8")
content = re.sub(
r"(^name:\s*)skill-name\s*$",
rf"\g<1>{args.name}",
content,
count=1,
flags=re.MULTILINE,
)
skill_md.write_text(content, encoding="utf-8")
readme = dst / "README.md"
if readme.is_file():
readme_content = re.sub(
r"^#\s+skill-name\s*$",
f"# {args.name}",
readme.read_text(encoding="utf-8"),
count=1,
flags=re.MULTILINE,
)
readme.write_text(readme_content, encoding="utf-8")
brief = {
"name": args.name,
"idea": args.idea or "",
"source_project": str(Path(args.from_project).resolve()) if args.from_project else "",
"status": "draft",
"created_at": datetime.now().astimezone().isoformat(timespec="seconds"),
}
(dst / "brief.yaml").write_text(safe_dump(brief), encoding="utf-8")
_print(f"草稿已创建: {dst}")
_print(f"下一步: 请完善 skiff 草稿 {args.name}")
_print(f"完成后运行: skiff check {args.name} && skiff finalize {args.name}")
def _draft_or_builtin_path(name: str) -> tuple[Path, str]:
draft = DRAFTS_DIR / name
if draft.is_dir():
return draft, "草稿"
builtin = SKILLS_DIR / name
if builtin.is_dir():
return builtin, "正式 skill"
raise SystemExit(f"找不到草稿或正式 skill: {name}")
def cmd_check(args: argparse.Namespace) -> None:
ensure_skills_home()
validate_skill_name(args.name)
path, kind = _draft_or_builtin_path(args.name)
issues = validate_skill_dir(path, args.name)
if kind == "草稿":
issues = [issue for issue in issues if "草稿文件: brief.yaml" not in issue]
if issues:
for issue in issues:
_err(f"✗ {issue}")
raise SystemExit(1)
_print(f"✓ 校验通过 ({kind}): {path}")
def cmd_finalize(args: argparse.Namespace) -> None:
ensure_skills_home()
validate_skill_name(args.name)
draft = DRAFTS_DIR / args.name
if not draft.is_dir():
raise SystemExit(f"草稿不存在: {args.name}")
final = SKILLS_DIR / args.name
if final.exists():
raise SystemExit(f"正式 skill 已存在: {final}")
issues = validate_skill_dir(draft, args.name)
issues = [issue for issue in issues if "草稿文件: brief.yaml" not in issue]
if issues:
for issue in issues:
_err(f"✗ {issue}")
raise SystemExit(1)
final.parent.mkdir(parents=True, exist_ok=True)
draft.replace(final)
brief = final / "brief.yaml"
if brief.exists():
brief.unlink()
_print(f"已完成 skill: {final}")
_print(f"下一步: skiff publish skills/{args.name} -m \"add {args.name}\" --push")
def cmd_doctor(args: argparse.Namespace) -> None:
ensure_skills_home()
targets = resolve_agent_args(flatten_agent_args(args.agents))
issues = 0
_print(f"skills 仓库: {SKILLS_HOME.resolve()}")
if SKILLS_HOME.is_symlink():
if not SKILLS_HOME.resolve().is_dir():
_err(f"✗ ~/.skills 指向无效路径: {SKILLS_HOME.resolve()}")
issues += 1
else:
_print("✓ ~/.skills 软链正常")
elif SKILLS_HOME.is_dir() and (SKILLS_HOME / "skills").is_dir():
_print("✓ ~/.skills 为本地仓库目录")
else:
_err("✗ ~/.skills 未正确配置")
issues += 1
for name in list_builtin_skills():
for target, link, expected in _installed_links(name, targets, source="builtin"):
status = check_link(link, expected)
if status.ok:
continue
_err(f"✗ [{name}/{target}] {status.issue}: {link}")
issues += 1
if args.fix:
try:
create_link(link, expected)
_print(f" 已修复: {link}")
except Exception as exc: # noqa: BLE001
_err(f" 修复失败: {exc}")
catalog = load_catalog()
for name in catalog:
ext = catalog_skill_path(name, catalog[name])
if not ext.exists():
_err(f"✗ 外部 skill 未 fetch: {name}")
issues += 1
for source, entry in load_sources().items():
checkout = source_checkout(source, entry)
root = source_skills_root(source, entry)
if not checkout.is_dir():
_err(f"✗ source checkout 不存在: {source} -> {checkout}")
issues += 1
elif not root.is_dir():
_err(f"✗ source skills_path 不存在: {source} -> {root}")
issues += 1
if issues == 0:
_print("\n全部正常")
else:
_print(f"\n发现 {issues} 个问题")
if not args.fix:
_print("提示: 使用 skiff doctor --fix 尝试自动修复软链")
sys.exit(1)
def cmd_init(args: argparse.Namespace) -> None:
"""使用 builtin skill 自带的模板初始化目标项目状态。"""
ensure_skills_home()
validate_skill_name(args.name)
skills_root = SKILLS_DIR.resolve()
skill_source = (SKILLS_DIR / args.name).resolve()
try:
skill_source.relative_to(skills_root)
except ValueError as exc:
raise SystemExit(f"builtin skill 路径逃逸仓库边界: {args.name}") from exc
if not (skill_source / "SKILL.md").is_file():
raise SystemExit(f"builtin skill 不存在: {args.name}")
project = _project_root(args.project)
try:
initial_project_stat = os.stat(project, follow_symlinks=False)
except OSError as exc:
raise SystemExit(f"项目目录不存在: {project}") from exc
if not stat.S_ISDIR(initial_project_stat.st_mode):
raise SystemExit(f"项目目录不存在: {project}")
initial_project_identity = (
initial_project_stat.st_dev,
initial_project_stat.st_ino,
stat.S_IFMT(initial_project_stat.st_mode),
)
destination = project / "docs" / args.name
project_file = destination / "project.md"
tasks_file = destination / "tasks.yaml"
knowledge_file = destination / "knowledge.yaml"
delivery_file = destination / "delivery.yaml"
managed_targets = [project_file, tasks_file]
if args.name == "ack":
managed_targets.extend((knowledge_file, delivery_file))
existing = [path for path in managed_targets if path.exists() or path.is_symlink()]
if existing:
paths = ", ".join(str(path.relative_to(project)) for path in existing)
raise SystemExit(f"拒绝覆盖已有路径: {paths}")
project_template = skill_source / "templates" / "project.template.md"
tasks_template = skill_source / "templates" / "tasks.template.yaml"
template_targets = [
(project_template, project_file),
(tasks_template, tasks_file),
]
if args.name == "ack":
template_targets.extend(
(
(skill_source / "templates" / "knowledge.template.yaml", knowledge_file),
(skill_source / "templates" / "delivery.template.yaml", delivery_file),
)
)
missing = [path for path, _ in template_targets if not path.is_file()]
if missing:
paths = ", ".join(str(path.relative_to(SKILLS_HOME)) for path in missing)
raise SystemExit(f"skill 缺少初始化模板: {paths}")
validator = skill_source / "scripts" / "validate_tasks.py"
knowledge_validator = skill_source / "scripts" / "validate_knowledge.py"
delivery_validator = skill_source / "scripts" / "validate_delivery.py"
if args.name == "ack":
missing_validators = [
path
for path in (validator, knowledge_validator, delivery_validator)
if not path.is_file()
]
if missing_validators:
paths = ", ".join(path.name for path in missing_validators)
raise SystemExit(f"ACK skill 缺少初始化校验器: {paths}")
version_file = skill_source / "VERSION"
ack_version = version_file.read_text(encoding="utf-8").strip() if version_file.is_file() else "unknown"
now = datetime.now().astimezone().isoformat(timespec="seconds")
values = {
"<project_name>": project.name,
"<repo_path>": str(project),
"<dev_worktree>": str(project),
"<overlay_file_path>": f"docs/{args.name}/project.md",
"<ack_version>": ack_version,
"<接入时的 ack skill 版本>": ack_version,
"<YYYY-MM-DDTHH:mm:ss+TZ>": now,
}
with tempfile.TemporaryDirectory(prefix=f"skiff-{args.name}-init-") as temp_dir:
staging = Path(temp_dir)
staged_files: dict[Path, Path] = {}
rendered_files: dict[Path, str] = {}
for template, target in template_targets:
staged = staging / target.relative_to(project)
staged.parent.mkdir(parents=True, exist_ok=True)
rendered_files[target] = _render_template(template, staged, values)
staged_files[target] = staged
if validator.is_file():
completed = subprocess.run(
[sys.executable, str(validator), str(staged_files[tasks_file])],
check=False,
)
if completed.returncode != 0:
raise SystemExit(
f"初始化任务板校验失败(exit {completed.returncode}"
)
if args.name == "ack" and knowledge_validator.is_file():
completed = subprocess.run(
[
sys.executable,
str(knowledge_validator),
str(staged_files[knowledge_file]),
"--tasks",
str(staged_files[tasks_file]),
"--project-root",
str(staging),
],
check=False,
)
if completed.returncode != 0:
raise SystemExit(
f"初始化知识库校验失败(exit {completed.returncode}"
)
if args.name == "ack" and delivery_validator.is_file():
completed = subprocess.run(
[
sys.executable,
str(delivery_validator),
str(staged_files[delivery_file]),
"--tasks",
str(staged_files[tasks_file]),
"--project-root",
str(staging),
],
check=False,
)
if completed.returncode != 0:
raise SystemExit(
f"初始化交付契约校验失败(exit {completed.returncode}"
)
for target, staged in staged_files.items():
if staged.read_text(encoding="utf-8") != rendered_files[target]:
raise SystemExit(
f"初始化临时文件在校验期间发生变化: {target.name}"
)
# Validation may take time, so guard against a concurrent initializer before writing.
existing = [path for path in managed_targets if path.exists() or path.is_symlink()]
if existing:
paths = ", ".join(str(path.relative_to(project)) for path in existing)
raise SystemExit(f"拒绝覆盖已有路径: {paths}")
project_fd: int | None = None
docs_fd: int | None = None
transaction_fd: int | None = None
staging_fd: int | None = None
docs_created = False
transaction_name: str | None = None
staged_names: list[str] = []
published = False
committed = False
try:
try:
project_fd = os.open(
project,
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
)
except OSError as exc:
raise SystemExit(
f"校验期间项目目录已移动或不可访问: {project}"
) from exc
opened_project_stat = os.fstat(project_fd)
opened_project_identity = (
opened_project_stat.st_dev,
opened_project_stat.st_ino,
stat.S_IFMT(opened_project_stat.st_mode),
)
if opened_project_identity != initial_project_identity:
raise SystemExit(f"校验期间项目目录已被替换: {project}")
_assert_open_directory_path(
project_fd,
project,
phase="初始化",
)
docs_fd, docs_created = _open_or_create_directory_at(project_fd, "docs")
if docs_created:
os.fsync(project_fd)
_assert_open_directory_path(
project_fd,
project,
phase="初始化",
)
_assert_open_directory_path(
docs_fd,
project / "docs",
phase="初始化",
label="docs 目录",
)
try:
destination_stat = os.stat(
args.name,
dir_fd=docs_fd,
follow_symlinks=False,
)
except FileNotFoundError:
pass
else:
if stat.S_ISLNK(destination_stat.st_mode):
raise SystemExit(
"初始化路径必须是普通目录且不能是软链接: "
f"docs/{args.name}"
)
raise SystemExit(f"拒绝覆盖已有路径: docs/{args.name}")
for _ in range(32):
candidate = f".{args.name}-init-{secrets.token_hex(8)}"
try:
os.mkdir(candidate, mode=0o700, dir_fd=docs_fd)
except FileExistsError:
continue
transaction_name = candidate
break
if transaction_name is None:
raise SystemExit("无法创建唯一的初始化暂存目录")
transaction_fd = os.open(
transaction_name,
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
dir_fd=docs_fd,
)
os.mkdir("payload", mode=0o755, dir_fd=transaction_fd)
staging_fd = os.open(
"payload",
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
dir_fd=transaction_fd,
)
for target in staged_files:
file_fd = os.open(
target.name,
os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
0o644,
dir_fd=staging_fd,
)
staged_names.append(target.name)
with os.fdopen(file_fd, "w", encoding="utf-8") as destination_file:
destination_file.write(rendered_files[target])
destination_file.flush()
os.fsync(destination_file.fileno())
os.fsync(staging_fd)
os.fsync(transaction_fd)
_assert_open_directory_path(
project_fd,
project,
phase="发布",
)
_assert_open_directory_path(
docs_fd,
project / "docs",
phase="发布",
label="docs 目录",
)
try:
_rename_directory_noreplace(
transaction_fd,
"payload",
docs_fd,
args.name,
)
except FileExistsError as exc:
raise SystemExit(
f"拒绝覆盖已有路径: docs/{args.name}"
) from exc
published = True
_assert_open_directory_path(
staging_fd,
destination,
phase="发布",
label="ACK 目录",
)
if (
transaction_name is not None
and _directory_entry_matches_open_fd(
docs_fd,
transaction_name,
transaction_fd,
)
):
try:
os.rmdir(transaction_name, dir_fd=docs_fd)
except OSError:
pass
else:
transaction_name = None
try:
os.fsync(docs_fd)
except OSError as exc:
raise SystemExit(
"初始化目录已完整发布,但无法确认目录项持久化;"
f"请检查 docs/{args.name} 后再重试"
) from exc
_assert_open_directory_path(
project_fd,
project,
phase="完成初始化",
)
_assert_open_directory_path(
docs_fd,
project / "docs",
phase="完成初始化",
label="docs 目录",
)
_assert_open_directory_path(
staging_fd,
destination,
phase="完成初始化",
label="ACK 目录",
)
committed = True
except BaseException:
if not published:
for name in reversed(staged_names):
try:
if staging_fd is not None:
os.unlink(name, dir_fd=staging_fd)
except FileNotFoundError:
pass
if transaction_fd is not None:
try:
os.rmdir("payload", dir_fd=transaction_fd)
except OSError:
pass
if (
transaction_name is not None
and transaction_fd is not None
and docs_fd is not None
and _directory_entry_matches_open_fd(
docs_fd,
transaction_name,
transaction_fd,
)
):
try:
os.rmdir(transaction_name, dir_fd=docs_fd)
except OSError:
pass
raise
finally:
for directory_fd in (
staging_fd,
transaction_fd,
docs_fd,
project_fd,
):
if directory_fd is not None:
os.close(directory_fd)
if not committed:
raise SystemExit("初始化事务未提交")
_print(f"✓ skill 项目状态初始化完成: {args.name}")
_print(f" 项目: {project}")
_print(f" 覆盖层: {project_file}")
_print(f" 任务板: {tasks_file}")
if args.name == "ack":
_print(f" 知识库: {knowledge_file}")
_print(f" 交付契约: {delivery_file}(默认关闭)")
_print("下一步: 填写 project.md 中的项目命令、路径权限和 Base URL")
def _add_common_flags(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"-a",
"--agent",
dest="agents",
nargs="+",
action="append",
metavar="AGENT",
help="目标 agentcursor、claude、claude-code、codex、opencode、*",
)
parser.add_argument(
"-g",
"--global",
dest="global_scope",
action="store_true",
help="全局安装/卸载(默认当前项目)",
)
parser.add_argument("-y", "--yes", action="store_true", help="跳过确认(兼容 Vercel skills")
parser.add_argument("--project", help="项目根目录(默认自动检测或当前目录)")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="skiff",
description="自研 Agent Skills 安装与管理 CLI(接口对齐 Vercel skills",
)
parser.add_argument("--version", action="version", version=f"skiff {__version__}")
sub = parser.add_subparsers(dest="command", required=True)
p_bootstrap = sub.add_parser(
"bootstrap",
help="将本项目 skiff skill 全局安装到所有 agent",
)
p_bootstrap.set_defaults(func=cmd_bootstrap)
p_update = sub.add_parser("update", help="通过 git pull 更新 skiff 自身")
p_update.set_defaults(func=cmd_update)
p_list = sub.add_parser("list", help="列出所有 source 中的 skill")
p_list.add_argument("--source", help="只列出指定来源(builtin、catalog 或 custom source")
p_list.set_defaults(func=cmd_list)
p_status = sub.add_parser("status", help="安装状态总览")
p_status.add_argument("-a", "--agent", dest="agents", nargs="+", action="append", metavar="AGENT")
p_status.set_defaults(func=cmd_status)
p_add = sub.add_parser(
"add",
help="安装 builtin、catalog 或 custom source 中的 skill",
description="安装 builtin、catalog 或 custom source 中的 skill",
)
p_add.add_argument("skills", nargs="*", metavar="skill", help="skill 名称(可多个)")
p_add.add_argument("-s", "--skill", dest="skills_flag", action="append", metavar="SKILL")
p_add.add_argument("--list", dest="list_available", action="store_true", help="列出可用 builtin skill,不安装")
p_add.add_argument("--all", action="store_true", help="安装指定来源的全部 skill 到全部 agent")
p_add.add_argument("--source", help="指定 skill 来源(也可使用 source/name")
_add_common_flags(p_add)
p_add.set_defaults(func=cmd_add)
p_select = sub.add_parser(
"select",
help="交互式选择并批量安装 skill",
)
_add_common_flags(p_select)
p_select.set_defaults(func=cmd_select)
p_remove = sub.add_parser(
"remove",
aliases=["rm", "r"],
help="从 agent 移除 skill",
)
p_remove.add_argument("skills", nargs="*", metavar="skill", help="skill 名称(可多个)")
p_remove.add_argument("-s", "--skill", dest="skills_flag", action="append", metavar="SKILL")
p_remove.add_argument("--all", action="store_true", help="移除当前范围内全部 skill")
_add_common_flags(p_remove)
p_remove.set_defaults(func=cmd_remove)
p_publish = sub.add_parser(
"publish",
help="在 ~/.skills 内 git add / commit / push",
)
p_publish.add_argument("paths", nargs="*", help="要提交的路径(默认 .")
p_publish.add_argument("-m", "--message", help="commit 说明")
p_publish.add_argument("--push", action="store_true", help="commit 后 push")
p_publish.add_argument("--no-commit", action="store_true", help="只 git add,不 commit")
p_publish.set_defaults(func=cmd_publish)
p_catalog = sub.add_parser("catalog", help="管理 catalog.yaml 中的预置来源")
catalog_sub = p_catalog.add_subparsers(dest="catalog_command", required=True)
p_catalog_add = catalog_sub.add_parser("add", help="添加预置 Git source")
p_catalog_add.add_argument("name", help="catalog source 名称")
p_catalog_add.add_argument("repo", help="Git 仓库 URL")
p_catalog_add.add_argument("--ref", default="main", help="分支或 tag(默认 main")
p_catalog_add.add_argument("--path", default=".", help="仓库内子路径(默认 .")
p_catalog_add.set_defaults(func=cmd_catalog_add)
p_fetch = sub.add_parser("fetch", help="拉取/更新 catalog source")
p_fetch.add_argument("name", help="catalog 名称")
p_fetch.set_defaults(func=cmd_fetch)
p_source = sub.add_parser("source", help="管理自定义 Skill source")
source_sub = p_source.add_subparsers(dest="source_command", required=True)
p_source_add = source_sub.add_parser("add", help="注册 Git 或本地 skill source")
p_source_add.add_argument("name", help="source 名称")
p_source_add.add_argument("repo", nargs="?", help="Git 仓库 URL")
p_source_add.add_argument("--local", help="已有本地仓库路径")
p_source_add.add_argument("--ref", default="main", help="Git 分支或 tag(默认 main")
p_source_add.add_argument("--checkout", help="Git checkout 路径")
p_source_add.add_argument("--skills-path", default="skills", help="仓库内 skills 父目录")
p_source_add.add_argument("--no-fetch", action="store_true", help="仅写配置,不立即 clone")
p_source_add.set_defaults(func=cmd_source_add)
p_source_list = source_sub.add_parser("list", help="列出已配置 source")
p_source_list.set_defaults(func=cmd_source_list)
p_source_show = source_sub.add_parser("show", help="显示 source 详情")
p_source_show.add_argument("name")
p_source_show.set_defaults(func=cmd_source_show)
p_source_fetch = source_sub.add_parser("fetch", help="克隆或更新 source")
p_source_fetch.add_argument("name", nargs="?")
p_source_fetch.add_argument("--all", action="store_true")
p_source_fetch.set_defaults(func=cmd_source_fetch)
p_source_remove = source_sub.add_parser("remove", help="移除 source 配置")
p_source_remove.add_argument("name")
p_source_remove.add_argument(
"--delete-checkout",
action="store_true",
help="同时永久删除 skiff 管理的 checkout",
)
p_source_remove.set_defaults(func=cmd_source_remove)
p_sync = sub.add_parser("sync", help="按 .skills.yaml 重建项目软链")
p_sync.add_argument("-a", "--agent", dest="agents", nargs="+", action="append")
p_sync.add_argument("--project")
p_sync.set_defaults(func=cmd_sync)
p_create = sub.add_parser("create", help="从 _template 创建自研 skill 草稿")
p_create.add_argument("name", help="skill 名称")
p_create.add_argument("--idea", help="创建 skill 的原始想法")
p_create.add_argument("--from-project", help="想法来源项目(仅记录上下文)")
p_create.set_defaults(func=cmd_create)
p_check = sub.add_parser("check", help="校验草稿或正式 skill")
p_check.add_argument("name", help="skill 名称")
p_check.set_defaults(func=cmd_check)
p_finalize = sub.add_parser("finalize", help="校验草稿并转为正式 skill")
p_finalize.add_argument("name", help="skill 名称")
p_finalize.set_defaults(func=cmd_finalize)
p_init = sub.add_parser("init", help="使用 skill 模板初始化项目状态")
p_init.add_argument("name", help="skill 名称")
p_init.add_argument("--project", help="项目根目录(默认自动检测或当前目录)")
p_init.set_defaults(func=cmd_init)
p_doctor = sub.add_parser("doctor", help="软链健康检查")
p_doctor.add_argument("-a", "--agent", dest="agents", nargs="+", action="append")
p_doctor.add_argument("--fix", action="store_true", help="自动修复可修复的软链")
p_doctor.set_defaults(func=cmd_doctor)
return parser
def main(argv: list[str] | None = None) -> None:
parser = build_parser()
args = parser.parse_args(argv)
args.func(args)
if __name__ == "__main__":
main()