feat(skills): add repo-readme skill and create-readme tool (#112)

* 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>
This commit is contained in:
Sergey 2026-07-29 01:04:51 +03:00 committed by GitHub
parent d8940de4f6
commit 7ea12b982d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 578 additions and 0 deletions

View file

@ -0,0 +1,162 @@
---
name: repo-readme
description: Use when creating or updating README.md for any slaid098 repository. Calls create-readme tool for deterministic structure with delimiter tags for slaid098.dev showcase. Also when user says "оформи README", "обнови описание репо", "readme template", "базовая структура readme".
---
# Repo README
Стандартизация `README.md` во всех репозиториях slaid098 через тулзу
`create-readme`. Тулза гарантирует структуру (разделители, Support block,
Quick Start, language switcher). Скилл даёт контекст: когда вызывать тулзу и
какие параметры передавать.
## 1. Когда использовать тулзу
- **Новый репо**`create-readme` (mode: `create`) — генерирует
стандартизированный README с нуля.
- **Проверка существующего README**`create-readme` (mode: `validate`) —
проверяет, что структура соответствует стандарту витрины.
- **После ручных правок README** → всегда `validate`. Любая правка руками
агента (через Edit/Write) может нарушить разделители — после правок
обязательна валидация.
Не генерируй README вручную через Write — структура критична для парсинга
витриной. Только через тулзу `create-readme`.
## 2. Tier system
Тулза принимает параметр `tier`:
- **`flagship`** — флагманские проекты (Anti-Detect MCP, Mobile Whisper и
т.п.). Полный набор параметров: `demo_gif` (демо-гифка), `custom_sections`,
`telegram`. Используется для заметных проектов на витрине.
- **`utility`** (default) — бытовые утилиты. Минимальный набор: `problem` /
`solution` (EN+RU), `quick_start`. `demo_gif` игнорируется.
Выбор tier влияет только на блок `demo_gif` после EN-разделителей. Остальная
структура одинакова для обоих tier'ов.
## 3. GitHub metadata (отдельный шаг, НЕ в тулзе)
`create-readme` отвечает только за файл `README.md`. Метаданные репозитория
настраиваются отдельно через `gh repo edit` (это bash, не тулза):
- Описание: `gh repo edit --description "короткое описание"`
- Topics для поиска: `gh repo edit --add-topic topic1 --add-topic topic2`
- Social preview image — через настройки GitHub UI (не CLI).
Метаданные не дублируют README — они для карточки репо на GitHub и поиска.
## 4. Workflow
1. `create-readme` (mode: `create`) → генерирует README с гарантированной
структурой.
2. Ручные правки если нужно (агент редактирует файл напрямую через Edit) —
например, расширить `custom_sections`, поправить формулировки.
3. `create-readme` (mode: `validate`) → проверяет, что структура не нарушена.
4. Если `validate` fails → фикс нарушения → re-`validate`. Цикл пока не
пройдёт.
Локальный режим (по умолчанию): тулза пишет в `file_path` (default
`README.md`) через `fs.writeFileSync`. Удалённый режим: передай `repo`
(`owner/name`) — тулза сделает PUT через `gh api
repos/{owner}/{repo}/contents/README.md` с base64-контентом и SHA.
## 5. Зачем разделители (контекст для агента)
Витрина **slaid098.dev** скачивает raw `README.md` из каждого репо и
извлекает фрагмент между разделителями:
- `<!-- summary-en:start -->` ... `<!-- summary-en:end -->` — английский
блок для карточки.
- `<!-- summary-ru:start -->` ... `<!-- summary-ru:end -->` — русский блок.
Поэтому структура README **должна быть гарантирована тулзой**, а не агентом.
Агент может менять текст *внутри* разделителей, но не должен удалять/двигать
сами разделители. `validate` ловит такие нарушения.
## 6. Шаблон README (reference)
Тулза `create-readme` генерирует ровно эту структуру (параметры подставляются):
```markdown
# 🚀 {repo_name}
> {tagline}
[English](#-english) | [Русский](#-русская-версия)
---
## 🇬🇧 English
<!-- summary-en:start -->
### 🔴 Problem
{problem_en}
### 🟢 Solution
{solution_en}
{custom_sections_en — доп. секции, если переданы}
<!-- summary-en:end -->
{demo_gif строка, только если tier=flagship}
### ⚡ Quick Start
\`\`\`bash
git clone https://github.com/slaid098/{repo_name}.git
{quick_start}
\`\`\`
---
## 🇷🇺 Русская версия
<!-- summary-ru:start -->
### 🔴 Проблема
{problem_ru}
### 🟢 Решение
{solution_ru}
{custom_sections_ru — доп. секции, если переданы}
<!-- summary-ru:end -->
### ⚡ Быстрый старт
\`\`\`bash
git clone https://github.com/slaid098/{repo_name}.git
{quick_start}
\`\`\`
---
## 💬 Support & Contact / Поддержка и связь
Have questions, need custom features, or want to support this project?
👉 **[Visit Support & Contact Page](https://slaid098.dev/support)**
{telegram строка, если передан}
```
`validate` проверяет: наличие обоих EN/RU разделителей, непустой контент
между ними, ссылку `slaid098.dev/support`, секции Quick Start (EN) и Быстрый
старт (RU), language switcher `[English]` / `[Русский]`.
## 7. Независимость от repo-init
- Скилл `repo-init` создаёт **пустой** `README.md` как часть инициализации
репо.
- `repo-readme` (через тулзу `create-readme`) **наполняет** его
стандартизированным контентом.
- Может применяться к существующим репо без `repo-init` — тулза перезапишет
`README.md` (локально) или обновит через GitHub API (с SHA).
## 8. Параметры тулзы (кратко)
`create-readme`:
- `mode``"create"` | `"validate"` (обязательный).
- `repo_name`, `tagline`, `problem_en`, `solution_en`, `problem_ru`,
`solution_ru`, `quick_start` — обязательны для `create`.
- `tier``"flagship"` | `"utility"` (default `utility`).
- `telegram` — username без `@` (optional).
- `demo_gif` — путь/URL (только для `flagship`).
- `custom_sections_en` / `custom_sections_ru` — массивы
`{ title, content }` (optional).
- `repo``owner/name` для удалённой операции (optional).
- `file_path` — локальный путь (default `README.md`).

View file

@ -0,0 +1,305 @@
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![Demo](${args.demo_gif})\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)}`
}
},
})

View file

@ -0,0 +1,60 @@
# ADR-049: create-readme tool + repo-readme skill for standardized README
## Статус
Accepted (2026-07-28)
## Контекст
Витрина slaid098.dev парсит `README.md` из каждого репозитория: скачивает raw
файл и извлекает фрагмент между разделителями `<!-- summary-en:start/end -->` и
`<!-- summary-ru:start/end -->`. До сих пор README формировался агентом вручную
через Write/Edit — это недетерминированно: разделители могли отсутствовать,
секции Support/Quick Start/language switcher — расходиться со стандартом, из-за
чего витрина не могла корректно отрендерить карточку. Issue #111 требует тулзу,
гарантирующую структуру, и скилл, дающий агенту контекст.
## Решение
Реализовать TS-плагин `create-readme` (`.opencode/tools/create-readme.ts`) с
двумя режимами:
- `create` — генерирует README по фиксированному шаблону через template
literal, подставляя параметры. Структура (разделители, Support block, Quick
Start, language switcher) зашита в тулзе, а не в агенте.
- `validate` — проверяет существующий README на соответствие стандарту (6
проверок).
Параметр `mode` = `tool.schema.enum(["create","validate"])` (zod через
`tool.schema` = `typeof z`, подтверждено `tool.d.ts`). `tier` (flagship/utility)
и `file_path` реализованы через `.optional()` + fallback в handler (`args.tier
?? "utility"`) вместо zod `.default()` — консистентно с существующими тулзами
(они `.default()` не используют) и надёжно независимо от того, парсит opencode
args через zod или передаёт raw.
Локальный режим: `fs.writeFileSync`/`readFileSync`. Удалённый режим: `gh api`
через `spawnSync` (GET для SHA, PUT с base64+content+message+sha) — как
`create-issue.ts`/`commit.ts` используют `spawnSync` напрямую. `gh api -X PUT`
помечен как destructive в `check-permissions.py`, но это правило проверяет
bash allow-rules конфига, а не `spawnSync` внутри плагина — тулзе allow-rule не
нужен.
Скилл `repo-readme` описывает: когда вызывать create vs validate, tier system,
GitHub metadata как отдельный шаг (`gh repo edit`, не в тулзе), workflow
(create → ручные правки → validate), зачем разделители, шаблон как reference,
независимость от repo-init.
## Альтернативы
- **zod `discriminatedUnion("mode", [createSchema, validateSchema])`**
отвергнуто: `tool()` принимает `args: Args extends z.ZodRawShape` (plain
object of zod schemas), а не `ZodDiscriminatedUnion`. Использована плоская
schema с `mode: enum` + ручная валидация обязательных полей в handler.
- **zod `.default()` для tier/file_path** — отвергнуто ради консистентности с
существующими тулзами и независимости от того, вызывает opencode zod-parse.
Использован `.optional()` + fallback `??`.
- **Импорт zod напрямую** — отвергнут: `tool.schema` уже re-export'ит полный
zod (`var schema: typeof z`), отдельный импорт и зависимость `zod` в
package.json не нужны (его нет в dependencies, и существующие тулзы его не
импортируют).
- **Регистрация тулзы в `opencode.json` (agent.tools)** — не требуется:
плагины авто-дискаверятся из `.opencode/tools/*.ts` (как `tunnel.ts`,
которого нет в конфиге).

View file

@ -0,0 +1,49 @@
---
pr: 112
title: feat(skills): add repo-readme skill and create-readme tool
---
## Что сделано
Создана тулза `create-readme` (`.opencode/tools/create-readme.ts`, TS-плагин) с
двумя режимами:
- `create` — генерирует стандартизированный двуязычный README.md по шаблону с
разделителями `<!-- summary-en:start/end -->` и `<!-- summary-ru:start/end -->`,
Support & Contact блоком, Quick Start, language switcher. Поддерживает
`custom_sections_en/ru`, `tier` (flagship/utility), `demo_gif`, `telegram`.
Локальный режим (`fs.writeFileSync` в `file_path`) и удалённый (`gh api
repos/{owner}/{repo}/contents/README.md` PUT с base64 + SHA).
- `validate` — проверяет существующий README на 6 критериев: наличие обоих
EN/RU разделителей, непустой контент между ними, ссылку slaid098.dev/support,
секции Quick Start (EN) + Быстрый старт (RU), language switcher.
Создан скилл `repo-readme` (`.opencode/skills/repo-readme/SKILL.md`): когда
использовать create vs validate, tier system, GitHub metadata как отдельный
шаг, workflow (create → ручные правки → validate), зачем разделители (парсинг
витриной slaid098.dev), шаблон README как reference, независимость от
repo-init, краткий список параметров тулзы.
Проверки: `tsc --noEmit` чисто; `check-permissions.py` OK (тулза вызывает
`gh api` через `spawnSync` внутри плагина — не bash-команда агента, не требует
allow-rule).
## Почему
Структура README критична для витрины slaid098.dev — она скачивает raw
README и извлекает фрагмент между разделителями. Тулза обеспечивает
детерминированную генерацию структуры, скилл даёт контекст агенту (когда
вызывать, tier, metadata). Ручная генерация README агентом через Write
рискует нарушить разделители.
## Pending
## Watch out
Handoff/ADR файлы названы `pr-0-*` (placeholder) — номер PR подставится после
create-pr и отдельного коммита `docs(handoff): set PR number`. Тулза
регистрации в `opencode.json` не требует (плагины авто-дискаверятся из
`.opencode/tools/*.ts`, как `tunnel.ts`). `tier`/`file_path` реализованы через
`.optional()` + fallback в handler (не `.default()`), консистентно с
существующими тулзами (commit.ts, create-issue.ts не используют zod `.default()`).

View file

@ -35,6 +35,7 @@ opencode-config/
│ │ ├── python-development/SKILL.md # Python dev patterns
│ │ ├── release/SKILL.md # Tag + GitHub Release
│ │ ├── repo-init/SKILL.md # New repository bootstrap
│ │ ├── repo-readme/SKILL.md # Standardized README generation (create-readme tool: create vs validate, tier system, GitHub metadata) — PR#112
│ │ ├── tunnel/SKILL.md # Cloudflare tunnel toggle (tool `tunnel()`: 1-й вызов start, 2-й stop) — PR#63 (восстановлен, удалён в PR#42)
│ │ ├── run-tests/SKILL.md # Test runner guide
│ │ └── spec/SKILL.md # 9-phase spec generation
@ -43,6 +44,7 @@ opencode-config/
│ │ ├── commit.ts # commit tool wrapper (1 arg message, validates format+staged) — PR#38
│ │ ├── create-issue.ts # create-issue tool wrapper (3 args, validates format+labels; optional repo?: string) — PR#38, PR#65
│ │ ├── create-pr.ts # create-pr tool wrapper (3 args, validates format+Closes #N; optional repo?: string) — PR#38, PR#65
│ │ ├── create-readme.ts # create-readme tool (TS plugin, modes: create/validate; standardized bilingual README with slaid098.dev delimiters; local fs + remote gh api) — PR#112
│ │ ├── merge-pr.ts # merge-pr tool wrapper (orchestrator-safe gh pr merge; optional repo?: string) — PR#30, PR#65
│ │ ├── memory-access.ts # memory-access tool (bump frontmatter last_accessed/access_count, regex replace, atomic write tmp+rename) — PR#101
│ │ ├── memory-doctor.ts # memory-doctor tool (read-only diagnostics: rg, Python src.memory importability, env vars, memory dir, RAG index; markdown ✅/❌ report) — PR#101