@opendirectory.dev/skills 0.1.44 → 0.1.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/package.json +1 -1
  2. package/registry.json +16 -0
  3. package/skills/noise-to-linkedin-carousel/README.md +81 -0
  4. package/skills/noise-to-linkedin-carousel/SKILL.md +53 -0
  5. package/skills/noise-to-linkedin-carousel/cover.png +0 -0
  6. package/skills/noise-to-linkedin-carousel/references/hook-patterns.md +45 -0
  7. package/skills/noise-to-linkedin-carousel/references/output-format.md +59 -0
  8. package/skills/noise-to-linkedin-carousel/references/quality-checklist.md +12 -0
  9. package/skills/noise-to-linkedin-carousel/references/slide-types.md +57 -0
  10. package/skills/oss-launch-kit/.env.example +2 -0
  11. package/skills/oss-launch-kit/PRD.md +122 -0
  12. package/skills/oss-launch-kit/README.md +27 -0
  13. package/skills/oss-launch-kit/SKILL.md +33 -0
  14. package/skills/oss-launch-kit/TECHNICAL_DESIGN.md +187 -0
  15. package/skills/oss-launch-kit/evals/cli-cli.full.md +49 -0
  16. package/skills/oss-launch-kit/evals/evals.json +44 -0
  17. package/skills/oss-launch-kit/evals/octocat-hello-world.full.md +53 -0
  18. package/skills/oss-launch-kit/evals/pydantic-pydantic.full.md +152 -0
  19. package/skills/oss-launch-kit/evals/vercel-next-js.full.md +152 -0
  20. package/skills/oss-launch-kit/hero.png +0 -0
  21. package/skills/oss-launch-kit/references/channel_rules.md +9 -0
  22. package/skills/oss-launch-kit/references/launch_framework.md +10 -0
  23. package/skills/oss-launch-kit/references/output_template.md +3 -0
  24. package/skills/oss-launch-kit/scripts/__pycache__/build_product_brief.cpython-313.pyc +0 -0
  25. package/skills/oss-launch-kit/scripts/__pycache__/generate_assets.cpython-313.pyc +0 -0
  26. package/skills/oss-launch-kit/scripts/build_product_brief.py +335 -0
  27. package/skills/oss-launch-kit/scripts/fetch_repo_context.py +169 -0
  28. package/skills/oss-launch-kit/scripts/generate_assets.py +526 -0
  29. package/skills/oss-launch-kit/scripts/run.py +41 -0
  30. package/skills/oss-launch-kit/scripts/test_logic.py +99 -0
