1374 lines
48 KiB
Python
1374 lines
48 KiB
Python
"""skiff CLI 入口。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import re
|
||
import subprocess
|
||
import sys
|
||
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,
|
||
EXTERNALS_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,
|
||
save_manifest,
|
||
)
|
||
from skiff.registry import (
|
||
discover_external_skills,
|
||
external_checkout_path,
|
||
external_skill_path,
|
||
load_registry,
|
||
registry_repo,
|
||
save_registry,
|
||
)
|
||
from skiff.selector import SkillChoice, select_skills
|
||
from skiff.skills import (
|
||
list_custom_skills,
|
||
list_owned_skills,
|
||
owned_skill_path,
|
||
read_skill_meta,
|
||
resolve_skill_source,
|
||
split_skill_spec,
|
||
skill_description,
|
||
validate_skill_dir,
|
||
validate_skill_name,
|
||
)
|
||
from skiff.sources import (
|
||
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
|
||
|
||
|
||
def _print(msg: str = "") -> None:
|
||
print(msg, file=sys.stdout)
|
||
|
||
|
||
def _err(msg: str) -> None:
|
||
print(msg, file=sys.stderr)
|
||
|
||
|
||
def _warn_deprecated(old: str, new: str) -> None:
|
||
_err(f"警告: `{old}` 已弃用,请改用 `{new}`")
|
||
|
||
|
||
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]) -> None:
|
||
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")
|
||
|
||
|
||
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_owned_skills()
|
||
return names
|
||
|
||
|
||
def _ensure_source_fetched(name: str, source: str | None = None) -> None:
|
||
name, source = split_skill_spec(name, source)
|
||
registry = load_registry()
|
||
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
|
||
|
||
registry_name = (
|
||
source.split(":", 1)[1]
|
||
if source and source.startswith("registry:")
|
||
else source
|
||
if source in registry
|
||
else name
|
||
if name in registry
|
||
else None
|
||
)
|
||
if registry_name and (
|
||
source in (None, "registry", registry_name)
|
||
or source == f"registry:{registry_name}"
|
||
):
|
||
_ensure_registry_fetched(registry_name, registry[registry_name])
|
||
return
|
||
|
||
if source not in (None, "registry"):
|
||
return
|
||
|
||
if name not in registry:
|
||
return
|
||
|
||
_ensure_registry_fetched(name, registry[name])
|
||
|
||
|
||
def _ensure_registry_fetched(name: str, entry: dict[str, object]) -> None:
|
||
path = external_skill_path(name, entry)
|
||
if (path / "SKILL.md").is_file() or discover_external_skills(name, entry):
|
||
return
|
||
|
||
repo = registry_repo(entry)
|
||
ref = entry.get("ref", "main")
|
||
dest = external_checkout_path(name, entry)
|
||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||
if dest.exists():
|
||
raise SystemExit(
|
||
f"外部仓库已存在但 skill 路径无效: {external_skill_path(name, entry)}"
|
||
)
|
||
_print(f"拉取外部 skill: {name}")
|
||
subprocess.run(
|
||
["git", "clone", "--depth", "1", "--branch", ref, "--", repo, str(dest)],
|
||
check=True,
|
||
)
|
||
if not discover_external_skills(name, entry):
|
||
raise SystemExit(
|
||
f"registry 条目 {name!r} 的 path 中没有可安装的 SKILL.md: {path}"
|
||
)
|
||
|
||
|
||
def _registry_skill_names(
|
||
name: str,
|
||
entry: dict[str, object] | None = None,
|
||
) -> list[str]:
|
||
entry = entry or load_registry().get(name)
|
||
if not entry:
|
||
raise SystemExit(f"registry 中不存在: {name}")
|
||
_ensure_registry_fetched(name, entry)
|
||
names = list(discover_external_skills(name, entry))
|
||
for skill_name in names:
|
||
validate_skill_name(skill_name)
|
||
if not names:
|
||
raise SystemExit(f"registry 条目 {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)
|
||
registry = load_registry()
|
||
if source in load_sources():
|
||
return [(name, source)]
|
||
if source == "registry" and name in registry:
|
||
available = _registry_skill_names(name, registry[name])
|
||
root = external_skill_path(name, registry[name])
|
||
if (root / "SKILL.md").is_file():
|
||
return [(name, "registry")]
|
||
return [(skill_name, f"registry:{name}") for skill_name in available]
|
||
if source in registry:
|
||
available = _registry_skill_names(source, registry[source])
|
||
if name not in available:
|
||
raise SystemExit(
|
||
f"registry collection {source!r} 中找不到 skill {name!r}"
|
||
)
|
||
return [(name, f"registry:{source}")]
|
||
if source is None and name in registry:
|
||
available = _registry_skill_names(name, registry[name])
|
||
root = external_skill_path(name, registry[name])
|
||
if (root / "SKILL.md").is_file():
|
||
return [(name, "registry")]
|
||
return [(skill_name, f"registry:{name}") for skill_name in available]
|
||
return [(name, source)]
|
||
|
||
|
||
def _manifest_source_details(resolved_source: str) -> tuple[str, dict[str, object]]:
|
||
if resolved_source.startswith("registry:"):
|
||
return "registry", {"registry": resolved_source.split(":", 1)[1]}
|
||
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()
|
||
owned = list_owned_skills()
|
||
registry = load_registry()
|
||
custom = (
|
||
{}
|
||
if args.source in ("owned", "registry")
|
||
else list_custom_skills(args.source)
|
||
)
|
||
|
||
if args.source in (None, "owned"):
|
||
_print("自研 (owned):")
|
||
for name in owned:
|
||
_print(f" {name}")
|
||
|
||
if args.source in (None, "registry"):
|
||
_print("\n外部 (registry):")
|
||
if not registry:
|
||
_print(" (无)")
|
||
else:
|
||
for name, entry in registry.items():
|
||
repo = entry.get("repo", "?")
|
||
_print(f" {name} ({repo})")
|
||
|
||
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"
|
||
owned_skill_path(project_skill)
|
||
_install_skill(project_skill, list(ALL_TARGETS), project_root=None)
|
||
_print("已安装项目 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))
|
||
owned = list_owned_skills()
|
||
registry = load_registry()
|
||
custom = list_custom_skills()
|
||
entries = [("owned", name) for name in owned]
|
||
unfetched_registry: list[str] = []
|
||
for package, entry in registry.items():
|
||
discovered = discover_external_skills(package, entry)
|
||
if not discovered:
|
||
unfetched_registry.append(package)
|
||
elif (external_skill_path(package, entry) / "SKILL.md").is_file():
|
||
entries.append(("registry", package))
|
||
else:
|
||
entries.extend(
|
||
(f"registry:{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_registry:
|
||
_print(f"[registry] {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()
|
||
owned = list_owned_skills()
|
||
if not owned:
|
||
_print("~/.skills/skills/ 中没有自研 skill")
|
||
return
|
||
|
||
_print(f"来源: {SKILLS_DIR}\n")
|
||
for name in owned:
|
||
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:
|
||
if args.source == "owned":
|
||
names = list_owned_skills()
|
||
elif args.source == "registry":
|
||
names = list(load_registry())
|
||
else:
|
||
_ensure_source_fetched("", args.source)
|
||
names = list_custom_skills(args.source)[args.source]
|
||
else:
|
||
names = list_owned_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)
|
||
registry = load_registry()
|
||
owned_names = list_owned_skills()
|
||
for name in [*owned_names, *registry]:
|
||
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="owned",
|
||
description=skill_description(name) or "",
|
||
)
|
||
for name in owned_names
|
||
]
|
||
choice_requests: dict[str, tuple[str, str]] = {
|
||
name: (name, "owned") for name in owned_names
|
||
}
|
||
for package, entry in registry.items():
|
||
skill_names = _registry_skill_names(package, entry)
|
||
root = external_skill_path(package, entry)
|
||
if (root / "SKILL.md").is_file():
|
||
if package in choice_requests:
|
||
_err(f"警告: registry 条目与 owned skill 同名,已忽略 external: {package}")
|
||
continue
|
||
choices.append(
|
||
make_choice(
|
||
name=package,
|
||
installed_name=package,
|
||
expected=root,
|
||
kind="external",
|
||
description=str(entry.get("description", "")),
|
||
)
|
||
)
|
||
choice_requests[package] = (package, "registry")
|
||
continue
|
||
for skill_name in skill_names:
|
||
expected = root / skill_name
|
||
if (
|
||
skill_name in owned_names
|
||
and expected.resolve() == (SKILLS_DIR / skill_name).resolve()
|
||
):
|
||
continue
|
||
choice_name = f"{package}/{skill_name}"
|
||
description = read_skill_meta(expected).get("description", "")
|
||
choices.append(
|
||
make_choice(
|
||
name=choice_name,
|
||
installed_name=skill_name,
|
||
expected=expected,
|
||
kind=f"external:{package}",
|
||
description=description,
|
||
indent=1,
|
||
)
|
||
)
|
||
choice_requests[choice_name] = (skill_name, f"registry:{package}")
|
||
child_names = tuple(
|
||
f"{package}/{skill_name}"
|
||
for skill_name in skill_names
|
||
if f"{package}/{skill_name}" in choice_requests
|
||
)
|
||
if child_names:
|
||
first_child = len(choices) - len(child_names)
|
||
choices.insert(
|
||
first_child,
|
||
SkillChoice(
|
||
name=package,
|
||
kind="repository",
|
||
description=str(entry.get("description", "")),
|
||
children=child_names,
|
||
),
|
||
)
|
||
|
||
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]
|
||
if source == "owned":
|
||
add_skill_to_manifest(
|
||
manifest_path,
|
||
skill_name,
|
||
source="owned",
|
||
extra={"targets": entry_targets} if entry_targets else None,
|
||
)
|
||
else:
|
||
package = (
|
||
skill_name
|
||
if source == "registry"
|
||
else source.split(":", 1)[1]
|
||
)
|
||
entry = registry[package]
|
||
extra: dict[str, object] = {"ref": entry.get("ref", "main")}
|
||
if source != "registry":
|
||
extra["registry"] = package
|
||
if entry_targets:
|
||
extra["targets"] = entry_targets
|
||
add_skill_to_manifest(
|
||
manifest_path,
|
||
skill_name,
|
||
source="registry",
|
||
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")
|
||
|
||
registry = load_registry()
|
||
expanded: list[str] = []
|
||
for spec in names:
|
||
name, source = split_skill_spec(spec)
|
||
if source in registry and source not in load_sources():
|
||
if name not in discover_external_skills(source, registry[source]):
|
||
raise SystemExit(
|
||
f"registry collection {source!r} 中找不到 skill {name!r}"
|
||
)
|
||
expanded.append(name)
|
||
elif source is None and name in registry:
|
||
discovered = discover_external_skills(name, registry[name])
|
||
root = external_skill_path(name, registry[name])
|
||
expanded.extend(
|
||
[name]
|
||
if (root / "SKILL.md").is_file()
|
||
else list(discovered)
|
||
)
|
||
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_registry_add(args: argparse.Namespace) -> None:
|
||
ensure_skills_home()
|
||
validate_skill_name(args.name)
|
||
registry = load_registry()
|
||
if args.name in registry:
|
||
raise SystemExit(f"registry 中已存在: {args.name}")
|
||
|
||
registry[args.name] = {
|
||
"repo": args.repo,
|
||
"ref": args.ref,
|
||
"path": args.path,
|
||
}
|
||
save_registry(registry)
|
||
_print(f"已添加 registry 条目: {args.name}")
|
||
|
||
|
||
def cmd_fetch(args: argparse.Namespace) -> None:
|
||
ensure_skills_home()
|
||
registry = load_registry()
|
||
if args.name not in registry:
|
||
raise SystemExit(f"registry 中不存在: {args.name}")
|
||
|
||
entry = registry[args.name]
|
||
repo = registry_repo(entry)
|
||
ref = entry.get("ref", "main")
|
||
dest = external_checkout_path(args.name, entry)
|
||
|
||
EXTERNALS_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_enable(args: argparse.Namespace) -> None:
|
||
_warn_deprecated("skiff enable", "skiff add <name>")
|
||
ensure_skills_home()
|
||
validate_skill_name(args.name)
|
||
root = _project_root(args.project)
|
||
manifest_path = root / ".skills.yaml"
|
||
|
||
name, source = split_skill_spec(args.name)
|
||
_ensure_source_fetched(name, source)
|
||
_, resolved_source = resolve_skill_source(name, source=source)
|
||
add_skill_to_manifest(manifest_path, name, source=resolved_source)
|
||
|
||
targets = resolve_agent_args(flatten_agent_args(args.agents))
|
||
_, data = load_manifest(manifest_path)
|
||
manifest_targets = data.get("targets")
|
||
if manifest_targets:
|
||
targets = [t for t in targets if t in manifest_targets]
|
||
|
||
_install_skill(name, targets, project_root=root, source=resolved_source)
|
||
_print(f"已启用项目 skill: {resolved_source}/{name} @ {root}")
|
||
|
||
|
||
def cmd_disable(args: argparse.Namespace) -> None:
|
||
_warn_deprecated("skiff disable", "skiff remove <name>")
|
||
root = _project_root(args.project)
|
||
manifest_path = root / ".skills.yaml"
|
||
if not remove_skill_from_manifest(manifest_path, args.name):
|
||
_print(f"manifest 中不存在: {args.name}")
|
||
return
|
||
|
||
targets = resolve_agent_args(flatten_agent_args(args.agents))
|
||
_remove_skill(args.name, targets, project_root=root)
|
||
|
||
|
||
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")
|
||
if source == "registry" and entry.get("registry"):
|
||
source = f"registry:{entry['registry']}"
|
||
_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_owned_path(name: str) -> tuple[Path, str]:
|
||
draft = DRAFTS_DIR / name
|
||
if draft.is_dir():
|
||
return draft, "草稿"
|
||
owned = SKILLS_DIR / name
|
||
if owned.is_dir():
|
||
return owned, "正式 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_owned_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_owned_skills():
|
||
for target, link, expected in _installed_links(name, targets, source="owned"):
|
||
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}")
|
||
|
||
registry = load_registry()
|
||
for name in registry:
|
||
ext = external_skill_path(name, registry[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()
|
||
skill_source = SKILLS_DIR / args.name
|
||
if not (skill_source / "SKILL.md").is_file():
|
||
raise SystemExit(f"builtin skill 不存在: {args.name}")
|
||
|
||
project = _project_root(args.project)
|
||
if not project.is_dir():
|
||
raise SystemExit(f"项目目录不存在: {project}")
|
||
destination = project / "docs" / args.name
|
||
project_file = destination / "project.md"
|
||
tasks_file = destination / "tasks.yaml"
|
||
managed_targets = (project_file, tasks_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"
|
||
missing = [path for path in (project_template, tasks_template) if not path.is_file()]
|
||
if missing:
|
||
paths = ", ".join(str(path.relative_to(SKILLS_HOME)) for path in missing)
|
||
raise SystemExit(f"skill 缺少初始化模板: {paths}")
|
||
|
||
destination.mkdir(parents=True, exist_ok=True)
|
||
|
||
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,
|
||
}
|
||
_render_template(project_template, project_file, values)
|
||
_render_template(tasks_template, tasks_file, values)
|
||
|
||
validator = skill_source / "scripts" / "validate_tasks.py"
|
||
if validator.is_file():
|
||
subprocess.run([sys.executable, str(validator), str(tasks_file)], check=True)
|
||
|
||
_print(f"✓ skill 项目状态初始化完成: {args.name}")
|
||
_print(f" 项目: {project}")
|
||
_print(f" 覆盖层: {project_file}")
|
||
_print(f" 任务板: {tasks_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="目标 agent(cursor、claude、claude-code、codex、*)",
|
||
)
|
||
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="只列出指定来源(owned、registry 或 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="安装 skill 到 agent(自研或 registry)",
|
||
description="安装 ~/.skills 中的自研 skill,或 registry 中的外部 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="列出可用自研 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_registry = sub.add_parser("registry", help="管理 registry.yaml 中的外部 skill")
|
||
registry_sub = p_registry.add_subparsers(dest="registry_command", required=True)
|
||
p_reg_add = registry_sub.add_parser("add", help="注册外部 Git skill")
|
||
p_reg_add.add_argument("name", help="registry 名称")
|
||
p_reg_add.add_argument("repo", help="Git 仓库 URL")
|
||
p_reg_add.add_argument("--ref", default="main", help="分支或 tag(默认 main)")
|
||
p_reg_add.add_argument("--path", default=".", help="仓库内子路径(默认 .)")
|
||
p_reg_add.set_defaults(func=cmd_registry_add)
|
||
|
||
p_fetch = sub.add_parser("fetch", help="拉取/更新 registry 中的外部 skill")
|
||
p_fetch.add_argument("name", help="registry 名称")
|
||
p_fetch.set_defaults(func=cmd_fetch)
|
||
|
||
p_source = sub.add_parser("source", help="管理包含多个 skills 的自定义仓库")
|
||
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_enable = sub.add_parser("enable", help=argparse.SUPPRESS)
|
||
p_enable.add_argument("name")
|
||
p_enable.add_argument("-a", "--agent", dest="agents", nargs="+", action="append")
|
||
p_enable.add_argument("--project")
|
||
p_enable.set_defaults(func=cmd_enable)
|
||
|
||
p_disable = sub.add_parser("disable", help=argparse.SUPPRESS)
|
||
p_disable.add_argument("name")
|
||
p_disable.add_argument("-a", "--agent", dest="agents", nargs="+", action="append")
|
||
p_disable.add_argument("--project")
|
||
p_disable.set_defaults(func=cmd_disable)
|
||
|
||
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()
|