* fix(create-readme): resolve file_path against context.worktree * test(loader): support fs imports and extended TS stripping * test(create-readme): add regression tests for local overwrite * docs(repo-readme): note file_path resolves against worktree * docs(handoff): set PR number --------- Co-authored-by: opencode-agent <agent@opencode.local>
433 lines
17 KiB
TypeScript
433 lines
17 KiB
TypeScript
import { spawnSync } from "child_process"
|
||
import { readFileSync, 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 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 ? `\nДоступ: [${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}
|
||
|
||

|
||
|
||
<!-- 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"
|
||
const absPath = path.resolve(context.worktree, file_path)
|
||
|
||
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(absPath, 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(absPath, "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)}`
|
||
}
|
||
},
|
||
})
|