From c913e1cf8933dccdedc4ab2687ad6ee38cf19c24 Mon Sep 17 00:00:00 2001 From: Trevin Chow Date: Fri, 15 May 2026 20:22:00 -0700 Subject: [PATCH 1/4] refactor(skill): SKILL.md-relative path resolver, drop Codex native plugin STEP 0 (CANONICAL PATH SELF-CHECK) used to force any SKILL.md load that wasn't under $HOME/.claude/plugins/cache/last30days-skill/last30days/{version}/ to re-Read from there. That guard is Claude-Code-specific (defends against the marketplaces/ stale-clone bug) and broke under non-Claude installers like `npx skills add`, ~/.codex/skills/, and ~/.agents/skills/. The new STEP 0 narrows the check to its actual target: fire only when the loaded SKILL.md path contains /.claude/plugins/marketplaces/. Every other install path is trusted. The 2026-04-22 incident workaround is preserved without breaking other harnesses. Step 1 SKILL_ROOT resolver collapses the Codex-first / Claude-fallback / CWD-fallback chain into a single precedence walk: Claude plugin cache (versioned) first, then ~/.codex/skills, ~/.agents/skills, repo checkout, ./.skills/last30days (npx skills install dir), CWD, and GEMINI_EXTENSION_DIR. Also drops Codex native plugin support: .codex-plugin/plugin.json is deleted, the badge VERSION jq fallback in line 108 stops looking at it, and render.py's _skill_version no longer scans for it. Codex users install via `npx skills add` or the per-harness skill dir going forward. render.py::_skill_version gains a SKILL.md frontmatter fallback so the badge no longer emits `v?` on install dirs that sync.sh populates (which don't include .claude-plugin/plugin.json). --- .codex-plugin/plugin.json | 43 ---------------- skills/last30days/SKILL.md | 65 +++++++++++++------------ skills/last30days/scripts/lib/render.py | 33 ++++++++----- tests/test_plugin_contract.py | 9 ---- 4 files changed, 54 insertions(+), 96 deletions(-) delete mode 100644 .codex-plugin/plugin.json diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json deleted file mode 100644 index 830c5b9..0000000 --- a/.codex-plugin/plugin.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "name": "last30days", - "version": "3.2.1", - "description": "Research any topic across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, GitHub, and 5+ more sources. AI agent scores by upvotes, likes, and real money - not editors.", - "author": { - "name": "Matt Van Horn", - "email": "mvanhorn@gmail.com", - "url": "https://github.com/mvanhorn" - }, - "homepage": "https://github.com/mvanhorn/last30days-skill", - "repository": "https://github.com/mvanhorn/last30days-skill", - "license": "MIT", - "keywords": [ - "research", - "reddit", - "twitter", - "youtube", - "tiktok", - "instagram", - "trends", - "polymarket", - "github", - "hacker-news" - ], - "skills": "./skills/", - "interface": { - "displayName": "Last 30 Days", - "shortDescription": "Research recent discussion across social and web sources", - "longDescription": "Research any topic across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, GitHub, and 5+ more sources. AI agent scores by upvotes, likes, and real money - not editors.", - "developerName": "Matt Van Horn", - "category": "Research", - "capabilities": [ - "Interactive", - "Read", - "Write" - ], - "websiteURL": "https://github.com/mvanhorn/last30days-skill", - "privacyPolicyURL": "https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement", - "termsOfServiceURL": "https://docs.github.com/en/site-policy/github-terms/github-terms-of-service", - "defaultPrompt": "Use Last 30 Days to research this topic from the last 30 days across Reddit, X, YouTube, and web.", - "brandColor": "#FF6B35" - } -} diff --git a/skills/last30days/SKILL.md b/skills/last30days/SKILL.md index a7db05f..6ad53a1 100644 --- a/skills/last30days/SKILL.md +++ b/skills/last30days/SKILL.md @@ -60,23 +60,22 @@ metadata: - clawhub --- -# STEP 0: CANONICAL PATH SELF-CHECK โ€” RUN BEFORE READING BELOW +# STEP 0: STALE-CLONE SELF-CHECK โ€” RUN BEFORE READING BELOW -Before reading anything else in this file, verify that the SKILL.md you just loaded is the versioned cache copy, not a stale marketplace clone: +Before reading anything else in this file, check whether you loaded SKILL.md from the one known stale-clone location: Claude Code's marketplaces directory. + +**Run this check:** ```bash -CANONICAL_ROOT="$HOME/.claude/plugins/cache/last30days-skill/last30days" -CANONICAL_LATEST=$(ls -d "$CANONICAL_ROOT"/*/ 2>/dev/null | sort -V | tail -1) -CANONICAL_LATEST="${CANONICAL_LATEST%/}" +CLAUDE_CACHE_LATEST=$(ls -d "$HOME/.claude/plugins/cache/last30days-skill/last30days"/*/ 2>/dev/null | sort -V | tail -1) +CLAUDE_CACHE_LATEST="${CLAUDE_CACHE_LATEST%/}" ``` -If the SKILL.md file you just read is NOT under `$CANONICAL_LATEST/`, STOP and re-read `$CANONICAL_LATEST/SKILL.md` before proceeding. Do not run `--help`, do not plan, do not invoke the engine against any other path. +If the SKILL.md path you just Read contains `/.claude/plugins/marketplaces/` AND `$CLAUDE_CACHE_LATEST` is non-empty, STOP and re-read `$CLAUDE_CACHE_LATEST/skills/last30days/SKILL.md` before proceeding. Otherwise the SKILL.md you have is fine โ€” continue. -**Why:** `~/.claude/plugins/marketplaces/last30days-skill/` is a git clone Claude Code auto-restores to `origin/main` on session start. It can lag the versioned cache by one or more releases. Three 2026-04-22 test runs (Linear, Coinbase) loaded SKILL.md from `marketplaces/`, ran `--help` from the same stale path, did not see the `--competitors` flag that existed in the cache, and fell back to a manual comparison plan. Result: 2 of 3 windows never invoked the feature they were asked to test. +**Why this specific check:** `~/.claude/plugins/marketplaces/last30days-skill/` is a git clone Claude Code auto-restores to `origin/main` on session start. It can lag the versioned cache by one or more releases. Three 2026-04-22 test runs (Linear, Coinbase) loaded SKILL.md from `marketplaces/`, ran `--help` from the same stale path, did not see the `--competitors` flag that existed in the cache, and fell back to a manual comparison plan. Result: 2 of 3 windows never invoked the feature they were asked to test. STEP 0 defends against that one Claude Code-specific bug. -**How to self-check:** the file path you used in your last Read tool call should match `$CANONICAL_LATEST/SKILL.md`. If it contains `marketplaces/` or any other prefix, that is the stale-path failure mode. Re-read from `$CANONICAL_LATEST/SKILL.md` and restart this contract from the top. - -The same pinned resolver appears later in Step 1 for the engine Bash invocation. That guard is necessary but insufficient โ€” by the time you reach Step 1, you may have already internalized an out-of-date flag list from the stale SKILL.md above it. This STEP 0 runs first so the CONTRACT itself is read from the right file. +**Other install paths are fine:** `~/.codex/skills/`, `~/.agents/skills/`, an `npx skills add` install dir, or a repo checkout are all valid load points - the resolver in Step 1 picks them up. Do NOT abort or hop on those paths. --- @@ -88,7 +87,7 @@ You are inside the `/last30days` SKILL. This is a specific research tool with a **How v3.0.7 fixes it:** three structural anchors. 1. **The MANDATORY first-line badge** (`๐ŸŒ last30days v{VERSION} ยท synced {YYYY-MM-DD}`) at the top of every response is the LAW 2 / LAW 4 enforcement anchor. See "BADGE (MANDATORY, FIRST LINE OF OUTPUT)" in the synthesis section. -2. **The pinned SKILL_ROOT resolution** in the engine Bash calls always points to the public plugin cache, never `~/.openclaw/` or other stale copies. +2. **The SKILL_ROOT resolver** in the engine Bash calls walks a precedence list of known install locations and picks the highest-versioned freshest copy, never `~/.openclaw/` or other stale copies. 3. **This preface** tells you plainly: do NOT improvise. Follow SKILL.md top to bottom. If you catch yourself about to write a `##` section header in a GENERAL-query body, a custom title line, a `Sources:` bullet list, a `for dir in ...` path-discovery loop, or a bare `python3 scripts/last30days.py "{TOPIC}"` engine call with no pre-flight flags โ€” stop. Those are the exact failure modes the LAWs and this contract exist to prevent. The 10/10 beta validation from 2026-04-18 and the 0/8 public v3.0.6 regression from the same day had THE SAME MODEL and SIMILAR SKILL.md CONTENT; the delta is the three anchors this release restores. Read SKILL.md top to bottom before emitting your first response. @@ -105,7 +104,7 @@ These anchors used to live at line 1094 of this file. Three independent Opus 4.7 ๐ŸŒ last30days v{VERSION} ยท synced {YYYY-MM-DD} ``` -Replace `{VERSION}` with the installed plugin version (`jq -r '.version' "$SKILL_ROOT/../../.codex-plugin/plugin.json" 2>/dev/null || jq -r '.version' "$SKILL_ROOT/.claude-plugin/plugin.json"`) and `{YYYY-MM-DD}` with today's date. No other text on this line. One blank line after, then the synthesis begins. +Replace `{VERSION}` with the installed plugin version (`jq -r '.version' "$SKILL_ROOT/../../.claude-plugin/plugin.json" 2>/dev/null || awk '/^version:/{gsub(/"/,"",$2); print $2; exit}' "$SKILL_ROOT/SKILL.md"`) and `{YYYY-MM-DD}` with today's date. No other text on this line. One blank line after, then the synthesis begins. **Why the badge is MANDATORY:** it is the structural anchor for the canonical output shape. Without it the model drifts into blog-post narrative format with `##` section headers and invented titles, violating LAW 2 and LAW 4. The 2026-04-18 public v3.0.6 0/8 regression produced outputs with section headers like "The headline", "Why he is everywhere", "1. gstack dominates", "The 'Homecoming' peak". Direct cause: this anchor was absent. Do NOT skip the badge. Do NOT describe it. Do NOT paraphrase it. Emit it verbatim as line 1. @@ -869,36 +868,38 @@ Store your plan as `QUERY_PLAN_JSON` - you'll pass it to the script in the next **IMPORTANT: Include `--x-handle={RESOLVED_HANDLE}` in the command. For comparison mode: Pass `--x-handle={TOPIC_A_HANDLE}` to the first pass, `--x-handle={TOPIC_B_HANDLE}` to the second pass, and both to the head-to-head pass. Also include `--subreddits={RESOLVED_SUBREDDITS}`, `--tiktok-hashtags={RESOLVED_HASHTAGS}`, `--tiktok-creators={RESOLVED_TIKTOK_CREATORS}`, and `--ig-creators={RESOLVED_IG_CREATORS}` from Step 0.55. Omit any flag where the value was not resolved (empty).** ```bash -# PIN SKILL_ROOT to an installed plugin cache first (highest-version dir wins on upgrade). -# Prefer Codex's skill package path when installed as a Codex plugin. Keep the Claude -# plugin-root fallback for other hosts, then fall back to a repo checkout. -SKILL_ROOT="$(ls -d "$HOME/.codex/plugins/cache/"*/last30days/*/skills/last30days/ 2>/dev/null | sort -V | tail -1)" -SKILL_ROOT="${SKILL_ROOT%/}" +# Resolve SKILL_ROOT by walking a precedence list of known install locations. +# Claude Code plugin cache wins when present (highest version dir picked on upgrade), +# then common per-harness skill dirs, then a repo checkout. +SKILL_ROOT="" -# Fallback for Claude plugin cache. -if [ -z "$SKILL_ROOT" ] || [ ! -f "$SKILL_ROOT/scripts/last30days.py" ]; then - CLAUDE_PLUGIN_ROOT="$(ls -d "$HOME/.claude/plugins/cache/last30days-skill/last30days/"*/ 2>/dev/null | sort -V | tail -1)" - CLAUDE_PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT%/}" - if [ -n "$CLAUDE_PLUGIN_ROOT" ]; then - if [ -f "$CLAUDE_PLUGIN_ROOT/skills/last30days/scripts/last30days.py" ]; then - SKILL_ROOT="$CLAUDE_PLUGIN_ROOT/skills/last30days" - elif [ -f "$CLAUDE_PLUGIN_ROOT/scripts/last30days.py" ]; then - SKILL_ROOT="$CLAUDE_PLUGIN_ROOT" - fi +# 1. Claude Code plugin cache (versioned). Both shapes ship in the wild โ€” pick the freshest. +CLAUDE_PLUGIN_ROOT="$(ls -d "$HOME/.claude/plugins/cache/last30days-skill/last30days/"*/ 2>/dev/null | sort -V | tail -1)" +CLAUDE_PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT%/}" +if [ -n "$CLAUDE_PLUGIN_ROOT" ]; then + if [ -f "$CLAUDE_PLUGIN_ROOT/skills/last30days/scripts/last30days.py" ]; then + SKILL_ROOT="$CLAUDE_PLUGIN_ROOT/skills/last30days" + elif [ -f "$CLAUDE_PLUGIN_ROOT/scripts/last30days.py" ]; then + SKILL_ROOT="$CLAUDE_PLUGIN_ROOT" fi fi -# Fallback for repo checkout / Gemini / local development hosts where the plugin cache does not exist. +# 2. Common per-harness skill dirs and repo checkout (npx skills, Codex, Agents, Gemini, etc). if [ -z "$SKILL_ROOT" ] || [ ! -f "$SKILL_ROOT/scripts/last30days.py" ]; then - for dir in "." "./skills/last30days" "${CLAUDE_PLUGIN_ROOT:-}" "${GEMINI_EXTENSION_DIR:-}"; do + for dir in \ + "$HOME/.codex/skills/last30days" \ + "$HOME/.agents/skills/last30days" \ + "./skills/last30days" \ + "./.skills/last30days" \ + "." \ + "${GEMINI_EXTENSION_DIR:-}"; do [ -n "$dir" ] && [ -f "$dir/scripts/last30days.py" ] && SKILL_ROOT="$dir" && break done fi if [ -z "${SKILL_ROOT:-}" ] || [ ! -f "$SKILL_ROOT/scripts/last30days.py" ]; then - echo "ERROR: Could not find scripts/last30days.py in Codex/Claude plugin cache or repo checkout" >&2 - echo "Expected Codex: $HOME/.codex/plugins/cache/{MARKETPLACE}/last30days/{VERSION}/skills/last30days/scripts/last30days.py" >&2 - echo "Expected Claude: $HOME/.claude/plugins/cache/last30days-skill/last30days/{VERSION}/skills/last30days/scripts/last30days.py" >&2 + echo "ERROR: Could not find scripts/last30days.py in any known install location" >&2 + echo "Searched: ~/.claude/plugins/cache/, ~/.codex/skills/, ~/.agents/skills/, ./skills/last30days, ./.skills/last30days, ." >&2 exit 1 fi diff --git a/skills/last30days/scripts/lib/render.py b/skills/last30days/scripts/lib/render.py index fdd24f8..5728b4e 100644 --- a/skills/last30days/scripts/lib/render.py +++ b/skills/last30days/scripts/lib/render.py @@ -12,21 +12,30 @@ from . import dates, schema def _skill_version() -> str: - """Read plugin version from a plugin manifest if available. + """Read plugin version from .claude-plugin/plugin.json, falling back to SKILL.md frontmatter. - Tries nearest plugin.json by walking up from render.py's own location. - Falls back to "?" if not found. This keeps the badge emission from - crashing on non-plugin-cache installs (repo checkout, Gemini, Codex). + sync.sh does not copy .claude-plugin/ to non-cache install dirs (~/.codex/skills, + ~/.agents/skills, Hermes), so SKILL.md frontmatter is the fallback that keeps the + badge from emitting v? on those installs. Returns "?" only if both sources are missing. """ + import re + here = pathlib.Path(__file__).resolve() - for parent in [here.parent, *here.parents]: - for manifest_dir in (".codex-plugin", ".claude-plugin"): - candidate = parent / manifest_dir / "plugin.json" - if candidate.is_file(): - try: - return json.loads(candidate.read_text()).get("version", "?") - except (json.JSONDecodeError, OSError): - return "?" + for parent in here.parents: + manifest = parent / ".claude-plugin" / "plugin.json" + if manifest.is_file(): + try: + return json.loads(manifest.read_text()).get("version", "?") + except (json.JSONDecodeError, OSError): + break + + for parent in here.parents: + skill_md = parent / "SKILL.md" + if skill_md.is_file(): + match = re.search(r'^version:\s*"([^"]+)"\s*$', skill_md.read_text(), re.MULTILINE) + if match: + return match.group(1) + break return "?" diff --git a/tests/test_plugin_contract.py b/tests/test_plugin_contract.py index 52708cb..36bfed9 100644 --- a/tests/test_plugin_contract.py +++ b/tests/test_plugin_contract.py @@ -22,20 +22,11 @@ def _skill_version() -> str: class TestPluginContract(unittest.TestCase): - def test_codex_manifest_points_at_skills_tree(self) -> None: - manifest = _json(ROOT / ".codex-plugin" / "plugin.json") - - self.assertEqual("last30days", manifest["name"]) - self.assertEqual("./skills/", manifest["skills"]) - self.assertTrue(SKILL_ROOT.joinpath("SKILL.md").is_file()) - self.assertTrue(SKILL_ROOT.joinpath("scripts", "last30days.py").is_file()) - def test_versions_match_across_manifests(self) -> None: pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) version = pyproject["project"]["version"] self.assertEqual(version, _skill_version()) - self.assertEqual(version, _json(ROOT / ".codex-plugin" / "plugin.json")["version"]) self.assertEqual(version, _json(ROOT / ".claude-plugin" / "plugin.json")["version"]) marketplace = _json(ROOT / ".claude-plugin" / "marketplace.json") From 997708ad48e697c470b82f72b17e2e96ab97a653 Mon Sep 17 00:00:00 2001 From: Trevin Chow Date: Fri, 15 May 2026 21:12:59 -0700 Subject: [PATCH 2/4] =?UTF-8?q?refactor(skill):=20apply=20ce-code-review?= =?UTF-8?q?=20fixes=20=E2=80=94=20bump=20to=203.2.2,=20fallback=20tests,?= =?UTF-8?q?=20comparison=20resolver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 12 fixes from the multi-agent code review on PR #400: Version 3.2.1 -> 3.2.2 across all manifests (SKILL.md frontmatter + body header, pyproject.toml, .claude-plugin/{plugin,marketplace}.json, sync.sh cache path). The PR ships observable behavior changes (STEP 0 logic flip, resolver order change, badge fallback) that should not silently appear under the same version number โ€” the new fallback reads SKILL.md version directly so the badge would otherwise be misleading. render.py::_skill_version: - `import re` moved to module top - _VERSION_RE extracted as a module-level compiled pattern that accepts double-quoted, single-quoted, and unquoted YAML version scalars - `break` -> `continue` on corrupt manifest, so a corrupt inner manifest no longer shadows a valid outer one - Wrap SKILL.md read_text() in try/except for UnicodeDecodeError to keep badge emission from crashing on mis-encoded SKILL.md - Docstring clarifies precedence; inline comment marks the fallback boundary between the manifest walk and the SKILL.md walk tests/test_skill_version.py (new): 7 unit tests for the fallback paths (manifest absent, manifest corrupt, corrupt-inner + valid-outer, both absent, SKILL.md without version, single-quoted, unquoted). tests/test_plugin_contract.py: tombstone test asserting .codex-plugin/ stays removed (was the only CI guard against accidental reintroduction). SKILL.md: - STEP 0 bash echoes CLAUDE_CACHE_LATEST so the model can see the resolved value when deciding whether to hop - "Both shapes ship in the wild" comment now names the two cache layouts (nested {cache}/{version}/skills/last30days/ vs flat {cache}/{version}/) - Comparison-mode bash invocation gets its own inline SKILL_ROOT resolver (latent gap: the contract tells the model to skip Step 1 on comparison queries, so SKILL_ROOT was previously unset there) CHANGELOG.md: [Unreleased] entries for the resolver rewrite and the breaking removal of Codex native-plugin support. All 9 reviewer personas surfaced findings; 3 cross-reviewer corroboration clusters were promoted (import re, "both shapes" comment, missing fallback tests). Maintainability follow-up flagged: regex now duplicated across render.py and 2 test files; could consolidate via shared lib/skill_meta.py helper in a future PR. --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- CHANGELOG.md | 5 ++ pyproject.toml | 2 +- skills/last30days/SKILL.md | 32 ++++++- skills/last30days/scripts/lib/render.py | 23 +++-- skills/last30days/scripts/sync.sh | 2 +- tests/test_plugin_contract.py | 6 ++ tests/test_skill_version.py | 113 ++++++++++++++++++++++++ uv.lock | 2 +- 10 files changed, 176 insertions(+), 13 deletions(-) create mode 100644 tests/test_skill_version.py diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index b975d61..54e78b5 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -11,7 +11,7 @@ { "name": "last30days", "description": "Research any topic across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, GitHub, and 5+ more sources. AI agent scores by upvotes, likes, and real money - not editors.", - "version": "3.2.1", + "version": "3.2.2", "author": { "name": "Matt Van Horn", "url": "https://github.com/mvanhorn" diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 86f7fb9..7859abc 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "last30days", - "version": "3.2.1", + "version": "3.2.2", "description": "Research any topic across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, GitHub, and 5+ more sources. AI agent scores by upvotes, likes, and real money - not editors.", "author": { "name": "Matt Van Horn", diff --git a/CHANGELOG.md b/CHANGELOG.md index b051bff..388e4b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Rename "Digg AI 1000" to just "Digg" in user-facing output (footer line, source label, inline-quote suffix, why_relevant, container attribution). Internal references to the upstream Digg AI 1000 product remain in code comments and docstrings. - Bump `POSTS_PER_CLUSTER` from 3 to 5 and the render-side display limit from 2 to 3 to match the per-source enrichment caps used by Reddit, HN, YouTube, TikTok, and GitHub. The previous 3/2 caps routinely truncated cluster context (e.g. dropped a Jason Calacanis quote tweet on a `cli-printing-press` run). +- Rewrite SKILL.md path resolution. STEP 0 narrows from a global canonical-path enforcement to a Claude-Code-marketplaces-only stale-clone guard. Step 1 SKILL_ROOT resolver walks a single precedence list (Claude plugin cache, then `~/.codex/skills/`, `~/.agents/skills/`, repo checkout, `./.skills/last30days` for `npx skills add`, CWD, Gemini). Adds SKILL.md frontmatter fallback to `render.py::_skill_version` so the badge no longer prints `v?` on installs that don't include `.claude-plugin/plugin.json`. + +### Removed + +- **BREAKING for Codex native-plugin users:** `.codex-plugin/plugin.json` and the matching SKILL_ROOT resolver branch in SKILL.md Step 1. Codex users should install via `npx skills add mvanhorn/last30days-skill` or copy the skill to `~/.codex/skills/last30days/` (which `sync.sh` already writes to in this repo). ## [3.2.0] - 2026-05-09 diff --git a/pyproject.toml b/pyproject.toml index c1d174c..2a77d94 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "last30days-skill" -version = "3.2.1" +version = "3.2.2" description = "Multi-source last-30-days research skill" readme = "README.md" requires-python = ">=3.12" diff --git a/skills/last30days/SKILL.md b/skills/last30days/SKILL.md index 6ad53a1..1422be6 100644 --- a/skills/last30days/SKILL.md +++ b/skills/last30days/SKILL.md @@ -1,6 +1,6 @@ --- name: last30days -version: "3.2.1" +version: "3.2.2" description: "Research what people actually say about any topic in the last 30 days. Pulls posts and engagement from Reddit, X, YouTube, TikTok, Hacker News, Polymarket, GitHub, and the web." argument-hint: 'last30days nvidia earnings reaction | last30days AI video tools | last30days what users want in react' allowed-tools: Bash, Read, Write, AskUserQuestion, WebSearch @@ -69,6 +69,7 @@ Before reading anything else in this file, check whether you loaded SKILL.md fro ```bash CLAUDE_CACHE_LATEST=$(ls -d "$HOME/.claude/plugins/cache/last30days-skill/last30days"/*/ 2>/dev/null | sort -V | tail -1) CLAUDE_CACHE_LATEST="${CLAUDE_CACHE_LATEST%/}" +echo "CLAUDE_CACHE_LATEST=$CLAUDE_CACHE_LATEST" ``` If the SKILL.md path you just Read contains `/.claude/plugins/marketplaces/` AND `$CLAUDE_CACHE_LATEST` is non-empty, STOP and re-read `$CLAUDE_CACHE_LATEST/skills/last30days/SKILL.md` before proceeding. Otherwise the SKILL.md you have is fine โ€” continue. @@ -233,7 +234,7 @@ If your Bash call to `last30days.py` does NOT include the FULL pre-flight checkl --- -# last30days v3.2.1: Research Any Topic from the Last 30 Days +# last30days v3.2.2: Research Any Topic from the Last 30 Days > **Permissions overview:** Reads public web/platform data and optionally saves research briefings to `LAST30DAYS_MEMORY_DIR` (defaults to `~/Documents/Last30Days`). X/Twitter search uses optional user-provided tokens (AUTH_TOKEN/CT0 env vars). Bluesky search uses optional app password (BSKY_HANDLE/BSKY_APP_PASSWORD env vars - create at bsky.app/settings/app-passwords). All credential usage and data writes are documented in the [Security & Permissions](#security--permissions) section. @@ -582,6 +583,30 @@ When the user asks "X vs Y" (or "X vs Y vs Z"), the engine fans out N full `pipe **Invocation:** ```bash +# Comparison mode skips Step 1, so resolve SKILL_ROOT inline here (same precedence +# walk as Step 1 โ€” keep the two in sync if you edit either). +SKILL_ROOT="" +CLAUDE_PLUGIN_ROOT="$(ls -d "$HOME/.claude/plugins/cache/last30days-skill/last30days/"*/ 2>/dev/null | sort -V | tail -1)" +CLAUDE_PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT%/}" +if [ -n "$CLAUDE_PLUGIN_ROOT" ]; then + if [ -f "$CLAUDE_PLUGIN_ROOT/skills/last30days/scripts/last30days.py" ]; then + SKILL_ROOT="$CLAUDE_PLUGIN_ROOT/skills/last30days" + elif [ -f "$CLAUDE_PLUGIN_ROOT/scripts/last30days.py" ]; then + SKILL_ROOT="$CLAUDE_PLUGIN_ROOT" + fi +fi +if [ -z "$SKILL_ROOT" ] || [ ! -f "$SKILL_ROOT/scripts/last30days.py" ]; then + for dir in \ + "$HOME/.codex/skills/last30days" \ + "$HOME/.agents/skills/last30days" \ + "./skills/last30days" \ + "./.skills/last30days" \ + "." \ + "${GEMINI_EXTENSION_DIR:-}"; do + [ -n "$dir" ] && [ -f "$dir/scripts/last30days.py" ] && SKILL_ROOT="$dir" && break + done +fi + "${LAST30DAYS_PYTHON}" "${SKILL_ROOT}/scripts/last30days.py" "{TOPIC_A} vs {TOPIC_B} vs {TOPIC_C}" \ --emit=compact \ --save-dir="${LAST30DAYS_MEMORY_DIR}" \ @@ -873,7 +898,8 @@ Store your plan as `QUERY_PLAN_JSON` - you'll pass it to the script in the next # then common per-harness skill dirs, then a repo checkout. SKILL_ROOT="" -# 1. Claude Code plugin cache (versioned). Both shapes ship in the wild โ€” pick the freshest. +# 1. Claude Code plugin cache (versioned, sort -V picks freshest). Two cache layouts ship in the wild: +# nested ({cache}/{version}/skills/last30days/scripts/...) and flat ({cache}/{version}/scripts/...). CLAUDE_PLUGIN_ROOT="$(ls -d "$HOME/.claude/plugins/cache/last30days-skill/last30days/"*/ 2>/dev/null | sort -V | tail -1)" CLAUDE_PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT%/}" if [ -n "$CLAUDE_PLUGIN_ROOT" ]; then diff --git a/skills/last30days/scripts/lib/render.py b/skills/last30days/scripts/lib/render.py index 5728b4e..f4c977e 100644 --- a/skills/last30days/scripts/lib/render.py +++ b/skills/last30days/scripts/lib/render.py @@ -4,6 +4,7 @@ from __future__ import annotations import json import pathlib +import re from collections import Counter from datetime import date from urllib.parse import urlparse @@ -11,15 +12,23 @@ from urllib.parse import urlparse from . import dates, schema +_VERSION_RE = re.compile( + r'''^version:\s*(?:"([^"]+)"|'([^']+)'|(\S+))\s*$''', + re.MULTILINE, +) + + def _skill_version() -> str: """Read plugin version from .claude-plugin/plugin.json, falling back to SKILL.md frontmatter. sync.sh does not copy .claude-plugin/ to non-cache install dirs (~/.codex/skills, ~/.agents/skills, Hermes), so SKILL.md frontmatter is the fallback that keeps the badge from emitting v? on those installs. Returns "?" only if both sources are missing. - """ - import re + A corrupt manifest at one ancestor does not shadow a valid manifest at a deeper one + (continue, not break). YAML frontmatter accepts double-quoted, single-quoted, or + unquoted version scalars. + """ here = pathlib.Path(__file__).resolve() for parent in here.parents: manifest = parent / ".claude-plugin" / "plugin.json" @@ -27,14 +36,18 @@ def _skill_version() -> str: try: return json.loads(manifest.read_text()).get("version", "?") except (json.JSONDecodeError, OSError): - break + continue + # No manifest found at any ancestor โ€” fall back to SKILL.md frontmatter. for parent in here.parents: skill_md = parent / "SKILL.md" if skill_md.is_file(): - match = re.search(r'^version:\s*"([^"]+)"\s*$', skill_md.read_text(), re.MULTILINE) + try: + match = _VERSION_RE.search(skill_md.read_text()) + except (OSError, UnicodeDecodeError): + break if match: - return match.group(1) + return next(g for g in match.groups() if g is not None) break return "?" diff --git a/skills/last30days/scripts/sync.sh b/skills/last30days/scripts/sync.sh index 6ec3bba..eca793b 100755 --- a/skills/last30days/scripts/sync.sh +++ b/skills/last30days/scripts/sync.sh @@ -15,7 +15,7 @@ COMMON_TARGETS=( # against the working tree so /last30days reflects local dev without # waiting for a release. Do NOT add ~/.claude/skills/last30days - it # creates a duplicate slash-command entry alongside the plugin version. - "$HOME/.claude/plugins/cache/last30days-skill/last30days/3.2.1" + "$HOME/.claude/plugins/cache/last30days-skill/last30days/3.2.2" "$HOME/.agents/skills/last30days" "$HOME/.codex/skills/last30days" ) diff --git a/tests/test_plugin_contract.py b/tests/test_plugin_contract.py index 36bfed9..6e8480e 100644 --- a/tests/test_plugin_contract.py +++ b/tests/test_plugin_contract.py @@ -22,6 +22,12 @@ def _skill_version() -> str: class TestPluginContract(unittest.TestCase): + def test_codex_plugin_scaffold_stays_removed(self) -> None: + # .codex-plugin/ was removed in the resolver-collapse refactor; Codex users + # install via `npx skills add` or `~/.codex/skills/`. A reintroduction would + # silently fork the install surface. + self.assertFalse((ROOT / ".codex-plugin").exists()) + def test_versions_match_across_manifests(self) -> None: pyproject = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) version = pyproject["project"]["version"] diff --git a/tests/test_skill_version.py b/tests/test_skill_version.py new file mode 100644 index 0000000..d36d16e --- /dev/null +++ b/tests/test_skill_version.py @@ -0,0 +1,113 @@ +"""Unit tests for render._skill_version() fallback paths. + +The function reads version from .claude-plugin/plugin.json first, then falls back +to SKILL.md frontmatter. These tests use monkeypatch to swap the render module's +__file__ attribute, which controls where the walk starts. +""" + +import sys +import unittest +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "skills" / "last30days" / "scripts")) + +from lib import render + + +class SkillVersionFallbackTests(unittest.TestCase): + def setUp(self): + # tmp_path equivalent for unittest + import tempfile + self._tmp = tempfile.TemporaryDirectory() + self.tmp_path = Path(self._tmp.name) + + def tearDown(self): + self._tmp.cleanup() + + def _make_render_at(self, parent: Path) -> Path: + """Place a dummy render.py inside parent and return its path.""" + parent.mkdir(parents=True, exist_ok=True) + fake_render = parent / "render.py" + fake_render.write_text("") + return fake_render + + def _write_manifest(self, parent: Path, version: str | None) -> None: + """Write .claude-plugin/plugin.json under parent. version=None writes corrupt JSON.""" + d = parent / ".claude-plugin" + d.mkdir(parents=True, exist_ok=True) + if version is None: + (d / "plugin.json").write_text("{not valid json") + else: + (d / "plugin.json").write_text(f'{{"version": "{version}"}}') + + def _write_skill_md(self, parent: Path, frontmatter_version_line: str | None) -> None: + """Write SKILL.md with frontmatter. None writes a SKILL.md with no version line.""" + if frontmatter_version_line is None: + body = "---\nname: test\n---\n# body\n" + else: + body = f"---\nname: test\n{frontmatter_version_line}\n---\n# body\n" + (parent / "SKILL.md").write_text(body) + + def test_manifest_absent_falls_back_to_skill_md_frontmatter(self): + skill_dir = self.tmp_path / "skill_root" + fake_render = self._make_render_at(skill_dir) + self._write_skill_md(skill_dir, 'version: "9.9.9"') + + with patch.object(render, "__file__", str(fake_render)): + self.assertEqual("9.9.9", render._skill_version()) + + def test_manifest_corrupt_falls_back_to_skill_md_frontmatter(self): + skill_dir = self.tmp_path / "skill_root" + fake_render = self._make_render_at(skill_dir) + self._write_manifest(skill_dir, version=None) # corrupt + self._write_skill_md(skill_dir, 'version: "8.8.8"') + + with patch.object(render, "__file__", str(fake_render)): + self.assertEqual("8.8.8", render._skill_version()) + + def test_corrupt_inner_manifest_does_not_shadow_valid_outer_manifest(self): + outer = self.tmp_path / "outer" + inner = outer / "skill_root" + fake_render = self._make_render_at(inner) + self._write_manifest(inner, version=None) # corrupt at inner + self._write_manifest(outer, version="7.7.7") # valid at outer + + with patch.object(render, "__file__", str(fake_render)): + self.assertEqual("7.7.7", render._skill_version()) + + def test_neither_source_present_returns_question_mark(self): + skill_dir = self.tmp_path / "skill_root" + fake_render = self._make_render_at(skill_dir) + # No manifest, no SKILL.md anywhere under tmp_path + + with patch.object(render, "__file__", str(fake_render)): + self.assertEqual("?", render._skill_version()) + + def test_skill_md_without_version_returns_question_mark(self): + skill_dir = self.tmp_path / "skill_root" + fake_render = self._make_render_at(skill_dir) + self._write_skill_md(skill_dir, frontmatter_version_line=None) + + with patch.object(render, "__file__", str(fake_render)): + self.assertEqual("?", render._skill_version()) + + def test_unquoted_yaml_version_is_accepted(self): + skill_dir = self.tmp_path / "skill_root" + fake_render = self._make_render_at(skill_dir) + self._write_skill_md(skill_dir, "version: 6.6.6") + + with patch.object(render, "__file__", str(fake_render)): + self.assertEqual("6.6.6", render._skill_version()) + + def test_single_quoted_yaml_version_is_accepted(self): + skill_dir = self.tmp_path / "skill_root" + fake_render = self._make_render_at(skill_dir) + self._write_skill_md(skill_dir, "version: '5.5.5'") + + with patch.object(render, "__file__", str(fake_render)): + self.assertEqual("5.5.5", render._skill_version()) + + +if __name__ == "__main__": + unittest.main() diff --git a/uv.lock b/uv.lock index 3f7c930..0249fdc 100644 --- a/uv.lock +++ b/uv.lock @@ -106,7 +106,7 @@ wheels = [ [[package]] name = "last30days-skill" -version = "3.2.1" +version = "3.2.2" source = { virtual = "." } [package.dev-dependencies] From 6c2c55733c27c9101fcb41a91aa946c45ba6344f Mon Sep 17 00:00:00 2001 From: Trevin Chow Date: Fri, 15 May 2026 22:14:37 -0700 Subject: [PATCH 3/4] fix(skill): use find instead of ls+glob in cache resolvers (zsh compatibility) zsh errors on globs that match nothing instead of returning the literal pattern (bash's default), and `2>/dev/null` does not suppress the error because it comes from the shell's glob expansion before `ls` even runs. Under Codex (which executes the SKILL.md bash via zsh), STEP 0 and the Step 1 / comparison-mode resolvers emitted noisy "no matches found" errors on machines without a Claude plugin cache populated. Replaces all three `ls -d $HOME/.claude/plugins/cache/last30days-skill/last30days/*/` invocations with `find ... -mindepth 1 -maxdepth 1 -type d 2>/dev/null`. find is POSIX-portable, errors silently when the base dir doesn't exist, and never triggers shell glob errors. `sort -V | tail -1` precedence preserved (verified: picks 3.10.0 over 3.2.1 over 3.1.0). Trailing-slash strip removed because find doesn't append slashes. Observed in Codex session running /last30days against PR #400 with the Claude plugin cache deleted - bash output was: zsh:1: no matches found: /Users/.../last30days/*/ After fix: clean empty output, exit 0, STEP 0 correctly treats it as "no cache present, do not hop", resolver falls through to per-harness skill dirs as designed. --- skills/last30days/SKILL.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/skills/last30days/SKILL.md b/skills/last30days/SKILL.md index 1422be6..be86600 100644 --- a/skills/last30days/SKILL.md +++ b/skills/last30days/SKILL.md @@ -67,7 +67,7 @@ Before reading anything else in this file, check whether you loaded SKILL.md fro **Run this check:** ```bash -CLAUDE_CACHE_LATEST=$(ls -d "$HOME/.claude/plugins/cache/last30days-skill/last30days"/*/ 2>/dev/null | sort -V | tail -1) +CLAUDE_CACHE_LATEST=$(find "$HOME/.claude/plugins/cache/last30days-skill/last30days" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort -V | tail -1) CLAUDE_CACHE_LATEST="${CLAUDE_CACHE_LATEST%/}" echo "CLAUDE_CACHE_LATEST=$CLAUDE_CACHE_LATEST" ``` @@ -586,8 +586,7 @@ When the user asks "X vs Y" (or "X vs Y vs Z"), the engine fans out N full `pipe # Comparison mode skips Step 1, so resolve SKILL_ROOT inline here (same precedence # walk as Step 1 โ€” keep the two in sync if you edit either). SKILL_ROOT="" -CLAUDE_PLUGIN_ROOT="$(ls -d "$HOME/.claude/plugins/cache/last30days-skill/last30days/"*/ 2>/dev/null | sort -V | tail -1)" -CLAUDE_PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT%/}" +CLAUDE_PLUGIN_ROOT="$(find "$HOME/.claude/plugins/cache/last30days-skill/last30days" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort -V | tail -1)" if [ -n "$CLAUDE_PLUGIN_ROOT" ]; then if [ -f "$CLAUDE_PLUGIN_ROOT/skills/last30days/scripts/last30days.py" ]; then SKILL_ROOT="$CLAUDE_PLUGIN_ROOT/skills/last30days" @@ -900,8 +899,9 @@ SKILL_ROOT="" # 1. Claude Code plugin cache (versioned, sort -V picks freshest). Two cache layouts ship in the wild: # nested ({cache}/{version}/skills/last30days/scripts/...) and flat ({cache}/{version}/scripts/...). -CLAUDE_PLUGIN_ROOT="$(ls -d "$HOME/.claude/plugins/cache/last30days-skill/last30days/"*/ 2>/dev/null | sort -V | tail -1)" -CLAUDE_PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT%/}" +# `find` (not `ls + glob`) because zsh errors on globs that match nothing, leaking +# noisy "no matches found" stderr in Codex/zsh sessions even with 2>/dev/null. +CLAUDE_PLUGIN_ROOT="$(find "$HOME/.claude/plugins/cache/last30days-skill/last30days" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort -V | tail -1)" if [ -n "$CLAUDE_PLUGIN_ROOT" ]; then if [ -f "$CLAUDE_PLUGIN_ROOT/skills/last30days/scripts/last30days.py" ]; then SKILL_ROOT="$CLAUDE_PLUGIN_ROOT/skills/last30days" From 2f277dfc66c3b96c5a912db50a88095f4dcd18fb Mon Sep 17 00:00:00 2001 From: Trevin Chow Date: Fri, 15 May 2026 22:34:52 -0700 Subject: [PATCH 4/4] fix(skill): address greptile P1+P2 review feedback on PR #400 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two real bugs flagged in the automated review of PR #400; both small. 1. render.py::_skill_version manifest with no "version" key `json.loads(manifest.read_text()).get("version", "?")` returned "?" immediately on a valid JSON manifest that lacked the "version" key, never falling through to the SKILL.md frontmatter fallback. Contradicted the docstring's "Returns '?' only if both sources are missing" contract. Same shape if version is present but empty string ("" produces the broken badge `๐ŸŒ last30days v ยท synced ...`). Fix: pull the version out of the parsed dict, then `continue` to the next ancestor if it's None or empty. Falls through to the SKILL.md walk only after exhausting every ancestor. 2. SKILL.md STEP 0 re-read target hardcoded to nested cache layout STEP 0 told the model to re-read from `$CLAUDE_CACHE_LATEST/skills/last30days/SKILL.md` โ€” the new nested layout. But Step 1's resolver explicitly handles both shapes (nested `{cache}/{version}/skills/last30days/` and flat `{cache}/{version}/`), noting "Both shapes ship in the wild." On an install where the highest-versioned cache happens to be the older flat shape, STEP 0's re-read target wouldn't exist; the model would silently stay on the stale marketplaces/ copy STEP 0 was supposed to move it away from โ€” the exact failure mode this guard was added to prevent. Fix: extend the STEP 0 bash to resolve $CLAUDE_CACHE_SKILL_MD by probing both layouts, then have the model hop to that resolved path instead of constructing the path from a hardcoded suffix. Two new tests in tests/test_skill_version.py cover the missing-key and empty-string cases for fix 1. Fix 2 is exercised via the bash probe at verify time (the STEP 0 prose-contract test isn't unit-testable from Python, but the dual-layout bash is verified to resolve to the correct SKILL.md on both shapes). Stale finding skipped: greptile also flagged a missing try/except on the SKILL.md read_text() call, but that was already addressed during the ce-code-review safe_auto pass earlier in this PR โ€” current code wraps it in `try/except (OSError, UnicodeDecodeError)`, strictly more defensive than the suggested fix. --- skills/last30days/SKILL.md | 15 ++++++++++++--- skills/last30days/scripts/lib/render.py | 6 ++++-- tests/test_skill_version.py | 24 ++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/skills/last30days/SKILL.md b/skills/last30days/SKILL.md index be86600..3075933 100644 --- a/skills/last30days/SKILL.md +++ b/skills/last30days/SKILL.md @@ -68,11 +68,20 @@ Before reading anything else in this file, check whether you loaded SKILL.md fro ```bash CLAUDE_CACHE_LATEST=$(find "$HOME/.claude/plugins/cache/last30days-skill/last30days" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort -V | tail -1) -CLAUDE_CACHE_LATEST="${CLAUDE_CACHE_LATEST%/}" -echo "CLAUDE_CACHE_LATEST=$CLAUDE_CACHE_LATEST" +# Two cache layouts ship in the wild โ€” nested ({version}/skills/last30days/SKILL.md) +# and flat ({version}/SKILL.md). Resolve to whichever shape actually exists. +CLAUDE_CACHE_SKILL_MD="" +if [ -n "$CLAUDE_CACHE_LATEST" ]; then + if [ -f "$CLAUDE_CACHE_LATEST/skills/last30days/SKILL.md" ]; then + CLAUDE_CACHE_SKILL_MD="$CLAUDE_CACHE_LATEST/skills/last30days/SKILL.md" + elif [ -f "$CLAUDE_CACHE_LATEST/SKILL.md" ]; then + CLAUDE_CACHE_SKILL_MD="$CLAUDE_CACHE_LATEST/SKILL.md" + fi +fi +echo "CLAUDE_CACHE_SKILL_MD=$CLAUDE_CACHE_SKILL_MD" ``` -If the SKILL.md path you just Read contains `/.claude/plugins/marketplaces/` AND `$CLAUDE_CACHE_LATEST` is non-empty, STOP and re-read `$CLAUDE_CACHE_LATEST/skills/last30days/SKILL.md` before proceeding. Otherwise the SKILL.md you have is fine โ€” continue. +If the SKILL.md path you just Read contains `/.claude/plugins/marketplaces/` AND `$CLAUDE_CACHE_SKILL_MD` is non-empty, STOP and re-read `$CLAUDE_CACHE_SKILL_MD` before proceeding. Otherwise the SKILL.md you have is fine โ€” continue. **Why this specific check:** `~/.claude/plugins/marketplaces/last30days-skill/` is a git clone Claude Code auto-restores to `origin/main` on session start. It can lag the versioned cache by one or more releases. Three 2026-04-22 test runs (Linear, Coinbase) loaded SKILL.md from `marketplaces/`, ran `--help` from the same stale path, did not see the `--competitors` flag that existed in the cache, and fell back to a manual comparison plan. Result: 2 of 3 windows never invoked the feature they were asked to test. STEP 0 defends against that one Claude Code-specific bug. diff --git a/skills/last30days/scripts/lib/render.py b/skills/last30days/scripts/lib/render.py index f4c977e..7e4aeff 100644 --- a/skills/last30days/scripts/lib/render.py +++ b/skills/last30days/scripts/lib/render.py @@ -34,11 +34,13 @@ def _skill_version() -> str: manifest = parent / ".claude-plugin" / "plugin.json" if manifest.is_file(): try: - return json.loads(manifest.read_text()).get("version", "?") + version = json.loads(manifest.read_text()).get("version") except (json.JSONDecodeError, OSError): continue + if version: + return version - # No manifest found at any ancestor โ€” fall back to SKILL.md frontmatter. + # No usable manifest found at any ancestor โ€” fall back to SKILL.md frontmatter. for parent in here.parents: skill_md = parent / "SKILL.md" if skill_md.is_file(): diff --git a/tests/test_skill_version.py b/tests/test_skill_version.py index d36d16e..f7a8e07 100644 --- a/tests/test_skill_version.py +++ b/tests/test_skill_version.py @@ -66,6 +66,30 @@ class SkillVersionFallbackTests(unittest.TestCase): with patch.object(render, "__file__", str(fake_render)): self.assertEqual("8.8.8", render._skill_version()) + def test_manifest_missing_version_key_falls_back_to_skill_md_frontmatter(self): + # Valid JSON, but no "version" key. Old behavior returned "?" and never tried + # SKILL.md. Fix from greptile review: fall through to SKILL.md fallback. + skill_dir = self.tmp_path / "skill_root" + fake_render = self._make_render_at(skill_dir) + (skill_dir / ".claude-plugin").mkdir() + (skill_dir / ".claude-plugin" / "plugin.json").write_text('{"name": "x"}') + self._write_skill_md(skill_dir, 'version: "4.4.4"') + + with patch.object(render, "__file__", str(fake_render)): + self.assertEqual("4.4.4", render._skill_version()) + + def test_manifest_empty_version_string_falls_back_to_skill_md_frontmatter(self): + # Manifest version present but empty โ€” treat as missing so the badge + # doesn't emit a useless "๐ŸŒ last30days v ยท synced ..." line. + skill_dir = self.tmp_path / "skill_root" + fake_render = self._make_render_at(skill_dir) + (skill_dir / ".claude-plugin").mkdir() + (skill_dir / ".claude-plugin" / "plugin.json").write_text('{"version": ""}') + self._write_skill_md(skill_dir, 'version: "2.2.2"') + + with patch.object(render, "__file__", str(fake_render)): + self.assertEqual("2.2.2", render._skill_version()) + def test_corrupt_inner_manifest_does_not_shadow_valid_outer_manifest(self): outer = self.tmp_path / "outer" inner = outer / "skill_root"