opencode-config/.opencode/tools/create-readme.ts
Sergey 1bd089936e
fix(readme): replace GB flag with US flag for English section (#126)
* fix(readme): replace GB flag with US flag for English section

* fix(create-readme): validate US English flag header in validateReadme

* docs(handoff): set PR number

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-07-29 18:09:10 +03:00

351 lines
12 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: string
why_en: string
what_en: string
why_ru: string
what_ru: string
quick_start: string
features_en: Feature[]
features_ru: Feature[]
telegram?: string
access_url?: 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})`
: ""
const accessLineEn = args.access_url ? `\nAccess at ${args.access_url}\n` : ""
const accessLineRu = args.access_url ? `\оступ: ${args.access_url}\n` : ""
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}
\`\`\`${accessLineEn}
---
## 🇷🇺 Русский
<!-- 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}
\`\`\`${accessLineRu}
---
## 💬 Support and contacts / Поддержка и контакты
👉 **[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")
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: 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'."),
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."),
},
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,
access_url: args.access_url,
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)}`
}
},
})