neo-skill 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/.shared/skill-creator/data/domains.json +56 -0
  2. package/.shared/skill-creator/data/output-patterns.csv +8 -0
  3. package/.shared/skill-creator/data/resource-patterns.csv +8 -0
  4. package/.shared/skill-creator/data/skill-reasoning.csv +11 -0
  5. package/.shared/skill-creator/data/trigger-patterns.csv +8 -0
  6. package/.shared/skill-creator/data/validation-rules.csv +11 -0
  7. package/.shared/skill-creator/data/workflow-patterns.csv +6 -0
  8. package/.shared/skill-creator/scripts/generate.py +300 -0
  9. package/.shared/skill-creator/scripts/package.py +140 -0
  10. package/.shared/skill-creator/scripts/search.py +231 -0
  11. package/.shared/skill-creator/scripts/validate.py +213 -0
  12. package/LICENSE +21 -0
  13. package/README.md +117 -0
  14. package/bin/omni-skill.js +55 -0
  15. package/bin/skill-creator.js +55 -0
  16. package/package.json +25 -0
  17. package/skills/review-gate/references/review-gate.md +228 -0
  18. package/skills/review-gate/skillspec.json +131 -0
  19. package/skills/skill-creator/references/output-patterns.md +82 -0
  20. package/skills/skill-creator/references/pre-delivery-checklist.md +70 -0
  21. package/skills/skill-creator/references/requirement-collection.md +80 -0
  22. package/skills/skill-creator/references/skill-system-design.md +112 -0
  23. package/skills/skill-creator/references/sources.md +5 -0
  24. package/skills/skill-creator/references/workflow-step-editing.md +103 -0
  25. package/skills/skill-creator/references/workflows.md +28 -0
  26. package/skills/skill-creator/scripts/init_skill.py +34 -0
  27. package/skills/skill-creator/scripts/package_skill.py +34 -0
  28. package/skills/skill-creator/scripts/validate_skill.py +35 -0
  29. package/skills/skill-creator/skillspec.json +117 -0
  30. package/src/omni_skill/__init__.py +1 -0
  31. package/src/omni_skill/cli.py +270 -0
  32. package/src/skill_creator/__init__.py +1 -0
  33. package/src/skill_creator/cli.py +278 -0
  34. package/src/skill_creator/packaging/package.py +30 -0
  35. package/src/skill_creator/packaging/ziputil.py +26 -0
  36. package/src/skill_creator/spec/model.py +111 -0
  37. package/src/skill_creator/spec/render.py +108 -0
  38. package/src/skill_creator/spec/validate.py +18 -0
  39. package/src/skill_creator/targets/claude.py +53 -0
  40. package/src/skill_creator/targets/common.py +46 -0
  41. package/src/skill_creator/targets/cursor.py +34 -0
  42. package/src/skill_creator/targets/github_skills.py +40 -0
  43. package/src/skill_creator/targets/windsurf.py +123 -0
  44. package/src/skill_creator/util/frontmatter.py +24 -0
  45. package/src/skill_creator/util/fs.py +32 -0
