feat: add self-update and improve skill selector

This commit is contained in:
2026-07-29 10:29:48 +08:00
parent afbdde157f
commit 18ebb70911
9 changed files with 431 additions and 47 deletions
+5 -1
View File
@@ -65,6 +65,7 @@ skiff bootstrap
| 命令 | 说明 |
|------|------|
| `skiff bootstrap` | 将本项目的 `skiff` skill 全局安装到所有 Agent |
| `skiff update` | 在 `~/.skills` 执行 `git pull`,更新 skiff 自身 |
| `skiff kit init <name> [--project DIR] [--copy]` | 在项目的 `docs/<name>/` 初始化规范包;默认软链接到 SSOT |
### 全局安装(自研 skill
@@ -113,7 +114,10 @@ skiff select --project ~/code/app # 指定项目
```
使用方向键移动、空格勾选、`/` 搜索、Enter 安装,按 `q` 或 Esc
取消。已经安装到目标范围的 skill 默认勾选;取消勾选不会卸载已有 skill。
取消。普通 `skiff select` 只安装到项目,并在每一项旁只读显示各 Agent 的
全局安装状态;`skiff select -g` 只安装到全局。已经安装到目标范围的 skill
默认勾选;取消勾选不会卸载已有 skill,卸载请使用 `skiff remove`。如果全局
存在同名但指向其它来源的 skill,项目选择器会显示“全局同名冲突”。
项目模式会把成功选择的项目写入 `.skills.yaml`。非交互环境请使用
`skiff add <name>...`。使用 `-a` 限定 Agent 时,该范围会记录在对应的
skill 条目中,后续 `skiff sync` 不会扩散到其他 Agent。
+82 -24
View File
@@ -38,6 +38,7 @@ from skiff.registry import (
external_checkout_path,
external_skill_path,
load_registry,
registry_repo,
save_registry,
)
from skiff.selector import SkillChoice, select_skills
@@ -139,7 +140,7 @@ def _ensure_registry_fetched(name: str, entry: dict[str, object]) -> None:
if (path / "SKILL.md").is_file() or discover_external_skills(name, entry):
return
repo = str(entry["repo"])
repo = registry_repo(entry)
ref = entry.get("ref", "main")
dest = external_checkout_path(name, entry)
dest.parent.mkdir(parents=True, exist_ok=True)
@@ -286,6 +287,29 @@ def _is_fully_installed(
)
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],
@@ -346,6 +370,13 @@ def cmd_bootstrap(args: argparse.Namespace) -> 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],
@@ -525,17 +556,38 @@ def cmd_select(args: argparse.Namespace) -> None:
for name in [*owned_names, *registry]:
validate_skill_name(name)
choices = [
SkillChoice(
def make_choice(
*,
name: str,
installed_name: str,
expected: Path,
kind: str,
description: str,
) -> SkillChoice:
return SkillChoice(
name=name,
kind="owned",
description=skill_description(name) or "",
kind=kind,
description=description,
installed=_is_fully_installed(
name,
SKILLS_DIR / name,
installed_name,
expected,
project_root,
targets,
),
readonly_status=(
_global_installation_note(installed_name, expected, targets)
if project_root is not None
else ""
),
)
choices = [
make_choice(
name=name,
installed_name=name,
expected=SKILLS_DIR / name,
kind="owned",
description=skill_description(name) or "",
)
for name in owned_names
]
@@ -550,40 +602,43 @@ def cmd_select(args: argparse.Namespace) -> None:
_err(f"警告: registry 条目与 owned skill 同名,已忽略 external: {package}")
continue
choices.append(
SkillChoice(
make_choice(
name=package,
installed_name=package,
expected=root,
kind="external",
description=str(entry.get("description", "")),
installed=_is_fully_installed(
package,
root,
project_root,
targets,
),
)
)
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(root / skill_name).get("description", "")
description = read_skill_meta(expected).get("description", "")
choices.append(
SkillChoice(
make_choice(
name=choice_name,
installed_name=skill_name,
expected=expected,
kind=f"external:{package}",
description=description,
installed=_is_fully_installed(
skill_name,
root / skill_name,
project_root,
targets,
),
)
)
choice_requests[choice_name] = (skill_name, f"registry:{package}")
try:
selected = select_skills(choices)
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:
@@ -738,7 +793,7 @@ def cmd_fetch(args: argparse.Namespace) -> None:
raise SystemExit(f"registry 中不存在: {args.name}")
entry = registry[args.name]
repo = entry["repo"]
repo = registry_repo(entry)
ref = entry.get("ref", "main")
dest = external_checkout_path(args.name, entry)
@@ -1149,6 +1204,9 @@ def build_parser() -> argparse.ArgumentParser:
)
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)
+12 -1
View File
@@ -26,6 +26,12 @@ def save_registry(data: dict[str, dict[str, Any]], path: Path | None = None) ->
path.write_text(yaml_io.safe_dump(data, allow_unicode=True, sort_keys=False), encoding="utf-8")
def registry_repo(entry: dict[str, Any]) -> str:
"""Expand a local home-relative repo while leaving remote URLs unchanged."""
repo = str(entry.get("repo", ""))
return str(Path(repo).expanduser()) if repo.startswith("~") else repo
def external_repo_path(entry: dict[str, Any]) -> Path:
"""Return the shared checkout path for a repo/ref pair."""
from skiff.paths import EXTERNALS_DIR
@@ -37,9 +43,14 @@ def external_repo_path(entry: dict[str, Any]) -> Path:
def external_checkout_path(name: str, entry: dict[str, Any]) -> Path:
"""Prefer an existing pre-shared-cache checkout for compatibility."""
"""Use a local repo directly, otherwise return its external checkout."""
from skiff.paths import EXTERNALS_DIR
configured_repo = str(entry.get("repo", ""))
local_repo = Path(registry_repo(entry))
if configured_repo.startswith("~") and local_repo.is_dir():
return local_repo.resolve()
legacy = EXTERNALS_DIR / name
return legacy if legacy.is_dir() else external_repo_path(entry)
+111 -17
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import unicodedata
from dataclasses import dataclass
from typing import Callable, Iterable
@@ -12,6 +13,36 @@ class SkillChoice:
kind: str
description: str = ""
installed: bool = False
readonly_status: str = ""
def fit_to_width(text: str, width: int) -> str:
"""Trim text to terminal display width without splitting wide characters."""
if width <= 0:
return ""
result: list[str] = []
used = 0
for char in text:
if unicodedata.combining(char):
char_width = 0
else:
char_width = 2 if unicodedata.east_asian_width(char) in {"W", "F"} else 1
if used + char_width > width:
break
result.append(char)
used += char_width
return "".join(result)
def display_width(text: str) -> int:
return sum(
0
if unicodedata.combining(char)
else 2
if unicodedata.east_asian_width(char) in {"W", "F"}
else 1
for char in text
)
def filter_choices(choices: Iterable[SkillChoice], query: str) -> list[SkillChoice]:
@@ -24,12 +55,14 @@ def filter_choices(choices: Iterable[SkillChoice], query: str) -> list[SkillChoi
if needle in choice.name.casefold()
or needle in choice.kind.casefold()
or needle in choice.description.casefold()
or needle in choice.readonly_status.casefold()
]
def select_skills(
choices: list[SkillChoice],
*,
scope_label: str = "当前作用域",
wrapper: Callable[..., set[str] | None] | None = None,
) -> set[str] | None:
"""Open the selector. Return names, or None when cancelled."""
@@ -48,29 +81,84 @@ def select_skills(
stdscr.keypad(True)
current = 0
query = ""
source_attr = curses.A_NORMAL
status_attr = curses.A_NORMAL
description_attr = curses.A_DIM
try:
if curses.has_colors():
curses.start_color()
curses.use_default_colors()
curses.init_pair(1, curses.COLOR_CYAN, -1)
curses.init_pair(2, curses.COLOR_GREEN, -1)
source_attr = curses.color_pair(1)
status_attr = curses.color_pair(2)
except curses.error:
pass
while True:
visible = filter_choices(choices, query)
current = min(current, max(0, len(visible) - 1))
stdscr.erase()
height, width = stdscr.getmaxyx()
header = "↑/↓ 移动 Space 勾选 / 搜索 Enter 安装 q 取消"
stdscr.addnstr(0, 0, header, max(0, width - 1))
text_width = max(0, width - 1)
stdscr.addnstr(
0,
0,
fit_to_width(f"安装目标: {scope_label}", text_width),
text_width,
)
header = "↑↓ 移动 Space 选择 / 搜索 Enter 安装 q 退出"
stdscr.addnstr(1, 0, fit_to_width(header, text_width), text_width)
if query:
stdscr.addnstr(1, 0, f"搜索: {query}", max(0, width - 1))
stdscr.addnstr(
2,
0,
fit_to_width(f"搜索: {query}", text_width),
text_width,
)
rows = max(1, height - 4)
start = max(0, current - rows + 1)
for row, choice in enumerate(visible[start : start + rows], start=2):
index = start + row - 2
item_capacity = max(1, (height - 4) // 3)
start = max(0, current - item_capacity + 1)
for offset, choice in enumerate(visible[start : start + item_capacity]):
index = start + offset
row = 3 + offset * 3
mark = "x" if choice.name in selected else " "
suffix = f" · {choice.description}" if choice.description else ""
line = f"[{mark}] {choice.name} {choice.kind}{suffix}"
attr = curses.A_REVERSE if index == current else curses.A_NORMAL
stdscr.addnstr(row, 0, line, max(0, width - 1), attr)
description = f" {choice.description}" if choice.description else ""
focus_attr = curses.A_REVERSE if index == current else curses.A_NORMAL
parts = [
(f"[{mark}] ", focus_attr),
(choice.name, focus_attr | curses.A_BOLD),
(f" {choice.kind}", focus_attr | source_attr),
]
if choice.readonly_status:
parts.append(
(f" {choice.readonly_status}", focus_attr | status_attr)
)
column = 0
for text, attr in parts:
available = text_width - column
if available <= 0:
break
fitted = fit_to_width(text, available)
stdscr.addnstr(row, column, fitted, available, attr)
column += display_width(fitted)
stdscr.addnstr(
row + 1,
0,
fit_to_width(description, text_width),
text_width,
focus_attr | description_attr,
)
footer = f"已选择 {len(selected)}"
stdscr.addnstr(height - 1, 0, footer, max(0, width - 1))
footer = (
f"已选择 {len(selected)} 项 · 取消选择不卸载,卸载用 skiff remove"
)
stdscr.addnstr(
height - 1,
0,
fit_to_width(footer, text_width),
text_width,
)
stdscr.refresh()
key = stdscr.get_wch()
@@ -87,7 +175,7 @@ def select_skills(
selected.symmetric_difference_update({name})
elif key == "/":
curses.curs_set(1)
query = _read_query(stdscr, curses, width)
query = _read_query(stdscr, curses, width, row=2)
curses.curs_set(0)
current = 0
@@ -95,12 +183,18 @@ def select_skills(
return runner(draw)
def _read_query(stdscr: object, curses: object, width: int) -> str:
def _read_query(stdscr: object, curses: object, width: int, *, row: int = 1) -> str:
query = ""
while True:
stdscr.move(1, 0)
stdscr.move(row, 0)
stdscr.clrtoeol()
stdscr.addnstr(1, 0, f"搜索: {query}", max(0, width - 1))
text_width = max(0, width - 1)
stdscr.addnstr(
row,
0,
fit_to_width(f"搜索: {query}", text_width),
text_width,
)
stdscr.refresh()
key = stdscr.get_wch()
if key in ("\n", "\r") or key == curses.KEY_ENTER: