solo-github-outreach
Use when "github outreach", "competitor dependents", "dependents scan", "propose our lib", or targeting users of competitor libraries. NOT for Reddit (/reddit) or social copy (/content-gen).
Install
npx skills add https://github.com/fortunto2/solo-factory/tree/main/skills/github-outreach
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install fortunto2-solo-factory@llmmart
git clone https://github.com/fortunto2/solo-factory.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole fortunto2/solo-factory collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
/github-outreach
Competitive outreach pipeline. Scan a competitor's dependents, evaluate which repos would benefit from switching to your library, and draft personalized GitHub issues.
Works with any crate/package — not specific to any product.
Scripts
Use these instead of reimplementing from scratch:
scripts/init_jsonl.py <repos.txt> <out.jsonl>— convert repo list to JSONLscripts/enrich.py <jsonl> [--batch 30]— add stars/description viagh api, skip forks/archivedscripts/evaluate.py <jsonl> <owner/repo>— deep-evaluate a repo (README + Cargo.toml + feature detection)scripts/evaluate.py <jsonl> --next— pick next highest-star enriched reposcripts/status.py <jsonl> [--targets] [--csv]— show pipeline status table
Data Format
All data lives in data/outreach/{competitor}/ in the project directory:
data/outreach/{competitor}/
config.json # Competitor, our product, feature matrix
dependents.jsonl # One JSON object per repo (append-only)
progress.json # Cursor: last evaluated index, stats
dependents.jsonl schema
Each line is a JSON object:
{
"repo": "owner/name",
"stars": 1234,
"description": "...",
"language": "Rust",
"last_push": "2026-03-20T...",
"archived": false,
"phase": "raw|enriched|evaluated|drafted|posted|skipped",
"score": 0,
"features_used": ["streaming", "tools", "embeddings"],
"our_advantages": ["websocket", "structured_outputs"],
"verdict": "skip|maybe|target",
"verdict_reason": "fork of AppFlowy, not original",
"draft_title": "",
"draft_body": "",
"issue_url": "",
"evaluated_at": "",
"notes": ""
}
JSONL is append-friendly and grep/jq compatible. Render as table with status command.
Routing
| User says | Action |
|---|---|
/outreach setup |
Configure competitor + our product |
/outreach enrich |
Add stars/description via gh api |
/outreach next |
Evaluate next unevaluated repo |
/outreach evaluate [repo] |
Deep-evaluate a specific repo |
/outreach draft [repo] |
Generate issue text for a target |
/outreach status |
Show progress table |
/outreach batch [N] |
Evaluate next N repos (default 5) |
Setup
First-time configuration.
Ask or read from
$ARGUMENTS:- Competitor: crate name or
owner/repo(e.g.,async-openai) - Our product: crate name + repo URL
- Feature matrix file path (or build interactively)
- Competitor: crate name or
Create
data/outreach/{competitor}/config.json:
{
"competitor": "async-openai",
"competitor_repo": "64bit/async-openai",
"our_product": "openai-oxide",
"our_repo": "fortunto2/openai-oxide",
"feature_matrix": {
"persistent_websockets": {"us": true, "them": false, "impact": "high", "pitch": "..."},
"structured_outputs": {"us": true, "them": false, "impact": "high", "pitch": "..."}
}
}
- Check if
dependents.jsonlexists. If not, run scraper:
Convert to JSONL with phase=raw.scripts/scrape-dependents.sh {competitor_repo} 50 > /tmp/deps.txt
Enrich
Fast pass: add GitHub metadata to all phase=raw entries.
# For each raw entry, call gh api
gh api repos/{owner}/{repo} --jq '{
stars: .stargazers_count,
description: .description,
language: .language,
last_push: .pushed_at,
archived: .archived,
topics: .topics
}'
- Update phase to
enriched - Skip if
gh apireturns 404 (private/deleted) — set phase=skipped - Rate limit: batch 30 per minute (authenticated), save after each batch
- Sort by stars descending for evaluation priority
Evaluate (per repo)
Deep analysis of a single repo. This is where the agent thinks.
Step 1: Quick filter (skip obvious non-targets)
- Archived? Skip
- Fork with <5 stars? Skip (not the original)
- Last push >1 year ago? Skip (abandoned)
- Stars <3 and no meaningful description? Skip
Step 2: Read README
gh api repos/{owner}/{repo}/readme --jq '.content' | base64 -d
Understand: what does this project do? How do they use the competitor?
Step 3: Read Cargo.toml (find usage pattern)
gh api repos/{owner}/{repo}/contents/Cargo.toml --jq '.content' | base64 -d
Which features do they use? What else is in their dependency tree?
Step 3b: Check if they forked the competitor
If Cargo.toml references the competitor via git = "..." (not crates.io), they forked it — this is a HIGH SIGNAL:
# Find their fork
gh api repos/{fork_owner}/{competitor_name}/commits --jq '.[0:5] | .[] | "\(.sha[0:7]) \(.commit.message | split("\n")[0])"'
Compare their fork commits against upstream. If they added a feature we already have (e.g. schemars for structured outputs), that's our strongest pitch: "you can drop the fork and use us — we have that built-in." Record exactly what they added in notes.
Step 4: Grep for usage patterns (optional, for top targets)
If stars >50, clone shallow and grep:
git clone --depth 1 {url} /tmp/outreach-eval
grep -rn "async.openai\|ChatCompletion\|stream\|tool_call\|embedding" /tmp/outreach-eval/src/
rm -rf /tmp/outreach-eval
Map findings to feature signals (streaming, tools, structured, websocket, etc.)
Step 5: Score and verdict
Score based on:
- Stars (weight: 30%) — reach/impact
- Activity (weight: 20%) — will they actually migrate?
- Feature fit (weight: 30%) — do our advantages matter to them?
- Approachability (weight: 20%) — open to contributions? Has issues enabled?
Verdict:
- target (score >= 60) — worth creating an issue
- maybe (score 30-59) — revisit later
- skip (score < 30) — not worth effort
Update JSONL entry with phase=evaluated.
Step 6: Cleanup
Always rm -rf /tmp/outreach-eval after analysis.
Draft (per repo)
Generate a personalized GitHub issue for a target repo.
- Load config (feature matrix, pitches)
- Load evaluation data (features_used, our_advantages)
- Draft issue using
references/issue-templates.md - Key rules:
- Never generic — reference their specific use case
- Lead with their problem — not our solution
- Offer concrete benefit — "your agent loop would be 40% faster with persistent WebSockets"
- No hard sell — "you might find this useful" tone
- Include migration path — show how imports change
- Save draft to JSONL entry (phase=drafted)
- Output draft for user review before posting
Status
Show progress across all repos.
Outreach: async-openai → openai-oxide
Phase Count
─────────────────
raw 12
enriched 340
evaluated 180
→ target 8
→ maybe 47
→ skip 125
drafted 3
posted 1
skipped 59
─────────────────
Total 591
Top targets (not yet drafted):
1. fastrepl/char (8068★) — streaming + agent loop
2. risingwavelabs/risingwave (7000★) — embeddings
...
Read from JSONL, aggregate by phase/verdict.
Batch Mode
Evaluate next N repos efficiently.
- Load JSONL, filter phase=enriched, sort by stars desc
- For each (up to N):
- Run Evaluate flow
- Print one-line result
- Continue to next (no pause)
- Print batch summary
Critical Rules
- Never post issues without user approval — draft only, user reviews
- Never clone repos larger than 100MB — check size via
gh apifirst - Always cleanup —
rm -rf /tmp/outreach-evalafter every evaluation - Rate limit gh api — max 30 requests per batch, save progress
- Respect repos — if issues are disabled, skip. If they said no, mark as skipped
- One issue per repo — never spam
- Personalize everything — generic "try our lib" issues get ignored and damage reputation
- JSONL is append-only — update by rewriting the line (match by repo field)
Gotchas
- Forks dominate dependents — 50%+ of dependents are forks of big projects (AppFlowy, meilisearch). Filter by checking if the repo is a fork via
gh api.forkfield. Only evaluate originals. - gh api rate limit — 5000/h authenticated but large scans hit it. Use
--paginatesparingly. CheckX-RateLimit-Remainingheader. - README doesn't show actual usage — a repo may list async-openai in Cargo.toml but barely use it. Always check Cargo.toml features and grep source.
- Stale dependents — GitHub's dependency graph is delayed. Some repos may have already switched away. Check Cargo.lock if available.
- Issue tone matters enormously — "I noticed you use X, have you tried Y?" works. "X is slow, switch to Y" does not. See
references/issue-templates.md. - Competitor forks are the strongest signal — if a repo uses
git = "..."instead of crates.io, they forked the competitor because it's missing something. Check the fork diff (usually 1-3 commits). If they added a feature we already have, that's our #1 pitch — "drop your fork, we have it built-in." Example: fastrepl/char forked async-openai to add schemars → ourstructuredfeature does exactly that.
Files (solo-factory)
-
references
-
issue-templates.md 3.8 KB
# GitHub Issue Templates for Outreach ## Principles - Lead with THEIR problem, not your solution - Reference specific code/features they use - Show concrete benefit with numbers - Include migration snippet (1-3 lines) - "You might find useful" tone, never "you should switch" - Always disclose: "I'm the maintainer of X" ## Template 1: Performance Win (streaming/agent loops) ```markdown Title: Potential performance improvement for {their_feature} with persistent WebSockets Hi! I noticed {repo_name} uses async-openai for {what_they_do}. I maintain [openai-oxide](https://github.com/fortunto2/openai-oxide), a Rust OpenAI client that keeps a single WebSocket connection open across multiple API calls. In benchmarks with sequential tool calls (similar to your {specific_pattern}), this reduces latency by ~40% compared to HTTP REST — no TLS handshake overhead after the first request. If you're interested, the migration is minimal: ```toml # Cargo.toml openai-oxide = { version = "0.9", features = ["websocket", "responses"] } ``` ```rust let mut session = client.ws_session().await?; // reuse session across calls let response = session.send(request).await?; ``` Happy to help if you'd like to try it. No pressure — just thought it might be relevant given your use of {streaming/tool_calls/agent_loop}. Disclosure: I'm the maintainer of openai-oxide. ``` ## Template 2: Structured Outputs (type safety) ```markdown Title: Auto-generated JSON schemas for structured outputs Hi! Looking at {repo_name}, I see you're building JSON schemas manually for {their_structured_usage}. You might find `openai-oxide`'s `parse::<T>()` useful — it auto-generates the schema from your Rust types: ```rust #[derive(Deserialize, JsonSchema)] struct YourType { /* fields */ } let result = client.chat().completions() .parse::<YourType>(request).await?; // result.parsed is Option<YourType> ``` No manual schema construction, no drift between types and schemas. Docs: https://fortunto2.github.io/openai-oxide/guides/structured-output.html Disclosure: I maintain openai-oxide. Happy to answer questions. ``` ## Template 3: WASM Deployment ```markdown Title: WASM/Cloudflare Workers support for {repo_name} Hi! I noticed {repo_name} {uses_wasm_or_edge_context}. openai-oxide compiles to `wasm32-unknown-unknown` out of the box — streaming, structured outputs, and retry logic all work in WASM. We have a live Cloudflare Workers demo: https://cloudflare-worker-dioxus.nameless-sunset-8f24.workers.dev ```toml openai-oxide = { version = "0.9", default-features = false, features = ["chat", "responses"] } ``` If edge deployment is on your roadmap, this might save you some cfg-gating work. Disclosure: I maintain openai-oxide. ``` ## Template 4: HTTP Optimizations (general) ```markdown Title: HTTP/2 optimizations for OpenAI API calls Hi! I saw {repo_name} makes OpenAI API calls via async-openai. You might see a latency improvement with these HTTP-level optimizations that openai-oxide enables by default: - gzip compression (~30% smaller responses) - TCP_NODELAY (lower latency) - HTTP/2 keep-alive pings (prevents idle disconnects) - HTTP/2 adaptive flow control - Connection pooling (4 per host) These are standard reqwest builder options — you could also add them to your current setup. Here's the relevant code if helpful: https://github.com/fortunto2/openai-oxide/blob/main/src/client.rs#L85 Disclosure: I maintain openai-oxide. ``` ## Anti-patterns (never do this) - "async-openai is slow/bad/outdated" — disrespectful - "You should switch to X" — pushy - Generic copy-paste to 50 repos — spam, gets you reported - Issues on repos with <5 stars — waste of time - Issues on archived repos — nobody home - Multiple issues on same repo — harassment - Not disclosing you're the maintainer — dishonest
-
-
scripts
-
enrich.py 4 KB
#!/usr/bin/env python3 """Enrich JSONL dependents with GitHub metadata via gh api. Usage: python3 enrich.py <jsonl_path> [--batch 30] """ # list-env-sensitive-calls: allow gh — GH_HOST is how a user points this at their # own GitHub host, so it is the interface here rather than noise, and a wrong host # fails on authentication instead of returning plausible data. import json import subprocess import sys import time from pathlib import Path def gh_api(endpoint: str) -> dict | None: """Call gh api and return parsed JSON.""" try: result = subprocess.run( ["gh", "api", endpoint, "--jq", "."], capture_output=True, text=True, timeout=15, ) if result.returncode == 0: return json.loads(result.stdout) except Exception: pass return None def enrich_jsonl(path: str, batch_size: int = 30): jsonl_path = Path(path) lines = jsonl_path.read_text().strip().split("\n") entries = [json.loads(line) for line in lines if line.strip()] enriched_count = 0 skipped_count = 0 for i, entry in enumerate(entries): if entry.get("phase") != "raw": continue repo = entry["repo"] print(f"[{i + 1}/{len(entries)}] {repo}...", end=" ", flush=True) data = gh_api(f"repos/{repo}") if data is None: entry["phase"] = "skipped" entry["verdict_reason"] = "404 or private" skipped_count += 1 print("SKIP (404)") elif data.get("archived"): entry.update( { "stars": data.get("stargazers_count", 0), "description": data.get("description", ""), "archived": True, "phase": "skipped", "verdict_reason": "archived", } ) skipped_count += 1 print(f"SKIP (archived, {data.get('stargazers_count', 0)}★)") elif data.get("fork") and data.get("stargazers_count", 0) < 5: entry.update( { "stars": data.get("stargazers_count", 0), "description": data.get("description", ""), "archived": False, "phase": "skipped", "verdict_reason": "low-star fork", } ) skipped_count += 1 print(f"SKIP (fork, {data.get('stargazers_count', 0)}★)") else: entry.update( { "stars": data.get("stargazers_count", 0), "description": data.get("description", ""), "language": data.get("language", ""), "last_push": data.get("pushed_at", ""), "archived": False, "fork": data.get("fork", False), "has_issues": data.get("has_issues", True), "topics": data.get("topics", []), "phase": "enriched", } ) enriched_count += 1 print(f"OK ({data.get('stargazers_count', 0)}★)") # Save after each batch if (enriched_count + skipped_count) % batch_size == 0: with open(jsonl_path, "w") as f: for e in entries: f.write(json.dumps(e, ensure_ascii=False) + "\n") print(f" [saved, {enriched_count} enriched, {skipped_count} skipped]") time.sleep(2) # Rate limit pause between batches # Final save with open(jsonl_path, "w") as f: for e in entries: f.write(json.dumps(e, ensure_ascii=False) + "\n") print(f"\nDone: {enriched_count} enriched, {skipped_count} skipped") if __name__ == "__main__": if len(sys.argv) < 2: print("Usage: enrich.py <jsonl_path> [--batch N]") sys.exit(1) batch = 30 if "--batch" in sys.argv: idx = sys.argv.index("--batch") batch = int(sys.argv[idx + 1]) enrich_jsonl(sys.argv[1], batch_size=batch) -
evaluate.py 6.2 KB
#!/usr/bin/env python3 """Evaluate a single repo from JSONL — read README + Cargo.toml via gh api. Usage: python3 evaluate.py <jsonl_path> <owner/repo> python3 evaluate.py <jsonl_path> --next # Pick next enriched by stars """ # list-env-sensitive-calls: allow gh — GH_HOST is how a user points this at their # own GitHub host, so it is the interface here rather than noise, and a wrong host # fails on authentication instead of returning plausible data. import base64 import json import subprocess import sys from datetime import datetime from pathlib import Path def gh_api(endpoint: str, jq: str = ".") -> str | None: try: result = subprocess.run( ["gh", "api", endpoint, "--jq", jq], capture_output=True, text=True, timeout=15, ) if result.returncode == 0: return result.stdout.strip() except Exception: pass return None def gh_content(repo: str, path: str) -> str | None: """Fetch file content from repo via gh api.""" raw = gh_api(f"repos/{repo}/contents/{path}", ".content") if raw: try: return base64.b64decode(raw).decode("utf-8", errors="replace") except Exception: pass return None def evaluate_repo(entry: dict) -> dict: """Evaluate a single repo and return updated entry.""" repo = entry["repo"] stars = entry.get("stars", 0) print(f"\nEvaluating: {repo} ({stars}★)") print("=" * 50) # Read README readme = gh_content(repo, "README.md") if readme: print(f"README: {len(readme)} chars") print(f" Preview: {readme[:200]}...") else: print("README: not found") # Read Cargo.toml cargo = gh_content(repo, "Cargo.toml") if cargo: print("Cargo.toml: found") # Extract async-openai usage for line in cargo.split("\n"): if "async-openai" in line.lower() or "openai" in line.lower(): print(f" {line.strip()}") else: print("Cargo.toml: not found (maybe workspace?)") # Try common paths for alt in ["crates/core/Cargo.toml", "src/Cargo.toml", "backend/Cargo.toml"]: cargo = gh_content(repo, alt) if cargo: print(f" Found at {alt}") break # Detect features features = [] all_text = (readme or "") + (cargo or "") lower_text = all_text.lower() feature_signals = { "streaming": ["stream", "sse", "event-stream", "create_stream"], "tools": ["tool_call", "function_call", "tool_choice", "function_calling"], "structured": ["json_schema", "response_format", "structured", "schemars"], "embeddings": ["embedding", "vector", "similarity"], "agent_loop": ["agent", "loop", "iteration", "multi-turn", "conversation"], "audio": ["audio", "transcription", "speech", "whisper", "tts"], "images": ["image", "dall-e", "generate.*image"], "realtime": ["realtime", "websocket", "wss://"], "wasm": ["wasm", "cloudflare", "worker", "edge"], } for feature, signals in feature_signals.items(): if any(s in lower_text for s in signals): features.append(feature) print(f"Features detected: {features}") # Determine our advantages for this repo advantage_map = { "streaming": "stream_helpers", "tools": "structured_outputs", "structured": "structured_outputs", "agent_loop": "persistent_websockets", "realtime": "persistent_websockets", "wasm": "wasm_builtin", } our_advantages = list(set(advantage_map[f] for f in features if f in advantage_map)) print(f"Our advantages: {our_advantages}") # Score score = 0 # Stars component (0-30) if stars >= 1000: score += 30 elif stars >= 100: score += 25 elif stars >= 20: score += 15 elif stars >= 5: score += 10 # Activity (0-20) last_push = entry.get("last_push", "") if last_push: try: pushed = datetime.fromisoformat(last_push.replace("Z", "+00:00")) days = (datetime.now(pushed.tzinfo) - pushed).days if days < 30: score += 20 elif days < 90: score += 15 elif days < 180: score += 10 except Exception: score += 5 # Feature fit (0-30) score += min(len(our_advantages) * 10, 30) # Approachability (0-20) if entry.get("has_issues", True): score += 10 if not entry.get("fork", False): score += 10 # Verdict if score >= 60: verdict = "target" elif score >= 30: verdict = "maybe" else: verdict = "skip" print(f"Score: {score}/100 → {verdict}") entry.update( { "phase": "evaluated", "score": score, "features_used": features, "our_advantages": our_advantages, "verdict": verdict, "evaluated_at": datetime.now().isoformat(), } ) return entry def main(): if len(sys.argv) < 2: print("Usage: evaluate.py <jsonl_path> <owner/repo>") print(" evaluate.py <jsonl_path> --next") sys.exit(1) jsonl_path = Path(sys.argv[1]) lines = jsonl_path.read_text().strip().split("\n") entries = [json.loads(line) for line in lines if line.strip()] if len(sys.argv) >= 3 and sys.argv[2] == "--next": # Pick next enriched by stars enriched = [e for e in entries if e.get("phase") == "enriched"] enriched.sort(key=lambda x: x.get("stars", 0), reverse=True) if not enriched: print("No enriched repos to evaluate") sys.exit(0) target_repo = enriched[0]["repo"] else: target_repo = sys.argv[2] # Find and evaluate for i, entry in enumerate(entries): if entry["repo"] == target_repo: entries[i] = evaluate_repo(entry) break else: print(f"Repo not found: {target_repo}") sys.exit(1) # Save with open(jsonl_path, "w") as f: for e in entries: f.write(json.dumps(e, ensure_ascii=False) + "\n") print(f"\nSaved to {jsonl_path}") if __name__ == "__main__": main() -
init_jsonl.py 1.5 KB
#!/usr/bin/env python3 """Convert raw repo list (one per line) to JSONL. Usage: python3 init_jsonl.py <repos_file> <output_jsonl> cat repos.txt | python3 init_jsonl.py - output.jsonl """ import json import sys from pathlib import Path def main(): if len(sys.argv) < 3: print("Usage: init_jsonl.py <repos_file_or_-> <output.jsonl>") sys.exit(1) src = sys.argv[1] out = Path(sys.argv[2]) if src == "-": lines = sys.stdin.read().strip().split("\n") else: lines = Path(src).read_text().strip().split("\n") repos = [line.strip() for line in lines if line.strip() and "/" in line] with open(out, "w") as f: for repo in repos: entry = { "repo": repo, "url": f"https://github.com/{repo}", "phase": "raw", "stars": 0, "description": "", "language": "", "last_push": "", "archived": False, "score": 0, "features_used": [], "our_advantages": [], "verdict": "", "verdict_reason": "", "draft_title": "", "draft_body": "", "issue_url": "", "evaluated_at": "", "notes": "", } f.write(json.dumps(entry, ensure_ascii=False) + "\n") print(f"Created {out} with {len(repos)} entries") if __name__ == "__main__": main() -
status.py 3.1 KB
#!/usr/bin/env python3 """Show outreach pipeline status from JSONL. Usage: python3 status.py <jsonl_path> [--targets] [--csv] """ import json import sys from collections import Counter from pathlib import Path def show_status(path: str, show_targets: bool = False, csv_mode: bool = False): jsonl_path = Path(path) if not jsonl_path.exists(): print(f"Not found: {path}") sys.exit(1) lines = jsonl_path.read_text().strip().split("\n") entries = [json.loads(line) for line in lines if line.strip()] # Phase counts phases = Counter(e.get("phase", "raw") for e in entries) verdicts = Counter( e.get("verdict", "") for e in entries if e.get("phase") == "evaluated" ) if csv_mode: # CSV output for spreadsheet print("repo,stars,phase,verdict,features_used,our_advantages,verdict_reason") for e in sorted(entries, key=lambda x: x.get("stars", 0), reverse=True): features = ";".join(e.get("features_used", [])) advantages = ";".join(e.get("our_advantages", [])) print( f"{e['repo']},{e.get('stars', 0)},{e.get('phase', 'raw')}," f"{e.get('verdict', '')},{features},{advantages}," f"{e.get('verdict_reason', '')}" ) return # Summary print("\nOutreach Pipeline Status") print(f"{'=' * 35}") print(f"Total repos: {len(entries)}") print() print("Phase Count") print(f"{'-' * 35}") for phase in ["raw", "enriched", "evaluated", "drafted", "posted", "skipped"]: count = phases.get(phase, 0) if count > 0: print(f" {phase:<15} {count:>5}") if phase == "evaluated": for v in ["target", "maybe", "skip"]: vc = verdicts.get(v, 0) if vc > 0: print(f" -> {v:<13} {vc:>3}") print(f"{'-' * 35}") # Top targets targets = [ e for e in entries if e.get("verdict") == "target" and e.get("phase") in ("evaluated", "drafted") ] targets.sort(key=lambda x: x.get("stars", 0), reverse=True) if targets: print(f"\nTop targets ({len(targets)}):") for t in targets[:10]: advantages = ", ".join(t.get("our_advantages", [])[:3]) print(f" {t.get('stars', 0):>6}★ {t['repo']:<40} {advantages}") # Top enriched (not yet evaluated) if show_targets: pending = [e for e in entries if e.get("phase") == "enriched"] pending.sort(key=lambda x: x.get("stars", 0), reverse=True) if pending: print(f"\nNext to evaluate ({len(pending)} pending):") for p in pending[:15]: desc = (p.get("description") or "")[:50] print(f" {p.get('stars', 0):>6}★ {p['repo']:<40} {desc}") if __name__ == "__main__": if len(sys.argv) < 2: print("Usage: status.py <jsonl_path> [--targets] [--csv]") sys.exit(1) show_status( sys.argv[1], show_targets="--targets" in sys.argv, csv_mode="--csv" in sys.argv, )
-
-
SKILL.md 9.5 KB
--- name: solo-github-outreach description: Use when "github outreach", "competitor dependents", "dependents scan", "propose our lib", or targeting users of competitor libraries. NOT for Reddit (/reddit) or social copy (/content-gen). license: MIT metadata: author: fortunto2 version: "1.0.0" openclaw: emoji: "🎯" allowed-tools: Read, Grep, Glob, Write, Edit, Bash, AskUserQuestion, WebSearch, WebFetch, mcp__searxng__web_search, mcp__solograph__project_info argument-hint: "<command> — commands: setup, enrich, evaluate, draft, status, next" --- # /github-outreach Competitive outreach pipeline. Scan a competitor's dependents, evaluate which repos would benefit from switching to your library, and draft personalized GitHub issues. Works with any crate/package — not specific to any product. ## Scripts Use these instead of reimplementing from scratch: - `scripts/init_jsonl.py <repos.txt> <out.jsonl>` — convert repo list to JSONL - `scripts/enrich.py <jsonl> [--batch 30]` — add stars/description via `gh api`, skip forks/archived - `scripts/evaluate.py <jsonl> <owner/repo>` — deep-evaluate a repo (README + Cargo.toml + feature detection) - `scripts/evaluate.py <jsonl> --next` — pick next highest-star enriched repo - `scripts/status.py <jsonl> [--targets] [--csv]` — show pipeline status table ## Data Format All data lives in `data/outreach/{competitor}/` in the project directory: ``` data/outreach/{competitor}/ config.json # Competitor, our product, feature matrix dependents.jsonl # One JSON object per repo (append-only) progress.json # Cursor: last evaluated index, stats ``` ### dependents.jsonl schema Each line is a JSON object: ```json { "repo": "owner/name", "stars": 1234, "description": "...", "language": "Rust", "last_push": "2026-03-20T...", "archived": false, "phase": "raw|enriched|evaluated|drafted|posted|skipped", "score": 0, "features_used": ["streaming", "tools", "embeddings"], "our_advantages": ["websocket", "structured_outputs"], "verdict": "skip|maybe|target", "verdict_reason": "fork of AppFlowy, not original", "draft_title": "", "draft_body": "", "issue_url": "", "evaluated_at": "", "notes": "" } ``` JSONL is append-friendly and `grep`/`jq` compatible. Render as table with `status` command. ## Routing | User says | Action | |-----------|--------| | `/outreach setup` | Configure competitor + our product | | `/outreach enrich` | Add stars/description via `gh api` | | `/outreach next` | Evaluate next unevaluated repo | | `/outreach evaluate [repo]` | Deep-evaluate a specific repo | | `/outreach draft [repo]` | Generate issue text for a target | | `/outreach status` | Show progress table | | `/outreach batch [N]` | Evaluate next N repos (default 5) | ## Setup First-time configuration. 1. Ask or read from `$ARGUMENTS`: - Competitor: crate name or `owner/repo` (e.g., `async-openai`) - Our product: crate name + repo URL - Feature matrix file path (or build interactively) 2. Create `data/outreach/{competitor}/config.json`: ```json { "competitor": "async-openai", "competitor_repo": "64bit/async-openai", "our_product": "openai-oxide", "our_repo": "fortunto2/openai-oxide", "feature_matrix": { "persistent_websockets": {"us": true, "them": false, "impact": "high", "pitch": "..."}, "structured_outputs": {"us": true, "them": false, "impact": "high", "pitch": "..."} } } ``` 3. Check if `dependents.jsonl` exists. If not, run scraper: ```bash scripts/scrape-dependents.sh {competitor_repo} 50 > /tmp/deps.txt ``` Convert to JSONL with phase=raw. ## Enrich Fast pass: add GitHub metadata to all `phase=raw` entries. ```bash # For each raw entry, call gh api gh api repos/{owner}/{repo} --jq '{ stars: .stargazers_count, description: .description, language: .language, last_push: .pushed_at, archived: .archived, topics: .topics }' ``` - Update phase to `enriched` - Skip if `gh api` returns 404 (private/deleted) — set phase=skipped - Rate limit: batch 30 per minute (authenticated), save after each batch - Sort by stars descending for evaluation priority ## Evaluate (per repo) Deep analysis of a single repo. This is where the agent thinks. ### Step 1: Quick filter (skip obvious non-targets) - Archived? Skip - Fork with <5 stars? Skip (not the original) - Last push >1 year ago? Skip (abandoned) - Stars <3 and no meaningful description? Skip ### Step 2: Read README ```bash gh api repos/{owner}/{repo}/readme --jq '.content' | base64 -d ``` Understand: what does this project do? How do they use the competitor? ### Step 3: Read Cargo.toml (find usage pattern) ```bash gh api repos/{owner}/{repo}/contents/Cargo.toml --jq '.content' | base64 -d ``` Which features do they use? What else is in their dependency tree? ### Step 3b: Check if they forked the competitor If Cargo.toml references the competitor via `git = "..."` (not crates.io), they forked it — this is a HIGH SIGNAL: ```bash # Find their fork gh api repos/{fork_owner}/{competitor_name}/commits --jq '.[0:5] | .[] | "\(.sha[0:7]) \(.commit.message | split("\n")[0])"' ``` Compare their fork commits against upstream. If they added a feature we already have (e.g. schemars for structured outputs), that's our strongest pitch: "you can drop the fork and use us — we have that built-in." Record exactly what they added in `notes`. ### Step 4: Grep for usage patterns (optional, for top targets) If stars >50, clone shallow and grep: ```bash git clone --depth 1 {url} /tmp/outreach-eval grep -rn "async.openai\|ChatCompletion\|stream\|tool_call\|embedding" /tmp/outreach-eval/src/ rm -rf /tmp/outreach-eval ``` Map findings to feature signals (streaming, tools, structured, websocket, etc.) ### Step 5: Score and verdict Score based on: - Stars (weight: 30%) — reach/impact - Activity (weight: 20%) — will they actually migrate? - Feature fit (weight: 30%) — do our advantages matter to them? - Approachability (weight: 20%) — open to contributions? Has issues enabled? Verdict: - **target** (score >= 60) — worth creating an issue - **maybe** (score 30-59) — revisit later - **skip** (score < 30) — not worth effort Update JSONL entry with phase=evaluated. ### Step 6: Cleanup Always `rm -rf /tmp/outreach-eval` after analysis. ## Draft (per repo) Generate a personalized GitHub issue for a `target` repo. 1. Load config (feature matrix, pitches) 2. Load evaluation data (features_used, our_advantages) 3. Draft issue using `references/issue-templates.md` 4. Key rules: - **Never generic** — reference their specific use case - **Lead with their problem** — not our solution - **Offer concrete benefit** — "your agent loop would be 40% faster with persistent WebSockets" - **No hard sell** — "you might find this useful" tone - **Include migration path** — show how imports change 5. Save draft to JSONL entry (phase=drafted) 6. Output draft for user review before posting ## Status Show progress across all repos. ``` Outreach: async-openai → openai-oxide Phase Count ───────────────── raw 12 enriched 340 evaluated 180 → target 8 → maybe 47 → skip 125 drafted 3 posted 1 skipped 59 ───────────────── Total 591 Top targets (not yet drafted): 1. fastrepl/char (8068★) — streaming + agent loop 2. risingwavelabs/risingwave (7000★) — embeddings ... ``` Read from JSONL, aggregate by phase/verdict. ## Batch Mode Evaluate next N repos efficiently. 1. Load JSONL, filter phase=enriched, sort by stars desc 2. For each (up to N): - Run Evaluate flow - Print one-line result - Continue to next (no pause) 3. Print batch summary ## Critical Rules 1. **Never post issues without user approval** — draft only, user reviews 2. **Never clone repos larger than 100MB** — check size via `gh api` first 3. **Always cleanup** — `rm -rf /tmp/outreach-eval` after every evaluation 4. **Rate limit gh api** — max 30 requests per batch, save progress 5. **Respect repos** — if issues are disabled, skip. If they said no, mark as skipped 6. **One issue per repo** — never spam 7. **Personalize everything** — generic "try our lib" issues get ignored and damage reputation 8. **JSONL is append-only** — update by rewriting the line (match by repo field) ## Gotchas 1. **Forks dominate dependents** — 50%+ of dependents are forks of big projects (AppFlowy, meilisearch). Filter by checking if the repo is a fork via `gh api` `.fork` field. Only evaluate originals. 2. **gh api rate limit** — 5000/h authenticated but large scans hit it. Use `--paginate` sparingly. Check `X-RateLimit-Remaining` header. 3. **README doesn't show actual usage** — a repo may list async-openai in Cargo.toml but barely use it. Always check Cargo.toml features and grep source. 4. **Stale dependents** — GitHub's dependency graph is delayed. Some repos may have already switched away. Check Cargo.lock if available. 5. **Issue tone matters enormously** — "I noticed you use X, have you tried Y?" works. "X is slow, switch to Y" does not. See `references/issue-templates.md`. 6. **Competitor forks are the strongest signal** — if a repo uses `git = "..."` instead of crates.io, they forked the competitor because it's missing something. Check the fork diff (usually 1-3 commits). If they added a feature we already have, that's our #1 pitch — "drop your fork, we have it built-in." Example: fastrepl/char forked async-openai to add schemars → our `structured` feature does exactly that.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.