From 0bd7c800185c5c63d241dab3d001d5fb3fe1e369 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 19 Oct 2026 21:46:11 +0300 Subject: [PATCH 1/3] Add repository marketplaces and docs skills endpoint builder --- .agents/plugins/marketplace.json | 12 ++ .claude-plugin/marketplace.json | 14 +++ .gitignore | 2 + README.md | 104 +++++++++++++++++- .../2026-10-19-marketplace-installs.md | 49 +++++++++ scripts/build_skills_index.py | 72 ++++++++++++ scripts/validate.py | 25 +++++ 7 files changed, 272 insertions(+), 6 deletions(-) create mode 100644 .agents/plugins/marketplace.json create mode 100644 .claude-plugin/marketplace.json create mode 100644 .gitignore create mode 100644 internal/agent-sessions/2026-10-19-marketplace-installs.md create mode 100644 scripts/build_skills_index.py diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 0000000..4fdcf1a --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,12 @@ +{ + "name": "polymorfa", + "interface": {"displayName": "Polymorfa"}, + "plugins": [ + { + "name": "polymorfa", + "source": {"source": "local", "path": "./"}, + "policy": {"installation": "AVAILABLE", "authentication": "ON_INSTALL"}, + "category": "Developer Tools" + } + ] +} diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..f7a1cb1 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,14 @@ +{ + "name": "polymorfa", + "owner": { + "name": "Polymorfa" + }, + "plugins": [ + { + "name": "polymorfa", + "source": "./", + "description": "Set up Polymorfa integrations using maintained documentation." + } + ], + "description": "Skills and documentation tools for Polymorfa integrations." +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ca90fc0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/dist/ +__pycache__/ diff --git a/README.md b/README.md index 9b18d45..1aa1aed 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,107 @@ Skills and plugin manifests for coding agents integrating Polymorfa. MIT license ## Install -- Claude Code: clone this repository and run `claude --plugin-dir /absolute/path/to/agent-kit`. -- Gemini CLI: run `gemini extensions install https://github.com/polymorfa/agent-kit`. -- Agent Plugins clients: load the repository root containing `plugin.json` through the client's plugin installer. -- Other skill clients: install the folders under `skills/` through the client's skill installer. Preserve unrelated skills and configuration. +### Claude Code -Cursor packaging lives in `.cursor-plugin/`. Marketplace publication and native client acceptance are separate from manifest validation. +After the marketplace change reaches `main`: + +```sh +claude plugin marketplace add polymorfa/agent-kit +claude plugin install polymorfa@polymorfa +``` + +For a local checkout, replace `polymorfa/agent-kit` with its absolute path. To load without installing, run `claude --plugin-dir /absolute/path/to/agent-kit`. + +### Codex CLI + +Codex supports Git marketplaces. With a CLI version that includes `codex plugin`: + +```sh +codex plugin marketplace add polymorfa/agent-kit +codex plugin add polymorfa@polymorfa +``` + +Start a new session to use the skills and MCP tools. The catalog is `.agents/plugins/marketplace.json`; the existing root `plugin.json`, `mcp.json`, and `skills/` provide the portable plugin. A separate `.codex-plugin/plugin.json` is unnecessary. This uses Polymorfa's repository marketplace, with no OpenAI-curated listing implied. The IDE extension does not support plugins; use the skills-only route below there. + +These commands require the catalog on `main`. Local verification used Codex CLI 0.163.0. See OpenAI's [plugin packaging and marketplace guide](https://developers.openai.com/plugins/build/plugins) and [supported plugin surfaces](https://learn.chatgpt.com/docs/plugins). + +### Gemini CLI + +```sh +gemini extensions install https://github.com/polymorfa/agent-kit +``` + +The extension uses `gemini-extension.json` and `GEMINI.md`. Native Gemini acceptance has not been run here. + +### Cursor + +Cursor packaging lives in `.cursor-plugin/`. For a published plugin, open Customize, find the plugin, and select Install with project or user scope, as described in [Cursor's plugin guide](https://cursor.com/docs/plugins). Marketplace publication is a separate step; this repository does not establish a published Cursor listing. Until a listing is available, install the skills into your project: + +```sh +npx skills add https://github.com/polymorfa/agent-kit --agent cursor +``` + +This installs skills only. It does not configure the bundled documentation MCP. Native Cursor plugin acceptance has not been run here. + +### Skills only + +From the target project directory: + +```sh +npx skills add https://github.com/polymorfa/agent-kit +``` + +Choose the target agent and skills when prompted. Project installation is the default; `--global` changes user configuration. Preserve unrelated skills and configuration. Other skill clients can copy the folders under `skills/` through their skill installer. + +Once the docs site serves the generated endpoint, this command will also work: + +```sh +npx skills add https://docs.polymorfa.com +``` + +The domain command requires docs deployment. Generating files in this repository does not publish them. Read [agent setup](https://docs.polymorfa.com/agent-setup/prompt.md) to add the CLI or authenticated API MCP. Existing API credentials remain the supported authentication method; do not configure an unimplemented OAuth login. +## Build the docs skills endpoint + +```sh +python3 scripts/build_skills_index.py +``` + +The builder writes `dist/well-known-skills/index.json` and `/` for every file in each `skills//` folder, including nested references. The index format matches [Stripe's live skills index](https://docs.stripe.com/.well-known/skills/index.json): + +```json +{ + "skills": [ + { + "name": "polymorfa", + "description": "Set up Polymorfa tooling or build a WhatsApp integration with its API, SDK, CLI, or MCP tools.", + "files": ["SKILL.md"] + } + ] +} +``` + +The real index includes both bundled skills. Files are fetched relative to `/.well-known/skills//`, for example `/.well-known/skills/polymorfa/SKILL.md`. + +Generated output is ignored by Git. The docs build should use a pinned agent-kit revision, run the builder, and copy the contents of `dist/well-known-skills/` to the site's `public/.well-known/skills/` directory (or equivalent static root). Serve the index as JSON and the skill files as their original bytes without authentication or HTML fallbacks. This keeps `skills/` as the source and avoids stale committed copies. Docs integration and deployment are outside this repository change. + +The builder supports this repository's plain, single-line `name` and `description` frontmatter. It rejects unsupported metadata and symlinks rather than publishing an ambiguous index. + ## Validate -Run `python3 scripts/validate.py`, then check plugin loading and documentation search in your agent. +```sh +python3 scripts/validate.py +claude plugin validate . +``` + +The Python validator checks the plugin manifests, both marketplaces, and the rebuilt endpoint's metadata, complete file inventory, and byte equality. For an isolated Claude check, set `CLAUDE_CONFIG_DIR` to a throwaway absolute directory. For Codex use an isolated `CODEX_HOME`. + +To test the HTTP endpoint locally, copy the generated directory to a test server root at `.well-known/skills/`, serve that root, and run this from a throwaway project directory: + +```sh +npx skills add http://localhost:18743 --agent codex --skill '*' --yes --copy +``` + +Omit `--global` so the install stays in that project. Verify the installed skill files against their sources. Plugin installation, MCP discovery, authenticated API access, and deployed availability are separate checks. diff --git a/internal/agent-sessions/2026-10-19-marketplace-installs.md b/internal/agent-sessions/2026-10-19-marketplace-installs.md new file mode 100644 index 0000000..c183a9d --- /dev/null +++ b/internal/agent-sessions/2026-10-19-marketplace-installs.md @@ -0,0 +1,49 @@ +# Marketplace install implementation + +Date: 2026-10-19. Collaborator: purpshell. Agent: Codex, gpt-6.1-sol, through T3 Code. The thread identifier was not supplied to this session. This is a sanitized checkpoint of visible task instructions and tool results, not a complete transcript. No hidden reasoning, credentials, or customer data is included. + +## Scope and source revision + +The owner requested Claude and Codex repository marketplace installs, a Stripe-compatible docs skills endpoint builder, validator coverage, README instructions, and a PR against main. No merge, publication, workflow enablement, or automated review request is authorized. The fresh clone started from main at `09df11ff9ae6acecc76bea77e3d8fc9ebf5f0269`. No contribution guide or AGENTS.md exists in this repository. Work stayed in `~/.polymorfa-agent-work/agent-kit-marketplace/` on `feat/marketplace-installs`. + +The OpenAI Docs skill was loaded for official source research. The originating monorepo's writing and checkout preservation rules were supplied by the user. No implementation delegation or automated reviewer was used. Roadmap records were not supplied or reconciled; this checkpoint does not claim Linear or Notion synchronization. + +## Schema evidence + +- [Anthropic marketplace guide](https://code.claude.com/docs/en/plugin-marketplaces): `.claude-plugin/marketplace.json` requires a name, owner, and plugin entries with name and source. Relative sources resolve from the marketplace root. The entry here uses `./`. +- [OpenAI packaging guide](https://developers.openai.com/plugins/build/plugins): `.agents/plugins/marketplace.json` declares the catalog; `source.path` resolves from its repository root. Root `plugin.json`, `mcp.json`, and `skills/` are supported portable components. No Codex overlay is needed. The guide documents `codex plugin marketplace add owner/repo` and Git ref selection. +- [OpenAI plugin surfaces](https://learn.chatgpt.com/docs/plugins): Codex CLI supports marketplace plugins and requires a new session after installation. The IDE extension does not support plugins. +- Installed `codex-cli 0.163.0`: `codex --help` lists `plugin`; `codex plugin marketplace add --help` accepts local paths, owner/repo with optional ref, HTTPS and SSH Git URLs; `codex plugin add --help` accepts `PLUGIN@MARKETPLACE`. +- [Stripe's live index](https://docs.stripe.com/.well-known/skills/index.json) was fetched. Its shape is `{"skills":[{"name":"...","description":"...","files":["SKILL.md","references/..."]}]}`. Fetching [a listed skill](https://docs.stripe.com/.well-known/skills/stripe-best-practices/SKILL.md) returned Markdown frontmatter, confirming the relative file layout. Stripe content was not copied into this repository. +- [Cursor plugin guide](https://cursor.com/docs/plugins): native marketplace install uses Customize and an Install action. Marketplace listing requires separate publication. This PR does not submit a listing. + +## Verification + +All installation writes used task-specific directories outside the user's real agent configuration. The local source is the fresh clone, including this patch. + +- `python3 scripts/validate.py`: passed. Rebuilds and validates the skills endpoint alongside existing plugin/MCP checks and the two catalogs. +- Claude Code `2.1.303`, with `CLAUDE_CONFIG_DIR` set to the absolute task `claude-config` directory: + - `claude plugin validate .`: passed without warnings. + - `claude plugin validate .claude-plugin/plugin.json`: passed. + - `claude plugin marketplace add `: added `polymorfa`. + - `claude plugin install polymorfa@polymorfa`: installed version 0.1.0. + - `claude plugin details polymorfa`: reported two skills (`polymorfa`, `polymorfa-webhooks`) and one MCP server (`polymorfa-docs`). This is component inventory, not a live MCP search. +- Codex CLI `0.163.0`, with `CODEX_HOME` set to the absolute task `codex-config` directory: + - `codex plugin marketplace add --json`: added `polymorfa`. + - `codex plugin add polymorfa@polymorfa --json`: installed 0.1.0 into the isolated plugin cache. + - `codex plugin list --json`: reported the plugin installed and enabled. +- `skills` npm CLI `1.7.2`: `npx --yes skills --help` confirms project install is the default, `--agent` selects the target, `--copy` avoids symlinks, and `--yes` skips prompts. No `--global` option was used. + - Copied `dist/well-known-skills/` to a task server root at `.well-known/skills/`. + - `python3 -m http.server 18743 --bind 127.0.0.1 --directory ` served it. + - From a throwaway task project, `npx --yes skills add http://localhost:18743 --agent codex --skill '*' --yes --copy` found and installed both skills under that project's `.agents/skills/`. + - Both installed SKILL.md files matched repository source bytes. +- Separate builder fixtures under the task directory: nested `references/guide.md` was included and copied; rebuilding removed a stale output file; validation rejected corrupted bytes; building rejected a source symlink. +- `git diff --check`: passed. + +## Availability and handoff + +Generated `dist/well-known-skills/` is ignored. The docs build must run the script at a pinned agent-kit revision and copy the generated contents to `public/.well-known/skills/` or an equivalent static root. No docs deployment was performed. The domain install command remains gated on that deployment. Default-branch repository marketplace installs remain gated on merging this PR. + +Gemini and Cursor executables were absent. Their native installs were not tested. The existing extension/plugin files and all skill contents remain unchanged. API, SDK, CLI product behavior, Admin, authentication, billing, and customer data are unaffected by these packaging changes. No workflow files were changed or re-enabled. + +A useful optional engineering article would compare manifest validation with real isolated installs, using the Claude component inventory and HTTP skill-byte checks as evidence. It would keep unmerged and undeployed availability explicit. diff --git a/scripts/build_skills_index.py b/scripts/build_skills_index.py new file mode 100644 index 0000000..1f87a49 --- /dev/null +++ b/scripts/build_skills_index.py @@ -0,0 +1,72 @@ +"""Build the static /.well-known/skills endpoint using only the standard library.""" + +import json +from pathlib import Path +import re +import shutil + +ROOT = Path(__file__).resolve().parents[1] +OUTPUT = ROOT / "dist" / "well-known-skills" + + +def skill_entries(skills_dir): + entries = [] + for directory in sorted(skills_dir.iterdir()): + if not directory.is_dir(): + continue + if directory.is_symlink() or not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", directory.name): + raise ValueError(f"Invalid skill directory: {directory}") + text = (directory / "SKILL.md").read_text(encoding="utf-8") + # This repository uses plain, single-line YAML metadata. Fail rather than + # silently misreading quoted or multiline YAML when that convention changes. + match = re.match(r"\A---\nname: ([a-z0-9-]+)\ndescription: ([^\n]+)\n---\n", text) + if not match or match[1] != directory.name or match[2][0] in "'\"|>{[&*!": + raise ValueError(f"Expected plain name and single-line description: {directory}") + files = [] + for path in sorted(directory.rglob("*")): + if path.is_symlink(): + raise ValueError(f"Symlinks cannot be published: {path}") + if path.is_file(): + files.append(path.relative_to(directory).as_posix()) + entries.append({"name": directory.name, "description": match[2], "files": files}) + if not entries: + raise ValueError("No skills found") + return entries + + +def build(skills_dir=ROOT / "skills", output=OUTPUT): + entries = skill_entries(skills_dir) + if output.is_symlink() or output.parent.is_symlink(): + raise ValueError(f"Output must not be a symlink: {output}") + if output.exists(): + shutil.rmtree(output) + output.mkdir(parents=True) + for entry in entries: + for filename in entry["files"]: + target = output / entry["name"] / filename + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(skills_dir / entry["name"] / filename, target) + (output / "index.json").write_text( + json.dumps({"skills": entries}, indent=2) + "\n", encoding="utf-8" + ) + return output + + +def validate_index(output=OUTPUT, skills_dir=ROOT / "skills"): + entries = skill_entries(skills_dir) + actual = json.loads((output / "index.json").read_text(encoding="utf-8")) + assert actual == {"skills": entries}, "Skills index differs from source metadata or files" + expected = {"index.json"} + for entry in entries: + for filename in entry["files"]: + relative = Path(entry["name"]) / filename + expected.add(relative.as_posix()) + assert (output / relative).read_bytes() == (skills_dir / relative).read_bytes(), relative + assert all(not path.is_symlink() for path in output.rglob("*")), "Symlink in output" + assert {path.relative_to(output).as_posix() for path in output.rglob("*") if path.is_file()} == expected + + +if __name__ == "__main__": + build() + validate_index() + print(f"Built and validated {OUTPUT}") diff --git a/scripts/validate.py b/scripts/validate.py index 15bff4e..def8f80 100644 --- a/scripts/validate.py +++ b/scripts/validate.py @@ -19,3 +19,28 @@ target = (root / relative).resolve() assert target.is_relative_to(root), (filename, field) assert target.is_file() if kind == "file" else target.is_dir(), (filename, field) + +# Each client has its own catalog format. Both resolve the plugin from repo root. +claude_marketplace = json.loads((root / ".claude-plugin/marketplace.json").read_text()) +assert claude_marketplace["name"] == "polymorfa" +assert claude_marketplace["owner"] == {"name": "Polymorfa"} +assert len(claude_marketplace["plugins"]) == 1 +assert claude_marketplace["plugins"][0]["name"] == "polymorfa" +assert claude_marketplace["plugins"][0]["source"] == "./" +codex_marketplace = json.loads((root / ".agents/plugins/marketplace.json").read_text()) +assert codex_marketplace == { + "name": "polymorfa", + "interface": {"displayName": "Polymorfa"}, + "plugins": [{ + "name": "polymorfa", + "source": {"source": "local", "path": "./"}, + "policy": {"installation": "AVAILABLE", "authentication": "ON_INSTALL"}, + "category": "Developer Tools", + }], +} + +from build_skills_index import build, validate_index + +build() +validate_index() +print("Claude and Codex marketplaces and generated skills index validated") From eec5dd450a4ba125d7eeb776bebae3b46cb55da6 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Mon, 19 Oct 2026 21:46:45 +0300 Subject: [PATCH 2/3] Record remote marketplace install acceptance --- .../2026-10-19-marketplace-installs.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/internal/agent-sessions/2026-10-19-marketplace-installs.md b/internal/agent-sessions/2026-10-19-marketplace-installs.md index c183a9d..30fb588 100644 --- a/internal/agent-sessions/2026-10-19-marketplace-installs.md +++ b/internal/agent-sessions/2026-10-19-marketplace-installs.md @@ -47,3 +47,19 @@ Generated `dist/well-known-skills/` is ignored. The docs build must run the scri Gemini and Cursor executables were absent. Their native installs were not tested. The existing extension/plugin files and all skill contents remain unchanged. API, SDK, CLI product behavior, Admin, authentication, billing, and customer data are unaffected by these packaging changes. No workflow files were changed or re-enabled. A useful optional engineering article would compare manifest validation with real isolated installs, using the Claude component inventory and HTTP skill-byte checks as evidence. It would keep unmerged and undeployed availability explicit. + +## Remote install checkpoint + +At pushed implementation revision `0bd7c800185c5c63d241dab3d001d5fb3fe1e369`, both Git-backed installs also passed in separate task configurations: + +```sh +CLAUDE_CONFIG_DIR=/claude-git-config claude plugin marketplace add 'https://github.com/polymorfa/agent-kit.git#feat/marketplace-installs' +CLAUDE_CONFIG_DIR=/claude-git-config claude plugin install polymorfa@polymorfa +CLAUDE_CONFIG_DIR=/claude-git-config claude plugin details polymorfa +CODEX_HOME=/codex-git-config codex plugin marketplace add polymorfa/agent-kit --ref feat/marketplace-installs --json +CODEX_HOME=/codex-git-config codex plugin add polymorfa@polymorfa --json +``` + +Claude cloned the selected branch and reported two skills and the documentation MCP server. Codex fetched the branch, installed version 0.1.0, and its cache contained the root portable manifest, MCP configuration, and both skill files. Remote and local branch heads matched that SHA. + +The skills-only Git path also passed from a second throwaway project: `npx --yes skills add https://github.com/polymorfa/agent-kit --agent cursor --skill '*' --yes --copy` installed both skills. This checks the skills installer with Cursor selected, not the native Cursor runtime. From e0ed733c8900c7912b7181634fba8a500519fae6 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 21 Oct 2026 11:18:17 +0300 Subject: [PATCH 3/3] Fix skills metadata validation and safe rebuilds --- .github/workflows/validate.yml | 1 + .../2026-10-21-marketplace-review-fixes.md | 27 +++++ scripts/build_skills_index.py | 15 ++- scripts/validate.py | 7 +- tests/test_skills_index.py | 99 +++++++++++++++++++ 5 files changed, 145 insertions(+), 4 deletions(-) create mode 100644 internal/agent-sessions/2026-10-21-marketplace-review-fixes.md create mode 100644 tests/test_skills_index.py diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index c892039..9bf366a 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -6,3 +6,4 @@ jobs: steps: - uses: actions/checkout@v4 - run: python3 scripts/validate.py + - run: python3 -m unittest discover -s tests -v diff --git a/internal/agent-sessions/2026-10-21-marketplace-review-fixes.md b/internal/agent-sessions/2026-10-21-marketplace-review-fixes.md new file mode 100644 index 0000000..0f52b6c --- /dev/null +++ b/internal/agent-sessions/2026-10-21-marketplace-review-fixes.md @@ -0,0 +1,27 @@ +# Marketplace review fixes + +Date: 2026-10-21. Collaborator: purpshell. Agent: Codex, gpt-6.1-sol, through T3 Code. This checkpoint records visible instructions and results. A thread identifier and complete transcript were not supplied. It contains no hidden reasoning, credentials or customer data. + +The owner authorized fixing unsolicited findings on agent-kit PR #2, committing and pushing normally, and checking hosted CI. Merge and review requests are prohibited. Work used a fresh clone under `~/.polymorfa-agent-work/agentkit2-findings/agent-kit`, on `feat/marketplace-installs`, starting at `eec5dd450a4ba125d7eeb776bebae3b46cb55da6`. No AGENTS.md exists in this repository or its local parent directories. README, both scripts, CI and all three inline review comments were read. The supplied writing policy was applied. No reviewer instructions were executed and no review was requested. + +## Verified findings + +- Codex comment 4317574028: fixed. The parser accepted descriptions containing YAML comments or mapping delimiters. It now rejects reserved initial characters, surrounding whitespace, whitespace followed by `#`, and `:` followed by whitespace or the end of the value. Ordinary URLs, URL fragments and embedded punctuation remain supported. +- Codex comment 4317574037: fixed. Malformed ignored JSON prevented validation from reaching its rebuild. The builder now runs before scanning JSON. A subprocess regression checks recovery from malformed index and stale generated JSON, then checks that a malformed source manifest still fails. +- CodeRabbit comment 4317588739: fixed. Equal, ancestor and descendant output paths could destroy source files. Resolved path containment is checked in both directions before deleting or creating output. Tests cover all overlap directions, a nonexistent descendant and a symlinked ancestor. + +## Verification + +- `gh api repos/polymorfa/agent-kit/pulls/2/comments` read all three findings as untrusted data. +- `git fetch origin` and `git rev-list --left-right --count HEAD...origin/main` returned `2 0`. Main remains `09df11ff9ae6acecc76bea77e3d8fc9ebf5f0269`, already an ancestor. No merge is needed. +- `python3 -m unittest discover -s tests -v` first reproduced all three defects against the original scripts. After fixes all six tests pass. Fixtures use task directories under `~/.polymorfa-agent-work/` and clean up only their own files. +- `python3 scripts/build_skills_index.py` and `python3 scripts/validate.py` pass. +- CI retains its validator step and adds the six regression tests. No checks were weakened. +- `git diff --check` passes. +- Built the original script from `git show eec5dd45:scripts/build_skills_index.py` into a separate task directory and compared every output file with the rebuilt endpoint. No content changed in `index.json`, `polymorfa/SKILL.md` or `polymorfa-webhooks/SKILL.md`. + +## Scope and availability + +Content changes are limited to `.github/workflows/validate.yml`, `scripts/build_skills_index.py`, `scripts/validate.py`, `tests/test_skills_index.py` and this checkpoint. Skill source files, generated output bytes, manifests and installer instructions remain unchanged. The docs site can continue using its pinned skill bytes; adopting these builder safeguards requires updating its pinned revision separately. + +No library, runner, Temporal, API, Admin/MCP, logging, Inspector, Console, SDK, product CLI, Help Center, marketing, billing, flag or release-contract behavior changed. No native installation retest is claimed for this Python-only follow-up. No docs deployment, roadmap synchronization, merge or publication was performed. Hosted CI results belong to the final pushed revision and are reported separately at handoff. diff --git a/scripts/build_skills_index.py b/scripts/build_skills_index.py index 1f87a49..f45ca94 100644 --- a/scripts/build_skills_index.py +++ b/scripts/build_skills_index.py @@ -20,7 +20,13 @@ def skill_entries(skills_dir): # This repository uses plain, single-line YAML metadata. Fail rather than # silently misreading quoted or multiline YAML when that convention changes. match = re.match(r"\A---\nname: ([a-z0-9-]+)\ndescription: ([^\n]+)\n---\n", text) - if not match or match[1] != directory.name or match[2][0] in "'\"|>{[&*!": + if ( + not match + or match[1] != directory.name + or match[2] != match[2].strip() + or match[2][0] in "-?:,[]{}#&*!|>'\"%@`" + or re.search(r"\s#|:(?:\s|$)", match[2]) + ): raise ValueError(f"Expected plain name and single-line description: {directory}") files = [] for path in sorted(directory.rglob("*")): @@ -38,6 +44,13 @@ def build(skills_dir=ROOT / "skills", output=OUTPUT): entries = skill_entries(skills_dir) if output.is_symlink() or output.parent.is_symlink(): raise ValueError(f"Output must not be a symlink: {output}") + resolved_output = output.resolve() + resolved_skills = skills_dir.resolve() + if ( + resolved_output.is_relative_to(resolved_skills) + or resolved_skills.is_relative_to(resolved_output) + ): + raise ValueError(f"Output must not overlap the skills directory: {output}") if output.exists(): shutil.rmtree(output) output.mkdir(parents=True) diff --git a/scripts/validate.py b/scripts/validate.py index def8f80..c27d813 100644 --- a/scripts/validate.py +++ b/scripts/validate.py @@ -1,5 +1,9 @@ import json from pathlib import Path +from build_skills_index import build, validate_index + +# Replace ignored build artifacts before the repository-wide JSON scan. +build() root = Path(__file__).resolve().parents[1] for path in root.rglob("*.json"): json.loads(path.read_text()) @@ -39,8 +43,5 @@ }], } -from build_skills_index import build, validate_index - -build() validate_index() print("Claude and Codex marketplaces and generated skills index validated") diff --git a/tests/test_skills_index.py b/tests/test_skills_index.py new file mode 100644 index 0000000..ca37b5f --- /dev/null +++ b/tests/test_skills_index.py @@ -0,0 +1,99 @@ +"""Regression checks for metadata parsing and safe endpoint rebuilds.""" + +import json +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) +from build_skills_index import build, skill_entries, validate_index + + +class SkillsIndexTests(unittest.TestCase): + def setUp(self): + scratch = Path.home() / ".polymorfa-agent-work" / "agentkit-tests" + scratch.mkdir(parents=True, exist_ok=True) + self.workspace = tempfile.TemporaryDirectory(dir=scratch) + self.addCleanup(self.workspace.cleanup) + self.root = Path(self.workspace.name) + self.skills = self.root / "skills" + self.skill = self.skills / "example" + self.skill.mkdir(parents=True) + self.write_skill("Set up the API.") + + def write_skill(self, description): + (self.skill / "SKILL.md").write_text( + f"---\nname: example\ndescription: {description}\n---\nBody.\n", + encoding="utf-8", + ) + + def test_rejects_yaml_syntax_in_descriptions(self): + descriptions = ["# fast", "Setup # fast", "Setup\t# fast", "Use API: safely", + "Use API:\tsafely", "Use API:", " leading", "trailing "] + descriptions += [character + "value" for character in "-?:,[]{}#&*!|>'\"%@`"] + for description in descriptions: + with self.subTest(description=description): + self.write_skill(description) + with self.assertRaises(ValueError): + skill_entries(self.skills) + + def test_accepts_plain_description_punctuation(self): + description = "Use https://example.com/#guide and C# with API:key." + self.write_skill(description) + output = build(self.skills, self.root / "output") + validate_index(output, self.skills) + self.assertEqual(json.loads((output / "index.json").read_text())["skills"][0]["description"], description) + + def test_rejects_overlapping_output_without_changing_sources(self): + source = (self.skill / "SKILL.md").read_bytes() + for output in (self.skills, self.root, self.skill, self.skills / "generated"): + with self.subTest(output=output): + with self.assertRaisesRegex(ValueError, "overlap"): + build(self.skills, output) + self.assertEqual((self.skill / "SKILL.md").read_bytes(), source) + self.assertFalse((self.skills / "generated").exists()) + + def test_rejects_overlap_through_symlinked_ancestor(self): + alias = self.root / "alias" + alias.symlink_to(self.root, target_is_directory=True) + with self.assertRaisesRegex(ValueError, "overlap"): + build(self.skills, alias / "skills" / "example" / "generated") + self.assertTrue((self.skill / "SKILL.md").is_file()) + + def test_rebuild_preserves_nested_files_and_removes_stale_files(self): + references = self.skill / "references" + references.mkdir() + (references / "guide.md").write_bytes(b"Nested reference.\n") + output = build(self.skills, self.root / "output") + (output / "stale.json").write_text("broken") + build(self.skills, output) + validate_index(output, self.skills) + self.assertFalse((output / "stale.json").exists()) + self.assertEqual((output / "example/references/guide.md").read_bytes(), b"Nested reference.\n") + + def test_validator_rebuilds_malformed_generated_json(self): + checkout = self.root / "checkout" + shutil.copytree(ROOT, checkout, ignore=shutil.ignore_patterns(".git", "dist", "__pycache__")) + output = checkout / "dist/well-known-skills" + output.mkdir(parents=True) + (output / "index.json").write_text("broken") + (output / "stale.json").write_text("also broken") + result = subprocess.run([sys.executable, str(checkout / "scripts/validate.py")], + capture_output=True, text=True, timeout=30) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("skills", json.loads((output / "index.json").read_text())) + self.assertFalse((output / "stale.json").exists()) + # Rebuilding generated state must not hide malformed source manifests. + (checkout / "plugin.json").write_text("broken") + result = subprocess.run([sys.executable, str(checkout / "scripts/validate.py")], + capture_output=True, text=True, timeout=30) + self.assertNotEqual(result.returncode, 0) + self.assertIn("JSONDecodeError", result.stderr) + + +if __name__ == "__main__": + unittest.main()