@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.
- package/package.json +1 -1
- package/registry.json +16 -0
- package/skills/noise-to-linkedin-carousel/README.md +81 -0
- package/skills/noise-to-linkedin-carousel/SKILL.md +53 -0
- package/skills/noise-to-linkedin-carousel/cover.png +0 -0
- package/skills/noise-to-linkedin-carousel/references/hook-patterns.md +45 -0
- package/skills/noise-to-linkedin-carousel/references/output-format.md +59 -0
- package/skills/noise-to-linkedin-carousel/references/quality-checklist.md +12 -0
- package/skills/noise-to-linkedin-carousel/references/slide-types.md +57 -0
- package/skills/oss-launch-kit/.env.example +2 -0
- package/skills/oss-launch-kit/PRD.md +122 -0
- package/skills/oss-launch-kit/README.md +27 -0
- package/skills/oss-launch-kit/SKILL.md +33 -0
- package/skills/oss-launch-kit/TECHNICAL_DESIGN.md +187 -0
- package/skills/oss-launch-kit/evals/cli-cli.full.md +49 -0
- package/skills/oss-launch-kit/evals/evals.json +44 -0
- package/skills/oss-launch-kit/evals/octocat-hello-world.full.md +53 -0
- package/skills/oss-launch-kit/evals/pydantic-pydantic.full.md +152 -0
- package/skills/oss-launch-kit/evals/vercel-next-js.full.md +152 -0
- package/skills/oss-launch-kit/hero.png +0 -0
- package/skills/oss-launch-kit/references/channel_rules.md +9 -0
- package/skills/oss-launch-kit/references/launch_framework.md +10 -0
- package/skills/oss-launch-kit/references/output_template.md +3 -0
- package/skills/oss-launch-kit/scripts/__pycache__/build_product_brief.cpython-313.pyc +0 -0
- package/skills/oss-launch-kit/scripts/__pycache__/generate_assets.cpython-313.pyc +0 -0
- package/skills/oss-launch-kit/scripts/build_product_brief.py +335 -0
- package/skills/oss-launch-kit/scripts/fetch_repo_context.py +169 -0
- package/skills/oss-launch-kit/scripts/generate_assets.py +526 -0
- package/skills/oss-launch-kit/scripts/run.py +41 -0
- 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()
|