diff --git a/INSTALL.md b/INSTALL.md index f051a40c..70b525e3 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -97,6 +97,8 @@ Windows 上 Claude 安装器还会额外写入 `~/.claude/commands/{character}-{ - `relationship` → `./skills/relationship/` - `celebrity` → `./skills/celebrity/` +如果目标 `{character}/{slug}` 已经存在,创建器会停止,不会改写原来的文件。请通过进化模式更新已有 Skill;使用 CLI 时改用 `--action update`,不要删除旧目录后重新创建。例外是命令明确提示本次创建失败、目录可能不完整:先检查该目录,只有确认它就是要丢弃的失败产物时才手动删除,然后重试 `create`。 + 用于宿主调用的 `SKILL.md` 已经自包含 Persona + Work。安装生成 Skill 时使用统一安装器;它只写入这一文件和 `.distilly-install.json`,不会复制可能含有私有原材料的整个生成目录。对于旧版下划线 frontmatter,安装器只在安装副本中规范为 `{character}-{slug}`,不会修改源 Skill: ```bash diff --git a/INSTALL_EN.md b/INSTALL_EN.md index b916f542..cb3e43dc 100644 --- a/INSTALL_EN.md +++ b/INSTALL_EN.md @@ -80,6 +80,14 @@ The installer writes only the self-contained `SKILL.md` and generated directory, and it normalizes legacy underscore frontmatter only in the installed copy. +If the target `{character}/{slug}` already exists, creation stops without +changing its files. Use Evolution Mode to update an existing Skill, or pass +`--action update` when using the CLI. Do not delete the old directory and +create it again. The exception is when the command explicitly reports that +this create failed and the directory may be incomplete. Inspect that directory +first. Remove it only after confirming it is the failed create you intend to +discard, then retry `create`. + On Windows, use the dedicated Claude Code installer when a command shim is needed: diff --git a/README.md b/README.md index b529f9c3..33cf3f3d 100644 --- a/README.md +++ b/README.md @@ -268,6 +268,7 @@ Distilly's current creator uses **Persona** as the universal base, with family-s ### 🧬 Evolution +- 🛡️ **Safe create** → if a Skill already uses the same `{character}/{slug}`, creation stops and points you to Evolution instead of rewriting its files - 📥 **Append files** → auto-analyze delta → merge into relevant sections, never overwrite existing conclusions - 💬 **Conversation correction** → say "they wouldn't do that, they'd be xxx" → writes to the Correction layer, takes effect immediately - 🕰️ **Version control** → auto-archive on every update, rollback to any previous version diff --git a/SKILL.md b/SKILL.md index 7f9fbcc9..d50843dc 100644 --- a/SKILL.md +++ b/SKILL.md @@ -616,7 +616,8 @@ Persona 摘要: - `profile` - `tags` - `knowledge_sources` -4. 然后调用: +4. 如果 `{resolved_base_dir}/{slug}` 已经存在,停止创建并转入“进化模式”。writer 会拒绝覆盖已有 Skill;不要删除旧目录后重试。只有当上一条创建命令明确报告目录可能不完整时,才先检查该目录;确认它就是要丢弃的失败产物后,可以手动删除并重试创建。 +5. 然后调用: ```bash python3 "{distilly_skill_root}/tools/skill_writer.py" \ --action create \ @@ -629,7 +630,7 @@ Persona 摘要: --persona /tmp/distilly_{slug}_persona.md \ --base-dir {resolved_base_dir} ``` -5. 该命令会统一生成: +6. 该命令会统一生成: - `SKILL.md` - `work.md` - `persona.md` @@ -648,11 +649,11 @@ Persona 摘要: - OpenCode:运行 `python3 "{distilly_skill_root}/tools/install_generated_skill.py" --skill-dir "{resolved_base_dir}/{slug}" --host opencode --force`;项目级安装追加 `--skills-dir .opencode/skills` - 统一安装器只写入自包含的 `SKILL.md` 和安装元数据,会在安装副本中规范旧版 frontmatter;不要手动复制整个生成目录,其中可能包含私有原始材料 - Claude Code on Windows:可再追加 `--install-claude-command-shim` -6. 如果当前是 `celebrity`,创建完成后必须再跑一次质量检查: +7. 如果当前是 `celebrity`,创建完成后必须再跑一次质量检查: ```bash python3 "{distilly_skill_root}/tools/research/quality_check.py" "{resolved_base_dir}/{slug}/SKILL.md" --profile {research_profile} ``` -7. 如果 `celebrity` 的质量检查仍然提示 `source_grounding` 失败: +8. 如果 `celebrity` 的质量检查仍然提示 `source_grounding` 失败: - 可以补写诚实的来源说明和局限说明 - 但只有在拿到真实、具体、可追溯的外部来源时,才能补充 URL - **不要**用站点首页、topic 页、搜索页、个人空间首页等泛化链接来“刷过”检查 @@ -1370,7 +1371,8 @@ After user confirmation, do not hand-build a `skills/colleague/{slug}`-style tre - `profile` - `tags` - `knowledge_sources` -4. Then call: +4. If `{resolved_base_dir}/{slug}` already exists, stop creation and switch to Evolution Mode. The writer refuses to overwrite an existing Skill; do not delete the old directory and retry. Only when the previous create command explicitly reports that the directory may be incomplete should you inspect it, remove it after confirming it is the failed create you intend to discard, and retry creation. +5. Then call: ```bash python3 "{distilly_skill_root}/tools/skill_writer.py" \ --action create \ @@ -1383,7 +1385,7 @@ After user confirmation, do not hand-build a `skills/colleague/{slug}`-style tre --persona /tmp/distilly_{slug}_persona.md \ --base-dir {resolved_base_dir} ``` -5. This command will generate: +6. This command will generate: - `SKILL.md` - `work.md` - `persona.md` @@ -1402,11 +1404,11 @@ After user confirmation, do not hand-build a `skills/colleague/{slug}`-style tre - OpenCode: run `python3 "{distilly_skill_root}/tools/install_generated_skill.py" --skill-dir "{resolved_base_dir}/{slug}" --host opencode --force`; append `--skills-dir .opencode/skills` for a project install - The shared installer writes only the self-contained `SKILL.md` and install metadata and normalizes legacy frontmatter in the installed copy. Do not manually copy the whole generated directory; it may contain private source material - Claude Code on Windows: optionally add `--install-claude-command-shim` -6. If the current family is `celebrity`, run a quality check after creation: +7. If the current family is `celebrity`, run a quality check after creation: ```bash python3 "{distilly_skill_root}/tools/research/quality_check.py" "{resolved_base_dir}/{slug}/SKILL.md" --profile {research_profile} ``` -7. If `source_grounding` still fails for a `celebrity` skill: +8. If `source_grounding` still fails for a `celebrity` skill: - you may add honest limitation notes and a grounded source summary - only add URLs when they are real, specific, and traceable sources - **never** use site roots, topic pages, search pages, or other generic links as fake grounding diff --git a/docs/lang/README_DE.md b/docs/lang/README_DE.md index 15d9873e..2d0507cd 100644 --- a/docs/lang/README_DE.md +++ b/docs/lang/README_DE.md @@ -260,6 +260,7 @@ Distilly verwendet **Persona** als universelle Basis, mit familienspezifischen M ### 🧬 Evolution +- 🛡️ **Sicheres Erstellen** → wenn bereits ein Skill mit demselben `{character}/{slug}` existiert, stoppt die Erstellung und verweist auf Evolution, statt dessen Dateien zu überschreiben - 📥 **Dateien anfügen** → automatische Delta-Analyse → Merge in die relevanten Abschnitte, überschreibt nie bestehende Schlussfolgerungen - 💬 **Gesprächskorrektur** → sage „so würden sie das nicht tun, sie wären xxx" → wird in die Korrekturschicht geschrieben, wirkt sofort - 🕰️ **Versionskontrolle** → automatische Archivierung bei jedem Update, Rollback zu jeder früheren Version diff --git a/docs/lang/README_EN.md b/docs/lang/README_EN.md index ca4a8961..f90d515d 100644 --- a/docs/lang/README_EN.md +++ b/docs/lang/README_EN.md @@ -268,6 +268,7 @@ Distilly's current creator uses **Persona** as the universal base, with family-s ### 🧬 Evolution +- 🛡️ **Safe create** → if a Skill already uses the same `{character}/{slug}`, creation stops and points you to Evolution instead of rewriting its files - 📥 **Append files** → auto-analyze delta → merge into relevant sections, never overwrite existing conclusions - 💬 **Conversation correction** → say "they wouldn't do that, they'd be xxx" → writes to the Correction layer, takes effect immediately - 🕰️ **Version control** → auto-archive on every update, rollback to any previous version diff --git a/docs/lang/README_ES.md b/docs/lang/README_ES.md index 97037f13..e6c1cecf 100644 --- a/docs/lang/README_ES.md +++ b/docs/lang/README_ES.md @@ -260,6 +260,7 @@ Distilly usa **Persona** como base universal, con módulos específicos de cada ### 🧬 Evolución +- 🛡️ **Creación segura** → si ya existe un Skill con el mismo `{character}/{slug}`, la creación se detiene y te dirige a Evolución en lugar de sobrescribir sus archivos - 📥 **Agregar archivos** → auto-analizar el delta → fusionar en secciones relevantes, nunca sobrescribe conclusiones existentes - 💬 **Corrección por conversación** → di "él no haría eso, sería xxx" → se escribe en la capa de Corrección, efecto inmediato - 🕰️ **Control de versiones** → auto-archivo en cada actualización, revertir a cualquier versión anterior diff --git a/docs/lang/README_JA.md b/docs/lang/README_JA.md index 2c900a38..cabcc455 100644 --- a/docs/lang/README_JA.md +++ b/docs/lang/README_JA.md @@ -262,6 +262,7 @@ Distilly は **Persona** を共通の土台とし、その上にファミリー ### 🧬 進化メカニズム +- 🛡️ **安全な作成** → 同じ `{character}/{slug}` の Skill がすでに存在する場合、ファイルを上書きせずに作成を停止し、進化モードの利用を案内します - 📥 **ファイル追加** → 自動で差分分析 → 関連セクションにマージ、既存の結論は上書きしない - 💬 **会話による修正** → 「彼はそんなことしない、xxx のはず」と伝える → Correction レイヤーに書き込まれ、即座に反映 - 🕰️ **バージョン管理** → 更新のたびに自動アーカイブ、任意の過去バージョンへロールバック可能 diff --git a/docs/lang/README_KO.md b/docs/lang/README_KO.md index 4d992695..85e0ae5e 100644 --- a/docs/lang/README_KO.md +++ b/docs/lang/README_KO.md @@ -262,6 +262,7 @@ Distilly는 **Persona**를 범용 베이스로 삼고, 그 위에 패밀리별 ### 🧬 진화 방식 +- 🛡️ **안전한 생성** → 같은 `{character}/{slug}`에 Skill이 이미 있으면 파일을 덮어쓰지 않고 생성을 중단한 뒤 진화 모드를 안내합니다 - 📥 **파일 추가** → 변경 내용을 자동 분석해 관련 섹션에 병합, 기존 결론은 덮어쓰지 않음 - 💬 **대화 기반 수정** → "그 사람은 이렇게 안 해, xxx여야 해"라고 말하면 Correction 레이어에 기록되어 즉시 반영 - 🕰️ **버전 관리** → 업데이트할 때마다 자동 아카이브, 이전 어느 버전으로든 롤백 가능 diff --git a/docs/lang/README_PT.md b/docs/lang/README_PT.md index c81017de..da5feb24 100644 --- a/docs/lang/README_PT.md +++ b/docs/lang/README_PT.md @@ -260,6 +260,7 @@ A Distilly usa **Persona** como a base universal, com módulos específicos de c ### 🧬 Evolução +- 🛡️ **Criação segura** → se já existir um Skill com o mesmo `{character}/{slug}`, a criação para e orienta o uso da Evolução em vez de sobrescrever os arquivos - 📥 **Adicionar arquivos** → auto-análise de delta → merge nas seções relevantes, nunca sobrescreve conclusões existentes - 💬 **Correção por conversa** → diga "ele não faria isso, ele seria xxx" → escreve na camada de Correção, efeito imediato - 🕰️ **Controle de versão** → auto-arquivamento a cada atualização, rollback para qualquer versão anterior diff --git a/docs/lang/README_RU.md b/docs/lang/README_RU.md index db83eeeb..3e5792a1 100644 --- a/docs/lang/README_RU.md +++ b/docs/lang/README_RU.md @@ -262,6 +262,7 @@ Distilly использует **Persona** как универсальную ба ### 🧬 Эволюция +- 🛡️ **Безопасное создание** → если Skill с тем же `{character}/{slug}` уже существует, создание останавливается и предлагает использовать эволюцию вместо перезаписи файлов - 📥 **Добавить файлы** → автоанализ дельты → мерж в соответствующие секции, никогда не перезаписывает существующие выводы - 💬 **Коррекция через диалог** → скажи «он бы так не сделал, он должен быть xxx» → записывается в слой коррекции, мгновенный эффект - 🕰️ **Версионирование** → автоархивация при каждом обновлении, откат к любой предыдущей версии diff --git a/docs/lang/README_ZH.md b/docs/lang/README_ZH.md index 886c443d..255d136d 100644 --- a/docs/lang/README_ZH.md +++ b/docs/lang/README_ZH.md @@ -263,6 +263,7 @@ Distilly 当前的创建器以 **Persona** 为通用底座,不同家族按场 ### 🧬 进化机制 +- 🛡️ **安全创建** → 如果同一个 `{character}/{slug}` 已经有 Skill,创建会停止并提示使用进化模式,不会改写原文件 - 📥 **追加文件** → 自动分析增量 → merge 进对应部分,不覆盖已有结论 - 💬 **对话纠正** → 说「他不会这样,他应该是 xxx」→ 写入 Correction 层,立即生效 - 🕰️ **版本管理** → 每次更新自动存档,支持回滚到任意历史版本 diff --git a/tests/test_cli_lifecycle.py b/tests/test_cli_lifecycle.py index 65065090..26d8811b 100644 --- a/tests/test_cli_lifecycle.py +++ b/tests/test_cli_lifecycle.py @@ -210,6 +210,68 @@ def test_create_name_only_normalizes_slug_and_rejects_unsafe_explicit_slug(self) ) self.assertFalse((root / "skills" / "escape").exists()) + def test_create_refuses_to_overwrite_an_existing_skill(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + writer = str(PROJECT_ROOT / "tools" / "skill_writer.py") + base_dir = root / "skills" / "colleague" + original_work = root / "original-work.md" + replacement_work = root / "replacement-work.md" + original_work.write_text("Original work\n", encoding="utf-8") + replacement_work.write_text("Replacement work\n", encoding="utf-8") + + self.run_cmd( + PYTHON, + writer, + "--action", + "create", + "--slug", + "existing", + "--name", + "Original Name", + "--work", + str(original_work), + "--base-dir", + str(base_dir), + cwd=root, + ) + + skill_dir = base_dir / "existing" + original_meta = (skill_dir / "meta.json").read_text(encoding="utf-8") + original_generated_work = (skill_dir / "work.md").read_text(encoding="utf-8") + + with self.assertRaises(subprocess.CalledProcessError) as context: + self.run_cmd( + PYTHON, + writer, + "--action", + "create", + "--slug", + "existing", + "--name", + "Replacement Name", + "--work", + str(replacement_work), + "--base-dir", + str(base_dir), + cwd=root, + ) + + self.assertTrue( + context.exception.stderr.startswith("error: create target already exists") + ) + self.assertIn("--action update", context.exception.stderr) + self.assertNotIn("Traceback", context.exception.stderr) + self.assertEqual( + (skill_dir / "meta.json").read_text(encoding="utf-8"), + original_meta, + ) + self.assertEqual( + (skill_dir / "work.md").read_text(encoding="utf-8"), + original_generated_work, + ) + self.assertEqual(list((skill_dir / "versions").iterdir()), []) + def test_update_accepts_safe_legacy_slug_with_spaces(self) -> None: with tempfile.TemporaryDirectory() as tmp_dir: root = Path(tmp_dir) diff --git a/tests/test_skill_writer.py b/tests/test_skill_writer.py index df738340..1b7932e6 100644 --- a/tests/test_skill_writer.py +++ b/tests/test_skill_writer.py @@ -1,11 +1,13 @@ from __future__ import annotations +import io import json import os import sys import tempfile import unittest from pathlib import Path +from unittest import mock TOOLS_DIR = Path(__file__).resolve().parents[1] / "tools" @@ -41,6 +43,182 @@ def test_create_skill_rejects_unsafe_slug_before_writing(self) -> None: ) self.assertFalse((root / "skills" / "escape").exists()) + def test_failed_create_never_deletes_a_replacement_directory(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + base_dir = Path(tmp_dir) / "skills" / "colleague" + skill_dir = base_dir / "contended-after-reservation" + displaced_dir = base_dir / "incomplete-first-creator" + + def replace_then_fail( + output_dir: Path, + _meta: dict, + _work_content: str, + _persona_content: str, + ) -> None: + self.assertEqual(output_dir, skill_dir) + (output_dir / "partial.txt").write_text("partial", encoding="utf-8") + output_dir.rename(displaced_dir) + output_dir.mkdir() + (output_dir / "publisher.txt").write_text("competitor", encoding="utf-8") + raise OSError("simulated write failure") + + with mock.patch.object( + skill_writer, + "write_artifacts", + side_effect=replace_then_fail, + ): + with self.assertRaisesRegex( + skill_writer.IncompleteSkillCreateError, + "simulated write failure", + ): + skill_writer.create_skill( + base_dir, + "contended-after-reservation", + {"name": "Contended After Reservation"}, + "Work body", + "Persona body", + ) + + self.assertEqual( + (skill_dir / "publisher.txt").read_text(encoding="utf-8"), + "competitor", + ) + self.assertEqual( + (displaced_dir / "partial.txt").read_text(encoding="utf-8"), + "partial", + ) + + def test_create_skill_preserves_a_competing_target(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + base_dir = Path(tmp_dir) / "skills" / "colleague" + skill_dir = base_dir / "contended" + original_mkdir = Path.mkdir + + def competing_reservation( + path: Path, + mode: int = 0o777, + parents: bool = False, + exist_ok: bool = False, + ) -> None: + if path != skill_dir: + original_mkdir( + path, + mode=mode, + parents=parents, + exist_ok=exist_ok, + ) + return + original_mkdir( + path, + mode=mode, + parents=parents, + exist_ok=exist_ok, + ) + (skill_dir / "publisher.txt").write_text("competitor", encoding="utf-8") + raise FileExistsError("simulated competing reservation") + + with mock.patch.object( + Path, + "mkdir", + new=competing_reservation, + ): + with self.assertRaisesRegex(FileExistsError, "create target already exists"): + skill_writer.create_skill( + base_dir, + "contended", + {"name": "Contended"}, + "Work body", + "Persona body", + ) + + self.assertEqual( + (skill_dir / "publisher.txt").read_text(encoding="utf-8"), + "competitor", + ) + self.assertEqual( + [path.relative_to(skill_dir) for path in skill_dir.rglob("*")], + [Path("publisher.txt")], + ) + self.assertEqual(list(base_dir.iterdir()), [skill_dir]) + + def test_create_skill_rejects_a_dangling_symlink_target(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + base_dir = Path(tmp_dir) / "skills" / "colleague" + base_dir.mkdir(parents=True) + skill_dir = base_dir / "existing-link" + link_target = base_dir / "missing-target" + skill_dir.symlink_to(link_target, target_is_directory=True) + + with self.assertRaisesRegex(FileExistsError, "--action update"): + skill_writer.create_skill( + base_dir, + "existing-link", + {"name": "Existing Link"}, + "Work body", + "Persona body", + ) + + self.assertTrue(skill_dir.is_symlink()) + self.assertEqual(skill_dir.readlink(), link_target) + + def test_cli_reports_post_reservation_failures_and_supports_recovery(self) -> None: + failures = ( + PermissionError("simulated permission failure"), + ValueError("simulated metadata failure"), + ) + + for failure in failures: + with self.subTest(failure=type(failure).__name__): + with tempfile.TemporaryDirectory() as tmp_dir: + base_dir = Path(tmp_dir) / "skills" / "colleague" + argv = [ + "skill_writer.py", + "--action", + "create", + "--slug", + "write-failure", + "--name", + "Write Failure", + "--base-dir", + str(base_dir), + ] + + with mock.patch.object(skill_writer.sys, "argv", argv): + with mock.patch.object( + skill_writer, + "write_artifacts", + side_effect=failure, + ): + with mock.patch.object( + skill_writer.sys, + "stderr", + new_callable=io.StringIO, + ) as stderr: + with self.assertRaises(SystemExit) as context: + skill_writer.main() + + self.assertEqual(context.exception.code, 1) + self.assertTrue(stderr.getvalue().startswith("error: create failed")) + self.assertIn("the directory may be incomplete", stderr.getvalue()) + self.assertIn(str(base_dir / "write-failure"), stderr.getvalue()) + self.assertIn( + "remove it only if it is the failed create", + stderr.getvalue(), + ) + self.assertNotIn("Traceback", stderr.getvalue()) + incomplete_dir = base_dir / "write-failure" + self.assertTrue(incomplete_dir.is_dir()) + + skill_writer.shutil.rmtree(incomplete_dir) + with mock.patch.object(skill_writer.sys, "argv", argv): + with mock.patch.object( + skill_writer.sys, + "stdout", + new_callable=io.StringIO, + ): + skill_writer.main() + self.assertTrue((incomplete_dir / "meta.json").is_file()) + def test_legacy_path_segments_are_windows_safe(self) -> None: self.assertEqual(validate_path_segment("Zadie Smith"), "Zadie Smith") self.assertEqual(validate_path_segment("Élodie"), "Élodie") diff --git a/tools/skill_writer.py b/tools/skill_writer.py index cd0d6936..26dd35a8 100644 --- a/tools/skill_writer.py +++ b/tools/skill_writer.py @@ -116,6 +116,28 @@ SLUG_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +class IncompleteSkillCreateError(RuntimeError): + """Raised when creation fails after its target directory was reserved.""" + + +def _existing_skill_error(target: Path) -> FileExistsError: + return FileExistsError( + f"create target already exists: {target}; " + "use --action update only if it is the existing Skill you intended to change" + ) + + +def _incomplete_skill_error( + target: Path, + error: Exception, +) -> IncompleteSkillCreateError: + return IncompleteSkillCreateError( + f"create failed after reserving {target}; the directory may be incomplete. " + "Inspect it and remove it only if it is the failed create you intend to " + f"discard, then retry. Original error: {error}" + ) + + def validate_slug(slug: str) -> str: """Require a safe kebab-case slug before using it in paths or skill names.""" if len(slug) > MAX_SLUG_LENGTH or not SLUG_PATTERN.fullmatch(slug): @@ -279,19 +301,28 @@ def create_skill( normalized_meta = enrich_skill_meta(meta, slug, meta.get("character")) preset = get_character_preset(normalized_meta["character"]) skill_dir = base_dir / slug - skill_dir.mkdir(parents=True, exist_ok=True) - - (skill_dir / "versions").mkdir(exist_ok=True) - for relative_path in preset.get("knowledge_dirs", ("docs", "messages", "emails")): - (skill_dir / "knowledge" / relative_path).mkdir(parents=True, exist_ok=True) + base_dir.mkdir(parents=True, exist_ok=True) + try: + skill_dir.mkdir() + except FileExistsError as error: + raise _existing_skill_error(skill_dir) from error - normalized_meta["lifecycle"]["created_at"] = normalized_meta.get("created_at", now_iso()) - normalized_meta["lifecycle"]["updated_at"] = normalized_meta["lifecycle"]["created_at"] - normalized_meta["lifecycle"]["version"] = "v1" - normalized_meta["generation"]["corrections_count"] = normalized_meta.get("corrections_count", 0) - sync_legacy_fields(normalized_meta) + try: + (skill_dir / "versions").mkdir() + for relative_path in preset.get("knowledge_dirs", ("docs", "messages", "emails")): + (skill_dir / "knowledge" / relative_path).mkdir(parents=True, exist_ok=True) + + normalized_meta["lifecycle"]["created_at"] = normalized_meta.get("created_at", now_iso()) + normalized_meta["lifecycle"]["updated_at"] = normalized_meta["lifecycle"]["created_at"] + normalized_meta["lifecycle"]["version"] = "v1" + normalized_meta["generation"]["corrections_count"] = normalized_meta.get( + "corrections_count", 0 + ) + sync_legacy_fields(normalized_meta) - write_artifacts(skill_dir, normalized_meta, work_content, persona_content) + write_artifacts(skill_dir, normalized_meta, work_content, persona_content) + except Exception as error: + raise _incomplete_skill_error(skill_dir, error) from error return skill_dir @@ -654,7 +685,11 @@ def main() -> None: work_content = Path(args.work).read_text(encoding="utf-8") if args.work else "" persona_content = Path(args.persona).read_text(encoding="utf-8") if args.persona else "" - skill_dir = create_skill(base_dir, slug, meta, work_content, persona_content) + try: + skill_dir = create_skill(base_dir, slug, meta, work_content, persona_content) + except (OSError, IncompleteSkillCreateError) as error: + print(f"error: {error}", file=sys.stderr) + sys.exit(1) print(f"Created skill: {skill_dir}") print(" Kind: meta-skill") print(f" Character: {meta['character']}")