opencode-config/.opencode/tools/create-readme.ts
Sergey d2cef94ac9
feat(create-readme): add cover image reference to template and validation (#158)
* feat(create-readme): add cover image reference to template and validation

* feat(repo-readme): extend skill with cover generation + add command

* chore(assets): add opencode brand logo and cover.png

* docs(handoff): scaffold cover-pipeline PR notes

* docs(handoff): set PR number

* docs(project-map): update after cover pipeline structural changes

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-07-30 23:15:12 +03:00

431 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { spawnSync } from "child_process"
import { readFileSync, writeFileSync } from "fs"
import { tool } from "@opencode-ai/plugin"
type Feature = { emoji: string; name: string; description: string }
type CustomSection = { title: string; content: string }
type CreateArgs = {
repo_name: string
tagline_en: string
tagline_ru: string
why_en: string
what_en: string
why_ru: string
what_ru: string
quick_start?: string
features_en: Feature[]
features_ru: Feature[]
access_url?: string
include_clone?: boolean
development_en?: string
development_ru?: string
custom_sections_en?: CustomSection[]
custom_sections_ru?: CustomSection[]
quick_start_steps_en?: string[]
quick_start_steps_ru?: string[]
}
function extractBetween(text: string, start: string, end: string): string | null {
const s = text.indexOf(start)
const e = text.indexOf(end)
if (s === -1 || e === -1 || e <= s) return null
return text.substring(s + start.length, e)
}
function renderFeaturesTable(features: Feature[], isRu: boolean): string {
const header = isRu
? "### Фичи\n\n| Фича | Описание |\n|------|----------|\n"
: "### Features\n\n| Feature | Description |\n|---------|-------------|\n"
const rows = features
.map((f) => `| ${f.emoji} ${f.name} | ${f.description} |`)
.join("\n")
return header + rows
}
function renderSteps(steps?: string[]): string {
if (!steps || steps.length === 0) return ""
const items = steps.map((s, i) => `${i + 1}. ${s}`).join("\n")
return `\n${items}\n`
}
function generateReadme(args: CreateArgs): string {
const customEn = (args.custom_sections_en || [])
.map((s) => `\n\n### ${s.title}\n${s.content}`)
.join("")
const customRu = (args.custom_sections_ru || [])
.map((s) => `\n\n### ${s.title}\n${s.content}`)
.join("")
const accessLineEn = args.access_url ? `\nAccess at [${args.access_url}](${args.access_url})\n` : ""
const accessLineRu = args.access_url ? `\оступ: [${args.access_url}](${args.access_url})\n` : ""
const developmentBlockEn = args.development_en
? `\n\n### 🔧 Development\n${args.development_en}\n`
: ""
const developmentBlockRu = args.development_ru
? `\n\n### 🔧 Разработка\n${args.development_ru}\n`
: ""
const cloneLine = args.include_clone !== false
? `git clone https://github.com/slaid098/${args.repo_name}.git\n`
: ""
const hasBashBlock = args.include_clone !== false || (args.quick_start ?? "") !== ""
const qs = args.quick_start ?? ""
const bashBlockEn = hasBashBlock
? `\`\`\`bash\n${cloneLine}${qs}\n\`\`\``
: ""
const bashBlockRu = hasBashBlock
? `\`\`\`bash\n${cloneLine}${qs}\n\`\`\``
: ""
const stepsEn = renderSteps(args.quick_start_steps_en)
const stepsRu = renderSteps(args.quick_start_steps_ru)
return `# 🚀 ${args.repo_name}
![Cover](assets/cover.png)
<!-- tagline-en:start -->
> ${args.tagline_en}
<!-- tagline-en:end -->
<!-- tagline-ru:start -->
> ${args.tagline_ru}
<!-- tagline-ru:end -->
[English](#-english) | [Русский](#-русский)
---
## 🇺🇸 English
<!-- summary-en:start -->
### ❓ Why
${args.why_en}
### ✅ What
${args.what_en}
<!-- summary-en:end -->
<!-- features-en:start -->
${renderFeaturesTable(args.features_en, false)}
<!-- features-en:end -->${customEn}
### ⚡ Quick Start
${bashBlockEn}${stepsEn}${accessLineEn}${developmentBlockEn}
---
## 🇷🇺 Русский
<!-- summary-ru:start -->
### ❓ Зачем
${args.why_ru}
### ✅ Что
${args.what_ru}
<!-- summary-ru:end -->
<!-- features-ru:start -->
${renderFeaturesTable(args.features_ru, true)}
<!-- features-ru:end -->${customRu}
### ⚡ Быстрый старт
${bashBlockRu}${stepsRu}${accessLineRu}${developmentBlockRu}
---
## 💬 Support and contacts / Поддержка и контакты
👉 **[slaid098.dev/support](https://slaid098.dev/support)**
`
}
export function validateReadme(content: string): { ok: boolean; issues: string[] } {
const issues: string[] = []
const delimiters = [
"tagline-en:start",
"tagline-en:end",
"tagline-ru:start",
"tagline-ru:end",
"summary-en:start",
"summary-en:end",
"features-en:start",
"features-en:end",
"summary-ru:start",
"summary-ru:end",
"features-ru:start",
"features-ru:end",
]
for (const d of delimiters) {
if (!content.includes(`<!-- ${d} -->`))
issues.push(`Missing <!-- ${d} --> delimiter`)
}
const pairs = [
{ start: "<!-- tagline-en:start -->", end: "<!-- tagline-en:end -->", label: "EN tagline" },
{ start: "<!-- tagline-ru:start -->", end: "<!-- tagline-ru:end -->", label: "RU tagline" },
{ start: "<!-- summary-en:start -->", end: "<!-- summary-en:end -->", label: "EN summary" },
{ start: "<!-- features-en:start -->", end: "<!-- features-en:end -->", label: "EN features" },
{ start: "<!-- summary-ru:start -->", end: "<!-- summary-ru:end -->", label: "RU summary" },
{ start: "<!-- features-ru:start -->", end: "<!-- features-ru:end -->", label: "RU features" },
]
for (const p of pairs) {
const between = extractBetween(content, p.start, p.end)
if (between !== null && !between.trim())
issues.push(`${p.label} content between delimiters is empty`)
}
if (!content.includes("assets/cover.png"))
issues.push("Missing cover image reference (assets/cover.png)")
if (!content.includes("# 🚀 "))
issues.push("Missing H1 title prefix '# 🚀 '")
if (/^##\s+(License|LICENSE|Лицензия)\s*$/m.test(content))
issues.push("Manual License section found — remove it (GitHub renders license from LICENSE file)")
if (!content.includes("slaid098.dev/support"))
issues.push("Missing Support link (slaid098.dev/support)")
if (!content.includes("Quick Start"))
issues.push("Missing 'Quick Start' section (English)")
if (!content.includes("Быстрый старт"))
issues.push("Missing 'Быстрый старт' section (Russian)")
if (!content.includes("[English](#-english)"))
issues.push("Missing or wrong [English](#-english) switcher link (should be #-english)")
if (!content.includes("## 🇷🇺 Русский"))
issues.push("Missing '## 🇷🇺 Русский' header (should be 'Русский', not 'Русская версия')")
if (!content.includes("## 🇺🇸 English"))
issues.push("Missing '## 🇺🇸 English' header")
if (!content.includes("[Русский](#-русский)"))
issues.push("Missing or wrong [Русский](#-русский) switcher link (should be #-русский, not #-русская-версия)")
return { ok: issues.length === 0, issues }
}
export default tool({
description:
"Create or validate README.md for slaid098 repositories. 'create' mode generates a standardized bilingual README with delimiter tags (<!-- summary-en:start/end -->, <!-- features-en:start/end -->, <!-- summary-ru:start/end -->, <!-- features-ru:start/end -->) parsed by the slaid098.dev showcase. 'validate' mode checks an existing README against the standard, including the '## 🇷🇺 Русский' header and the [Русский](#-русский) switcher anchor. Supports local file (fs) and remote (gh api repos/{owner}/{repo}/contents/README.md) operation.",
args: {
mode: tool.schema
.enum(["create", "validate"])
.describe("Operation mode: 'create' generates README, 'validate' checks existing README structure"),
repo_name: tool.schema
.string()
.optional()
.describe("Repository name (e.g. 'anti-detect-mcp'). Required for create mode."),
tagline_en: tool.schema
.string()
.optional()
.describe("Short English tagline (1 sentence). Required for create mode. Must not contain Cyrillic."),
tagline_ru: tool.schema
.string()
.optional()
.describe("Short Russian tagline (1 sentence). Required for create mode. Must contain Cyrillic."),
why_en: tool.schema
.string()
.optional()
.describe("Why this exists — in English (1-2 sentences). Required for create mode."),
what_en: tool.schema
.string()
.optional()
.describe("What it does — in English (1-2 sentences). Required for create mode."),
why_ru: tool.schema
.string()
.optional()
.describe("Зачем этот проект — на русском (1-2 предложения). Required for create mode."),
what_ru: tool.schema
.string()
.optional()
.describe("Что делает — на русском (1-2 предложения). Required for create mode."),
quick_start: tool.schema
.string()
.optional()
.describe("Install/setup command (e.g. 'pip install -r requirements.txt'). Optional — omit or pass '' when setup is steps-only (use quick_start_steps_*); bash block omitted when empty AND include_clone is false."),
quick_start_steps_en: tool.schema
.array(tool.schema.string())
.optional()
.describe("Array of raw-markdown strings rendered as a numbered list of clickable steps after the EN Quick Start bash block. Each string = one step, may contain markdown links [text](url). Omit to render only the bash block."),
quick_start_steps_ru: tool.schema
.array(tool.schema.string())
.optional()
.describe("Array of raw-markdown strings rendered as a numbered list of clickable steps after the RU Быстрый старт bash block. Each string = one step, may contain markdown links [text](url). Omit to render only the bash block."),
features_en: tool.schema
.array(
tool.schema.object({
emoji: tool.schema.string(),
name: tool.schema.string(),
description: tool.schema.string(),
}),
)
.optional()
.describe("Array of features for EN table. Each: { emoji, name, description }. Required for create mode."),
features_ru: tool.schema
.array(
tool.schema.object({
emoji: tool.schema.string(),
name: tool.schema.string(),
description: tool.schema.string(),
}),
)
.optional()
.describe("Array of features for RU table. Each: { emoji, name, description }. Required for create mode."),
custom_sections_en: tool.schema
.array(
tool.schema.object({
title: tool.schema.string(),
content: tool.schema.string(),
}),
)
.optional()
.describe("Additional sections rendered after EN features block (outside delimiters)."),
custom_sections_ru: tool.schema
.array(
tool.schema.object({
title: tool.schema.string(),
content: tool.schema.string(),
}),
)
.optional()
.describe("Additional sections rendered after RU features block (outside delimiters)."),
repo: tool.schema
.string()
.optional()
.describe("owner/name for remote operation via GitHub API. If omitted, operates locally on file_path."),
file_path: tool.schema
.string()
.optional()
.describe("Local file path (local mode only). Default: 'README.md'."),
access_url: tool.schema
.string()
.optional()
.describe("Optional URL for Access/Доступ line after Quick Start bash block. EN: 'Access at {url}', RU: 'Доступ: {url}'. Omit if no web access."),
include_clone: tool.schema
.boolean()
.optional()
.describe("If false, omits 'git clone' from Quick Start bash block. Default true (backward compatible). Set false for userscripts, web apps, npm packages."),
development_en: tool.schema
.string()
.optional()
.describe("Raw markdown for ### 🔧 Development section after EN Quick Start (outside delimiters, not shown on slaid098.dev). Omit if no dev section needed."),
development_ru: tool.schema
.string()
.optional()
.describe("Raw markdown for ### 🔧 Разработка section after RU Быстрый старт (outside delimiters, not shown on slaid098.dev). Omit if no dev section needed."),
},
async execute(args, context) {
try {
const file_path = args.file_path ?? "README.md"
if (args.mode === "create") {
const required: Record<string, string | undefined> = {
repo_name: args.repo_name,
tagline_en: args.tagline_en,
tagline_ru: args.tagline_ru,
why_en: args.why_en,
what_en: args.what_en,
why_ru: args.why_ru,
what_ru: args.what_ru,
}
for (const [k, v] of Object.entries(required)) {
if (!v) return `${k} is required for create mode`
}
if (!args.features_en || args.features_en.length === 0)
return `❌ features_en is required for create mode`
if (!args.features_ru || args.features_ru.length === 0)
return `❌ features_ru is required for create mode`
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(args.repo_name!))
return `❌ repo_name must be lowercase kebab-case (a-z0-9 with single dashes), got: "${args.repo_name}"`
if (/[ЁА-яё]/.test(args.tagline_en!))
return `❌ tagline_en must be English (no Cyrillic), got Cyrillic chars`
if (!/[ЁА-яё]/.test(args.tagline_ru!))
return `❌ tagline_ru must contain Cyrillic (Russian text), got: "${args.tagline_ru}"`
const hasBash = args.include_clone !== false || (args.quick_start ?? "") !== ""
const hasStepsEn = !!args.quick_start_steps_en && args.quick_start_steps_en.length > 0
const hasStepsRu = !!args.quick_start_steps_ru && args.quick_start_steps_ru.length > 0
if (!hasBash && !hasStepsEn)
return `❌ quick_start or quick_start_steps_en required (Quick Start EN would be empty)`
if (!hasBash && !hasStepsRu)
return `❌ quick_start or quick_start_steps_ru required (Quick Start RU would be empty)`
const content = generateReadme({
repo_name: args.repo_name!,
tagline_en: args.tagline_en!,
tagline_ru: args.tagline_ru!,
why_en: args.why_en!,
what_en: args.what_en!,
why_ru: args.why_ru!,
what_ru: args.what_ru!,
quick_start: args.quick_start,
features_en: args.features_en!,
features_ru: args.features_ru!,
access_url: args.access_url,
include_clone: args.include_clone,
development_en: args.development_en,
development_ru: args.development_ru,
custom_sections_en: args.custom_sections_en,
custom_sections_ru: args.custom_sections_ru,
quick_start_steps_en: args.quick_start_steps_en,
quick_start_steps_ru: args.quick_start_steps_ru,
})
if (args.repo) {
const getRes = spawnSync(
"gh",
["api", `repos/${args.repo}/contents/README.md`],
{ encoding: "utf-8", cwd: context.worktree },
)
let sha: string | undefined
if (getRes.status === 0) {
try {
sha = JSON.parse(getRes.stdout).sha
} catch {
sha = undefined
}
}
const content64 = Buffer.from(content, "utf-8").toString("base64")
const putArgs = [
"api",
"-X",
"PUT",
`repos/${args.repo}/contents/README.md`,
"-f",
"message=docs: update README",
"-f",
`content=${content64}`,
]
if (sha) putArgs.push("-f", `sha=${sha}`)
const putRes = spawnSync("gh", putArgs, {
encoding: "utf-8",
cwd: context.worktree,
})
if (putRes.status !== 0) {
return `⚠️ create-readme failed: gh api PUT failed (exit ${putRes.status}): ${putRes.stderr || putRes.stdout}`
}
return `README.md updated in ${args.repo} via GitHub API`
}
writeFileSync(file_path, content, "utf-8")
return `README.md created at ${file_path}`
}
let content: string
if (args.repo) {
const getRes = spawnSync(
"gh",
["api", `repos/${args.repo}/contents/README.md`],
{ encoding: "utf-8", cwd: context.worktree },
)
if (getRes.status !== 0) {
return `⚠️ create-readme failed: gh api GET failed (exit ${getRes.status}): ${getRes.stderr || getRes.stdout}`
}
const data = JSON.parse(getRes.stdout)
content = Buffer.from(data.content, "base64").toString("utf-8")
} else {
content = readFileSync(file_path, "utf-8")
}
const result = validateReadme(content)
if (result.ok) return "✅ README structure is valid"
return `❌ Validation issues:\n${result.issues.map((i) => `- ${i}`).join("\n")}`
} catch (e) {
return `⚠️ create-readme failed: ${e instanceof Error ? e.message : String(e)}`
}
},
})