Compare commits
10 Commits
5c9eda4fbd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edaeaa89f4 | ||
|
|
9242af5fcb | ||
|
|
000596dad6 | ||
|
|
22ec9f0d59 | ||
|
|
673980639b | ||
|
|
c120cd415e | ||
|
|
1ff1499633 | ||
|
|
2097ffb527 | ||
|
|
681994316b | ||
|
|
efda299a9e |
@@ -5,9 +5,8 @@
|
|||||||
"email": "daymadev89@gmail.com"
|
"email": "daymadev89@gmail.com"
|
||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"description": "Professional Claude Code skills for GitHub operations, document conversion, diagram generation, statusline customization, Teams communication, repomix utilities, skill creation, CLI demo generation, LLM icon access, Cloudflare troubleshooting, UI design system extraction, professional presentation creation, YouTube video downloading, secure repomix packaging, ASR transcription correction, video comparison quality analysis, comprehensive QA testing infrastructure, prompt optimization with EARS methodology, session history recovery, local Claude session continuation from `.claude` artifacts, documentation cleanup, format-controlled deep research report generation with evidence tracking, PDF generation with Chinese font support, CLAUDE.md progressive disclosure optimization, CCPM skill registry search and management, Promptfoo LLM evaluation framework, iOS app development with XcodeGen and SwiftUI, fact-checking with automated corrections, Twitter/X content fetching, intelligent macOS disk space recovery, skill quality review and improvement, GitHub contribution strategy, complete internationalization/localization setup, plugin/skill troubleshooting with diagnostic tools, evidence-based competitor analysis with source citations, Windows Remote Desktop (AVD/W365) connection quality diagnosis with transport protocol analysis and log parsing, Tailscale+proxy conflict diagnosis with SSH tunnel SOP for remote development, multi-path parallel product analysis with cross-model test-time compute scaling, real financial data collection for US equities with validation and yfinance pitfall handling, advanced Excel automation for formatted workbook generation and complex xlsm parsing, macOS programmatic window screenshot capture workflows, and verified Scrapling CLI installation and web extraction workflows",
|
"description": "Professional Claude Code skills for GitHub operations, document conversion, diagram generation, statusline customization, Teams communication, repomix utilities, skill creation, CLI demo generation, LLM icon access, Cloudflare troubleshooting, UI design system extraction, professional presentation creation, YouTube video downloading, secure repomix packaging, ASR transcription correction, video comparison quality analysis, comprehensive QA testing infrastructure, prompt optimization with EARS methodology, session history recovery, local Claude session continuation from `.claude` artifacts, documentation cleanup, format-controlled deep research report generation with evidence tracking, PDF generation with Chinese font support, CLAUDE.md progressive disclosure optimization, CCPM skill registry search and management, Promptfoo LLM evaluation framework, iOS app development with XcodeGen and SwiftUI, fact-checking with automated corrections, Twitter/X content fetching, intelligent macOS disk space recovery, skill quality review and improvement, GitHub contribution strategy, complete internationalization/localization setup, plugin/skill troubleshooting with diagnostic tools, evidence-based competitor analysis with source citations, Windows Remote Desktop (AVD/W365) connection quality diagnosis with transport protocol analysis and log parsing, Tailscale+proxy conflict diagnosis with SSH tunnel SOP for remote development, multi-path parallel product analysis with cross-model test-time compute scaling, real financial data collection for US equities with validation and yfinance pitfall handling, advanced Excel automation for formatted workbook generation and complex xlsm parsing, macOS programmatic window screenshot capture workflows, and verified Scrapling CLI installation and web extraction workflows, and plugin marketplace development for converting skills repos into official Claude Code marketplaces",
|
||||||
"version": "1.40.0",
|
"version": "1.40.1"
|
||||||
"homepage": "https://github.com/daymade/claude-code-skills"
|
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
@@ -292,7 +291,7 @@
|
|||||||
"description": "Corrects speech-to-text (ASR/STT) transcription errors using dictionary rules and native Claude AI corrections (no API key needed by default). Supports intelligent paragraph breaks, filler word reduction, interactive review, Chinese domain names, and iterative dictionary building. Use when users mention transcript correction, ASR errors, speech-to-text mistakes, homophone errors, or working with transcription files",
|
"description": "Corrects speech-to-text (ASR/STT) transcription errors using dictionary rules and native Claude AI corrections (no API key needed by default). Supports intelligent paragraph breaks, filler word reduction, interactive review, Chinese domain names, and iterative dictionary building. Use when users mention transcript correction, ASR errors, speech-to-text mistakes, homophone errors, or working with transcription files",
|
||||||
"source": "./",
|
"source": "./",
|
||||||
"strict": false,
|
"strict": false,
|
||||||
"version": "1.3.0",
|
"version": "1.4.0",
|
||||||
"category": "productivity",
|
"category": "productivity",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"transcription",
|
"transcription",
|
||||||
@@ -417,7 +416,7 @@
|
|||||||
"description": "Create PDF documents from markdown with Chinese font support. Supports theme system (default for formal docs, warm-terra for training materials) and dual backend (weasyprint or Chrome). Triggers include convert to PDF, generate PDF, markdown to PDF, or printable documents",
|
"description": "Create PDF documents from markdown with Chinese font support. Supports theme system (default for formal docs, warm-terra for training materials) and dual backend (weasyprint or Chrome). Triggers include convert to PDF, generate PDF, markdown to PDF, or printable documents",
|
||||||
"source": "./",
|
"source": "./",
|
||||||
"strict": false,
|
"strict": false,
|
||||||
"version": "1.2.0",
|
"version": "1.3.0",
|
||||||
"category": "document-conversion",
|
"category": "document-conversion",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"pdf",
|
"pdf",
|
||||||
@@ -426,6 +425,7 @@
|
|||||||
"chrome",
|
"chrome",
|
||||||
"themes",
|
"themes",
|
||||||
"chinese-fonts",
|
"chinese-fonts",
|
||||||
|
"cjk",
|
||||||
"document-generation",
|
"document-generation",
|
||||||
"legal",
|
"legal",
|
||||||
"reports",
|
"reports",
|
||||||
@@ -543,10 +543,10 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "twitter-reader",
|
"name": "twitter-reader",
|
||||||
"description": "Fetch Twitter/X post content by URL using jina.ai API to bypass JavaScript restrictions. Use when Claude needs to retrieve tweet content including author, timestamp, post text, images, and thread replies. Supports individual posts or batch fetching from x.com or twitter.com URLs",
|
"description": "Fetch Twitter/X post content including long-form Articles with full images and metadata. Use when Claude needs to retrieve tweet/article content, author info, engagement metrics (likes, retweets, bookmarks), and embedded media. Supports individual posts and X Articles (long-form content). Automatically downloads all images to local attachments folder and generates complete Markdown with proper image references. Preferred over Jina for X Articles with images.",
|
||||||
"source": "./",
|
"source": "./",
|
||||||
"strict": false,
|
"strict": false,
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"category": "utilities",
|
"category": "utilities",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"twitter",
|
"twitter",
|
||||||
@@ -556,7 +556,10 @@
|
|||||||
"content-fetching",
|
"content-fetching",
|
||||||
"api",
|
"api",
|
||||||
"scraping",
|
"scraping",
|
||||||
"threads"
|
"threads",
|
||||||
|
"images",
|
||||||
|
"attachments",
|
||||||
|
"markdown"
|
||||||
],
|
],
|
||||||
"skills": [
|
"skills": [
|
||||||
"./twitter-reader"
|
"./twitter-reader"
|
||||||
@@ -945,6 +948,45 @@
|
|||||||
"skills": [
|
"skills": [
|
||||||
"./asr-transcribe-to-text"
|
"./asr-transcribe-to-text"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "marketplace-dev",
|
||||||
|
"description": "Convert any Claude Code skills repository into an official plugin marketplace. Creates .claude-plugin/marketplace.json conforming to the Anthropic spec, validates it, tests installation, and creates a PR. Includes anti-patterns from real development experience.",
|
||||||
|
"source": "./",
|
||||||
|
"strict": false,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"category": "developer-tools",
|
||||||
|
"keywords": [
|
||||||
|
"marketplace",
|
||||||
|
"plugin",
|
||||||
|
"distribution",
|
||||||
|
"packaging"
|
||||||
|
],
|
||||||
|
"skills": [
|
||||||
|
"./marketplace-dev"
|
||||||
|
],
|
||||||
|
"hooks": {
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Write|Edit",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "${CLAUDE_PLUGIN_ROOT}/marketplace-dev/scripts/post_edit_validate.sh"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": "Write|Edit",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "${CLAUDE_PLUGIN_ROOT}/marketplace-dev/scripts/post_edit_sync_check.sh"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,346 +1,233 @@
|
|||||||
---
|
---
|
||||||
name: cli-demo-generator
|
name: cli-demo-generator
|
||||||
description: This skill should be used when users want to create animated CLI demos, terminal recordings, or command-line demonstration GIFs. It supports both manual tape file creation and automated demo generation from command descriptions. Use when users mention creating demos, recording terminal sessions, or generating animated GIFs of CLI workflows.
|
description: Generates professional animated CLI demos as GIFs using VHS terminal recordings. Handles tape file creation, self-bootstrapping demos with hidden setup, output noise filtering, post-processing speed-up, and frame-level verification. Use when users want to create terminal demos, record CLI workflows as GIFs, generate animated documentation, build demo tapes for README files, or need to showcase any command-line tool visually. Also triggers on "record terminal", "VHS tape", "demo GIF", "animate my CLI", or any request to visually demonstrate shell commands.
|
||||||
---
|
---
|
||||||
|
|
||||||
# CLI Demo Generator
|
# CLI Demo Generator
|
||||||
|
|
||||||
Generate professional animated CLI demos with ease. This skill supports both automated generation from command descriptions and manual control for custom demos.
|
Create professional animated CLI demos. Four approaches, from fully automated to pixel-precise manual control.
|
||||||
|
|
||||||
## When to Use This Skill
|
## Quick Start
|
||||||
|
|
||||||
Trigger this skill when users request:
|
**Simplest path** — give commands, get GIF:
|
||||||
- "Create a demo showing how to install my package"
|
|
||||||
- "Generate a CLI demo of these commands"
|
|
||||||
- "Make an animated GIF of my terminal workflow"
|
|
||||||
- "Record a terminal session and convert to GIF"
|
|
||||||
- "Batch generate demos from this config"
|
|
||||||
- "Create an interactive typing demo"
|
|
||||||
|
|
||||||
## Core Capabilities
|
|
||||||
|
|
||||||
### 1. Automated Demo Generation (Recommended)
|
|
||||||
|
|
||||||
Use the `auto_generate_demo.py` script for quick, automated demo creation. This is the easiest and most common approach.
|
|
||||||
|
|
||||||
**Basic Usage:**
|
|
||||||
```bash
|
```bash
|
||||||
scripts/auto_generate_demo.py \
|
python3 ${CLAUDE_SKILL_DIR}/scripts/auto_generate_demo.py \
|
||||||
-c "npm install my-package" \
|
-c "npm install my-package" \
|
||||||
-c "npm run build" \
|
-c "npm run build" \
|
||||||
-o demo.gif
|
-o demo.gif
|
||||||
```
|
```
|
||||||
|
|
||||||
**With Options:**
|
**Self-bootstrapping demo** — for repeatable recordings that clean their own state:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
scripts/auto_generate_demo.py \
|
python3 ${CLAUDE_SKILL_DIR}/scripts/auto_generate_demo.py \
|
||||||
-c "command1" \
|
-c "npm install my-package" \
|
||||||
-c "command2" \
|
-c "npm run build" \
|
||||||
|
-o demo.gif \
|
||||||
|
--bootstrap "npm uninstall my-package 2>/dev/null" \
|
||||||
|
--speed 2
|
||||||
|
```
|
||||||
|
|
||||||
|
## Critical: VHS Parser Limitations
|
||||||
|
|
||||||
|
VHS `Type` strings cannot contain `$`, `\"`, or backticks. These cause parse errors:
|
||||||
|
|
||||||
|
```tape
|
||||||
|
# FAILS — VHS parser rejects special chars
|
||||||
|
Type "echo \"hello $USER\""
|
||||||
|
Type "claude() { command claude \"$@\"; }"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Workaround: base64 encode the command**, decode at runtime:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Encode your complex command
|
||||||
|
echo 'claude() { command claude "$@" 2>&1 | grep -v "noise"; }' | base64
|
||||||
|
# Output: Y2xhdWRlKCkgey4uLn0K
|
||||||
|
|
||||||
|
# 2. Use in tape
|
||||||
|
Type "echo Y2xhdWRlKCkgey4uLn0K | base64 -d > /tmp/wrapper.sh && source /tmp/wrapper.sh"
|
||||||
|
```
|
||||||
|
|
||||||
|
This pattern is essential for output filtering, function definitions, and any command with shell special characters.
|
||||||
|
|
||||||
|
## Approaches
|
||||||
|
|
||||||
|
### 1. Automated Generation (Recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ${CLAUDE_SKILL_DIR}/scripts/auto_generate_demo.py \
|
||||||
|
-c "command1" -c "command2" \
|
||||||
-o output.gif \
|
-o output.gif \
|
||||||
--title "Installation Demo" \
|
--title "My Demo" \
|
||||||
--theme "Dracula" \
|
--theme "Catppuccin Latte" \
|
||||||
--width 1400 \
|
--font-size 24 \
|
||||||
--height 700
|
--width 1400 --height 600
|
||||||
```
|
```
|
||||||
|
|
||||||
**Script Parameters:**
|
| Flag | Default | Description |
|
||||||
- `-c, --command`: Command to include (can be specified multiple times)
|
|------|---------|-------------|
|
||||||
- `-o, --output`: Output GIF file path (required)
|
| `-c` | required | Command to include (repeatable) |
|
||||||
- `--title`: Demo title (optional, shown at start)
|
| `-o` | required | Output GIF path |
|
||||||
- `--theme`: VHS theme (default: Dracula)
|
| `--title` | none | Title shown at start |
|
||||||
- `--font-size`: Font size (default: 16)
|
| `--theme` | Dracula | VHS theme name |
|
||||||
- `--width`: Terminal width (default: 1400)
|
| `--font-size` | 16 | Font size in pt |
|
||||||
- `--height`: Terminal height (default: 700)
|
| `--width` | 1400 | Terminal width px |
|
||||||
- `--no-execute`: Generate tape file only, don't execute VHS
|
| `--height` | 700 | Terminal height px |
|
||||||
|
| `--bootstrap` | none | Hidden setup command (repeatable) |
|
||||||
|
| `--filter` | none | Regex pattern to filter from output |
|
||||||
|
| `--speed` | 1 | Playback speed multiplier (uses gifsicle) |
|
||||||
|
| `--no-execute` | false | Generate .tape only |
|
||||||
|
|
||||||
**Smart Features:**
|
Smart timing: `install`/`build`/`test`/`deploy` → 3s, `ls`/`pwd`/`echo` → 1s, others → 2s.
|
||||||
- Automatic timing based on command complexity
|
|
||||||
- Optimized sleep durations (1-3s depending on operation)
|
|
||||||
- Proper spacing between commands
|
|
||||||
- Professional defaults
|
|
||||||
|
|
||||||
### 2. Batch Demo Generation
|
### 2. Batch Generation
|
||||||
|
|
||||||
Use `batch_generate.py` for creating multiple demos from a configuration file.
|
Create multiple demos from one config:
|
||||||
|
|
||||||
**Configuration File (YAML):**
|
|
||||||
```yaml
|
```yaml
|
||||||
|
# demos.yaml
|
||||||
demos:
|
demos:
|
||||||
- name: "Install Demo"
|
- name: "Install"
|
||||||
output: "install.gif"
|
output: "install.gif"
|
||||||
title: "Installation"
|
commands: ["npm install my-package"]
|
||||||
theme: "Dracula"
|
- name: "Usage"
|
||||||
commands:
|
|
||||||
- "npm install my-package"
|
|
||||||
- "npm run build"
|
|
||||||
|
|
||||||
- name: "Usage Demo"
|
|
||||||
output: "usage.gif"
|
output: "usage.gif"
|
||||||
commands:
|
commands: ["my-package --help", "my-package run"]
|
||||||
- "my-package --help"
|
|
||||||
- "my-package run"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Usage:**
|
|
||||||
```bash
|
```bash
|
||||||
scripts/batch_generate.py config.yaml --output-dir ./demos
|
python3 ${CLAUDE_SKILL_DIR}/scripts/batch_generate.py demos.yaml --output-dir ./gifs
|
||||||
```
|
```
|
||||||
|
|
||||||
**When to Use Batch Generation:**
|
|
||||||
- Creating a suite of related demos
|
|
||||||
- Documenting multiple features
|
|
||||||
- Generating demos for tutorials or documentation
|
|
||||||
- Maintaining consistent demo series
|
|
||||||
|
|
||||||
### 3. Interactive Recording
|
### 3. Interactive Recording
|
||||||
|
|
||||||
Use `record_interactive.sh` for recording live terminal sessions.
|
Record a live terminal session:
|
||||||
|
|
||||||
**Usage:**
|
|
||||||
```bash
|
|
||||||
scripts/record_interactive.sh output.gif \
|
|
||||||
--theme "Dracula" \
|
|
||||||
--width 1400
|
|
||||||
```
|
|
||||||
|
|
||||||
**Recording Process:**
|
|
||||||
1. Script starts asciinema recording
|
|
||||||
2. Type commands naturally in your terminal
|
|
||||||
3. Press Ctrl+D when finished
|
|
||||||
4. Script auto-converts to GIF via VHS
|
|
||||||
|
|
||||||
**When to Use Interactive Recording:**
|
|
||||||
- Demonstrating complex workflows
|
|
||||||
- Showing real command output
|
|
||||||
- Capturing live interactions
|
|
||||||
- Recording debugging sessions
|
|
||||||
|
|
||||||
### 4. Manual Tape File Creation
|
|
||||||
|
|
||||||
For maximum control, create VHS tape files manually using templates.
|
|
||||||
|
|
||||||
**Available Templates:**
|
|
||||||
- `assets/templates/basic.tape` - Simple command demo
|
|
||||||
- `assets/templates/interactive.tape` - Typing simulation
|
|
||||||
|
|
||||||
**Example Workflow:**
|
|
||||||
1. Copy template: `cp assets/templates/basic.tape my-demo.tape`
|
|
||||||
2. Edit commands and timing
|
|
||||||
3. Generate GIF: `vhs < my-demo.tape`
|
|
||||||
|
|
||||||
Consult `references/vhs_syntax.md` for complete VHS syntax reference.
|
|
||||||
|
|
||||||
## Workflow Guidance
|
|
||||||
|
|
||||||
### For Simple Demos (1-3 commands)
|
|
||||||
|
|
||||||
Use automated generation for quick results:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
scripts/auto_generate_demo.py \
|
bash ${CLAUDE_SKILL_DIR}/scripts/record_interactive.sh output.gif --theme "Catppuccin Latte"
|
||||||
-c "echo 'Hello World'" \
|
# Type commands naturally, Ctrl+D when done
|
||||||
-c "ls -la" \
|
|
||||||
-o hello-demo.gif \
|
|
||||||
--title "Hello Demo"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### For Multiple Related Demos
|
Requires asciinema (`brew install asciinema`).
|
||||||
|
|
||||||
Create a batch configuration file and use batch generation:
|
### 4. Manual Tape File
|
||||||
|
|
||||||
1. Create `demos-config.yaml` with all demo definitions
|
For maximum control, write a tape directly. Templates in `assets/templates/`:
|
||||||
2. Run: `scripts/batch_generate.py demos-config.yaml --output-dir ./output`
|
|
||||||
3. All demos generate automatically with consistent settings
|
|
||||||
|
|
||||||
### For Interactive/Complex Workflows
|
- `basic.tape` — simple command sequence
|
||||||
|
- `interactive.tape` — typing simulation
|
||||||
|
- `self-bootstrap.tape` — **self-cleaning demo with hidden setup** (recommended for repeatable demos)
|
||||||
|
|
||||||
Use interactive recording to capture real behavior:
|
## Advanced Patterns
|
||||||
|
|
||||||
|
These patterns come from production use. See `references/advanced_patterns.md` for full details.
|
||||||
|
|
||||||
|
### Self-Bootstrapping Demos
|
||||||
|
|
||||||
|
Demos that clean previous state, set up environment, and hide all of it from the viewer:
|
||||||
|
|
||||||
|
```tape
|
||||||
|
Hide
|
||||||
|
Type "cleanup-previous-state 2>/dev/null"
|
||||||
|
Enter
|
||||||
|
Sleep 2s
|
||||||
|
Type "clear"
|
||||||
|
Enter
|
||||||
|
Sleep 500ms
|
||||||
|
Show
|
||||||
|
|
||||||
|
Type "the-command-users-see"
|
||||||
|
Enter
|
||||||
|
Sleep 3s
|
||||||
|
```
|
||||||
|
|
||||||
|
The `Hide` → commands → `clear` → `Show` sequence is critical. `clear` wipes the terminal buffer so hidden commands don't leak into the GIF.
|
||||||
|
|
||||||
|
### Output Noise Filtering
|
||||||
|
|
||||||
|
Filter noisy progress lines from commands that produce verbose output:
|
||||||
|
|
||||||
|
```tape
|
||||||
|
# Hidden: create a wrapper function that filters noise
|
||||||
|
Hide
|
||||||
|
Type "echo <base64-encoded-wrapper> | base64 -d > /tmp/w.sh && source /tmp/w.sh"
|
||||||
|
Enter
|
||||||
|
Sleep 500ms
|
||||||
|
Type "clear"
|
||||||
|
Enter
|
||||||
|
Sleep 500ms
|
||||||
|
Show
|
||||||
|
|
||||||
|
# Visible: clean command, filtered output
|
||||||
|
Type "my-noisy-command"
|
||||||
|
Enter
|
||||||
|
Sleep 3s
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frame Verification
|
||||||
|
|
||||||
|
After recording, verify GIF content by extracting key frames:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
scripts/record_interactive.sh my-workflow.gif
|
# Extract frames at specific positions
|
||||||
# Type commands naturally
|
ffmpeg -i demo.gif -vf "select=eq(n\,100)" -frames:v 1 /tmp/frame.png -y 2>/dev/null
|
||||||
# Ctrl+D when done
|
|
||||||
|
# View the frame (Claude can read images)
|
||||||
|
# Use Read tool on /tmp/frame.png to verify content
|
||||||
```
|
```
|
||||||
|
|
||||||
### For Custom Timing/Layout
|
### Post-Processing Speed-Up
|
||||||
|
|
||||||
Create manual tape file with precise control:
|
Use gifsicle to speed up recordings without re-recording:
|
||||||
|
|
||||||
1. Start with template or generate base tape with `--no-execute`
|
```bash
|
||||||
2. Edit timing, add comments, customize layout
|
# 2x speed (halve frame delay)
|
||||||
3. Generate: `vhs < custom-demo.tape`
|
gifsicle -d2 original.gif "#0-" > fast.gif
|
||||||
|
|
||||||
## Best Practices
|
# 1.5x speed
|
||||||
|
gifsicle -d4 original.gif "#0-" > faster.gif
|
||||||
|
```
|
||||||
|
|
||||||
Refer to `references/best_practices.md` for comprehensive guidelines. Key recommendations:
|
### Template Placeholder Pattern
|
||||||
|
|
||||||
**Timing:**
|
Keep tape files generic with placeholders, replace at build time:
|
||||||
- Quick commands (ls, pwd): 1s sleep
|
|
||||||
- Standard commands (grep, cat): 2s sleep
|
|
||||||
- Heavy operations (install, build): 3s+ sleep
|
|
||||||
|
|
||||||
**Sizing:**
|
```tape
|
||||||
- Standard: 1400x700 (recommended)
|
# In tape file
|
||||||
- Compact: 1200x600
|
Type "claude plugin marketplace add MARKETPLACE_REPO"
|
||||||
- Presentations: 1800x900
|
|
||||||
|
|
||||||
**Themes:**
|
# In build script
|
||||||
- Documentation: Nord, GitHub Dark
|
sed "s|MARKETPLACE_REPO|$DETECTED_REPO|g" template.tape > rendered.tape
|
||||||
- Code demos: Dracula, Monokai
|
vhs rendered.tape
|
||||||
- Presentations: High-contrast themes
|
```
|
||||||
|
|
||||||
**Duration:**
|
## Timing & Sizing Reference
|
||||||
- Target: 15-30 seconds
|
|
||||||
- Maximum: 60 seconds
|
| Context | Width | Height | Font | Duration |
|
||||||
- Create series for complex topics
|
|---------|-------|--------|------|----------|
|
||||||
|
| README/docs | 1400 | 600 | 16-20 | 10-20s |
|
||||||
|
| Presentation | 1800 | 900 | 24 | 15-30s |
|
||||||
|
| Compact embed | 1200 | 600 | 14-16 | 10-15s |
|
||||||
|
| Wide output | 1600 | 800 | 16 | 15-30s |
|
||||||
|
|
||||||
|
See `references/best_practices.md` for detailed guidelines.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### VHS Not Installed
|
| Problem | Solution |
|
||||||
|
|---------|----------|
|
||||||
```bash
|
| VHS not installed | `brew install charmbracelet/tap/vhs` |
|
||||||
# macOS
|
| gifsicle not installed | `brew install gifsicle` |
|
||||||
brew install vhs
|
| GIF too large | Reduce dimensions, sleep times, or use `--speed 2` |
|
||||||
|
| Text wraps/breaks | Increase `--width` or decrease `--font-size` |
|
||||||
# Linux (via Go)
|
| VHS parse error on `$` or `\"` | Use base64 encoding (see Critical section above) |
|
||||||
go install github.com/charmbracelet/vhs@latest
|
| Hidden commands leak into GIF | Add `clear` + `Sleep 500ms` before `Show` |
|
||||||
```
|
| Commands execute before previous finishes | Increase `Sleep` duration |
|
||||||
|
|
||||||
### Asciinema Not Installed
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# macOS
|
|
||||||
brew install asciinema
|
|
||||||
|
|
||||||
# Linux
|
|
||||||
sudo apt install asciinema
|
|
||||||
```
|
|
||||||
|
|
||||||
### Demo File Too Large
|
|
||||||
|
|
||||||
**Solutions:**
|
|
||||||
1. Reduce duration (shorter sleep times)
|
|
||||||
2. Use smaller dimensions (1200x600)
|
|
||||||
3. Consider MP4 format: `Output demo.mp4`
|
|
||||||
4. Split into multiple shorter demos
|
|
||||||
|
|
||||||
### Output Not Readable
|
|
||||||
|
|
||||||
**Solutions:**
|
|
||||||
1. Increase font size: `--font-size 18`
|
|
||||||
2. Use wider terminal: `--width 1600`
|
|
||||||
3. Choose high-contrast theme: `--theme "Dracula"`
|
|
||||||
4. Test on target display device
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
### Example 1: Quick Install Demo
|
|
||||||
|
|
||||||
User request: "Create a demo showing npm install"
|
|
||||||
|
|
||||||
```bash
|
|
||||||
scripts/auto_generate_demo.py \
|
|
||||||
-c "npm install my-package" \
|
|
||||||
-o install-demo.gif \
|
|
||||||
--title "Package Installation"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Example 2: Multi-Step Tutorial
|
|
||||||
|
|
||||||
User request: "Create a demo showing project setup with git clone, install, and run"
|
|
||||||
|
|
||||||
```bash
|
|
||||||
scripts/auto_generate_demo.py \
|
|
||||||
-c "git clone https://github.com/user/repo.git" \
|
|
||||||
-c "cd repo" \
|
|
||||||
-c "npm install" \
|
|
||||||
-c "npm start" \
|
|
||||||
-o setup-demo.gif \
|
|
||||||
--title "Project Setup" \
|
|
||||||
--theme "Nord"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Example 3: Batch Generation
|
|
||||||
|
|
||||||
User request: "Generate demos for all my CLI tool features"
|
|
||||||
|
|
||||||
1. Create `features-demos.yaml`:
|
|
||||||
```yaml
|
|
||||||
demos:
|
|
||||||
- name: "Help Command"
|
|
||||||
output: "help.gif"
|
|
||||||
commands: ["my-tool --help"]
|
|
||||||
|
|
||||||
- name: "Init Command"
|
|
||||||
output: "init.gif"
|
|
||||||
commands: ["my-tool init", "ls -la"]
|
|
||||||
|
|
||||||
- name: "Run Command"
|
|
||||||
output: "run.gif"
|
|
||||||
commands: ["my-tool run --verbose"]
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Generate all:
|
|
||||||
```bash
|
|
||||||
scripts/batch_generate.py features-demos.yaml --output-dir ./demos
|
|
||||||
```
|
|
||||||
|
|
||||||
### Example 4: Interactive Session
|
|
||||||
|
|
||||||
User request: "Record me using my CLI tool interactively"
|
|
||||||
|
|
||||||
```bash
|
|
||||||
scripts/record_interactive.sh my-session.gif --theme "Tokyo Night"
|
|
||||||
# User types commands naturally
|
|
||||||
# Ctrl+D to finish
|
|
||||||
```
|
|
||||||
|
|
||||||
## Bundled Resources
|
|
||||||
|
|
||||||
### scripts/
|
|
||||||
- **`auto_generate_demo.py`** - Automated demo generation from command lists
|
|
||||||
- **`batch_generate.py`** - Generate multiple demos from YAML/JSON config
|
|
||||||
- **`record_interactive.sh`** - Record and convert interactive terminal sessions
|
|
||||||
|
|
||||||
### references/
|
|
||||||
- **`vhs_syntax.md`** - Complete VHS tape file syntax reference
|
|
||||||
- **`best_practices.md`** - Demo creation guidelines and best practices
|
|
||||||
|
|
||||||
### assets/
|
|
||||||
- **`templates/basic.tape`** - Basic command demo template
|
|
||||||
- **`templates/interactive.tape`** - Interactive typing demo template
|
|
||||||
- **`examples/batch-config.yaml`** - Example batch configuration file
|
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
**Required:**
|
**Required:** VHS (`brew install charmbracelet/tap/vhs`)
|
||||||
- VHS (https://github.com/charmbracelet/vhs)
|
|
||||||
|
|
||||||
**Optional:**
|
**Optional:** gifsicle (speed-up), asciinema (interactive recording), ffmpeg (frame verification), PyYAML (batch YAML configs)
|
||||||
- asciinema (for interactive recording)
|
|
||||||
- PyYAML (for batch YAML configs): `pip install pyyaml`
|
|
||||||
|
|
||||||
## Output Formats
|
|
||||||
|
|
||||||
VHS supports multiple output formats:
|
|
||||||
|
|
||||||
```tape
|
|
||||||
Output demo.gif # GIF (default, best for documentation)
|
|
||||||
Output demo.mp4 # MP4 (better compression for long demos)
|
|
||||||
Output demo.webm # WebM (smaller file size)
|
|
||||||
```
|
|
||||||
|
|
||||||
Choose based on use case:
|
|
||||||
- **GIF**: Documentation, README files, easy embedding
|
|
||||||
- **MP4**: Longer demos, better quality, smaller size
|
|
||||||
- **WebM**: Web-optimized, smallest file size
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
This skill provides three main approaches:
|
|
||||||
|
|
||||||
1. **Automated** (`auto_generate_demo.py`) - Quick, easy, smart defaults
|
|
||||||
2. **Batch** (`batch_generate.py`) - Multiple demos, consistent settings
|
|
||||||
3. **Interactive** (`record_interactive.sh`) - Live recording, real output
|
|
||||||
|
|
||||||
Choose the approach that best fits the user's needs. For most cases, automated generation is the fastest and most convenient option.
|
|
||||||
|
|||||||
51
cli-demo-generator/assets/templates/self-bootstrap.tape
Normal file
51
cli-demo-generator/assets/templates/self-bootstrap.tape
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# Self-bootstrapping demo template
|
||||||
|
# Cleans previous state, sets up environment, records clean demo
|
||||||
|
#
|
||||||
|
# MARKETPLACE_REPO — replaced by recording script via sed
|
||||||
|
# BASE64_WRAPPER — base64-encoded output filter function
|
||||||
|
# To create: echo 'my_func() { command my_func "$@" 2>&1 | grep -v "noise"; }' | base64
|
||||||
|
|
||||||
|
Output demo.gif
|
||||||
|
Set Theme "Catppuccin Latte"
|
||||||
|
Set FontSize 24
|
||||||
|
Set Width 1400
|
||||||
|
Set Height 600
|
||||||
|
Set Padding 20
|
||||||
|
Set TypingSpeed 10ms
|
||||||
|
Set Shell zsh
|
||||||
|
|
||||||
|
# Hidden bootstrap: cleanup + output filter + clear screen
|
||||||
|
Hide
|
||||||
|
Type "cleanup-command-here 2>/dev/null"
|
||||||
|
Enter
|
||||||
|
Sleep 3s
|
||||||
|
# Base64-encoded wrapper filters noisy output lines.
|
||||||
|
# VHS cannot parse shell special chars ($, \") in Type strings, so base64 is the workaround.
|
||||||
|
Type "echo BASE64_WRAPPER | base64 -d > /tmp/cw.sh && source /tmp/cw.sh"
|
||||||
|
Enter
|
||||||
|
Sleep 500ms
|
||||||
|
Type "clear"
|
||||||
|
Enter
|
||||||
|
Sleep 500ms
|
||||||
|
Show
|
||||||
|
|
||||||
|
# Stage 1: Setup
|
||||||
|
Type "setup-command MARKETPLACE_REPO"
|
||||||
|
Enter
|
||||||
|
Sleep 8s
|
||||||
|
Enter
|
||||||
|
Sleep 300ms
|
||||||
|
|
||||||
|
# Stage 2: Main action
|
||||||
|
Type "main-command"
|
||||||
|
Enter
|
||||||
|
Sleep 3s
|
||||||
|
Enter
|
||||||
|
Sleep 300ms
|
||||||
|
|
||||||
|
# Stage 3: Verify
|
||||||
|
Type "verify-command"
|
||||||
|
Enter
|
||||||
|
Sleep 2s
|
||||||
|
|
||||||
|
Sleep 1s
|
||||||
240
cli-demo-generator/references/advanced_patterns.md
Normal file
240
cli-demo-generator/references/advanced_patterns.md
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
# Advanced VHS Demo Patterns
|
||||||
|
|
||||||
|
Battle-tested patterns from production demo recording workflows.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
- Self-bootstrapping tapes
|
||||||
|
- Output noise filtering with base64 wrapper
|
||||||
|
- Frame-level verification
|
||||||
|
- Post-processing with gifsicle
|
||||||
|
- Auto-detection and template rendering
|
||||||
|
- Recording script structure
|
||||||
|
|
||||||
|
## Self-Bootstrapping Tapes
|
||||||
|
|
||||||
|
A self-bootstrapping tape cleans its own state before recording, so running it twice produces identical output. Three phases:
|
||||||
|
|
||||||
|
```tape
|
||||||
|
# Phase 1: HIDDEN CLEANUP — remove previous state
|
||||||
|
Hide
|
||||||
|
Type "my-tool uninstall 2>/dev/null; my-tool reset 2>/dev/null"
|
||||||
|
Enter
|
||||||
|
Sleep 3s
|
||||||
|
|
||||||
|
# Phase 2: HIDDEN SETUP — create helpers (base64 for special chars)
|
||||||
|
Type "echo <base64-wrapper> | base64 -d > /tmp/helper.sh && source /tmp/helper.sh"
|
||||||
|
Enter
|
||||||
|
Sleep 500ms
|
||||||
|
|
||||||
|
# Phase 3: CLEAR + SHOW — wipe buffer before revealing
|
||||||
|
Type "clear"
|
||||||
|
Enter
|
||||||
|
Sleep 500ms
|
||||||
|
Show
|
||||||
|
|
||||||
|
# Phase 4: VISIBLE DEMO — what the viewer sees
|
||||||
|
Type "my-tool install"
|
||||||
|
Enter
|
||||||
|
Sleep 3s
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why `clear` before `Show`:** VHS's `Hide` stops recording frames, but the terminal buffer still accumulates text. Without `clear`, the hidden commands' text appears in the first visible frame.
|
||||||
|
|
||||||
|
## Output Noise Filtering with Base64
|
||||||
|
|
||||||
|
Many CLI tools produce verbose progress output that clutters demos. The solution: a hidden shell wrapper that filters noise lines.
|
||||||
|
|
||||||
|
### Step 1: Create the wrapper function
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# The function you want (can't type directly in VHS due to $/" chars)
|
||||||
|
my_tool() { command my_tool "$@" 2>&1 | grep -v -E "cache|progress|downloading|timeout"; }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Base64 encode it
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo 'my_tool() { command my_tool "$@" 2>&1 | grep -v -E "cache|progress|downloading|timeout"; }' | base64
|
||||||
|
# Output: bXlfdG9vbCgpIHsgY29tbWFuZC4uLn0K
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Use in tape
|
||||||
|
|
||||||
|
```tape
|
||||||
|
Hide
|
||||||
|
Type "echo bXlfdG9vbCgpIHsgY29tbWFuZC4uLn0K | base64 -d > /tmp/w.sh && source /tmp/w.sh"
|
||||||
|
Enter
|
||||||
|
Sleep 500ms
|
||||||
|
Type "clear"
|
||||||
|
Enter
|
||||||
|
Sleep 500ms
|
||||||
|
Show
|
||||||
|
|
||||||
|
# Now `my_tool` calls the wrapper — clean output
|
||||||
|
Type "my_tool deploy"
|
||||||
|
Enter
|
||||||
|
Sleep 5s
|
||||||
|
```
|
||||||
|
|
||||||
|
### When to filter
|
||||||
|
|
||||||
|
- Git operations: filter "Cloning", "Refreshing", cache messages
|
||||||
|
- Package managers: filter download progress, cache hits
|
||||||
|
- Build tools: filter intermediate compilation steps
|
||||||
|
- Any command with `SSH not configured`, `timeout: 120s`, etc.
|
||||||
|
|
||||||
|
## Frame-Level Verification
|
||||||
|
|
||||||
|
After recording, extract and inspect key frames to verify the GIF shows what you expect.
|
||||||
|
|
||||||
|
### Extract specific frames
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Frame at position N (0-indexed)
|
||||||
|
ffmpeg -i demo.gif -vf "select=eq(n\,100)" -frames:v 1 /tmp/frame_100.png -y 2>/dev/null
|
||||||
|
|
||||||
|
# Multiple frames at once
|
||||||
|
for n in 50 200 400; do
|
||||||
|
ffmpeg -i demo.gif -vf "select=eq(n\,$n)" -frames:v 1 "/tmp/frame_$n.png" -y 2>/dev/null
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check total frame count and duration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ffmpeg -i demo.gif 2>&1 | grep -E "Duration|fps"
|
||||||
|
# Duration: 00:00:10.50, ... 25 fps → 262 frames total
|
||||||
|
```
|
||||||
|
|
||||||
|
### What to verify
|
||||||
|
|
||||||
|
| Frame | Check |
|
||||||
|
|-------|-------|
|
||||||
|
| First (~frame 5) | No leaked hidden commands |
|
||||||
|
| Mid (~frame N/2) | Key output visible, no noise |
|
||||||
|
| Final (~frame N-10) | All commands completed, result shown |
|
||||||
|
|
||||||
|
### Claude can read frames
|
||||||
|
|
||||||
|
Use the Read tool on extracted PNG files — Claude's vision can verify text content in terminal screenshots.
|
||||||
|
|
||||||
|
## Post-Processing with gifsicle
|
||||||
|
|
||||||
|
Speed up or optimize GIFs after recording, avoiding re-recording.
|
||||||
|
|
||||||
|
### Speed control
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 2x speed — halve frame delay (most common)
|
||||||
|
gifsicle -d2 input.gif "#0-" > output.gif
|
||||||
|
|
||||||
|
# 1.5x speed
|
||||||
|
gifsicle -d4 input.gif "#0-" > output.gif
|
||||||
|
|
||||||
|
# 3x speed
|
||||||
|
gifsicle -d1 input.gif "#0-" > output.gif
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optimize file size
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Lossless optimization
|
||||||
|
gifsicle -O3 input.gif > optimized.gif
|
||||||
|
|
||||||
|
# Reduce colors (lossy but smaller)
|
||||||
|
gifsicle --colors 128 input.gif > smaller.gif
|
||||||
|
```
|
||||||
|
|
||||||
|
### Typical recording script pattern
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Record at normal speed
|
||||||
|
vhs demo.tape
|
||||||
|
|
||||||
|
# Speed up 2x for final output
|
||||||
|
cp demo.gif /tmp/demo_raw.gif
|
||||||
|
gifsicle -d2 /tmp/demo_raw.gif "#0-" > demo.gif
|
||||||
|
rm /tmp/demo_raw.gif
|
||||||
|
```
|
||||||
|
|
||||||
|
## Auto-Detection and Template Rendering
|
||||||
|
|
||||||
|
For demos that need to adapt to the environment (e.g., different repo URLs, detected tools).
|
||||||
|
|
||||||
|
### Template placeholders
|
||||||
|
|
||||||
|
Use `sed` to replace placeholders before recording:
|
||||||
|
|
||||||
|
```tape
|
||||||
|
# demo.tape (template)
|
||||||
|
Type "tool marketplace add REPO_PLACEHOLDER"
|
||||||
|
Enter
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build script detects the correct repo
|
||||||
|
REPO=$(detect_repo)
|
||||||
|
sed "s|REPO_PLACEHOLDER|$REPO|g" demo.tape > /tmp/rendered.tape
|
||||||
|
vhs /tmp/rendered.tape
|
||||||
|
```
|
||||||
|
|
||||||
|
### Auto-detect pattern (shell function)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
detect_repo() {
|
||||||
|
local upstream origin
|
||||||
|
upstream=$(git remote get-url upstream 2>/dev/null | sed 's|.*github.com[:/]||; s|\.git$||') || true
|
||||||
|
origin=$(git remote get-url origin 2>/dev/null | sed 's|.*github.com[:/]||; s|\.git$||') || true
|
||||||
|
|
||||||
|
# Check upstream first (canonical), then origin (fork)
|
||||||
|
if [[ -n "$upstream" ]] && gh api "repos/$upstream/contents/.target-file" &>/dev/null; then
|
||||||
|
echo "$upstream"
|
||||||
|
elif [[ -n "$origin" ]]; then
|
||||||
|
echo "$origin"
|
||||||
|
else
|
||||||
|
echo "fallback/default"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Recording Script Structure
|
||||||
|
|
||||||
|
A complete recording script follows this pattern:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
# 1. Check prerequisites
|
||||||
|
for cmd in vhs gifsicle; do
|
||||||
|
command -v "$cmd" &>/dev/null || { echo "Missing: $cmd"; exit 1; }
|
||||||
|
done
|
||||||
|
|
||||||
|
# 2. Auto-detect dynamic values
|
||||||
|
REPO=$(detect_repo)
|
||||||
|
echo "Using repo: $REPO"
|
||||||
|
|
||||||
|
# 3. Render tape template
|
||||||
|
sed "s|PLACEHOLDER|$REPO|g" "$SCRIPT_DIR/demo.tape" > /tmp/rendered.tape
|
||||||
|
|
||||||
|
# 4. Clean previous state
|
||||||
|
cleanup_state || true
|
||||||
|
|
||||||
|
# 5. Record
|
||||||
|
(cd "$REPO_DIR" && vhs /tmp/rendered.tape)
|
||||||
|
|
||||||
|
# 6. Speed up
|
||||||
|
cp "$REPO_DIR/demo.gif" /tmp/raw.gif
|
||||||
|
gifsicle -d2 /tmp/raw.gif "#0-" > "$REPO_DIR/demo.gif"
|
||||||
|
|
||||||
|
# 7. Clean up
|
||||||
|
cleanup_state || true
|
||||||
|
rm -f /tmp/raw.gif /tmp/rendered.tape
|
||||||
|
|
||||||
|
# 8. Report
|
||||||
|
SIZE=$(ls -lh "$REPO_DIR/demo.gif" | awk '{print $5}')
|
||||||
|
echo "Done: demo.gif ($SIZE)"
|
||||||
|
```
|
||||||
@@ -2,10 +2,14 @@
|
|||||||
"""
|
"""
|
||||||
Auto-generate CLI demos from command descriptions.
|
Auto-generate CLI demos from command descriptions.
|
||||||
|
|
||||||
This script creates VHS tape files and generates GIF demos automatically.
|
Creates VHS tape files and generates GIF demos with support for:
|
||||||
|
- Hidden bootstrap commands (self-cleaning state)
|
||||||
|
- Output noise filtering via base64-encoded wrapper
|
||||||
|
- Post-processing speed-up via gifsicle
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import base64
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -21,21 +25,54 @@ def create_tape_file(
|
|||||||
width: int = 1400,
|
width: int = 1400,
|
||||||
height: int = 700,
|
height: int = 700,
|
||||||
padding: int = 20,
|
padding: int = 20,
|
||||||
|
bootstrap: Optional[List[str]] = None,
|
||||||
|
filter_pattern: Optional[str] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Generate a VHS tape file from commands."""
|
"""Generate a VHS tape file from commands."""
|
||||||
|
|
||||||
tape_lines = [
|
tape_lines = [
|
||||||
f'Output {output_gif}',
|
f'Output {output_gif}',
|
||||||
'',
|
f'Set Theme "{theme}"',
|
||||||
f'Set FontSize {font_size}',
|
f'Set FontSize {font_size}',
|
||||||
f'Set Width {width}',
|
f'Set Width {width}',
|
||||||
f'Set Height {height}',
|
f'Set Height {height}',
|
||||||
f'Set Theme "{theme}"',
|
|
||||||
f'Set Padding {padding}',
|
f'Set Padding {padding}',
|
||||||
|
'Set TypingSpeed 10ms',
|
||||||
|
'Set Shell zsh',
|
||||||
'',
|
'',
|
||||||
]
|
]
|
||||||
|
|
||||||
# Add title if provided
|
# Hidden bootstrap: cleanup + optional output filter
|
||||||
|
has_hidden = bootstrap or filter_pattern
|
||||||
|
if has_hidden:
|
||||||
|
tape_lines.append('Hide')
|
||||||
|
|
||||||
|
if bootstrap:
|
||||||
|
# Combine all bootstrap commands with semicolons
|
||||||
|
combined = "; ".join(
|
||||||
|
cmd if "2>/dev/null" in cmd else f"{cmd} 2>/dev/null"
|
||||||
|
for cmd in bootstrap
|
||||||
|
)
|
||||||
|
tape_lines.append(f'Type "{combined}"')
|
||||||
|
tape_lines.append('Enter')
|
||||||
|
tape_lines.append('Sleep 3s')
|
||||||
|
|
||||||
|
if filter_pattern:
|
||||||
|
# Create a wrapper function that filters noisy output
|
||||||
|
wrapper = f'_wrap() {{ "$@" 2>&1 | grep -v -E "{filter_pattern}"; }}'
|
||||||
|
encoded = base64.b64encode(wrapper.encode()).decode()
|
||||||
|
tape_lines.append(f'Type "echo {encoded} | base64 -d > /tmp/cw.sh && source /tmp/cw.sh"')
|
||||||
|
tape_lines.append('Enter')
|
||||||
|
tape_lines.append('Sleep 500ms')
|
||||||
|
|
||||||
|
# Clear screen before Show to prevent hidden text from leaking
|
||||||
|
tape_lines.append('Type "clear"')
|
||||||
|
tape_lines.append('Enter')
|
||||||
|
tape_lines.append('Sleep 500ms')
|
||||||
|
tape_lines.append('Show')
|
||||||
|
tape_lines.append('')
|
||||||
|
|
||||||
|
# Title
|
||||||
if title:
|
if title:
|
||||||
tape_lines.extend([
|
tape_lines.extend([
|
||||||
f'Type "# {title}" Sleep 500ms Enter',
|
f'Type "# {title}" Sleep 500ms Enter',
|
||||||
@@ -43,68 +80,94 @@ def create_tape_file(
|
|||||||
'',
|
'',
|
||||||
])
|
])
|
||||||
|
|
||||||
# Add commands with smart timing
|
# Commands with smart timing
|
||||||
for i, cmd in enumerate(commands, 1):
|
for i, cmd in enumerate(commands, 1):
|
||||||
# Type the command
|
# If filter is active, prefix with _wrap
|
||||||
tape_lines.append(f'Type "{cmd}" Sleep 500ms')
|
if filter_pattern:
|
||||||
|
tape_lines.append(f'Type "_wrap {cmd}"')
|
||||||
|
else:
|
||||||
|
tape_lines.append(f'Type "{cmd}"')
|
||||||
tape_lines.append('Enter')
|
tape_lines.append('Enter')
|
||||||
|
|
||||||
# Smart sleep based on command complexity
|
# Smart sleep based on command complexity
|
||||||
if any(keyword in cmd.lower() for keyword in ['install', 'build', 'test', 'deploy']):
|
if any(kw in cmd.lower() for kw in ['install', 'build', 'test', 'deploy', 'marketplace']):
|
||||||
sleep_time = '3s'
|
sleep_time = '3s'
|
||||||
elif any(keyword in cmd.lower() for keyword in ['ls', 'pwd', 'echo', 'cat']):
|
elif any(kw in cmd.lower() for kw in ['ls', 'pwd', 'echo', 'cat', 'grep']):
|
||||||
sleep_time = '1s'
|
sleep_time = '1s'
|
||||||
else:
|
else:
|
||||||
sleep_time = '2s'
|
sleep_time = '2s'
|
||||||
|
|
||||||
tape_lines.append(f'Sleep {sleep_time}')
|
tape_lines.append(f'Sleep {sleep_time}')
|
||||||
|
|
||||||
# Add spacing between commands
|
# Empty line between stages for readability
|
||||||
if i < len(commands):
|
if i < len(commands):
|
||||||
|
tape_lines.append('Enter')
|
||||||
|
tape_lines.append('Sleep 300ms')
|
||||||
tape_lines.append('')
|
tape_lines.append('')
|
||||||
|
|
||||||
|
tape_lines.append('')
|
||||||
|
tape_lines.append('Sleep 1s')
|
||||||
return '\n'.join(tape_lines)
|
return '\n'.join(tape_lines)
|
||||||
|
|
||||||
|
|
||||||
|
def speed_up_gif(gif_path: str, speed: int) -> bool:
|
||||||
|
"""Speed up GIF using gifsicle. Returns True on success."""
|
||||||
|
try:
|
||||||
|
subprocess.run(['gifsicle', '--version'], capture_output=True, check=True)
|
||||||
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||||
|
print("⚠ gifsicle not found, skipping speed-up. Install: brew install gifsicle", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# delay = 10 / speed (10 = normal, 5 = 2x, 3 = ~3x)
|
||||||
|
delay = max(1, 10 // speed)
|
||||||
|
tmp = f"/tmp/demo_raw_{Path(gif_path).stem}.gif"
|
||||||
|
|
||||||
|
subprocess.run(['cp', gif_path, tmp], check=True)
|
||||||
|
with open(gif_path, 'wb') as out:
|
||||||
|
subprocess.run(['gifsicle', f'-d{delay}', tmp, '#0-'], stdout=out, check=True)
|
||||||
|
Path(tmp).unlink(missing_ok=True)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description='Auto-generate CLI demos from commands',
|
description='Auto-generate CLI demos from commands',
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
epilog='''
|
epilog='''
|
||||||
Examples:
|
Examples:
|
||||||
# Generate demo from single command
|
# Simple demo
|
||||||
%(prog)s -c "npm install" -o demo.gif
|
%(prog)s -c "npm install" -o demo.gif
|
||||||
|
|
||||||
# Generate demo with multiple commands
|
# With hidden bootstrap (self-cleaning)
|
||||||
%(prog)s -c "git clone repo" -c "cd repo" -c "npm install" -o setup.gif
|
%(prog)s -c "my-tool run" -o demo.gif \\
|
||||||
|
--bootstrap "my-tool reset" --speed 2
|
||||||
|
|
||||||
# Custom theme and size
|
# With output noise filtering
|
||||||
%(prog)s -c "ls -la" -o demo.gif --theme Monokai --width 1200
|
%(prog)s -c "deploy-tool push" -o demo.gif \\
|
||||||
|
--filter "cache|progress|downloading"
|
||||||
# With title
|
|
||||||
%(prog)s -c "echo Hello" -o demo.gif --title "My Demo"
|
|
||||||
'''
|
'''
|
||||||
)
|
)
|
||||||
|
|
||||||
parser.add_argument('-c', '--command', action='append', required=True,
|
parser.add_argument('-c', '--command', action='append', required=True,
|
||||||
help='Command to include in demo (can be specified multiple times)')
|
help='Command to include (repeatable)')
|
||||||
parser.add_argument('-o', '--output', required=True,
|
parser.add_argument('-o', '--output', required=True,
|
||||||
help='Output GIF file path')
|
help='Output GIF file path')
|
||||||
parser.add_argument('--title', help='Demo title (optional)')
|
parser.add_argument('--title', help='Demo title')
|
||||||
parser.add_argument('--theme', default='Dracula',
|
parser.add_argument('--theme', default='Dracula', help='VHS theme (default: Dracula)')
|
||||||
help='VHS theme (default: Dracula)')
|
parser.add_argument('--font-size', type=int, default=16, help='Font size (default: 16)')
|
||||||
parser.add_argument('--font-size', type=int, default=16,
|
parser.add_argument('--width', type=int, default=1400, help='Terminal width (default: 1400)')
|
||||||
help='Font size (default: 16)')
|
parser.add_argument('--height', type=int, default=700, help='Terminal height (default: 700)')
|
||||||
parser.add_argument('--width', type=int, default=1400,
|
parser.add_argument('--bootstrap', action='append',
|
||||||
help='Terminal width (default: 1400)')
|
help='Hidden setup command run before demo (repeatable)')
|
||||||
parser.add_argument('--height', type=int, default=700,
|
parser.add_argument('--filter',
|
||||||
help='Terminal height (default: 700)')
|
help='Regex pattern to filter from command output')
|
||||||
|
parser.add_argument('--speed', type=int, default=1,
|
||||||
|
help='Playback speed multiplier (default: 1, uses gifsicle)')
|
||||||
parser.add_argument('--no-execute', action='store_true',
|
parser.add_argument('--no-execute', action='store_true',
|
||||||
help='Generate tape file only, do not execute VHS')
|
help='Generate tape file only')
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
# Generate tape file content
|
|
||||||
tape_content = create_tape_file(
|
tape_content = create_tape_file(
|
||||||
commands=args.command,
|
commands=args.command,
|
||||||
output_gif=args.output,
|
output_gif=args.output,
|
||||||
@@ -113,37 +176,38 @@ Examples:
|
|||||||
font_size=args.font_size,
|
font_size=args.font_size,
|
||||||
width=args.width,
|
width=args.width,
|
||||||
height=args.height,
|
height=args.height,
|
||||||
|
bootstrap=args.bootstrap,
|
||||||
|
filter_pattern=args.filter,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Write tape file
|
|
||||||
output_path = Path(args.output)
|
output_path = Path(args.output)
|
||||||
tape_file = output_path.with_suffix('.tape')
|
tape_file = output_path.with_suffix('.tape')
|
||||||
|
tape_file.write_text(tape_content)
|
||||||
with open(tape_file, 'w') as f:
|
|
||||||
f.write(tape_content)
|
|
||||||
|
|
||||||
print(f"✓ Generated tape file: {tape_file}")
|
print(f"✓ Generated tape file: {tape_file}")
|
||||||
|
|
||||||
if not args.no_execute:
|
if not args.no_execute:
|
||||||
# Check if VHS is installed
|
|
||||||
try:
|
try:
|
||||||
subprocess.run(['vhs', '--version'], capture_output=True, check=True)
|
subprocess.run(['vhs', '--version'], capture_output=True, check=True)
|
||||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||||
print("✗ VHS is not installed!", file=sys.stderr)
|
print("✗ VHS not installed. Install: brew install charmbracelet/tap/vhs", file=sys.stderr)
|
||||||
print("Install it with: brew install vhs", file=sys.stderr)
|
print(f"✓ Run manually: vhs {tape_file}", file=sys.stderr)
|
||||||
print(f"✓ You can manually run: vhs < {tape_file}", file=sys.stderr)
|
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
# Execute VHS
|
print(f"Recording: {args.output}")
|
||||||
print(f"Generating GIF: {args.output}")
|
|
||||||
try:
|
try:
|
||||||
subprocess.run(['vhs', str(tape_file)], check=True)
|
subprocess.run(['vhs', str(tape_file)], check=True)
|
||||||
print(f"✓ Demo generated: {args.output}")
|
|
||||||
print(f" Size: {output_path.stat().st_size / 1024:.1f} KB")
|
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
print(f"✗ VHS execution failed: {e}", file=sys.stderr)
|
print(f"✗ VHS failed: {e}", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
# Post-processing speed-up
|
||||||
|
if args.speed > 1:
|
||||||
|
print(f"Speeding up {args.speed}x...")
|
||||||
|
speed_up_gif(args.output, args.speed)
|
||||||
|
|
||||||
|
size_kb = output_path.stat().st_size / 1024
|
||||||
|
print(f"✓ Done: {args.output} ({size_kb:.0f} KB)")
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
274
marketplace-dev/SKILL.md
Normal file
274
marketplace-dev/SKILL.md
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
---
|
||||||
|
name: marketplace-dev
|
||||||
|
description: |
|
||||||
|
Converts any Claude Code skills repository into an official plugin marketplace.
|
||||||
|
Analyzes existing skills, generates .claude-plugin/marketplace.json conforming to
|
||||||
|
the Anthropic spec, validates with `claude plugin validate`, tests real installation,
|
||||||
|
and creates a PR to the upstream repo. Encodes hard-won anti-patterns from real
|
||||||
|
marketplace development (schema traps, version semantics, description pitfalls).
|
||||||
|
Use when the user mentions: marketplace, plugin support, one-click install,
|
||||||
|
marketplace.json, plugin distribution, auto-update, or wants a skills repo
|
||||||
|
installable via `claude plugin install`. Also trigger when the user has a skills
|
||||||
|
repo and asks about packaging, distribution, or making it installable.
|
||||||
|
argument-hint: [repo-path]
|
||||||
|
---
|
||||||
|
|
||||||
|
# marketplace-dev
|
||||||
|
|
||||||
|
Convert a Claude Code skills repository into an official plugin marketplace so users
|
||||||
|
can install skills via `claude plugin marketplace add` and get auto-updates.
|
||||||
|
|
||||||
|
**Input**: a repo with `skills/` directories containing SKILL.md files.
|
||||||
|
**Output**: `.claude-plugin/marketplace.json` + validated + installation-tested + PR-ready.
|
||||||
|
|
||||||
|
## Phase 1: Analyze the Target Repo
|
||||||
|
|
||||||
|
### Step 1: Discover all skills
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Find every SKILL.md
|
||||||
|
find <repo-path>/skills -name "SKILL.md" -type f 2>/dev/null
|
||||||
|
```
|
||||||
|
|
||||||
|
For each skill, extract from SKILL.md frontmatter:
|
||||||
|
- `name` — the skill identifier
|
||||||
|
- `description` — the ORIGINAL text, do NOT rewrite or translate
|
||||||
|
|
||||||
|
### Step 2: Read the repo metadata
|
||||||
|
|
||||||
|
- `VERSION` file (if exists) — this becomes `metadata.version`
|
||||||
|
- `README.md` — understand the project, author info, categories
|
||||||
|
- `LICENSE` — note the license type
|
||||||
|
- Git remotes — identify upstream vs fork (`git remote -v`)
|
||||||
|
|
||||||
|
### Step 3: Determine categories
|
||||||
|
|
||||||
|
Group skills by function. Categories are freeform strings. Good patterns:
|
||||||
|
- `business-diagnostics`, `content-creation`, `thinking-tools`, `utilities`
|
||||||
|
- `developer-tools`, `productivity`, `documentation`, `security`
|
||||||
|
|
||||||
|
Ask the user to confirm categories if grouping is ambiguous.
|
||||||
|
|
||||||
|
## Phase 2: Create marketplace.json
|
||||||
|
|
||||||
|
### The official schema (memorize this)
|
||||||
|
|
||||||
|
Read `references/marketplace_schema.md` for the complete field reference.
|
||||||
|
Key rules that are NOT obvious from the docs:
|
||||||
|
|
||||||
|
1. **`$schema` field is REJECTED** by `claude plugin validate`. Do not include it.
|
||||||
|
2. **`metadata` only has 3 valid fields**: `description`, `version`, `pluginRoot`. Nothing else.
|
||||||
|
`metadata.homepage` does NOT exist — the validator accepts it silently but it's not in the spec.
|
||||||
|
3. **`metadata.version`** is the marketplace catalog version, NOT individual plugin versions.
|
||||||
|
It should match the repo's VERSION file (e.g., `"2.3.0"`).
|
||||||
|
4. **Plugin entry `version`** is independent. For first-time marketplace registration, use `"1.0.0"`.
|
||||||
|
5. **`strict: false`** is required when there's no `plugin.json` in the repo.
|
||||||
|
With `strict: false`, the marketplace entry IS the entire plugin definition.
|
||||||
|
Having BOTH `strict: false` AND a `plugin.json` with components causes a load failure.
|
||||||
|
6. **`source: "./"` with `skills: ["./skills/<name>"]`** is the pattern for skills in the same repo.
|
||||||
|
7. **Reserved marketplace names** that CANNOT be used: `claude-code-marketplace`,
|
||||||
|
`claude-code-plugins`, `claude-plugins-official`, `anthropic-marketplace`,
|
||||||
|
`anthropic-plugins`, `agent-skills`, `knowledge-work-plugins`, `life-sciences`.
|
||||||
|
8. **`tags` vs `keywords`**: Both are optional. In the current Claude Code source,
|
||||||
|
`keywords` is defined but never consumed in search. `tags` only has a UI effect
|
||||||
|
for the value `"community-managed"` (shows a label). Neither affects discovery.
|
||||||
|
The Discover tab searches only `name` + `description` + `marketplaceName`.
|
||||||
|
Include `keywords` for future-proofing but don't over-invest.
|
||||||
|
|
||||||
|
### Generate the marketplace.json
|
||||||
|
|
||||||
|
Use this template, filling in from the analysis:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "<marketplace-name>",
|
||||||
|
"owner": {
|
||||||
|
"name": "<github-org-or-username>"
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"description": "<one-line description of the marketplace>",
|
||||||
|
"version": "<from-VERSION-file-or-1.0.0>"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "<skill-name>",
|
||||||
|
"description": "<EXACT text from SKILL.md frontmatter, do NOT rewrite>",
|
||||||
|
"source": "./",
|
||||||
|
"strict": false,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"category": "<category>",
|
||||||
|
"keywords": ["<relevant>", "<keywords>"],
|
||||||
|
"skills": ["./skills/<skill-name>"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Naming the marketplace
|
||||||
|
|
||||||
|
The `name` field is what users type after `@` in install commands:
|
||||||
|
`claude plugin install dbs@<marketplace-name>`
|
||||||
|
|
||||||
|
Choose a name that is:
|
||||||
|
- Short and memorable
|
||||||
|
- kebab-case (lowercase, hyphens only)
|
||||||
|
- Related to the project identity, not generic
|
||||||
|
|
||||||
|
### Description rules
|
||||||
|
|
||||||
|
- **Use the ORIGINAL description from each SKILL.md frontmatter**
|
||||||
|
- Do NOT translate, embellish, or "improve" descriptions
|
||||||
|
- If the repo's audience is Chinese, keep descriptions in Chinese
|
||||||
|
- If bilingual, use the first language in the SKILL.md description field
|
||||||
|
- The `metadata.description` at marketplace level can be a new summary
|
||||||
|
|
||||||
|
## Maintaining an existing marketplace
|
||||||
|
|
||||||
|
When adding a new plugin to an existing marketplace.json:
|
||||||
|
|
||||||
|
1. **Bump `metadata.version`** — this is the marketplace catalog version.
|
||||||
|
Follow semver: new plugin = minor bump, breaking change = major bump.
|
||||||
|
2. **Update `metadata.description`** — append the new skill's summary.
|
||||||
|
3. **Set new plugin `version` to `"1.0.0"`** — it's new to the marketplace.
|
||||||
|
4. **Bump existing plugin `version`** when its SKILL.md content changes.
|
||||||
|
Claude Code uses version to detect updates — same version = skip update.
|
||||||
|
5. **Audit `metadata` for invalid fields** — `metadata.homepage` is a common
|
||||||
|
mistake (not in spec, silently ignored). Remove if found.
|
||||||
|
|
||||||
|
## Phase 3: Validate
|
||||||
|
|
||||||
|
### Step 1: CLI validation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
claude plugin validate .
|
||||||
|
```
|
||||||
|
|
||||||
|
This catches schema errors. Common failures and fixes:
|
||||||
|
- `Unrecognized key: "$schema"` → remove the `$schema` field
|
||||||
|
- `Duplicate plugin name` → ensure all names are unique
|
||||||
|
- `Path contains ".."` → use `./` relative paths only
|
||||||
|
|
||||||
|
### Step 2: Installation test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Add as local marketplace
|
||||||
|
claude plugin marketplace add .
|
||||||
|
|
||||||
|
# Install a plugin
|
||||||
|
claude plugin install <plugin-name>@<marketplace-name>
|
||||||
|
|
||||||
|
# Verify it appears
|
||||||
|
claude plugin list | grep <plugin-name>
|
||||||
|
|
||||||
|
# Check for updates (should say "already at latest")
|
||||||
|
claude plugin update <plugin-name>@<marketplace-name>
|
||||||
|
|
||||||
|
# Clean up
|
||||||
|
claude plugin uninstall <plugin-name>@<marketplace-name>
|
||||||
|
claude plugin marketplace remove <marketplace-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: GitHub installation test (if pushed)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test from GitHub (requires the branch to be pushed)
|
||||||
|
claude plugin marketplace add <github-user>/<repo>
|
||||||
|
claude plugin install <plugin-name>@<marketplace-name>
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
claude plugin list | grep <plugin-name>
|
||||||
|
|
||||||
|
# Clean up
|
||||||
|
claude plugin uninstall <plugin-name>@<marketplace-name>
|
||||||
|
claude plugin marketplace remove <marketplace-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pre-flight Checklist (MUST pass before proceeding to PR)
|
||||||
|
|
||||||
|
Run this checklist after every marketplace.json change. Do not skip items.
|
||||||
|
|
||||||
|
### Sync check: skills ↔ marketplace.json
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List all skill directories on disk
|
||||||
|
DISK_SKILLS=$(find skills -maxdepth 1 -mindepth 1 -type d -exec basename {} \; | sort)
|
||||||
|
|
||||||
|
# List all skills registered in marketplace.json
|
||||||
|
JSON_SKILLS=$(python3 -c "
|
||||||
|
import json
|
||||||
|
with open('.claude-plugin/marketplace.json') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
for p in data['plugins']:
|
||||||
|
for s in p.get('skills', []):
|
||||||
|
print(s.split('/')[-1])
|
||||||
|
" | sort)
|
||||||
|
|
||||||
|
# Compare — must match
|
||||||
|
diff <(echo "$DISK_SKILLS") <(echo "$JSON_SKILLS")
|
||||||
|
```
|
||||||
|
|
||||||
|
If diff shows output, skills are out of sync. Fix before proceeding.
|
||||||
|
|
||||||
|
### Metadata check
|
||||||
|
|
||||||
|
Verify these by reading marketplace.json:
|
||||||
|
|
||||||
|
- [ ] `metadata.version` bumped from previous version
|
||||||
|
- [ ] `metadata.description` mentions all skill categories
|
||||||
|
- [ ] No `metadata.homepage` (not in spec, silently ignored)
|
||||||
|
- [ ] No `$schema` field (rejected by validator)
|
||||||
|
|
||||||
|
### Per-plugin check
|
||||||
|
|
||||||
|
For each plugin entry:
|
||||||
|
|
||||||
|
- [ ] `description` matches SKILL.md frontmatter EXACTLY (not rewritten)
|
||||||
|
- [ ] `version` is `"1.0.0"` for new plugins, bumped for changed plugins
|
||||||
|
- [ ] `source` is `"./"` and `skills` path starts with `"./"`
|
||||||
|
- [ ] `strict` is `false` (no plugin.json in repo)
|
||||||
|
- [ ] `name` is kebab-case, unique across all entries
|
||||||
|
|
||||||
|
### Final validation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
claude plugin validate .
|
||||||
|
```
|
||||||
|
|
||||||
|
Must show `✔ Validation passed` before creating PR.
|
||||||
|
|
||||||
|
## Phase 4: Create PR
|
||||||
|
|
||||||
|
### Principles
|
||||||
|
- **Pure incremental**: do NOT modify any existing files (skills, README, etc.)
|
||||||
|
- **Squash commits**: avoid binary bloat in git history from iterative changes
|
||||||
|
- Only add: `.claude-plugin/marketplace.json`, optionally `scripts/`, optionally update README
|
||||||
|
|
||||||
|
### README update (if appropriate)
|
||||||
|
Add the marketplace install method above existing install instructions:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Install
|
||||||
|
|
||||||
|
 <!-- only if demo exists -->
|
||||||
|
|
||||||
|
**Claude Code plugin marketplace (one-click install, auto-update):**
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
claude plugin marketplace add <owner>/<repo>
|
||||||
|
claude plugin install <skill>@<marketplace-name>
|
||||||
|
\`\`\`
|
||||||
|
```
|
||||||
|
|
||||||
|
### PR description template
|
||||||
|
Include:
|
||||||
|
- What was added (marketplace.json with N skills, M categories)
|
||||||
|
- Install commands users will use after merge
|
||||||
|
- Design decisions (pure incremental, original descriptions, etc.)
|
||||||
|
- Validation evidence (`claude plugin validate .` passed)
|
||||||
|
- Test plan (install commands to verify)
|
||||||
|
|
||||||
|
## Anti-Patterns (things that went wrong and how to fix them)
|
||||||
|
|
||||||
|
Read `references/anti_patterns.md` for the full list of pitfalls discovered during
|
||||||
|
real marketplace development. These are NOT theoretical — every one was encountered
|
||||||
|
and debugged in production.
|
||||||
100
marketplace-dev/references/anti_patterns.md
Normal file
100
marketplace-dev/references/anti_patterns.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# Anti-Patterns: Marketplace Development Pitfalls
|
||||||
|
|
||||||
|
Every item below was encountered during real marketplace development.
|
||||||
|
Not theoretical — each cost debugging time.
|
||||||
|
|
||||||
|
## Schema Errors
|
||||||
|
|
||||||
|
### Adding `$schema` field
|
||||||
|
- **Symptom**: `claude plugin validate` fails with `Unrecognized key: "$schema"`
|
||||||
|
- **Fix**: Do not include `$schema`. Unlike many JSON schemas, Claude Code rejects it.
|
||||||
|
- **Why it's tempting**: Other marketplace examples (like daymade/claude-code-skills) include it,
|
||||||
|
and it works for JSON Schema-aware editors. But the validator is strict.
|
||||||
|
|
||||||
|
### Using `metadata.homepage`
|
||||||
|
- **Symptom**: Silently ignored — no error, no effect.
|
||||||
|
- **Fix**: `metadata` only supports `description`, `version`, `pluginRoot`. Put `homepage`
|
||||||
|
on individual plugin entries if needed.
|
||||||
|
- **Why it's tricky**: `homepage` IS valid on plugin entries (from plugin.json schema),
|
||||||
|
so it looks correct but is wrong at the metadata level.
|
||||||
|
|
||||||
|
### Conflicting `strict: false` with `plugin.json`
|
||||||
|
- **Symptom**: Plugin fails to load with "conflicting manifests" error.
|
||||||
|
- **Fix**: Choose one authority. `strict: false` means marketplace.json is the SOLE
|
||||||
|
definition. Remove plugin.json or set `strict: true` and let plugin.json define components.
|
||||||
|
|
||||||
|
## Version Confusion
|
||||||
|
|
||||||
|
### Using marketplace version as plugin version
|
||||||
|
- **Symptom**: All plugins show the same version (e.g., "2.3.0") even though each was
|
||||||
|
introduced at different times.
|
||||||
|
- **Fix**: `metadata.version` = marketplace catalog version (matches repo VERSION file).
|
||||||
|
Each plugin entry `version` is independent. First time in marketplace = `"1.0.0"`.
|
||||||
|
|
||||||
|
### Not bumping version after changes
|
||||||
|
- **Symptom**: Users don't receive updates after you push changes.
|
||||||
|
- **Fix**: Claude Code uses version to detect updates. Same version = skip.
|
||||||
|
Bump the plugin `version` in marketplace.json when you change skill content.
|
||||||
|
|
||||||
|
## Description Errors
|
||||||
|
|
||||||
|
### Rewriting or translating SKILL.md descriptions
|
||||||
|
- **Symptom**: Descriptions don't match the actual skill behavior. English descriptions
|
||||||
|
for a Chinese-audience repo feel foreign.
|
||||||
|
- **Fix**: Copy the EXACT `description` field from each SKILL.md frontmatter.
|
||||||
|
The author wrote it for their audience — preserve it.
|
||||||
|
|
||||||
|
### Inventing features in descriptions
|
||||||
|
- **Symptom**: Description promises "8,000+ consultations" or "auto-backup and rollback"
|
||||||
|
when the SKILL.md doesn't mention these specifics.
|
||||||
|
- **Fix**: Only state what the SKILL.md frontmatter says. If you want to add context,
|
||||||
|
use the marketplace-level `metadata.description`, not individual plugin descriptions.
|
||||||
|
|
||||||
|
## Installation Testing
|
||||||
|
|
||||||
|
### Not testing after GitHub push
|
||||||
|
- **Symptom**: Local validation passes, but `claude plugin marketplace add <user>/<repo>`
|
||||||
|
fails because it clones the default branch which doesn't have the marketplace.json.
|
||||||
|
- **Fix**: Push marketplace.json to the repo's default branch before testing GitHub install.
|
||||||
|
Feature branches only work if the user specifies the ref.
|
||||||
|
|
||||||
|
### Forgetting to clean up test installations
|
||||||
|
- **Symptom**: Next test run finds stale marketplace/plugins and produces confusing results.
|
||||||
|
- **Fix**: Always uninstall plugins and remove marketplace after testing:
|
||||||
|
```bash
|
||||||
|
claude plugin uninstall <plugin>@<marketplace> 2>/dev/null
|
||||||
|
claude plugin marketplace remove <marketplace> 2>/dev/null
|
||||||
|
```
|
||||||
|
|
||||||
|
## PR Best Practices
|
||||||
|
|
||||||
|
### Modifying existing files unnecessarily
|
||||||
|
- **Symptom**: PR diff includes unrelated changes (empty lines in .gitignore, whitespace
|
||||||
|
changes in README), making it harder to review and merge.
|
||||||
|
- **Fix**: Only add new files. If modifying README, be surgical — only add the install section.
|
||||||
|
Verify with `git diff upstream/main` that no unrelated files are touched.
|
||||||
|
|
||||||
|
### Not squashing commits
|
||||||
|
- **Symptom**: Git history has 15+ commits with iterative demo.gif changes, bloating the
|
||||||
|
repo by megabytes. Users cloning the marketplace download all this history.
|
||||||
|
- **Fix**: Squash all commits into one before creating the PR:
|
||||||
|
```bash
|
||||||
|
git reset --soft upstream/main
|
||||||
|
git commit -m "feat: add Claude Code plugin marketplace"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Discovery Misconceptions
|
||||||
|
|
||||||
|
### Over-investing in keywords/tags
|
||||||
|
- **Symptom**: Spending time crafting perfect keyword lists.
|
||||||
|
- **Reality**: In the current Claude Code source (verified by reading DiscoverPlugins.tsx),
|
||||||
|
the Discover tab searches ONLY `name` + `description` + `marketplaceName`.
|
||||||
|
`keywords` is defined in the schema but never consumed. `tags` only affects UI
|
||||||
|
for the specific value `"community-managed"`. Include keywords for future-proofing
|
||||||
|
but don't obsess over them.
|
||||||
|
|
||||||
|
### Using `tags` for search optimization
|
||||||
|
- **Symptom**: Adding `tags: ["business", "diagnosis"]` expecting search improvements.
|
||||||
|
- **Reality**: Only `tags: ["community-managed"]` has any effect (shows a UI label).
|
||||||
|
The official Anthropic marketplace (123 plugins) uses tags on only 3 plugins,
|
||||||
|
all with the value `["community-managed"]`.
|
||||||
92
marketplace-dev/references/marketplace_schema.md
Normal file
92
marketplace-dev/references/marketplace_schema.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# marketplace.json Complete Schema Reference
|
||||||
|
|
||||||
|
Source: https://code.claude.com/docs/en/plugin-marketplaces + https://code.claude.com/docs/en/plugins-reference
|
||||||
|
Verified against Claude Code source code and `claude plugin validate`.
|
||||||
|
|
||||||
|
## Root Level
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `name` | string | **Yes** | Marketplace identifier, kebab-case. Users see this: `plugin install X@<name>` |
|
||||||
|
| `owner` | object | **Yes** | `name` (required string), `email` (optional string) |
|
||||||
|
| `plugins` | array | **Yes** | Array of plugin entries |
|
||||||
|
| `metadata` | object | No | See metadata fields below |
|
||||||
|
|
||||||
|
### metadata fields (ONLY these 3 are valid)
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `metadata.description` | string | Brief marketplace description |
|
||||||
|
| `metadata.version` | string | Marketplace catalog version (NOT plugin version) |
|
||||||
|
| `metadata.pluginRoot` | string | Base directory prepended to relative plugin source paths |
|
||||||
|
|
||||||
|
**Invalid metadata fields** (silently ignored or rejected):
|
||||||
|
- `metadata.homepage` — does NOT exist in spec
|
||||||
|
- `metadata.repository` — does NOT exist in spec
|
||||||
|
- `$schema` — REJECTED by `claude plugin validate`
|
||||||
|
|
||||||
|
## Plugin Entry Fields
|
||||||
|
|
||||||
|
Each entry in the `plugins` array. Can include any field from the plugin.json
|
||||||
|
manifest schema, plus marketplace-specific fields.
|
||||||
|
|
||||||
|
### Required
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `name` | string | Plugin identifier, kebab-case |
|
||||||
|
| `source` | string or object | Where to fetch the plugin |
|
||||||
|
|
||||||
|
### Standard metadata (from plugin.json schema)
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `description` | string | Brief plugin description |
|
||||||
|
| `version` | string | Plugin version (independent of metadata.version) |
|
||||||
|
| `author` | object | `name` (required), `email` (optional), `url` (optional) |
|
||||||
|
| `homepage` | string | Plugin homepage URL |
|
||||||
|
| `repository` | string | Source code URL |
|
||||||
|
| `license` | string | SPDX identifier (MIT, Apache-2.0, etc.) |
|
||||||
|
| `keywords` | array | Tags for discovery (currently unused in search) |
|
||||||
|
|
||||||
|
### Marketplace-specific
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `category` | string | Freeform category for organization |
|
||||||
|
| `tags` | array | Tags for searchability (only `community-managed` has UI effect) |
|
||||||
|
| `strict` | boolean | Default: true. Set false when no plugin.json exists |
|
||||||
|
|
||||||
|
### Component paths (from plugin.json schema)
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `commands` | string or array | Custom command file/directory paths |
|
||||||
|
| `agents` | string or array | Custom agent file paths |
|
||||||
|
| `skills` | string or array | Custom skill directory paths |
|
||||||
|
| `hooks` | string or object | Hook configuration or path |
|
||||||
|
| `mcpServers` | string or object | MCP server config or path |
|
||||||
|
| `lspServers` | string or object | LSP server config or path |
|
||||||
|
| `outputStyles` | string or array | Output style paths |
|
||||||
|
| `userConfig` | object | User-configurable values prompted at enable time |
|
||||||
|
| `channels` | array | Channel declarations for message injection |
|
||||||
|
|
||||||
|
## Source Types
|
||||||
|
|
||||||
|
| Source | Format | Example |
|
||||||
|
|--------|--------|---------|
|
||||||
|
| Relative path | string `"./"` | `"source": "./"` |
|
||||||
|
| GitHub | object | `{"source": "github", "repo": "owner/repo"}` |
|
||||||
|
| Git URL | object | `{"source": "url", "url": "https://..."}` |
|
||||||
|
| Git subdirectory | object | `{"source": "git-subdir", "url": "...", "path": "..."}` |
|
||||||
|
| npm | object | `{"source": "npm", "package": "@scope/pkg"}` |
|
||||||
|
|
||||||
|
All object sources support optional `ref` (branch/tag) and `sha` (40-char commit).
|
||||||
|
|
||||||
|
## Reserved Marketplace Names
|
||||||
|
|
||||||
|
Cannot be used: `claude-code-marketplace`, `claude-code-plugins`,
|
||||||
|
`claude-plugins-official`, `anthropic-marketplace`, `anthropic-plugins`,
|
||||||
|
`agent-skills`, `knowledge-work-plugins`, `life-sciences`.
|
||||||
|
|
||||||
|
Names impersonating official marketplaces are also blocked.
|
||||||
64
marketplace-dev/scripts/post_edit_sync_check.sh
Executable file
64
marketplace-dev/scripts/post_edit_sync_check.sh
Executable file
@@ -0,0 +1,64 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# PostToolUse hook: warn when SKILL.md is edited but marketplace.json version not bumped
|
||||||
|
# Detects skill content changes that need a corresponding version bump in marketplace.json
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
INPUT=$(cat)
|
||||||
|
|
||||||
|
FILE_PATH=$(echo "$INPUT" | python3 -c "
|
||||||
|
import json, sys
|
||||||
|
try:
|
||||||
|
data = json.load(sys.stdin)
|
||||||
|
print(data.get('tool_input', {}).get('file_path', ''))
|
||||||
|
except:
|
||||||
|
print('')
|
||||||
|
" 2>/dev/null)
|
||||||
|
|
||||||
|
# Only care about SKILL.md edits
|
||||||
|
[[ "$FILE_PATH" != *"SKILL.md"* ]] && exit 0
|
||||||
|
|
||||||
|
# Find the skill name from path (e.g., /repo/skills/dbs-hook/SKILL.md → dbs-hook)
|
||||||
|
SKILL_DIR=$(dirname "$FILE_PATH")
|
||||||
|
SKILL_NAME=$(basename "$SKILL_DIR")
|
||||||
|
|
||||||
|
# Search for marketplace.json upward from the edited file
|
||||||
|
SEARCH_DIR="$SKILL_DIR"
|
||||||
|
MARKETPLACE_JSON=""
|
||||||
|
while [[ "$SEARCH_DIR" != "/" ]]; do
|
||||||
|
if [[ -f "$SEARCH_DIR/.claude-plugin/marketplace.json" ]]; then
|
||||||
|
MARKETPLACE_JSON="$SEARCH_DIR/.claude-plugin/marketplace.json"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
SEARCH_DIR=$(dirname "$SEARCH_DIR")
|
||||||
|
done
|
||||||
|
|
||||||
|
[[ -z "$MARKETPLACE_JSON" ]] && exit 0
|
||||||
|
|
||||||
|
# Check if this skill is registered and if version needs bumping
|
||||||
|
python3 -c "
|
||||||
|
import json, sys
|
||||||
|
|
||||||
|
with open('$MARKETPLACE_JSON') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
skill_name = '$SKILL_NAME'
|
||||||
|
found = False
|
||||||
|
for p in data.get('plugins', []):
|
||||||
|
skills = p.get('skills', [])
|
||||||
|
for s in skills:
|
||||||
|
if s.rstrip('/').split('/')[-1] == skill_name:
|
||||||
|
found = True
|
||||||
|
version = p.get('version', 'unknown')
|
||||||
|
print(json.dumps({
|
||||||
|
'result': f'SKILL.md for \"{skill_name}\" was modified. Remember to bump its version in marketplace.json (currently {version}). Users on the old version won\\'t receive this update otherwise.'
|
||||||
|
}))
|
||||||
|
break
|
||||||
|
if found:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not found:
|
||||||
|
print(json.dumps({
|
||||||
|
'result': f'SKILL.md for \"{skill_name}\" was modified but this skill is NOT registered in marketplace.json. Add it if you want it installable via plugin marketplace.'
|
||||||
|
}))
|
||||||
|
" 2>/dev/null
|
||||||
32
marketplace-dev/scripts/post_edit_validate.sh
Executable file
32
marketplace-dev/scripts/post_edit_validate.sh
Executable file
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# PostToolUse hook: auto-validate marketplace.json after Write/Edit
|
||||||
|
# Checks if the edited file is marketplace.json, runs validation if so.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Read tool use details from stdin
|
||||||
|
INPUT=$(cat)
|
||||||
|
|
||||||
|
# Check if the edited file is marketplace.json
|
||||||
|
FILE_PATH=$(echo "$INPUT" | python3 -c "
|
||||||
|
import json, sys
|
||||||
|
try:
|
||||||
|
data = json.load(sys.stdin)
|
||||||
|
path = data.get('tool_input', {}).get('file_path', '')
|
||||||
|
print(path)
|
||||||
|
except:
|
||||||
|
print('')
|
||||||
|
" 2>/dev/null)
|
||||||
|
|
||||||
|
if [[ "$FILE_PATH" == *"marketplace.json"* ]]; then
|
||||||
|
MARKETPLACE_DIR=$(dirname "$(dirname "$FILE_PATH")")
|
||||||
|
if [[ -f "$FILE_PATH" ]]; then
|
||||||
|
RESULT=$(cd "$MARKETPLACE_DIR" && claude plugin validate . 2>&1) || true
|
||||||
|
if echo "$RESULT" | grep -q "Validation passed"; then
|
||||||
|
echo '{"result": "marketplace.json validated ✔"}'
|
||||||
|
else
|
||||||
|
ERRORS=$(echo "$RESULT" | grep -v "^$" | head -5)
|
||||||
|
echo "{\"result\": \"marketplace.json validation FAILED:\\n$ERRORS\"}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
@@ -57,4 +57,6 @@ uv run --with weasyprint scripts/batch_convert.py *.md --output-dir ./pdfs
|
|||||||
|
|
||||||
**weasyprint import error**: Run with `uv run --with weasyprint` or use `--backend chrome` instead.
|
**weasyprint import error**: Run with `uv run --with weasyprint` or use `--backend chrome` instead.
|
||||||
|
|
||||||
|
**CJK text in code blocks garbled (weasyprint)**: The script auto-detects code blocks containing Chinese/Japanese/Korean characters and converts them to styled divs with CJK-capable fonts. If you still see issues, use `--backend chrome` which has native CJK support. Alternatively, convert code blocks to markdown tables before generating the PDF.
|
||||||
|
|
||||||
**Chrome header/footer appearing**: The script passes `--no-pdf-header-footer`. If it still appears, your Chrome version may not support this flag — update Chrome.
|
**Chrome header/footer appearing**: The script passes `--no-pdf-header-footer`. If it still appears, your Chrome version may not support this flag — update Chrome.
|
||||||
|
|||||||
@@ -125,6 +125,35 @@ def _ensure_list_spacing(text: str) -> str:
|
|||||||
return "\n".join(result)
|
return "\n".join(result)
|
||||||
|
|
||||||
|
|
||||||
|
_CJK_RANGE = re.compile(
|
||||||
|
r"[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff"
|
||||||
|
r"\U00020000-\U0002a6df\U0002a700-\U0002ebef"
|
||||||
|
r"\u3000-\u303f\uff00-\uffef]"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fix_cjk_code_blocks(html: str) -> str:
|
||||||
|
"""Replace <pre><code> blocks containing CJK with styled divs.
|
||||||
|
|
||||||
|
weasyprint renders <pre> blocks using monospace fonts that lack CJK glyphs,
|
||||||
|
causing garbled output. This converts CJK-heavy code blocks to styled divs
|
||||||
|
that use the document's CJK font stack instead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _replace_if_cjk(match: re.Match) -> str:
|
||||||
|
content = match.group(1)
|
||||||
|
if _CJK_RANGE.search(content):
|
||||||
|
return f'<div class="cjk-code-block">{content}</div>'
|
||||||
|
return match.group(0)
|
||||||
|
|
||||||
|
return re.sub(
|
||||||
|
r"<pre><code(?:\s[^>]*)?>(.+?)</code></pre>",
|
||||||
|
_replace_if_cjk,
|
||||||
|
html,
|
||||||
|
flags=re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _md_to_html(md_file: str) -> str:
|
def _md_to_html(md_file: str) -> str:
|
||||||
"""Convert markdown to HTML using pandoc with list spacing preprocessing."""
|
"""Convert markdown to HTML using pandoc with list spacing preprocessing."""
|
||||||
if not shutil.which("pandoc"):
|
if not shutil.which("pandoc"):
|
||||||
@@ -147,7 +176,9 @@ def _md_to_html(md_file: str) -> str:
|
|||||||
print(f"Error: pandoc failed: {result.stderr}", file=sys.stderr)
|
print(f"Error: pandoc failed: {result.stderr}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
return result.stdout
|
html = result.stdout
|
||||||
|
html = _fix_cjk_code_blocks(html)
|
||||||
|
return html
|
||||||
|
|
||||||
|
|
||||||
def _build_full_html(html_content: str, css: str, title: str) -> str:
|
def _build_full_html(html_content: str, css: str, title: str) -> str:
|
||||||
|
|||||||
@@ -86,3 +86,46 @@ hr {
|
|||||||
border-top: 1px solid #ccc;
|
border-top: 1px solid #ccc;
|
||||||
margin: 1.5em 0;
|
margin: 1.5em 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: 'Menlo', 'PingFang SC', 'Heiti SC', 'Noto Sans CJK SC', monospace;
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 10pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background: #f5f5f5;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin: 1em 0;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre code {
|
||||||
|
font-family: 'Menlo', 'PingFang SC', 'Heiti SC', 'Noto Sans CJK SC', monospace;
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
font-size: 9pt;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CJK code blocks converted to styled divs by preprocessor.
|
||||||
|
Uses inherit to reuse body's CJK font (weasyprint may not find PingFang SC). */
|
||||||
|
.cjk-code-block {
|
||||||
|
font-family: inherit;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin: 1em 0;
|
||||||
|
font-size: 10pt;
|
||||||
|
line-height: 1.8;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|||||||
@@ -110,12 +110,48 @@ header, .date {
|
|||||||
}
|
}
|
||||||
|
|
||||||
code {
|
code {
|
||||||
|
font-family: 'Menlo', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC', monospace;
|
||||||
background: #faf5f0;
|
background: #faf5f0;
|
||||||
padding: 1px 4px;
|
padding: 1px 4px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background: #faf5f0;
|
||||||
|
border: 1px solid #e2d6c8;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin: 10px 0;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre code {
|
||||||
|
font-family: 'Menlo', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC', monospace;
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CJK code blocks converted to styled divs by preprocessor.
|
||||||
|
Uses inherit to reuse body's CJK font (weasyprint may not resolve all font names). */
|
||||||
|
.cjk-code-block {
|
||||||
|
font-family: inherit;
|
||||||
|
background: #faf5f0;
|
||||||
|
border: 1px solid #e2d6c8;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin: 10px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.7;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
strong {
|
strong {
|
||||||
color: #1f1b17;
|
color: #1f1b17;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,15 +17,21 @@ All scripts use PEP 723 inline metadata — `uv run` auto-installs dependencies.
|
|||||||
# First time: Initialize database
|
# First time: Initialize database
|
||||||
uv run scripts/fix_transcription.py --init
|
uv run scripts/fix_transcription.py --init
|
||||||
|
|
||||||
# Phase 1: Dictionary corrections (instant, free)
|
# Single file
|
||||||
uv run scripts/fix_transcription.py --input meeting.md --stage 1
|
uv run scripts/fix_transcription.py --input meeting.md --stage 1
|
||||||
|
|
||||||
|
# Batch: multiple files in parallel (use shell loop)
|
||||||
|
for f in /path/to/*.txt; do
|
||||||
|
uv run scripts/fix_transcription.py --input "$f" --stage 1
|
||||||
|
done
|
||||||
```
|
```
|
||||||
|
|
||||||
After Stage 1, Claude reads the output and fixes remaining ASR errors natively (no API key needed):
|
After Stage 1, Claude reads the output and fixes remaining ASR errors natively (no API key needed):
|
||||||
1. Read Stage 1 output in ~200-line chunks using the Read tool
|
1. Read all Stage 1 outputs — read **entire** transcript before proposing corrections (later context disambiguates earlier errors)
|
||||||
2. Identify ASR errors — homophones, garbled terms, broken sentences
|
2. Identify ASR errors — compile all corrections across files
|
||||||
3. Present corrections in a table for user review before applying
|
3. Apply fixes with sed in batch, verify each with diff
|
||||||
4. Save stable patterns to dictionary for future reuse
|
4. Finalize: rename `_stage1.md` → `.md`, delete original `.txt`
|
||||||
|
5. Save stable patterns to dictionary for future reuse
|
||||||
|
|
||||||
See `references/example_session.md` for a concrete input/output walkthrough.
|
See `references/example_session.md` for a concrete input/output walkthrough.
|
||||||
|
|
||||||
@@ -51,6 +57,25 @@ Two-phase pipeline with persistent learning:
|
|||||||
|
|
||||||
**After fixing, always save reusable corrections to dictionary.** This is the skill's core value — see `references/iteration_workflow.md` for the complete checklist.
|
**After fixing, always save reusable corrections to dictionary.** This is the skill's core value — see `references/iteration_workflow.md` for the complete checklist.
|
||||||
|
|
||||||
|
### Dictionary Addition After Fixing
|
||||||
|
|
||||||
|
After native AI correction, review all applied fixes and decide which to save. Use this decision matrix:
|
||||||
|
|
||||||
|
| Pattern type | Example | Action |
|
||||||
|
|-------------|---------|--------|
|
||||||
|
| Non-word → correct term | 克劳锐→Claude, cloucode→Claude Code | ✅ Add (zero false positive risk) |
|
||||||
|
| Rare word → correct term | 潜彩→前采, 维星→韦青 | ✅ Add (verify it's not a real word first) |
|
||||||
|
| Person/company name ASR error | 宋天航→宋天生, 策马攀山→策马看山 | ✅ Add (stable, unique) |
|
||||||
|
| Common word → context word | 争→蒸, 钱财→前采, 报纸→标品 | ❌ Skip (high false positive risk) |
|
||||||
|
| Real brand → different brand | Xcode→Claude Code, Clover→Claude | ❌ Skip (real words in other contexts) |
|
||||||
|
|
||||||
|
Batch add multiple corrections in one session:
|
||||||
|
```bash
|
||||||
|
uv run scripts/fix_transcription.py --add "错误1" "正确1" --domain tech
|
||||||
|
uv run scripts/fix_transcription.py --add "错误2" "正确2" --domain business
|
||||||
|
# Chain with && for efficiency
|
||||||
|
```
|
||||||
|
|
||||||
## False Positive Prevention
|
## False Positive Prevention
|
||||||
|
|
||||||
Adding wrong dictionary rules silently corrupts future transcripts. **Read `references/false_positive_guide.md` before adding any correction rule**, especially for short words (≤2 chars) or common Chinese words that appear correctly in normal text.
|
Adding wrong dictionary rules silently corrupts future transcripts. **Read `references/false_positive_guide.md` before adding any correction rule**, especially for short words (≤2 chars) or common Chinese words that appear correctly in normal text.
|
||||||
@@ -59,21 +84,46 @@ Adding wrong dictionary rules silently corrupts future transcripts. **Read `refe
|
|||||||
|
|
||||||
When running inside Claude Code, use Claude's own language understanding for Phase 2:
|
When running inside Claude Code, use Claude's own language understanding for Phase 2:
|
||||||
|
|
||||||
1. Run Stage 1 (dictionary): `--input file.md --stage 1`
|
1. Run Stage 1 (dictionary) on all files (parallel if multiple)
|
||||||
2. Verify Stage 1 — diff original vs output. If dictionary introduced false positives, work from the **original** file
|
2. Verify Stage 1 — diff original vs output. If dictionary introduced false positives, work from the **original** file
|
||||||
3. Read the full text in ~200-line chunks. Read the entire transcript before proposing corrections — later context often disambiguates earlier errors
|
3. Read **all** Stage 1 outputs fully before proposing any corrections — later context often disambiguates earlier errors. For large files (>10k tokens), read in chunks but finish the entire file before identifying errors
|
||||||
4. Identify ASR errors:
|
4. Identify ASR errors per file — classify by confidence:
|
||||||
- Product/tool names: "close code" → "Claude Code", "get Hub" → "GitHub"
|
- **High confidence** (apply directly): non-words, obvious garbling, product name variants
|
||||||
- Technical terms: "Web coding" → "Vibe Coding", "happy pass" → "happy path"
|
- **Medium confidence** (present for review): context-dependent homophones, person names
|
||||||
- Homophone errors: "上海文" → "上下文", "分值" → "分支"
|
5. Apply fixes efficiently:
|
||||||
- English ASR garbling: "Pre top" → "prototype", "rapper" → "repo"
|
- **Global replacements** (unique non-words like "克劳锐"→"Claude"): use `sed -i ''` with `-e` flags, multiple patterns in one command
|
||||||
- Broken sentences: "很大程。路上" → "很大程度上"
|
- **Context-dependent** (common words like "争"→"蒸" only in distillation context): use sed with longer context phrases for uniqueness, or Edit tool
|
||||||
5. Present corrections in high/medium confidence tables with line numbers
|
6. Verify with diff: `diff original.txt corrected_stage1.md`
|
||||||
6. Apply with sed on a copy, verify with diff, replace original
|
7. Finalize files: rename `*_stage1.md` → `*.md`, delete original `.txt`
|
||||||
7. Generate word diff: `uv run scripts/generate_word_diff.py original.md corrected.md diff.html`
|
8. Save stable patterns to dictionary (see "Dictionary Addition" below)
|
||||||
8. Save stable patterns to dictionary
|
|
||||||
9. Remove false positives if Stage 1 had any
|
9. Remove false positives if Stage 1 had any
|
||||||
|
|
||||||
|
### Common ASR Error Patterns
|
||||||
|
|
||||||
|
AI product names are frequently garbled. These patterns recur across transcripts:
|
||||||
|
|
||||||
|
| Correct term | Common ASR variants |
|
||||||
|
|-------------|-------------------|
|
||||||
|
| Claude | cloud, Clou, calloc, 克劳锐, Clover, color |
|
||||||
|
| Claude Code | cloud code, Xcode, call code, cloucode, cloudcode, color code |
|
||||||
|
| Claude Agent SDK | cloud agent SDK |
|
||||||
|
| Opus | Opaas |
|
||||||
|
| Vibe Coding | web coding, Web coding |
|
||||||
|
| GitHub | get Hub, Git Hub |
|
||||||
|
| prototype | Pre top |
|
||||||
|
|
||||||
|
Person names and company names also produce consistent ASR errors across sessions — always add confirmed name corrections to the dictionary.
|
||||||
|
|
||||||
|
### Efficient Batch Fix Strategy
|
||||||
|
|
||||||
|
When fixing multiple files (e.g., 5 transcripts from one day):
|
||||||
|
|
||||||
|
1. **Stage 1 in parallel**: run all files through dictionary at once
|
||||||
|
2. **Read all files first**: build a mental model of speakers, topics, and recurring terms before fixing anything
|
||||||
|
3. **Compile a global correction list**: many errors repeat across files from the same session (same speakers, same topics)
|
||||||
|
4. **Apply global corrections first** (sed with multiple `-e` flags), then per-file context-dependent fixes
|
||||||
|
5. **Verify all diffs**, finalize all files, then do one dictionary addition pass
|
||||||
|
|
||||||
### Enhanced Capabilities (Native Mode Only)
|
### Enhanced Capabilities (Native Mode Only)
|
||||||
|
|
||||||
- **Intelligent paragraph breaks**: Add `\n\n` at logical topic transitions
|
- **Intelligent paragraph breaks**: Add `\n\n` at logical topic transitions
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
# /// script
|
||||||
|
# requires-python = ">=3.10"
|
||||||
|
# dependencies = [
|
||||||
|
# "httpx>=0.24.0",
|
||||||
|
# "filelock>=3.13.0",
|
||||||
|
# ]
|
||||||
|
# ///
|
||||||
"""
|
"""
|
||||||
Enhanced transcript fixer wrapper with improved user experience.
|
Enhanced transcript fixer wrapper with improved user experience.
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
# /// script
|
||||||
|
# requires-python = ">=3.10"
|
||||||
|
# dependencies = []
|
||||||
|
# ///
|
||||||
"""Normalize and repair speaker timestamp lines in ASR transcripts.
|
"""Normalize and repair speaker timestamp lines in ASR transcripts.
|
||||||
|
|
||||||
This script targets transcript lines shaped like:
|
This script targets transcript lines shaped like:
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
# /// script
|
||||||
|
# requires-python = ">=3.10"
|
||||||
|
# dependencies = [
|
||||||
|
# "httpx>=0.24.0",
|
||||||
|
# "filelock>=3.13.0",
|
||||||
|
# ]
|
||||||
|
# ///
|
||||||
"""
|
"""
|
||||||
Transcript Fixer - Main Entry Point
|
Transcript Fixer - Main Entry Point
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
# /// script
|
||||||
|
# requires-python = ">=3.10"
|
||||||
|
# dependencies = []
|
||||||
|
# ///
|
||||||
"""
|
"""
|
||||||
Generate Word-Level Diff HTML Comparison
|
Generate Word-Level Diff HTML Comparison
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
# /// script
|
||||||
|
# requires-python = ">=3.10"
|
||||||
|
# dependencies = []
|
||||||
|
# ///
|
||||||
"""Split a transcript into named sections and optionally rebase timestamps.
|
"""Split a transcript into named sections and optionally rebase timestamps.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|||||||
@@ -64,6 +64,10 @@ COMMON_WORDS_2CHAR: Set[str] = {
|
|||||||
"明确", "清晰", "具体", "详细", "准确", "完整", "稳定", "灵活",
|
"明确", "清晰", "具体", "详细", "准确", "完整", "稳定", "灵活",
|
||||||
# --- Domain terms that look like ASR errors but are valid ---
|
# --- Domain terms that look like ASR errors but are valid ---
|
||||||
"线数", "曲线", "分母", "正面", "旗号", "无果", "演技",
|
"线数", "曲线", "分母", "正面", "旗号", "无果", "演技",
|
||||||
|
# --- Common verb+一 patterns (打一个/来一个/做一下 etc.) ---
|
||||||
|
# "打一" caused production false positive: "打一个锚" → "答疑个锚" (2026-04)
|
||||||
|
"打一", "来一", "做一", "写一", "给一", "拉一", "开一", "看一",
|
||||||
|
"跑一", "找一", "选一", "试一", "走一", "问一", "搞一", "聊一",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Common 3+ character words that should also be protected.
|
# Common 3+ character words that should also be protected.
|
||||||
@@ -88,6 +92,14 @@ COMMON_WORDS_3PLUS: Set[str] = {
|
|||||||
"保健品", "保健操", "医疗保健",
|
"保健品", "保健操", "医疗保健",
|
||||||
"文化内涵",
|
"文化内涵",
|
||||||
"无果而终",
|
"无果而终",
|
||||||
|
# --- Common verb+一+量词 patterns (防止"打一"→X 类误纠) ---
|
||||||
|
"打一个", "打一针", "打一下", "打一次", "打一把",
|
||||||
|
"来一个", "来一下", "来一次", "来一杯",
|
||||||
|
"做一个", "做一下", "做一次",
|
||||||
|
"写一个", "写一下", "写一篇",
|
||||||
|
"给一个", "看一下", "看一看", "看一遍",
|
||||||
|
"跑一下", "跑一遍", "跑一次",
|
||||||
|
"试一下", "试一试", "试一次",
|
||||||
# --- Common Chinese idioms/phrases containing short words ---
|
# --- Common Chinese idioms/phrases containing short words ---
|
||||||
# These are needed to prevent idiom corruption
|
# These are needed to prevent idiom corruption
|
||||||
"正面临", "正面对",
|
"正面临", "正面对",
|
||||||
@@ -132,6 +144,8 @@ SUBSTRING_COLLISION_MAP: dict[str, list[str]] = {
|
|||||||
"保健": ["保健品", "保健操", "医疗保健"],
|
"保健": ["保健品", "保健操", "医疗保健"],
|
||||||
# "内涵" common in compound words
|
# "内涵" common in compound words
|
||||||
"内涵": ["内涵段子", "文化内涵"],
|
"内涵": ["内涵段子", "文化内涵"],
|
||||||
|
# "打一" common in verb+一+量词 (2026-04 production false positive)
|
||||||
|
"打一": ["打一个", "打一针", "打一下", "打一次", "打一把"],
|
||||||
}
|
}
|
||||||
|
|
||||||
ALL_COMMON_WORDS: Set[str] = COMMON_WORDS_2CHAR | COMMON_WORDS_3PLUS
|
ALL_COMMON_WORDS: Set[str] = COMMON_WORDS_2CHAR | COMMON_WORDS_3PLUS
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
Security scan passed
|
Security scan passed
|
||||||
Scanned at: 2026-01-11T15:27:31.794239
|
Scanned at: 2026-04-06T16:24:46.159857
|
||||||
Tool: gitleaks + pattern-based validation
|
Tool: gitleaks + pattern-based validation
|
||||||
Content hash: 2349813d82b8932f89fdc653c0dde1b2335483478227653554553b59007975c1
|
Content hash: bfcc70ffa4bfda5e6308942605ad8dc3cf62e8aeade5b00c6d9b774bebb190fb
|
||||||
|
|||||||
@@ -1,72 +1,156 @@
|
|||||||
---
|
---
|
||||||
name: twitter-reader
|
name: twitter-reader
|
||||||
description: Fetch Twitter/X post content by URL using jina.ai API to bypass JavaScript restrictions. Use when Claude needs to retrieve tweet content including author, timestamp, post text, images, and thread replies. Supports individual posts or batch fetching from x.com or twitter.com URLs.
|
description: Fetch Twitter/X post content including long-form Articles with full images and metadata. Use when Claude needs to retrieve tweet/article content, author info, engagement metrics, and embedded media. Supports individual posts and X Articles (long-form content). Automatically downloads all images to local attachments folder and generates complete Markdown with proper image references. Preferred over Jina for X Articles with images.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Twitter Reader
|
# Twitter Reader
|
||||||
|
|
||||||
Fetch Twitter/X post content without needing JavaScript or authentication.
|
Fetch Twitter/X post and article content with full media support.
|
||||||
|
|
||||||
|
## Quick Start (Recommended)
|
||||||
|
|
||||||
|
For X Articles with images, use the new fetch_article.py script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run --with pyyaml python scripts/fetch_article.py <article_url> [output_dir]
|
||||||
|
```
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```bash
|
||||||
|
uv run --with pyyaml python scripts/fetch_article.py \
|
||||||
|
https://x.com/HiTw93/status/2040047268221608281 \
|
||||||
|
./Clippings
|
||||||
|
```
|
||||||
|
|
||||||
|
This will:
|
||||||
|
- Fetch structured data via `twitter-cli` (likes, retweets, bookmarks)
|
||||||
|
- Fetch content with images via `jina.ai` API
|
||||||
|
- Download all images to `attachments/YYYY-MM-DD-AUTHOR-TITLE/`
|
||||||
|
- Generate complete Markdown with embedded image references
|
||||||
|
- Include YAML frontmatter with metadata
|
||||||
|
|
||||||
|
### Example Output
|
||||||
|
|
||||||
|
```
|
||||||
|
Fetching: https://x.com/HiTw93/status/2040047268221608281
|
||||||
|
--------------------------------------------------
|
||||||
|
Getting metadata...
|
||||||
|
Title: 你不知道的大模型训练:原理、路径与新实践
|
||||||
|
Author: Tw93
|
||||||
|
Likes: 1648
|
||||||
|
|
||||||
|
Getting content and images...
|
||||||
|
Images: 15
|
||||||
|
|
||||||
|
Downloading 15 images...
|
||||||
|
✓ 01-image.jpg
|
||||||
|
✓ 02-image.jpg
|
||||||
|
...
|
||||||
|
|
||||||
|
✓ Saved: ./Clippings/2026-04-03-文章标题.md
|
||||||
|
✓ Images: ./Clippings/attachments/2026-04-03-HiTw93-.../ (15 downloaded)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Alternative: Jina API (Text-only)
|
||||||
|
|
||||||
|
For simple text-only fetching without authentication:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Single tweet
|
||||||
|
curl "https://r.jina.ai/https://x.com/USER/status/TWEET_ID" \
|
||||||
|
-H "Authorization: Bearer ${JINA_API_KEY}"
|
||||||
|
|
||||||
|
# Batch fetching
|
||||||
|
scripts/fetch_tweets.sh url1 url2 url3
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Full Article Mode (fetch_article.py)
|
||||||
|
- ✅ Structured metadata (author, date, engagement metrics)
|
||||||
|
- ✅ Automatic image download (all embedded media)
|
||||||
|
- ✅ Complete Markdown with local image references
|
||||||
|
- ✅ YAML frontmatter for PKM systems
|
||||||
|
- ✅ Handles X Articles (long-form content)
|
||||||
|
|
||||||
|
### Simple Mode (Jina API)
|
||||||
|
- Text-only content
|
||||||
|
- No authentication required beyond Jina API key
|
||||||
|
- Good for quick text extraction
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
You need a Jina API key to use this skill:
|
### For Full Article Mode
|
||||||
|
- `uv` (Python package manager)
|
||||||
1. Visit https://jina.ai/ to sign up (free tier available)
|
- No additional setup (twitter-cli auto-installed)
|
||||||
2. Get your API key from the dashboard
|
|
||||||
3. Set the environment variable:
|
|
||||||
|
|
||||||
|
### For Simple Mode (Jina)
|
||||||
```bash
|
```bash
|
||||||
export JINA_API_KEY="your_api_key_here"
|
export JINA_API_KEY="your_api_key_here"
|
||||||
|
# Get from https://jina.ai/
|
||||||
```
|
```
|
||||||
|
|
||||||
## Quick Start
|
## Output Structure
|
||||||
|
|
||||||
For a single tweet, use curl directly:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl "https://r.jina.ai/https://x.com/USER/status/TWEET_ID" \
|
|
||||||
-H "Authorization: Bearer ${JINA_API_KEY}"
|
|
||||||
```
|
```
|
||||||
|
output_dir/
|
||||||
For multiple tweets, use the bundled script:
|
├── YYYY-MM-DD-article-title.md # Main Markdown file
|
||||||
|
└── attachments/
|
||||||
```bash
|
└── YYYY-MM-DD-author-title/
|
||||||
scripts/fetch_tweets.sh url1 url2 url3
|
├── 01-image.jpg
|
||||||
|
├── 02-image.jpg
|
||||||
|
└── ...
|
||||||
```
|
```
|
||||||
|
|
||||||
## What Gets Returned
|
## What Gets Returned
|
||||||
|
|
||||||
|
### Full Article Mode
|
||||||
|
- **YAML Frontmatter**: source, author, date, likes, retweets, bookmarks
|
||||||
|
- **Markdown Content**: Full article text with local image references
|
||||||
|
- **Attachments**: All downloaded images in dedicated folder
|
||||||
|
|
||||||
|
### Simple Mode
|
||||||
- **Title**: Post author and content preview
|
- **Title**: Post author and content preview
|
||||||
- **URL Source**: Original tweet link
|
- **URL Source**: Original tweet link
|
||||||
- **Published Time**: GMT timestamp
|
- **Published Time**: GMT timestamp
|
||||||
- **Markdown Content**: Full post text with media descriptions
|
- **Markdown Content**: Text with remote media URLs
|
||||||
|
|
||||||
## Bundled Scripts
|
|
||||||
|
|
||||||
### fetch_tweet.py
|
|
||||||
|
|
||||||
Python script for fetching individual tweets.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python scripts/fetch_tweet.py https://x.com/user/status/123 output.md
|
|
||||||
```
|
|
||||||
|
|
||||||
### fetch_tweets.sh
|
|
||||||
|
|
||||||
Bash script for batch fetching multiple tweets.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
scripts/fetch_tweets.sh \
|
|
||||||
"https://x.com/user/status/123" \
|
|
||||||
"https://x.com/user/status/456"
|
|
||||||
```
|
|
||||||
|
|
||||||
## URL Formats Supported
|
## URL Formats Supported
|
||||||
|
|
||||||
- `https://x.com/USER/status/ID`
|
- `https://x.com/USER/status/ID` (posts)
|
||||||
- `https://twitter.com/USER/status/ID`
|
- `https://x.com/USER/article/ID` (long-form articles)
|
||||||
- `https://x.com/...` (redirects work automatically)
|
- `https://twitter.com/USER/status/ID` (legacy)
|
||||||
|
|
||||||
## Environment Variables
|
## Scripts
|
||||||
|
|
||||||
- `JINA_API_KEY`: Required. Your Jina.ai API key for accessing the reader API
|
### fetch_article.py
|
||||||
|
Full-featured article fetcher with image download:
|
||||||
|
```bash
|
||||||
|
uv run --with pyyaml python scripts/fetch_article.py <url> [output_dir]
|
||||||
|
```
|
||||||
|
|
||||||
|
### fetch_tweet.py
|
||||||
|
Simple text-only fetcher using Jina API:
|
||||||
|
```bash
|
||||||
|
python scripts/fetch_tweet.py <tweet_url> [output_file]
|
||||||
|
```
|
||||||
|
|
||||||
|
### fetch_tweets.sh
|
||||||
|
Batch fetch multiple tweets (Jina API):
|
||||||
|
```bash
|
||||||
|
scripts/fetch_tweets.sh <url1> <url2> ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration from Jina API
|
||||||
|
|
||||||
|
Old workflow:
|
||||||
|
```bash
|
||||||
|
curl "https://r.jina.ai/https://x.com/..."
|
||||||
|
# Manual image extraction and download
|
||||||
|
```
|
||||||
|
|
||||||
|
New workflow:
|
||||||
|
```bash
|
||||||
|
uv run --with pyyaml python scripts/fetch_article.py <url>
|
||||||
|
# Automatic image download, complete Markdown
|
||||||
|
```
|
||||||
|
|||||||
247
twitter-reader/scripts/fetch_article.py
Normal file
247
twitter-reader/scripts/fetch_article.py
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Fetch Twitter/X Article with images using twitter-cli.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python fetch_article.py <article_url> [output_dir]
|
||||||
|
|
||||||
|
Example:
|
||||||
|
python fetch_article.py https://x.com/HiTw93/status/2040047268221608281 ./Clippings
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Fetches structured data via twitter-cli
|
||||||
|
- Downloads all images to attachments folder
|
||||||
|
- Generates Markdown with embedded image references
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
def run_twitter_cli(url: str) -> dict:
|
||||||
|
"""Fetch article data using twitter-cli via uv run."""
|
||||||
|
cmd = ["uv", "run", "--with", "twitter-cli", "twitter", "article", url]
|
||||||
|
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f"Error fetching article: {result.stderr}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
return parse_yaml_output(result.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def run_jina_api(url: str) -> str:
|
||||||
|
"""Fetch article text with images using Jina API."""
|
||||||
|
api_key = os.getenv("JINA_API_KEY", "")
|
||||||
|
jina_url = f"https://r.jina.ai/{url}"
|
||||||
|
|
||||||
|
cmd = ["curl", "-s", jina_url]
|
||||||
|
if api_key:
|
||||||
|
cmd.extend(["-H", f"Authorization: Bearer {api_key}"])
|
||||||
|
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f"Warning: Jina API failed: {result.stderr}", file=sys.stderr)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def parse_yaml_output(output: str) -> dict:
|
||||||
|
"""Parse twitter-cli YAML output into dict."""
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
data = yaml.safe_load(output)
|
||||||
|
if data.get("ok") and "data" in data:
|
||||||
|
return data["data"]
|
||||||
|
return data
|
||||||
|
except ImportError:
|
||||||
|
print("Error: PyYAML required. Install with: uv pip install pyyaml", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error parsing YAML: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_image_urls(text: str) -> list:
|
||||||
|
"""Extract image URLs from markdown text."""
|
||||||
|
# Extract all pbs.twimg.com URLs (note: twimg not twitter)
|
||||||
|
pattern = r'https://pbs\.twimg\.com/media/[^\s\)"\']+'
|
||||||
|
matches = re.findall(pattern, text)
|
||||||
|
|
||||||
|
# Deduplicate and normalize to large size
|
||||||
|
seen = set()
|
||||||
|
urls = []
|
||||||
|
for url in matches:
|
||||||
|
base_url = url.split('?')[0]
|
||||||
|
if base_url not in seen:
|
||||||
|
seen.add(base_url)
|
||||||
|
urls.append(f"{base_url}?format=jpg&name=large")
|
||||||
|
|
||||||
|
return urls
|
||||||
|
|
||||||
|
|
||||||
|
def download_images(image_urls: list, attachments_dir: Path) -> list:
|
||||||
|
"""Download images and return list of local paths."""
|
||||||
|
attachments_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
local_paths = []
|
||||||
|
|
||||||
|
for i, url in enumerate(image_urls, 1):
|
||||||
|
filename = f"{i:02d}-image.jpg"
|
||||||
|
filepath = attachments_dir / filename
|
||||||
|
|
||||||
|
cmd = ["curl", "-sL", url, "-o", str(filepath)]
|
||||||
|
result = subprocess.run(cmd, capture_output=True)
|
||||||
|
|
||||||
|
if result.returncode == 0 and filepath.exists() and filepath.stat().st_size > 0:
|
||||||
|
local_paths.append(f"attachments/{attachments_dir.name}/{filename}")
|
||||||
|
print(f" ✓ {filename}")
|
||||||
|
else:
|
||||||
|
print(f" ✗ Failed: {filename}")
|
||||||
|
|
||||||
|
return local_paths
|
||||||
|
|
||||||
|
|
||||||
|
def replace_image_urls(text: str, image_urls: list, local_paths: list) -> str:
|
||||||
|
"""Replace remote image URLs with local paths in markdown text."""
|
||||||
|
for remote_url, local_path in zip(image_urls, local_paths):
|
||||||
|
# Extract base URL pattern
|
||||||
|
base_url = remote_url.split('?')[0].replace('?format=jpg&name=large', '')
|
||||||
|
# Replace all variations of this URL
|
||||||
|
pattern = re.escape(base_url) + r'(\?[^\)]*)?'
|
||||||
|
text = re.sub(pattern, local_path, text)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_filename(name: str) -> str:
|
||||||
|
"""Sanitize string for use in filename."""
|
||||||
|
# Remove special chars, keep alphanumeric, CJK, and some safe chars
|
||||||
|
name = re.sub(r'[^\w\s\-\u4e00-\u9fff]', '', name)
|
||||||
|
name = re.sub(r'\s+', '-', name.strip())
|
||||||
|
return name[:60] # Limit length
|
||||||
|
|
||||||
|
|
||||||
|
def generate_markdown(data: dict, text: str, image_urls: list, local_paths: list, source_url: str) -> str:
|
||||||
|
"""Generate complete Markdown document."""
|
||||||
|
# Parse date
|
||||||
|
created = data.get("createdAtLocal", "")
|
||||||
|
if created:
|
||||||
|
date_str = created[:10]
|
||||||
|
else:
|
||||||
|
date_str = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
author = data.get("author", {})
|
||||||
|
metrics = data.get("metrics", {})
|
||||||
|
title = data.get("articleTitle", "Untitled")
|
||||||
|
|
||||||
|
# Build frontmatter
|
||||||
|
md = f"""---
|
||||||
|
source: {source_url}
|
||||||
|
author: {author.get("name", "")}
|
||||||
|
date: {date_str}
|
||||||
|
likes: {metrics.get("likes", 0)}
|
||||||
|
retweets: {metrics.get("retweets", 0)}
|
||||||
|
bookmarks: {metrics.get("bookmarks", 0)}
|
||||||
|
---
|
||||||
|
|
||||||
|
# {title}
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Replace image URLs with local paths
|
||||||
|
if image_urls and local_paths:
|
||||||
|
text = replace_image_urls(text, image_urls, local_paths)
|
||||||
|
|
||||||
|
md += text
|
||||||
|
return md
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Fetch Twitter/X Article with images")
|
||||||
|
parser.add_argument("url", help="Twitter/X article URL")
|
||||||
|
parser.add_argument("output_dir", nargs="?", default=".", help="Output directory (default: current)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not args.url.startswith(("https://x.com/", "https://twitter.com/")):
|
||||||
|
print("Error: URL must be from x.com or twitter.com", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"Fetching: {args.url}")
|
||||||
|
print("-" * 50)
|
||||||
|
|
||||||
|
# Fetch metadata from twitter-cli
|
||||||
|
print("Getting metadata...")
|
||||||
|
data = run_twitter_cli(args.url)
|
||||||
|
|
||||||
|
title = data.get("articleTitle", "")
|
||||||
|
if not title:
|
||||||
|
print("Error: Could not fetch article data", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
author = data.get("author", {})
|
||||||
|
|
||||||
|
print(f"Title: {title}")
|
||||||
|
print(f"Author: {author.get('name', 'Unknown')}")
|
||||||
|
print(f"Likes: {data.get('metrics', {}).get('likes', 0)}")
|
||||||
|
|
||||||
|
# Fetch content with images from Jina API
|
||||||
|
print("\nGetting content and images...")
|
||||||
|
jina_content = run_jina_api(args.url)
|
||||||
|
|
||||||
|
# Use Jina content if available, otherwise fall back to twitter-cli text
|
||||||
|
if jina_content:
|
||||||
|
text = jina_content
|
||||||
|
# Remove Jina header lines to get clean markdown
|
||||||
|
# Find "Markdown Content:" and keep everything after it
|
||||||
|
marker = "Markdown Content:"
|
||||||
|
idx = text.find(marker)
|
||||||
|
if idx != -1:
|
||||||
|
text = text[idx + len(marker):].lstrip()
|
||||||
|
else:
|
||||||
|
text = data.get("articleText", "")
|
||||||
|
|
||||||
|
# Extract image URLs
|
||||||
|
image_urls = extract_image_urls(text)
|
||||||
|
print(f"Images: {len(image_urls)}")
|
||||||
|
|
||||||
|
# Setup output paths
|
||||||
|
output_dir = Path(args.output_dir)
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Create attachments folder
|
||||||
|
date_str = data.get("createdAtLocal", "")[:10] if data.get("createdAtLocal") else datetime.now().strftime("%Y-%m-%d")
|
||||||
|
safe_author = sanitize_filename(author.get("screenName", "unknown"))
|
||||||
|
safe_title = sanitize_filename(title)
|
||||||
|
attachments_name = f"{date_str}-{safe_author}-{safe_title[:30]}"
|
||||||
|
attachments_dir = output_dir / "attachments" / attachments_name
|
||||||
|
|
||||||
|
# Download images
|
||||||
|
local_paths = []
|
||||||
|
if image_urls:
|
||||||
|
print(f"\nDownloading {len(image_urls)} images...")
|
||||||
|
local_paths = download_images(image_urls, attachments_dir)
|
||||||
|
|
||||||
|
# Generate Markdown
|
||||||
|
md_content = generate_markdown(data, text, image_urls, local_paths, args.url)
|
||||||
|
|
||||||
|
# Save Markdown
|
||||||
|
md_filename = f"{date_str}-{safe_title}.md"
|
||||||
|
md_path = output_dir / md_filename
|
||||||
|
md_path.write_text(md_content, encoding="utf-8")
|
||||||
|
|
||||||
|
print(f"\n✓ Saved: {md_path}")
|
||||||
|
if local_paths:
|
||||||
|
print(f"✓ Images: {attachments_dir} ({len(local_paths)} downloaded)")
|
||||||
|
|
||||||
|
return md_path
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user