From 127b85417ac0827174f66ba4b818a97986090d9b Mon Sep 17 00:00:00 2001 From: Derek C Date: Sun, 20 Sep 2026 14:45:23 -0700 Subject: [PATCH] Validate plugin distribution and correct local installation --- .github/dependabot.yml | 9 ++++++++ .github/workflows/ci.yml | 20 +++++++++++++++++ .gitignore | 3 +++ README.md | 28 ++++++++++++++++-------- SECURITY.md | 7 ++++++ scripts/validate.py | 45 ++++++++++++++++++++++++++++++++++++++ tests/test_distribution.py | 24 ++++++++++++++++++++ 7 files changed, 127 insertions(+), 9 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 SECURITY.md create mode 100644 scripts/validate.py create mode 100644 tests/test_distribution.py diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..fecd44d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,9 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + groups: + actions: + patterns: ["*"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f50da18 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,20 @@ +name: CI +on: + push: + branches: [main] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Run offline regression tests + run: python -m unittest discover -s tests -v + - name: Check Python syntax + run: python -m compileall -q scripts tests diff --git a/.gitignore b/.gitignore index b2a8a38..c9b7298 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ .claude/worktrees/ .DS_Store + +__pycache__/ +*.pyc diff --git a/README.md b/README.md index 853304c..da70fc5 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ The machine never writes for you. It interviews you, then rearranges what you sa Three files make it yours: a profile, a voice guide built from studying your ten best posts, and a lessons file that accumulates every correction you make. The machine gets more like you with every piece. +See the [fictional founder example](example/personal/profile.md), its [interview transcript](example/personal/transcripts/2026-07-16-alert-theory.md), and the [resulting draft](example/personal/drafts/2026-07-16-alert-theory.md). + ## The pipeline ``` @@ -30,21 +32,17 @@ Three files make it yours: a profile, a voice guide built from studying your ten ## Install -**As a Claude Code plugin:** - -``` -/plugin install derekcedarbaum2/content-machine -``` - -**Or clone and use directly:** +Clone the plugin and load it explicitly in Claude Code: ```bash git clone https://github.com/derekcedarbaum2/content-machine.git cd content-machine -claude # skills are picked up from skills/ +claude --plugin-dir . ``` -Then run `/content-setup`. It builds your personal folder (default `~/.content-machine/`, relocatable), interviews you for your profile, and studies your top posts to write your voice guide. Nothing personal lives in this repo — the machine is shared, your voice is not. +Run `/content-machine:content-setup`. Plugin commands use the `content-machine:` namespace, so `/draft` in the workflow above becomes `/content-machine:draft`. + +It builds your personal folder (default `~/.content-machine/`, relocatable), interviews you for your profile, and studies your top posts to write your voice guide. Nothing personal lives in this repo — the machine is shared, your voice is not. ## What's where @@ -67,3 +65,15 @@ The Oracle reads whatever your Claude Code environment can reach — MCP servers ## License MIT. The architecture is Alex Lieberman's, described publicly on the podcast; this implementation and the rubrics are original work. + +## Support and validation + +Claude Code with local plugin support. The workflow is Markdown; other agents can read it, but plugin loading is verified only for the documented Claude Code layout. + +This is an independently maintained project. Report reproducible bugs through Issues; security reports follow [SECURITY.md](SECURITY.md). The latest release and default branch receive fixes, with no response-time guarantee. + +Run the local checks with: + +```bash +python3 -m unittest discover -s tests -v +``` diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..3097d1f --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,7 @@ +# Security policy + +Report suspected vulnerabilities through this repository's Security tab using “Report a vulnerability.” Include the affected version, reproduction steps, and likely impact. Use fictional data and redact credentials before submitting a report. + +Please keep exploit details and personal information out of public issues. If private reporting is unavailable, open an issue asking for a private reporting channel without disclosing the vulnerability. + +Security fixes target the latest release and the default branch. Older versions have no separate support commitment. This is an independently maintained project, so response times are not guaranteed. diff --git a/scripts/validate.py b/scripts/validate.py new file mode 100644 index 0000000..b2b2472 --- /dev/null +++ b/scripts/validate.py @@ -0,0 +1,45 @@ +"""Check the distributable plugin without calling a model or external service.""" +import json +from pathlib import Path +import re +import sys +from urllib.parse import unquote +SKILLS = {"content-machine", "content-setup", "draft", "interview", "lessons", "oracle", "repurpose"} + +def validate(root): + root = Path(root) + errors = [] + try: + manifest = json.loads((root / ".claude-plugin/plugin.json").read_text()) + if manifest.get("name") != "content-machine" or not re.fullmatch(r"\d+\.\d+\.\d+", manifest.get("version", "")): + errors.append("Plugin name or version is invalid") + except (OSError, ValueError) as error: + errors.append(f"Cannot read plugin manifest: {error}") + for name in sorted(SKILLS): + p = root / "skills" / name / "SKILL.md" + if not p.exists(): + errors.append(f"Missing skill: {name}") + continue + text = p.read_text() + if not text.startswith("---\n") or "description:" not in text.split("---", 2)[1]: + errors.append(f"Missing skill frontmatter: {name}") + for name in ("profile.md", "voice-guide.md", "sources.md", "content-lessons.md"): + if not (root / "templates/personal" / name).is_file(): + errors.append(f"Missing onboarding template: {name}") + for p in root.rglob("*.md"): + if ".git" in p.parts: + continue + for url in re.findall(r"\[[^\]]*\]\(([^\s)]+)\)", p.read_text()): + if re.match(r"[a-zA-Z][a-zA-Z0-9+.-]*:", url) or url.startswith(("#", "/", "~")): + continue + target = unquote(url.split("#")[0]) + if target and not (p.parent / target).exists(): + errors.append(f"Broken link in {p.relative_to(root)}: {target}") + return errors + +if __name__ == "__main__": + errors = validate(Path(__file__).resolve().parents[1]) + for error in errors: + print(error, file=sys.stderr) + print(f"Plugin validation: {len(errors)} error(s)") + raise SystemExit(bool(errors)) diff --git a/tests/test_distribution.py b/tests/test_distribution.py new file mode 100644 index 0000000..2a8e6d3 --- /dev/null +++ b/tests/test_distribution.py @@ -0,0 +1,24 @@ +import importlib.util +from pathlib import Path +import shutil +import tempfile +import unittest +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location("validate", ROOT / "scripts/validate.py") +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + +class DistributionTests(unittest.TestCase): + def test_shipped_plugin(self): + self.assertEqual(module.validate(ROOT), []) + + def test_missing_skill_and_broken_link_are_rejected(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + for folder in ("skills", "templates", ".claude-plugin"): + shutil.copytree(ROOT / folder, root / folder) + (root / "skills/draft/SKILL.md").unlink() + (root / "README.md").write_text("[missing](missing.md)\n") + errors = module.validate(root) + self.assertTrue(any("Missing skill: draft" in e for e in errors)) + self.assertTrue(any("missing.md" in e for e in errors))