feat: add self-update and improve skill selector
This commit is contained in:
@@ -1,11 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from skiff import cli
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -76,6 +80,19 @@ class CreateWorkflowTests(unittest.TestCase):
|
||||
self.assertIn("claude", first.stdout)
|
||||
self.assertIn("codex", first.stdout)
|
||||
|
||||
def test_update_pulls_skills_home(self) -> None:
|
||||
with (
|
||||
patch.object(cli, "SKILLS_HOME", self.skills_home),
|
||||
patch.object(cli, "ensure_skills_home"),
|
||||
patch.object(cli.subprocess, "run") as run,
|
||||
):
|
||||
cli.cmd_update(argparse.Namespace())
|
||||
|
||||
run.assert_called_once_with(
|
||||
["git", "-C", str(self.skills_home), "pull"],
|
||||
check=True,
|
||||
)
|
||||
|
||||
def test_create_writes_draft_and_brief_without_registering_owned_skill(self) -> None:
|
||||
project = self.home / "project"
|
||||
project.mkdir()
|
||||
|
||||
@@ -7,6 +7,9 @@ import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from skiff.registry import external_checkout_path, registry_repo
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -79,6 +82,17 @@ class RegistryCollectionTests(unittest.TestCase):
|
||||
check=False,
|
||||
)
|
||||
|
||||
def test_registry_repo_expands_home_relative_local_path(self) -> None:
|
||||
with patch.dict(os.environ, {"HOME": str(self.home)}):
|
||||
self.assertEqual(
|
||||
registry_repo({"repo": "~/.skills"}),
|
||||
str(self.skills_home),
|
||||
)
|
||||
self.assertEqual(
|
||||
external_checkout_path("skills", {"repo": "~/.skills"}),
|
||||
self.skills_home.resolve(),
|
||||
)
|
||||
|
||||
def test_add_collection_installs_every_discovered_skill(self) -> None:
|
||||
result = self.run_skiff("add", "test-pack", "-g", "-a", "codex")
|
||||
|
||||
|
||||
+177
-4
@@ -13,13 +13,17 @@ from unittest.mock import Mock, patch
|
||||
from skiff import cli
|
||||
from skiff import yaml_io
|
||||
from skiff.registry import external_repo_path, external_skill_path
|
||||
from skiff.selector import SkillChoice, filter_choices, select_skills
|
||||
from skiff.selector import SkillChoice, filter_choices, fit_to_width, select_skills
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class SelectorTests(unittest.TestCase):
|
||||
def test_fit_to_width_counts_cjk_as_two_columns(self) -> None:
|
||||
self.assertEqual(fit_to_width("a中文b", 4), "a中")
|
||||
self.assertEqual(fit_to_width("初始化 ACK", 8), "初始化 A")
|
||||
|
||||
def test_manifest_entry_targets_round_trip(self) -> None:
|
||||
data = {
|
||||
"skills": [
|
||||
@@ -36,12 +40,18 @@ class SelectorTests(unittest.TestCase):
|
||||
def test_filter_matches_name_kind_and_description(self) -> None:
|
||||
choices = [
|
||||
SkillChoice("frontend-design", "external", "创建界面"),
|
||||
SkillChoice("discussion-notes", "owned", "维护讨论笔记"),
|
||||
SkillChoice(
|
||||
"discussion-notes",
|
||||
"owned",
|
||||
"维护讨论笔记",
|
||||
readonly_status="全局: codex",
|
||||
),
|
||||
]
|
||||
|
||||
self.assertEqual([c.name for c in filter_choices(choices, "front")], ["frontend-design"])
|
||||
self.assertEqual([c.name for c in filter_choices(choices, "owned")], ["discussion-notes"])
|
||||
self.assertEqual([c.name for c in filter_choices(choices, "界面")], ["frontend-design"])
|
||||
self.assertEqual([c.name for c in filter_choices(choices, "codex")], ["discussion-notes"])
|
||||
|
||||
def test_selector_preserves_preselected_items(self) -> None:
|
||||
choices = [
|
||||
@@ -59,6 +69,71 @@ class SelectorTests(unittest.TestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(selected, {"already-there"})
|
||||
rendered = [call.args[2] for call in screen.addnstr.call_args_list]
|
||||
self.assertTrue(any("取消选择不卸载" in line for line in rendered))
|
||||
|
||||
def test_selector_renders_scope_and_readonly_global_status(self) -> None:
|
||||
choices = [
|
||||
SkillChoice(
|
||||
"ack",
|
||||
"owned",
|
||||
installed=False,
|
||||
readonly_status="全局: cursor,claude,codex",
|
||||
)
|
||||
]
|
||||
screen = Mock()
|
||||
screen.getmaxyx.return_value = (24, 120)
|
||||
screen.get_wch.return_value = "\n"
|
||||
|
||||
with patch.object(curses, "curs_set"):
|
||||
selected = select_skills(
|
||||
choices,
|
||||
scope_label="项目 /code/app(全局状态只读)",
|
||||
wrapper=lambda draw: draw(screen),
|
||||
)
|
||||
|
||||
rendered = [call.args[2] for call in screen.addnstr.call_args_list]
|
||||
self.assertEqual(selected, set())
|
||||
self.assertTrue(any("安装目标: 项目 /code/app" in line for line in rendered))
|
||||
title = "".join(
|
||||
call.args[2]
|
||||
for call in screen.addnstr.call_args_list
|
||||
if call.args[0] == 3
|
||||
)
|
||||
self.assertIn("[ ] ack", title)
|
||||
self.assertIn("全局: cursor,claude,codex", title)
|
||||
self.assertNotIn("初始化", title)
|
||||
|
||||
def test_selector_renders_description_on_indented_second_line(self) -> None:
|
||||
choices = [
|
||||
SkillChoice(
|
||||
"ack",
|
||||
"owned",
|
||||
description="初始化、检查并运行 ACK",
|
||||
readonly_status="全局: codex",
|
||||
)
|
||||
]
|
||||
screen = Mock()
|
||||
screen.getmaxyx.return_value = (24, 100)
|
||||
screen.get_wch.return_value = "\n"
|
||||
|
||||
with patch.object(curses, "curs_set"):
|
||||
select_skills(choices, wrapper=lambda draw: draw(screen))
|
||||
|
||||
title_calls = [
|
||||
call for call in screen.addnstr.call_args_list if call.args[0] == 3
|
||||
]
|
||||
description_call = next(
|
||||
call for call in screen.addnstr.call_args_list if call.args[0] == 4
|
||||
)
|
||||
self.assertEqual(
|
||||
"".join(call.args[2] for call in title_calls),
|
||||
"[ ] ack owned 全局: codex",
|
||||
)
|
||||
self.assertEqual(description_call.args[2], " 初始化、检查并运行 ACK")
|
||||
name_call = next(call for call in title_calls if call.args[2] == "ack")
|
||||
self.assertTrue(name_call.args[4] & curses.A_BOLD)
|
||||
self.assertTrue(description_call.args[4] & curses.A_DIM)
|
||||
|
||||
def test_shared_repo_path_is_same_for_different_skill_entries(self) -> None:
|
||||
first = {"repo": "https://example.test/skills.git", "ref": "main", "path": "a"}
|
||||
@@ -136,6 +211,17 @@ class SelectCommandTests(unittest.TestCase):
|
||||
stdin.isatty.return_value = True
|
||||
stdout.isatty.return_value = True
|
||||
installed: list[str] = []
|
||||
selected_choices: list[SkillChoice] = []
|
||||
selected_scope: list[str] = []
|
||||
|
||||
def choose(
|
||||
choices: list[SkillChoice],
|
||||
*,
|
||||
scope_label: str,
|
||||
) -> set[str]:
|
||||
selected_choices.extend(choices)
|
||||
selected_scope.append(scope_label)
|
||||
return {"owned-one", "external-one"}
|
||||
|
||||
with (
|
||||
patch.object(cli.sys, "stdin", stdin),
|
||||
@@ -157,6 +243,11 @@ class SelectCommandTests(unittest.TestCase):
|
||||
patch.object(cli, "_registry_skill_names", return_value=["external-one"]),
|
||||
patch.object(cli, "external_skill_path", return_value=external),
|
||||
patch.object(cli, "_list_fully_installed_names", return_value=["owned-one"]),
|
||||
patch.object(
|
||||
cli,
|
||||
"_global_installation_note",
|
||||
return_value="全局: codex",
|
||||
),
|
||||
patch.object(
|
||||
cli,
|
||||
"_is_fully_installed",
|
||||
@@ -165,7 +256,7 @@ class SelectCommandTests(unittest.TestCase):
|
||||
patch.object(
|
||||
cli,
|
||||
"select_skills",
|
||||
return_value={"owned-one", "external-one"},
|
||||
side_effect=choose,
|
||||
),
|
||||
patch.object(
|
||||
cli,
|
||||
@@ -183,6 +274,40 @@ class SelectCommandTests(unittest.TestCase):
|
||||
self.assertIn("source: registry", manifest)
|
||||
self.assertIn("targets:", manifest)
|
||||
self.assertIn("codex", manifest)
|
||||
self.assertEqual(
|
||||
[choice.readonly_status for choice in selected_choices],
|
||||
["全局: codex", "全局: codex"],
|
||||
)
|
||||
self.assertIn("全局状态只读", selected_scope[0])
|
||||
|
||||
def test_global_installation_note_reports_agents_and_source_conflicts(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
expected = root / "expected"
|
||||
other = root / "other"
|
||||
expected.mkdir()
|
||||
other.mkdir()
|
||||
global_dirs = {
|
||||
target: root / target / "skills"
|
||||
for target in ("cursor", "claude", "codex")
|
||||
}
|
||||
for directory in global_dirs.values():
|
||||
directory.mkdir(parents=True)
|
||||
(global_dirs["cursor"] / "ack").symlink_to(expected, target_is_directory=True)
|
||||
(global_dirs["claude"] / "ack").symlink_to(other, target_is_directory=True)
|
||||
|
||||
with patch.object(
|
||||
cli,
|
||||
"agent_skill_dir",
|
||||
side_effect=lambda target, project_root=None: global_dirs[target],
|
||||
):
|
||||
note = cli._global_installation_note(
|
||||
"ack",
|
||||
expected,
|
||||
["cursor", "claude", "codex"],
|
||||
)
|
||||
|
||||
self.assertEqual(note, "全局: cursor;全局同名冲突: claude")
|
||||
|
||||
def test_select_rejects_invalid_registry_name_before_rendering(self) -> None:
|
||||
args = argparse.Namespace(
|
||||
@@ -227,7 +352,7 @@ class SelectCommandTests(unittest.TestCase):
|
||||
selected_choices: list[SkillChoice] = []
|
||||
installed: list[tuple[str, str | None]] = []
|
||||
|
||||
def choose(choices: list[SkillChoice]) -> set[str]:
|
||||
def choose(choices: list[SkillChoice], **_: object) -> set[str]:
|
||||
selected_choices.extend(choices)
|
||||
return {"waza/think"}
|
||||
|
||||
@@ -268,6 +393,54 @@ class SelectCommandTests(unittest.TestCase):
|
||||
self.assertEqual([choice.name for choice in selected_choices], ["waza/think", "waza/ui"])
|
||||
self.assertEqual(installed, [("think", "registry:waza")])
|
||||
|
||||
def test_select_deduplicates_local_registry_collection_from_owned(self) -> None:
|
||||
args = argparse.Namespace(
|
||||
agents=[["codex"]],
|
||||
global_scope=True,
|
||||
project=None,
|
||||
yes=False,
|
||||
)
|
||||
stdin = Mock()
|
||||
stdout = Mock()
|
||||
stdin.isatty.return_value = True
|
||||
stdout.isatty.return_value = True
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
skills_root = Path(temp) / "skills"
|
||||
(skills_root / "ack").mkdir(parents=True)
|
||||
selected_choices: list[SkillChoice] = []
|
||||
|
||||
def choose(choices: list[SkillChoice], **_: object) -> set[str]:
|
||||
selected_choices.extend(choices)
|
||||
return set()
|
||||
|
||||
with (
|
||||
patch.object(cli.sys, "stdin", stdin),
|
||||
patch.object(cli.sys, "stdout", stdout),
|
||||
patch.object(cli, "SKILLS_DIR", skills_root),
|
||||
patch.object(cli, "ensure_skills_home"),
|
||||
patch.object(cli, "list_owned_skills", return_value=["ack"]),
|
||||
patch.object(cli, "skill_description", return_value="owned ack"),
|
||||
patch.object(
|
||||
cli,
|
||||
"load_registry",
|
||||
return_value={
|
||||
"skills": {
|
||||
"repo": "~/.skills",
|
||||
"ref": "main",
|
||||
"path": "skills",
|
||||
}
|
||||
},
|
||||
),
|
||||
patch.object(cli, "_registry_skill_names", return_value=["ack"]),
|
||||
patch.object(cli, "external_skill_path", return_value=skills_root),
|
||||
patch.object(cli, "_is_fully_installed", return_value=False),
|
||||
patch.object(cli, "select_skills", side_effect=choose),
|
||||
):
|
||||
cli.cmd_select(args)
|
||||
|
||||
self.assertEqual([choice.name for choice in selected_choices], ["ack"])
|
||||
|
||||
def test_install_rolls_back_earlier_target_when_later_target_fails(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
|
||||
Reference in New Issue
Block a user