"""skiff CLI 入口。""" from __future__ import annotations import argparse import re import shutil 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, KITS_DIR, 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_kit_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 安装,或 skiff add -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 ...`" ) 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 ") 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 ") 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_kit_init(args: argparse.Namespace) -> None: """将 owned kit 初始化到目标项目。""" ensure_skills_home() kit_source = KITS_DIR / args.name if not kit_source.is_dir(): available = sorted(path.name for path in KITS_DIR.iterdir() if path.is_dir()) if KITS_DIR.is_dir() else [] suffix = f";可用 kit: {', '.join(available)}" if available else "" raise SystemExit(f"kit 不存在: {args.name}{suffix}") project = _project_root(args.project) if not project.is_dir(): raise SystemExit(f"项目目录不存在: {project}") destination = project / "docs" / args.name kit_target = destination / "kit" project_file = destination / "project.md" tasks_file = destination / "tasks.yaml" managed_targets = (kit_target, 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 = kit_source / "templates" / "project.template.md" tasks_template = kit_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"kit 缺少初始化模板: {paths}") destination.mkdir(parents=True, exist_ok=True) if args.copy: shutil.copytree(kit_source, kit_target) mode = "copy" else: kit_target.symlink_to(kit_source.resolve(), target_is_directory=True) mode = "symlink" version_file = kit_source / "VERSION" kit_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, "": str(project), "": str(project), "": f"docs/{args.name}/project.md", "": kit_version, "<接入时的 ack 版本,见 kit 根 VERSION>": kit_version, "": now, } _render_kit_template(project_template, project_file, values) _render_kit_template(tasks_template, tasks_file, values) validator = kit_target / "scripts" / "validate_tasks.py" if validator.is_file(): subprocess.run([sys.executable, str(validator), str(tasks_file)], check=True) _print(f"✓ kit 初始化完成: {args.name}") _print(f" 项目: {project}") _print(f" 模式: {mode}") _print(f" kit: {kit_target}") _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_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) p_kit = sub.add_parser("kit", help="管理项目规范包") kit_sub = p_kit.add_subparsers(dest="kit_command", required=True) p_kit_init = kit_sub.add_parser("init", help="在项目中初始化 kit") p_kit_init.add_argument("name", help="kit 名称") p_kit_init.add_argument("--project", help="项目根目录(默认自动检测或当前目录)") p_kit_init.add_argument( "--copy", action="store_true", help="复制 kit,而不是创建指向 SSOT 的软链接", ) p_kit_init.set_defaults(func=cmd_kit_init) 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()