opencode-config/.opencode/tools/create-readme.ts
opencode-agent 0fbf1d97bc
All checks were successful
CI / bootstrap (push) Successful in 11s
CI / lint (push) Successful in 26s
CI / typecheck (push) Successful in 33s
CI / test (3.12) (push) Successful in 1m39s
CI / test (3.13) (push) Successful in 2m21s
CI / test (3.14) (push) Successful in 1m26s
CI / complexity (push) Successful in 20s
feat(create-readme): support Forgejo contents API
2026-08-06 15:11:23 +00:00

379 lines
15 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/contacts](https://slaid098.dev/contacts)**
`
}
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 content64 = Buffer.from(content, "utf-8").toString("base64")
if (process.env.FORGEJO_URL) {
const base = process.env.FORGEJO_URL
const token = process.env.FORGEJO_TOKEN
const headers: Record<string, string> = {
Authorization: `token ${token}`,
Accept: "application/json",
}
let sha: string | undefined
try {
const getRes = await fetch(`${base}/api/v1/repos/${args.repo}/contents/README.md`, { headers })
if (getRes.status === 200) {
sha = (await getRes.json()).sha
} else if (getRes.status !== 404) {
const text = await getRes.text()
return `⚠️ create-readme failed: Forgejo GET README.md → HTTP ${getRes.status}: ${text}`
}
} catch (e) {
return `⚠️ create-readme failed: Forgejo GET failed: ${e instanceof Error ? e.message : String(e)}`
}
const putBody: Record<string, string> = {
content: content64,
message: "docs: update README",
}
if (sha) putBody.sha = sha
try {
const putRes = await fetch(`${base}/api/v1/repos/${args.repo}/contents/README.md`, {
method: "PUT",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify(putBody),
})
if (!putRes.ok) {
const text = await putRes.text()
return `⚠️ create-readme failed: Forgejo PUT README.md → HTTP ${putRes.status}: ${text}`
}
} catch (e) {
return `⚠️ create-readme failed: Forgejo PUT failed: ${e instanceof Error ? e.message : String(e)}`
}
return `README.md updated in ${args.repo} via Forgejo API`
}
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 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)}`
}
},
})