diff --git a/README.md b/README.md index a46730b..7a4f3ed 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,16 @@ node ~/.claude/skills/last30days/scripts/lib/vendor/bird-search/bird-search.mjs **Requirements:** Node.js 22+ (for the vendored Twitter GraphQL client). +### Codex CLI + +This skill also works in OpenAI Codex CLI. Install to the Codex skills directory instead: + +```bash +git clone https://github.com/mvanhorn/last30days-skill.git ~/.agents/skills/last30days +``` + +Same SKILL.md, same Python engine, same scripts. The `agents/openai.yaml` provides Codex-specific discovery metadata. Invoke with `$last30days` or through the `/skills` menu. + ## Usage ``` diff --git a/SKILL.md b/SKILL.md index 0e0670c..2b8329a 100644 --- a/SKILL.md +++ b/SKILL.md @@ -61,7 +61,22 @@ This text MUST appear before you call any tools. It confirms to the user that yo **Step 1: Run the research script** ```bash -python3 "${CLAUDE_PLUGIN_ROOT:-$HOME/.claude/skills/last30days}/scripts/last30days.py" "$ARGUMENTS" --emit=compact 2>&1 +# Find skill root — works in repo checkout, Claude Code, or Codex install +for dir in \ + "." \ + "${CLAUDE_PLUGIN_ROOT:-}" \ + "$HOME/.claude/skills/last30days" \ + "$HOME/.agents/skills/last30days" \ + "$HOME/.codex/skills/last30days"; do + [ -n "$dir" ] && [ -f "$dir/scripts/last30days.py" ] && SKILL_ROOT="$dir" && break +done + +if [ -z "${SKILL_ROOT:-}" ]; then + echo "ERROR: Could not find scripts/last30days.py" >&2 + exit 1 +fi + +python3 "${SKILL_ROOT}/scripts/last30days.py" "$ARGUMENTS" --emit=compact 2>&1 ``` The script will automatically: diff --git a/agents/openai.yaml b/agents/openai.yaml new file mode 100644 index 0000000..d29b82e --- /dev/null +++ b/agents/openai.yaml @@ -0,0 +1,8 @@ +interface: + display_name: "Last 30 Days" + short_description: "Research any topic across Reddit, X, YouTube, and the web from the last 30 days. Returns synthesized expert answers and copy-paste prompts." + default_prompt: "Research this topic from the last 30 days across Reddit, X, YouTube, and web. Synthesize what people are actually saying, upvoting, and sharing right now." + brand_color: "#FF6B35" + +policy: + allow_implicit_invocation: true diff --git a/docs/plans/2026-02-14-feat-codex-skill-compatibility-plan.md b/docs/plans/2026-02-14-feat-codex-skill-compatibility-plan.md new file mode 100644 index 0000000..4c57c43 --- /dev/null +++ b/docs/plans/2026-02-14-feat-codex-skill-compatibility-plan.md @@ -0,0 +1,243 @@ +--- +title: "feat: Add Codex CLI compatibility" +type: feat +date: 2026-02-14 +--- + +# feat: Add Codex CLI Compatibility + +## Overview + +Make /last30days work as a Codex CLI skill alongside Claude Code. Both platforms use `SKILL.md` with YAML frontmatter — the gap is small but the details matter. Inspired by PR #24 (el-analista) and PR #5 (jblwilliams) on the public repo, applied to the v2.1 codebase. + +## Research Findings + +### How Codex Skills Work (from [official docs](https://developers.openai.com/codex/skills)) + +**Format:** Identical to Claude Code — `SKILL.md` with YAML frontmatter + Markdown body. + +**Required frontmatter:** Only `name` and `description`. The official skill-creator guidance says "Do not include any other fields in YAML frontmatter." This is stricter than Claude Code which allows `version`, `allowed-tools`, `argument-hint`, etc. + +**Discovery:** Codex uses "progressive disclosure" — it reads ONLY the `description` field to decide whether to invoke a skill. The body loads only after triggering. This means the description must be comprehensive about when to use/not use the skill. + +**Invocation:** Users invoke with `$skill-name` or `/skills` menu. Codex can also implicitly match based on the description (configurable via `agents/openai.yaml`). + +**Installation paths** (scanned in order): +| Scope | Path | +|-------|------| +| Folder | `$CWD/.agents/skills/` | +| Repo | `$REPO_ROOT/.agents/skills/` | +| User | `$HOME/.agents/skills/` | +| Admin | `/etc/codex/skills/` | +| System | Bundled | + +Note: Some docs also mention `~/.codex/skills/` as an alias for `$HOME/.agents/skills/`. Both should be checked. + +**`agents/openai.yaml`** (optional sidecar): +```yaml +interface: + display_name: "User-facing name" + short_description: "Brief description" + default_prompt: "Surrounding prompt template" + brand_color: "#hex" +policy: + allow_implicit_invocation: true +dependencies: + tools: + - type: "mcp" + value: "toolName" +``` + +**Size guidance:** Keep SKILL.md under 500 lines. Use `references/` directory for detailed docs that load on demand. + +**Scripts:** Put executable code in `scripts/`. These can run without being loaded into context — good for our Python research engine. + +### What Real Codex Skills Look Like (from [openai/skills catalog](https://github.com/openai/skills)) + +**openai-docs skill** — Uses MCP tools (`mcp__openaiDeveloperDocs__search_openai_docs`). Has a workflow section, fallback instructions if MCP isn't set up, and quality rules. Clean and focused. + +**pdf skill** — Runs scripts (`pdftoppm`, `reportlab`), has file conventions (`tmp/pdfs/`, `output/pdf/`), specifies dependencies. Good example of a skill that shells out to tools like we do. + +**skill-creator** — The meta-skill. Emphasizes "the context window is a public good" and treating the LLM as "already very smart — only add information it genuinely lacks." Has 6 creation steps, validation scripts, and naming conventions. + +### Key Insight: Frontmatter Compatibility Problem + +Claude Code SKILL.md uses: +```yaml +name: last30days +version: "2.1" +description: Research a topic... +argument-hint: 'nano banana pro prompts...' +allowed-tools: Bash, Read, Write, AskUserQuestion, WebSearch +``` + +Codex wants only `name` and `description`. The question: does Codex error on unknown frontmatter fields, or ignore them? + +**Safe answer:** Codex uses standard YAML parsing and likely ignores unknown keys. But the official guidance says "Do not include any other fields" — meaning it's untested territory and could break in future Codex updates. + +**Our approach:** Keep one SKILL.md with Claude-specific fields. If Codex chokes, we add a thin wrapper. This is pragmatic — maintaining two SKILL.md files defeats the purpose of cross-platform compatibility. + +### PR #24 Analysis (el-analista) + +Good ideas to incorporate: +- Portable script path resolution (repo → Claude → Codex → agents) +- `agents/openai.yaml` for Codex discovery +- Platform-neutral output text ("assistant" instead of "Claude") +- Sandbox-friendly cache/output dir fallbacks with env var overrides +- Last-chance retry for Bird search (better query noise stripping) + +Not applicable to v2.1: +- Based on v2.0 codebase — doesn't have YouTube, vendored Bird, or pipeline changes +- We'll cherry-pick the ideas, not the code + +### PR #5 Analysis (jblwilliams) + +Not needed: +- Codex JWT auth — our OpenAI API calls work natively in Codex already +- SSE response handling — we don't stream responses +- The 403 enrichment issues they hit are specific to Codex-hosted auth, not our use case + +## Proposed Solution + +Five changes, all additive — zero impact on existing Claude Code behavior: + +### 1. Add `agents/openai.yaml` for Codex discovery + +```yaml +interface: + display_name: "Last 30 Days" + short_description: "Research any topic across Reddit, X, YouTube, and the web from the last 30 days. Returns synthesized expert answers and copy-paste prompts." + default_prompt: "Research this topic from the last 30 days across Reddit, X, YouTube, and web. Synthesize what people are actually saying, upvoting, and sharing right now." + brand_color: "#FF6B35" + +policy: + allow_implicit_invocation: true +``` + +### 2. Make SKILL.md script path portable + +Replace the hardcoded Claude path with a lookup that checks multiple install locations: + +```bash +# Find the skill root +for dir in \ + "." \ + "${CLAUDE_PLUGIN_ROOT:-}" \ + "$HOME/.claude/skills/last30days" \ + "$HOME/.agents/skills/last30days" \ + "$HOME/.codex/skills/last30days"; do + [ -n "$dir" ] && [ -f "$dir/scripts/last30days.py" ] && SKILL_ROOT="$dir" && break +done + +if [ -z "${SKILL_ROOT:-}" ]; then + echo "ERROR: Could not find scripts/last30days.py" >&2 + exit 1 +fi + +python3 "${SKILL_ROOT}/scripts/last30days.py" "$ARGUMENTS" --emit=compact 2>&1 +``` + +### 3. Platform-neutral Python output text + +Replace "Claude" with "assistant" in LLM-facing output strings only. Human-facing docs (README, etc.) stay as-is. + +Files: +- `scripts/last30days.py` — web search marker text (~3 lines) +- `scripts/lib/render.py` — docstrings + web-only banner (~4 lines) +- `scripts/lib/http.py` — User-Agent string (~1 line) + +### 4. Sandbox-friendly cache/output dirs + +Codex runs sandboxed. Add env var overrides + tempdir fallback (from PR #24): + +**`scripts/lib/cache.py`:** +- Check `LAST30DAYS_CACHE_DIR` env var +- Catch `PermissionError`, fall back to `tempfile.gettempdir()/last30days/cache` + +**`scripts/lib/render.py`:** +- Check `LAST30DAYS_OUTPUT_DIR` env var +- Catch `PermissionError`, fall back to `tempfile.gettempdir()/last30days/out` + +### 5. README + installation docs + +Add a "Codex Compatibility" section to README: + +```markdown +## Codex Compatibility + +This skill works in both Claude Code and OpenAI Codex CLI. + +**Claude Code:** `git clone` into `~/.claude/skills/last30days` +**Codex CLI:** `git clone` into `~/.agents/skills/last30days` + +Both use the same SKILL.md, same Python engine, same scripts. +The `agents/openai.yaml` provides Codex-specific discovery metadata. +``` + +## What We're NOT Doing + +- **Separate SKILL.md for Codex** — One file, both platforms. Claude-specific frontmatter fields (`allowed-tools`, `version`, `argument-hint`) are likely ignored by Codex's YAML parser. If this breaks, we'll address it then. +- **Codex JWT auth (PR #5)** — Our OpenAI Responses API calls work natively in Codex. No special handling needed. +- **SSE streaming (PR #5)** — Not our use case. +- **Codex-specific tool names in SKILL.md** — Both LLMs understand "do a web search" and "run this bash command." The instructions work cross-platform as-is. +- **Publishing to openai/skills catalog** — Out of scope for now. Users install via git clone. + +## Acceptance Criteria + +- [x] `agents/openai.yaml` exists with proper `interface` and `policy` sections +- [x] SKILL.md uses portable path resolution (repo checkout, `~/.claude/skills/`, `~/.agents/skills/`, `~/.codex/skills/`) +- [x] Python scripts use "assistant" instead of "Claude" in LLM-facing output (~8 string replacements) +- [x] Cache dir falls back gracefully in sandboxed environments (`LAST30DAYS_CACHE_DIR` env var + `PermissionError` catch) +- [x] Output dir falls back gracefully in sandboxed environments (`LAST30DAYS_OUTPUT_DIR` env var + `PermissionError` catch) +- [x] Existing Claude Code behavior is unchanged (zero regressions) +- [x] README documents Codex installation path (`~/.agents/skills/last30days`) +- [x] `python3 scripts/last30days.py "test topic" --mock --emit=compact` still works + +## Files to Create/Modify + +### New Files +- `agents/openai.yaml` — Codex discovery metadata (~10 lines) + +### Modified Files +- `SKILL.md` — Portable script path resolution (~15 lines changed) +- `README.md` — Add "Codex Compatibility" section (~15 lines) +- `scripts/last30days.py` — "Claude" → "assistant" in output strings (~3 lines) +- `scripts/lib/render.py` — "Claude" → "assistant" + output dir fallback (~15 lines) +- `scripts/lib/cache.py` — Cache dir env override + fallback (~12 lines) +- `scripts/lib/http.py` — User-Agent string (~1 line) + +### Total scope: ~70 lines changed across 7 files. Small, additive, low risk. + +## Dependencies & Risks + +| Risk | Likelihood | Mitigation | +|------|-----------|------------| +| Codex rejects unknown YAML frontmatter (`allowed-tools`, etc.) | Low-Medium | Standard YAML parsers ignore unknown keys. If it breaks, strip Claude-specific fields and use `agents/openai.yaml` for metadata. | +| Codex sandbox blocks Node.js (vendored Bird) | Medium | Bird failure already falls back to xAI API. If no xAI key, X search skipped gracefully. | +| yt-dlp not in Codex sandbox PATH | Medium | YouTube already degrades gracefully — "yt-dlp not installed, skipping YouTube." | +| Codex sandbox blocks `~/.cache/` writes | Medium | Env var override + tempdir fallback handles this. (Proven approach from PR #24) | +| Codex changes skill discovery paths | Low | We check 5 paths. Easy to add more. | +| Codex description matching triggers on wrong queries | Low | Write description with clear "use when" / "do not use when" boundaries per official guidance. | + +## References + +### Community PRs +- [PR #24](https://github.com/mvanhorn/last30days-skill/pull/24) (el-analista) — Codex compatibility, portable paths, platform-neutral text +- [PR #5](https://github.com/mvanhorn/last30days-skill/pull/5) (jblwilliams) — Codex auth support + +### Official Codex Docs +- [Agent Skills](https://developers.openai.com/codex/skills) — SKILL.md format, discovery, installation paths +- [AGENTS.md Guide](https://developers.openai.com/codex/guides/agents-md/) — Custom instructions, hierarchical loading +- [Codex CLI Features](https://developers.openai.com/codex/cli/features/) — Overview of CLI capabilities +- [Configuration Reference](https://developers.openai.com/codex/config-reference/) — config.toml, skill enable/disable + +### Examples +- [openai/skills catalog](https://github.com/openai/skills) — Official curated skills +- [skill-creator](https://github.com/openai/skills/blob/main/skills/.system/skill-creator/SKILL.md) — Meta-skill for creating skills, best practices +- [pdf skill](https://github.com/openai/skills/blob/main/skills/.curated/pdf/SKILL.md) — Example of skill that runs external scripts +- [openai-docs skill](https://github.com/openai/skills/blob/main/skills/.curated/openai-docs/SKILL.md) — Example of MCP-backed skill + +### Community Analysis +- [Skills in OpenAI Codex](https://blog.fsck.com/2025/12/19/codex-skills/) — Jesse Vincent's deep dive on skill internals +- [Simon Willison on skills adoption](https://simonw.substack.com/p/openai-are-quietly-adopting-skills) — Cross-platform skill format analysis +- [SkillsMP marketplace](https://skillsmp.com/) — Community marketplace supporting both Claude Code and Codex skills diff --git a/scripts/last30days.py b/scripts/last30days.py index aefbf9e..e5b0e69 100644 --- a/scripts/last30days.py +++ b/scripts/last30days.py @@ -376,8 +376,8 @@ def run_research( Returns: Tuple of (reddit_items, x_items, youtube_items, web_needed, raw_openai, raw_xai, raw_reddit_enriched, reddit_error, x_error, youtube_error) - Note: web_needed is True when WebSearch should be performed by Claude. - The script outputs a marker and Claude handles WebSearch in its session. + Note: web_needed is True when web search should be performed by the assistant. + The script outputs a marker and the assistant handles web search in its session. """ reddit_items = [] x_items = [] @@ -392,7 +392,7 @@ def run_research( # Check if WebSearch is needed (always needed in web-only mode) web_needed = sources in ("all", "web", "reddit-web", "x-web") - # Web-only mode: no API calls needed, Claude handles everything + # Web-only mode: no API calls needed, assistant handles everything if sources == "web": if progress: progress.start_web_only() @@ -803,7 +803,7 @@ def output_result( print(f"Topic: {topic}") print(f"Date range: {from_date} to {to_date}") print("") - print("Claude: Use your WebSearch tool to find 8-15 relevant web pages.") + print("Assistant: Use your web search tool to find 8-15 relevant web pages.") print("EXCLUDE: reddit.com, x.com, twitter.com (already covered above)") print(f"INCLUDE: blogs, docs, news, tutorials from the last {days} days") print("") diff --git a/scripts/lib/cache.py b/scripts/lib/cache.py index 0a6ac7b..7fad3b3 100644 --- a/scripts/lib/cache.py +++ b/scripts/lib/cache.py @@ -3,6 +3,7 @@ import hashlib import json import os +import tempfile from datetime import datetime, timezone from pathlib import Path from typing import Any, Optional @@ -10,11 +11,23 @@ from typing import Any, Optional CACHE_DIR = Path.home() / ".cache" / "last30days" DEFAULT_TTL_HOURS = 24 MODEL_CACHE_TTL_DAYS = 7 +MODEL_CACHE_FILE = CACHE_DIR / "model_selection.json" def ensure_cache_dir(): - """Ensure cache directory exists.""" - CACHE_DIR.mkdir(parents=True, exist_ok=True) + """Ensure cache directory exists. Supports env override and sandbox fallback.""" + global CACHE_DIR, MODEL_CACHE_FILE + env_dir = os.environ.get("LAST30DAYS_CACHE_DIR") + if env_dir: + CACHE_DIR = Path(env_dir) + MODEL_CACHE_FILE = CACHE_DIR / "model_selection.json" + + try: + CACHE_DIR.mkdir(parents=True, exist_ok=True) + except PermissionError: + CACHE_DIR = Path(tempfile.gettempdir()) / "last30days" / "cache" + MODEL_CACHE_FILE = CACHE_DIR / "model_selection.json" + CACHE_DIR.mkdir(parents=True, exist_ok=True) def get_cache_key(topic: str, from_date: str, to_date: str, sources: str) -> str: @@ -112,8 +125,8 @@ def clear_cache(): pass -# Model selection cache (longer TTL) -MODEL_CACHE_FILE = CACHE_DIR / "model_selection.json" +# Model selection cache (longer TTL) — MODEL_CACHE_FILE is set at module level +# and updated by ensure_cache_dir() if env override or fallback is needed. def load_model_cache() -> dict: diff --git a/scripts/lib/http.py b/scripts/lib/http.py index 650a8f1..d5da25e 100644 --- a/scripts/lib/http.py +++ b/scripts/lib/http.py @@ -20,7 +20,7 @@ def log(msg: str): sys.stderr.flush() MAX_RETRIES = 3 RETRY_DELAY = 1.0 -USER_AGENT = "last30days-skill/2.0 (Claude Code Skill)" +USER_AGENT = "last30days-skill/2.1 (Assistant Skill)" class HTTPError(Exception): diff --git a/scripts/lib/render.py b/scripts/lib/render.py index 8a21f93..15ffd53 100644 --- a/scripts/lib/render.py +++ b/scripts/lib/render.py @@ -1,6 +1,8 @@ """Output rendering for last30days skill.""" import json +import os +import tempfile from pathlib import Path from typing import List, Optional @@ -10,8 +12,17 @@ OUTPUT_DIR = Path.home() / ".local" / "share" / "last30days" / "out" def ensure_output_dir(): - """Ensure output directory exists.""" - OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + """Ensure output directory exists. Supports env override and sandbox fallback.""" + global OUTPUT_DIR + env_dir = os.environ.get("LAST30DAYS_OUTPUT_DIR") + if env_dir: + OUTPUT_DIR = Path(env_dir) + + try: + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + except PermissionError: + OUTPUT_DIR = Path(tempfile.gettempdir()) / "last30days" / "out" + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) def _assess_data_freshness(report: schema.Report) -> dict: @@ -35,7 +46,7 @@ def _assess_data_freshness(report: schema.Report) -> dict: def render_compact(report: schema.Report, limit: int = 15, missing_keys: str = "none") -> str: - """Render compact output for Claude to synthesize. + """Render compact output for the assistant to synthesize. Args: report: Report data @@ -61,7 +72,7 @@ def render_compact(report: schema.Report, limit: int = 15, missing_keys: str = " # Web-only mode banner (when no API keys) if report.mode == "web-only": - lines.append("**🌐 WEB SEARCH MODE** - Claude will search blogs, docs & news") + lines.append("**🌐 WEB SEARCH MODE** - assistant will search blogs, docs & news") lines.append("") lines.append("---") lines.append("**⚡ Want better results?** Add API keys to unlock Reddit & X data:") @@ -204,7 +215,7 @@ def render_compact(report: schema.Report, limit: int = 15, missing_keys: str = " lines.append(f" *{item.why_relevant}*") lines.append("") - # Web items (if any - populated by Claude) + # Web items (if any - populated by the assistant) if report.web_error: lines.append("### Web Results") lines.append("") @@ -356,15 +367,15 @@ def render_full_report(report: schema.Report) -> str: lines.append(f"> {item.snippet}") lines.append("") - # Placeholders for Claude synthesis + # Placeholders for assistant synthesis lines.append("## Best Practices") lines.append("") - lines.append("*To be synthesized by Claude*") + lines.append("*To be synthesized by assistant*") lines.append("") lines.append("## Prompt Pack") lines.append("") - lines.append("*To be synthesized by Claude*") + lines.append("*To be synthesized by assistant*") lines.append("") return "\n".join(lines) diff --git a/scripts/lib/ui.py b/scripts/lib/ui.py index 82a7cae..7b12f1c 100644 --- a/scripts/lib/ui.py +++ b/scripts/lib/ui.py @@ -344,7 +344,7 @@ class ProgressDisplay: def end_web_only(self): """End web-only spinner.""" if self.spinner: - self.spinner.stop(f"{Colors.GREEN}Web{Colors.RESET} Claude will search the web") + self.spinner.stop(f"{Colors.GREEN}Web{Colors.RESET} assistant will search the web") def show_web_only_complete(self): """Show completion for web-only mode.""" @@ -352,7 +352,7 @@ class ProgressDisplay: if IS_TTY: sys.stderr.write(f"\n{Colors.GREEN}{Colors.BOLD}✓ Ready for web search{Colors.RESET} ") sys.stderr.write(f"{Colors.DIM}({elapsed:.1f}s){Colors.RESET}\n") - sys.stderr.write(f" {Colors.GREEN}Web:{Colors.RESET} Claude will search blogs, docs & news\n\n") + sys.stderr.write(f" {Colors.GREEN}Web:{Colors.RESET} assistant will search blogs, docs & news\n\n") else: sys.stderr.write(f"✓ Ready for web search ({elapsed:.1f}s)\n") sys.stderr.flush() diff --git a/scripts/lib/websearch.py b/scripts/lib/websearch.py index fe87654..e7d779a 100644 --- a/scripts/lib/websearch.py +++ b/scripts/lib/websearch.py @@ -1,12 +1,12 @@ """WebSearch module for last30days skill. -NOTE: WebSearch uses Claude's built-in WebSearch tool, which runs INSIDE Claude Code. -Unlike Reddit/X which use external APIs, WebSearch results are obtained by Claude +NOTE: WebSearch uses the assistant's built-in web search tool, which runs inside the host environment. +Unlike Reddit/X which use external APIs, web search results are obtained by the assistant directly and passed to this module for normalization and scoring. The typical flow is: -1. Claude invokes WebSearch tool with the topic -2. Claude passes results to parse_websearch_results() +1. The assistant invokes its web search tool with the topic +2. The assistant passes results to parse_websearch_results() 3. Results are normalized into WebSearchItem objects """