@@ -0,0 +1,526 @@
1
+ """Generate a coordinated OSS launch strategy and asset bundle.
2
+
3
+ This module acts as an orchestrator: it determines readiness, evaluates channel
4
+ fitness, and provides strategic hooks and handoffs to specialized skills.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import re
11
+ import sys
12
+ from typing import Any
13
+
14
+
15
+ def _clean(text: str | None) -> str:
16
+ return re.sub(r"\s+", " ", (text or "").strip())
17
+
18
+
19
+ def _strip_markdown(text: str | None) -> str:
20
+ cleaned = text or ""
21
+ cleaned = re.sub(r"\[([^\]]+)\]\([^\)]+\)", r"\1", cleaned)
22
+ cleaned = re.sub(r"\[([^\]]+)\]\[\]", r"\1", cleaned)
23
+ cleaned = re.sub(r"[`*_>#]", "", cleaned)
24
+ return _clean(cleaned)
25
+
26
+
27
+ def _shorten(text: str, limit: int) -> str:
28
+ text = _clean(text)
29
+ if len(text) <= limit:
30
+ return text
31
+ truncated = text[:limit].rstrip()
32
+ if " " in truncated:
33
+ truncated = truncated[: truncated.rfind(" ")].rstrip()
34
+ return truncated + "..."
35
+
36
+
37
+ def _repo_slug(brief: dict[str, Any]) -> str:
38
+ return _clean(brief.get("repo_name") or "unknown-repo")
39
+
40
+
41
+ def _title_case(text: str) -> str:
42
+ words = text.split()
43
+ return " ".join(word if len(word) <= 3 else word[:1].upper() + word[1:] for word in words)
44
+
45
+
46
+ def _strip_trailing_period(text: str) -> str:
47
+ return text[:-1] if text.endswith(".") else text
48
+
49
+
50
+ def _canonical_summary(brief: dict[str, Any]) -> str:
51
+ summary = _strip_markdown(brief.get("one_line_summary"))
52
+ if summary:
53
+ return _strip_trailing_period(summary)
54
+
55
+ problem = _strip_markdown(brief.get("problem_solved"))
56
+ if problem:
57
+ return _strip_trailing_period(problem)
58
+
59
+ return "an open-source project"
60
+
61
+
62
+ def _choose_show_hn_title(brief: dict[str, Any]) -> str:
63
+ repo_name = _repo_slug(brief)
64
+ summary = _canonical_summary(brief)
65
+ audience = _strip_markdown(brief.get("audience"))
66
+
67
+ candidates = []
68
+ if summary and audience and audience != "unknown":
69
+ candidates.append(f"Show HN: {repo_name} - {summary} for {audience}")
70
+ if summary:
71
+ candidates.append(f"Show HN: {repo_name} - {summary}")
72
+ candidates.append(f"Show HN: {repo_name}")
73
+
74
+ for candidate in candidates:
75
+ if len(candidate) <= 72:
76
+ return candidate
77
+ return candidates[-1]
78
+
79
+
80
+ def _intro_line(brief: dict[str, Any]) -> str:
81
+ repo_name = _repo_slug(brief)
82
+ summary = _canonical_summary(brief)
83
+ audience = _strip_markdown(brief.get("audience"))
84
+ if audience and audience != "unknown":
85
+ return _shorten(f"I built {repo_name} because I wanted a clearer way to serve {audience} who need {summary.lower()}.", 220)
86
+ return _shorten(f"I built {repo_name} because I wanted a clearer way to package {summary.lower()} as a launchable OSS project.", 220)
87
+
88
+
89
+ def _low_confidence_note(brief: dict[str, Any]) -> str:
90
+ confidence = _clean(brief.get("confidence"))
91
+ assumptions = brief.get("assumptions") or []
92
+ if confidence == "low" or assumptions:
93
+ notes = [f"Confidence: {confidence or 'unknown'}"]
94
+ if assumptions:
95
+ notes.append("Assumptions: " + "; ".join(assumptions[:3]).rstrip("."))
96
+ return "\n".join(f"- {note}" for note in notes)
97
+ return "- Confidence: high"
98
+
99
+
100
+ def _is_low_confidence(brief: dict[str, Any]) -> bool:
101
+ return _clean(brief.get("confidence")) == "low" or bool(brief.get("assumptions"))
102
+
103
+
104
+ def _low_confidence_edit_note() -> str:
105
+ return "Edit before posting: this repo context is sparse, so tighten the copy after reviewing the README and repo signals."
106
+
107
+
108
+ def _normalize_phrase(text: str) -> str:
109
+ return re.sub(r"[^a-z0-9]+", " ", _clean(text).lower()).strip()
110
+
111
+
112
+ def _phrase_overlap(a: str, b: str) -> bool:
113
+ a_words = {word for word in _normalize_phrase(a).split() if len(word) > 2}
114
+ b_words = {word for word in _normalize_phrase(b).split() if len(word) > 2}
115
+ if not a_words or not b_words:
116
+ return False
117
+ return len(a_words & b_words) >= 3
118
+
119
+
120
+ def _is_marketing_heavy(text: str) -> bool:
121
+ normalized = _normalize_phrase(text)
122
+ signals = [
123
+ "worlds best",
124
+ "ultimate",
125
+ "powerful",
126
+ "all in one",
127
+ "beautiful",
128
+ "magical",
129
+ "supercharge",
130
+ "next generation",
131
+ "revolutionary",
132
+ "seamless",
133
+ "unlock",
134
+ "delight",
135
+ "built for teams",
136
+ "move faster",
137
+ ]
138
+ return any(signal in normalized for signal in signals)
139
+
140
+
141
+ def _ph_repo_category(brief: dict[str, Any]) -> str:
142
+ text = " ".join(
143
+ part
144
+ for part in [
145
+ _strip_markdown(brief.get("problem_solved")),
146
+ _strip_markdown(brief.get("one_line_summary")),
147
+ _strip_markdown(brief.get("value_proposition")),
148
+ ]
149
+ if part
150
+ ).lower()
151
+ if any(term in text for term in ["framework", "sdk"]):
152
+ return "framework"
153
+ if any(term in text for term in ["library", "package", "module"]):
154
+ return "library"
155
+ if any(term in text for term in ["cli", "tool", "automation", "workflow", "build", "deploy"]):
156
+ return "tool"
157
+ if any(term in text for term in ["design", "css", "ui", "frontend"]):
158
+ return "UI toolkit"
159
+ return "open-source project"
160
+
161
+
162
+ def _render_low_confidence_note(brief: dict[str, Any]) -> str:
163
+ note = _low_confidence_note(brief)
164
+ if _is_low_confidence(brief):
165
+ note += "\n" + _low_confidence_edit_note()
166
+ return note
167
+
168
+ def _tagline_candidates(brief: dict[str, Any]) -> list[str]:
169
+ summary = _canonical_summary(brief)
170
+ audience = _strip_markdown(brief.get("audience"))
171
+ problem = _strip_markdown(brief.get("problem_solved"))
172
+ features = [_strip_markdown(feature) for feature in (brief.get("key_features") or [])[:3]]
173
+ repo_name = _repo_slug(brief)
174
+ value_prop = _strip_markdown(brief.get("value_proposition"))
175
+ focus = features[0] if features else summary
176
+
177
+ candidates = []
178
+ if audience and audience != "unknown":
179
+ candidates.append(f"Launch copy for {audience} built from repo facts")
180
+ if problem:
181
+ candidates.append(f"Turns {problem.lower()} into launch-ready copy")
182
+ if value_prop:
183
+ candidates.append(_shorten(value_prop, 60))
184
+ if focus:
185
+ candidates.append(_shorten(f"Built for repos about {focus.lower()}", 60))
186
+ candidates.extend(
187
+ [
188
+ f"Launch assets grounded in README and metadata",
189
+ f"Turns GitHub repos into launch copy",
190
+ f"Helps OSS maintainers write launch-ready posts",
191
+ ]
192
+ )
193
+ if repo_name:
194
+ candidates.append(f"Launch copy for {repo_name}")
195
+ return [candidate for candidate in candidates if candidate]
196
+
197
+
198
+ def _pick_tagline(brief: dict[str, Any]) -> str:
199
+ candidates = []
200
+ for candidate in _tagline_candidates(brief):
201
+ candidate = _strip_markdown(candidate)
202
+ if not candidate:
203
+ continue
204
+ if candidate.lower().startswith(_repo_slug(brief).lower()):
205
+ continue
206
+ if candidate.endswith(".") or candidate.endswith("?"):
207
+ candidate = candidate[:-1]
208
+ if len(candidate) <= 60:
209
+ candidates.append(candidate)
210
+ if candidates:
211
+ return candidates[0]
212
+ fallback = _strip_markdown(brief.get("value_proposition")) or "Launch copy grounded in repo facts"
213
+ return _shorten(fallback, 60)
214
+
215
+ def _reddit_subreddit_candidates(brief: dict[str, Any]) -> list[tuple[str, str]]:
216
+ audience = _strip_markdown(brief.get("audience")).lower()
217
+ summary = _canonical_summary(brief).lower()
218
+ title = _repo_slug(brief).lower()
219
+ candidates: list[tuple[str, str]] = []
220
+ low_conf = _is_low_confidence(brief)
221
+
222
+ def add(subreddit: str, context: str) -> None:
223
+ if subreddit and subreddit.lower() not in {item[0].lower() for item in candidates}:
224
+ candidates.append((subreddit, context))
225
+
226
+ if low_conf:
227
+ return [
228
+ ("r/opensource", "open-source launch context"),
229
+ ("r/SideProject", "general maker context"),
230
+ ("r/programming", "developer audience"),
231
+ ("r/devtools", "developer tools audience"),
232
+ ("r/commandline", "command-line users"),
233
+ ]
234
+
235
+ if any(term in summary for term in ["cli", "tool", "automation", "build", "deploy", "developer"]):
236
+ add("r/devtools", "developer tools audience")
237
+ if any(term in summary for term in ["open source", "oss", "github", "repo"]):
238
+ add("r/opensource", "open-source launch context")
239
+ if any(term in audience for term in ["developer", "engineer", "team"]):
240
+ add("r/programming", "developer audience")
241
+ if any(term in summary for term in ["side project", "indie", "personal"]):
242
+ add("r/SideProject", "maker / indie launch context")
243
+ if any(term in summary for term in ["python", "go", "rust", "javascript", "typescript"]):
244
+ for topic in ["python", "go", "rust", "javascript", "typescript"]:
245
+ if topic in summary:
246
+ add(f"r/{topic.capitalize()}", f"language-specific community for {topic}")
247
+ break
248
+
249
+ if "cli" in title or "terminal" in summary:
250
+ add("r/commandline", "command-line users")
251
+ if any(term in summary for term in ["self-host", "server", "infra", "pipeline", "ci"]):
252
+ add("r/devops", "devops / infrastructure audience")
253
+
254
+ fallback_pool = [
255
+ ("r/SideProject", "general maker context"),
256
+ ("r/opensource", "open-source launch context"),
257
+ ("r/programming", "developer audience"),
258
+ ("r/devtools", "developer tools audience"),
259
+ ("r/commandline", "command-line users"),
260
+ ]
261
+ for subreddit, context in fallback_pool:
262
+ add(subreddit, context)
263
+
264
+ seen: set[str] = set()
265
+ deduped: list[tuple[str, str]] = []
266
+ for subreddit, context in candidates:
267
+ key = subreddit.lower()
268
+ if key in seen:
269
+ continue
270
+ seen.add(key)
271
+ deduped.append((subreddit, context))
272
+ if len(deduped) == 5:
273
+ break
274
+ return deduped
275
+
276
+
277
+ def generate_product_hunt(brief: dict[str, Any]) -> dict[str, str]:
278
+ """Generate Product Hunt assets from a grounded brief."""
279
+
280
+ repo_name = _repo_slug(brief)
281
+ audience = _strip_markdown(brief.get("audience"))
282
+ summary = _strip_markdown(brief.get("one_line_summary")) or _canonical_summary(brief)
283
+ problem = _strip_markdown(brief.get("problem_solved"))
284
+ value_prop = _strip_markdown(brief.get("value_proposition"))
285
+ low_confidence = _is_low_confidence(brief)
286
+ marketing_heavy = _is_marketing_heavy(summary) or _is_marketing_heavy(problem) or _is_marketing_heavy(value_prop)
287
+ category = _ph_repo_category(brief)
288
+
289
+ if low_confidence:
290
+ return {
291
+ "tagline": f"Draft: {repo_name}",
292
+ "description": "Draft only. Not enough repo signal for a polished Product Hunt description. Edit after reviewing the README and metadata.",
293
+ "maker_comment": "Draft only. This needs manual editing before posting.",
294
+ }
295
+
296
+ if audience and audience != "unknown":
297
+ audience_phrase = audience
298
+ else:
299
+ audience_phrase = "OSS builders"
300
+
301
+ if marketing_heavy:
302
+ audience_phrase = category
303
+ description = _shorten(
304
+ f"Built for {audience_phrase}. This draft keeps the pitch factual and avoids repeating the README’s slogans.",
305
+ 220,
306
+ )
307
+ else:
308
+ description_parts = [f"Built for {audience_phrase}."]
309
+ if problem and not _phrase_overlap(problem, summary):
310
+ description_parts.append("Focuses on the repo’s core function instead of reusing README language.")
311
+ elif summary and not _phrase_overlap(summary, problem or summary):
312
+ description_parts.append("Keeps the pitch factual and plain.")
313
+ else:
314
+ description_parts.append("Keeps the pitch grounded in repo facts.")
315
+ description = _shorten(" ".join(description_parts), 220)
316
+
317
+ if category.lower() == audience_phrase.lower():
318
+ tagline = f"{repo_name}: {category}"
319
+ else:
320
+ tagline = f"{repo_name}: {category} for {audience_phrase}"
321
+
322
+ maker_comment = "Draft only. Edit before posting."
323
+ return {
324
+ "tagline": tagline,
325
+ "description": description,
326
+ "maker_comment": maker_comment,
327
+ }
328
+
329
+
330
+ def render_channel_strategy(brief: dict[str, Any]) -> str:
331
+ """Render concise channel strategy with hooks and handoffs."""
332
+ fitness = brief.get("channel_fitness") or {}
333
+ type_ = brief.get("project_type") or "project"
334
+ repo_name = _repo_slug(brief)
335
+ summary = _canonical_summary(brief)
336
+
337
+ sections = ["## Channel Strategy & Positioning"]
338
+
339
+ # Show HN
340
+ if fitness.get("show_hn") != "low":
341
+ title = _choose_show_hn_title(brief)
342
+ hook = _intro_line(brief)
343
+ sections.append(f"### [Show HN] - Fit: {fitness.get('show_hn', '').upper()}")
344
+ sections.append(f"**Positioning**: Focus on technical implementation and 'why I built this'.")
345
+ sections.append(f"**Recommended Title**: `{title}`")
346
+ sections.append(f"**Hook**: {hook}")
347
+ sections.append("> [!TIP]\n> Use `show-hn-writer` for a full submission draft.")
348
+ sections.append("")
349
+
350
+ # Product Hunt
351
+ if fitness.get("product_hunt") != "low":
352
+ ph = generate_product_hunt(brief)
353
+ sections.append(f"### [Product Hunt] - Fit: {fitness.get('product_hunt', '').upper()}")
354
+ sections.append(f"**Positioning**: Highlight the {type_}'s utility for the broader maker community.")
355
+ sections.append(f"**Tagline**: {ph['tagline']}")
356
+ sections.append(f"**Brief**: {ph['description']}")
357
+ sections.append("> [!TIP]\n> Use `producthunt-launch-kit` for full asset generation (badges, images).")
358
+ sections.append("")
359
+
360
+ # Reddit
361
+ if fitness.get("reddit") != "low":
362
+ candidates = _reddit_subreddit_candidates(brief)
363
+ sections.append(f"### [Reddit] - Fit: {fitness.get('reddit', '').upper()}")
364
+ sections.append(f"**Niche Strategy**: Engage {', '.join(c[0] for c in candidates[:3])} with feedback-first posts.")
365
+ sections.append(f"**Key Hook**: {summary}")
366
+ sections.append("> [!TIP]\n> Use `reddit-post-engine` to generate subreddit-specific variants.")
367
+ sections.append("")
368
+
369
+ # Twitter/X
370
+ sections.append(f"### [Twitter/X] - Fit: HIGH")
371
+ sections.append(f"**Thread Strategy**: Start with the problem of {brief.get('problem_solved', 'the current niche')}.")
372
+ sections.append(f"**Hook**: I built `{repo_name}` to solve {summary.lower()}.")
373
+ sections.append("> [!TIP]\n> Use `tweet-thread-from-blog` or `linkedin-post-generator` to expand this hook.")
374
+
375
+ return "\n".join(sections)
376
+
377
+
378
+ def render_readiness_fix_plan(brief: dict[str, Any]) -> str:
379
+ """Render a checklist-style fix plan for unready repositories."""
380
+ readiness_obj = brief.get("launch_readiness") or {}
381
+ if not isinstance(readiness_obj, dict) or not readiness_obj.get("fix_plan"):
382
+ return ""
383
+
384
+ score = readiness_obj.get("score", "low").upper()
385
+ sections = [
386
+ "## Launch Readiness Fix Plan",
387
+ f"The project is currently at **{score}** readiness. Resolve these issues before an aggressive public launch:",
388
+ ""
389
+ ]
390
+
391
+ for item in readiness_obj["fix_plan"]:
392
+ severity = f"({item['severity'].capitalize()} impact)"
393
+ sections.append(f"- [ ] {item['suggested_fix']} {severity}")
394
+ sections.append(f" - **Why**: {item['reason']}")
395
+ sections.append(f" - **Likely file(s)**: {', '.join(f'`{f}`' for f in item['likely_files'])}")
396
+ sections.append("")
397
+
398
+ return "\n".join(sections)
399
+
400
+
401
+ def _generate_fitness_explanation(brief: dict[str, Any]) -> str:
402
+ fitness = brief.get("channel_fitness") or {}
403
+ type_ = brief.get("project_type") or "project"
404
+ readiness_obj = brief.get("launch_readiness") or {}
405
+ readiness_score = readiness_obj.get("score", "medium") if isinstance(readiness_obj, dict) else readiness_obj
406
+
407
+ lines = [f"This is identified as a **{type_}** with **{readiness_score.capitalize()} Launch Readiness**."]
408
+
409
+ if fitness.get("show_hn") == "high":
410
+ lines.append("- **Show HN**: High fit. Technical communities appreciate technical tools and libraries.")
411
+ elif fitness.get("show_hn") == "low":
412
+ lines.append("- **Show HN**: Not recommended yet. Repository context might be too thin for Hacker News.")
413
+
414
+ if fitness.get("product_hunt") == "high":
415
+ lines.append("- **Product Hunt**: High fit. Polished apps and frameworks perform well here.")
416
+ elif fitness.get("product_hunt") == "low":
417
+ lines.append("- **Product Hunt**: Not recommended yet. Pure libraries or early-stage tools often struggle on PH without a UI/Demo.")
418
+
419
+ if fitness.get("reddit") == "high":
420
+ lines.append("- **Reddit**: High fit. Community-driven feedback is ideal for this project.")
421
+ elif fitness.get("reddit") == "low" and readiness_score == "low":
422
+ lines.append("- **Reddit**: OK for feedback only. Avoid a 'launch' post; ask for specific technical reviews instead.")
423
+
424
+ return "\n".join(lines)
425
+
426
+
427
+ def generate_launch_strategy(brief: dict[str, Any]) -> dict[str, Any]:
428
+ fitness = brief.get("channel_fitness") or {}
429
+ readiness_obj = brief.get("launch_readiness") or {}
430
+ readiness_score = readiness_obj.get("score", "medium") if isinstance(readiness_obj, dict) else readiness_obj
431
+
432
+ # Simple recommendation logic
433
+ recommended = [k for k, v in fitness.items() if v == "high"]
434
+ if not recommended:
435
+ recommended = [k for k, v in fitness.items() if v == "medium"]
436
+
437
+ # Timeline coordination
438
+ if readiness_score == "low":
439
+ sequence = [
440
+ "Phase 0: Readiness Fixes (Complete the checklist below)",
441
+ "Day 1-3: Documentation Sprint (Fix README, add examples)",
442
+ "Day 4+: Re-evaluate for Show HN/PH once fundamentals are robust"
443
+ ]
444
+ elif readiness_score == "medium":
445
+ sequence = [
446
+ "Phase 0: Polish Core Docs (Quickstart & Examples)",
447
+ "Step 1: Soft Launch (Internal beta, small Discord niche, existing users)",
448
+ "Step 2: Collect & Address initial feedback",
449
+ "Step 3: Re-readiness check before Product Hunt"
450
+ ]
451
+ elif fitness.get("show_hn") == "high":
452
+ sequence = ["Day 1: Show HN", "Day 3: Reddit", "Day 5: Product Hunt"]
453
+ else:
454
+ sequence = ["Day 1: Twitter/X Soft Launch", "Day 3: Reddit", "Day 7: Show HN (if feedback is good)"]
455
+
456
+ return {
457
+ "explanation": _generate_fitness_explanation(brief),
458
+ "recommended_channels": recommended,
459
+ "timeline": sequence
460
+ }
461
+
462
+
463
+ def render_full_launch_kit_markdown(brief: dict[str, Any]) -> str:
464
+ repo_name = _repo_slug(brief)
465
+ strategy = generate_launch_strategy(brief)
466
+ readiness_obj = brief.get("launch_readiness") or {}
467
+ score = readiness_obj.get("score", "medium") if isinstance(readiness_obj, dict) else readiness_obj
468
+
469
+ sections = [
470
+ f"# Launch Orchestrator for {repo_name}",
471
+ "",
472
+ "## Executive Summary & Launch Readiness",
473
+ f"**Project Maturity**: {score.upper()}",
474
+ ]
475
+
476
+ if score == "low":
477
+ sections.append("\n> [!CAUTION]")
478
+ sections.append("> **This project is not launch-ready yet.** Fix the fundamental issues in the readiness plan below before running a public launch.")
479
+ sections.append("")
480
+ sections.append(render_readiness_fix_plan(brief))
481
+ elif score == "medium":
482
+ sections.append("\n## Soft Launch Strategy")
483
+ sections.append("- Start with existing community channels, small newsletter segments, or internal users.")
484
+ sections.append("- Avoid high-friction 'loud' launches until the core documentation gaps below are filled.")
485
+ sections.append("")
486
+ sections.append(render_readiness_fix_plan(brief))
487
+ sections.append("")
488
+ sections.append("## Coordinated Launch Timeline")
489
+ sections.append("\n".join(f"- [ ] {step}" for step in strategy["timeline"]))
490
+ sections.append("")
491
+ sections.append("## Suggested Skills (Post-Fix)")
492
+ sections.append("> [!TIP]\n> Once fixes are complete, use `producthunt-launch-kit` for full asset generation.")
493
+ sections.append("> [!TIP]\n> For a technical deep-dive once documentation is robust, use `show-hn-writer`.")
494
+ else:
495
+ # High readiness
496
+ sections.append("")
497
+ sections.append("## Coordinated Launch Timeline")
498
+ sections.append("\n".join(f"- [ ] {step}" for step in strategy["timeline"]))
499
+ sections.append("")
500
+ sections.append(strategy["explanation"])
501
+ sections.append("")
502
+ sections.append(render_readiness_fix_plan(brief))
503
+ sections.append("")
504
+ sections.append(render_channel_strategy(brief))
505
+
506
+ sections.extend([
507
+ "",
508
+ "## Full Confidence Notes & Assumptions",
509
+ _render_low_confidence_note(brief),
510
+ ])
511
+
512
+ return "\n\n".join(sections).rstrip() + "\n"
513
+
514
+
515
+ def main() -> None:
516
+ if len(sys.argv) != 2:
517
+ raise SystemExit("Usage: python generate_assets.py <product-brief-json>")
518
+
519
+ with open(sys.argv[1], "r", encoding="utf-8") as handle:
520
+ brief = json.load(handle)
521
+
522
+ print(render_full_launch_kit_markdown(brief))
523
+
524
+
525
+ if __name__ == "__main__":
526
+ main()
@@ -0,0 +1,41 @@
1
+ """CLI entrypoint for oss-launch-kit.
2
+
3
+ This orchestrates GitHub repo fetching, brief building, and full launch-kit generation.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ from pathlib import Path
10
+
11
+ from build_product_brief import build_product_brief
12
+ from fetch_repo_context import fetch_repo_context
13
+ from generate_assets import render_full_launch_kit_markdown
14
+
15
+
16
+ def build_parser() -> argparse.ArgumentParser:
17
+ parser = argparse.ArgumentParser(description="Generate a coordinated OSS launch strategy and readiness report from a GitHub repo.")
18
+ parser.add_argument("--repo-url", required=True, help="Public GitHub repository URL")
19
+ parser.add_argument("--output", default="launch-kit.md", help="Output Markdown file path")
20
+ return parser
21
+
22
+
23
+ def run(repo_url: str) -> tuple[dict, dict, str]:
24
+ repo_context = fetch_repo_context(repo_url)
25
+ brief = build_product_brief(repo_context)
26
+ markdown = render_full_launch_kit_markdown(brief)
27
+ return repo_context, brief, markdown
28
+
29
+
30
+ def main() -> None:
31
+ parser = build_parser()
32
+ args = parser.parse_args()
33
+
34
+ _, _, markdown = run(args.repo_url)
35
+ output_path = Path(args.output)
36
+ output_path.write_text(markdown, encoding="utf-8")
37
+ print(f"Wrote {output_path}")
38
+
39
+
40
+ if __name__ == "__main__":
41
+ main()
@@ -0,0 +1,99 @@
1
+ import unittest
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ # Add the scripts directory to sys.path
6
+ sys.path.append(str(Path(__file__).parent))
7
+
8
+ from build_product_brief import build_product_brief
9
+ from generate_assets import render_full_launch_kit_markdown
10
+
11
+ class TestOSSLaunchKitLogic(unittest.TestCase):
12
+ def test_low_readiness_handling(self):
13
+ # Mock repo context for a very weak repo
14
+ low_context = {
15
+ "name": "octocat/Hello-World",
16
+ "description": "My first repository on GitHub!",
17
+ "stars": 2,
18
+ "readme_text": "Hello World!",
19
+ "language": "HTML",
20
+ "license": None,
21
+ "confidence": "low"
22
+ }
23
+
24
+ brief = build_product_brief(low_context)
25
+ self.assertEqual(brief["launch_readiness"]["score"], "low")
26
+ self.assertTrue(len(brief["launch_readiness"]["fix_plan"]) > 0)
27
+
28
+ markdown = render_full_launch_kit_markdown(brief)
29
+
30
+ # Assertions for low readiness output
31
+ self.assertIn("Project Maturity**: LOW", markdown)
32
+ self.assertIn("> [!CAUTION]", markdown)
33
+ self.assertIn("This project is not launch-ready yet", markdown)
34
+ self.assertIn("## Launch Readiness Fix Plan", markdown)
35
+
36
+ # High friction sections and full checklist should be absent
37
+ self.assertNotIn("### [Show HN]", markdown)
38
+ self.assertNotIn("### [Product Hunt]", markdown)
39
+ self.assertNotIn("## Coordinated Launch Timeline", markdown)
40
+ self.assertNotIn("## Channel Strategy & Positioning", markdown)
41
+
42
+ def test_medium_readiness_handling(self):
43
+ # Mock repo context for a medium tool
44
+ medium_context = {
45
+ "full_name": "user/medium-tool",
46
+ "description": "A tool that does something useful",
47
+ "stars": 25,
48
+ "readme_text": "# Medium Tool\n\n## install\nnpm install medium-tool\n\n## usage\nmedium-tool --help\n\n## license\nMIT\n\n" + "Word " * 200, # > 1000 chars
49
+ "language": "JavaScript",
50
+ "license": "MIT",
51
+ "confidence": "medium"
52
+ }
53
+
54
+ brief = build_product_brief(medium_context)
55
+ self.assertEqual(brief["launch_readiness"]["score"], "medium")
56
+
57
+ markdown = render_full_launch_kit_markdown(brief)
58
+
59
+ # Assertions for medium readiness output
60
+ self.assertIn("Project Maturity**: MEDIUM", markdown)
61
+ self.assertIn("## Soft Launch Strategy", markdown)
62
+ self.assertIn("## Launch Readiness Fix Plan", markdown)
63
+ self.assertIn("## Coordinated Launch Timeline", markdown)
64
+ self.assertIn("Step 1: Soft Launch", markdown)
65
+ self.assertIn("## Suggested Skills (Post-Fix)", markdown)
66
+
67
+ # Should still have handoffs but in the Post-Fix section
68
+ self.assertIn("use `producthunt-launch-kit`", markdown)
69
+
70
+ def test_high_readiness_handling(self):
71
+ # Mock repo context for a strong tool
72
+ high_context = {
73
+ "full_name": "cli/cli",
74
+ "description": "GitHub’s official command line tool helps developers solve github workflow issues",
75
+ "stars": 35000,
76
+ "readme_text": "# GitHub CLI\n\n## Quickstart\nbrew install gh\n\n## Usage\ngh issue list\n\n## License\nMIT\n\n## Contributing\nSee CONTRIBUTING.md\n\n## Demo\nVisit https://cli.github.com for a demo.\n\n" + "Long description about why this tool is amazing. " * 50,
77
+ "language": "Go",
78
+ "license": "MIT",
79
+ "confidence": "high"
80
+ }
81
+
82
+ brief = build_product_brief(high_context)
83
+ self.assertEqual(brief["launch_readiness"]["score"], "high")
84
+
85
+ markdown = render_full_launch_kit_markdown(brief)
86
+
87
+ # Assertions for high readiness output
88
+ self.assertIn("Project Maturity**: HIGH", markdown)
89
+ self.assertIn("## Coordinated Launch Timeline", markdown)
90
+ self.assertIn("- [ ] Day 1: Show HN", markdown)
91
+ self.assertIn("## Channel Strategy & Positioning", markdown)
92
+ self.assertIn("### [Show HN] - Fit: HIGH", markdown)
93
+ # cli/cli is a tool so PH is low, which is fine
94
+ self.assertIn("### [Twitter/X]", markdown)
95
+ self.assertNotIn("> [!CAUTION]", markdown)
96
+ self.assertNotIn("## Soft Launch Strategy", markdown)
97
+
98
+ if __name__ == "__main__":
99
+ unittest.main()