Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "sofa",
"displayName": "Stack Overflow for Agents",
"version": "0.1.2",
"version": "0.1.3",
"description": "Search and contribute to hosted Stack Overflow for Agents knowledge through SOFA skills and MCP tools.",
"author": {
"name": "Stack Overflow",
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,6 @@ Check for an updated plugin version through the installation surface and install

## Package source and checks

SOFA's canonical skill text lives in the [SOFA source repository](https://github.com/StackEng/sofa). From a clean SOFA checkout, run `uv run python plugins/sofa/scripts/export_claude_plugin.py /path/to/this-checkout` to export `skills/sofa/SKILL.md`, `skills/sofa-contribute/SKILL.md`, `skills/sofa-status/SKILL.md`, and `plugin-guidance.json`. Those four files are generated and must not be edited here. This repository authors `.claude-plugin/plugin.json`, `.mcp.json`, this README, and package validation. Run `python3 -B scripts/check_package.py` to check the local tree, configuration, and common credential assignment patterns. This is a narrow check, not a comprehensive secret scan. The SOFA exporter is the source of truth for generated bytes. CI also runs `claude plugin validate --strict .` with the current Claude Code release. Versions before 2.1.281 report the directory listing URL fields as unknown and do not provide the current MCP validation checks.
SOFA's canonical skill text lives in the [SOFA source repository](https://github.com/StackEng/sofa). From a clean SOFA checkout, run `uv run python plugins/sofa/scripts/export_claude_plugin.py /path/to/this-checkout` to export `skills/sofa/SKILL.md`, `skills/sofa-contribute/SKILL.md`, `skills/sofa-status/SKILL.md`, and `plugin-guidance.json`. The exporter also writes an identical `skills/sofa-status/plugin-guidance.json` companion for hosts that expose only skill directories. Both declarations retain the same guidance identity and skill digest. Those five files are generated and must not be edited here. This repository authors `.claude-plugin/plugin.json`, `.mcp.json`, this README, and package validation. Run `python3 -B scripts/check_package.py` to check the local tree, configuration, and common credential assignment patterns. This is a narrow check, not a comprehensive secret scan. The SOFA exporter is the source of truth for generated bytes. CI also runs `claude plugin validate --strict .` with the current Claude Code release. Versions before 2.1.281 report the directory listing URL fields as unknown and do not provide the current MCP validation checks.

This plugin is licensed under the proprietary terms in [LICENSE](LICENSE), adapted from the [Stack Internal plugin license](https://github.com/StackExchange/stack-internal-plugin/blob/main/LICENSE). The manifest omits `license` because Anthropic defines that field as an SPDX identifier. Hosted Claude qualification and directory submission are separate later steps.
11 changes: 9 additions & 2 deletions scripts/check_package.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@
ROOT = Path(__file__).resolve().parents[1]
SKILLS = ("sofa", "sofa-contribute", "sofa-status")
MCP_URL = "https://agents.stackoverflow.com/mcp"
SKILL_GUIDANCE = "skills/sofa-status/plugin-guidance.json"
VERSIONED_CONTENT = (
".mcp.json",
"plugin-guidance.json",
SKILL_GUIDANCE,
*(f"skills/{name}/SKILL.md" for name in SKILLS),
)
PACKAGE_DIRECTORIES = {
Expand All @@ -34,6 +36,7 @@
"LICENSE",
"README.md",
"plugin-guidance.json",
SKILL_GUIDANCE,
"scripts/check_package.py",
"scripts/test_check_package.py",
*(f"skills/{name}/SKILL.md" for name in SKILLS),
Expand Down Expand Up @@ -113,8 +116,9 @@ def check_package() -> None:
skill_dir = skills_dir / name
if skill_dir.is_symlink() or not skill_dir.is_dir():
raise ValueError(f"skill must be a directory: {name}")
if {path.name for path in skill_dir.iterdir()} != {"SKILL.md"}:
raise ValueError(f"skill must contain only SKILL.md: {name}")
expected = {"SKILL.md", "plugin-guidance.json"} if name == "sofa-status" else {"SKILL.md"}
if {path.name for path in skill_dir.iterdir()} != expected:
raise ValueError(f"unexpected skill contents: {name}")
if not require_regular(f"skills/{name}/SKILL.md").read_text(encoding="utf-8").strip():
raise ValueError(f"skill is empty: {name}")

Expand Down Expand Up @@ -142,6 +146,9 @@ def check_package() -> None:
if mcp != {"mcpServers": {"sofa": {"type": "http", "url": MCP_URL}}}:
raise ValueError("MCP config must contain only the fixed hosted SOFA server")

root_guidance = require_regular("plugin-guidance.json").read_bytes()
if require_regular(SKILL_GUIDANCE).read_bytes() != root_guidance:
raise ValueError("root and skill guidance declarations must match byte-for-byte")
guidance = read_json("plugin-guidance.json")
if guidance.get("surface") != "public_directory":
raise ValueError("plugin guidance must target the public directory surface")
Expand Down
34 changes: 34 additions & 0 deletions scripts/test_check_package.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ def setUp(self) -> None:
for filename in (".mcp.json", "LICENSE", "README.md", "plugin-guidance.json"):
shutil.copy2(source / filename, self.root / filename)

self.companion = self.root / "skills/sofa-status/plugin-guidance.json"

def check_staged_package(self) -> None:
with patch.object(check_package, "ROOT", self.root):
check_package.check_package()
Expand Down Expand Up @@ -60,6 +62,37 @@ def run_version_check(self, base_sha: str) -> subprocess.CompletedProcess[str]:
def test_intact_package_with_license(self) -> None:
self.check_staged_package()

def test_missing_skill_guidance_is_rejected(self) -> None:
self.companion.unlink()
with self.assertRaisesRegex(ValueError, "missing package files"):
self.check_staged_package()

def test_mismatched_skill_guidance_is_rejected(self) -> None:
self.companion.write_text('{"version":"0.0.0"}\n')
with self.assertRaisesRegex(ValueError, "guidance declarations must match"):
self.check_staged_package()

def test_linked_skill_guidance_is_rejected(self) -> None:
self.companion.unlink()
self.companion.symlink_to(self.root / "plugin-guidance.json")
with self.assertRaisesRegex(ValueError, "linked package path"):
self.check_staged_package()

def test_adding_skill_guidance_requires_version_bump(self) -> None:
declaration = self.companion.read_bytes()
self.companion.unlink()
base_sha = self.commit_baseline()
self.companion.write_bytes(declaration)
subprocess.run(
["git", "add", "skills/sofa-status/plugin-guidance.json"],
cwd=self.root, check=True,
)
result = self.run_version_check(base_sha)
self.assertNotEqual(result.returncode, 0)
self.assertIn("higher manifest version", result.stderr)
self.set_version("0.2.0")
self.assertEqual(self.run_version_check(base_sha).returncode, 0)

def test_missing_license_is_rejected(self) -> None:
(self.root / "LICENSE").unlink()
with self.assertRaisesRegex(ValueError, "missing package files: .*LICENSE"):
Expand Down Expand Up @@ -113,6 +146,7 @@ def test_numeric_version_increase_allows_changed_guidance(self) -> None:
base_sha = self.commit_baseline()
guidance = self.root / "plugin-guidance.json"
guidance.write_text(guidance.read_text(encoding="utf-8") + "\n", encoding="utf-8")
self.companion.write_bytes(guidance.read_bytes())
self.set_version("0.1.10")

self.assertEqual(self.run_version_check(base_sha).returncode, 0)
Expand Down
5 changes: 5 additions & 0 deletions skills/sofa-status/plugin-guidance.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"version": "1.1.2",
"digest": "sha256:d5177da83d19a5053aef7d45c3aad64b53d308f0b15c6dddbcb50ad11d5477e3",
"surface": "public_directory"
}
Loading