feat: add kit project initializer
This commit is contained in:
@@ -78,9 +78,9 @@ AGENTS.md # 本文档
|
|||||||
|
|
||||||
| Kit | 说明 | 推荐落地位置 |
|
| Kit | 说明 | 推荐落地位置 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| [agent-collaboration-kit](kits/agent-collaboration-kit/README.md) | 多 Agent 协作闭环规范包 | `docs/agent-collaboration-kit/` |
|
| [ack](kits/ack/README.md) | 多 Agent 协作闭环规范包 | `docs/ack/` |
|
||||||
|
|
||||||
新建规范包:创建 `kits/<name>/README.md`,写清适用场景、复制到项目后的推荐目录、需要项目填充的占位符,并在本仓库 commit。
|
新建规范包:创建 `kits/<name>/README.md`,写清适用场景、复制到项目后的推荐目录、需要项目填充的占位符,并在本仓库 commit。已有 kit 可通过 `skiff kit init <name>` 初始化到项目。
|
||||||
|
|
||||||
### 外部(External Git)
|
### 外部(External Git)
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,13 @@ AGENTS.md # 详细规范与架构说明
|
|||||||
|
|
||||||
| Kit | 说明 |
|
| 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:
|
新建 skill:
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ skiff bootstrap
|
|||||||
| 命令 | 说明 |
|
| 命令 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `skiff bootstrap` | 将本项目的 `skiff` skill 全局安装到所有 Agent |
|
| `skiff bootstrap` | 将本项目的 `skiff` skill 全局安装到所有 Agent |
|
||||||
|
| `skiff kit init <name> [--project DIR] [--copy]` | 在项目的 `docs/<name>/` 初始化规范包;默认软链接到 SSOT |
|
||||||
|
|
||||||
### 全局安装(自研 skill)
|
### 全局安装(自研 skill)
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -17,6 +18,7 @@ from skiff.paths import (
|
|||||||
DRAFTS_DIR,
|
DRAFTS_DIR,
|
||||||
EXTERNALS_DIR,
|
EXTERNALS_DIR,
|
||||||
CONFIG_FILE,
|
CONFIG_FILE,
|
||||||
|
KITS_DIR,
|
||||||
SKILLS_DIR,
|
SKILLS_DIR,
|
||||||
SKILLS_HOME,
|
SKILLS_HOME,
|
||||||
TEMPLATE_DIR,
|
TEMPLATE_DIR,
|
||||||
@@ -80,6 +82,13 @@ def _project_root(explicit: str | None = None) -> Path:
|
|||||||
return find_repo_root() or Path.cwd()
|
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]:
|
def _collect_skill_names(positional: list[str] | None, flagged: list[str] | None) -> list[str]:
|
||||||
names = list(positional or [])
|
names = list(positional or [])
|
||||||
if flagged:
|
if flagged:
|
||||||
@@ -1039,6 +1048,71 @@ def cmd_doctor(args: argparse.Namespace) -> None:
|
|||||||
sys.exit(1)
|
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>": project.name,
|
||||||
|
"<repo_path>": str(project),
|
||||||
|
"<dev_worktree>": str(project),
|
||||||
|
"<overlay_file_path>": f"docs/{args.name}/project.md",
|
||||||
|
"<kit_version>": kit_version,
|
||||||
|
"<接入时的 ack 版本,见 kit 根 VERSION>": kit_version,
|
||||||
|
"<YYYY-MM-DDTHH:mm:ss+TZ>": 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:
|
def _add_common_flags(parser: argparse.ArgumentParser) -> None:
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-a",
|
"-a",
|
||||||
@@ -1207,6 +1281,18 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
p_doctor.add_argument("--fix", action="store_true", help="自动修复可修复的软链")
|
p_doctor.add_argument("--fix", action="store_true", help="自动修复可修复的软链")
|
||||||
p_doctor.set_defaults(func=cmd_doctor)
|
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
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from pathlib import Path
|
|||||||
HOME = Path.home()
|
HOME = Path.home()
|
||||||
SKILLS_HOME = HOME / ".skills"
|
SKILLS_HOME = HOME / ".skills"
|
||||||
SKILLS_DIR = SKILLS_HOME / "skills"
|
SKILLS_DIR = SKILLS_HOME / "skills"
|
||||||
|
KITS_DIR = SKILLS_HOME / "kits"
|
||||||
TEMPLATE_DIR = SKILLS_DIR / "_template"
|
TEMPLATE_DIR = SKILLS_DIR / "_template"
|
||||||
DRAFTS_DIR = SKILLS_HOME / ".drafts"
|
DRAFTS_DIR = SKILLS_HOME / ".drafts"
|
||||||
REGISTRY_FILE = SKILLS_HOME / "registry.yaml"
|
REGISTRY_FILE = SKILLS_HOME / "registry.yaml"
|
||||||
|
|||||||
@@ -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(
|
||||||
|
"# <project_name>\nversion=<kit_version>\npath=<overlay_file_path>\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(kit / "templates" / "tasks.template.yaml").write_text(
|
||||||
|
'updatedAt: "<YYYY-MM-DDTHH:mm:ss+TZ>"\n'
|
||||||
|
'kitVersion: "<接入时的 ack 版本,见 kit 根 VERSION>"\n'
|
||||||
|
'project:\n'
|
||||||
|
' name: "<project_name>"\n'
|
||||||
|
' repoPath: "<repo_path>"\n'
|
||||||
|
' devWorktree: "<dev_worktree>"\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("<project_name>", 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()
|
||||||
Reference in New Issue
Block a user