@@ -0,0 +1,270 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import shutil
6
+ from pathlib import Path
7
+ from typing import Dict, List, Set
8
+
9
+ from skill_creator.cli import cmd_generate
10
+
11
+ STATE_FILE = ".neo-skill.json"
12
+
13
+ SUPPORTED_AIS = [
14
+ "claude", "cursor", "windsurf", "antigravity", "copilot",
15
+ "kiro", "codex", "qoder", "roocode", "gemini", "trae", "opencode", "continue",
16
+ ]
17
+
18
+ AI_COPY_RULES: Dict[str, Dict] = {
19
+ "claude": {
20
+ "sync_pairs": [(".claude/skills", ".claude/skills")],
21
+ "base_dirs": [".claude"],
22
+ },
23
+ "windsurf": {
24
+ "sync_pairs": [(".windsurf/workflows", ".windsurf/workflows")],
25
+ "base_dirs": [".windsurf"],
26
+ },
27
+ "cursor": {
28
+ "sync_pairs": [(".cursor/commands", ".cursor/commands")],
29
+ "base_dirs": [".cursor"],
30
+ },
31
+ "copilot": {
32
+ "sync_pairs": [(".github/skills", ".github/skills")],
33
+ "base_dirs": [".github"],
34
+ },
35
+ "antigravity": {
36
+ "sync_pairs": [(".agent", ".agent"), (".shared", ".shared")],
37
+ "base_dirs": [".agent", ".shared"],
38
+ },
39
+ "kiro": {
40
+ "sync_pairs": [(".kiro", ".kiro")],
41
+ "base_dirs": [".kiro"],
42
+ },
43
+ "codex": {
44
+ "sync_pairs": [(".codex", ".codex")],
45
+ "base_dirs": [".codex"],
46
+ },
47
+ "qoder": {
48
+ "sync_pairs": [(".qoder", ".qoder")],
49
+ "base_dirs": [".qoder"],
50
+ },
51
+ "roocode": {
52
+ "sync_pairs": [(".roocode", ".roocode")],
53
+ "base_dirs": [".roocode"],
54
+ },
55
+ "gemini": {
56
+ "sync_pairs": [(".gemini", ".gemini")],
57
+ "base_dirs": [".gemini"],
58
+ },
59
+ "trae": {
60
+ "sync_pairs": [(".trae", ".trae")],
61
+ "base_dirs": [".trae"],
62
+ },
63
+ "opencode": {
64
+ "sync_pairs": [(".opencode", ".opencode")],
65
+ "base_dirs": [".opencode"],
66
+ },
67
+ "continue": {
68
+ "sync_pairs": [(".continue", ".continue")],
69
+ "base_dirs": [".continue"],
70
+ },
71
+ }
72
+
73
+
74
+ def _get_pkg_root() -> Path:
75
+ return Path(__file__).resolve().parent.parent.parent
76
+
77
+
78
+ def _get_pkg_version() -> str:
79
+ pkg_json = _get_pkg_root() / "package.json"
80
+ if pkg_json.exists():
81
+ try:
82
+ data = json.loads(pkg_json.read_text(encoding="utf-8"))
83
+ return str(data.get("version", "unknown")).strip() or "unknown"
84
+ except Exception:
85
+ pass
86
+ return "unknown"
87
+
88
+
89
+ def _write_init_state(cwd: Path, selected_ais: List[str]) -> None:
90
+ state_path = cwd / STATE_FILE
91
+ payload = {"ais": selected_ais}
92
+ state_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
93
+
94
+
95
+ def _read_init_state(cwd: Path) -> Dict:
96
+ state_path = cwd / STATE_FILE
97
+ if not state_path.exists():
98
+ return {"ok": False, "error": f"Missing {STATE_FILE}. Please run: omni-skill init --ai <target>"}
99
+ try:
100
+ data = json.loads(state_path.read_text(encoding="utf-8"))
101
+ ais = [a.strip().lower() for a in data.get("ais", []) if a]
102
+ resolved = _resolve_selected_ais(ais)
103
+ if not resolved["ok"]:
104
+ return resolved
105
+ if not resolved["selected"]:
106
+ return {"ok": False, "error": f"Invalid {STATE_FILE}: ais is empty. Please re-run init."}
107
+ return {"ok": True, "selected": resolved["selected"]}
108
+ except Exception:
109
+ return {"ok": False, "error": f"Invalid {STATE_FILE}. Please delete it and re-run init."}
110
+
111
+
112
+ def _resolve_selected_ais(ai_values: List[str]) -> Dict:
113
+ supported: Set[str] = set(SUPPORTED_AIS + ["all"])
114
+ if not ai_values:
115
+ return {"ok": True, "selected": []}
116
+ for v in ai_values:
117
+ if v not in supported:
118
+ return {"ok": False, "error": f"Unknown --ai value: {v}"}
119
+ if "all" in ai_values:
120
+ return {"ok": True, "selected": list(SUPPORTED_AIS)}
121
+ return {"ok": True, "selected": list(set(ai_values))}
122
+
123
+
124
+ def _sync_dir_replace(src: Path, dest: Path) -> None:
125
+ if not src.exists():
126
+ return
127
+ dest.mkdir(parents=True, exist_ok=True)
128
+ for entry in src.iterdir():
129
+ dest_path = dest / entry.name
130
+ if dest_path.exists():
131
+ if dest_path.is_dir():
132
+ shutil.rmtree(dest_path)
133
+ else:
134
+ dest_path.unlink()
135
+ if entry.is_dir():
136
+ shutil.copytree(entry, dest_path)
137
+ else:
138
+ shutil.copy2(entry, dest_path)
139
+
140
+
141
+ def _build_sync_pairs(effective_ais: List[str]) -> List[tuple]:
142
+ pairs = [
143
+ ("skills", "skills"),
144
+ (".shared/skill-creator", ".shared/skill-creator"),
145
+ ]
146
+ for ai in effective_ais:
147
+ rules = AI_COPY_RULES.get(ai)
148
+ if rules:
149
+ pairs.extend(rules["sync_pairs"])
150
+ return pairs
151
+
152
+
153
+ def _perform_sync(pkg_root: Path, cwd: Path, sync_pairs: List[tuple]) -> None:
154
+ for src_rel, dest_rel in sync_pairs:
155
+ src = pkg_root / src_rel
156
+ dest = cwd / dest_rel
157
+ if src.resolve() == dest.resolve():
158
+ print(f" Skipping {dest_rel} (source equals destination)")
159
+ continue
160
+ if not src.exists():
161
+ print(f" Skipping {dest_rel} (not found in package)")
162
+ continue
163
+ print(f" Syncing {dest_rel} (replace items)")
164
+ _sync_dir_replace(src, dest)
165
+
166
+
167
+ def _write_version_files(cwd: Path, effective_ais: List[str], version: str) -> None:
168
+ for ai in effective_ais:
169
+ rules = AI_COPY_RULES.get(ai)
170
+ if not rules:
171
+ continue
172
+ for base_dir in rules["base_dirs"]:
173
+ dir_path = cwd / base_dir
174
+ dir_path.mkdir(parents=True, exist_ok=True)
175
+ (dir_path / "VERSION").write_text(f"{version}\n", encoding="utf-8")
176
+
177
+
178
+ def _generate_outputs_best_effort(pkg_root: Path, cwd: Path) -> None:
179
+ skills_dir = cwd / "skills"
180
+ if not skills_dir.exists():
181
+ return
182
+ specs = sorted(skills_dir.glob("*/skillspec.json"))
183
+ if not specs:
184
+ return
185
+ print("\nGenerating skill outputs from skillspec.json ...")
186
+ for spec_path in specs:
187
+ try:
188
+ ns = argparse.Namespace(repo_root=str(cwd), spec=str(spec_path))
189
+ cmd_generate(ns)
190
+ except SystemExit as e:
191
+ rel = spec_path.relative_to(cwd) if spec_path.is_relative_to(cwd) else spec_path
192
+ print(f" Skipping generator for {rel} (exit {e.code})")
193
+
194
+
195
+ def _handle_init(selected_ais: List[str], mode: str) -> int:
196
+ pkg_root = _get_pkg_root()
197
+ cwd = Path.cwd().resolve()
198
+ version = _get_pkg_version()
199
+ effective_ais = selected_ais if selected_ais else list(SUPPORTED_AIS)
200
+
201
+ sync_pairs = _build_sync_pairs(effective_ais)
202
+ print("Initializing skills in:", cwd)
203
+ _perform_sync(pkg_root, cwd, sync_pairs)
204
+ _generate_outputs_best_effort(pkg_root, cwd)
205
+ _write_version_files(cwd, effective_ais, version)
206
+ print("\nDone! neo-skill initialized.")
207
+
208
+ if mode == "init":
209
+ _write_init_state(cwd, selected_ais)
210
+
211
+ return 0
212
+
213
+
214
+ def _cmd_init(args: argparse.Namespace) -> int:
215
+ ai_values = [a.strip().lower() for a in (args.ai or []) if a]
216
+ resolved = _resolve_selected_ais(ai_values)
217
+ if not resolved["ok"]:
218
+ raise SystemExit(resolved["error"])
219
+ if not resolved["selected"]:
220
+ _print_init_help()
221
+ return 1
222
+ return _handle_init(resolved["selected"], "init")
223
+
224
+
225
+ def _cmd_update(args: argparse.Namespace) -> int:
226
+ cwd = Path.cwd().resolve()
227
+ state = _read_init_state(cwd)
228
+ if not state["ok"]:
229
+ raise SystemExit(state["error"])
230
+ return _handle_init(state["selected"], "update")
231
+
232
+
233
+ def _print_init_help() -> None:
234
+ supported = "|".join(SUPPORTED_AIS + ["all"])
235
+ print("Usage: omni-skill init --ai <target>")
236
+ print(f" <target>: {supported}")
237
+ print("Examples:")
238
+ print(" omni-skill init --ai claude")
239
+ print(" omni-skill init --ai cursor")
240
+ print(" omni-skill init --ai windsurf")
241
+ print(" omni-skill init --ai all")
242
+
243
+
244
+ def build_parser() -> argparse.ArgumentParser:
245
+ p = argparse.ArgumentParser(
246
+ prog="omni-skill",
247
+ description="Multi-assistant skill initializer and generator.",
248
+ )
249
+
250
+ sub = p.add_subparsers(dest="cmd", required=True)
251
+
252
+ p_init = sub.add_parser("init", help="Initialize skills for target AI assistants")
253
+ p_init.add_argument("--ai", action="append", help="Target AI (can be repeated)")
254
+ p_init.set_defaults(func=_cmd_init)
255
+
256
+ p_update = sub.add_parser("update", help="Update skills based on previous init state")
257
+ p_update.set_defaults(func=_cmd_update)
258
+
259
+ return p
260
+
261
+
262
+ def main() -> None:
263
+ parser = build_parser()
264
+ args = parser.parse_args()
265
+ rc = args.func(args)
266
+ raise SystemExit(rc)
267
+
268
+
269
+ if __name__ == "__main__":
270
+ main()
@@ -0,0 +1 @@
1
+ __version__ = "0.1.11"
@@ -0,0 +1,278 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from pathlib import Path
5
+ from typing import List
6
+
7
+ from .spec.validate import assert_spec_valid, validate_spec
8
+ from .spec.render import generate_all
9
+ from .packaging.package import package_claude_skill, package_repo_zip
10
+ from .util.fs import ensure_dir, write_json
11
+
12
+ DEFAULT_QUESTIONS: List[str] = [
13
+ "这个 skill 的一句话目标是什么(动词开头)?",
14
+ "用户会用哪些触发语句/关键词来表述这个需求(至少 5 条)?",
15
+ "期望输出是什么形态(报告/代码/补丁/命令/文件树/清单)?",
16
+ "硬约束有哪些(例如不可改变行为/不可联网/必须跑测试等)?",
17
+ "允许使用的工具边界是什么(shell/git/python/读写文件/网络等)?",
18
+ "需要哪些输入(repo/commit/diff/config/logs 等),从哪里来?",
19
+ "工作流要拆成哪些关键步骤/分支?关键 gate 是什么?",
20
+ "常见失败/边界情况有哪些?希望如何处理(停止/回退/提示)?",
21
+ "哪些内容应放到 references 或 scripts 以降低上下文占用?",
22
+ ]
23
+
24
+
25
+ def cmd_init(args: argparse.Namespace) -> int:
26
+ repo_root = Path(args.repo_root).resolve()
27
+ skill_name = args.name.strip()
28
+ skill_dir = repo_root / "skills" / skill_name
29
+ ensure_dir(skill_dir / "references")
30
+ ensure_dir(skill_dir / "scripts")
31
+ ensure_dir(skill_dir / "assets")
32
+
33
+ spec_path = skill_dir / "skillspec.json"
34
+ if spec_path.exists() and not args.force:
35
+ raise SystemExit(f"Spec already exists: {spec_path} (use --force to overwrite)")
36
+
37
+ spec = {
38
+ "version": 1,
39
+ "name": skill_name,
40
+ "description": args.description.strip() if args.description else f"<TODO: describe when to use {skill_name}>",
41
+ "questions": DEFAULT_QUESTIONS,
42
+ "triggers": [
43
+ f"Create a new skill named {skill_name}",
44
+ f"Update the {skill_name} skill",
45
+ f"Generate SKILL.md for {skill_name}",
46
+ f"Package {skill_name} as a Claude .skill zip",
47
+ f"Validate the {skill_name} skill",
48
+ ],
49
+ "freedom_level": "low",
50
+ "workflow": {
51
+ "type": "sequential",
52
+ "steps": [
53
+ {
54
+ "id": "collect",
55
+ "title": "Collect requirements (<=10 questions)",
56
+ "kind": "action",
57
+ "commands": [],
58
+ "notes": "Ask only what is missing. Never exceed 10 questions total.",
59
+ },
60
+ {
61
+ "id": "plan",
62
+ "title": "Plan resources (SKILL.md vs references vs scripts)",
63
+ "kind": "action",
64
+ "commands": [],
65
+ "notes": "Enforce progressive disclosure: keep SKILL.md concise; move details into references/.",
66
+ },
67
+ {
68
+ "id": "generate",
69
+ "title": "Generate multi-assistant outputs",
70
+ "kind": "action",
71
+ "commands": [
72
+ f"skill-creator generate skills/{skill_name}/skillspec.json",
73
+ ],
74
+ "notes": "Produce Claude/Windsurf/Cursor/GitHub Skills wrappers.",
75
+ },
76
+ {
77
+ "id": "validate",
78
+ "title": "Validate the spec + outputs",
79
+ "kind": "gate",
80
+ "commands": [
81
+ f"skill-creator validate skills/{skill_name}/skillspec.json",
82
+ ],
83
+ "notes": "Fail fast on metadata violations or banned files.",
84
+ },
85
+ {
86
+ "id": "package",
87
+ "title": "Package for Claude (.skill)",
88
+ "kind": "action",
89
+ "commands": [
90
+ f"skill-creator package --target claude --skill {skill_name}",
91
+ ],
92
+ "notes": "Zip root must be the skill folder (not flat files).",
93
+ },
94
+ ],
95
+ },
96
+ "references": [
97
+ "references/output-patterns.md",
98
+ "references/workflows.md",
99
+ ],
100
+ "scripts": [
101
+ "scripts/init_skill.py",
102
+ "scripts/validate_skill.py",
103
+ "scripts/package_skill.py",
104
+ ],
105
+ "assets": [],
106
+ }
107
+
108
+ # Seed references from shared templates if present
109
+ shared_data = repo_root / ".shared" / "skill-creator" / "data"
110
+ if (shared_data / "output-patterns.md").exists():
111
+ (skill_dir / "references" / "output-patterns.md").write_text(
112
+ (shared_data / "output-patterns.md").read_text(encoding="utf-8"),
113
+ encoding="utf-8",
114
+ )
115
+ if (shared_data / "workflows.md").exists():
116
+ (skill_dir / "references" / "workflows.md").write_text(
117
+ (shared_data / "workflows.md").read_text(encoding="utf-8"),
118
+ encoding="utf-8",
119
+ )
120
+
121
+ # Minimal script placeholders (deterministic tools often live here)
122
+ (skill_dir / "scripts" / "init_skill.py").write_text(
123
+ """# Placeholder: generated skill-specific initializer\n""",
124
+ encoding="utf-8",
125
+ )
126
+ (skill_dir / "scripts" / "validate_skill.py").write_text(
127
+ """# Placeholder: generated skill-specific validator\n""",
128
+ encoding="utf-8",
129
+ )
130
+ (skill_dir / "scripts" / "package_skill.py").write_text(
131
+ """# Placeholder: generated skill-specific packager\n""",
132
+ encoding="utf-8",
133
+ )
134
+
135
+ write_json(spec_path, spec)
136
+ print(str(spec_path))
137
+ return 0
138
+
139
+
140
+ def cmd_generate(args: argparse.Namespace) -> int:
141
+ repo_root = Path(args.repo_root).resolve()
142
+ spec_path = Path(args.spec).resolve()
143
+ assert_spec_valid(spec_path)
144
+
145
+ # Determine targets: default is windsurf only, --all for all targets
146
+ if getattr(args, "all", False):
147
+ targets = None # None means all targets
148
+ print("Generating for all targets (windsurf + backward compat)...")
149
+ else:
150
+ targets = [getattr(args, "target", "windsurf") or "windsurf"]
151
+ print(f"Generating for: {targets[0]} (primary target)")
152
+
153
+ spec = generate_all(repo_root, spec_path, targets=targets)
154
+ print(f"Generated outputs for: {spec.name}")
155
+ return 0
156
+
157
+
158
+ def _validate_claude_frontmatter(skill_md: Path) -> List[str]:
159
+ # Strict: frontmatter must contain only name + description.
160
+ txt = skill_md.read_text(encoding="utf-8")
161
+ if not txt.startswith("---\n"):
162
+ return [f"Missing YAML frontmatter: {skill_md}"]
163
+ try:
164
+ end = txt.index("\n---\n", 4)
165
+ except ValueError:
166
+ return [f"Unterminated YAML frontmatter: {skill_md}"]
167
+ fm = txt[4:end].strip().splitlines()
168
+ keys = []
169
+ for line in fm:
170
+ if not line.strip() or line.strip().startswith("#"):
171
+ continue
172
+ if ":" not in line:
173
+ continue
174
+ keys.append(line.split(":", 1)[0].strip())
175
+ missing = [k for k in ["name", "description"] if k not in keys]
176
+ extra = [k for k in keys if k not in {"name", "description"}]
177
+ errs = []
178
+ if missing:
179
+ errs.append(f"Frontmatter missing keys {missing}: {skill_md}")
180
+ if extra:
181
+ errs.append(f"Frontmatter has extra keys {extra} (Claude strict mode): {skill_md}")
182
+ return errs
183
+
184
+
185
+ def cmd_validate(args: argparse.Namespace) -> int:
186
+ repo_root = Path(args.repo_root).resolve()
187
+ spec_path = Path(args.spec).resolve()
188
+
189
+ errors = validate_spec(spec_path)
190
+ if errors:
191
+ raise SystemExit("Spec validation failed:\n- " + "\n- ".join(errors))
192
+
193
+ # Validate generated outputs if present
194
+ spec_name = Path(args.spec).resolve().parent.name
195
+ claude_skill_md = repo_root / ".claude" / "skills" / spec_name / "SKILL.md"
196
+ if claude_skill_md.exists():
197
+ fm_errors = _validate_claude_frontmatter(claude_skill_md)
198
+ if fm_errors:
199
+ raise SystemExit("Claude SKILL.md validation failed:\n- " + "\n- ".join(fm_errors))
200
+
201
+ # Ban noisy docs inside target skill folders (keep only execution-relevant files)
202
+ banned = {"README.md", "CHANGELOG.md", "INSTALL.md"}
203
+ for target_root in [repo_root / ".claude" / "skills" / spec_name, repo_root / ".github" / "skills" / spec_name]:
204
+ if not target_root.exists():
205
+ continue
206
+ for p in target_root.rglob("*"):
207
+ if p.is_file() and p.name in banned:
208
+ raise SystemExit(f"Banned file inside skill bundle: {p}")
209
+
210
+ print("OK")
211
+ return 0
212
+
213
+
214
+ def cmd_package(args: argparse.Namespace) -> int:
215
+ repo_root = Path(args.repo_root).resolve()
216
+ out_dir = repo_root / "dist"
217
+ ensure_dir(out_dir)
218
+
219
+ if args.target == "claude":
220
+ # Validate before packaging
221
+ cmd_validate(
222
+ argparse.Namespace(
223
+ repo_root=str(repo_root),
224
+ spec=str(repo_root / "skills" / args.skill / "skillspec.json"),
225
+ )
226
+ )
227
+ out = package_claude_skill(repo_root, args.skill, out_dir)
228
+ print(str(out))
229
+ return 0
230
+
231
+ if args.target == "repo":
232
+ out = package_repo_zip(repo_root, out_dir / f"{repo_root.name}.zip")
233
+ print(str(out))
234
+ return 0
235
+
236
+ raise SystemExit(f"Unknown target: {args.target}")
237
+
238
+
239
+ def build_parser() -> argparse.ArgumentParser:
240
+ p = argparse.ArgumentParser(
241
+ prog="skill-creator",
242
+ description="Canonical SkillSpec -> multi-assistant skill outputs",
243
+ )
244
+ p.add_argument("--repo-root", default=".", help="Repo root (default: current directory)")
245
+
246
+ sub = p.add_subparsers(dest="cmd", required=True)
247
+
248
+ p_init = sub.add_parser("init", help="Initialize a new skill directory under skills/")
249
+ p_init.add_argument("name", help="Skill name (kebab-case)")
250
+ p_init.add_argument("--description", default="", help="Short description (should include trigger conditions)")
251
+ p_init.add_argument("--force", action="store_true", help="Overwrite if already exists")
252
+ p_init.set_defaults(func=cmd_init)
253
+
254
+ p_gen = sub.add_parser("generate", help="Generate multi-assistant outputs from skillspec.json")
255
+ p_gen.add_argument("spec", help="Path to skillspec.json")
256
+ p_gen.add_argument("--target", "-t", choices=["windsurf", "claude", "cursor", "github"],
257
+ default="windsurf", help="Target to generate (default: windsurf)")
258
+ p_gen.add_argument("--all", "-a", action="store_true",
259
+ help="Generate for all targets (windsurf + backward compat)")
260
+ p_gen.set_defaults(func=cmd_generate)
261
+
262
+ p_val = sub.add_parser("validate", help="Validate spec and generated outputs")
263
+ p_val.add_argument("spec", help="Path to skillspec.json")
264
+ p_val.set_defaults(func=cmd_validate)
265
+
266
+ p_pkg = sub.add_parser("package", help="Package outputs")
267
+ p_pkg.add_argument("--target", choices=["claude", "repo"], required=True)
268
+ p_pkg.add_argument("--skill", default="", help="Skill name (required for --target claude)")
269
+ p_pkg.set_defaults(func=cmd_package)
270
+
271
+ return p
272
+
273
+
274
+ def main() -> None:
275
+ parser = build_parser()
276
+ args = parser.parse_args()
277
+ rc = args.func(args)
278
+ raise SystemExit(rc)
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from ..util.fs import ensure_dir
6
+ from .ziputil import zip_dir
7
+
8
+
9
+ def package_claude_skill(repo_root: Path, skill_name: str, out_dir: Path) -> Path:
10
+ """Create a .skill zip for Claude.
11
+
12
+ Layout (zip root contains folder <skill_name>/):
13
+ <skill_name>/SKILL.md
14
+ <skill_name>/resources/*
15
+ """
16
+ claude_skill_dir = repo_root / ".claude" / "skills" / skill_name
17
+ if not claude_skill_dir.exists():
18
+ raise FileNotFoundError(f"Claude skill dir not found: {claude_skill_dir}")
19
+
20
+ ensure_dir(out_dir)
21
+ out_path = out_dir / f"{skill_name}.skill"
22
+ zip_dir(claude_skill_dir, out_path, root_name=skill_name)
23
+ return out_path
24
+
25
+
26
+ def package_repo_zip(repo_root: Path, out_path: Path) -> Path:
27
+ """Zip the entire repo for distribution (IDE-friendly)."""
28
+ repo_root = repo_root.resolve()
29
+ zip_dir(repo_root, out_path, root_name=repo_root.name)
30
+ return out_path
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ import zipfile
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+
8
+ def zip_dir(src_dir: Path, out_path: Path, *, root_name: Optional[str] = None) -> None:
9
+ """Zip a directory.
10
+
11
+ If root_name is provided, the zip will contain entries under that root folder,
12
+ regardless of the actual directory name.
13
+ """
14
+ src_dir = src_dir.resolve()
15
+ out_path.parent.mkdir(parents=True, exist_ok=True)
16
+
17
+ with zipfile.ZipFile(out_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
18
+ for p in src_dir.rglob("*"):
19
+ if p.is_dir():
20
+ continue
21
+ rel = p.relative_to(src_dir)
22
+ if root_name:
23
+ arcname = Path(root_name) / rel
24
+ else:
25
+ arcname = rel
26
+ zf.write(p, arcname.as_posix())