opencode-config/.opencode/tools/create-readme.ts
Sergey baf6b30547
fix(infra): AST route imports + check_tests structure + check_readme full port (#248)
* feat(infra): AST route imports check in project-status

* feat(infra): check_tests structure + AST stub-detector

* feat(infra): check_readme full port from validateReadme

* refactor(infra): drop validate mode from create-readme tool

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-03 20:50:04 +03:00

340 lines
13 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 { writeFileSync } from "fs"
import path from "path"
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 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 default tool({
description:
"Create README.md for slaid098 repositories. 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. Includes the '## 🇷🇺 Русский' header and the [Русский](#-русский) switcher anchor. Supports local file (fs) and remote (gh api repos/{owner}/{repo}/contents/README.md) operation. README validation is handled by .opencode/scripts/project-status.py:check_readme (read-only architecture oracle).",
args: {
repo_name: tool.schema
.string()
.optional()
.describe("Repository name (e.g. 'anti-detect-mcp'). Required."),
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"
const absPath = path.resolve(context.worktree, file_path)
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(absPath, content, "utf-8")
return `README.md created at ${file_path}`
} catch (e) {
return `⚠️ create-readme failed: ${e instanceof Error ? e.message : String(e)}`
}
},
})