opencode-config/.opencode/tools/create-readme.ts
Sergey 464fa6ec83
feat(readme): new bilingual standard v2 with features table (#116)
* feat(readme): new bilingual standard v2 with features table

* docs(handoff): rename handoff and ADR to PR number convention

* docs(review): fix ADR-051 section name and add PR#116 refs

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-07-29 03:22:28 +03:00

340 lines
11 KiB
TypeScript

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: string
why_en: string
what_en: string
why_ru: string
what_ru: string
quick_start: string
features_en: Feature[]
features_ru: Feature[]
telegram?: string
custom_sections_en?: CustomSection[]
custom_sections_ru?: CustomSection[]
}
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 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 telegramLine = args.telegram
? `\n💬 **Direct Telegram:** [@${args.telegram}](https://t.me/${args.telegram})`
: ""
return `# 🚀 ${args.repo_name}
> ${args.tagline}
[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
\`\`\`bash
git clone https://github.com/slaid098/${args.repo_name}.git
${args.quick_start}
\`\`\`
---
## 🇷🇺 Русская версия
<!-- summary-ru:start -->
### ❓ Зачем
${args.why_ru}
### ✅ Что
${args.what_ru}
<!-- summary-ru:end -->
<!-- features-ru:start -->
${renderFeaturesTable(args.features_ru, true)}
<!-- features-ru:end -->${customRu}
### ⚡ Быстрый старт
\`\`\`bash
git clone https://github.com/slaid098/${args.repo_name}.git
${args.quick_start}
\`\`\`
---
## 💬 Support and contacts / Поддержка и контакты
Have questions or want to support?
👉 **[slaid098.dev/support](https://slaid098.dev/support)**${telegramLine}
`
}
function validateReadme(content: string): { ok: boolean; issues: string[] } {
const issues: string[] = []
const delimiters = [
"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: "<!-- 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("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]"))
issues.push("Missing [English] language switcher link")
if (!content.includes("[Русский]"))
issues.push("Missing [Русский] language switcher link")
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. 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: tool.schema
.string()
.optional()
.describe("Short English tagline (1 sentence). Required for create mode."),
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'). Required for create mode."),
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."),
telegram: tool.schema
.string()
.optional()
.describe("Telegram username without @. Optional; adds Direct Telegram line to Support section."),
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'."),
},
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: args.tagline,
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,
}
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`
const content = generateReadme({
repo_name: args.repo_name!,
tagline: args.tagline!,
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!,
telegram: args.telegram,
custom_sections_en: args.custom_sections_en,
custom_sections_ru: args.custom_sections_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)}`
}
},
})