diff --git a/.github/workflows/skills-check.yml b/.github/workflows/skills-check.yml new file mode 100644 index 0000000..d2131fe --- /dev/null +++ b/.github/workflows/skills-check.yml @@ -0,0 +1,29 @@ +name: skills-check + +on: + pull_request: + push: + branches: [main] + +jobs: + vendor-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + fetch-depth: 0 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + - name: Verify vendored skills match the lockfile digests + run: python3 scripts/vendor/skill_vendor.py check --offline + - name: Verify lockfile pins still match upstream refs and content + run: python3 scripts/vendor/skill_vendor.py check + - name: Reject direct skill edits that bypass the source package + if: github.event_name == 'pull_request' + run: | + changed="$(git diff --name-only "origin/${{ github.base_ref }}...HEAD")" + if echo "$changed" | grep -q '^skills/' && ! echo "$changed" | grep -q '^skills\.lock\.json$'; then + echo "::error::skills/ changed without skills.lock.json. Skill bodies are vendored; edit the source package and run scripts/vendor/skill_vendor.py update." + exit 1 + fi diff --git a/.github/workflows/skills-sync.yml b/.github/workflows/skills-sync.yml new file mode 100644 index 0000000..e4bd082 --- /dev/null +++ b/.github/workflows/skills-sync.yml @@ -0,0 +1,58 @@ +name: skills-sync + +on: + repository_dispatch: + types: [skills-updated] + schedule: + - cron: "23 3 * * *" + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + sync: + runs-on: ubuntu-latest + env: + # A PAT lets the pushed branch/PR trigger CI; without it the PR is + # still created but checks must be kicked off manually. + GH_TOKEN: ${{ secrets.SKILLS_SYNC_TOKEN || secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + token: ${{ secrets.SKILLS_SYNC_TOKEN || secrets.GITHUB_TOKEN }} + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + - name: Vendor skills from pinned sources + run: python3 scripts/vendor/skill_vendor.py update + - name: Verify the fresh snapshot + run: python3 scripts/vendor/skill_vendor.py check + - name: Push the sync branch when the snapshot changed + id: push + run: | + if git diff --quiet && git diff --cached --quiet; then + echo "vendored skills already current; nothing to sync" + echo "changed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -B chore/skills-sync + git add skills/ skills.lock.json + git commit -m "chore: sync vendored skills from source packages" + git push --force-with-lease origin chore/skills-sync + echo "changed=true" >> "$GITHUB_OUTPUT" + - name: Open the sync PR if none exists + if: steps.push.outputs.changed == 'true' + run: | + if gh pr view chore/skills-sync >/dev/null 2>&1; then + echo "sync PR already open; branch updated" + exit 0 + fi + gh pr create \ + --title "chore: sync vendored skills" \ + --body "Automated refresh of the vendored skill snapshot from skills.lock.json sources. All gates (tests, validate_distribution, validate_skills, skills-check) must be green before merge." \ + --head chore/skills-sync --base main \ + --label skills-sync diff --git a/README.md b/README.md index 57becb8..3d6b2b5 100644 --- a/README.md +++ b/README.md @@ -327,6 +327,6 @@ Open functional issues at ", + "skills": ["processon-use", "..."], + "dest": "skills/", + "sha256": {"processon-use": "", "...": "..."} + } + ] + } +""" +import argparse +import hashlib +import json +import re +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +SKILL_NAME_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") + + +def fail(message: str) -> None: + print(f"ERROR: {message}") + + +def hash_skill_dir(skill_dir: Path) -> str: + """Deterministic digest over every file in a skill directory.""" + digest = hashlib.sha256() + files = sorted(p for p in skill_dir.rglob("*") if p.is_file()) + for path in files: + rel = path.relative_to(skill_dir).as_posix() + digest.update(rel.encode("utf-8")) + digest.update(b"\0") + digest.update(hashlib.sha256(path.read_bytes()).hexdigest().encode("ascii")) + digest.update(b"\n") + return digest.hexdigest() + + +def resolve_ref(repo: str, ref: str) -> str: + out = subprocess.run( + # the explicit peel pattern makes annotated tags also list their + # refs/tags/^{} line, whose commit sha is what we lock on + ["git", "ls-remote", repo, ref, f"{ref}^{{}}"], + check=True, capture_output=True, text=True, + ).stdout.strip() + if not out: + raise RuntimeError(f"{repo}: ref '{ref}' not found") + # annotated tags produce two lines (refs/tags/ and its peeled + # refs/tags/^{}); the peeled commit sha wins + sha = None + for line in out.splitlines(): + candidate, _, name = line.partition("\t") + short = name.removeprefix("refs/tags/").removeprefix("refs/heads/") + if short == f"{ref}^{{}}": + sha = candidate + elif short == ref and sha is None: + sha = candidate + if sha is None: + raise RuntimeError(f"{repo}: could not resolve ref '{ref}'") + return sha + + +def fetch_checkout(repo: str, ref: str, workdir: Path) -> Path: + checkout = workdir / "checkout" + subprocess.run(["git", "init", "--quiet", str(checkout)], check=True) + subprocess.run(["git", "-C", str(checkout), "remote", "add", "origin", repo], check=True) + subprocess.run( + ["git", "-C", str(checkout), "fetch", "--quiet", "--depth", "1", "origin", ref], + check=True, + ) + subprocess.run( + ["git", "-C", str(checkout), "checkout", "--quiet", "--detach", "FETCH_HEAD"], + check=True, + ) + head = subprocess.run( + ["git", "-C", str(checkout), "rev-parse", "HEAD"], + check=True, capture_output=True, text=True, + ).stdout.strip() + print(f"fetched {repo}@{ref} -> {head}") + return checkout + + +def load_lock(path: Path) -> dict: + lock = json.loads(path.read_text()) + if lock.get("version") != 1: + raise RuntimeError(f"unsupported lockfile version: {lock.get('version')}") + return lock + + +def validate_source(source: dict, root: Path) -> Path: + for key in ("package", "repo", "ref", "skills", "dest"): + if key not in source: + raise RuntimeError(f"source {source.get('package', '?')}: missing key '{key}'") + for name in source["skills"]: + if not SKILL_NAME_RE.match(name): + raise RuntimeError(f"{source['package']}: illegal skill name '{name}'") + dest = (root / source["dest"]).resolve() + if root not in dest.parents and dest != root: + raise RuntimeError(f"{source['package']}: dest escapes repository root") + return dest + + +def source_checkout(source: dict, overrides: dict, workdir: Path) -> tuple[Path, str]: + """Return (checkout_path, resolved_sha) for one source.""" + package = source["package"] + if package in overrides: + local = Path(overrides[package]).resolve() + if not local.is_dir(): + raise RuntimeError(f"{package}: --source-path '{local}' is not a directory") + sha = subprocess.run( + ["git", "-C", str(local), "rev-parse", "HEAD"], + check=True, capture_output=True, text=True, + ).stdout.strip() + print(f"using local override for {package}: {local} @ {sha}") + return local, sha + sha = resolve_ref(source["repo"], source["ref"]) + return fetch_checkout(source["repo"], source["ref"], workdir / package), sha + + +def cmd_update(lock_path: Path, overrides: dict) -> int: + root = lock_path.parent + lock = load_lock(lock_path) + errors = 0 + with tempfile.TemporaryDirectory(prefix="skill-vendor-") as tmp: + for source in lock["sources"]: + dest = validate_source(source, root) + try: + checkout, sha = source_checkout(source, overrides, Path(tmp)) + except (RuntimeError, subprocess.CalledProcessError) as exc: + fail(f"{source['package']}: {exc}") + errors += 1 + continue + source["sha"] = sha + digests = {} + for name in source["skills"]: + src = checkout / "skills" / name + if not (src / "SKILL.md").is_file(): + fail(f"{source['package']}: skills/{name}/SKILL.md not found at {source['ref']}") + errors += 1 + continue + target = dest / name + if target.exists(): + shutil.rmtree(target) + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copytree(src, target) + digests[name] = hash_skill_dir(target) + source["sha256"] = digests + print(f"{source['package']}: vendored {len(digests)} skills at {sha[:12]}") + if errors: + return 1 + lock_path.write_text(json.dumps(lock, indent=2, ensure_ascii=False) + "\n") + print(f"lockfile updated: {lock_path}") + return 0 + + +def cmd_check(lock_path: Path, offline: bool, overrides: dict) -> int: + root = lock_path.parent + lock = load_lock(lock_path) + errors = 0 + with tempfile.TemporaryDirectory(prefix="skill-vendor-") as tmp: + for source in lock["sources"]: + package = source["package"] + dest = validate_source(source, root) + locked = source.get("sha256", {}) + for name in source["skills"]: + target = dest / name + if not (target / "SKILL.md").is_file(): + fail(f"{package}: managed skill skills/{name} is missing from the tree") + errors += 1 + continue + actual = hash_skill_dir(target) + if actual != locked.get(name): + fail(f"{package}: skills/{name} content differs from the lockfile digest") + errors += 1 + if offline: + continue + try: + checkout, sha = source_checkout(source, overrides, Path(tmp)) + except (RuntimeError, subprocess.CalledProcessError) as exc: + fail(f"{package}: {exc}") + errors += 1 + continue + if sha != source.get("sha"): + fail(f"{package}: {source['ref']} moved to {sha[:12]} " + f"(locked at {str(source.get('sha'))[:12]}); run update") + errors += 1 + continue + for name in source["skills"]: + src = checkout / "skills" / name + if not (src / "SKILL.md").is_file(): + fail(f"{package}: skills/{name} missing upstream") + errors += 1 + continue + if hash_skill_dir(src) != locked.get(name): + fail(f"{package}: skills/{name} upstream content differs from the lockfile") + errors += 1 + if errors: + return 1 + print("skill vendor check: all managed skills match the lockfile" + + (" (offline)" if offline else "")) + return 0 + + +def parse_overrides(pairs: list[str]) -> dict: + overrides = {} + for pair in pairs or []: + package, sep, path = pair.partition("=") + if not sep: + raise SystemExit(f"--source-path expects PKG=PATH, got '{pair}'") + overrides[package] = path + return overrides + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("command", choices=("update", "check")) + parser.add_argument("--lock", default="skills.lock.json", + help="path to the lockfile (default: skills.lock.json)") + parser.add_argument("--offline", action="store_true", + help="check only the in-tree snapshot, skip upstream resolution") + parser.add_argument("--source-path", action="append", default=[], + metavar="PKG=PATH", help="use a local checkout for a source package") + args = parser.parse_args() + lock_path = Path(args.lock).resolve() + if not lock_path.is_file(): + fail(f"lockfile not found: {lock_path}") + return 1 + overrides = parse_overrides(args.source_path) + try: + if args.command == "update": + return cmd_update(lock_path, overrides) + return cmd_check(lock_path, args.offline, overrides) + except (RuntimeError, subprocess.CalledProcessError) as exc: + fail(str(exc)) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills.lock.json b/skills.lock.json new file mode 100644 index 0000000..7d2ce1b --- /dev/null +++ b/skills.lock.json @@ -0,0 +1,102 @@ +{ + "version": 1, + "sources": [ + { + "package": "stitch-skills", + "repo": "https://github.com/full-stack-skills/stitch-skills.git", + "ref": "v1.1.0", + "sha": "f50f92b0e33170f723b11e1d31d37437bad0cdbb", + "skills": [ + "stitch-code-to-design", + "stitch-delete-project", + "stitch-delivery-harness", + "stitch-design-md", + "stitch-design-use", + "stitch-extract-design-md", + "stitch-extract-static-html", + "stitch-local-setup", + "stitch-loop", + "stitch-manage-design-system", + "stitch-mcp-create-project", + "stitch-mcp-generate-screen-from-text", + "stitch-mcp-get-project", + "stitch-mcp-get-screen", + "stitch-mcp-list-projects", + "stitch-mcp-list-screens", + "stitch-react-components", + "stitch-react-native", + "stitch-react-vite-dashboard", + "stitch-remotion", + "stitch-shadcn-ui", + "stitch-site-md", + "stitch-skill-creator", + "stitch-taste-design", + "stitch-ued-guide", + "stitch-ui-design-spec-bootstrap", + "stitch-ui-design-spec-element-plus", + "stitch-ui-design-spec-generator", + "stitch-ui-design-spec-layui", + "stitch-ui-design-spec-uview", + "stitch-ui-design-spec-uviewpro", + "stitch-ui-design-spec-vant", + "stitch-ui-design-variants", + "stitch-ui-designer", + "stitch-ui-prompt-architect", + "stitch-upload-to-stitch", + "stitch-uview-components", + "stitch-uview-plus-components", + "stitch-uviewpro-components", + "stitch-vue-bootstrap-components", + "stitch-vue-element-components", + "stitch-vue-layui-components", + "stitch-vue-vant-components" + ], + "dest": "skills/", + "sha256": { + "stitch-code-to-design": "3559a2c5df5413a2bd8e2bb3609cbc49e6350dff78940f3bc3386e898d688416", + "stitch-delete-project": "91d357801e3cfc013bf2351ccc89423682b1343075e7f2d794c23a47ba64526b", + "stitch-delivery-harness": "b8e3cfa81894d9bb906fe01748ac7261c157b674cfcc0bbc9ddc54730a53a541", + "stitch-design-md": "033de60830c42d8d282c18e20aebcd43b34b6474fc1c99575069725d54313335", + "stitch-design-use": "fbffb180937c2e87f608b3c6765d408d2e499d12bb078e410d83cc67674add2c", + "stitch-extract-design-md": "b1a9d667853814b6a365336a99234b8b18f5e5d642f27a9fe1f20b8418628b07", + "stitch-extract-static-html": "88e44208c0ac3f9b157142426902d44f7c06424d2414260e65fea659bf25d7d1", + "stitch-local-setup": "dfbff1af84ce037557c338a0e02fff991b342203a3dbf6bdd55bd725cd746ad0", + "stitch-loop": "51a0b94cd1ea8fc13314bfe71a232e818731956886aac9c54a425ace8943db54", + "stitch-manage-design-system": "30d2a8fb21fba966cb3bff0d48d1c8947969e8ba85e938231335cf63684d23e4", + "stitch-mcp-create-project": "046b7a2efc7f0f43aa3e1e7f78ed9896901ac3afc0362d070cabf423216e5c0c", + "stitch-mcp-generate-screen-from-text": "d7c8490ee2bad41679c57934a82482321476587ae5409af82c3b78067659d0a6", + "stitch-mcp-get-project": "6d905459cbfd09e70dcf8e5e807cb6edc2cb4c69a2de7cd93e5306cee81ee875", + "stitch-mcp-get-screen": "89aa998ffa9e8f2aefe06008d68ee3e461cd778b779646764d3fdfc9c33af41d", + "stitch-mcp-list-projects": "a33d476cd7908dc0ff75c67c783479d4c5f01a730744792e9625ab0136d5d836", + "stitch-mcp-list-screens": "7148f5880168231389d9347851a115cabc581cb7c34a710009ac205e899fe00b", + "stitch-react-components": "d27cd3542bff6f13f845984ffa49e709f015158aa60e1aaed05e9967bcbdefd9", + "stitch-react-native": "8fa073b4462a6a04ad54f3fe744556bc835b516ebd77a6cf5a62da3ad994ef1e", + "stitch-react-vite-dashboard": "c87a151aa496c1c268beb4c9b17ecdfa3ccb44213e28eab5124aa7bfbd1bd994", + "stitch-remotion": "cef26eaa344c925bc17b73677752f61e0ae202090982e37eaf9846cadf13ffbf", + "stitch-shadcn-ui": "a5ee6c8408cf436d6cac79b0ca8759abb0f453c9106aa90f31ffce68c5a7acbd", + "stitch-site-md": "43a2a4b0f28624759308c92fdd06910628cf7032661a0fcf1f37568fe367775e", + "stitch-skill-creator": "a55fadf208a8f7abb030d0ecaeec9fcd87758da30a90f082ae8d0864dd1a3cef", + "stitch-taste-design": "59291601eae2a5ec1667b44f312cb9a9db8e7d6611ea554d7b4adf016a0a48a5", + "stitch-ued-guide": "6d0897cdb835e2154feb3522decaa4e8910ca90901fce19f01428c9f0379384c", + "stitch-ui-design-spec-bootstrap": "4cf03a604702fdad2bb3be3e4a93878805c9b40dabe06bcf898368c392da0f4c", + "stitch-ui-design-spec-element-plus": "6262d16a84a39707086fd630df6f9d3adc1ea2034c3853859c3f5956c635b6c7", + "stitch-ui-design-spec-generator": "2d8c859a016233a9230d8325f8089afb7dffd2197049f7a12de7da1944937d66", + "stitch-ui-design-spec-layui": "17e40c8816d549781bdce846b321c0e40984ab0fa8e2168664ce869297fc8961", + "stitch-ui-design-spec-uview": "718bef0126049b004ef812079b29205212ff0e526e86d4d1bdf35b962dc46eb1", + "stitch-ui-design-spec-uviewpro": "5c27bc5509cf91376f08faed268cccbc51c600de22e70c01634846aa393fa1d6", + "stitch-ui-design-spec-vant": "4d5279caadbb66276ad9bb63e09966b146bcd38225d349a212c3c2fb8190884c", + "stitch-ui-design-variants": "9780d17c728202d53f0ffdf8c5c879b314ba24793e60f0bbc7de67e248ebd899", + "stitch-ui-designer": "fa3ef131db547f62f6eaadb9c6b2cdf2e96444d8f7a971a91b78d2489961e975", + "stitch-ui-prompt-architect": "7e8916e574f6f0c080fedbe9b2df088acd67dfcf32471684b4ee341f3cc2be1e", + "stitch-upload-to-stitch": "7735867bf4f4be28b999a2533986f0ad1478696aee02fec66b6a08d7bf20ff9c", + "stitch-uview-components": "7f6e92e954a0798f4e6963d2e3b0990f856784262c9e9d7cdfd0192b2f2ef127", + "stitch-uview-plus-components": "25204c84a9165858add4b5100eaaccbccb3dd5bc73ab673dade033575f5a8c7d", + "stitch-uviewpro-components": "1ba765e34f287b0aa08461ecac45e8891e47ccb8ee904c67858a6745663c53cf", + "stitch-vue-bootstrap-components": "f0f844769d097f752dbb720b4a03cd70849a7ad343643cd2405c4bcf6049818a", + "stitch-vue-element-components": "fea1c6437ae67514d362f03a65f460b3742c3d6ff8ba373e8a46a152ec6d2199", + "stitch-vue-layui-components": "da34df2ec54d7bf0f707a712d15e669341a2b795120d88c5231e020288d2390d", + "stitch-vue-vant-components": "b7294ec6c621069a9343e24382d404de4bc6cddf2b7ba40c571c9a055441bf77" + } + } + ] +} diff --git a/skills/stitch-delete-project/SKILL.md b/skills/stitch-delete-project/SKILL.md index f90ce77..07550bc 100644 --- a/skills/stitch-delete-project/SKILL.md +++ b/skills/stitch-delete-project/SKILL.md @@ -1,5 +1,6 @@ --- name: stitch-delete-project +license: Apache-2.0 description: 安全删除一个明确指定的 Stitch 项目;仅在用户明确要求删除远程项目时使用,先预览完整资源名并再次取得批准,删除后只读对账。 --- diff --git a/skills/stitch-design-md/LICENSE.txt b/skills/stitch-design-md/LICENSE.txt index 10c3135..b31ddff 100644 --- a/skills/stitch-design-md/LICENSE.txt +++ b/skills/stitch-design-md/LICENSE.txt @@ -1 +1,201 @@ -Apache-2.0. See repository LICENSE. + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative + Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/skills/stitch-design-md/SKILL.md b/skills/stitch-design-md/SKILL.md index f3336bb..26321aa 100644 --- a/skills/stitch-design-md/SKILL.md +++ b/skills/stitch-design-md/SKILL.md @@ -4,6 +4,10 @@ description: 从 Stitch 屏幕或用户提供的 HTML/截图整理并验证语 license: Apache-2.0 --- +> **来源声明**:本技能包含源自 [google-labs-code/stitch-skills](https://github.com/google-labs-code/stitch-skills) +> (Apache License 2.0,完整文本见同目录 `LICENSE.txt`;上游为非 Google 官方支持产品)的内容。 +> 本项目对其进行了改编与整合,原内容版权归 Google LLC 及其贡献者所有。 + # Stitch 语义设计系统文档 ## 快速开始 diff --git a/skills/stitch-design-use/SKILL.md b/skills/stitch-design-use/SKILL.md index 5a70edf..3a3f8d9 100644 --- a/skills/stitch-design-use/SKILL.md +++ b/skills/stitch-design-use/SKILL.md @@ -1,5 +1,6 @@ --- name: stitch-design-use +license: Apache-2.0 description: Stitch Design 根路由;当用户要用 Stitch 完成从认证、读取、生成、设计系统、资产处理到完整交付的任务时,选择最窄的下游 Skill,不直接扩大远程写入授权。 --- diff --git a/skills/stitch-mcp-create-project/SKILL.md b/skills/stitch-mcp-create-project/SKILL.md index ce10419..918f8e4 100644 --- a/skills/stitch-mcp-create-project/SKILL.md +++ b/skills/stitch-mcp-create-project/SKILL.md @@ -1,7 +1,7 @@ --- name: stitch-mcp-create-project description: Creates a new Stitch project container. Use this when starting a new design task, app idea, or fresh workspace. -license: Complete terms in LICENSE.txt +license: Apache-2.0 --- diff --git a/skills/stitch-mcp-get-project/SKILL.md b/skills/stitch-mcp-get-project/SKILL.md index f9c1926..f54beaf 100644 --- a/skills/stitch-mcp-get-project/SKILL.md +++ b/skills/stitch-mcp-get-project/SKILL.md @@ -1,5 +1,6 @@ --- name: stitch-mcp-get-project +license: Apache-2.0 description: Retrieves the detailed metadata of a specific Stitch project. --- diff --git a/skills/stitch-mcp-get-screen/SKILL.md b/skills/stitch-mcp-get-screen/SKILL.md index c9f8f95..cbcb6dc 100644 --- a/skills/stitch-mcp-get-screen/SKILL.md +++ b/skills/stitch-mcp-get-screen/SKILL.md @@ -1,5 +1,6 @@ --- name: stitch-mcp-get-screen +license: Apache-2.0 description: Retrieves the full details of a specific screen, including HTML code. --- diff --git a/skills/stitch-mcp-list-projects/SKILL.md b/skills/stitch-mcp-list-projects/SKILL.md index 199aa4d..ad25c03 100644 --- a/skills/stitch-mcp-list-projects/SKILL.md +++ b/skills/stitch-mcp-list-projects/SKILL.md @@ -1,5 +1,6 @@ --- name: stitch-mcp-list-projects +license: Apache-2.0 description: Lists all Stitch projects accessible to the user. --- diff --git a/skills/stitch-mcp-list-screens/SKILL.md b/skills/stitch-mcp-list-screens/SKILL.md index 8551298..6132654 100644 --- a/skills/stitch-mcp-list-screens/SKILL.md +++ b/skills/stitch-mcp-list-screens/SKILL.md @@ -1,5 +1,6 @@ --- name: stitch-mcp-list-screens +license: Apache-2.0 description: Lists all screens contained within a specific project. --- diff --git a/skills/stitch-skill-creator/SKILL.md b/skills/stitch-skill-creator/SKILL.md index 1fe4ff6..fa2e225 100644 --- a/skills/stitch-skill-creator/SKILL.md +++ b/skills/stitch-skill-creator/SKILL.md @@ -1,7 +1,7 @@ --- name: stitch-skill-creator description: "A factory skill for creating new Stitch Scenario Skills. It enforces the \"Design First, Execute Last\" SOP and standard Stitch architectural patterns. Use this when you need to add support for a new domain e.g. \"Music Apps\" \"Social Networks\" to the Stitch ecosystem." -license: Complete terms in LICENSE.txt +license: Apache-2.0 allowed-tools: Read Write Bash --- diff --git a/skills/stitch-skill-creator/scripts/init_stitch_skill.py b/skills/stitch-skill-creator/scripts/init_stitch_skill.py index 25cf625..b5d5309 100755 --- a/skills/stitch-skill-creator/scripts/init_stitch_skill.py +++ b/skills/stitch-skill-creator/scripts/init_stitch_skill.py @@ -22,7 +22,7 @@ STITCH_SKILL_TEMPLATE = """--- name: {skill_name} description: Specialized prompt architect for {scenario_title} screens. -license: Complete terms in LICENSE.txt +license: Apache-2.0 --- # {scenario_title} Screen Designer diff --git a/skills/stitch-ued-guide/SKILL.md b/skills/stitch-ued-guide/SKILL.md index 5f9adde..9c84e24 100644 --- a/skills/stitch-ued-guide/SKILL.md +++ b/skills/stitch-ued-guide/SKILL.md @@ -1,5 +1,6 @@ --- name: stitch-ued-guide +license: Apache-2.0 description: UED guidelines, visual vocabulary, and prompt structure for Stitch. Use when the user asks about layout/style terms, device constraints, or when structuring/improving Stitch prompts; combine with stitch-ui-prompt-architect for vague→concrete prompts. --- diff --git a/skills/stitch-ui-design-spec-bootstrap/SKILL.md b/skills/stitch-ui-design-spec-bootstrap/SKILL.md index 2f8313c..fc26cb6 100644 --- a/skills/stitch-ui-design-spec-bootstrap/SKILL.md +++ b/skills/stitch-ui-design-spec-bootstrap/SKILL.md @@ -1,5 +1,6 @@ --- name: stitch-ui-design-spec-bootstrap +license: Apache-2.0 description: Bootstrap-Vue design spec for Stitch. Outputs hard-constraints prefix or selector JSON and assembled prompt. --- diff --git a/skills/stitch-ui-design-spec-element-plus/SKILL.md b/skills/stitch-ui-design-spec-element-plus/SKILL.md index 271549f..7335a71 100644 --- a/skills/stitch-ui-design-spec-element-plus/SKILL.md +++ b/skills/stitch-ui-design-spec-element-plus/SKILL.md @@ -1,5 +1,6 @@ --- name: stitch-ui-design-spec-element-plus +license: Apache-2.0 description: Element Plus design spec for Stitch. Outputs hard-constraints prefix or selector JSON and assembled prompt. --- diff --git a/skills/stitch-ui-design-spec-generator/SKILL.md b/skills/stitch-ui-design-spec-generator/SKILL.md index 12eaa97..1620f50 100644 --- a/skills/stitch-ui-design-spec-generator/SKILL.md +++ b/skills/stitch-ui-design-spec-generator/SKILL.md @@ -1,5 +1,6 @@ --- name: stitch-ui-design-spec-generator +license: Apache-2.0 description: 将 Stitch 页面需求或 PRD 转为可审阅的本地 UI 设计规格;只产出设计决策,不调用远程写工具,也不冻结 MCP 模型、设备、字体或颜色变体枚举。 --- diff --git a/skills/stitch-ui-design-spec-layui/SKILL.md b/skills/stitch-ui-design-spec-layui/SKILL.md index 9750121..3427246 100644 --- a/skills/stitch-ui-design-spec-layui/SKILL.md +++ b/skills/stitch-ui-design-spec-layui/SKILL.md @@ -1,5 +1,6 @@ --- name: stitch-ui-design-spec-layui +license: Apache-2.0 description: Layui-Vue design spec for Stitch. Outputs hard-constraints prefix or selector JSON and assembled prompt. --- diff --git a/skills/stitch-ui-design-spec-uview/SKILL.md b/skills/stitch-ui-design-spec-uview/SKILL.md index 4e6f5fd..42a3e3c 100644 --- a/skills/stitch-ui-design-spec-uview/SKILL.md +++ b/skills/stitch-ui-design-spec-uview/SKILL.md @@ -1,5 +1,6 @@ --- name: stitch-ui-design-spec-uview +license: Apache-2.0 description: uView 2 design spec for Stitch. Outputs hard-constraints prefix or selector JSON and assembled prompt. --- diff --git a/skills/stitch-ui-design-spec-uviewpro/SKILL.md b/skills/stitch-ui-design-spec-uviewpro/SKILL.md index c5cf183..a26bbf1 100644 --- a/skills/stitch-ui-design-spec-uviewpro/SKILL.md +++ b/skills/stitch-ui-design-spec-uviewpro/SKILL.md @@ -1,5 +1,6 @@ --- name: stitch-ui-design-spec-uviewpro +license: Apache-2.0 description: uView Pro design spec for Stitch. Outputs hard-constraints prefix or selector JSON and assembled prompt. --- diff --git a/skills/stitch-ui-design-spec-vant/SKILL.md b/skills/stitch-ui-design-spec-vant/SKILL.md index c27070c..4aad22e 100644 --- a/skills/stitch-ui-design-spec-vant/SKILL.md +++ b/skills/stitch-ui-design-spec-vant/SKILL.md @@ -1,5 +1,6 @@ --- name: stitch-ui-design-spec-vant +license: Apache-2.0 description: Vant 4 design spec for Stitch. Outputs hard-constraints prefix or selector JSON and assembled prompt. --- diff --git a/skills/stitch-ui-design-variants/SKILL.md b/skills/stitch-ui-design-variants/SKILL.md index d4f2b11..716add7 100644 --- a/skills/stitch-ui-design-variants/SKILL.md +++ b/skills/stitch-ui-design-variants/SKILL.md @@ -1,5 +1,6 @@ --- name: stitch-ui-design-variants +license: Apache-2.0 description: Logic skill that generates prompts for alternative design variants e.g. A B testing options. --- diff --git a/skills/stitch-ui-prompt-architect/LICENSE.txt b/skills/stitch-ui-prompt-architect/LICENSE.txt index d9a10c0..b31ddff 100644 --- a/skills/stitch-ui-prompt-architect/LICENSE.txt +++ b/skills/stitch-ui-prompt-architect/LICENSE.txt @@ -91,34 +91,34 @@ modifications, and in Source or Object form, provided that You meet the following conditions: - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and + (a) You must give any other recipients of the Work or Derivative + Works a copy of this License; and (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and + stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions @@ -174,3 +174,28 @@ of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/skills/stitch-ui-prompt-architect/SKILL.md b/skills/stitch-ui-prompt-architect/SKILL.md index 22242a5..07758dc 100644 --- a/skills/stitch-ui-prompt-architect/SKILL.md +++ b/skills/stitch-ui-prompt-architect/SKILL.md @@ -4,6 +4,10 @@ description: 润色模糊 UI 需求或将已有 Design Spec/框架契约转为 S license: Apache-2.0 --- +> **来源声明**:本技能包含源自 [google-labs-code/stitch-skills](https://github.com/google-labs-code/stitch-skills) +> (Apache License 2.0,完整文本见同目录 `LICENSE.txt`;上游为非 Google 官方支持产品)的内容。 +> 本项目对其进行了改编与整合,原内容版权归 Google LLC 及其贡献者所有。 + # Stitch 提示词设计 ## 快速开始 diff --git a/skills/stitch-uview-components/SKILL.md b/skills/stitch-uview-components/SKILL.md index 84931e5..e64aa6d 100644 --- a/skills/stitch-uview-components/SKILL.md +++ b/skills/stitch-uview-components/SKILL.md @@ -1,5 +1,6 @@ --- name: stitch-uview-components +license: Apache-2.0 description: Convert Stitch designs into uni-app and Vue 2 and uView 2.0 pages and components. Uses Stitch MCP get_screen for retrieval; high-reliability fetch via scripts; enforces uni-app page structure and uView 2 u-* component contracts. --- diff --git a/skills/stitch-uview-plus-components/SKILL.md b/skills/stitch-uview-plus-components/SKILL.md index 63fb504..0be386a 100644 --- a/skills/stitch-uview-plus-components/SKILL.md +++ b/skills/stitch-uview-plus-components/SKILL.md @@ -1,5 +1,6 @@ --- name: stitch-uview-plus-components +license: Apache-2.0 description: "Convert Stitch designs into uni-app + Vue 3 + uview-plus pages and components. Use when the user mentions Stitch, uview-plus, up- components, or uni-app Vue 3 conversion targeting uview-plus. Retrieves screen HTML via Stitch MCP get_screen, rewrites Tailwind structure into up-* components, and enforces stronger setup, theme, safe-area, tabbar, popup, upload, and dark-mode rules than the generic uView Pro conversion skill." --- diff --git a/skills/stitch-uviewpro-components/SKILL.md b/skills/stitch-uviewpro-components/SKILL.md index aa4524e..7b64528 100644 --- a/skills/stitch-uviewpro-components/SKILL.md +++ b/skills/stitch-uviewpro-components/SKILL.md @@ -1,5 +1,6 @@ --- name: stitch-uviewpro-components +license: Apache-2.0 description: Convert Stitch designs into uni-app and Vue 3 and uView Pro pages and components. Uses Stitch MCP get_screen for retrieval; high-reliability fetch via scripts; enforces uni-app page structure and uView Pro u-* component contracts, rpx, script setup. --- diff --git a/skills/stitch-vue-bootstrap-components/SKILL.md b/skills/stitch-vue-bootstrap-components/SKILL.md index f1c8312..d43eff4 100644 --- a/skills/stitch-vue-bootstrap-components/SKILL.md +++ b/skills/stitch-vue-bootstrap-components/SKILL.md @@ -1,5 +1,6 @@ --- name: stitch-vue-bootstrap-components +license: Apache-2.0 description: Convert Stitch designs into modular Vite/Vue 3 and BootstrapVue or BootstrapVueNext components. Uses [BootstrapVue Vue 3] support; Stitch MCP get_screen for retrieval; high-reliability fetch via scripts; enforces Vue SFC structure and Bootstrap component contracts b-container b-row b-button etc.. --- diff --git a/skills/stitch-vue-element-components/SKILL.md b/skills/stitch-vue-element-components/SKILL.md index be428e9..b8a6a55 100644 --- a/skills/stitch-vue-element-components/SKILL.md +++ b/skills/stitch-vue-element-components/SKILL.md @@ -1,5 +1,6 @@ --- name: stitch-vue-element-components +license: Apache-2.0 description: Convert Stitch designs into modular Vite/Vue 3 and Element Plus components. Uses Stitch MCP get_screen to retrieve design JSON and HTML; supports high-reliability fetch via scripts; enforces Vue SFC structure, data decoupling, and Element Plus component contracts. --- diff --git a/skills/stitch-vue-layui-components/SKILL.md b/skills/stitch-vue-layui-components/SKILL.md index ae39f94..03599d8 100644 --- a/skills/stitch-vue-layui-components/SKILL.md +++ b/skills/stitch-vue-layui-components/SKILL.md @@ -1,5 +1,6 @@ --- name: stitch-vue-layui-components +license: Apache-2.0 description: Convert Stitch designs into modular Vite/Vue 3 and Layui-Vue components. Uses Stitch MCP get_screen for retrieval; high-reliability fetch via scripts; enforces Vue SFC structure and Layui-Vue component contracts layui-btn layui-input lay-card etc.. --- diff --git a/skills/stitch-vue-vant-components/SKILL.md b/skills/stitch-vue-vant-components/SKILL.md index 97b08e4..d11ab88 100644 --- a/skills/stitch-vue-vant-components/SKILL.md +++ b/skills/stitch-vue-vant-components/SKILL.md @@ -1,5 +1,6 @@ --- name: stitch-vue-vant-components +license: Apache-2.0 description: Convert Stitch designs into modular Vite/Vue 3 and Vant 4 mobile components. Uses Stitch MCP get_screen for retrieval; high-reliability fetch via scripts; enforces Vue SFC structure and Vant 4 component contracts van-button van-field van-nav-bar etc.. --- diff --git a/tests/test_skill_vendor.py b/tests/test_skill_vendor.py new file mode 100644 index 0000000..82fd720 --- /dev/null +++ b/tests/test_skill_vendor.py @@ -0,0 +1,152 @@ +"""Self-tests for scripts/vendor/skill_vendor.py. + +Every test builds a throwaway upstream git repository and a consumer tree in a +temporary directory, then drives the vendor script as a subprocess exactly the +way CI does. The tamper cases are mutation checks: a gate that cannot detect +them proves nothing. +""" +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "vendor" / "skill_vendor.py" + +GIT_ENV = { + **os.environ, + "GIT_AUTHOR_NAME": "test", + "GIT_AUTHOR_EMAIL": "test@example.com", + "GIT_COMMITTER_NAME": "test", + "GIT_COMMITTER_EMAIL": "test@example.com", +} + + +def run_git(args, cwd): + subprocess.run(["git", *args], cwd=cwd, env=GIT_ENV, check=True, + capture_output=True, text=True) + + +def make_upstream(base: Path, skills: dict[str, str]) -> Path: + """Create an upstream skill package repo with the given {name: body}.""" + upstream = base / "upstream" + for name, body in skills.items(): + skill_dir = upstream / "skills" / name + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + f"---\nname: {name}\ndescription: {body}\n---\n\n# {name}\n" + ) + run_git(["init", "--quiet"], upstream) + run_git(["add", "-A"], upstream) + run_git(["commit", "--quiet", "-m", "initial"], upstream) + run_git(["tag", "-a", "v1.0.0", "-m", "release"], upstream) + return upstream + + +def make_consumer(base: Path, upstream: Path, skills: list[str]) -> Path: + consumer = base / "consumer" + consumer.mkdir() + (consumer / "skills").mkdir() + lock = { + "version": 1, + "sources": [{ + "package": "demo-skills", + "repo": str(upstream), + "ref": "v1.0.0", + "sha": "", + "skills": skills, + "dest": "skills/", + "sha256": {}, + }], + } + (consumer / "skills.lock.json").write_text(json.dumps(lock, indent=2)) + return consumer + + +def vendor(command, consumer: Path, *extra): + return subprocess.run( + [sys.executable, str(SCRIPT), command, "--lock", "skills.lock.json", *extra], + cwd=consumer, capture_output=True, text=True, + ) + + +class SkillVendorTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.base = Path(self.tmp.name) + + def tearDown(self): + self.tmp.cleanup() + + def test_update_then_check_roundtrip(self): + upstream = make_upstream(self.base, {"demo-one": "first demo skill gate", + "demo-two": "second demo skill gate"}) + consumer = make_consumer(self.base, upstream, ["demo-one", "demo-two"]) + result = vendor("update", consumer) + self.assertEqual(result.returncode, 0, result.stderr + result.stdout) + for name in ("demo-one", "demo-two"): + self.assertTrue((consumer / "skills" / name / "SKILL.md").is_file()) + lock = json.loads((consumer / "skills.lock.json").read_text()) + source = lock["sources"][0] + self.assertTrue(source["sha"]) + self.assertEqual({"demo-one", "demo-two"}, set(source["sha256"])) + self.assertEqual(vendor("check", consumer, "--offline").returncode, 0) + self.assertEqual(vendor("check", consumer).returncode, 0) + + def test_check_detects_in_tree_tampering(self): + upstream = make_upstream(self.base, {"demo-one": "first demo skill gate"}) + consumer = make_consumer(self.base, upstream, ["demo-one"]) + self.assertEqual(vendor("update", consumer).returncode, 0) + target = consumer / "skills" / "demo-one" / "SKILL.md" + target.write_text(target.read_text() + "local edit\n") + result = vendor("check", consumer, "--offline") + self.assertEqual(result.returncode, 1) + self.assertIn("differs from the lockfile digest", result.stdout) + + def test_check_detects_upstream_movement_and_update_resyncs(self): + upstream = make_upstream(self.base, {"demo-one": "first demo skill gate"}) + consumer = make_consumer(self.base, upstream, ["demo-one"]) + self.assertEqual(vendor("update", consumer).returncode, 0) + skill = upstream / "skills" / "demo-one" / "SKILL.md" + skill.write_text(skill.read_text() + "upstream change\n") + run_git(["add", "-A"], upstream) + run_git(["commit", "--quiet", "-m", "change"], upstream) + run_git(["tag", "-f", "-a", "v1.0.0", "-m", "release"], upstream) + result = vendor("check", consumer) + self.assertEqual(result.returncode, 1) + self.assertIn("moved", result.stdout) + self.assertEqual(vendor("update", consumer).returncode, 0) + self.assertEqual(vendor("check", consumer).returncode, 0) + self.assertIn("upstream change", + (consumer / "skills" / "demo-one" / "SKILL.md").read_text()) + + def test_update_fails_when_skill_missing_upstream(self): + upstream = make_upstream(self.base, {"demo-one": "first demo skill gate"}) + consumer = make_consumer(self.base, upstream, ["demo-one", "demo-ghost"]) + result = vendor("update", consumer) + self.assertEqual(result.returncode, 1) + self.assertIn("demo-ghost", result.stdout) + + def test_illegal_skill_name_is_rejected(self): + upstream = make_upstream(self.base, {"demo-one": "first demo skill gate"}) + consumer = make_consumer(self.base, upstream, [".."]) + result = vendor("update", consumer) + self.assertEqual(result.returncode, 1) + self.assertIn("illegal skill name", result.stdout) + + def test_check_fails_when_managed_skill_deleted(self): + upstream = make_upstream(self.base, {"demo-one": "first demo skill gate"}) + consumer = make_consumer(self.base, upstream, ["demo-one"]) + self.assertEqual(vendor("update", consumer).returncode, 0) + import shutil + shutil.rmtree(consumer / "skills" / "demo-one") + result = vendor("check", consumer, "--offline") + self.assertEqual(result.returncode, 1) + self.assertIn("missing from the tree", result.stdout) + + +if __name__ == "__main__": + unittest.main()