Claude Skill

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).

LLM Mart · 0 points · 7 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download fortunto2-solo-factory-skills_github-outreach-bf3e92b.zip · 12 KB
Part of fortunto2/solo-factory — 43 skills

Install

skills CLI npx skills add https://github.com/fortunto2/solo-factory/tree/main/skills/github-outreach
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install fortunto2-solo-factory@llmmart
Git 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 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:

{
  "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:

{
  "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": "..."}
  }
}
  1. Check if dependents.jsonl exists. If not, run scraper:
    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.

# 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

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.

  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.
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.

No comments yet.

Reviews (0)

No reviews yet.

Related