diff --git a/README.en.md b/README.en.md index 2f75ab6..f6d1e3c 100644 --- a/README.en.md +++ b/README.en.md @@ -30,7 +30,7 @@ Each skill still lives in its own `github.com/lovstudio/{name}-skill` repo. This ## Skills -> **11 skills** — 11 Free + 0 Paid. +> **12 skills** — 12 Free + 0 Paid. @@ -43,6 +43,7 @@ Each skill still lives in its own `github.com/lovstudio/{name}-skill` repo. This | ![Free](https://img.shields.io/badge/Free-green) | [`auto-context`](https://github.com/lovstudio/auto-context-skill) | Watch your Claude Code context for pollution and suggest when to fork or reset. | | ![Free](https://img.shields.io/badge/Free-green) | [`cc-migrate-session`](https://github.com/lovstudio/cc-migrate-session) | Keep your Claude Code session history working after you move a project folder. | | ![Free](https://img.shields.io/badge/Free-green) | [`deploy-to-vercel`](https://github.com/lovstudio/deploy-to-vercel-skill) | Ship a frontend to Vercel with custom domain and Cloudflare DNS wired up automatically. | +| ![Free](https://img.shields.io/badge/Free-green) | [`dev-blog`](https://github.com/lovstudio/dev-blog-skill) | Turn a development session into a practical blog post and publish it to LovStudio's blog feed. | | ![Free](https://img.shields.io/badge/Free-green) | [`finder-action`](https://github.com/lovstudio/finder-action-skill) | Add a custom right-click action to macOS Finder in minutes. | | ![Free](https://img.shields.io/badge/Free-green) | [`gh-access`](https://github.com/lovstudio/gh-access-skill) | Grant, revoke, or audit collaborator access on private GitHub repos in one command. | | ![Free](https://img.shields.io/badge/Free-green) | [`gh-contribute`](https://github.com/lovstudio/gh-contribute-skill) | Ship a clean PR to any upstream GitHub repo — fork, branch, push, and open PR for you. | diff --git a/README.md b/README.md index 7c78aa0..8dfd2eb 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ ## 技能列表 -> **11 个技能** — 11 个免费 + 0 个付费。 +> **12 个技能** — 12 个免费 + 0 个付费。 @@ -43,6 +43,7 @@ | ![Free](https://img.shields.io/badge/Free-green) | [上下文体检 · `auto-context`](https://github.com/lovstudio/auto-context-skill) | 监测 Claude Code 上下文是否被污染,适时提示你 /fork 或 /btw。 | | ![Free](https://img.shields.io/badge/Free-green) | [会话迁移 · `cc-migrate-session`](https://github.com/lovstudio/cc-migrate-session) | 项目目录搬家后,让 Claude Code 的历史会话还能正常 `--resume`。 | | ![Free](https://img.shields.io/badge/Free-green) | [部署到 Vercel · `deploy-to-vercel`](https://github.com/lovstudio/deploy-to-vercel-skill) | 一键把前端部署到 Vercel,自动配好 Cloudflare DNS 和自定义域名。 | +| ![Free](https://img.shields.io/badge/Free-green) | [开发博客 · `dev-blog`](https://github.com/lovstudio/dev-blog-skill) | 把一次开发会话沉淀成中文技术博客,并发布到 LovStudio 网站博客列表。 | | ![Free](https://img.shields.io/badge/Free-green) | [访达右键动作 · `finder-action`](https://github.com/lovstudio/finder-action-skill) | 几分钟给 macOS 访达右键菜单加一个你自己的动作。 | | ![Free](https://img.shields.io/badge/Free-green) | [GitHub 协作者管理 · `gh-access`](https://github.com/lovstudio/gh-access-skill) | 一条命令给私有 GitHub 仓库加减协作者权限,或盘点现有访问清单。 | | ![Free](https://img.shields.io/badge/Free-green) | [GitHub 投稿 PR · `gh-contribute`](https://github.com/lovstudio/gh-contribute-skill) | 给任意上游 GitHub 仓库提一份干净的 PR——fork、分支、推送、开 PR 一站搞定。 | diff --git a/skills.yaml b/skills.yaml index fb2ddda..bea4ab5 100644 --- a/skills.yaml +++ b/skills.yaml @@ -27,6 +27,17 @@ skills: tagline_en: Watch your Claude Code context for pollution and suggest when to fork or reset. tagline_zh: 监测 Claude Code 上下文是否被污染,适时提示你 /fork 或 /btw。 +- name: dev-blog + repo: lovstudio/dev-blog-skill + name_zh: 开发博客 + paid: false + category: Dev Tools + version: 0.1.0 + description: Summarize a development session into a practical Chinese blog post + and publish it to LovStudio's Supabase blog feed. + tagline_en: Turn a development session into a practical blog post and publish it + to LovStudio's blog feed. + tagline_zh: 把一次开发会话沉淀成中文技术博客,并发布到 LovStudio 网站博客列表。 - name: cc-migrate-session repo: lovstudio/cc-migrate-session name_zh: 会话迁移 diff --git a/skills/dev-blog/.gitignore b/skills/dev-blog/.gitignore new file mode 100644 index 0000000..69ce30e --- /dev/null +++ b/skills/dev-blog/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.pyc +*.pyo +.DS_Store +.venv/ +venv/ +node_modules/ +.env +.env.local diff --git a/skills/dev-blog/README.md b/skills/dev-blog/README.md new file mode 100644 index 0000000..20e009c --- /dev/null +++ b/skills/dev-blog/README.md @@ -0,0 +1,73 @@ +# lovstudio:dev-blog + +![Version](https://img.shields.io/badge/version-0.1.0-CC785C) + +Summarize a development session into a practical Chinese blog post and publish +it to LovStudio's Supabase-backed website blog feed. + +Part of [lovstudio skills](https://github.com/lovstudio/skills) — by [lovstudio.ai](https://lovstudio.ai) + +## Install + +```bash +git clone https://github.com/lovstudio/dev-blog-skill ~/.claude/skills/lovstudio-dev-blog +``` + +Requires Python 3.8+. No third-party Python packages are needed. + +## Usage + +Ask Claude Code: + +```text +/lovstudio:dev-blog 总结这次开发过程,生成一篇博客并同步到网站 +``` + +The skill will gather context, draft a Chinese article, save a local Markdown +draft, run a dry-run payload check, then publish to Supabase `blog_posts`. + +You can also run the publisher directly: + +```bash +python3 ~/.claude/skills/lovstudio-dev-blog/scripts/publish_blog_post.py \ + --input .output/dev-blog/example.md \ + --title "一次开发上下文如何变成可复用博客" \ + --slug "dev-context-to-blog" \ + --excerpt "把开发过程沉淀成网站博客,关键在于先结构化上下文,再用 Supabase 作为发布源。" \ + --tags "dev,lovstudio,blog" \ + --env-file /Users/mark/lovstudio/coding/web/.env.local +``` + +## Options + +| Option | Default | Description | +|--------|---------|-------------| +| `--input` | (required) | Markdown/MDX post body. | +| `--title` | (required) | Blog post title. | +| `--slug` | generated from title | URL slug. | +| `--excerpt` | first paragraph | Blog card summary. | +| `--tags` | `dev,lovstudio` | Comma-separated tags. | +| `--author` | `Mark` | Author name. | +| `--cover` | empty | Optional cover image URL. | +| `--published-at` | now | ISO timestamp. | +| `--source-kind` | `dev-skill` | Stored in `blog_posts.source_kind`. | +| `--source-path` | `dev-blog:` | Stable source key. | +| `--draft` | false | Publish as hidden draft. | +| `--hide-from-index` | false | Keep visible detail page but omit from `/blog`. | +| `--env-file` | empty | Optional env file containing Supabase credentials. | +| `--dry-run` | false | Print payload without writing. | + +## Supabase Target + +The script upserts into `blog_posts` by `slug` and sets: + +- `is_visible=true` +- `show_in_index=true` +- `source_kind=dev-skill` + +It requires `NEXT_PUBLIC_SUPABASE_URL` and `SUPABASE_SERVICE_ROLE_KEY` in the +environment or in the file passed through `--env-file`. + +## License + +MIT diff --git a/skills/dev-blog/SKILL.md b/skills/dev-blog/SKILL.md new file mode 100644 index 0000000..802ba2f --- /dev/null +++ b/skills/dev-blog/SKILL.md @@ -0,0 +1,161 @@ +--- +name: lovstudio:dev-blog +category: Dev Tools +tagline: "Summarize a development session into a practical blog post and publish it to LovStudio's Supabase blog feed." +description: > + Summarize the current development context, code changes, decisions, and + lessons into a practical Chinese blog post for yourself and developers facing + similar issues, then publish it to LovStudio's Supabase `blog_posts` table so + it appears on the website blog list. Trigger when the user says "生成博客", + "同步到网站博客", "总结上下文写博文", "开发日志", "generate blog post", + "sync to website blog", or "summarize context as blog". +license: MIT +compatibility: > + Requires Python 3.8+. Publishing requires Supabase service-role credentials + available in environment variables or a local .env file. +metadata: + author: lovstudio + version: "0.1.0" + tags: dev blog supabase writing +--- + +# Dev Blog + +Turn the current development session into a useful Chinese technical blog post +and publish it to LovStudio's website blog feed. + +## When to Use + +- The user asks to summarize current context and write a blog post. +- The user wants a development log, incident write-up, or lessons learned article. +- The user asks to sync a generated post to the LovStudio website blog list. +- Trigger phrases: "生成博客", "同步到网站博客", "总结上下文写博文", "开发日志", "generate blog post", "sync to website blog". + +## Workflow (MANDATORY) + +**You MUST follow these steps in order:** + +### Step 1: Gather Context + +Collect the source material before writing: + +- Recent user intent and constraints from the conversation. +- Relevant files, diffs, commands, errors, and verification output. +- The final decision or implementation, including tradeoffs. +- What a future reader should learn from this case. + +If the topic, audience, or publish target is unclear, ask one concise question. +Do not ask for fields that can be inferred from the current context. + +### Step 2: Draft the Article + +Write in Chinese for two audiences: + +- Primary: Mark, as a durable record of the work. +- Secondary: developers or AI builders who may hit a similar issue. + +Use this structure unless the context clearly calls for a different one: + +1. `# ` +2. Opening: what problem triggered the work and why it mattered. +3. Context: project/background, only enough for the reader to orient. +4. Process: the key investigation path, failed assumptions, and turning points. +5. Solution: what changed, why this shape fits the system. +6. Takeaways: reusable engineering lessons. + +Style rules: + +- Prefer concrete nouns, file/table names, commands, and exact constraints. +- Avoid generic AI productivity claims. +- Do not include secrets, tokens, private customer details, or raw `.env` values. +- Keep code excerpts short and only when they explain the decision. +- The post body must be valid Markdown/MDX. + +### Step 3: Prepare Metadata + +Derive these fields: + +| Field | Rule | +|-------|------| +| `title` | Specific, readable Chinese title. | +| `slug` | ASCII lowercase kebab-case; if Chinese title has no ASCII, use a short English slug. | +| `excerpt` | 1-2 sentence summary under 180 chars. | +| `tags` | 2-5 tags, include `dev` and a concrete domain tag. | +| `author` | Default `Mark`. | +| `source_kind` | Default `dev-skill`. | + +### Step 4: Save Draft Locally + +Create a temporary Markdown file in the current project, normally: + +```bash +mkdir -p .output/dev-blog +``` + +Use a filename based on the slug, for example: + +```text +.output/dev-blog/<slug>.md +``` + +Report the absolute path of the draft if publishing is skipped or fails. + +### Step 5: Publish to Supabase + +Run a dry run first and inspect the payload: + +```bash +python3 ~/.claude/skills/lovstudio-dev-blog/scripts/publish_blog_post.py \ + --input .output/dev-blog/<slug>.md \ + --title "<title>" \ + --slug "<slug>" \ + --excerpt "<excerpt>" \ + --tags "dev,lovstudio" \ + --env-file /Users/mark/lovstudio/coding/web/.env.local \ + --dry-run +``` + +Then publish: + +```bash +python3 ~/.claude/skills/lovstudio-dev-blog/scripts/publish_blog_post.py \ + --input .output/dev-blog/<slug>.md \ + --title "<title>" \ + --slug "<slug>" \ + --excerpt "<excerpt>" \ + --tags "dev,lovstudio" \ + --env-file /Users/mark/lovstudio/coding/web/.env.local +``` + +The script upserts by `slug`, sets `is_visible=true`, `show_in_index=true`, and +uses `source_kind=dev-skill`. A successful publish returns `/blog/<slug>`. + +## CLI Reference + +| Argument | Default | Description | +|----------|---------|-------------| +| `--input` | (required) | Markdown/MDX post body. | +| `--title` | (required) | Blog post title. | +| `--slug` | generated from title | URL slug. Use ASCII kebab-case. | +| `--excerpt` | first paragraph | Blog card summary. | +| `--tags` | `dev,lovstudio` | Comma-separated tags. | +| `--author` | `Mark` | Author name. | +| `--cover` | empty | Optional cover image URL. | +| `--published-at` | now | ISO timestamp. | +| `--source-kind` | `dev-skill` | Stored in `blog_posts.source_kind`. | +| `--source-path` | `dev-blog:<slug>` | Stable source key for traceability. | +| `--draft` | false | Set `is_visible=false`. | +| `--hide-from-index` | false | Set `show_in_index=false`. | +| `--env-file` | empty | Local `.env` file to load credentials from. | +| `--dry-run` | false | Print payload without writing to Supabase. | + +## Dependencies + +No third-party Python dependencies. + +Publishing requires: + +- `NEXT_PUBLIC_SUPABASE_URL` +- `SUPABASE_SERVICE_ROLE_KEY` + +Never print or copy these values into the article or final response. diff --git a/skills/dev-blog/scripts/publish_blog_post.py b/skills/dev-blog/scripts/publish_blog_post.py new file mode 100755 index 0000000..bbcee42 --- /dev/null +++ b/skills/dev-blog/scripts/publish_blog_post.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 +"""Publish a generated development blog post to Supabase `blog_posts`. + +The script is intentionally dependency-free. It uses PostgREST directly so the +skill can run anywhere Python 3.8+ is available. +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import json +import os +import re +import sys +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any, Dict, Optional + + +DEFAULT_SOURCE_KIND = "dev-skill" +DEFAULT_AUTHOR = "Mark" + + +def load_env_file(path: Optional[Path]) -> None: + if not path: + return + if not path.exists(): + raise SystemExit(f"Env file not found: {path}") + + for raw in path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = value.strip().strip("'\"") + if key and key not in os.environ: + os.environ[key] = value + + +def env_first(*names: str) -> str: + for name in names: + value = os.environ.get(name, "").strip() + if value: + return value + return "" + + +def split_tags(value: str) -> list[str]: + return [t.strip() for t in re.split(r"[,,]", value) if t.strip()] + + +def slugify(value: str) -> str: + value = value.lower().strip() + value = re.sub(r"['’]", "", value) + value = re.sub(r"[^a-z0-9]+", "-", value) + value = re.sub(r"-{2,}", "-", value).strip("-") + if value: + return value[:96].strip("-") + stamp = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%d-%H%M%S") + return f"post-{stamp}" + + +def first_paragraph(markdown: str, limit: int = 180) -> str: + lines: list[str] = [] + in_code = False + + for raw in markdown.splitlines(): + line = raw.strip() + if line.startswith("```") or line.startswith("~~~"): + in_code = not in_code + continue + if in_code or not line: + if lines: + break + continue + if line.startswith("#") or line.startswith(">") or line.startswith("!["): + continue + if re.match(r"^[-*]\s+", line): + continue + lines.append(line) + + text = re.sub(r"\s+", " ", " ".join(lines)).strip() + if len(text) <= limit: + return text + return text[: limit - 1].rstrip() + "..." + + +def read_content(path: Path) -> str: + if not path.exists(): + raise SystemExit(f"Input file not found: {path}") + content = path.read_text(encoding="utf-8").strip() + if not content: + raise SystemExit(f"Input file is empty: {path}") + return content + + +def build_payload(args: argparse.Namespace, content: str) -> Dict[str, Any]: + title = args.title.strip() + if not title: + raise SystemExit("--title is required") + + slug = args.slug.strip() if args.slug else slugify(title) + excerpt = args.excerpt.strip() if args.excerpt else first_paragraph(content) + tags = split_tags(args.tags) if args.tags else ["dev", "lovstudio"] + published_at = args.published_at or dt.datetime.now(dt.timezone.utc).isoformat() + source_path = args.source_path or f"dev-blog:{slug}" + + return { + "slug": slug, + "title": title, + "excerpt": excerpt, + "content_mdx": content, + "cover": args.cover or None, + "tags": tags, + "author": args.author, + "published_at": published_at, + "is_visible": not args.draft, + "show_in_index": not args.hide_from_index, + "source_kind": args.source_kind, + "source_path": source_path, + "research_artifacts": { + "generated_by": "lovstudio:dev-blog", + "schema": 1, + }, + } + + +def postgrest_upsert(supabase_url: str, service_key: str, payload: Dict[str, Any]) -> Dict[str, Any]: + base = supabase_url.rstrip("/") + url = f"{base}/rest/v1/blog_posts?on_conflict=slug" + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + req = urllib.request.Request( + url, + data=body, + method="POST", + headers={ + "apikey": service_key, + "Authorization": f"Bearer {service_key}", + "Content-Type": "application/json", + "Prefer": "resolution=merge-duplicates,return=representation", + }, + ) + + try: + with urllib.request.urlopen(req, timeout=30) as res: + raw = res.read().decode("utf-8") + data = json.loads(raw) if raw else [] + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace") + raise SystemExit(f"Supabase upsert failed: HTTP {exc.code}\n{detail}") from exc + except urllib.error.URLError as exc: + raise SystemExit(f"Supabase upsert failed: {exc}") from exc + + if isinstance(data, list) and data: + return data[0] + if isinstance(data, dict): + return data + return payload + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Publish a Markdown/MDX blog post to Supabase blog_posts.") + parser.add_argument("--input", required=True, help="Markdown/MDX file containing the final post body.") + parser.add_argument("--title", required=True, help="Blog post title.") + parser.add_argument("--slug", default="", help="URL slug. Defaults to a slug generated from --title.") + parser.add_argument("--excerpt", default="", help="Short summary. Defaults to the first paragraph.") + parser.add_argument("--tags", default="dev,lovstudio", help="Comma-separated tags.") + parser.add_argument("--author", default=DEFAULT_AUTHOR, help="Author name.") + parser.add_argument("--cover", default="", help="Optional cover image URL.") + parser.add_argument("--published-at", default="", help="ISO timestamp. Defaults to now.") + parser.add_argument("--source-kind", default=DEFAULT_SOURCE_KIND, help="Source kind stored in blog_posts.source_kind.") + parser.add_argument("--source-path", default="", help="Stable source key. Defaults to dev-blog:<slug>.") + parser.add_argument("--draft", action="store_true", help="Set is_visible=false.") + parser.add_argument("--hide-from-index", action="store_true", help="Set show_in_index=false.") + parser.add_argument("--env-file", default="", help="Optional .env file to load before publishing.") + parser.add_argument("--dry-run", action="store_true", help="Print the payload and skip network writes.") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + input_path = Path(args.input).expanduser().resolve() + env_path = Path(args.env_file).expanduser().resolve() if args.env_file else None + load_env_file(env_path) + + content = read_content(input_path) + payload = build_payload(args, content) + + if args.dry_run: + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return 0 + + supabase_url = env_first("NEXT_PUBLIC_SUPABASE_URL", "SUPABASE_URL", "VITE_SUPABASE_URL") + service_key = env_first("SUPABASE_SERVICE_ROLE_KEY", "SUPABASE_SERVICE_KEY") + if not supabase_url or not service_key: + raise SystemExit( + "Missing Supabase credentials. Set NEXT_PUBLIC_SUPABASE_URL and " + "SUPABASE_SERVICE_ROLE_KEY, or pass --env-file." + ) + + row = postgrest_upsert(supabase_url, service_key, payload) + slug = row.get("slug", payload["slug"]) + print(json.dumps({"ok": True, "slug": slug, "href": f"/blog/{slug}"}, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + sys.exit(main())