* feat(tools): add create-readme tool * feat(skills): add repo-readme skill * docs(handoff): add handoff and ADR * docs(handoff): set PR number * docs(project-map): update after structural changes --------- Co-authored-by: opencode-agent <agent@opencode.local>
305 lines
10 KiB
TypeScript
305 lines
10 KiB
TypeScript
import { spawnSync } from "child_process"
|
|
import { readFileSync, writeFileSync } from "fs"
|
|
import { tool } from "@opencode-ai/plugin"
|
|
|
|
type CustomSection = { title: string; content: string }
|
|
|
|
type CreateArgs = {
|
|
repo_name: string
|
|
tagline: string
|
|
problem_en: string
|
|
solution_en: string
|
|
problem_ru: string
|
|
solution_ru: string
|
|
quick_start: string
|
|
tier?: "flagship" | "utility"
|
|
telegram?: string
|
|
demo_gif?: 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 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 demoLine =
|
|
args.tier === "flagship" && args.demo_gif ? `\n\n` : ""
|
|
const telegramLine = args.telegram
|
|
? `💬 **Direct Telegram:** [@${args.telegram}](https://t.me/${args.telegram})`
|
|
: ""
|
|
|
|
return `# 🚀 ${args.repo_name}
|
|
> ${args.tagline}
|
|
|
|
[English](#-english) | [Русский](#-русская-версия)
|
|
|
|
---
|
|
|
|
## 🇬🇧 English
|
|
|
|
<!-- summary-en:start -->
|
|
### 🔴 Problem
|
|
${args.problem_en}
|
|
|
|
### 🟢 Solution
|
|
${args.solution_en}${customEn}
|
|
<!-- summary-en:end -->
|
|
${demoLine}
|
|
### ⚡ Quick Start
|
|
\`\`\`bash
|
|
git clone https://github.com/slaid098/${args.repo_name}.git
|
|
${args.quick_start}
|
|
\`\`\`
|
|
|
|
---
|
|
|
|
## 🇷🇺 Русская версия
|
|
|
|
<!-- summary-ru:start -->
|
|
### 🔴 Проблема
|
|
${args.problem_ru}
|
|
|
|
### 🟢 Решение
|
|
${args.solution_ru}${customRu}
|
|
<!-- summary-ru:end -->
|
|
|
|
### ⚡ Быстрый старт
|
|
\`\`\`bash
|
|
git clone https://github.com/slaid098/${args.repo_name}.git
|
|
${args.quick_start}
|
|
\`\`\`
|
|
|
|
---
|
|
|
|
## 💬 Support & Contact / Поддержка и связь
|
|
|
|
Have questions, need custom features, or want to support this project?
|
|
👉 **[Visit Support & Contact Page](https://slaid098.dev/support)**
|
|
${telegramLine}
|
|
`
|
|
}
|
|
|
|
function validateReadme(content: string): { ok: boolean; issues: string[] } {
|
|
const issues: string[] = []
|
|
|
|
if (!content.includes("<!-- summary-en:start -->"))
|
|
issues.push("Missing <!-- summary-en:start --> delimiter")
|
|
if (!content.includes("<!-- summary-en:end -->"))
|
|
issues.push("Missing <!-- summary-en:end --> delimiter")
|
|
if (!content.includes("<!-- summary-ru:start -->"))
|
|
issues.push("Missing <!-- summary-ru:start --> delimiter")
|
|
if (!content.includes("<!-- summary-ru:end -->"))
|
|
issues.push("Missing <!-- summary-ru:end --> delimiter")
|
|
|
|
const enContent = extractBetween(
|
|
content,
|
|
"<!-- summary-en:start -->",
|
|
"<!-- summary-en:end -->",
|
|
)
|
|
if (enContent !== null && !enContent.trim())
|
|
issues.push("EN summary content between delimiters is empty")
|
|
const ruContent = extractBetween(
|
|
content,
|
|
"<!-- summary-ru:start -->",
|
|
"<!-- summary-ru:end -->",
|
|
)
|
|
if (ruContent !== null && !ruContent.trim())
|
|
issues.push("RU summary content between delimiters is empty")
|
|
|
|
if (!content.includes("slaid098.dev/support"))
|
|
issues.push("Missing Support & Contact 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 -->, <!-- summary-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."),
|
|
problem_en: tool.schema
|
|
.string()
|
|
.optional()
|
|
.describe("Problem statement in English (1-2 sentences). Required for create mode."),
|
|
solution_en: tool.schema
|
|
.string()
|
|
.optional()
|
|
.describe("Solution in English (1-2 sentences). Required for create mode."),
|
|
problem_ru: tool.schema
|
|
.string()
|
|
.optional()
|
|
.describe("Problem statement in Russian (1-2 sentences). Required for create mode."),
|
|
solution_ru: tool.schema
|
|
.string()
|
|
.optional()
|
|
.describe("Solution in Russian (1-2 sentences). 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."),
|
|
tier: tool.schema
|
|
.enum(["flagship", "utility"])
|
|
.optional()
|
|
.describe("Repository tier: 'flagship' (enables demo_gif) or 'utility' (minimal). Default: 'utility'."),
|
|
telegram: tool.schema
|
|
.string()
|
|
.optional()
|
|
.describe("Telegram username without @. Optional; adds Direct Telegram line to Support section."),
|
|
demo_gif: tool.schema
|
|
.string()
|
|
.optional()
|
|
.describe("Path or URL to demo GIF. Only emitted when tier='flagship'."),
|
|
custom_sections_en: tool.schema
|
|
.array(
|
|
tool.schema.object({
|
|
title: tool.schema.string(),
|
|
content: tool.schema.string(),
|
|
}),
|
|
)
|
|
.optional()
|
|
.describe("Additional sections rendered inside EN delimiters (after Solution)."),
|
|
custom_sections_ru: tool.schema
|
|
.array(
|
|
tool.schema.object({
|
|
title: tool.schema.string(),
|
|
content: tool.schema.string(),
|
|
}),
|
|
)
|
|
.optional()
|
|
.describe("Additional sections rendered inside RU delimiters (after Решение)."),
|
|
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"
|
|
const tier = args.tier ?? "utility"
|
|
|
|
if (args.mode === "create") {
|
|
const required: Record<string, string | undefined> = {
|
|
repo_name: args.repo_name,
|
|
tagline: args.tagline,
|
|
problem_en: args.problem_en,
|
|
solution_en: args.solution_en,
|
|
problem_ru: args.problem_ru,
|
|
solution_ru: args.solution_ru,
|
|
quick_start: args.quick_start,
|
|
}
|
|
for (const [k, v] of Object.entries(required)) {
|
|
if (!v) return `❌ ${k} is required for create mode`
|
|
}
|
|
|
|
const content = generateReadme({
|
|
repo_name: args.repo_name!,
|
|
tagline: args.tagline!,
|
|
problem_en: args.problem_en!,
|
|
solution_en: args.solution_en!,
|
|
problem_ru: args.problem_ru!,
|
|
solution_ru: args.solution_ru!,
|
|
quick_start: args.quick_start!,
|
|
tier,
|
|
telegram: args.telegram,
|
|
demo_gif: args.demo_gif,
|
|
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)}`
|
|
}
|
|
},
|
|
})
|