From 5f90758e223be8be6f1c6462a4345c13ea003c62 Mon Sep 17 00:00:00 2001 From: laily Date: Tue, 28 Jul 2026 16:41:41 +0800 Subject: [PATCH] feat: add kit project initializer --- AGENTS.md | 4 +- README.md | 8 ++- skiff/README.md | 1 + skiff/cli.py | 86 +++++++++++++++++++++++++++++++++ skiff/paths.py | 1 + tests/test_kit_init.py | 107 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 204 insertions(+), 3 deletions(-) create mode 100644 tests/test_kit_init.py diff --git a/AGENTS.md b/AGENTS.md index 9f53082..56e2f0c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,9 +78,9 @@ AGENTS.md # 本文档 | Kit | 说明 | 推荐落地位置 | | --- | --- | --- | -| [agent-collaboration-kit](kits/agent-collaboration-kit/README.md) | 多 Agent 协作闭环规范包 | `docs/agent-collaboration-kit/` | +| [ack](kits/ack/README.md) | 多 Agent 协作闭环规范包 | `docs/ack/` | -新建规范包:创建 `kits//README.md`,写清适用场景、复制到项目后的推荐目录、需要项目填充的占位符,并在本仓库 commit。 +新建规范包:创建 `kits//README.md`,写清适用场景、复制到项目后的推荐目录、需要项目填充的占位符,并在本仓库 commit。已有 kit 可通过 `skiff kit init ` 初始化到项目。 ### 外部(External Git) diff --git a/README.md b/README.md index c4a251a..a2e3ae8 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,13 @@ AGENTS.md # 详细规范与架构说明 | Kit | 说明 | |-----|------| -| [agent-collaboration-kit](kits/agent-collaboration-kit/README.md) | 多 Agent 协作闭环规范包,复制到项目 `docs/agent-collaboration-kit/` 后按项目情况填写 | +| [ack](kits/ack/README.md) | 多 Agent 协作闭环规范包,通过 `skiff kit init ack` 初始化到项目 `docs/ack/` | + +```bash +skiff kit init ack # 当前项目,软链接到 SSOT(推荐) +skiff kit init ack --project ~/app # 指定项目 +skiff kit init ack --copy # 整份复制,后续需手动升级 +``` 新建 skill: diff --git a/skiff/README.md b/skiff/README.md index e580c30..240468c 100644 --- a/skiff/README.md +++ b/skiff/README.md @@ -65,6 +65,7 @@ skiff bootstrap | 命令 | 说明 | |------|------| | `skiff bootstrap` | 将本项目的 `skiff` skill 全局安装到所有 Agent | +| `skiff kit init [--project DIR] [--copy]` | 在项目的 `docs//` 初始化规范包;默认软链接到 SSOT | ### 全局安装(自研 skill) diff --git a/skiff/cli.py b/skiff/cli.py index 8acaf98..7178f01 100644 --- a/skiff/cli.py +++ b/skiff/cli.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse import re +import shutil import subprocess import sys from datetime import datetime @@ -17,6 +18,7 @@ from skiff.paths import ( DRAFTS_DIR, EXTERNALS_DIR, CONFIG_FILE, + KITS_DIR, SKILLS_DIR, SKILLS_HOME, TEMPLATE_DIR, @@ -80,6 +82,13 @@ def _project_root(explicit: str | None = None) -> Path: 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: @@ -1039,6 +1048,71 @@ def cmd_doctor(args: argparse.Namespace) -> None: 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", @@ -1207,6 +1281,18 @@ def build_parser() -> argparse.ArgumentParser: 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 diff --git a/skiff/paths.py b/skiff/paths.py index 11089b9..82890a1 100644 --- a/skiff/paths.py +++ b/skiff/paths.py @@ -7,6 +7,7 @@ from pathlib import Path HOME = Path.home() SKILLS_HOME = HOME / ".skills" SKILLS_DIR = SKILLS_HOME / "skills" +KITS_DIR = SKILLS_HOME / "kits" TEMPLATE_DIR = SKILLS_DIR / "_template" DRAFTS_DIR = SKILLS_HOME / ".drafts" REGISTRY_FILE = SKILLS_HOME / "registry.yaml" diff --git a/tests/test_kit_init.py b/tests/test_kit_init.py new file mode 100644 index 0000000..297ef7e --- /dev/null +++ b/tests/test_kit_init.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +class KitInitTests(unittest.TestCase): + def setUp(self) -> None: + self.temp_dir = tempfile.TemporaryDirectory() + self.home = Path(self.temp_dir.name) + self.skills_home = self.home / ".skills" + kit = self.skills_home / "kits" / "ack" + (kit / "templates").mkdir(parents=True) + (kit / "scripts").mkdir() + (kit / "VERSION").write_text("1.2.3\n", encoding="utf-8") + (kit / "templates" / "project.template.md").write_text( + "# \nversion=\npath=\n", + encoding="utf-8", + ) + (kit / "templates" / "tasks.template.yaml").write_text( + 'updatedAt: ""\n' + 'kitVersion: "<接入时的 ack 版本,见 kit 根 VERSION>"\n' + 'project:\n' + ' name: ""\n' + ' repoPath: ""\n' + ' devWorktree: ""\n', + encoding="utf-8", + ) + + def tearDown(self) -> None: + self.temp_dir.cleanup() + + def run_skiff(self, *args: str) -> subprocess.CompletedProcess[str]: + env = os.environ.copy() + env["HOME"] = str(self.home) + env["PYTHONPATH"] = str(REPO_ROOT) + return subprocess.run( + [sys.executable, "-m", "skiff", *args], + cwd=REPO_ROOT, + env=env, + text=True, + capture_output=True, + check=False, + ) + + def test_init_creates_symlink_and_rendered_project_files(self) -> None: + project = self.home / "sample-app" + project.mkdir() + + result = self.run_skiff("kit", "init", "ack", "--project", str(project)) + + self.assertEqual(result.returncode, 0, result.stderr) + target = project / "docs" / "ack" + self.assertTrue((target / "kit").is_symlink()) + self.assertEqual((target / "kit").resolve(), self.skills_home / "kits" / "ack") + project_content = (target / "project.md").read_text(encoding="utf-8") + tasks_content = (target / "tasks.yaml").read_text(encoding="utf-8") + self.assertIn("# sample-app", project_content) + self.assertIn("version=1.2.3", project_content) + self.assertIn(f'repoPath: "{project}"', tasks_content) + self.assertNotIn("", tasks_content) + + def test_init_copy_mode_copies_kit(self) -> None: + project = self.home / "copied-app" + project.mkdir() + + result = self.run_skiff("kit", "init", "ack", "--copy", "--project", str(project)) + + self.assertEqual(result.returncode, 0, result.stderr) + target = project / "docs" / "ack" / "kit" + self.assertTrue(target.is_dir()) + self.assertFalse(target.is_symlink()) + self.assertEqual((target / "VERSION").read_text(encoding="utf-8"), "1.2.3\n") + + def test_init_refuses_to_overwrite_existing_files(self) -> None: + project = self.home / "existing-app" + target = project / "docs" / "ack" + target.mkdir(parents=True) + existing = target / "project.md" + existing.write_text("keep me", encoding="utf-8") + + result = self.run_skiff("kit", "init", "ack", "--project", str(project)) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("拒绝覆盖已有路径", result.stderr) + self.assertEqual(existing.read_text(encoding="utf-8"), "keep me") + self.assertFalse((target / "kit").exists()) + + def test_init_rejects_missing_project_directory(self) -> None: + project = self.home / "missing-app" + + result = self.run_skiff("kit", "init", "ack", "--project", str(project)) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("项目目录不存在", result.stderr) + self.assertFalse(project.exists()) + + +if __name__ == "__main__": + unittest.main()